熟悉类和对象
作为一门面向对象语言,C++ 提供了使用对象以及编写对象蓝图(即类(classes))的能力。你当然可以在 C++ 中不使用类和对象来编写程序,但那样就等于没有利用这门语言最根本、也最有价值的能力;不用类来写 C++ 程序,就像去了巴黎却只吃麦当劳。要想高效使用类和对象,你必须理解它们的语法与能力。
第 1 章“C++ 与标准库速成”回顾了类定义的基础语法。第 5 章“用类进行设计”介绍了 C++ 中面向对象的编程方法,并给出了类与对象设计的具体策略。本章将讲解使用类和对象所涉及的基本概念,包括编写类定义、定义成员函数、在栈和自由存储区中使用对象、编写构造函数、默认构造函数、编译器生成的构造函数、构造函数初始化器(也叫 ctor-initializer),拷贝构造函数、initializer-list 构造函数、析构函数以及赋值运算符。即使你已经对类和对象很熟悉,也应该快速浏览一下本章,因为其中包含了一些你也许还不熟悉的小知识点。
引入电子表格示例
Section titled “引入电子表格示例”本章和下一章都会给出一个可以运行的简单电子表格应用示例。电子表格是由“单元格”组成的二维网格,每个单元格包含一个数字或一个字符串。像 Microsoft Excel 这样的专业电子表格软件,还能执行数学运算,例如计算一组单元格值的总和。本书中的电子表格示例当然无意在市场上挑战 Microsoft,但它非常适合用来说明类和对象相关的问题。
这个电子表格应用使用两个基础类:Spreadsheet 和 SpreadsheetCell。每个 Spreadsheet 对象都包含若干 SpreadsheetCell 对象。此外,还有一个 SpreadsheetApplication 类用来管理一组 Spreadsheet。本章重点讲解 SpreadsheetCell 类。第 9 章“精通类和对象”会继续构建 Spreadsheet 与 SpreadsheetApplication 类。
当你编写一个类时,你需要指定将作用于该类对象的行为,也就是成员函数(member functions);同时还要指定每个对象所包含的属性,也就是数据成员(data members)。
编写类的过程包含两个部分:定义类本身,以及定义类的成员函数。
下面是在 spreadsheet_cell 模块中,一个简单的 SpreadsheetCell 类的首次尝试,其中每个单元格只能存储一个数字:
export module spreadsheet_cell;
export class SpreadsheetCell{ public: void setValue(double value); double getValue() const; private: double m_value;};正如 第 1 章 所述,第一行说明这里定义了一个名为 spreadsheet_cell 的模块。每个类定义都以关键字 class 开头,后跟类名。如果该类定义在某个模块中,并且必须对导入该模块的客户端可见,那么 class 关键字前面就要加上 export。类定义属于一种声明(declaration),并且以分号结束。
类定义通常放在一个以类名命名的文件中。例如,SpreadsheetCell 类定义会放进名为 SpreadsheetCell.cppm 的文件。有些编译器要求必须使用特定扩展名,另一些则允许你自由选择扩展名。
一个类可以拥有多个成员(members)。成员可以是成员函数(member function)(而成员函数本身又可以是普通函数、构造函数或析构函数),也可以是成员变量(member variable,也叫数据成员),还可以是成员枚举、类型别名、嵌套类等等。
那两行看起来像函数原型的代码,声明了这个类支持的成员函数:
void setValue(double value);double getValue() const;第 1 章 指出,凡是不会修改对象的成员函数,最好总是声明为 const,例如这里的 getValue()。
而那行看起来像变量声明的代码,则声明了这个类的数据成员:
double m_value;类定义了哪些成员函数和数据成员会生效。它们只适用于该类的某个具体实例(instance),也就是对象(object)。这一规则唯一的例外是静态成员,它将在 第 9 章 中解释。类定义的是概念;对象则承载实际的比特数据。因此,每个对象都会拥有自己那份 m_value 数据成员的值。而成员函数的实现则由所有对象共享。类中可以包含任意数量的成员函数和数据成员。不过,你不能让某个数据成员与某个成员函数同名。
类中的每个成员都必须受到三种访问说明符(access specifiers)之一的约束:public、private 或 protected。protected 会在 第 10 章“理解继承技术”中结合继承来解释。一个访问说明符会作用于它后面的所有成员声明,直到遇到下一个访问说明符为止。在 SpreadsheetCell 类中,成员函数 setValue() 与 getValue() 具有 public 访问权限,而数据成员 m_value 具有 private 访问权限。
类的默认访问说明符是 private:也就是说,在遇到第一个访问说明符之前,所有成员声明默认都具备 private 访问权限。例如,如果把 public 访问说明符移到 setValue() 成员函数声明之后,那么 setValue() 就不再是 public,而会变成 private:
export class SpreadsheetCell{ void setValue(double value); // now has private access public: double getValue() const; private: double m_value;};在 C++ 中,struct 和 class 一样,同样可以拥有成员函数。事实上,它们只有一个区别:对于 struct,默认访问说明符是 public;而对于 class,默认访问说明符是 private。
例如,SpreadsheetCell 类也可以改写成一个 struct,如下:
export struct SpreadsheetCell{ void setValue(double value); double getValue() const; private: double m_value;};不过,这么写并不符合惯例。通常只有在你只需要一组可公开访问的数据成员,而不需要成员函数时,才会使用 struct。下面就是这样一个简单 struct 的例子,它用于存储二维点坐标:
export struct Point{ double x; double y;};你可以按任意顺序声明成员和访问控制说明符:C++ 并不强加任何限制,不会要求“成员函数必须在数据成员前面”或者“public 必须在 private 前面”。此外,访问说明符也可以重复出现。例如,SpreadsheetCell 的定义完全可以写成:
export class SpreadsheetCell{ public: void setValue(double value); private: double m_value; public: double getValue() const;};不过,为了清晰起见,最好还是按访问说明符对声明进行分组,并在每个访问级别内部再分别整理成员函数和数据成员。
类内成员初始化器
Section titled “类内成员初始化器”数据成员可以直接在类定义中初始化。例如,SpreadsheetCell 类可以通过下面这种方式,直接在类定义中把 m_value 默认初始化为 0:
export class SpreadsheetCell{ // Remainder of the class definition omitted for brevity private: double m_value { 0 };};定义成员函数
Section titled “定义成员函数”前面那份 SpreadsheetCell 类定义已经足以让你创建该类对象了。不过,如果你尝试调用 setValue() 或 getValue() 成员函数,链接器就会抱怨这些成员函数没有定义。这是因为到目前为止,这些成员函数只有原型,还没有真正的实现。通常,类定义会放在模块接口文件中。至于成员函数定义,你可以有两种选择:放在模块接口文件中,或者放在模块实现文件(module implementation file)中。
下面是把成员函数实现直接写在类体中的 SpreadsheetCell:
export module spreadsheet_cell;
export class SpreadsheetCell{ public: void setValue(double value) { m_value = value; } double getValue() const { return m_value; } private: double m_value { 0 };};与头文件不同,对于 C++ 模块而言,把成员函数定义直接放进模块接口文件并没有坏处。这一点会在 第 11 章“模块、头文件与杂项主题”里进一步讨论。不过,本书通常会把成员函数定义放在模块实现文件中,以便让模块接口文件保持简洁,不掺杂实现细节。
模块实现文件的第一行要说明这些实现是为哪个模块服务的。下面是 spreadsheet_cell 模块中 SpreadsheetCell 两个成员函数的定义:
module spreadsheet_cell;
void SpreadsheetCell::setValue(double value){ m_value = value;}
double SpreadsheetCell::getValue() const{ return m_value;}注意,每个成员函数名前面都带着“类名 + 两个冒号”:
void SpreadsheetCell::setValue(double value)这里的 :: 叫做作用域解析运算符(scope resolution operator)。在这个上下文中,它告诉编译器:接下来要定义的 setValue() 成员函数属于 SpreadsheetCell 类。还要注意的是,在定义成员函数时,你不需要再次写访问说明符。
访问数据成员
Section titled “访问数据成员”类的非静态成员函数,例如 setValue() 和 getValue(),总是代表某个具体对象执行的。在成员函数体内部,你可以访问该对象上属于这个类的全部数据成员。在前面 setValue() 的定义中,下面这一行代码会修改调用该成员函数的那个对象内部的 m_value 变量:
m_value = value;如果针对两个不同对象调用 setValue(),那么这一行代码(在每个对象上各执行一次)就会修改两个不同对象中的对应变量。
调用其他成员函数
Section titled “调用其他成员函数”你可以在某个成员函数内部调用同一个类的其他成员函数。举例来说,考虑对 SpreadsheetCell 类做一个扩展,让它既可以把单元格值作为字符串来设置和读取,也可以作为数字来设置和读取。当你试图用字符串去设置单元格值时,单元格会尝试把该字符串转换成数字。如果字符串不能表示有效数字,就忽略它。在本程序中,凡是不是数字的字符串,都会让单元格的数值变为 0。下面是对这种 SpreadsheetCell 类定义的第一次尝试:
export module spreadsheet_cell;import std;export class SpreadsheetCell{ public: void setValue(double value); double getValue() const;
void setString(std::string_view value); std::string getString() const; private: std::string doubleToString(double value) const; double stringToDouble(std::string_view value) const; double m_value { 0 };};这一版类只用 double 来存储数据。如果客户端把数据作为 string 设置,它就会先被转换成 double。若文本不是一个有效数字,则 double 值会被设为 0。类定义中新增了两个成员函数,用于以文本形式设置和读取单元格内容;同时还新增了两个 private 的辅助成员函数(helper member functions),用于在 double 与 string 之间互相转换。下面是这些成员函数的完整实现:
module spreadsheet_cell;import std;using namespace std;
void SpreadsheetCell::setValue(double value){ m_value = value;}
double SpreadsheetCell::getValue() const{ return m_value;}
void SpreadsheetCell::setString(string_view value){ m_value = stringToDouble(value);}
string SpreadsheetCell::getString() const{ return doubleToString(m_value);}
string SpreadsheetCell::doubleToString(double value) const{ return to_string(value);}
double SpreadsheetCell::stringToDouble(string_view value) const{ double number { 0 }; from_chars(value.data(), value.data() + value.size(), number); return number;}std::to_string() 与 from_chars() 会在 第 2 章“使用字符串与字符串视图”中解释。
注意,以这种方式实现 doubleToString() 成员函数时,例如 6.1 这样的值会被转换成 6.100000。不过,因为它是一个 private 辅助成员函数,所以你完全可以自由修改其实现,而不必改动任何客户端代码。
前面的类定义说明了:一个 SpreadsheetCell 包含一个数据成员、四个 public 成员函数以及两个 private 成员函数。不过,类定义本身并不会真正创建任何 SpreadsheetCell;它只是规定了对象的形状和行为。从这个意义上说,类就像建筑蓝图。蓝图规定了房子应该长什么样,但画出蓝图并不会自动造出房子。房子必须之后依据蓝图再去建造。
同样地,在 C++ 中,你可以通过声明一个 SpreadsheetCell 类型的变量,根据 SpreadsheetCell 的类定义构造出一个 SpreadsheetCell 对象(object)。正如建筑师可以根据同一份蓝图建造不止一栋房子,程序员也可以根据同一个 SpreadsheetCell 类创建不止一个对象。创建和使用对象有两种方式:在栈上,以及在自由存储区上。
下面是一些在栈上创建并使用 SpreadsheetCell 对象的代码:
SpreadsheetCell myCell, anotherCell;myCell.setValue(6);anotherCell.setString("3.2");println("cell 1: {}", myCell.getValue());println("cell 2: {}", anotherCell.getValue());创建对象的方式和声明普通变量很像,区别只在于变量类型是类名。像 myCell.setValue(6); 这样的语句里,. 被称为点运算符(dot operator),也叫成员访问运算符(member access operator);它允许你在对象上调用 public 成员函数。如果对象中存在 public 数据成员,你也可以通过点运算符访问它们。不过请记住,通常不推荐 public 数据成员。
程序输出如下:
cell 1: 6cell 2: 3.2自由存储区上的对象
Section titled “自由存储区上的对象”你也可以通过 new 来动态分配对象:
SpreadsheetCell* myCellp { new SpreadsheetCell { } };myCellp->setValue(3.7);println("cell 1: {} {}", myCellp->getValue(), myCellp->getString());delete myCellp;myCellp = nullptr;当你在自由存储区上创建对象时,要通过“箭头运算符”-> 来访问其成员。箭头运算符把解引用(*)与成员访问(.)合并在一起。你当然也可以把这两个运算符拆开来写,但那样在风格上显得很别扭:
SpreadsheetCell* myCellp { new SpreadsheetCell { } };(*myCellp).setValue(3.7);println("cell 1: {} {}", (*myCellp).getValue(), (*myCellp).getString());delete myCellp;myCellp = nullptr;就像你必须释放其他在自由存储区中分配的内存一样,你也必须对在自由存储区中分配的对象调用 delete,以释放它们的内存——前面的代码片段就是这样做的! 为了保证安全、避免内存问题,你真的应该使用智能指针,如下例所示:
auto myCellp { make_unique<SpreadsheetCell>() };// Equivalent to:// unique_ptr<SpreadsheetCell> myCellp { new SpreadsheetCell { } };myCellp->setValue(3.7);println("cell 1: {} {}", myCellp->getValue(), myCellp->getString());使用智能指针时,你无需手动释放内存;它会自动发生。
当你用 new 分配对象后,在使用完成时要用 delete 释放它;更好的做法是使用智能指针来自动管理内存!
this 指针
Section titled “this 指针”每一次普通成员函数调用,都会隐式传入一个“隐藏”参数,它是指向该成员函数所作用对象的指针,名字叫做 this。你可以用这个指针访问数据成员或调用成员函数,也可以把它传给其他成员函数或普通函数。有时它还适合用来消除名称歧义。比如,假设你在 SpreadsheetCell 类中使用的数据成员名不是 m_value,而是 value。那么 setValue() 就会像下面这样:
void SpreadsheetCell::setValue(double value){ value = value; // Confusing!}这一行很容易让人困惑。这里的 value 到底指的是作为参数传进来的那个 value,还是对象自身的成员 value?
为了区分这两个名字,你可以使用 this 指针:
void SpreadsheetCell::setValue(double value){ this->value = value;}不过,如果你遵循 第 3 章“编写有风格的代码”介绍的命名约定,就永远不会遇到这种名称冲突。
你也可以在对象的成员函数内部,利用 this 指针去调用某个接受“指向对象的指针”作为参数的函数。例如,假设你写了一个独立的 printCell() 函数(不是成员函数),如下:
void printCell(const SpreadsheetCell& cell){ println("{}", cell.getString());}如果你想在 setValue() 成员函数内部调用 printCell(),那么就必须把 *this 作为参数传给它,这样 printCell() 才能拿到当前这个 SpreadsheetCell 的引用,即 setValue() 正在操作的那个对象:
void SpreadsheetCell::setValue(double value){ this->value = value; printCell(*this);} 显式对象参数
Section titled “ 显式对象参数”从 C++23 开始,你不必再完全依赖编译器提供隐式的 this 参数,而是可以使用显式对象参数(explicit object parameter),它通常是某种引用类型。下面这段代码演示了如何使用显式对象参数,重写上一节中的 SpreadsheetCell::setValue():
void SpreadsheetCell::setValue(this SpreadsheetCell& self, double value){ self.m_value = value; printCell(self);}现在,setValue() 的第一个参数变成了显式对象参数,通常命名为 self,当然你也可以使用任意你喜欢的名字。self 的类型前面要加上 this 关键字。这个显式对象参数必须是成员函数的第一个参数。一旦使用了显式对象参数,这个函数就不再拥有隐式定义的 this;因此在 setValue() 的函数体中,你必须显式使用 self 来访问任何属于 SpreadsheetCell 的内容。
调用使用显式对象参数的成员函数时,与调用使用隐式 this 参数的成员函数并无差别。即便 setValue() 现在形式上声明了两个参数 self 和 value,你在调用时依旧只需要传一个参数——也就是你想设置的那个 value:
SpreadsheetCell myCell;myCell.setValue(6);像本节这样使用显式对象参数,其实没有任何收益,甚至还让代码更啰唆。不过,它在以下几种场景中很有用:
- 为带引用限定符的成员函数提供更显式的写法,这一点会在 第 9 章 讨论。
- 用于成员函数模板,且显式对象参数的类型本身是模板类型参数。这对避免
const和非const成员函数重载中的重复代码很有帮助,会在 第 12 章“使用模板编写泛型代码”中讲解。 - 编写递归 lambda 表达式,这一点会在 第 19 章“函数指针、函数对象与 Lambda 表达式”中说明。
理解对象生命周期
Section titled “理解对象生命周期”对象生命周期包含三个活动:创建(creation)、销毁(destruction)与赋值(assignment)。理解对象是在何时被创建、销毁和赋值的,以及你如何自定义这些行为,是非常重要的。
对象会在你声明它们时被创建(如果它们位于栈上),或者在你用智能指针、new 或 new[] 显式分配空间时被创建。当一个对象被创建时,它内部嵌入的所有对象也都会被创建。下面是一个例子:
import std;
class MyClass{ private: std::string m_name;};
int main(){ MyClass obj;}其中嵌入的 string 对象会在 main() 中 MyClass 对象创建时一并创建,并在其外层对象被销毁时一并销毁。
通常,在声明变量时就给它初始值是很有帮助的,例如:
int x { 0 };同样地,你也可以在对象创建时给对象提供初始值。要做到这一点,可以声明并编写一个特殊成员函数,即构造函数(constructor),你可以在其中执行对象初始化所需的工作。每当一个对象被创建时,它的某个构造函数就会被执行。
编写构造函数
Section titled “编写构造函数”从语法上说,构造函数的名字必须与类名相同。构造函数从不写返回类型,并且可以有参数,也可以没有参数。能够在不传任何参数时调用的构造函数叫做默认构造函数(default constructor)。它既可以是“完全没有参数的构造函数”,也可以是“所有参数都有默认值的构造函数”。在某些上下文中,你可能必须提供默认构造函数;如果没有提供,编译器就会报错。默认构造函数会在本章后面进一步讨论。
下面是给 SpreadsheetCell 类添加构造函数的第一次尝试:
export class SpreadsheetCell{ public: SpreadsheetCell(double initialValue); // Remainder of the class definition omitted for brevity};就像普通成员函数一样,构造函数也必须提供实现:
SpreadsheetCell::SpreadsheetCell(double initialValue){ setValue(initialValue);}SpreadsheetCell 构造函数是 SpreadsheetCell 类的一个成员,所以在构造函数名前仍然必须加上正常的 SpreadsheetCell:: 作用域解析。构造函数本身的名字也叫 SpreadsheetCell,于是最终就形成了看起来有点滑稽的 SpreadsheetCell::SpreadsheetCell。这里的实现只是简单调用了 setValue()。
使用构造函数
Section titled “使用构造函数”使用构造函数可以在创建对象的同时初始化其值。无论是栈上对象,还是自由存储区上的对象,都可以使用构造函数。
栈上对象的构造函数
Section titled “栈上对象的构造函数”当你在栈上分配 SpreadsheetCell 对象时,可以这样使用构造函数:
SpreadsheetCell myCell(5), anotherCell(4);println("cell 1: {}", myCell.getValue());println("cell 2: {}", anotherCell.getValue());或者,你也可以使用统一初始化语法:
SpreadsheetCell myCell { 5 }, anotherCell { 4 };要注意,你并不会显式去调用 SpreadsheetCell 构造函数。例如,绝对不要写成下面这样:
SpreadsheetCell myCell.SpreadsheetCell(5); // WILL NOT COMPILE!同样地,你也不能在对象创建之后再去调用构造函数。下面这种写法也是错误的:
SpreadsheetCell myCell;myCell.SpreadsheetCell(5); // WILL NOT COMPILE!自由存储区上对象的构造函数
Section titled “自由存储区上对象的构造函数”当你动态分配 SpreadsheetCell 对象时,构造函数的使用方式如下:
auto smartCellp { make_unique<SpreadsheetCell>(4) };// … do something with the cell, no need to delete the smart pointer
// Or with raw pointers, without smart pointers (not recommended)SpreadsheetCell* myCellp { new SpreadsheetCell { 5 } };// Or// SpreadsheetCell* myCellp{ new SpreadsheetCell(5) };SpreadsheetCell* anotherCellp { nullptr };anotherCellp = new SpreadsheetCell { 4 };// … do something with the cellsdelete myCellp; myCellp = nullptr;delete anotherCellp; anotherCellp = nullptr;注意,你可以先声明一个指向 SpreadsheetCell 对象的指针,而不必立刻调用构造函数。这一点和栈上对象不同:栈上对象会在声明点立即调用构造函数。
记得总是初始化指针,要么让它指向正确对象,要么初始化为 nullptr。
提供多个构造函数
Section titled “提供多个构造函数”一个类中可以提供不止一个构造函数。所有构造函数都拥有相同的名字(也就是类名),但不同构造函数必须接受不同数量的参数或不同类型的参数。在 C++ 中,如果你拥有多个同名函数,编译器会选择参数类型与调用点最匹配的那个版本。这叫做重载(overloading),会在 第 9 章 中详细讨论。
对于 SpreadsheetCell 类而言,提供两个构造函数很有帮助:一个接收初始 double 值,另一个接收初始字符串值。新的类定义如下:
export class SpreadsheetCell{ public: SpreadsheetCell(double initialValue); SpreadsheetCell(std::string_view initialValue); // Remainder of the class definition omitted for brevity};第二个构造函数的实现如下:
SpreadsheetCell::SpreadsheetCell(string_view initialValue){ setString(initialValue);}下面这段代码会使用这两个不同的构造函数:
SpreadsheetCell aThirdCell { "test" }; // Uses string-arg ctorSpreadsheetCell aFourthCell { 4.4 }; // Uses double-arg ctorauto aFifthCellp { make_unique<SpreadsheetCell>("5.5") }; // string-arg ctorprintln("aThirdCell: {}", aThirdCell.getValue());println("aFourthCell: {}", aFourthCell.getValue());println("aFifthCellp: {}", aFifthCellp->getValue());当一个类里有多个构造函数时,你可能会很自然地想用一个构造函数去实现另一个。例如,你也许会想在字符串构造函数中这样调用 double 构造函数:
SpreadsheetCell::SpreadsheetCell(string_view initialValue){ SpreadsheetCell(stringToDouble(initialValue));}这看起来似乎很合理。毕竟,你本来就可以在一个成员函数内部调用另一个普通成员函数。这段代码可以成功编译、链接并运行,但它并不会做你期望的事情! 这里对 SpreadsheetCell 构造函数的显式调用,实际上会创建一个新的、匿名的临时 SpreadsheetCell 对象。它并不会去初始化当前这个本应被构造的对象。
不过,事情也没有那么糟。C++ 的确支持委托构造函数(delegating constructors)。它允许你在 ctor-initializer 中调用同一个类的其他构造函数。不过这部分内容要等到本章稍后介绍完 ctor-initializer 之后再讲。
默认构造函数
Section titled “默认构造函数”默认构造函数(default constructor) 是指不需要任何参数的构造函数。它也被称为零参数构造函数(zero-argument constructor)。
什么时候需要默认构造函数
Section titled “什么时候需要默认构造函数”考虑对象数组。创建对象数组时,实际上会完成两件事:为所有对象分配一块连续的内存空间,并对每个对象调用默认构造函数。C++ 并没有提供任何语法让你告诉数组创建代码“请改调用另一个构造函数”。例如,如果你没有给 SpreadsheetCell 类定义默认构造函数,下面的代码就无法编译:
SpreadsheetCell cells[3]; // FAILS compilation without default constructorSpreadsheetCell* myCellp { new SpreadsheetCell[10] }; // Also FAILS你可以借助初始化器(initializers) 绕开这个限制,例如这样:
SpreadsheetCell cells[3] { SpreadsheetCell { 0 }, SpreadsheetCell { 23 }, SpreadsheetCell { 41 } };不过,如果你打算创建某个类对象的数组,通常更简单的做法还是确保该类本身拥有默认构造函数。如果你根本没有定义任何自己的构造函数,编译器会自动为你生成一个默认构造函数。这种编译器生成的构造函数会在后面进一步讨论。
如何编写默认构造函数
Section titled “如何编写默认构造函数”下面是带有默认构造函数的 SpreadsheetCell 类定义的一部分:
export class SpreadsheetCell{ public: SpreadsheetCell(); // Remainder of the class definition omitted for brevity};默认构造函数的第一版实现如下:
SpreadsheetCell::SpreadsheetCell(){ m_value = 0;}如果你已经为 m_value 使用了类内成员初始化器,那么这个默认构造函数体内的唯一语句就可以省掉:
SpreadsheetCell::SpreadsheetCell(){}在栈上使用默认构造函数时,可以这样写:
SpreadsheetCell myCell;myCell.setValue(6);println("cell 1: {}", myCell.getValue());前面的代码创建了一个名为 myCell 的新 SpreadsheetCell,为它设置值,并输出该值。与栈上其他构造函数的调用方式不同,你不应该使用函数调用语法来调用默认构造函数。基于其他构造函数的用法,你也许会想这样写:
SpreadsheetCell myCell(); // WRONG, but will compile.myCell.setValue(6); // However, this line will not compile.println("cell 1: {}", myCell.getValue());不幸的是,那句试图调用默认构造函数的代码居然能编译通过。而它后面那一行则无法编译。这个问题通常被称为最烦人的解析(most vexing parse),意思是编译器会把第一行理解成一个函数声明:声明了一个名为 myCell 的函数,它不接受任何参数并返回一个 SpreadsheetCell 对象。当它读到第二行时,就会认为你正在把一个函数名当对象来用!
当然,除了使用函数调用风格的圆括号之外,你还可以使用统一初始化语法,如下:
SpreadsheetCell myCell { }; // Calls the default constructor.当你在栈上使用默认构造函数创建对象时,要么使用统一初始化语法的花括号,要么就完全省略圆括号。
对于自由存储区上的对象分配,默认构造函数可以这样使用:
auto smartCellp { make_unique<SpreadsheetCell>() };// Or with a raw pointer (not recommended)SpreadsheetCell* myCellp { new SpreadsheetCell { } };// Or// SpreadsheetCell* myCellp { new SpreadsheetCell };// Or// SpreadsheetCell* myCellp { new SpreadsheetCell() };// … use myCellpdelete myCellp; myCellp = nullptr;编译器生成的默认构造函数
Section titled “编译器生成的默认构造函数”本章最开始的 SpreadsheetCell 类定义是这样的:
export class SpreadsheetCell{ public: void setValue(double value); double getValue() const; private: double m_value;};这份定义没有显式声明默认构造函数,但下面这段代码依然可以正常工作:
SpreadsheetCell myCell;myCell.setValue(6);下面这个定义和前一个几乎一样,只是额外添加了一个接收 double 的显式构造函数。它依然没有显式声明默认构造函数。
export class SpreadsheetCell{ public: SpreadsheetCell(double initialValue); // No default constructor // Remainder of the class definition omitted for brevity};在这种定义下,下面的代码就不再能编译了:
SpreadsheetCell myCell;myCell.setValue(6);这里到底发生了什么? 原因在于:如果你完全没有声明任何构造函数,编译器就会自动为你生成一个不接受任何参数的构造函数。这个编译器生成的默认构造函数(compiler-generated default constructor)会对类中所有对象类型的数据成员调用默认构造函数,但不会初始化 int、double 这样的语言内建基本类型。即便如此,它依然使你能够创建该类对象。然而,只要你自己声明了任意一个构造函数,编译器就不再自动为你生成默认构造函数。
显式默认化的默认构造函数
Section titled “显式默认化的默认构造函数”在 C++11 之前,如果你的类需要若干个接收参数的显式构造函数,但同时又需要一个什么都不做的默认构造函数,你仍然必须像前面那样自己手写一个空的默认构造函数。
为了避免手动编写这些空默认构造函数,C++ 支持了显式默认化默认构造函数(explicitly defaulted default constructors)这一概念。这样一来,你就可以把类定义写成如下形式,而无需再给默认构造函数提供一个空实现:
export class SpreadsheetCell{ public: SpreadsheetCell() = default; SpreadsheetCell(double initialValue); SpreadsheetCell(std::string_view initialValue); // Remainder of the class definition omitted for brevity};SpreadsheetCell 定义了两个自定义构造函数。不过,因为你用 default 关键字显式默认化了默认构造函数,所以编译器仍然会生成一个标准的编译器默认构造函数。你既可以把 = default 直接写在类定义中,也可以放到实现文件里。
显式删除的默认构造函数
Section titled “显式删除的默认构造函数”与“显式默认化默认构造函数”相反的做法也存在,它被称为显式删除的默认构造函数(explicitly deleted default constructors)。例如,你可能会定义一个只有静态成员函数的类(参见 第 9 章),在这种类中你既不想编写任何构造函数,也不希望编译器自动生成默认构造函数。此时,你就需要显式删除默认构造函数。
export class MyClass{ public: MyClass() = delete;};构造函数初始化器,也叫 ctor-initializer
Section titled “构造函数初始化器,也叫 ctor-initializer”到目前为止,本章一直在构造函数体中初始化数据成员,比如下面这个例子:
SpreadsheetCell::SpreadsheetCell(double initialValue){ setValue(initialValue);}C++ 还提供了另一种在构造函数中初始化数据成员的方法,叫做构造函数初始化器(constructor initializer),也常称为 ctor-initializer 或 member initializer list。下面是同一个 SpreadsheetCell 构造函数,使用 ctor-initializer 语法改写后的版本:
SpreadsheetCell::SpreadsheetCell(double initialValue) : m_value { initialValue }{}如你所见,ctor-initializer 在语法上位于构造函数参数列表和构造函数体起始花括号之间。整个列表以冒号开始,各项之间用逗号分隔。列表中的每一项都可以是:用函数式记法或统一初始化语法对某个数据成员进行初始化、调用某个基类构造函数(见 第 10 章),或者调用某个委托构造函数(稍后本章会讲到)。
使用 ctor-initializer 初始化数据成员,其行为与“在构造函数体内再做初始化”是不同的。当 C++ 创建一个对象时,它必须先创建该对象的全部数据成员,然后才会调用构造函数。而在创建这些数据成员的过程中,如果某个数据成员本身也是对象,就必须先对它调用构造函数。等到你在构造函数体里给某个对象赋值时,你其实已经不是在“构造”这个对象了,而只是修改它的值。ctor-initializer 允许你在数据成员被创建的那一刻就为它们提供初值,这比之后再赋值更高效。
如果你的类里有某个数据成员本身是一个拥有默认构造函数的类对象,那么你就不必在 ctor-initializer 中显式初始化它。例如,如果你的数据成员是一个 std::string,它的默认构造函数会把字符串初始化为空串,因此在 ctor-initializer 中再显式把它设成 "" 就是多余的。
另一方面,如果你的类中有某个数据成员是“没有默认构造函数的类对象”,那你就必须使用 ctor-initializer 来正确构造它。例如,考虑下面这个 SpreadsheetCell 类:
export class SpreadsheetCell{ public: SpreadsheetCell(double d);};这个类只有一个接收 double 的构造函数,并没有默认构造函数。你可以像下面这样,把它作为另一个类的数据成员:
class SomeClass{ public: SomeClass(); private: SpreadsheetCell m_cell;};你可以把 SomeClass 的构造函数实现成下面这样:
SomeClass::SomeClass() { }然而,在这个实现下,代码无法编译。编译器不知道该如何初始化 SomeClass 的 m_cell 数据成员,因为它没有默认构造函数。
你必须像下面这样,在 ctor-initializer 中初始化 m_cell 数据成员:
SomeClass::SomeClass() : m_cell { 1.0 } { }有些程序员更喜欢在构造函数体里赋初值,尽管那样可能效率稍低。不过,有几类数据必须通过 ctor-initializer 或类内初始化器来初始化。下表总结了这些情况:
| 数据类型 | 说明 |
|---|---|
const 数据成员 | const 变量一旦创建后,你就不能再合法地给它赋值。它的值必须在创建时就提供。 |
| 引用数据成员 | 引用不能脱离被引用对象而存在,而且一旦创建,就不能改去引用别的对象。 |
| 没有默认构造函数的对象型数据成员 | C++ 会尝试用默认构造函数来初始化成员对象。如果不存在默认构造函数,它就无法初始化该对象,你必须显式告诉它该调用哪个构造函数。 |
| 没有默认构造函数的基类 | 这一点会在 第 10 章 中讲解。 |
关于 ctor-initializer,还有一个非常重要的注意点:它初始化数据成员的顺序,并不是按你在 ctor-initializer 列表里写出的顺序,而是按它们在类定义中出现的顺序! 看下面这个名为 Foo 的类定义。它的构造函数只是简单存储一个 double 值,并把该值打印到控制台。
class Foo{ public: Foo(double value); private: double m_value { 0 };};
Foo::Foo(double value) : m_value { value }{ println("Foo::m_value = {}", m_value);}再假设你有另一个类 MyClass,它把一个 Foo 对象作为自己的数据成员之一。
class MyClass{ public: MyClass(double value); private: double m_value { 0 }; Foo m_foo;};它的构造函数可以这样实现:
MyClass::MyClass(double value) : m_value { value }, m_foo { m_value }{ println("MyClass::m_value = {}", m_value);}这个 ctor-initializer 先把传入的 value 存入 m_value,然后再用 m_value 作为参数调用 Foo 构造函数。你可以像下面这样创建 MyClass 的实例:
MyClass instance { 1.2 };程序输出如下:
Foo::m_value = 1.2MyClass::m_value = 1.2看起来一切都很好。现在,只对 MyClass 定义做一个很小的改动:把 m_value 与 m_foo 这两个数据成员的声明顺序对调。除此之外什么都不改。
class MyClass{ public: MyClass(double value); private: Foo m_foo; double m_value { 0 };};现在程序输出就取决于你的系统了。例如,它可能会变成这样:
Foo::m_value = -9.255963134931783e+61MyClass::m_value = 1.2这显然和你预期的完全不同。你也许会根据 ctor-initializer 的书写顺序,以为 m_value 会先初始化,然后才拿它去调用 Foo 构造函数。但 C++ 不是这样工作的。数据成员总是按它们在类定义中出现的顺序初始化,而不是按 ctor-initializer 中的顺序! 所以在这个例子里,Foo 构造函数先被调用,而传进去的 m_value 当时还没有初始化。
注意,有些编译器会在 ctor-initializer 的顺序和类定义中的声明顺序不一致时给出警告。
对这个例子来说,修复方法很简单。不要把 m_value 传给 Foo 构造函数,而是直接传入参数 value:
MyClass::MyClass(double value) : m_value { value }, m_foo { value }{ println("MyClass::m_value = {}", m_value);}ctor-initializer 初始化数据成员时遵循的是“它们在类定义中的声明顺序”,而不是“它们在 ctor-initializer 列表中的出现顺序”。
拷贝构造函数
Section titled “拷贝构造函数”C++ 中有一种特殊构造函数,叫做拷贝构造函数(copy constructor),它允许你创建一个对象,使其成为另一个对象的精确副本。下面是 SpreadsheetCell 类中拷贝构造函数的声明:
export class SpreadsheetCell{ public: SpreadsheetCell(const SpreadsheetCell& src); // Remainder of the class definition omitted for brevity};拷贝构造函数接收一个“引用到 const”的源对象。和其他构造函数一样,它不返回任何值。拷贝构造函数应当把源对象的全部数据成员复制过来。当然,从技术上讲,你在拷贝构造函数里想做什么都可以,但通常最好遵循大家预期的行为,把新对象初始化为旧对象的副本。下面给出 SpreadsheetCell 拷贝构造函数的一个实现示例。注意这里使用了 ctor-initializer。
SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src) : m_value { src.m_value }{}如果你不自己编写拷贝构造函数,C++ 会自动为你生成一个,它会用源对象中对应的数据成员来初始化新对象里的每个数据成员。对于对象类型的数据成员而言,这意味着它们的拷贝构造函数会被调用。假设类中有一组数据成员,名为 m1、m2、… mn,那么这个编译器生成的拷贝构造函数可以表示为如下形式:
classname::classname(const classname& src) : m1 { src.m1 }, m2 { src.m2 }, … mn { src.mn } { }因此,在大多数情况下,你根本不需要自己显式编写拷贝构造函数!
拷贝构造函数何时被调用
Section titled “拷贝构造函数何时被调用”C++ 中向函数传参的默认语义是按值传递(pass-by-value)。这意味着函数拿到的是值或对象的一份副本。因此,每当你把一个对象传给函数时,编译器都会调用新对象的拷贝构造函数来完成初始化。例如,假设你有下面这个按值接收 std::string 参数的 printString() 函数:
void printString(string value){ println("{}", value);}还记得 std::string 其实是一个类,而不是内建类型。当你的代码调用 printString() 并传入一个 string 实参时,参数 value 会通过调用拷贝构造函数来初始化。该拷贝构造函数的参数,就是你传给 printString() 的那个 string。在下面这个例子中,printString() 中参数对象 value 的 string 拷贝构造函数会被执行,而它的参数就是 name:
string name { "heading one" };printString(name); // Copies name当 printString() 函数执行结束时,value 会被销毁。因为它只是 name 的一个副本,所以 name 本身仍然保持不变。当然,你可以通过把参数声明为“引用到 const”来避免拷贝构造的开销,本章后面很快就会讨论这一点。
当函数按值返回对象时,拷贝构造函数也有可能被调用。这个问题会在本章稍后的“对象作为返回值”一节中讨论。
显式调用拷贝构造函数
Section titled “显式调用拷贝构造函数”你也可以显式使用拷贝构造函数。能够把一个对象构造成另一个对象的精确副本,通常是很有用的。例如,你可能会这样创建一个 SpreadsheetCell 对象的副本:
SpreadsheetCell myCell1 { 4 };SpreadsheetCell myCell2 { myCell1 }; // myCell2 has the same values as myCell1按引用传递对象
Section titled “按引用传递对象”为了避免向函数传对象时发生拷贝,你应该把函数声明成接收该对象的引用(reference)。按引用传递对象通常比按值传递更高效,因为只需要复制对象的地址,而不是复制对象的全部内容。此外,按引用传递还能避免对象中动态内存分配带来的问题,这一点会在 第 9 章 中讨论。
当你按引用传递对象时,使用该引用的函数就有能力修改原始对象。如果你按引用传递只是出于效率考虑,那就应该通过同时声明为 const,来阻止这种修改发生。这种方式叫做“按引用到 const 传递对象”(passing objects by reference-to-const),并且本书前面的很多例子已经一直在这样做。
注意,SpreadsheetCell 类中有若干成员函数把 std::string_view 作为参数。正如 第 2 章 所讨论的,string_view 本质上只是一个指针加一个长度。因此,复制它很便宜,通常直接按值传递即可。
像 int、double 这样的基本类型也是如此,通常应直接按值传递。把这类类型写成“引用到 const”并不会带来任何好处。
SpreadsheetCell 类的 doubleToString() 成员函数总是按值返回一个 string,因为该成员函数的实现会创建一个局部 string 对象,并在函数末尾把它返回给调用者。如果返回该 string 的引用就行不通了,因为它所引用的那个 string 会在函数退出时被销毁。
显式默认化与删除拷贝构造函数
Section titled “显式默认化与删除拷贝构造函数”和默认构造函数一样,你也可以对编译器生成的拷贝构造函数进行显式默认化或显式删除,如下:
SpreadsheetCell(const SpreadsheetCell& src) = default;或者:
SpreadsheetCell(const SpreadsheetCell& src) = delete;一旦删除拷贝构造函数,该对象就无法再被拷贝。这可以用来禁止按值传递对象,这一点会在 第 9 章 中讨论。
Initializer-list 构造函数
Section titled “Initializer-list 构造函数”initializer-list 构造函数(initializer-list constructor) 是指:其第一个参数是 std::initializer_list<T>(见 第 1 章),并且没有其他参数,或者其余参数都有默认值的构造函数。类模板 initializer_list<T> 定义在 <initializer_list> 中。下面这个类演示了它的用法。这个类只接受“拥有偶数个元素”的 initializer_list<double>;否则就抛出异常。第 1 章 已经介绍过异常。
class EvenSequence{ public: EvenSequence(initializer_list<double> values) { if (values.size() % 2 != 0) { throw invalid_argument { "initializer_list should " "contain even number of elements." }; } m_sequence.reserve(values.size()); for (const auto& value : values) { m_sequence.push_back(value); } }
void print() const { for (const auto& value : m_sequence) { std::print("{}, ", value); } println(""); } private: vector<double> m_sequence;};在 initializer-list 构造函数内部,你可以用基于范围的 for 循环访问 initializer-list 中的元素。要获取 initializer-list 的元素个数,可以调用它的 size() 成员函数。
EvenSequence 的 initializer-list 构造函数使用了一个基于范围的 for 循环,把给定 initializer_list<T> 中的元素复制进去。你也可以使用 vector 的 assign() 成员函数。vector 的各种成员函数(包括 assign())会在 第 18 章“标准库容器”中详细讲解。这里只先预告一下,让你感受 vector 的威力,下面是使用 assign() 改写后的 initializer-list 构造函数:
EvenSequence(initializer_list<double> values){ if (values.size() % 2 != 0) { throw invalid_argument { "initializer_list should " "contain even number of elements." }; } m_sequence.assign(values);}EvenSequence 对象可以这样构造:
try { EvenSequence p1 { 1.0, 2.0, 3.0, 4.0, 5.0, 6.0 }; p1.print();
EvenSequence p2 { 1.0, 2.0, 3.0 };} catch (const invalid_argument& e) { println("{}", e.what());}p2 的构造会抛出异常,因为它传入的 initializer-list 含有奇数个元素。
标准库对 initializer-list 构造函数提供了完整支持。例如,std::vector 容器就可以通过 initializer-list 来初始化:
vector<string> myVec { "String 1", "String 2", "String 3" };如果没有 initializer-list 构造函数,初始化这个 vector 的一种方式就只能是多次调用 push_back():
vector<string> myVec;myVec.push_back("String 1");myVec.push_back("String 2");myVec.push_back("String 3");initializer list 并不仅限于构造函数,也可以像 第 1 章 中那样与普通函数配合使用。
委托构造函数
Section titled “委托构造函数”委托构造函数(delegating constructors) 允许一个构造函数去调用同一个类中的另一个构造函数。不过,这个调用不能写在构造函数体里,而必须写在 ctor-initializer 中,并且它必须是该列表中唯一的成员初始化项。示例如下:
SpreadsheetCell::SpreadsheetCell(string_view initialValue) : SpreadsheetCell { stringToDouble(initialValue) }{}当这个 string_view 构造函数(即委托构造函数)被调用时,它会先把调用委托给目标构造函数——本例中就是 double 构造函数。等目标构造函数返回之后,才会执行委托构造函数本身的函数体。
在使用委托构造函数时,一定要避免构造函数递归。如下就是一个例子:
class MyClass{ MyClass(char c) : MyClass { 1.2 } { } MyClass(double d) : MyClass { 'm' } { }};第一个构造函数委托给第二个,第二个又反过来委托回第一个。这样的代码行为由标准规定为未定义,具体结果取决于你的编译器。
转换构造函数与 explicit 构造函数
Section titled “转换构造函数与 explicit 构造函数”当前 SpreadsheetCell 的构造函数集合如下:
export class SpreadsheetCell{ public: SpreadsheetCell() = default; SpreadsheetCell(double initialValue); SpreadsheetCell(std::string_view initialValue); SpreadsheetCell(const SpreadsheetCell& src); // Remainder omitted for brevity};单参数的 double 和 string_view 构造函数可以把一个 double 或一个 string_view 转换成 SpreadsheetCell。这类构造函数叫做转换构造函数(converting constructors)。编译器可以利用这类构造函数替你执行隐式转换。下面就是一个例子:
SpreadsheetCell myCell { 4 };myCell = 5;myCell = "6"sv; // A string_view literal (see Chapter 2).这种行为并不总是你想要的。若你想阻止编译器执行这种隐式转换,就可以把构造函数标记为 explicit。explicit 关键字只出现在类定义中。例如:
export class SpreadsheetCell{ public: SpreadsheetCell() = default; SpreadsheetCell(double initialValue); explicit SpreadsheetCell(std::string_view initialValue); SpreadsheetCell(const SpreadsheetCell& src); // Remainder omitted for brevity};这样一改,下面这样的语句就不能再通过编译了:
myCell = "6"sv; // A string_view literal (see Chapter 2).在 C++11 之前,转换构造函数只能有一个参数,就像这里的 SpreadsheetCell 例子那样。自 C++11 起,由于列表初始化(list initialization) 的支持,转换构造函数也可以拥有多个参数。来看一个例子。假设你有下面这个类:
class MyClass{ public: MyClass(int) { } MyClass(int, int) { }};这个类有两个构造函数,并且从 C++11 开始,它们都属于转换构造函数。下面的例子展示了编译器会自动把 1、{1} 和 {1,2} 这样的参数转换成 MyClass 实例,方法就是调用这些转换构造函数:
void process(const MyClass& c) { }
int main(){ process(1); process({ 1 }); process({ 1, 2 });}若想阻止这些隐式转换,可以把这两个转换构造函数都标记为 explicit:
class MyClass{ public: explicit MyClass(int) { } explicit MyClass(int, int) { }};这样一来,这些转换就必须显式执行了,例如:
process(MyClass{ 1 });process(MyClass{ 1, 2 });你还可以给 explicit 传入一个布尔参数,把它变成条件式 explicit。语法如下:
explicit(true) MyClass(int);当然,单独写 explicit(true) 与普通的 explicit 完全等价。但放到使用类型特征(type traits) 的泛型模板代码中时,它就更有价值了。借助类型特征,你可以查询某个类型的某些性质,例如“某个类型是否可以转换成另一个类型”。这种类型特征的结果就可以作为 explicit() 的参数。类型特征能帮助你编写高级泛型代码,会在 第 26 章“高级模板”中讲解。
编译器生成构造函数的小结
Section titled “编译器生成构造函数的小结”对于每个类,编译器都可以自动生成默认构造函数和拷贝构造函数。不过,它最终会生成哪些构造函数,取决于你自己已经定义了哪些构造函数。规则总结如下表:
| 如果你定义了… | 那么编译器会生成… | 并且你可以这样创建对象… |
|---|---|---|
| [没有任何构造函数] | 一个默认构造函数 一个拷贝构造函数 | 无参数创建:SpreadsheetCell a; 作为副本创建:SpreadsheetCell b{a}; |
| 只有默认构造函数 | 一个拷贝构造函数 | 无参数创建:SpreadsheetCell a; 作为副本创建:SpreadsheetCell b{a}; |
| 只有拷贝构造函数 | 不生成任何构造函数 | 理论上可以通过拷贝另一个对象来创建(实际上你根本无法创建任何对象,因为没有非拷贝构造函数) |
| 只有单参数或多参数的非拷贝构造函数 | 一个拷贝构造函数 | 通过参数创建:SpreadsheetCell a{6}; 作为副本创建:SpreadsheetCell b{a}; |
| 同时有默认构造函数和单参数/多参数非拷贝构造函数 | 一个拷贝构造函数 | 无参数创建:SpreadsheetCell a; 带参数创建:SpreadsheetCell b{5}; 作为副本创建:SpreadsheetCell c{a}; |
注意默认构造函数与拷贝构造函数之间并不对称。只要你没有显式定义拷贝构造函数,编译器就会替你生成一个。相反,一旦你定义了任意一个构造函数,编译器就会停止生成默认构造函数。
正如本章前面提到的那样,默认构造函数和默认拷贝构造函数的自动生成,也会受到你是否把它们显式默认化或显式删除的影响。
当一个对象被销毁时,会发生两件事:对象的析构函数(destructor)成员函数会被调用,并且对象占用的内存会被释放。析构函数是你为对象执行清理工作的机会,例如释放动态分配的内存或关闭文件句柄。如果你没有声明析构函数,编译器会替你生成一个,它会递归地按成员逐个销毁对象,并允许对象被删除。类的析构函数是一个成员函数,其名字等于类名并在前面加一个波浪号(˜)。析构函数不返回任何值,也不接收任何参数。下面是一个析构函数示例,它只是简单地向标准输出打印一些内容:
export class SpreadsheetCell{ public: ˜SpreadsheetCell(); // Destructor. // Remainder of the class definition omitted for brevity};
SpreadsheetCell::˜SpreadsheetCell(){ println("Destructor called.");}栈上的对象会在它们离开作用域(out of scope) 时被销毁,也就是说,只要当前函数或其他执行块(block)结束,它们就会被销毁。换句话说,每当代码遇到一个结束花括号,在那对花括号内部创建的所有栈上对象都会被销毁。下面这个程序展示了这种行为:
int main(){ SpreadsheetCell myCell { 5 }; if (myCell.getValue() == 5) { SpreadsheetCell anotherCell { 6 }; } // anotherCell is destroyed as this block ends.
println("myCell: {}", myCell.getValue());} // myCell is destroyed as this block ends.栈上的对象会按照其声明(和构造)顺序的逆序被销毁。例如,在下面这段代码片段中,myCell2 先于 anotherCell2 被创建,因此 anotherCell2 会先于 myCell2 被销毁(注意,你可以在程序任意位置通过一个左花括号开始新的代码块):
{ SpreadsheetCell myCell2 { 4 }; SpreadsheetCell anotherCell2 { 5 }; // myCell2 constructed before anotherCell2} // anotherCell2 destroyed before myCell2这种顺序同样适用于“作为其他对象数据成员的对象”。还记得数据成员是按它们在类中声明的顺序初始化的。因此,依据“对象按构造逆序销毁”这条规则,对象型数据成员会按它们在类中声明顺序的逆序被销毁。
如果对象是在自由存储区中分配的,并且没有借助智能指针来管理,那么它们不会自动销毁。你必须对对象指针调用 delete,这样才会调用它的析构函数并释放其内存。下面这个程序展示了这种行为。
不要像下面这个例子那样编写程序,其中 cellPtr2 没有被删除。请确保你总是在动态分配内存后,根据它是通过 new 还是 new[] 分配的,分别用 delete 或 delete[] 释放它。或者更好,像前面讨论的那样直接使用智能指针!
int main(){ SpreadsheetCell* cellPtr1 { new SpreadsheetCell { 5 } }; SpreadsheetCell* cellPtr2 { new SpreadsheetCell { 6 } }; println("cellPtr1: {}", cellPtr1->getValue()); delete cellPtr1; // Destroys cellPtr1 cellPtr1 = nullptr;} // cellPtr2 is NOT destroyed because delete was not called on it.就像你可以在 C++ 中把一个 int 的值赋给另一个 int 一样,你也可以把一个对象的值赋给另一个对象。例如,下面这段代码就把 myCell 的值赋给了 anotherCell:
SpreadsheetCell myCell { 5 }, anotherCell;anotherCell = myCell;你也许会想说 myCell 被“复制”给了 anotherCell。不过在 C++ 的术语里,“拷贝”(copying) 只发生在对象被初始化的时候。如果某个对象原本已经有值,后来又被新值覆盖,更准确的说法是它被“赋值了”(assigned to)。要注意,C++ 为“拷贝”提供的机制是拷贝构造函数。由于它是一个构造函数,所以它只能用于对象创建时,而不能用于对象创建之后的再次赋值。
因此,C++ 在每个类中还提供了另一个成员函数,专门用来执行赋值。这个成员函数叫做赋值运算符(assignment operator)。它的名字是 operator=,因为它实际上就是为该类重载了 = 运算符。在前面的例子里,会调用 anotherCell 的赋值运算符,并把 myCell 作为参数传给它。
像往常一样,如果你不自己编写赋值运算符,C++ 也会替你生成一个,从而让对象之间能够互相赋值。C++ 默认的赋值行为几乎与默认拷贝行为完全一样:它会把源对象中的每个数据成员递归地赋值给目标对象中的对应成员。
声明赋值运算符
Section titled “声明赋值运算符”下面是 SpreadsheetCell 类中的赋值运算符声明:
export class SpreadsheetCell{ public: SpreadsheetCell& operator=(const SpreadsheetCell& rhs); // Remainder of the class definition omitted for brevity};赋值运算符通常和拷贝构造函数一样,接收一个“引用到 const”的源对象。这里源对象被命名为 rhs,代表等号右侧(right-hand side),当然你也完全可以给它取任何你想要的名字。而调用赋值运算符的那个对象,则位于等号左侧。
与拷贝构造函数不同,赋值运算符会返回一个 SpreadsheetCell 对象的引用。原因在于,赋值是可以链式进行(chained)的,例如下面这样:
myCell = anotherCell = aThirdCell;执行这一行时,首先会调用 anotherCell 的赋值运算符,并把 aThirdCell 作为它的“右侧”参数。接着才会调用 myCell 的赋值运算符。不过此时传给它的参数并不是 anotherCell 本身,而是“把 aThirdCell 赋值给 anotherCell”这一操作的结果。等号实际上只是成员函数调用的一种简写。把这一行展开成完整的函数式语法之后,问题就一目了然了:
myCell.operator=(anotherCell.operator=(aThirdCell));现在你就能看出:anotherCell 上的 operator= 必须返回一个值,这样这个值才能作为参数传给 myCell 的 operator=。正确的返回值应当是 anotherCell 自身的引用,这样它才能作为 myCell 赋值时的源对象。
从技术上讲,你完全可以把赋值运算符声明成任意返回类型,甚至可以返回 void。但你始终都应该返回“被调用该运算符的对象本身的引用”,因为这才是使用者的预期。
定义赋值运算符
Section titled “定义赋值运算符”赋值运算符的实现与拷贝构造函数有些相似,但也有几个重要区别。首先,拷贝构造函数只会在初始化时被调用,因此目标对象还没有有效值。而赋值运算符则会覆盖掉对象当前已有的值。这个差异在对象中拥有动态分配资源(例如内存)时才会真正变得重要。第 9 章 会详细讨论这一点。
其次,在 C++ 中,对象给自己赋值是合法的。例如,下面的代码可以编译并运行:
SpreadsheetCell cell { 4 };cell = cell; // Self-assignment因此,你的赋值运算符需要考虑到“自赋值”(self-assignment) 的可能性。在 SpreadsheetCell 里,这一点其实并不重要,因为它唯一的数据成员只是一个基本类型 double。不过,当类中含有动态分配内存或其他资源时,是否正确处理自赋值就至关重要了,这一点会在 第 9 章 中详细讨论。为了防止这类问题,赋值运算符通常做的第一件事,就是检查是否发生了自赋值,如果是就立刻返回。
下面是 SpreadsheetCell 赋值运算符定义的开头:
SpreadsheetCell& SpreadsheetCell::operator=(const SpreadsheetCell& rhs){ if (this == &rhs) {这一行首先检查是否发生了自赋值,不过它看上去可能有点难懂。自赋值发生在等号左右两边其实是同一个对象的时候。判断两个对象是否相同的一种方式,就是看它们是否占用同一块内存——更明确地说,看指向它们的指针是否相等。还记得 this 是任何成员函数内部都能访问到的、指向当前对象的指针。因此,this 指向的是等号左边那个对象。类似地,&rhs 是一个指向右侧对象的指针。如果这两个指针相等,那这次赋值就必然是自赋值。不过,由于返回类型是 SpreadsheetCell&,所以你仍然必须返回一个正确的值。所有赋值运算符最终都会返回 *this,自赋值场景也不例外:
return *this; }this 是指向当前成员函数所作用对象的指针,因此 *this 就是这个对象本身。编译器会把它作为对象引用返回,以匹配声明中的返回类型。现在,如果不是自赋值,你就需要对每个成员执行赋值:
m_value = rhs.m_value; return *this;}这里成员函数复制了值,最后再返回 *this,理由前面已经解释过。
细心的读者会注意到,拷贝赋值运算符与拷贝构造函数之间其实有一些重复代码:它们都需要复制所有数据成员。第 9 章 会介绍 copy-and-swap 惯用法,用来避免这种重复。
如果你的类在拷贝操作上需要特殊处理,请始终同时实现拷贝构造函数和拷贝赋值运算符。
显式默认化与删除赋值运算符
Section titled “显式默认化与删除赋值运算符”你也可以对编译器生成的赋值运算符进行显式默认化或显式删除,如下:
SpreadsheetCell& operator=(const SpreadsheetCell& rhs) = default;或者:
SpreadsheetCell& operator=(const SpreadsheetCell& rhs) = delete;编译器生成的拷贝构造函数与拷贝赋值运算符
Section titled “编译器生成的拷贝构造函数与拷贝赋值运算符”C++11 弃用了这样一种自动生成规则:如果类中有用户声明的拷贝赋值运算符或析构函数,编译器仍然自动生成拷贝构造函数。若在这种情况下你依然需要编译器生成的拷贝构造函数,就可以显式把它默认化:
MyClass(const MyClass& src) = default;C++11 也弃用了另一条自动生成规则:如果类中有用户声明的拷贝构造函数或析构函数,编译器仍然自动生成拷贝赋值运算符。若在这种情况下你依然需要编译器生成的拷贝赋值运算符,同样可以显式把它默认化:
MyClass& operator=(const MyClass& rhs) = default;区分拷贝与赋值
Section titled “区分拷贝与赋值”有时很难一眼看出:对象究竟是通过拷贝构造函数初始化的,还是通过赋值运算符被赋值的。一个基本原则是:看起来像声明的语句,通常会使用拷贝构造函数;看起来像赋值语句的代码,则由赋值运算符处理。来看下面的代码:
SpreadsheetCell myCell { 5 };SpreadsheetCell anotherCell { myCell };这里 anotherCell 是通过拷贝构造函数构造出来的。再看下面这一句:
SpreadsheetCell aThirdCell = myCell;aThirdCell 依然是通过拷贝构造函数构造的,因为这仍然是一条声明语句。这里并不会调用 operator=! 这种语法不过是 SpreadsheetCell aThirdCell{myCell}; 的另一种写法。不过,请看下面这段代码:
anotherCell = myCell; // Calls operator= for anotherCell这里 anotherCell 早就已经构造好了,因此编译器会调用 operator=。
对象作为返回值
Section titled “对象作为返回值”当函数返回对象时,有时候也很难看清楚到底发生了哪些拷贝与赋值。例如,SpreadsheetCell::getString() 的实现如下:
string SpreadsheetCell::getString() const{ return doubleToString(m_value);}现在来看下面这段代码:
SpreadsheetCell myCell2 { 5 };string s1;s1 = myCell2.getString();当 getString() 返回这个字符串时,编译器实际上会通过调用某个 string 拷贝构造函数,创建一个匿名的临时 string 对象。随后,当你把这个结果赋给 s1 时,s1 的赋值运算符会被调用,并把那个临时 string 作为参数传进去。然后,这个临时 string 对象又会被销毁。因此,这单独一行代码就可能触发一个拷贝构造函数和一个赋值运算符(当然,它们作用于两个不同对象)。
如果你还不够晕,再看看下面这段代码:
SpreadsheetCell myCell3 { 5 };string s2 = myCell3.getString();在这种情况下,getString() 在返回时依然会创建一个匿名临时 string 对象。但此时被调用的不再是 s2 的赋值运算符,而是它的拷贝构造函数。
如果引入了移动语义(move semantics),那么编译器在从 getString() 返回字符串时,可以使用移动构造函数(move constructor)或移动赋值运算符(move assignment operator),而不是拷贝构造函数或拷贝赋值运算符。在某些场景下,这会更高效。相关内容会在 第 9 章 中讨论。不过更进一步说,编译器其实还可以(而且常常甚至必须)实现拷贝省略(copy elision),从而在返回值时直接优化掉昂贵的拷贝或移动操作;参见 第 1 章。
如果你哪天又忘了这些调用发生的先后顺序,或者忘了到底调用了哪个构造函数/运算符,最简单的办法就是临时在代码里加入一些辅助输出,或者用调试器单步跟踪你的代码。
拷贝构造函数与对象成员
Section titled “拷贝构造函数与对象成员”你还应该注意构造函数内部“赋值运算符调用”和“拷贝构造函数调用”的区别。如果一个对象内部还包含其他对象,那么编译器生成的拷贝构造函数会递归地对每个内部对象调用其拷贝构造函数。当你自己编写拷贝构造函数时,也可以像前面展示的那样,通过 ctor-initializer 实现同样的语义。如果你在 ctor-initializer 中漏掉了某个数据成员,那么编译器会在执行构造函数体中的代码之前,先对它执行默认初始化(对于对象来说就是调用默认构造函数)。因此,等到构造函数体真正开始执行时,所有对象型数据成员其实都已经完成初始化了。
例如,你完全可以把 SpreadsheetCell 的拷贝构造函数写成下面这样:
SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src){ m_value = src.m_value;}不过,当你在拷贝构造函数体内给数据成员赋值时,你实际上是在对这些成员使用赋值运算符,而不是拷贝构造函数,因为它们早就已经先被初始化过了。
如果你把拷贝构造函数写成下面这样,那么 m_value 才是通过拷贝构造被初始化的:
SpreadsheetCell::SpreadsheetCell(const SpreadsheetCell& src) : m_value { src.m_value }{}本章介绍了 C++ 在面向对象编程方面的基础能力:类与对象。它首先回顾了编写类和使用对象的基本语法,包括访问控制。接着又讲解了对象生命周期:对象何时被构造、销毁和赋值,以及这些操作会触发哪些成员函数。本章还覆盖了构造函数语法细节,包括 ctor-initializer 和 initializer-list 构造函数,并介绍了拷贝赋值运算符的概念。它也精确说明了编译器究竟会在什么情况下为你生成哪些构造函数,并解释了默认构造函数指的是不需要任何参数的构造函数。
你可能会觉得本章内容大多是在复习。也可能正相反,它让你真正看清了 C++ 中面向对象编程的世界。无论哪种情况,既然你现在已经熟悉对象与类了,接下来就请阅读 第 9 章,进一步学习它们的技巧与细微之处。
通过完成下面这些练习,你可以巩固本章讨论的内容。所有练习的参考解答都包含在本书网站 www.wiley.com/go/proc++6e 的代码下载包中。不过,如果你在某道题上卡住了,请先回过头重读本章相关部分,尝试自己找答案,再去查看网站上的解答。
-
练习 8-1: 实现一个
Person类,用数据成员保存名(first name)和姓(last name)。添加一个接收两个参数(名字和姓氏)的构造函数。提供合适的 getter 和 setter。编写一个简单的main()函数来测试你的实现,分别在栈上和自由存储区中创建Person对象。 -
练习 8-2: 在练习 8-1 所实现的那组成员函数下,下面这行代码无法编译:
Person persons[3];你能解释为什么这段代码无法编译吗? 请修改你的
Person类实现,使它能够工作。 -
练习 8-3: 给你的
Person类实现添加以下成员函数:拷贝构造函数、拷贝赋值运算符以及析构函数。在这些成员函数中,实现你认为必要的逻辑,并额外向控制台输出一行文本,以便追踪它们何时被执行。修改main()来测试这些新成员函数。注意:从技术上说,对于这个简单的Person类,这些新成员函数其实并非严格必需,因为编译器生成的版本已经足够好了,但本练习的目的是练习亲手编写它们。 -
练习 8-4: 从你的
Person类中移除拷贝构造函数、拷贝赋值运算符和析构函数,因为对于这个简单类来说,编译器默认生成的版本正是你所需要的。接着,添加一个新的数据成员用于存储某个人的姓名首字母(initials),并提供 getter 和 setter。再添加一个接收三个参数的新构造函数,分别是名字、姓氏和首字母。随后修改原先那个双参数构造函数,让它能根据给定的名字与姓氏自动生成首字母,并把实际构造工作委托给新的三参数构造函数。在main()中测试这一新功能。