1、参考引用
- C++高级编程(第4版,C++17标准)马克·葛瑞格尔
2、建议先看《21天学通C++》 这本书入门,笔记链接如下
- 21天学通C++读书笔记(文章链接汇总)
1. 编写类
- 编写类时,需要指定行为或方法 (应用于类的对象),还需要指定属性或数据成员 (每个对象都会包含)。编写类有两个要素:定义类本身和定义类的方法
1.1 类定义
-
类定义是一条 C++ 语句,必须用分号结束
// SpreadsheetCell.h class SpreadsheetCell { public: // 类支持的方法 void setValue(double inValue); double getValue() const; // 最好将不改变对象的任何数据成员的成员函数声明为 const private: double mValue; // 类的数据成员 };
-
类的成员
- 类可有许多成员。成员可以是成员函数(方法、构造函数或析构函数),也可以是成员变量(也称为数据成员)、成员枚举、类型别名和嵌套类等
- 成员函数与数据成员不能同名
-
访问控制
- 类中的每个方法和成员都可用三种访问说明符之一来说明: public、protected 或 private。访问说明符将应用于其后声明的所有成员,直到遇到另一个访问说明符
- 与类相似,C++ 中的结构 (struct) 也可以拥有方法。实际上,唯一的区别就是结构的默认访问说明符是 public,而类默认是 private
-
声明顺序
- 可使用任何顺序声明成员和访问控制说明符:C++ 没有施加任何限制,例如成员函数在数据成员之前,或者 public 在 private 之前。此外,可重复使用访问说明符
-
类内成员初始化器
- 可直接在类定义中初始化成员变量,如下所示:在 SpreadsheetCell 类定义中直接将 mValue 初始化为 0
class SpreadsheetCell { public: ... private: double mValue = 0; };
1.2 定义方法
-
类定义必须在方法定义之前,通常类定义在头文件中,方法定义在包含头文件的源文件中
#include "SpreadsheetCell.h" void SpreadsheetCell::setValue(double inValue) { mValue = inValue; } double SpreadsheetCell::getValue() const { return mValue; }
-
访问数据成员
- 类的非静态方法总是在类的特定对象上执行,在类的方法体中可以访问对象所属类的所有数据成员
- 如果两个不同的对象调用 setValue(),这行代码 (对每个对象执行一次) 会改变两个不同对象内的变量值
mValue = inValue;
-
this 指针
- 每个普通的方法调用都会传递一个指向对象的指针,这就是称为 “隐藏” 参数的 this 指针
- 使用这个指针可访问数据成员或者调用方法,也可将其传递给其他方法或函数
- 有时还用它来消除名称的歧义。例如,可使用 value 而不是 mValue 作为 SpreadsheetCell 类的数据成员,用 value 而不是 inValue 作为 setValue()方法的参数
void SpreadsheetCell::setValue(double value) { // value = value; this->value = value; // 避免名称歧义 }
- 如果对象的某个方法调用了某个函数,而该函数采用指向对象的指针作为参数,就可使用 this 指针调用这个函数
void printCell(const SpreadsheetCell &cell) { cout << cell.getString() << endl; } void SpreadsheetCell::setValue(double value) { this->value = value; printCell(*this); // 该指针指向 setValue() 操作的 SpreadsheetCell 对象 // cout << *this << endl; // 重载了 << 后可使用该行输出,不用编写 printCell 函数 }
1.3 使用对象
-
可采用两种方法来创建和使用对象:在堆栈中或者在堆中
-
堆栈中的对象
- 创建对象类似于声明简单变量,区别在于变量类型是类名
- “.” 运算符允许调用对象的方法,若对象中有公有数据成员,也可用点运算符访问,但不推荐使用公有数据成员
SpreadsheetCell myCell; myCell.setValue(6); cout << "cell: " << myCell.getValue() << endl;
-
堆中的对象
- 在堆中创建对象时,通过 “箭头” 运算符访问其成员
SpreadsheetCell *myCellp = new SpreadsheetCell(); myCellp->setValue(3.7); cout << "cell: " << myCellp->getValue() << endl; // cout << "cell: " << (*myCellp).getValue() << endl; // 与上一行等价 // 用 new 为某个对象分配内存,使用完对象后,要用 delete 销毁对象(或使用智能指针自动管理内存) delete myCellp; myCellp = nullptr;
2. 对象的生命周期
- 对象的生命周期涉及 3 个活动:创建、销毁和赋值
2.1 创建对象
- 在声明对象 (如果是在堆栈中) 或使用 new、new[] 或智能指针显式分配空间时,就会创建对象。当创建对象时,会同时创建内嵌的对象
- 在 main() 函数中创建 myClass 对象时,同时创建内嵌的 string 对象,当包含它的对象被销毁时,string 也被销毁
- 在声明变量时最好给它们赋初始值,与此类似,也应该初始化对象。声明并编写一个名为构造函数的方法,可以提供这一功能,在构造函数中可以执行对象的初始化任务
#include <string> class myClass { private: std::string mName; }; int main() { myClass obj; return 0; }
2.1.1 编写构造函数
- 构造函数没有返回类型,参数可有可无,没有参数的构造函数称为默认构造函数。可以是无参构造函数,也可以让所有参数都使用默认值。许多情况下,都必须提供默认构造函数,如果不提供,就会导致编译器错误
class SpreadsheetCell { public: SpreadsheetCell(double initialValue); // 构造函数 }; // 必须提供构造函数的实现 SpreadsheetCell::SpreadsheetCell(double initialValue) { setValue(initialValue); }
2.1.2 使用构造函数
-
在堆栈中使用构造函数
SpreadsheetCell myCell(5); cout << "cell: " << myCell.getValue() << endl; // 不要显式调用构造函数 SpreadsheetCell myCell.SpreadsheetCell(5); // 编译错误 // 后来也不能调用构造函数 SpreadsheetCell myCell; myCell.SpreadsheetCell(5); // 编译错误
-
在堆中使用构造函数
- 注意:在堆中可以声明一个指向 SpreadsheetCell 对象的指针,而不立即调用构造函数,但是堆栈中的对象在声明时会调用构造函数
auto smartCellp = make_unique<SpreadsheetCell>(4); // ...do something with the cell, 不需要删除智能指针 // 不推荐下面使用原始指针的方式 SpreadsheetCell *myCellp = new SpreadsheetCell(5); delete myCellp; myCellp = nullptr;
2.1.3 提供多个构造函数
- 在一个类中可提供多个构造函数。所有构造函数的名称相同 (类名),但不同的构造函数具有不同数量的参数或者不同的参数类型
- C++ 支持委托构造函数 (delegating constructors),允许构造函数初始化器调用同一个类的其他构造函数
class SpreadsheetCell { public: SpreadsheetCell(double initialValue); SpreadsheetCell(std::string_view initialValue); };
2.1.4 默认构造函数
- 默认构造函数没有参数,也称为无参构造函数。使用默认构造函数可以在不指定值的情况下初始化数据成员
- 什么时候需要默认构造函数?
- 创建对象数组需要完成两个任务:为所有对象分配内存连续的空间,为每个对象调用默认构造函数
- C++ 没有提供任何语法,让创建数组的代码直接调用不同的构造函数
- 如果想在标准库容器如 vector 中存储类,也需要默认构造函数
// 该行没有定义 SpreadsheetCell 类的默认构造函数,无法通过编译 SpreadsheetCell cells[3]; // 基于堆栈的数组可使用下列初始化器的方式绕过上面的限制 SpreadsheetCell cells[3] = {SpreadsheetCell(0), SpreadsheetCell(23), SpreadsheetCell(41)};
- 如何编写默认构造函数?
class SpreadsheetCell { public: SpreadsheetCell(); // 默认构造函数 }; SpreadsheetCell::SpreadsheetCell() { // 默认构造函数的实现 mValue = 0; // 如果使用类内成员初始化方式则可省略该行代码 } SpreadsheetCell myCell; // 在堆栈中创建对象时,调用默认构造函数不需要使用圆括号 myCell.setValue(6); cout << "cell: " << myCell.getValue() << endl; // 对于堆中的对象,可以这样使用默认构造函数 auto smartCellp = make_unique<SpreadsheetCell>();
- 编译器生成的默认构造函数?
- 如果没有指定任何构造函数,编译器将自动生成无参构造函数
- 如果声明了默认构造函数或其他构造函数,编译器就不会再自动生成默认构造函数
默认构造函数 = 无参构造函数
- 默认构造函数并不仅仅是说如果没有声明任何构造函数就会自动生成一个构造函数
- 而且还指:如果没有参数,构造函数就采用默认值
- 显式的默认构造函数
- 如果类需要一些接收参数的显式构造函数,还需要一个什么都不做的默认构造函数,就必须显式的编写空的默认构造函数
class SpreadsheetCell { public: SpreadsheetCell() = default; // 显式默认构造函数 };
- 显式删除默认构造函数
- 可定义一个只有静态方法的类,这个类没有任何构造函数,也不想让编译器生成默认构造函数,这种情况下可使用显式删除默认构造函数
class MyClass { public: MyClass() = delete; // 显式默认构造函数 };
2.1.5 构造函数初始化器
- C++ 提供了另一种在构造函数中初始化数据成员的方法,叫作构造函数初始化器
SpreadsheetCell::SpreadsheetCell(double initialValue) : mValue(initialValue) {}
- 某些程序员喜欢在构造函数体内提供初始值(即使这么做效率不高)。然而,某些数据类型必须在构造函数初始化器中或使用类内初始化器进行初始化,下图对此进行了总结
- 数据成员的初始化顺序:按照它们在类定义中出现的顺序,而不是在构造函数初始化器中的顺序
class Foo { public: Foo(double value); private: double mValue; }; Foo::Foo(double value) : mValue(value) { cout << "Foo::mValue = " << mValue << endl; } // 将 Foo 对象作为自己的一个数据成员 class MyClass { public: MyClass(double value); private: // 必须保持下列顺序,否则输出结果未知 double mValue; Foo mFoo; }; // 其构造器函数首先在 mValue 中存储给定的值,然后将 mValue 作为实参来调用 Foo 构造函数 MyClass::MyClass(double value) : mValue(value), mFoo(mValue) { cout << "MyClass::mValue = " << mValue << endl; } MyClass instance(1.2);
// 输出 Foo::mValue = 1.2 MyClass::mValue = 1.2
2.1.6 复制构造函数
-
C++ 中有一种特殊的构造函数,叫作复制构造函数,允许所创建的对象是另一个对象的精确副本
- 如果没有编写复制构造函数,C++ 会自动生成一个,用源对象中相应数据成员的值初始化新对象的每个数据成员
- 如果数据成员是对象,初始化意味着调用它们的复制构造函数
- 多数情况下,由编译器生成的构造函数足以满足要求,不需要亲自编写复制构造函数
class SpreadsheetCell { public: // 复制构造函数采用源对象的 const 引用作为参数 SpreadsheetCell(const SpreadsheetCell &src); };
-
什么时候调用复制构造函数
- C++ 中传递函数参数的默认方式是值传递,这意味着函数或方法接收某个值或对象的副本。因此,无论什么时候给函数或方法传递一个对象,编译器都会调用新对象的复制构造函数进行初始化
- 可通过将参数作为 const 引用来传递,从而避免复制构造函数的开销
-
显式调用复制构造函数
SpreadsheetCell myCell1(4); SpreadsheetCell myCell2(myCell1);
-
按引用传递对象
- 向函数或方法传递对象时,为避免复制对象,可让函数或方法采用对象的引用作为参数。按引用传递对象通常比按值传递对象的效率更高,因为:只需要复制对象的地址,而不需要复制对象的全部内容(最好按 const 引用)
- 诸如 int 和 double 等基本类型应当按值传递,按 const 引用传递这些类型什么也得不到
2.1.7 初始化列表构造函数
#include <iostream>
#include <initializer_list>
#include <stdexcept>
#include <vector>
using namespace std;
class EvenSequence {
public:
// 初始化列表构造函数
EvenSequence(initializer_list<double> args) {
if (args.size() % 2 != 0) {
throw invalid_argument("initializer_list should "
"contain even number of elements.");
}
mSequence.reserve(args.size());
// 将顺序遍历 initializer_list 并将其元素添加到 mSequence 中
for (const auto &value : args) {
mSequence.push_back(value);
}
}
// 用于打印 mSequence 中的所有元素
void dump() const {
for (const auto& value : mSequence) {
cout << value << ", ";
}
cout << endl;
}
private:
vector<double> mSequence;
};
int main() {
EvenSequence p1 = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0}; // 等号可省略
p1.dump();
try {
EvenSequence p2 = {1.0, 2.0, 3.0};
} catch (const invalid_argument& e) {
cout << e.what() << endl;
}
return 0;
}
2.1.8 委托构造函数
- 委托构造函数允许构造函数调用同一个类的其他构造函数。然而,这个调用不能放在构造函数体内,而必须放在构造函数初始化器中,且必须是列表中唯一的成员初始化器
SpreadsheetCell::SpreadsheetCell(string_view initialValue)
: SpreadsheetCell(stringToDouble(initialValue)) {}
2.1.9 总结编译器生成的构造函数
2.2 销毁对象
- 当销毁对象时,会发生两件事:调用对象的析构函数,释放对象占用的内存。在析构函数中可执行对象清理,例如释放动态分配的内存或关闭文件句柄。若没有声明析构函数,编译器将自动生成一个,析构函数会逐一销毁成员,然后删除对象
- 堆栈中对象的销毁顺序与声明顺序 (构建顺序) 相反,如果某个对象是其他对象的数据成员,这一顺序也适用:数据成员对象的销毁顺序与其在类中声明的顺序相反
- 没有智能指针的帮助,在堆中分配的对象不会自动销毁。必须使用 delete 删除对象指针,从而调用析构函数并释放内存
2.3 对象赋值
- C++ 中 “复制” 只在初始化对象时发生。如果一个已经具有值的对象被改写,更精确的术语是 “赋值”
- C++ 提供的复制工具是复制构造函数:这是一个构造函数,只能用在创建对象时,而不能用于对象的赋值
- C++ 为所有的类提供了执行赋值的方法:赋值运算符,实际是为类重载了 = 运算符
此处的赋值运算符也称复制赋值运算符,因为左边和右边的对象都会继续存在。之所以要这样区分,是因为还有移动赋值运算符,当赋值结束后右边的对象会被销毁
SpreadsheetCell myCell(5), anotherCell; anotherCell = myCell;
2.3.1 声明赋值运算符
- 赋值运算符与复制构造函数类似,采用了源对象的 const 引用。在此情况下,将源对象称为 rhs,代表等号 “右边”,调用赋值运算符的对象在等号的左边
- 与复制构造函数不同的是,赋值运算符返回 SpreadsheetCell 对象的引用
class SpreadsheetCell { public: SpreadsheetCell& operator= (const SpreadsheetCell &rhs); };
2.3.2 定义赋值运算符
- 赋值运算符的实现与复制构造函数类似,但存在一些重要的区别。首先,复制构造函数只有在初始化时才调用,此时目标对象还没有有效的值,赋值运算符可以改写对象的当前值
- 在 C++ 中允许将对象的值赋给自身
SpreadsheetCell cell(4); cell = cell;
- 为阻止自赋值现象,赋值运算符通常在方法开始时检测自赋值,如果发现自赋值,则立刻返回
SpreadsheetCell& SpreadsheetCell::operator= (const SpreadsheetCell &rhs) { // this 是一个指向左边对象的指针,&rhs 是一个指向右边对象的指针 if (this == &rhs) { return *this; // this 指针指向执行方法的对象,因此 *this 就是对象本身 } }
2.4 复制和赋值的区别
-
声明时会使用复制构造函数,赋值语句会使用赋值运算符
SpreadsheetCell myCell(5); SpreadsheetCell anotherCell(myCell); // 复制构造函数 SpreadsheetCell aThirdCell = myCell; // 也是复制构造函数 anotherCell = myCell; // 此处,anotherCell 已经构建,因此会调用 operator =
-
按值返回对象
string SpreadsheetCell::getString() const { return doubleToString(mValue); } // 复制构造函数 & 赋值运算符 SpreadsheetCell myCell2(5); string s1; // 当 getString() 返回 mString 时,编译器实际上调用 string 复制构造函数 // 创建一个未命名的临时字符串对象,将结果赋给 s1 时,会调用 s1 的赋值运算符 s1 = myCell2.getString(); // 复制构造函数 SpreadsheetCell myCell3(5); // 此时 s2 调用的是复制构造函数 string s2 = myCell3.getString();
通过移动语义,编译器可使用移动构造函数而不是复制构造函数,这样效率更高
-
复制构造函数和对象成员
- 如果某个对象包含其他对象,编译器生成的复制构造函数会递归调用每个被包含对象的复制构造函数
- 在复制构造函数的函数体内对数据成员赋值时,使用的是赋值运算符而不是复制构造函数,因为它们已经初始化
3. 友元
- C++ 允许某个类将其他类、其他类的成员函数或非成员函数声明为友元 (friend),友元可以访问类的 protected 和 private 数据成员和方法
- 类、方法或函数不能将自身声明为其他类的友元并访问这些类的非公有名称
- fiend 类和方法很容易被滥用:友元可以违反封装的原则,将类的内部暴露给其他类或函数
// 现在 Bar 类的所有成员可以访问 Foo 类的 private 和 protected 数据成员和方法 class Foo { friend class Bar; // ... }
// 也可将 Bar 类的一个特定方法作为友元 class Foo { friend void Bar::processFoo(const Foo &foo); // ... }
// 独立函数也可成为类的友元 class Foo { // 类中的 friend 声明可以用作函数的原型 friend void dumpFoo(const Foo &foo); // ... } void dumpFoo(const Foo &foo) { // 将 Foo 对象的所有数据转存到控制台,希望可以访问 Foo 对象的内部数据成员 }
4. 对象的动态内存分配
4.1 Spreadsheet 类
- 此处的 Spreadsheet 类在两个方向上都使用数字,下面是这个简单 Spreadsheet 类的第一个定义
#include <cstddef> #include "SpreadsheetCell.h" class Spreadsheet { public: Spreadsheet(size_t width, size_t height); void setCellAt(size_t x, size_t y, const SpreadsheetCell &cell); SpreadsheetCell& getCellAt(size_t x, size_t y); private: bool inRange(size_t value, size_t upper) const; size_t mWidth = 0; size_t mHeight = 0; SpreadsheetCell* *mCells = nullptr; };
- 动态分配二维数组
Spreadsheet::Spreadsheet(size_t width, size_t height) : mWidth(width), mHeight(height) { mCells = new SpreadsheetCell*[mWidth]; for (size_t i = 0; i < mWidth; ++i) { mCells[i] = new SpreadsheetCell[mHeight]; } }
- 堆栈名为 s1 的 Spreadsheet 对象分配的内存如下图
- 设置和获取方法
void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell &cell) { // 使用 inRange 类检测电子表格中的 x 和 y 坐标是否有效 if (!inRange(x, mWidth) || !inRange(y, mHeight)) { throw std::out_of_range(""); } mCells[x][y] = cell; } SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y) { if (!inRange(x, mWidth) || !inRange(y, mHeight)) { throw std::out_of_range(""); } return mCells[x][y]; }
- 考虑到上述代码中 setCellAt 和 getCellAt 部分重复,定义以下方法来避免重复
void verifyCoordinate(size_t x, size_t y) const; void Spreadsheet::verifyCoordinate(size_t x, size_t y) const { if (x >= mWidth || y >= mHeight) { throw std::out_of_range(""); } }
void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell &cell) { // 使用 inRange 类检测电子表格中的 x 和 y 坐标是否有效 verifyCoordinate(x, y); mCells[x][y] = cell; } SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y) { verifyCoordinate(x, y); return mCells[x][y]; }
4.2 使用析构函数释放内存
- 如果不再需要动态分配的内存,就必须释放它们。如果为对象动态分配了内存,就在析构函数中释放内存。当销毁对象时,编译器确保调用析构函数。下面是带有析构函数的 Spreadsheet 类定义
class Spreadsheet { public: Spreadsheet(size_t width, size_t height); ~Spreadsheet(); // 析构函数的定义 }; // 析构函数的实现 Spreadsheet::~Spreadsheet() { for (size_t i = 0; i < mWidth; ++i) { delete[] mCells[i]; } delete[] mCells; mCells = nullptr; }
- 为析构函数隐式标记 noexcept,因为它们不应当抛出异常,析构函数隐式使用 noexcept,不必专门添加这个关键字
- 在析构函数中可以编写任何代码,但最好让析构函数只释放内存或者清理其他资源
4.3 处理复制和赋值
-
如果没有自行编写复制构造函数或赋值运算符,C++ 将自动生成:编译器生成的方法递归调用对象数据成员的复制构造函数或赋值运算符
- 然而对于基本类型,如 int、double 和指针,只是提供表层 (或按位) 复制或赋值:只是将数据成员从源对象直接复制或赋值到目标对象,当为对象动态分配内存时,这样做会引发问题
- 例如,在下面的代码中,当 s1 传递给函数 printSpreadsheet() 时,复制了电子表格 s1 以初始化 s
#include "SpreadsheetCell.h" void printSpreadsheet(Spreadsheet s) { // ... } int main() { Spreadsheet s1(4, 3); printSpreadsheet(s1); return 0; }
-
Spreadsheet 包含一个指针变量:mCells。Spreadsheet 的表层复制向目标对象提供了一个 mCells 指针的副本,但没有复制底层数据。最终结果是 s 和 s1 都有一个指向同一数据的指针,如下图所示
-
如果 s 修改了 mCells 所指的内容,这一改动也会在 s1 中表现出来。更糟糕的是,当函数 printSpreadsheet() 退出时,会调用 s 的析构函数,释放 mCells 所指内存,现在 s1 拥有的指针所指内存不再有效,称为悬挂指针
-
令人难以置信的是,当使用赋值时,情况会变得更糟。假定编写了下面的代码,在第一行之后,当构建两个对象时,内存的布局如下图
Spreadsheet s1(2, 2), s2(4, 3); s1 = s2;
-
当执行赋值语句后,内存的布局如下图
-
现在,不仅 s1 和 s2 中的 mCells 指向同一内存,而且 s1 前面所指的内存被遗弃,这称为内存泄漏,这就是在赋值运算符中进行深层复制的原因
-
可以看出,依赖 C++ 默认的复制构造函数或赋值运算符并不总是正确的
在类中动态分配内存后,应该编写自己的复制构造函数和赋值运算符,以提供深层的内存复制
-
Spreadsheet 类的复制构造函数
- 注意使用了委托构造函数。把这个复制构造函数的构造函数初始化器首先委托给非复制构造函数,以分配适当的内存量。复制构造函数体此后复制实际值,对 mCells 动态分配的二维数组进行了深层复制
class Spreadsheet { public: // 复制构造函数的声明 Spreadsheet(const Spreadsheet& src) : Spreadsheet(src.mWidth, src.mHeight) { // ... } }; // 复制构造函数的定义 Spreadsheet::Spreadsheet(const Spreadsheet &src) : Spreadsheet(src.mWidth, src.mHeight) { for (size_t i = 0; i < mWidth; ++i) { for (size_t j = 0; j < mHeight; ++j) { mCells[i][j] = src.mCells[i][j]; } } }
-
Spreadsheet 类的赋值运算符
- 通过三个阶段实现安全处理异常
- 第一个阶段创建一个临时副本。这不修改当前 Spreadsheet 对象的状态,因此,如果在这个阶段发生异常,不会出现问题
- 第二个阶段使用 swap() 函数,将创建的临时副本与当前对象交换。swap() 永远不会抛出异常
- 第三个阶段销毁临时对象 (由于发生了交换,现在包含原始对象) 以清理任何内存
class Spreadsheet { public: // 复制构造函数的声明 Spreadsheet& operator= (const Spreadsheet &rhs); friend void swap(Spreadsheet &first, Spreadsheet &second) noexcept; }; void swap(Spreadsheet &first, Spreadsheet &second) noexcept { // 使用 std::swap() 工具函数交换每个数据成员 using std::swap; swap(first.mWidth, second.mWidth); swap(first.mHeight, second.mHeight); swap(first.mCells, second.mCells); } Spreadsheet& Spreadsheet::operator= (const Spreadsheet &rhs) { // 检查自赋值 if (this == &rhs) { return *this; } // 对右边进行复制,称为 temp,然后用这个副本替代 *this Spreadsheet temp(rhs); swap(*this, temp); return *this; }
- 通过三个阶段实现安全处理异常
-
禁止赋值和按值传递
- 可以把复制构造函数和赋值运算符标记为 private,且不提供任何实现,从而禁用复制和赋值
4.4 使用移动语义处理移动
4.4.1 右值引用
- 在 C++中,左值是可获取其地址的一个量,例如一个有名称的变量。由于经常出现在赋值语句的左边,因此将其称作左值。另外,所有不是左值的量都是右值,例如字面量、临时对象或临时值,通常右值位于赋值运算符的右边
// 4 * 2 的结果为右值,是一个临时值,将在语句执行完毕时销毁 int a = 4 * 2;
- 右值引用是一个对右值的引用,这是一个当右值是临时对象时才适用的概念。右值引用的目的:在涉及临时对象时提供可选用的特定函数。通过右值引用,某些涉及复制大量值的操作可通过简单地复制指向这些值的指针来实现
- 函数可将 && 作为参数说明的一部分以指定右值引用参数。通常,临时对象被当作 const type&,但当函数重载使用了右值引用时,可以解析临时对象,用于该函数重载。下面的示例说明了这一点。代码首先定义了两个 handleMessage() 函数,一个接收左值引用,另一个接收右值引用
void handleMessage(std::string& message) { cout << "handleMessage with lvalue reference: " << message << endl; } void handleMessage(std::string&& message) { cout << "handleMessage with rvalue reference: " << message << endl; } std::string a = "Hello "; std::String b = "World"; handleMessage(a); // 调用左值引用 handleMessage(a + b); // a + b 的结果是临时的,故调用右值引用 handleMessage("Hello World"); // 字面量不是左值,故调用右值引用
- 右值引用参数 (string && message) 永远不会与左值 (b)绑定。但可使用 std:move() 将左值转换为右值,强迫编译器调用 handleMessage() 函数的右值引用版本
handleMessage(std::move(b));
- 有名称的变量是左值,在 handleMessage() 函数中,右值引用参数 message 本身是一个左值,原因是它具有名称。如果希望将这个左值引用参数,作为右值传递给另一个函数,则需要使用 std::move(),将左值转换为右值
- 例如,假设要添加以下函数,使用右值引用参数
void helper(std::string&& message) {} void handleMessage(std::string&& message) { // helper(message); // message 具有名称为左值,导致编译错误 helper(std::move(message)); }
4.4.2 实现移动语义
- 移动语义是通过右值引用实现的。为了对类增加移动语义,需要实现移动构造函数和移动赋值运算符。移动构造函数和移动赋值运算符应使用 noexcept 限定符标记,这告诉编译器,它们不会抛出任何异常
class Spreadsheet { public: Spreadsheet(Spreadsheet&& src) noexcept; // 移动构造函数声明 Spreadsheet& operator= (Spreadsheet&& rhs) noexcept; // 移动赋值声明 private: void cleanup() noexcept; void moveFrom(Spreadsheet& src) noexcept; };
// 在析构函数和移动赋值运算符中调用 void Spreadsheet::cleanup() noexcept { for (size_t i = 0; i < mWidth; ++i) { delete[] mCells[i]; } delete[] mCells; mCells = nullptr; mWidth = mHeight = 0; } // 把成员变量从源对象移动到目标对象,接着重置源对象 void Spreadsheet::moveFrom(Spreadsheet& src) noexcept { // 假设 Spreadsheet 类有一个名为 mName 的 std::string 数据成员 // 移动对象数据成员 mName = std::move(src.mName); // 浅复制对象 mWidth = src.mWidth; mHeight = src.mHeight; mCells = src.mCells; // Reset the source object, because ownership has been moved! src.mWidth = 0; src.mHeight = 0; src.mCells = nullptr; } // 移动构造函数定义 Spreadsheet::Spreadsheet(Spreadsheet&& src) noexcept { cout << "Move constructor" << endl; moveFrom(src); } // 移动赋值运算符定义 Spreadsheet& Spreadsheet::operator=(Spreadsheet&& rhs) noexcept { cout << "Move assignment operator" << endl; if (this == &rhs) { return *this; } cleanup(); moveFrom(rhs); return *this; }
如果类中动态分配了内存,则通常应当实现:析构函数、复制构造函数、移动构造函数、复制赋值运算符和移动赋值运算符,这称为 “5 规则”
4.4.3 使用移动语义实现交换函数
- 考虑交换两个对象的 swap() 函数,这是使用移动语义提高性能的示例。下面的 swapCopy() 实现没有使田移动语义
void swapCopy(T& a, T& b) { T temp(a); a = b; b = temp; }
- 上述实现首先将 a 复制到 temp,其次将 b 复制到 a,最后将 temp 复制到b。如果类 T 的复制开销很大,这个交换实现将严重影响性能。使用移动语义,swap() 函数可避免所有复制
void swapMove(T& a, T& b) {
T temp(std::move(a));
a = std::move(b);
b = std::move(temp);
}
4.5 零规则
- “零规则” 指出:在设计类时,应当使其不需要上述 5 个特殊成员函数。应当避免拥有任何旧式的、动态分配的内存。而改用现代结构,如标准库容器。例如,在 Spreadsheet 类中,用 vector<vector<SpreadsheetCelI>>替代 SpreadsheetCell** 数据成员。该 vector 自动处理内存,因此不需要上述 5 个特殊成员函数
5. 与方法有关的更多内容
5.1 静态方法
- 与数据成员类似,方法有时会应用于全部类对象而不是单个对象,此时可以像静态数据成员那样编写静态方法。以 SpreadsheetCell 类为例,这个类有两个辅助方法 stringToDouble() 和 doubleToString()。这两个方法没有访问特定对象的信息,因此可以是静态的
class SpreadsheetCell { private: static std::string doubleToString(double inValue); static double stringToDouble(std::string_view inString); };
- 在方法定义前不需要重复 static 关键字。静态方法不属于特定对象,因此没有 this 指针,当用某个特定对象调用静态方法时,静态方法不会访问这个对象的非静态数据成员
- 静态方法就像普通函数,唯一区别是静态方法可访问类的 private 和 protected 静态数据成员。若同一类型其他对象对静态方法可见 (例如传递了对象的指针或引用),那静态方法也可访问其他对象 private 和 protected 非静态数据成员
5.2 const 方法
- const(常量)对象的值不能改变。如果使用常量对象、常量对象的引用和指向常量对象的指针,编译器将不允许调用对象的任何方法,除非这些方法承诺不改变任何数据成员。为了保证方法不改变数据成员,可以用 const 关键字标记方法本身。下面的 SpreadsheetCell 类包含了用 const 标记的不改变任何数据成员的方法
class SpreadsheetCell { public: double getValue() const; // 最好将不改变对象的任何数据成员的成员函数声明为 const };
// const 规范是方法原型的一部分,必须放在方法的定义中 double SpreadsheetCell::getValue() const { return mValue; }
- 不能将静态方法声明为 const,因为静态方法没有类的实例,因此不可能改变内部的值
- 非 const 对象可调用 const 方法和非 const 方法。然而,const 对象只能调用 const 方法
- const 对象也会被销毁,它们的析构函数也会被调用,因此不应该将析构函数标记为 const
mutable 数据成员
- 假定电子表格应用程序要获取数据的读取频率,在 SpreadsheetCell 类中加入一个计数器,计算 getValue() 和 getString() 调用的次数。但这样做使编译器认为这些方法是非 const 的,解决方法是:将计数器变量设置为 mutable,告诉编译器在 const() 方法中允许改变这个值
class SpreadsheetCell { public: double getValue() const; // 最好将不改变对象的任何数据成员的成员函数声明为 const private: double mValue = 0; mutable size_t mNumAccesses = 0; };
// 定义 double SpreadsheetCell::getValue() const { mNumAccesses++; return mValue; }
5.3 方法重载
- 在类中可编写多个构造函数,所有这些构造函数的名称都相同,这些构造函数只是参数数量或类型不同。C++ 中,可对任何方法或函数做同样的事情,可重载函数或方法:将函数或方法的名称用于多个函数,但参数类型或数目不同
- C++ 不允许仅根据方法的返回类型重载方法名称,因为许多情况下,编译器不可能判断调用哪个方法实例
5.3.1 基于 const 的重载
- 可根据 const 来重载方法。编写两个名称相同、参数也相同的方法,其中一个是 const,另一个不是 const
- 如果是 const 对象,就调用 const 方法
- 如果是非 const 对象,就调用非 const 方法
5.3.2 显式删除重载
- 重载方法可被显式删除,可以用这种方法禁止调用具有特定参数的成员函数
class MyClass { public: void foo(int i); };
- 可用下面的方式调用
MyClass c; c.foo(123); c.foo(1.23);
- 在第三行,编译器将 double 值(1.23)转换为整型值(1),然后调用 foo(int i)。编译器可能会给出警告,但是仍然会执行这一隐式转换。显式删除 foo() 的 double 实例,可以禁止编译器执行这一转换
class MyClass { public: void foo(int i); void foo(double d) = delete; };
5.4 内联方法
- 函数或方法的调用不应在生成的代码中实现,就像调用独立的代码块那样。相反,编译器应将方法体或函数体直接插入到调用方法或函数的位置,这个过程称为内联(inline),具有这一行为的函数或方法称为内联方法或内联函数。内联比使用 #define 宏安全。可在方法或函数定义的名称之前使用 inline 关键字,将某个方法或函数指定为内联的
// 提示编译器,用实际方法体替换对 getValue() 调用,而不是生成代码进行函数调用 inline double SpreadsheetCell::getValue() const { mNumAccesses++; return mValue; }
- 如果编写了内联函数或内联方法,应该将定义和原型一起放在头文件
- C++ 提供了另一种声明内联方法的语法,这种语法根本不使用 inline 关键字,而是直接将方法定义放在类定义中
class SpreadsheetCell { public: double getValue() const { mNumAccesses++; return mValue; } };
5.5 默认参数
- 默认参数与方法重载类似,在原型中可为函数或方法的参数指定默认值
- 如果用户指定了这些参数,默认值会被忽略
- 如果用户忽略了这些参数,将会使用默认值
- 存在一个限制:只能从最右边的参数开始提供连续的默认参数列表,否则编译器将无法用默认参数匹配缺失的参数
class Spreadsheet { public: Spreadsheet(size_t width = 100; size_t height = 100); };
- 可用 0 个、1 个或 2 个参数调用 Spreadsheet 构造函数,尽管只有一个非复制构造函数
Spreadsheet s1; Spreadsheet s2(5); Spreadsheet s3(5, 6);
- 任何默认参数能做到的事情,都可以用方法重载做到。可编写 3 个不同的构造函数,每个都具有不同数量的参数。然而,默认参数允许在一个构造函数中使用三个不同数量的参数
6. 不同的数据成员类型
6.1 静态数据成员
- 有时让类的所有对象都包含某个变量的副本是没必要的。数据成员可能只对类有意义,而每个对象都拥有其副本是不合适的,静态数据成员解决了这个问题
- 静态数据成员属于类但不是对象的数据成员,可将静态数据成员当作类的全局变量
class Spreadsheet { private: static size_t sCounter; };
- 不仅要在类定义中列出 static 类成员,还需要在源文件中为其分配内存,通常是定义类方法的那个源文件。在此还可初始化静态成员,但注意与普通的变量和数据成员不同,默认情况下它们会初始化为 0。static 指针会初始化为 nullptr
// 为 sCounter 分配空间并初始化为 0 size_t Spreadsheet::sCounter; // 若采用下述内联变量则可删除该行代码
6.1.1 内联变量
- 从 C++17 开始,可将静态数据成员声明为 inline。这样做的好处是不必在源文件中为它们分配空间
class Spreadsheet { private: static inline size_t sCounter = 0; };
6.1.2 在类方法内访问静态数据成员
- 在类方法内部,可以像使用普通数据成员那样使用静态数据成员
class Spreadsheet { public: size_t getId() const; private: static size_t sCounter; size_t mId = 0; };
Spreadsheet::Spreadsheet(size_t width, size_t height) : mId(sCounter++), mWidth(width), mHeight(height) { mCells = new SpreadsheetCell*[mWidth]; for (size_t i = 0; i < mWidth; ++i) { mCells[i] = new SpreadsheetCell[mHeight]; } }
- 可以看出,构造函数可访问 sCounter,就像这是一个普通成员
6.1.3 在方法外访问静态数据成员
- 访问控制限定符适用于静态数据成员:sCounter 是私有的,因此不能在类方法之外访问。如果 sCounter 是公有的,就可在类方法外访问
- 如果要访问静态数据成员,应该实现静态的 get/set 方法
6.2 静态常量数据成员
- 类中的数据成员可声明为 const,意味着在创建并初始化后,数据成员的值不能再改变。如果某个常量只适用于类,应该使用静态常量 (static const 或 const static) 数据成员,而不是全局常量
class Spreadsheet { public: static const size_t kMaxHeight = 100; static const size_t kMaxWidth = 100; };
6.3 引用数据成员
- 下面是新的使用了前置声明的 Spreadsheet 类定义,用来通知编译器关于 SpreadsheetApplication 类的信息
class SpreadsheetApplication; class Spreadsheet { public: Spreadsheet(size_t width, size_t height, SpreadsheetApplication& theApp); private: SpreadsheetApplication& mTheApp; };
6.4 常量引用数据成员
- 就像普通引用可引用常量对象一样,引用成员也可引用常量对象。例如,为让 Spreadsheet 只包含应用程序对象的常量引用,只需要在类定义中将 mTheApp 声明为常量引用
- 常量引用 SpreadsheetApplication 数据成员只能用于调用 SpreadsheetApplication 对象上的常量方法
class Spreadsheet { public: Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp); private: const SpreadsheetApplication& mTheApp; };
7. 嵌套类
- 可在类的定义中提供另一个类定义。例如,假定 SpreadsheetCell 类实际上是 Spreadsheet 类的一部分,因此不妨将 SpreadsheetCell 重命名为 Cell,可将二者定义为
class Spreadsheet { public: class Cell { public: Cell() = default; Cell(double initialValue); }; Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp) };
- 现在 Cell 类定义位于 Spreadsheet 类内部,因此在 Spreadsheet 类外引用 Cell 必须用 Spreadsheet:: 作用域限定名称,即使在方法定义时也是如此
Spreadsheet::Cell::Cell(double initialValue) : mValue(initialValue) {}
- 如果在 Spreadsheet 类中直接完整定义嵌套的 Cell 类,将使 Spreadsheet 类的定义略显臃肿。为解决这个问题,只需要在 Spreadsheet 中为 Cell 添加前置声明,然后独立地定义 Cell 类
class Spreadsheet { public: class Cell; Spreadsheet(size_t width, size_t height, const SpreadsheetApplication& theApp) }; class Spreadsheet::Cell { public: Cell() = default; Cell(double initialValue); };
嵌套的类有权访问外围类中的所有 private 或 protected 成员,而外围类却只能访问嵌套类中的 public 成员
8. 创建稳定的接口
- 在 C++ 中,类是主要的抽象单元,应将抽象原则应用到类,尽可能分离接口和实现。确切地讲,应该将所有数据成员设置为 private,并提供相应的 getter 和 setter 方法。这就是 SpreadsheetCell 类的实现方式:
- 将 mVaue 设置为 private
- setValue() 和 getValue() 用于设置或获取这些值
8.1 使用接口类和实现类
- C++ 语法要求将 public 接口和 private (或 protected) 数据成员及方法放在一个类定义中,从而将类的某些内部实现细节向客户公开。这种做法的缺点在于:如果不得不在类中加入新的非公有方法或数据成员,所有的客户代码都必须重新编译,对于较大项目而言这是负担
- 解决方案:可创建清晰的接口,并隐藏所有实现细节,从而得到稳定的接口,基本原则是为想编写的每个类都定义两个类:接口类和实现类
- 实现类:与已编写的类相同
- 接口类:给出了与实现类一样的 public 方法,但只有一个数据成员:指向实现类对象的一个指针。这称为 bridge 模式,接口类方法的实现只是调用实现类对象的等价方法。这样做的结果是无论实现如何改变,都不会影响 public 接口类,从而降低了重新编译的必要性。当实现改变(只有实现改变)时,使用接口类的客户不需要重新编译。如果它是按值传递的数据成员,在实现类的定义改变时,客户代码必须重新编译