C++ Primer 第十三章 类设计者的工具

一个类通过五种特殊的成员函数来控制这些操作,包括:

  • 拷贝构造函数
  • 拷贝赋值运算符
  • 移动构造函数
  • 移动赋值运算符
  • 析构函数

拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。
拷贝和移动赋值函数运算符定义了将一个对象赋予同类型的另一个对象时做什么。
析构函数定义了当此类型对象销毁时做什么

我们称这些操作为拷贝控制操作

拷贝、赋值与销毁

拷贝构造函数

如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数

class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数
// ...
};

拷贝构造函数的第一个参数必须是一个引用类型

合成拷贝构造函数

如果我们没有为一个类定义拷贝构造函数,编译器会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。

作为一个例子,我们的 Sales_data 类的合成拷贝构造函数等价于:

#pragma once
#ifndef SALES_DATA_H
#define SALES_DATA_H
#include <iostream>
#include <string>
class Sales_data;
std::istream &read(std::istream&, Sales_data&);
class Sales_data {
public:
合成拷贝构造函数等价于
Sales_data(const Sales_data&);
private:
// 数据成员
std::string bookNo; // ISBN 编号
unsigned units_sold = 0; // 销量
double revenue = 0.0; // 总销售收入
};
与 Sales_data 的合成的拷贝构造函数等价
Sales_data::Sales_data(const Sales_data &orig) : bookNo(orig.bookNo), units_sold(orig.units_sold), revenue(orig.revenue) { }
#endif

拷贝初始化

string dots(10, '.'); // 直接初始化
string s(dots); // 直接初始化
string s2 = dots; // 拷贝初始化
string null_book = "9-999-99999-9"; // 拷贝初始化
string nines = string(100, '9'); // 拷贝初始化

当使用直接初始化时,我们实际上是要求编译器使用普通的函数匹配来选择与我们提供的参数最匹配的构造函数。当我们使用拷贝初始化时,我们要求编译器将右侧运算对象拷贝到正在创建的对象中,如果需要的话还要进行类型转换。

拷贝初始化不仅在我们用=定义变量时会发生,在下列情况下也会发生

  • 将一个对象作为实参传递给一个非引用类型的形参
  • 从一个返回类型为非引用类型的函数返回一个对象
  • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员

参数和返回值

在函数调用过程中,具有非引用类型的参数要进行拷贝初始化。类似的,当一个函数具有非引用的返回类型时,返回值会被用来初始化调用方的结果。

拷贝构造函数被用来初始化非引用类类型参数,这一特性解释了为什么拷贝构造函数自己的参数必须是引用类型。如果其参数不是引用类型,则调用永远也不会成功 —— 为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。

拷贝初始化的限制

如果我们使用的初始化值要求通过一个 explicit 的构造函数来进行类型转换,那么使用拷贝初始化还是直接初始化就不是无关紧要的了:

vector<int> v1(10); // 正确:直接初始化
vector<int> v2 = 10; // 错误:接受大小参数的构造函数是 explicit 的
void f(vector<int>); // f 的参数进行拷贝初始化
f(10); // 错误:不能用一个 explicit 的构造函数拷贝一个实参
f(vector<int>(10)); // 正确:从一个 int 直接构造一个临时 vector

编译器可以绕过拷贝构造函数

编译器被允许将下面的代码

string null_book = "9-999-99999-9"; // 拷贝初始化

改写为

string null_book("9-999-99999-9"); // 编译器略过了拷贝的构造函数

但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的(例如,不能是 private 的)

拷贝赋值运算符

与类控制其对象如何初始化一样,类也可以控制其对象如何赋值

Sales_data trans, accum;
trans = accum; // 使用 Sales_data 的拷贝赋值运算符

与拷贝构造函数一样,如果类未定义自己的拷贝赋值运算符,编译器会为它合成一个

重载赋值运算符

  • 重载运算符本质上是函数,其名字由 operator 关键字后接表示要定义的运算符的符号组成。
  • 重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数。
  • 为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
class Foo {
public:
Foo& operator = (const Foo&);
// ...
};

合成拷贝赋值运算符

与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符

Sales_data& Sales_data::operator = (const Sales_data &rhs) {
bookNo = rhs.bookNo; // 调用 string::operator=
units_sold = rhs.units_sold; // 使用内置的 int 赋值
revenue = rhs.revenue; // 使用内置的 double 赋值
return *this; // 返回一个此对象的引用
}

析构函数

析构函数执行与构造函数相反的操作:构造函数初始化对象的非 static 数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非 static 数据成员。

析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数:

class Foo {
public:
~Foo(); // 析构函数
// ...
};

由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。

析构函数完成什么工作

在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁
内置类型没有析构函数,因此销毁内置类型成员什么也不需要做

隐式销毁一个内置指针类型的成员不会 delete 它所指向的对象

与普通指针不同,智能指针是类类型,所以具有析构函数。因此,与普通指针不同,智能指针成员在析构阶段会被自动释放。

什么时候会调用析构函数

无论何时一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用域时被销毁。
  • 当一个对象被销毁时,其成员被销毁。
  • 容器(无论是标准库容器还是数组)被销毁时,其元素被销毁。
  • 对于动态分配的对象,当对指向它的指针应用 delete 运算符时被销毁。
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

由于析构函数自动运行,我们的程序可以按需要分配资源,而(通常)无须担心何时释放这些资源。
例如,下面代码片段定义了四个 Sales_data 对象:

{ // 新作用域
// p 和 p2 指向动态分配的对象
Sales_data *p = new Sales_data; // p 是一个内置指针
auto p2 = make_shared<Sales_data>(); // p2 是一个 shared_ptr
Sales_data item(*p); // 拷贝构造函数将 *p 拷贝到 item 中
vector<Sales_data> vec; // 局部对象
vec.push_back(*p2); // 拷贝 p2 指向的对象
delete p; // 对 p 指向的对象执行析构函数
} // 退出局部作用域:对 item、p2 和 vec 调用析构函数
// 销毁 p2 会递减其 引用计数;如果引用计数变为 0,对象被释放
// 销毁 vec 会销毁它的元素

当指向一个对象的引用或指针离开作用域时,析构函数不会执行

合成析构函数

当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数。类似拷贝构造函数和拷贝赋值运算符,对于某些类,合成析构函数被用来阻止该类型的对象被销毁。如果不是这种情况,合成析构函数的函数体就为空。

例如:下面的代码片段等价于 Sales_data 的合成析构函数

class Sales_data {
public:
// 合成析构函数等价于
~Sales_data();
};

认识到析构函数体自身并不直接销毁成员是非常重要的。成员是在析构函数体之后隐含的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的另一部分而进行的。

三/五法则

需要析构函数的类也需要拷贝和赋值操作

  • 当我们决定一个类是否要定义它自己版本的拷贝控制成员时,一个基本原则是首先确定这个类是否需要一个析构函数。
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) { }
~HasPtr();
};

在这个版本的类定义中,构造函数中分配的内存将在 HasPtr 对象销毁时被释放。但不幸的是,我们引入了一个严重的错误!这个版本的类使用了合成的拷贝函数和拷贝赋值运算符。这些函数简单拷贝指针成员,这意味着多个 HasPtr 对象可能指向相同的内存:

HasPtr f(HasPtr hp) {
HasPtr ret = hp;
// 处理 ret
return ret; // ret 和 hp 被销毁
}
// 当 f 返回时,hp 和 ret 都被销毁,在两个对象上都会调用 HasPtr 的析构函数。
// 此析构函数会 delete ret 和 hp 中的指针成员。但者两个对象包含相同的指针值。
// 此代码会导致指针被 delete 两次。

需要拷贝操作的类也需要赋值操作,反之亦然

  • 第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然 —— 如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。

使用 =default

我们可以通过将拷贝控制成员定义为 =default 来显式地要求编译器生成合成得版本

class Sales_data {
public;
// 拷贝控制成员;使用 default
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator= (const Sales_data &);
~Sales_data() = default;
// 其他成员的定义,如前
};
  • 当我们在类内使用 =default 修饰成员的声明时,合成的函数将隐式地声明为内联的(就像任何其他类内声明的成员函数一样)。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用 =default,就像对拷贝赋值运算符所做的那样。

阻止拷贝

  • 为了阻止拷贝,看起来可能应该不定义拷贝控制成员。但是,这种策略是无效的:如果我们的类未定义这些操作,编译器为它生成合成的版本。

定义删除的函数

  • 在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的:
struct NoCopy {
NoCopy() = default; // 使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; // 阻止拷贝
NoCopy &operator = (const NoCopy&) = delete; // 阻止赋值
~NoCopy() = default; // 使用合成的析构函数
// 其他成员
};
  • =delete 必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。
  • 与 =default 的另一个不同之处是,我们可以对任何函数指定 =delete。

析构函数不能是删除的成员

  • 我们不能删除析构函数。如果析构函数被删除,就无法销毁此类型的对象了。
  • 对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象。而且,如果一个类有某个成员的类型删除了析构函数,我们也不能定义该类的变量或临时对象。
  • 对于删除了析构函数的类型,虽然我们不能定义这种类型的变量或成员,但可以动态分配这种类型的对象。但是,不能释放这些对象。
struct NoDtor {
NoDtor() = default; // 使用合成默认构造函数
~NoDtor() = delete; // 我们不能销毁 NoDtor 类型的对象
};
int main(int argc, char *argv[]) {
NoDtor nd; // 错误:NoDtor 的析构函数是删除的
NoDtor *p = new NoDtor(); // 正确:但我们不能 delete p
delete p; // 错误:NoDtro 的析构函数是删除的
return 0;
}

合成的拷贝控制成员可能是删除的

  • 如果类的某个成员的析构函数是删除的或不可访问的(例如,是 private 的),则类的合成析构函数被定义为删除的。
  • 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类合成的拷贝构造函数也被定义为删除的。
  • 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个 const 的或引用成员,则类的合成拷贝赋值运算符被定义为删除的。
  • 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个 const 成员,它没有类内初始化器且其类型未显式定义默认构造函数,则该类的默认构造函数被定义为删除的。
    本质上,当不可能拷贝、赋值或销毁类的成员时,类的合成拷贝控制成员就被定义为删除的

private 拷贝控制

在新标准发布之前,类是通过将其拷贝构造函数和拷贝赋值运算符声明为 private 的来阻止拷贝:

class PrivateCopy {
// 无访问说明符:接下来的成员默认为 private 的;
// 拷贝控制成员是 private 的,因此普通用户代码无法访问
PrivateCopy(const PrivateCopy&);
PrivateCopy &operator = (const PrivateCopy&);
// 其他成员
public:
PrivateCopy() = default; // 使用合成的默认构造函数
~PrivateCopy(); // 用户可以定义此类型的对象,但无法拷贝它们
};
  • 由于拷贝构造函数和拷贝赋值运算符是 private 的,用户代码将不能拷贝这个类型的对象。
  • 为了阻止友元和成员函数进行拷贝,我们将这些拷贝控制成员声明为 private 的,但并不定义它们。

希望阻止拷贝的类应该使用 =default 来定义它们自己的拷贝构造函数和拷贝赋值运算符,而不应该将它们声明为 private 的

拷贝控制和资源管理

为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。

  • 定义一个拷贝构造函数,完成 string 的拷贝,而不是拷贝指针
  • 定义一个析构函数来释放 string
  • 定义一个拷贝赋值运算符来释放对象当前的 string,并从右侧对象拷贝 string
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) { }
HasPtr(const HasPtr &s) : ps(new std::string(*s.ps)), i(s.i) { };
const std::string &get() const { return *ps; }
HasPtr &operator= (const HasPtr&);
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};

类值拷贝赋值运算符

赋值运算符通常组合了析构函数和构造函数的操作。类似析构函数,赋值操作会销毁左侧运算对象的资源。类似拷贝构造函数,赋值操作会从右侧运算对象拷贝数据。

HasPtr& HasPtr::operator = (const HasPtr &rhs) {
auto newp = new std::string(*(rhs.ps)); // 拷贝底层 string
delete ps; // 释放旧内存
ps = newp; // 从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; // 返回本对象
}

一个好的模式是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后,销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁,就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了

考虑如果赋值运算符如下编写将会发生什么

HasPtr& HasPtr::operator = (const HasPtr &rhs) {
delete ps; // 释放对象指向的 string
// 如果 rhs 和 *this 是同一个对象,我们就将从已释放的内存中拷贝数据!
ps = new string(*(rhs.ps));
i = rhs.i;
return *this;
}

定义行为像指针的类

令一个类展现类似指针的行为的最好方法是使用 shared_ptr 来管理类中的资源。但是有时我们希望直接管理资源。在这种情况下,使用引用计数就很有用了。

引用计数

引用计数的工作方式如下:

  • 除了初始化对象外,每个构造函数(拷贝构造函数除外)还要创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态。当我们创建一个对象时,只有一个对象共享状态,因此将计数器初始化为 1
  • 拷贝构造函数不分配新的计数器,而是拷贝给定对象的数据成员,包括计数器。拷贝构造函数递增共享的计数器,指出给定对象的状态又被一个新用户所共享。
  • 析构函数递减计数器,指出共享状态的用户少了一个。如果计数器变为 0,则析构函数释放状态。
  • 拷贝赋值运算符递增右侧运算对象的计数器,递减左侧运算对象的计数器。如果左侧运算对象的计数器变为 0,意味着它的共享状态没有用户了,拷贝赋值运算符就必须销毁状态。

定义一个使用引用计数的类

class HasPtr {
public:
// 构造函数分配新的 string 和新的计数器,将计数器置为 1
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) { };
// 拷贝构造函数拷贝所有三个数据成员,并递增计数器
HasPtr(const HasPtr &p) : ps(p.ps), i(p.i), use(p.use) { ++ *use; }
HasPtr& operator = (const HasPtr &);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // 用来记录有多少个对象共享 *ps 的成员
};

类指针的拷贝成员“篡改”引用计数

HasPtr::~HasPtr() {
if (-- *use == 0) { // 如果引用计数变为 0
delete ps; // 释放 string 内存
delete use; // 释放计数器内存
}
}
HasPtr& HasPtr::operator = (const HasPtr &rhs) {
++ *rhs.use; // 递增右侧运算对象的引用计数
if (-- *use == 0) { // 然后递减本对象的引用计数
delete ps; // 如果没有其他用户
delete use; // 释放本对象分配的成员
}
ps = rhs.ps; // 将数据从 rhs 拷贝到本对象
i = rhs.i;
use = rhs.use;
return *this; // 返回本对象
}

交换操作

如果一个类定义了自己的 swap,那么算法将使用类自定义版本。否则,算法将使用标准库定义的 swap。

HasPtr temp = v1; // 创建 v1 的值的一个临时副本
v1 = v2; // 将 v2 的值赋予 v1
v2 = temp; // 将保存的 v1 的值赋予 v2

理论上,这些内存分配都是不必要的。我们更希望 swap 交换指针,而不是分配 string 的新副本。
即,我们希望这样交换两个 HasPtr

string *temp = v1.ps; // 为 v1.ps 中的指针创建一个副本
v1.ps = v2.ps; // 将 v2.ps 中的指针赋予 v1.ps
v2.ps = temp; // 将保存的 v1.ps 中原来的指针赋予 v2.ps

编写我们自己的 swap 函数

class HasPtr {
friend void swap(HasPtr &lhs, HasPtr &rhs);
// 其他成员定义与之前一样
};
void swap(HasPtr &lhs, HasPtr &rhs) {
using std::swap;
swap(lhs.ps, rhs.ps); // 交换指针,而不是 string 数据
swap(lhs.i, rhs.i); // 交换 int 成员
}

swap 函数应该调用 swap,而不是 std::swap

void swap(Foo &lhs, Foo &rhs) {
// 错误:这个函数使用了标准库版本的 swap,而不是 HasPtr 版本
std::swap(lhs.h, rhs.h);
// 交换类型 Foo 的其他成员
}

正确的 swap 函数如下所示:

void swap(Foo &lhs, Foo &rhs) {
using std::swap;
swap(lhs.h, rhs.h); // 使用 HasPtr 版本的 swap
// 交换类型 Foo 的其他成员
}

如果存在类型特定的 swap 版本,其匹配程度会优于 std 中定义的版本

在赋值运算符中使用 swap

定义 swap 的类通常用 swap 来定义它们的赋值运算符。这些运算符使用了一种名为拷贝并交换的技术。这种技术将左侧运算对象与右侧运算对象的一个副本进行交换:

HasPtr& HasPtr::operator = (HasPtr rhs) {
// 交换左侧运算对象和局部变量的 rhs 的内容
swap(*this, rhs); // rhs 现在指向本对象曾经使用的内存
return *this; // rhs 被销毁,从而 delete 了 rhs 中的指针
}

使用拷贝和交换的赋值运算符自动就是异常安全的,且能正确处理自赋值

拷贝控制示例

#pragma once
#ifndef MESSAGE_H
#define MESSAGE_H
#include <iostream>
#include <set>
#include <string>
class Folder;
class Message {
friend class Folder;
friend void swap(Message&, Message&);
public:
// folders 被隐式初始化为空集合
explicit Message(const std::string &str = "") : contents(str) { }
// 拷贝控制成员,用来管理指向本 Message 的指针
Message(const Message&); // 拷贝构造函数
Message& operator = (const Message&); // 拷贝赋值运算符
~Message();
// 从给定 Folder 集合中添加/删除本 Message
void save(Folder&);
void remove(Folder&);
private:
std::string contents; // 实际消息文本
std::set<Folder*> folders; // 包含本 Message 的 Folder
// 拷贝构造函数、拷贝赋值运算符和析构函数所使用的工具函数
// 将本 Message 添加到指向参数的 Folder 中
void add_to_Folders(const Message&);
// 从 folders 中的每个 Folder 中删除本 Message
void remove_from_Folders();
};
class Folder {
friend class Message;
friend void swap(Message&, Message&);
friend void swap(Folder&, Folder&);
public:
Folder() = default;
Folder(const Folder&);
Folder &operator = (const Folder&);
~Folder();
void save(Message&);
void remove(Message&);
private:
std::set<Message*> messages; // 本 Folder 包含的 Message
void addMsg(Message*);
void remMsg(Message*);
};
void Folder::addMsg(Message *m) {
messages.insert(m);
}
void Folder::remMsg(Message *m) {
messages.erase(m);
}
Folder::Folder(const Folder& f) : messages(f.messages) {
for (auto &m : messages)
m -> folders.insert(this);
}
Folder& Folder::operator = (const Folder &f) {
for (auto m : f.messages)
m -> folders.erase(this);
messages = f.messages;
for (auto m : f.messages)
m -> folders.insert(this);
return *this;
}
Folder::~Folder() {
for (auto m : messages)
m -> folders.erase(this);
}
void Folder::save(Message &m) {
messages.insert(&m);
m.folders.insert(this);
}
void Folder::remove(Message &m) {
messages.erase(&m);
m.folders.erase(this);
}
void swap(Folder &lhs, Folder &rhs) {
for (auto m : lhs.messages)
m -> remove(lhs);
for (auto m : rhs.messages)
m -> remove(rhs);
swap(lhs.messages, rhs.messages);
for (auto m : lhs.messages)
m -> save(lhs);
for (auto m : rhs.messages)
m -> save(rhs);
}
void Message::save(Folder &f) {
folders.insert(&f); // 将给定 Folder 添加到我们的 Folder 列表中
f.addMsg(this); // 将本 Message 添加到 f 的 Message 集合中
}
void Message::remove(Folder &f) {
folders.erase(&f); // 将给定 Folder 从我们的 Folder 列表中删除
f.remMsg(this); // 将本 Message 从 f 的 Message 集合中删除
}
// 将本 Message 添加到指向 m 的 Folder 中
void Message::add_to_Folders(const Message &m) {
for (auto f : m.folders) // 对每个包含 m 的 Folder
f -> addMsg(this); // 向该 Folder 添加一个指向本 Message 的指针
}
// Message 的拷贝构造函数
Message::Message(const Message &m) : contents(m.contents), folders(m.folders) {
add_to_Folders(m);
}
// 从对应的 Folder 中删除本 Message
void Message::remove_from_Folders() {
for (auto f : folders) // 对 folders 中每个指针
f -> remMsg(this); // 从该 Folder 中删除本 Message
}
// Message 的析构函数
Message::~Message() {
remove_from_Folders();
}
// Message 的拷贝赋值运算符
Message& Message::operator = (const Message &rhs) {
// 通过先插入指针再删除它们来处理自赋值情况
remove_from_Folders(); // 更新已有 Folder
contents = rhs.contents; // 从 rhs 拷贝消息内容
folders = rhs.folders; // 从 rhs 拷贝 Folder 指针
add_to_Folders(rhs); // 将本 Message 添加到那些 Folder 中
return *this;
}
// Message 的 swap 函数
void swap(Message &lhs, Message &rhs) {
using std::swap; // 在本例中严格来说并不重要,但这是一个好习惯
// 将每个消息的指针从它(原来)所在的 Folder 中删除
for (auto f : lhs.folders)
f -> remMsg(&lhs);
for (auto f : rhs.folders)
f -> remMsg(&rhs);
// 交换 contents 和 Folder 指针 set
swap(lhs.folders, rhs.folders); // 使用 swap(set&, set&)
swap(lhs.contents, rhs.contents); // 使用 swap(string&, string&)
// 将每个 Message 的指针添加到它的(新) Folder 中
for (auto f : lhs.folders)
f -> addMsg(&lhs);
for (auto f : rhs.folders)
f -> remMsg(&rhs);
}
#endif

动态内存管理类

实现标准库 vector 类的一个简化版本。我们所做的一个简化是不适用模板,我们的类只用于 string。因此,它被命名为 StrVec

StrVec 类的设计

每个 StrVec 有三个指针成员指向其元素所使用的内存:

  • elements,指向分配的内存中的首元素
  • first_free,指向最后一个实际元素之后的位置
  • cap,指向分配的内存末尾之后的位置

我们的类还有 4 个工具函数:

  • alloc_n_copy 会分配内存,并拷贝一个给定范围中的元素
  • free 会销毁构造的元素并释放内存
  • chk_n_alloc 保证 StrVec 至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc 会调用 reallocate 来分配更多内存
  • reallocate 在内存用完时为 StrVec 分配新内存

移动构造函数和 std::move
移动构造函数:(有关实现的任何其他细节,目前都尚未公开),可以假定 string 的移动构造函数进行了指针的拷贝,而不是为字符分配内存空间然后拷贝字符

move 标准库函数:它定义在 utility 头文件中。

StrVec 代码

#pragma once
#ifndef STRVEC_H
#define STRVEC_H
#include <iostream>
#include <string>
#include <utility>
#include <memory>
#include <initializer_list>
#include <algorithm>
/*
* a.allocate(n) 分配一段原始的、未构造的内存,保存 n 个类型为 T 的对象
* a.construct(p, args) p 必须是一个类型为 T* 的指针,指向一块原始内存;arg 被传递给类型为 T 的构造函数,用来在 p 指向的内存中构造一个对象
* a.destroy(p) p 为 T* 类型的指针,此算法对 p 指向的对象执行析构函数
* a.deallocate(p, n) 释放从 T* 指针 p 中地址开始的内存,这块内存保存了 n 个类型为 T 的对象;
p 必须是一个先前由 allocate 返回的指针,且 n 必须是 p 创建时所要求的大小。
在调用 deallocate 之前,用户必须对每个在这块内存中创建的对象调用 destroy
* uninitialized_copy(b, e, b2) 从迭代器 b 和 e 指出的输入范围中拷贝元素到迭代器b2 指定的未构造的原始内存中。b2 指向的内存必须足够大,能容纳输入序列中元素的拷贝
* elements, 指向分配的内存中的首元素
* first_free, 指向最后一个实际元素之后的位置
* cap, 指向分配的内存末尾之后的位置
* alloc_n_copy 会分配内存,并拷贝一个给定范围中的元素
* free 会销毁构造的元素并释放内存
* chk_n_alloc 保证 StrVec 至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc 会调用 reallocate 来分配更多内存
* reallocate 在内存用完时为 StrVec 分配新内存
* alloc_n_copy 会分配内存,并拷贝一个给定范围中的元素
* free 会销毁构造的元素并释放内存
* chk_n_alloc 保证 StrVec 至少有容纳一个新元素的空间。如果没有空间添加新元素,chk_n_alloc 会调用 reallocate 来分配更多内存
* reallocate 在内存用完时为 StrVec 分配新内存
*/
// StrVec 类定义
// 类 vector 类内存分配策略的简化实现
class StrVec {
public:
StrVec() :
elements(nullptr), first_free(nullptr), cap(nullptr) { }; // 成员进行默认初始化
StrVec(std::initializer_list<std::string> li) {
auto data = alloc_n_copy(li.begin(), li.end());
elements = data.first;
first_free = cap = data.second;
}
StrVec(const StrVec&); // 拷贝构造函数
StrVec &operator = (const StrVec&); // 拷贝赋值运算符
~StrVec(); // 析构函数
void push_back(const std::string&); // 拷贝元素
size_t size() const { return first_free - elements; }
size_t capacity() const { return cap - elements; }
std::string *begin() const { return elements; }
std::string *end() const { return first_free; }
void reserve(const size_t &); // 分配至少能容纳 n 个元素的内存空间
void resize(const size_t &); // 调整大小为 n 个元素
void resize(const size_t &, const std::string &);
// ...
private:
static std::allocator<std::string> alloc; // 分配元素
// 被添加元素的函数所使用
void chk_n_alloc() { if (size() == capacity()) reallocate(); }
// 工具函数,被拷贝构造函数、赋值运算符和析构函数所使用
std::pair<std::string*, std::string*> alloc_n_copy(const std::string*, const std::string*);
void free(); // 销毁元素并释放内存
void reallocate(); // 获得更多的内存并拷贝已有元素
std::string *elements; // 指向数组首元素的指针
std::string *first_free; // 指向数组第一个空闲元素的指针
std::string *cap; // 指向数组尾后位置的指针
};
std::allocator<std::string> StrVec::alloc;
void StrVec::push_back(const std::string &s) {
chk_n_alloc(); // 确保有空间容纳新元素
// 在 first_free 指向的元素中构造 s 的副本
alloc.construct(first_free ++, s);
}
std::pair<std::string*, std::string*> StrVec::alloc_n_copy(const std::string *b, const std::string *e) {
// 分配空间保存给定范围中的元素
auto data = alloc.allocate(e - b);
// 初始化并返回一个 pair,该 pair 由 data 和 uninitialized_copy 的返回值构成
return {data, uninitialized_copy(b, e, data)};
}
void StrVec::free() {
// 不能传递给 deallocate 一个空指针,如果 elements 为 0,函数什么也不做
if (elements) {
// 逆序销毁旧元素
for (auto p = first_free; p != elements; /* 空 */)
alloc.destroy(-- p); // 析构
// for_each 和 lambda 表达式
// for_each(elements, first_free, [](std::string &p) { alloc.destroy(&p); });
alloc.deallocate(elements, cap - elements); // 释放
}
}
StrVec::StrVec(const StrVec &s) {
// 调用 alloc_n_copy 分配空间以容纳与 s 中一样多的元素
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
StrVec::~StrVec() {
free();
}
StrVec& StrVec::operator = (const StrVec &rhs) {
// 调用 alloc_n_copy 分配内存,大小与 rhs 中元素占用空间一样多
auto data = alloc_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
/*
在编写 reallocate 成员函数之前,我们稍微思考一下此函数应该做什么。
* 为一个新的、更大的 string 数组分配内存
* 在内存空间的前一部分构造对象,保存现有元素
* 销毁原内存空间中的元素,并释放这块内存
*/
void StrVec::reallocate() {
// 我们将分配当前大小两倍的内存空间
auto newcapacity = size() ? 2 * size() : 1;
// 分配新内存
auto newdata = alloc.allocate(newcapacity);
// 将数据从旧内存移动到新内存
auto dest = newdata; // 指向新数组中下一个空闲位置
auto elem = elements; // 指向旧数组中下一个元素
for (size_t i = 0; i != size(); ++ i)
alloc.construct(dest ++, std::move(*elem ++));
free(); // 一旦我们移动完元素就释放旧内存空间
// 更新我们的数据结构,执行新元素
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
void StrVec::reserve(const size_t &n) {
if (n > capacity()) {
auto newcapacity = n;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++ i) {
alloc.construct(dest ++, std::move(*elem ++));
}
free();
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
} else {
return ;
}
}
void StrVec::resize(const size_t &n) {
if (n > capacity()) {
auto newcapacity = n;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++ i) {
alloc.construct(dest ++, std::move(*elem ++));
}
free();
while (dest != newdata + newcapacity)
alloc.construct(dest ++, std::string());
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
} else if (n == capacity() || n <= 0) {
return ;
} else {
auto newcapacity = n;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++ i) {
alloc.construct(dest ++, std::move(*elem ++));
}
free();
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
}
void StrVec::resize(const size_t &n, const std::string &s) {
if (n > capacity()) {
auto newcapacity = n;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++ i) {
alloc.construct(dest ++, std::move(*elem ++));
}
free();
while (dest != newdata + newcapacity)
alloc.construct(dest ++, s);
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
} else if (n == capacity() || n <= 0) {
return ;
} else {
auto newcapacity = n;
auto newdata = alloc.allocate(newcapacity);
auto dest = newdata;
auto elem = elements;
for (size_t i = 0; i != size(); ++ i) {
alloc.construct(dest ++, std::move(*elem ++));
}
free();
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
}
#endif

对象移动

标准库容器、string 和 shared_ptr 类既支持移动也支持拷贝。IO 类和 unique_ptr 类可以移动但不能拷贝

右值引用

所谓右值引用就是必须绑定到右值的引用。我们通过 && 而不是 & 来获得右值引用。
如我们所看到的,右值引用有一个重要的性质 —— 只能绑定到一个将要销毁的对象。因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中。

  • 我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用绑定到一个左值上
int i = 42;
int &r = i; // 正确:r 引用 i
int &&rr = i; // 错误:不能将一个右值引用绑定到一个左值上
int &r2 = i * 42; // 错误:i * 42 是一个右值
const int &r3 = i * 42; // 正确:我们可以将一个 const 的引用绑定到一个左值上
int &&rr2 = i * 42; // 正确:将 rr2 绑定到乘法结果上
  • 返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式的例子。我们可以将一个左值引用绑定到这类表达式的结果上。

  • 返回非引用类型的函数,连同算数、关系、位以及后置递增/递减运算符,都生成右值。我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个 const 的左值引用或者一个右值引用绑定到这类表达式上。

左值持久:右值短暂

考察左值和右值表达式的列表,两者相互区别之处就很明显了:左值有持久的状态,而右值要么是字面常量,要么是在表达式求值过程中创建的临时对象。

由于右值引用只能绑定到临时对象,我们得知

  • 所引用的对象将要被销毁
  • 该对象没有其他用户

这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源
右值引用指向将要被销毁的对象。因此,我们可以从绑定到右值引用的对象“窃取”状态

变量是左值

变量可以看作只有一个运算对象而没有运算符的表达式,虽然我们很少这样看待变量。类似其他任何表达式,变量表达式也有左值/右值属性。变量表达式都是左值。带来的结果就是,我们不能将一个右值引用绑定到一个右值引用类型的变量上,这有些令人惊讶:

int &&rr1 = 42; // 正确:字面值常量是右值
int &&rr2 = rr1; // 错误:表达式 rr1 是左值

其实有了右值表示临时对象这一观察结果,变量是左值这一特性并不令人惊讶。毕竟,变量是持久的,直至离开作用域时才被销毁。
变量是左值,因此我们不能将一个右值引用直接绑定到一个变量上,即使这个变量是右值引用类型也不行

标准 move 函数

虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显式地将一个左值转换为对应的右值引用类型。我们还可以通过调用一个名为 move 的新标准库函数来获得绑定到左值上的右值引用。

int &&rr3 = std::move(rr1);

调用 move 就意味着承诺:除了对 rr1 赋值或销毁它外,我们将不再使用它。
我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值。
调用 move 的代码应该使用 std::move 而不是 move,这样做可以避免潜在的名字冲突。

移动构造函数和移动赋值运算符

类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用。不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参。

除了完成资源移动,移动构造函数还必须确保移动后源对象处于这样一个状态 —— 销毁它是无害的

StrVec::StrVec(StrVec &&s) noexcept // 移动操作不应抛出任何异常
// 成员初始化器接管 s 中的资源
: elements(s.elements), first_free(s.first_free), cap(s.cap) {
// 令 s 进入一个这样的状态
s.elements = s.first_free = s.cap = nullptr;
}

移动操作、标准库容器和异常

当编写一个不抛出异常的移动操作时,我们通过一种通知标准库的方法:在我们的构造函数中指明 noexcept,noexcept 是新标准引入的。

我们必须在类头文件的声明中和定义中(如果定义在类外的话)都指定 noexcept
不抛出异常的移动构造函数和移动赋值运算符必须标记为 noexcept

移动赋值运算符

StrVec& StrVec::operator = (StrVec &&rhs) noexcept {
// 直接检测自赋值
if (this != &rhs) {
free(); // 释放已有元素
elements = rhs.elements; // 从 rhs 接管资源
first_free = rhs.first_free;
cap = rhs.cap;
// 将 rhs 置于可析构状态
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
}

移后源对象必须可析构

在移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设

合成的移动操作

只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非 static 数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员。如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员

// 编译器会为 X 和 hasX 合成移动操作
struct X {
int i; // 内置类型可以移动
std::string s; // string 定义了自己的移动操作
};
struct hasX {
X mem; // X 有合成的移动操作
};
X x, x2 = std::move(x); // 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数

与拷贝操作不同,移动操作永远不会隐式定义为删除的操作。但是,如果我们显式地要求编译器生成 =default 的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。除了这一重要例外,什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则

  • 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。移动赋值运算符的情况类似。
  • 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的
  • 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的
  • 类似拷贝赋值运算符,如果有类成员是 const 的或是引用,则类的移动赋值运算符被定义为删除的
// 假定 Y 是一个类,它定义了自己的拷贝构造函数但未定义自己的移动构造函数
struct hasY {
hasY() = default;
hasY(hasY&&) = default;
Y = mem; // hasY 将有一个删除的移动构造函数
};
hasY hy, hy2 = std::move(hy); // 错误:移动构造函数是删除的

定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的

移动右值,拷贝左值......

StrVec v1, v2;
v1 = v2; // v2 是左值;使用拷贝赋值
StrVec getVec(istream &); // getVec 返回一个右值
v2 = getVec(cin); // getVec(cin) 是一个右值;使用移动赋值

......但如果没有移动构造函数,右值也被拷贝

class Foo {
public:
Foo() = default;
Foo(const Foo&); // 拷贝构造函数
// 其他成员定义,但 Foo 未定义移动构造函数
};
Foo x;
Foo y(x); // 拷贝构造函数;x 是一个左值
Foo z(std::move(x)); // 拷贝构造函数,因为未定义移动构造函数

值得注意的是,用拷贝构造函数代替移动构造函数几乎肯定是安全的。
如果一个类有一个可用拷贝构造函数而没有移动构造函数,则其对象是通过拷贝构造函数来“移动”的。拷贝赋值运算符和移动赋值运算符的情况类似

拷贝并交换赋值运算符和移动操作

HasPtr 类:如果我们为此添加一个移动构造函数,它实际上也会获得一个移动赋值运算符

// 添加的移动构造函数
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }
// 赋值运算符既是移动赋值运算符,也是拷贝赋值运算符
HasPtr& operator = (HasPtr rhs) {
swap(*this, rhs);
return *this;
}
// 例如:假定 hp 和 hp2 都是 HasPtr 对象
hp = hp2; // hp2 是一个左值;hp2 通过拷贝构造函数来拷贝
hp = std::move(hp2); // 移动构造函数移动 hp2

建议:更新三五法则
如果一个类定义了任何一个拷贝操作,它就应该定义所有五个操作

Message 类的移动操作

void Message::move_Folders(Message *m) {
folders = std::move(m -> folders); // 使用 set 的移动赋值运算符
for (auto f : folders) { // 对每个 Folder
f -> remMsg(m); // 从 Folder 中删除旧 Message
f -> addMsg(this); // 将本 Message 添加到 Folder 中
}
m -> folders.clear(); // 确保销毁 m 是无害的
}
Message::Message(Message &&m) : contents(std::move(m.contents)) {
move_Folders(&m); // 移动 folders 并更新 Folder 指针
}
Message& Message::operator = (Message &&rhs) {
if (this != &rhs) { // 直接检查自赋值情况
remove_from_Folders();
contents = std::move(rhs.contents); // 移动赋值运算符
move_Folders(&rhs); // 重置 Folders 指向本 Message
}
return *this;
}

移动迭代器

  • 我们通过调用标准库的 make_move_iterator 函数将一个普通迭代器转换为一个移动迭代器。此函数接受一个迭代器参数,返回一个移动迭代器。
  • 原迭代器的所有其他操作在移动迭代器中都照常工作。
void StrVec::reallocate() {
// 我们将分配当前大小两倍的内存空间
auto newcapacity = size() ? 2 * size() : 1;
// 分配新内存
auto first = alloc.allocate(newcapacity);
auto last = std::uninitialized_copy(make_move_iterator(begin()), make_move_iterator(end()), first);
free(); // 一旦我们移动完元素就释放旧内存空间
// 更新我们的数据结构,执行新元素
elements = first;
first_free = last;
cap = elements + newcapacity;
}

值得注意的是,标准库不保证哪些算法适用移动迭代器,哪些不适用。由于移动一个对象可能销毁掉原对象,因此你只有在确信算法在为一个元素赋值或将其传递给一个用户定义的函数后不再访问它时,才能将移动迭代器传递给算法

右值引用和成员函数

一般来说,我们不需要为函数操作定义接受一个 const X&& 或是一个(普通的)X& 参数的版本。当我们希望从实参“窃取”数据时,通常传递一个右值引用。为了达到这一目的,实参不能是 const 的。类似的,从一个对象进行拷贝的操作不应该改变该对象。因此,通常不需要定义一个接受一个(普通的)X& 参数的版本。

区分移动和拷贝的重载函数通常有一个版本接受一个 const T&,而另一个版本接受一个 T&&

class StrVec {
public:
void push_back(const std::string&); // 拷贝元素
void push_back(std::string&&);
};
std::allocator<std::string> StrVec::alloc;
void StrVec::push_back(const std::string &s) {
chk_n_alloc(); // 确保有空间容纳新元素
// 在 first_free 指向的元素中构造 s 的副本
alloc.construct(first_free ++, s);
}
void StrVec::push_back(std::string &&s) {
chk_n_alloc();
alloc.construct(first_free ++, std::move(s));
}

当我们调用 push_back 时,实参类型决定了新元素是拷贝还是移动到容器中

StrVec vec; // 空 StrVec
string s = "some string or another";
vec.push_back(s); // 调用 push_back(const string&);
vec.push_back("done"); // 调用 push_back(string&&);
// 这些调用的差别在于实参是一个左值还是右值(从 "done" 创建的临时 string),具体调用哪个版本据此来决定

右值和左值引用成员函数

有时,右值的使用方式可能令人惊讶

s1 + s2 = "Wow!";

在旧标准中,我们没有办法阻止这种使用方式。为了维持向后兼容性,新标准库类仍然允许向右值赋值。但是,我们可能希望在自己的类中阻止这种用法。在此情况下,我们希望强制左侧运算对象(即,this 指向的对象)是一个左值。

我们指出 this 的左值/右值属性的方式与定义 const 成员函数相同,即,在参数列表后放置一个引用限定符

class Foo {
public:
Foo &operator = (const Foo&) &; // 只能向可修改的左值赋值
// Foo 的其他参数
};
Foo &Foo::operator = (const Foo &rhs) & {
// 执行将 rhs 赋予本对象所需的工作
return *this;
}
  • 引用限定符可以是 & 或 &&,分别指出 this 可以指向一个左值或右值。
  • 引用限定符只能用于(非 static)成员函数,且必须同时出现在函数的声明和定义中。

对于 & 限定的函数,我们只能将它用于左值;对于 && 限定的函数,只能用于右值;

Foo &retFoo(); // 返回一个引用;retFoo 调用是一个左值
Foo retVal(); // 返回一个值;retVal 调用是一个右值
Foo i, j; // i 和 j 是左值
i = j; // 正确:i 是左值
retFoo() = j; // 正确:retFoo() 返回一个左值
retVal = j; // 错误:retVal() 返回一个右值
i = retVal(); // 正确:我们可以将一个右值作为赋值操作的右侧运算对象

一个函数可以同时用 const 和引用限定。在此情况下,引用限定符必须跟随在 const 限定符之后

class Foo {
public:
Foo someMem() & const; // 错误:const 限定符必须在前
Foo anotherMem() const &; // 正确:const 限定符在前
};

重载和引用函数

class Foo {
public:
Foo sorted() &&; // 可用于可改变的右值
Foo sorted() const &; // 可用于任何类型的 Foo
// Foo 的其他参数
private:
std::vector<int> data;
};
// 本对象为右值,因此可以原址排序
Foo Foo::sorted() && {
sort(data.begin(), data.end());
return *this;
}
// 本对象是 const 或是一个左值,哪种情况我们都不能对其进行原址排序
Foo Foo::sorted() const & {
Foo ret(*this); // 拷贝一个副本
sort(ret.data.begin(), ret.data.end()); // 排序副本
return ret; // 返回副本
}
// 编译器会根据调用 sorted 的对象的左值/右值属性来确定使用哪个 sorted 版本
retVal().sorted(); // retVal() 是一个右值,调用 Foo::sorted() &&;
retFoo().sorted(); // retFoo() 是一个左值,调用 Foo::sorted() const &;

当我们定义 const 成员函数时,可以定义两个版本,唯一的差别是一个版本有 const 限定而另一个没有。引用限定的函数则不一样。如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加:

class Foo {
public:
Foo sorted() &&;
Foo sorted() const; // 错误:必须加上引用限定符
// Comp 是函数类型的类型别名
// 此函数类型可以用来比较 int 值
using Comp = bool(const int&, const int&);
Foo sorted(Comp*); // 正确:不同的参数列表
Foo sorted(Comp*) const; // 正确:两个版本都没有引用限定符
};

如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须有引用限定符

posted @   HuiPuKui  阅读(15)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
点击右上角即可分享
微信分享提示