C++ Primer 第七章笔记

7.1 定义抽象数据类型

7.1.2 定义改进的 Sales_data 类

​ 定义和声明成员函数的方式与普通函数差不多。成员函数的声明在类的内部,它的定义既可以在类的内部也可以在类的外部。作为接口部分的非成员函数,它们的定义和声明都在类的外部。

​ 定义在类内部的函数是隐式的 inline 函数。 #### 引入 this

​ 成员函数通过一个名为 this 的额外的隐式参数来访问调用它地那个对象。当我们调用一个成员函数时,用请求该函数地对象地址初始化 this。 this 是一个常量指针,不允许改变 this 保存的对象。

引入 const 成员函数

​ 在默认情况下,我们不能把 this 绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。C++ 允许把 const 关键字放在成员函数地参数列表之后,此时,紧跟在参数列表后面的 const 表示 this 是一个指向常量地指针。像这样使用 const 地成员函数被称作常量成员函数(const member function)。

​ 因为 this 是指向常量地指针,所以常量成员函数不能改变调用它的对象的内容。在例子中,isbn 可以读取调用它的对象的数据成员,但是不能写入新值。常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

类作用域和成员函数

​ 类本身就是一个作用域,类的成员函数的定义嵌套在类的作用域之内。其次,类的成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序,因为编译器首先编译成员的声明,然后才轮到成员函数体。

在类的外部定义成员函数

像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与内部的声明保持一致。如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定 const 属性。同时,类外部定义的成员的名字必须包含它所属的类名:

1
2
3
4
5
6
double Sales_data::avg_price() const {
if (units_sold)
return revenue/units_sold;
else
return 0;
}

函数名 Sales_data::avg_price() 使用作用域运算符来说明如下的事实:我们定义一个名为 avg_price() 的函数,并且该函数被声明在类 Sales_data 的作用域内。一旦编译器看到这个函数名,就能理解剩余的代码时位于类的作用域内的。

定义一个返回 this 对象的函数

函数 combine 的设计初衷类似于复合赋值运算符 +=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入参数:

1
2
3
4
5
6
7
8
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
units_sold += rhs.units_sold; // add the members of rhs into
revenue += rhs.revenue; // the members of ''this'' object
return *this; // return the object on which the function was called
}

total.combine(trans)

上面这个 return 返回 total 的调用。

7.1.3 定义类相关的非成员函数

类的作者常常需要当以一些辅助函数,比如 add、read 和 print 等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。

​ 我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果在函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。

一般来说,如果非成员函数时类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。

7.1.4 构造函数

每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;初次之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。

不同于其他成员函数,构造函数不能被声明成 const 的。当我们创建类的一个 const 对象时,直到构造函数完成初始化过程,对象才能真正取得其 “常量” 属性。因此,构造函数在 const 对象的构造过程中可以向其写值。

合成的默认构造函数

我们的 Sales_data 类并没有定义任何构造函数,可是之前使用了 Sales_data 对象的程序仍然可以正确地编译和运行。

类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。

如我们所见,默认构造函数在很多方面都具有其特殊性。其中之一时,如果我们的类没有显示地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。

编译器创建的构造函数被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员。
  • 否则,默认初始化该成员。

某些类不能依赖于合成的默认构造函数

合成的默认构造函数只适合非常简单的类。对于一个普通的类来说,必须定义它自己的默认构造函数,原因由三:第一个原因也是最容易理解的一个原因就是编译器只有在发现类不包含任何构造函数的情况下才会替我们生成了一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。这条规则的依据是,如果一个类再某种情况下需要控制对象初始化,那么该类很可能再所有情况下都需要控制。

第二个原因是对于某些类来说,合成的默认构造函数可能执行错误的操作。如果定义再块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则它们的值将是未定义的。该准则同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该阿紫类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。

第三个原因时有的时候编译器不能因为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。

= fault 的含义

从解释默认构造函数的含义开始:

1
Sales_data() = default;

首先明确一点,因为该构造函数不接受任何实参,所以它时一个默认构造函数。我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。

在 C++11 中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = default 来要求编译器生成构造函数。其中, = default 既可以和声明一起出现在类的内部,也可以定义出现在类的外部。和其他函数一样,如果 = default 在类的内部,则默认构造函数是内联的;如果它在类的外部,则将该成员默认情况下不是内联的。

构造函数初始值列表

​ 接下来我们介绍类中定义的另外两个构造函数:

1
2
Sales_data(const std::string &s): bookNo(s) { }
Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { }

​ 这两个定义中出现了新的部分,即冒号以及冒号和花括号之间的代码,其中花括号定义了(空的)函数体。我们把新出现的部分称为构造函数初始值列表(constructor initialize list),它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(包括在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。

通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

在类的外部定义构造函数

与其他几个构造函数不同,以 istream 为参数的构造函数需要执行一些实际的操作。在它的函数体内,调用了 read 函数以给数据成员赋以初值:

1
2
3
4
Sales_data::Sales_data(std::istream &is)
{
read(is, *this); // read will read a transaction from is into this // object
}

构造函数没有返回类型,所以上述定义从我们指定的函数名字开始。当我们在类的外部定义构造函数时,必须指明该构造函数时哪个类的成员。因此,Sales_data::Sales_data 的含义是我们定义 Sales_data 类的成员,它的名字是 Sales_data。又因为该成员的名字和类名相同,所以它是一个构造函数。这个构造函数的列表是空的,尽管构造函数初始值是空的,但是由于执行了构造函数体,所以对象的成员仍然能被初始化。没有出现在构造函数初始值列表中的成员将通过相应类内初始值(如果存在的话·)初始化,或者执行默认初始化。

7.1.5 拷贝、赋值和析构

除了定义类的对象如何初始化之外。类还需要控制拷贝、赋值和销毁对象时发生的行为。对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等。当我们使用了赋值运算符时会发生产生对象的赋值操作。当对象不再存在时执行销毁操作,比如一个局部对象回在创建它的块结束时会被销毁,当 vector 对象(或者数组)销毁时存储在其中的对象也会被销毁。

如果我们不主动定义这些操作,则编译器将会替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。

某些类不能依赖于合成的版本

管理动态内存的类通常不能依赖于上述操作的合成版本。

7.2 访问控制与封装

到目前为止,我们已经为类定义了接口,但并没有任何机制强制用户使用这些接口。我们的类还没有封装。也就是说,用户可以直达 Sales_data 对象的内部并且控制它的具体实现细节。在 C++ 中,我们使用访问说明符(access specifiers)加强类的封装性:

  • 定义在 public 说明符之后的成员在整个程序内可被访问,public 成员定义类的接口。
  • 定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private 部分封装了(即隐藏了)类的实现细节。

    一个类可以包含 0 个或多个访问说明符,而且对于某个访问说明符能出现多次次也没有严格限定。每个访问说明符制定了接下来的成员的访问级别,其有效范围直到下一次访问说明符或者到达结尾处为止。

使用 class 或 struct 关键字

class 和 struct 的默认访问权限不太一样。类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式如果我们使用 struct 关键字,则定义在第一个访问说明符之前的成员是 public 的;相反,如果我们使用 class 关键字,则这些成员是 private 的。

​ 出于统一编程风格的考虑,当我们希望定义的类的所有成员是 public 的时,使用 struct;反之,如果希望成员是 private 的,使用 class。

使用 class 和 struct 定义类的唯一区别就是默认的访问权限。

7.2.1 友元

​ 类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。如果类想把一个函数作为它的友元,只需要增加一条以 friend 关键字开始的函数声明语句即可。

友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。一般来说,最好在类定义开始或结束前的位置集中声明友元。

友元的声明

友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。

为了使友元对类的用户可见,我们通常把友元的声明与类本身防止再同一个头文件中(类的外部)。因此,我们的 Sales_data 头文件应该为 read、print 和 add 提供独立的声明(除了类内部的友元声明之外)。

许多编译器并未强制限定友元函数必须再使用之前在类的外部声明。

7.3 类的其他特性

7.3.1 类成员再谈

定义一个类型成员

除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型别名和其他成员一样存在访问限制,可以是 public 或者 private 中的一种。用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别,具体原因将在解释。因此,类型成员通常在类开始的地方出现。

可变数据成员

如果我们希望能够修改类的某个数据成员,即使是在一个 const 成员函数内。可以通过在变量的声明中加入 mutable 关键字做到这一点。

​ 一个可变数据成员(mutable data member)永远不会是 const,即使它是 const 对象的成员。因此,一个 const 成员函数可以改变一个可变成员的值。

类数据成员的初始值

提供一个类内初始值时,必须以符号 = 或者花括号表示。

7.3.3 类类型

​ 每个类定义了唯一的类型。对于两个类来收,即使它们的成员完全一样,这两个类也是两个不同的类型。

类的声明

1
class Screeen;

我们也能仅声明类而暂时不定义它。这种声明被称作前向声明(forward declaration),它向程序中引入了名字 Screen 并且指明 Screen 是一种类类型。对于类型 Screen 来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知 Screen 是一个类类型,但是不清楚它到底包含哪些成员。

不完全类型只能在非常有限的情境下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。

7.4 类的作用域

每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符访问。不问哪种情况,跟在运算符之后的名字都必须是对应类的成员。

7.4.1 名字查找与类的作用域

​ 到目前为止,我们编写的程序中,名字查找(name lookup)(寻找与所用名字最匹配的声明的过程)的过程比较直截了当:

  • 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
  • 如果没找到,继续查找外层作用域。
  • 如果最终没有找到匹配的声明,则程序报错。

    对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别,不过在当前的这个例子中体现得不太明显。类的定义分两部处理:

  • 首先,编译成员的声明。

  • 直到类全部可见后才编译函数体。

    编译器处理完类中的全部声明后才会处理成员函数的定义。

用于类成员声明的名字查找

这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。

7.5 构造函数再探

7.5.1 构造函数初始值列表

构造函数的初始值有时必不可少

​ 有时我们可以忽略数据成员的初始化和赋值之间的差异,但并非总能这样。如果成员是 const 或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。

成员初始化的顺序

成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。

默认实参和构造函数

如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

7.5.2 委托构造函数

​ 一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把自己的一些(或者全部)职责委托给了其他构造函数。

​ 和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟着圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。

7.5.3 默认构造函数的作用

​ 当对象被默认初始化或值初始化时,自动执行默认构造函数。默认初始化在以下情况下发生:

  • 当我们在块作用域内不使用任何初始值顶一个非静态变量或者数组时。

  • 当一个类本身含有类类型的成员且使用合成的默认构造函数时。

  • 当类类型的成员没有在构造函数初始值列表中显式地初始值中显示地初始化时。

    值初始化在以下情况发生:

  • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。

  • 当我们不使用初始值定义一个局部静态变量时。

  • 当我们通过书写形如 T()的表达式只接受一个实参用于说明 vector 大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化。

7.5.4 隐式的类类型转换

​ 如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种那个构造函数称作转换构造函数(converting constructor)。

只允许一步类类型转换

1
2
3
4
5
6
7
8
9
// error: requires two user-defined conversions:
// (1) convert "9-999-99999-9" to string
// (2) convert that (temporary) string to Sales_data
item.combine("9-999-99999-9");

// ok: explicit conversion to string, implicit conversion to Sales_data
item.combine(string("9-999-99999-9"));
// ok: implicit conversion to string, explicit conversion to Sales_data
item.combine(Sales_data("9-999-99999-9"));

抑制构造函数定义的隐式转换

​ 在要求隐式转换的程序中,我们可以通过将构造函数声明为 explicit 加以阻止,explicit 函数只能用于直接初始化。

为转换显式地使用构造函数

​ 尽管编译器不会将 explicit 的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:

1
2
3
4
// ok: the argument is an explicitly constructed Sales_data object
item.combine(Sales_data(null_book));
// ok: static_cast can use an explicit constructor
item.combine(static_cast<Sales_data>(cin));

7.5.5 聚合类

​ 聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它时聚合的:

  • 所有成员都是 public 的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有 virtual 函数。

​ 我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员。初始值的顺序必须与声明的顺序一致。与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。

​ 显式地初始化类的对象的成员存在三个明显的缺点:

  • 要求类的所有成员都是 public 的。
  • 将正确初始化每个对象的每个成员的重任嫁给了类的用户(而非类的作者)。因为用户很容易忘记掉某个初始值,后者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
  • 添加或删除一个成员之后,所有的初始化语句都需要更新。

7.5.6 字面值常量类

​ 数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

  • 数据成员都必须是字面值类型。
  • 类必须至少含有一个 constexpr 构造函数。
  • 如果一个数据成员含有类内初始值,则内置类型的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的 constexpr 构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

constexpr 构造函数

​ 尽管构造函数不能是 const 的,但是字面值常量类的构造函数可以是 constexpr 函数。事实上,一个字面值常量类比如至少提供一个 constexpr 构造函数。

​ constexpr 函数可以声明成 =default 的形式(胡这是删除函数的形式),否则,constexpr 构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又符合 constexpr 函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点可知,constexpr 构造函数体一般来说应该是空的。我们通过前置关键词 constexpr 就可以声明一个 constexpr 构造函数了。

7.6 类的静态成员

有时候类需要它的一些成员与类的本身直接相关,而不是与类的各个对象保持关联。

声明静态成员

​ 我们通过在成员的声明之前加上关键字 static 使得其与类关联在一起。和其他成员一样,静态成员可以是 public 的或 private 的。静态数据成员可以是常量、引用、指针、类类型等。

​ 类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。类似的,静态成员函数也不与任何对象绑定在一起,它们不包含 this 指针。作为结果,静态成员函数不能声明成 const 的,而且我们也不能在 static 函数体内使用 this 指针。这一限制既适用于 this 的显式使用,也对调用非静态成员的隐式使用有效。

使用类的静态成员

​ 使用作用域运算符直接访问静态成员。虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员。成员函数不用过作用域运算符就能直接使用静态成员。

定义静态成员

​ 和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复 static 关键字,该关键字只出现在类内部的声明语句。

​ 和类的所有成员一样,当我们指向类外部的静态成员时,必须指明成员所属的类名。static 关键字则只出现在类内部的声明语句中。

​ 因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能再类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。

​ 类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在与程序的整个声明周期之中。

​ 要想确保对象只定义一次,最好的方法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。

静态成员的类内初始化

​ 通常情况下,类的静态成员不应该再类的内部初始化。然而,我们可以为静态成员提供 const 整数类型的类内初始值。不过要求静态成员必须是字面值常量类型的 constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在适合于常量表达式的地方。

# C++

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×