Construct & Destruct¶
前面说了那么多,我们终于要开始 OOP 的内容了…
成员函数与访问控制¶
C++ 中的结构体可以有成员函数,或称为方法:
我们定义了两个 rectangle 类型的变量 c1 和 c2。换一种说法,我们定义了 rectangle 类,并定义它的两个对象 c1 与 c2,然后调用了它们的 area() 成员函数。我们会发现这种语法非常自然地表现出封装的概念。作为类比,下面是 C 中能做到最好的封装:
相比之下就没有那么自然了。1
成员函数在类的内部声明,基本的语法与函数相同,同样可以重载,也同样可以调用其他成员函数。至于定义,可以在类的内部定义,也可以在类的外部定义。在类的外部定义时,需要加上类名和作用域解析符 :::
这使得我们能够将类的声明和定义分离(例如,将声明放在头文件中,定义放在源文件中),这是一种良好的编程习惯。
但成员函数并不是最重要的封装手段,访问控制才是。C++ 引入了三个访问说明符 public、private 和 protected,用于控制类的成员的可访问性。
public表示,它之后的各成员具有公开成员访问。对public成员的访问不受限制。private表示,它之后的各成员具有私有成员访问。对private成员的访问仅限于类的内部。protected表示,它之后的各成员具有受保护成员访问。对protected成员的访问仅限于类的内部和派生类。
从这个访问说明符开始,到下一个访问说明符之前或者到类定义结束,都是受这个访问说明符约束的。上面的例子中,两个成员变量都是 private 的,三个成员函数都是 public 的。所以在类的外部,我们无法直接访问并修改 c1.width,但可以调用 c1.set_width(5) 来修改它。
目前我们还看不到 protected 的用处,它与继承有关,我们会在后面看到。
关于访问说明符
有时候,你可能会见到有人认为应当显式写明每一个成员的访问说明符:
语法上没什么问题,但这样写不会带来任何好处,完全没有必要。
private 成员的命名方式
关于 private 成员的命名方式,社区约定俗成的表示方式是给私有成员加上前缀。至于使用什么前缀,不同的风格有不同的看法。例如,有的风格认为应该前缀一个下划线 _:
这也是我们推荐的方式。也有其他风格认为应该前缀 m(表示 member):
还有前缀 m_ 之类的。不管怎么说,这种风格的好处是,一眼就能看出哪些是私有成员,哪些是公有成员。这并非 C++ 标准规定,不做区分也是完全合法的。
但是,这样似乎更加繁琐了?我们发现现在不能这样了:
这需要构造函数。
标记成员变量为 const
一般来说,当可能时,应当尽量将成员变量标记为 const,以避免意外的修改。这是不可变性的一种体现。
在本例中,若使用 const,就没有必要再对其进行多余的封装了,因为它们本身就是不可修改的。
从更高层的角度,软件工程中,特别是并行领域,最臭名昭著的问题之一就是共享可变状态。共享与可变两者单独都是 OK 的,但是当它们结合在一起时,就造成了今天软件工程中无数令人头疼的 bug。这是由并行编程与现代计算机体系结构的本质决定的。
当然这个烦恼并非 OOP 独有;不同范式受其影响程度不同,例如 FP 推崇的不可变性直接绕过了它。而 OOP 本质是状态与行为的组合,传统上更依赖于可变状态,所以要下更多的功夫来避免这个问题。
struct 与 class 的区别¶
我们在 Filling in the Gaps 中提到过,struct 与 class 并无本质区别,只是默认的访问控制不同。struct 的访问说明符默认为 public,而 class 默认为 private。这个“默认”会应用到类中没有显式被标记可访问性的成员上,也就是从类的声明开始,到第一个访问说明符之前,或者到类定义结束。另外它也对继承有影响。不管怎么说,如果你在任何地方都显式地写明了访问说明符,那么 struct 和 class 就没有任何区别了。
构造函数¶
构造函数(或简写为 ctor)是一种特殊的成员函数,用于初始化对象。构造函数不写返回类型,函数名与类名相同,可以有参数,也可以重载。当创建一个对象时,调用的就是构造函数。
构造函数的一种用途是,需要初始化成员变量,但又需要在初始化时执行一些操作或数据验证。矩形的长和宽肯定不能是负数:
另外,如果构造函数是 private 的,那么就不能在类的外部创建对象了。
默认初始化¶
构造函数的另一种用途是保证对象处在一个确定的状态。例如我们都知道 C 的默认初始化不保证变量会是 0 或任何确定的值:
在 C++ 中同样如此,除非我们的构造函数定义得当——这种情况下就可以保证对象的状态是确定的。因为只要对象被创建,构造函数就一定会被调用。
背后其实涉及到一些非常复杂的情况…这可能会涉及到默认构造函数,我们马上会看到。
成员初始化式列表¶
我们之前提到过最好将类设计为不可变的:
但有个问题是,如果这时我们还想加入验证,从而必须用到构造函数,那么怎么办?旧的写法会产生不只一种报错:
/* error: constructor for 'rectangle' must explicitly
initialize the const member 'width' */
/* error: constructor for 'rectangle' must explicitly
initialize the const member 'height' */
rectangle(double w, double h) {
if (w < 0 || h < 0) { /* ... */ }
width = w; /* error: cannot assign to non-static data member 'width'
with const-qualified type 'const double' */
height = h; /* error: cannot assign to non-static data member 'height'
with const-qualified type 'const double' */
}
所以在此我们需要引入新的语法:成员初始化式列表。这是一种在构造函数中初始化成员变量的方法,属于函数体的一部分。const 成员只能通过这种方式初始化。注意在这里我们既可以用 () 也可以用 {},不过出于与初始化变量相同的原因,我们更推荐在 C++11 之后使用 {}:
rectangle(double w, double h) : width(w), height {h} {
if (w < 0 || h < 0) {
std::cerr << "invalid dimension\n";
exit(1); // better use exception
}
}
rectangle c {3, 4};
通过成员初始化式的方式,也可以解决构造函数的参数与成员重名的情况(使用 this 同样可以解决这个问题,但同样只有在成员不为 const 时):
成员初始化式同样可以是空的,这种情况下成员变量会被零初始化:
但这里同样会引入新的问题…考虑成员变量初始化的顺序。假设我们现在有一个比较小众的需求:
Question
上面的代码中,r 的结果是?
A. rectangle[width=15, height=5]
B. rectangle[width=10, height=5]
C. 都不是
注意这里的第 6 行,我们的写法是先用 a 初始化 height,然后再用 height + 10 初始化 width。那么我们可能会期望得到 A 的结果;然而实际运行可能得到 B 的结果;正确的答案是 C。这里的陷阱在于成员变量按照声明顺序初始化,而非初始化式的顺序。上面的代码中,width 先于 height 初始化而 width 又依赖于 height 的值,此时 height 还未被初始化,它的值是不确定的!所以 width 会被初始化为一个不确定的值加上 10,然后 height 才被初始化为 5。
编译器一般会对此种情况发出两条警告:一条是 -Wreorder 提示实际初始化的顺序与初始化式顺序不同,另一条是 -Wuninitialized 提示 height 在被初始化前就被使用。这件事同样告诉我们,总是打开 -Wall -Wextra 是好习惯,并且不要忽略任何编译警告。实际上更好的方法是,除非有充分的理由,否则不要使用成员变量去初始化其他成员变量。为什么不写成下面这样呢?
Info
当然,因为 width 和 height 的类型为 double,所以即使其未被初始化,内存中对应的字节也很难能恰好被解释为一个能对结果有什么影响的双精度浮点数,如下所示。这就是为什么上面的问题在运行时几乎总会得到 B 的结果。如果它们是整数类型就会很明显了:
另外,C++11 起,成员变量可以直接在类中初始化:
委托构造函数¶
考虑我们需要对矩形的长宽做验证,但是同时,我们希望加入对正方形的支持:
同样的验证逻辑我们写了两遍!没有更好的办法么?
有的。委托构造函数允许我们在构造函数中调用其他的构造函数。既然我们已经有针对常规矩形的验证逻辑了,完全可以让正方形去复用它的逻辑:
但有一个限制:委托构造函数与成员初始化式不可同时出现。这是合理的要求,毕竟任何构造函数都应该初始化其所有成员。当然,如果成员不是 const 的,那么完全可以在构造函数体内再次给成员变量赋值。(调用基类的构造函数是另一回事,不受此限制,我们之后会看到。)
析构函数¶
构造函数在对象被创建时调用,那么析构函数(或简写为 dtor)顾名思义,就是在对象被销毁前调用的特殊函数。析构函数会负责回收对象所用到的资源,并且在继承与多态中有相当大的作用。析构函数同样没有返回类型,其函数名为类名前附波浪号 ~,与构造函数不同的是它必须没有参数(因此不能被重载,这没什么意义)。析构函数会在对象生命周期结束时被调用,但与构造函数不同的是,它也可以被程序员显式调用(这能够支持 C++ 的放置 new 语法)。rectangle 类太简单从而不需要任何析构,我们来考虑一个更复杂的案例 vector。简单起见我们固定其大小并且目前还不能访问其内容,但这不重要。
注意我们如何声明析构函数,以及析构函数被调用时的结果。在继续之前,先确保自己完全理解这里的 4 行输出是如何以及何时产生的:
- 当对象
v被创建时,它的构造函数被调用,得到第 1 行输出。 - 当指针
pv指向的对象被new创建时,它的构造函数被调用,得到第 2 行输出。 delete pv释放了pv指向的对象,这个过程中会自动调用析构函数,得到第 3 行输出。- 最后,
main函数结束,对象v的生命周期结束,所以其析构函数被自动调用,得到第 4 行输出。
重要的一点是认识到 pv 是一个指针,指针本身的生命周期与它所指向的对象的生命周期没有什么必然的联系。pv 本身的生命周期在 main 函数结束时结束,但这并不影响它所指向的对象。必须确保 pv 指向的对象在 main 函数结束前被释放,或者在之后还有某种方式释放它。否则就会造成内存泄漏。
另外,析构函数可以被显式调用,但你几乎不需要这样做。显式调用析构函数的唯一用途是放置 new,如果你的对象不是由分配 new 创建的,那么就不应该显式调用析构函数。析构同一个对象超过一次是未定义行为,尽管这种行为通常是可以分析的。下面是一个简短的关于放置 new 和析构函数的例子,目前我们不会去过多地讨论它:
放置 new
放置 new 是 C++ 中的一种特殊的 new 语法,用于在已分配但还未初始化的内存区域上创建对象。它的语法为:
其中 placement_args 通常是一个指向已分配内存的指针,type 是要创建的对象的类型。通过放置 new 创建的对象会在指定的内存区域上进行初始化,并不分配任何新的内存。它返回一个指向新创建对象的指针,这个指针的类型为 type *,但地址值与 placement_args 相同。
放置 new 所用到的空间必须足够容纳所分配对象的大小。使用放置 new 时不能使用 delete(注意,这并非释放这个对象本身占用的内存)。析构函数不会自动调用,必须得显式调用2。
这种语法通常在实现自定义内存分配器时使用(例如 std::allocator),或者在需要在特定内存区域上创建对象时使用(例如在嵌入式系统中)。
关于 placement_args
placement_args 之所以是复数形式是有原因的:它不一定只能是一个参数。这会涉及到 operator new 的重载,这不是什么重点,我们会在 Operator & Cast 简要了解。
下面是一个更有意思的放置 new 的例子,它是良定义的 C++ 代码:
默认构造函数与弃置函数¶
我们一直没有提到,实际上,如果我们在声明类时没有定义任何构造函数,编译器会自动生成一个(或者说,一些)构造函数。对于基本类型(例如 int、double、指针)等,编译器会将其默认初始化(也就是,不会初始化)。对于类类型,编译器会调用其默认构造函数。默认构造函数是指没有参数的构造函数:
对于上面这样的 S,自 C++11 起,编译器会为其自动生成 3 个构造函数:
- 默认构造函数
S() = default;,什么都不做。 - 复制构造函数
S(const S &s)。 - 移动构造函数
S(S &&s)。
目前我们暂时用不到后两者,可以粗略认为它们的作用是用另一个 S 对象初始化当前对象,所以我们还是先关注默认构造函数。默认构造函数也可以显式定义:
但这并不是重点。重点是,如果我们定义了任意一个构造函数,编译器就不会再自动生成默认构造函数了;即使我们声明的是复制构造函数或移动构造函数也是如此。
C++11 还引入了 = default 语法,允许我们显式预置默认构造函数:
显式预置的构造函数不做任何事情,编译器会为其生成空的实现。这个语法只能用于默认构造函数,不能用于其他构造函数(因为它们有参数)。
这里的易错点是 = default 语法会默认初始化基本类型成员变量,而非零初始化。如果要确保成员变量被零初始化,仍然需要使用成员初始化式列表:
另外,构造函数和析构函数可以被标记为 = delete,表示这个构造函数或析构函数被弃置。任何函数都可以被弃置,包括构造函数、析构函数、成员函数,甚至是全局函数。被弃置的函数不可被调用,否则会产生编译错误。
例如,对于这个类 S,我们只能像 new S {5} 这样创建对象。这样得到的指针不能被 delete,因为这样会调用析构函数,而析构函数被弃置了。出于同样的原因,我们不能写 S s {5}。我们也不能使用 S s2 {s1} 这样的语法来复制对象,因为复制构造函数被弃置了。
this¶
this 是一个关键字,当在(非 static)成员函数中使用时,它是一个指向当前对象的指针。“当前”的意思是调用这个成员函数的对象。
我们之前提到,如果构造函数参数与成员变量重名,也可以使用 this 来区分它们,当然这种方法还是不如成员初始化式简洁。
上面的例子中除了成员变量的初始化外,其他的 this 使用都是不必要的。有的人可能认为处处都写上 this 会让代码更清晰,这是代码风格的选择,对程序的效率不会有任何影响。
另外,this 的一个非常重要的特性是,它是任何非 static 成员函数的第一个参数,由编译器隐式传递。也就是,当我们在成员函数内部使用 this 时,实际上就是在使用这个隐式的参数。对于之前 S 的例子来说,我们可以不严谨地认为编译器眼中的代码是这样的1:
此外,this 在重载一些特殊成员函数时也会很有用,例如 operator=,以及为自定义类实现流输入输出运算符时。我们会在 Operator & Cast 中看到。
static 成员¶
我们已经知道,当 static 修饰全局变量时,限制该变量的作用域为当前文件;当 static 修饰局部变量时,表示该变量的生命周期为整个程序运行期间。在 C++ 中,static 还可以修饰类的成员变量和成员函数。它表示该成员属于类本身,而不是属于某个对象。某种角度上来说,它就只是将全局变量 / 函数的作用域限制在类的内部而已。需要注意,因为 static 成员函数属于类而非对象,它们没有 this 指针,不能访问类的非 static 成员。在使用 static 成员时,既可以使用类名与作用域解析符 :: 访问,也可以使用对象名访问(但不推荐)。
另外,对于成员变量,在类内部的只是声明,而不是定义。也就是说,static 成员变量必须在类外部定义一次3。成员函数没有这个限制。
const 修饰的成员函数¶
我们之前一直忽略了一个问题…下面的代码无法编译:
问题在于 c 是一个 const 对象。虽然 area() 不修改任何成员变量,但编译器并不知道这点;我们必须用一些方法告诉编译器这个函数不会修改任何成员变量。为此,我们可以在函数声明后加上 const:
它标记了这个函数不会修改任何成员变量。注意这里的 const 是放在函数声明的末尾,而不是参数列表的前面。如此,area() 就可以在 const 对象上调用了。注意上面的写法与下面的写法是完全不同的意思:
从本质上来说,const 成员函数影响的是 this 的类型。在 const 成员函数中,this 的类型是 const rectangle *,而在非 const 成员函数中,this 的类型是 rectangle *。如果不加 const 我们就不能在 const 对象上调用这个函数,本质上是因为 const rectangle * 无法隐式转换为 rectangle *。同样的,标记为 const 的成员函数也不能修改任何成员变量(除非它们是 mutable 的)。
验证 const 对 this 的影响
另外,const 属于函数签名的一部分;这意味着它会影响函数的重载:
上面的例子非常有意思。注意到由于 ps 的类型是 const S *,所以调用 ps->f() 时,编译器会选择 const 的版本,而与它实际指向对象(a)的类型无关。这是在编译时确定的。
这有什么用呢?const 成员函数通常被用来实现访问器(或称为只读方法),也就是只读取成员变量而不修改它们的函数,例如前面的 area()。另外,在重载一些运算符时,例如 operator[],也会用到 const 成员函数。很多时候这样的重载会涉及到引用,我们会在 Reference & Value Category 中看到。
最后要说明的是,const 只对 this 施加限制;在 const 成员函数中,仍然可以修改函数参数、static 成员变量(因为它们属于类本身而非对象),或者全局变量,因为对这些变量的修改与 this 无关。
对 const 成员函数的语义限制是,它不能对对象的状态造成任何外部可见的影响。这里的“外部可见”非常重要…
mutable 成员¶
有时,偶尔,我是说我们每个人总会遇到一些非常非常稀有的情况。考虑下面的例子:
这种情况大多出现在优化代码的过程中。假设 S::f() 的计算非常耗时,我们希望实现一种“缓存”机制来避免重复计算。可能有人认为可以借助 static 局部变量来实现:
问题在于 static 局部变量的生命周期是整个程序运行期间!不同的 S 对象会共享同一份数据,这就会导致错误的结果。在我们的这个例子中,我们希望每个对象都有自己的缓存。这时我们就得求助于 mutable。
被 mutable 标记的成员变量可以被任意自由地修改,即使在 const 成员函数中。mutable 不能和 static 或 const 一起使用,尽管后两者可以一起使用。
mutable 的使用场景相当有限,它是违反 const 性的。如果真的需要用到,那么一定要确保它不会对对象的状态造成任何外部可见的影响。例如下面的代码能通过编译,但它是逻辑上错误的。无论调用一个 const 成员函数多少次,只要在这其中没有调用任何非 const 成员函数改变对象的状态,那么它的结果就应该是相同的(如果我们不将函数的运行时间考虑在内)。不当的 mutable 使用会导致这个假设不成立。也因此,mutable 成员变量不应该是 public 的。
嵌套类¶
在类的内部同样可以定义类,这种类称为嵌套类。嵌套类的作用域是它所在的类的作用域。嵌套类可以访问外部类的所有成员,包括非 public 成员,反之则不成立。嵌套类在实现例如链表或者迭代器时会很有用。
嵌套类与内部类不同
Java 等语言中的内部类与 C++ 的嵌套类是不同的。Java 的内部类同时拥有外部类的 this 指针,而 C++ 的嵌套类没有。C++ 的嵌套类与 Java 的静态内部类类似。
friend¶
也有些时候,我们希望一些特定的外部的类或函数能够访问类的非 public 成员。C++ 提供了友元的概念来实现这一点。在类内部,我们可以使用 friend 声明一个函数或类为友元,后者就可以访问这个类的所有成员。
第 3 行的 friend 声明了外部函数 g 为 S 的友元函数。这样,g 就可以访问 S 的所有成员。friend 声明只标记一个关系,而并不实际声明或定义任何东西,因此 friend 声明也无所谓访问控制,放在哪里都可以。friend 声明的最常用场景也是实现运算符重载,当然在内部类的情况下也可以用到,详见 Operator & Cast。friend 声明对类同样适用,这时 friend 类的所有成员都可以访问这个类的所有成员。friend 关系有下面的特点:
friend关系是单向的。如果A是B的友元,B并不是A的友元。friend关系不具有传递性。如果A是B的友元,B是C的友元,A并不是C的友元。friend关系不能被继承。如果A是B的友元,B是C的派生类,A并不是C的友元。friend关系与重载无关。如果A是B的友元,A的重载版本并不是B的友元。
这能够限制 friend 的作用范围到最小。因为严格来说,它是一种破坏了封装的行为。
Question
上面的代码有 3 处使用 friend 声明。它们分别影响哪里的访问?
答案
friend struct S;使得第 9 行_i._v的访问能够成功。S的friend void g(S *);使得第 13 行s->_i的访问能够成功。inner的friend void g(S *);使得第 13 行s->_i._v的访问能够成功。
在类中实现 friend 函数
friend 函数可以在类的内部实现,但它并不是类的成员函数,不具有 this 指针,同样也与访问权限无关。但将 friend 函数放在类的内部实现通常不会带来额外的好处。
这里的报错看似很奇怪!对比下面的写法:
注意到上下两个程序的报错信息是不一样的:上面的程序可以正常调用 g(S *),但调用 g() 时的报错是“未定义函数 g”。而对于下面的程序,g() 产生的报错就是看起来更正常的“参数错误”的报错了。这里涉及到实参依赖查找。通常来说你不需要用到 ADL 这个概念,所以上面的写法容易产生让人感到困惑的报错。一般来说,只需要在类内部声明 friend 函数,然后在类外部实现它就可以了。
union¶
C++ 中的 union 与 C 语言中的 union 有相同的特性,即所有非 static 成员共享同一块内存。但 C++ 中的 union 同样可以有构造函数、析构函数、成员函数和访问权限、static 成员等等…基本上与类没有什么区别,除了一点:union 不能继承或被继承。union 的默认访问权限是 public。
Has-a 关系¶
我们终于要以一个轻松的 OO 的特性来结束这一节了!
Has-a 关系是面向对象编程中一个非常重要的概念,它表示一个对象包含另一个对象。Has-a 关系可以向下细分为组合、聚合等等一系列更小的概念。当然,这一般是不需要了解的细节,统称为 has-a 关系即可。
当然,下面的内容可能就不太轻松了。考试会考的是 has-a 关系中,对象的构造和析构顺序。考虑下面程序的输出:
在构造 T 时,是先构造 T 的成员变量 s0、s1 和 s,还是先调用 T 的构造函数?答案是先以声明顺序构造 T 的成员变量,然后才是 T 的构造函数。析构顺序则是先调用 T 的析构函数,然后以相反的顺序析构 T 的成员变量。所以上面程序的输出是:
与 has-a 关系相对的是 is-a 关系,即继承关系。我们会在 Inheritance & Polymorphism 中看到。
成员的内存布局同样是需要注意的点。C++ 定义的类成员的布局较为麻烦。自 C++11 起:
- 可访问性相同的成员,它们的地址按照声明顺序递增;
- 可访问性不同的成员,顺序是未指明的:编译器对此一定有确定的行为,但程序员并不能对其做任何假设。
另外,出于对齐的需求,任意两个成员间或者最后一个成员之后可能存在额外的填充。
x y a 的地址一定是升序排列的,_z _p 也是;但是不能对 y 和 _z 的相对布局做任何假设。
附录:this 可以是 nullptr 吗?¶
Question
下面的代码会输出什么?
A. this is not null
B. this is null
C. 运行时错误
D. 编译错误
这道题没有正确答案,因为它是未定义行为。所幸考试中不会出现这种题目。如果不启用优化选项,那么上面的代码会输出 this is null,因为编译器仍然会忠实地求值 this。但如果启用优化,编译器会认为 this 不可能为 nullptr,所以它可能会将 if (this) 优化掉,输出 this is not null。这就是为什么我们说它是未定义行为的原因。尽管这种行为有时是可以分析的,但它没有任何实际意义。不要这样做。另外,如果 S 是一个多态类,上面的代码就一定会产生运行时错误了。
编译下面的代码,我们通常会得到一个警告:
附录:delete this¶
你也许会疑惑,既然 this 是一个指针,那么我们可以进行 delete this 操作吗?答案是可以,但这是相当小众(且不容易用对)的用法。考虑如果析构函数是 private 的:
因为析构函数是 private 的,所以我们:
- 只能使用
new来创建对象。 - 不能直接使用
delete来删除对象。
这种情况下我们一般会提供一个成员函数来管理对象的生命周期,例如在这个例子中是 close(),它调用 delete this 来删除对象。
它有什么使用例呢?例如,考虑引用计数。在下面这个例子中,ref_counted 的生命周期会由其引用计数来管理:
但这种用法在使用时需要多加小心,因为如果对象在某次 release() 调用中被删除了,就不能再继续使用这个对象了。所以一般来说这不是个好主意,用智能指针之类的机制来管理对象的生命周期通常是更好的选择。