[C++]C++Primer Chapter 7
类
类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。
类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。
7.1 定义抽象数据类型
7.1.1 设计Sales_data类
由于14.1节(第490页)将要解释的原因,执行加法和IO的函数不作为Sales_data的成员,相反的,我们将其定义成普通函数;执行复合赋值运算的函数是成员函数。
7.1.2 定义改进的Sales_data类
引入this
this是一个常量指针,指向“这个”对象。
引入const成员函数
把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数(const member function)。
常量对象,以及常量对象的引用或指针都只能调用常量成员函数。
类作用域和成员函数
编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
在类的外部定义成员函数
定义一个返回this对象的函数
内置的赋值运算符把它的左侧运算对象当成左值返回(参见4.4节,第129页),因此为了与它保持一致,combine函数(重载赋值运算符)必须返回引用类型(参见6.3.2节,第202页)。因为此时的左侧运算对象是一个Sales_data的对象,所以返回类型应该是Sales_data&。
7.1.3 定义类相关的非成员函数
我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来(参见6.1.2节,第168页)。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。
一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。
定义read和print函数
第一点,read和print分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们(参见6.2.2节,第188页)。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。
第二点,print函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。
定义add函数
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。
不同于其他成员函数,构造函数不能被声明成const的(参见7.1.2节,第231页)。
合成的默认构造函数
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。
如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
如果存在类内的初始值(参见2.6.1节,第64页),用它来初始化成员。
否则,默认初始化(参见2.2.1节,第40页)该成员。
某些类不能依赖于合成的默认构造函数
只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。
如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。
如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。
= default的含义
在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上= default来要求编译器生成构造函数。其中,= default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果= default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。
构造函数初始值列表
构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
7.1.5 拷贝、赋值和析构
7.2 访问控制与封装
在C++语言中,我们使用访问说明符(access specifiers)加强类的封装性
定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。
7.2.1 友元
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。
一般来说,最好在类定义开始或结束前的位置集中声明友元。
关键概念:封装的益处
封装有两个重要的优点:
确保用户代码不会无意间破坏封装对象的状态。
被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。
友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。
为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。因此,我们的Sales_data头文件应该为read、print和add提供独立的声明(除了类内部的友元声明之外)。
7.3 类的其他特性
7.3.1 类成员再探
除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public或者private中的一种。用来定义类型的成员必须先定义后使用。
令成员作为内联函数
我们可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义:虽然我们无须在声明和定义的地方同时说明inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明inline,这样可以使类更容易理解。
inline成员函数也应该与相应的类定义在同一个头文件中。
重载成员函数
可变数据成员
有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。
一个可变数据成员(mutable data member)永远不会是const,即使它是const对象的成员。
类数据成员的初始值
类内初始值必须使用=的初始化形式(初始化Screen的数据成员时所用的)或者花括号括起来的直接初始化形式(初始化screens所用的)。
当我们提供一个类内初始值时,必须以符号=或者花括号表示。
7.3.2 返回*this的成员函数
从const成员函数返回*this
一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。
基于const的重载
通过区分成员函数是否是const的,我们可以对其进行重载
建议:对于公共代码使用私有功能函数
7.3.3 类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。
类的声明
我们也能仅仅声明类而暂时不定义它
这种声明有时被称作前向声明(forward declaration),它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
一个类的成员类型不能是该类自己。然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针
7.3.4 友元再探
类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
类之间的友元关系
友元关系不存在传递性。
令成员函数作为友元
除了令整个Window_mgr作为友元之外,Screen还可以只为clear提供访问权限。当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类
首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen。
接下来定义Screen,包括对于clear的友元声明。
最后定义clear,此时它才可以使用Screen的成员。
函数重载和友元
尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中(参见7.2.1节,第241页)。
甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的
友元声明的作用是影响访问权限,它本身并非普通意义上的声明。
7.4 类的作用域
作用域和定义在类外部的成员
一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。
函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。
7.4.1 名字查找与类的作用域
编译器处理完类中的全部声明后才会处理成员函数的定义。
用于类成员声明的名字查找
这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
类作用域之后,在外围的作用域中查
尽管外层的对象被隐藏掉了,但我们仍然可以用作用域运算符访问它。
7.5 构造函数再探
7.5.1 构造函数初始值列表
随着构造函数体一开始执行,初始化就完成了。
建议:使用构造函数初始值
在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。
成员初始化的顺序
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
默认实参和构造函数
7.5.2 委托构造函数
C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
当这些受委托的构造函数执行完后,接着执行istream&构造函数体的内容。
7.5.3 默认构造函数的作用
默认初始化在以下情况下发生:
当我们在块作用域内不使用任何初始值定义一个非静态变量(参见2.2.1节,第39页)或者数组时(参见3.5.1节,第101页)。
当一个类本身含有类类型的成员且使用合成的默认构造函数时(参见7.1.4节,第235页)。
当类类型的成员没有在构造函数初始值列表中显式地初始化时(参见7.1.4节,第237页)。
值初始化在以下情况下发生:
在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时(参见3.5.1节,第101页)。
当我们不使用初始值定义一个局部静态变量时(参见6.1.1节,第185页)。
当我们通过书写形如T( )的表达式显式地请求值初始化时,其中T是类型名(vector的一个构造函数只接受一个实参用于说明vector大小(参见3.3.1节,第88页),它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。
7.5.4 隐式的类类型转换
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)。
能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。
只允许一步类类型转换
编译器只会自动地执行一步类型转换
抑制构造函数定义的隐式转换
关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit的。只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复
explicit构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)(参见3.2.1节,第76页)。此时,我们只能使用直接初始化而不能使用explicit构造函数
为转换显式地使用构造函数
7.5.5 聚合类
聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
所有成员都是public的。
没有定义任何构造函数。
没有类内初始值(参见2.6.1节,第64页)。
没有基类,也没有virtual函数
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员
初始值的顺序必须与声明的顺序一致,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化(参见3.5.1节,第101页)。
7.5.6 字面值常量类
数据成员都是字面值类型的聚合类(参见7.5.5节,第266页)是字面值常量类。
如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:
数据成员都必须是字面值类型。
类必须至少含有一个constexpr构造函数。
如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式(参见2.4.4节,第58页);或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
类必须使用析构函数的默认定义,该成员负责销毁类的对象(参见7.1.5节,第239页)。
constexpr构造函数
尽管构造函数不能是const的(参见7.1.4节,第235页),但是字面值常量类的构造函数可以是constexpr(参见6.5.2节,第213页)函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。
constexpr构造函数可以声明成= default(参见7.1.4节,第237页)的形式(或者是删除函数的形式,我们将在13.1.6节(第449页)介绍相关知识)。否则,constexpr构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句(参见6.5.2节,第214页))。综合这两点可知,constexpr构造函数体一般来说应该是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了
constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数,或者是一条常量表达式。constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型
7.6 类的静态成员
声明静态成员
我们通过在成员的声明之前加上关键字static使得其与类关联在一起。和其他成员一样,静态成员可以是public的或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。
静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果,静态成员函数不能声明成const的,而且我们也不能在static函数体内使用this指针。
使用类的静态成员
使用作用域运算符直接访问静态成员。
定义静态成员
当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr(参见7.5.6节,第267页)。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。
即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。
静态成员能用于某些场景,而普通成员不能
静态数据成员可以是不完全类型(参见7.3.3节,第249页)。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参