C++ primer 第7章 类
文章目录
- C++中可用类定义自己的数据类型
- 数据抽象能帮助将对象的具体实现与对象能执行的操作分离
- 类的基本思想是
数据抽象
和封装
数据抽象
:接口和实现分离的编程/设计技术。接口
包括用户能执行的操作,实现
包括类的数据成员、接口实现的函数体、定义类所需的各种私有函数封装
:实现了类的接口和实现的分离,封装后的类隐藏了它的实现细节。用户只能使用接口而无法访问实现部分- 类要实现数据抽象和封装,需定义一个
抽象数据类型
,由类的设计者考虑类的实现,使用类的程序员只需知道类做了什么,不需了解工作细节
定义抽象数据类型
- 允许用户访问其所有成员且需要用户实现操作的类不是抽象数据类型。
设计Sales_data类
- 类的用户是程序员,而非程序的最终使用者
- 设计类的接口时应考虑如何才能易于使用,使用时不应再顾及实现机制
- 设计良好的类既要有直观且易于使用的接口,也要有高效的实现
定义改进的Sales_data类
成员函数
的声明必须在类的内部,但其定义可在类内或类外。若定义也在类内,就是隐式的inline函数
- 组成接口的非成员函数其声明和定义都在类外
- 调用成员函数时,是在替对象调用。成员函数通过名为
this指针
的隐式形参
来访问调用它的对象,调用成员函数时,用请求该函数的对象地址初始化this指针 this指针
是成员函数的隐式形参
,调用时传入的实参是对象的地址
- 成员函数体内部可直接使用调用它的对象的成员,不需要显式写出this和成员访问符。因为
成员函数定义的作用域在类作用域的内部
(不管是类内定义还是类外定义) - 任何自定义名为this的变量都是非法的
- 例子:this指针隐式传入对象地址
|
|
this指针是一个常量指针
,对象创建后this指针指向的地址不会变。- 默认
this指针指向的对象非常量
,因此不能在const对象上调用普通成员函数。 const成员函数
:在成员函数声明时,形参列表后加const关键字。这个const使得隐式传入该函数的this指针成为指向常量的指针
,因此const成员函数不可修改对象内容,可被const对象调用。- 常量对象、常量对象的指针/引用,都只能调用const成员函数
- 将成员函数设置为const有助于提高灵活性,使const对象和非const对象都可调用。
- 例子:const成员函数传入指向const对象的this指针
|
|
类本身就是一个作用域
,成员函数的定义嵌套在类的作用域中,无论成员函数在类的内部还是外部定义- 编译器分
两步处理类
(因此成员函数体可随意使用类中的成员,无需在意它们出现的顺序):- 编译成员的声明
- 如果有函数体,再编译函数体
- 在类外部定义成员函数时,定义必须与类内的声明匹配。如类内被声明为const成员函数,则定义时也需要const
- 在类外部定义的成员,其名字必须包含类名的
作用域
。这时,其后的形参列表和函数体都会属于该类的命名空间,但前面的返回类型不属于。 - 成员函数也可
返回this指针
,将返回类型写为类名,返回用return *this;
即可。若要返回左值
,则需将返回类型写为引用。 - 例子:成员函数返回this指针
|
|
定义类相关的非成员函数
- 如果函数概念上属于类(即,是类接口的一部分)但不是类的成员,则它一般与类声明在同一头文件中
- IO类是不能被拷贝的类型,只能用引用传参。由于读写会改变流,使用时都是普通引用,不是常量引用
- 默认情况下,拷贝类的对象其实是拷贝数据成员
构造函数
构造函数
:控制类对象的初始化过程- 构造函数的名字和类名相同,没有返回类型
- 只要类的对象被创建,就会执行构造函数。
- 每个类有一个或多个构造函数,类似于重载,以形参列表区分。
构造函数不能是const成员函数
,因为构造时要改变类。即使是const对象,也是完成初始化过程后才成为常量,初始化过程中可写- 类默认初始化时,调用
默认构造函数
,它不需要任何实参 - 如果类没有显式定义构造函数,编译器会隐式定义默认构造函数,被称为
合成的默认构造函数
- 合成的默认构造函数对成员初始化:
- 如果成员存在类内初始值,则用该值初始化
- 如果成员没有类内初始值,则用默认初始化
类内初始值
:C++11允许成员在类中声明时同时提供一个初始值,该初始值会在构造时被构造函数使用。- 经常
需要定义自己的默认构造函数
,原因:- 只有类没有声明任何构造函数时才会合成构造函数。(如果一个类在某种情况下需要手动控制初始化,则编译器认为它在任何情况下都需要手动控制初始化)
- 合成的默认构造函数可能产生未定义,如块中的内置类型或复合类型(引用/指针)未提供类内初始值时,它们被默认初始化的值未定义
- 编译器不能为某些类合成默认构造函数,如类内的某成员不存在默认构造函数,因而不能被默认初始化时
- 可用
constructor()=default;
手动定义一个构造函数由编译器合成。它可用于类内和类外的定义。 构造函数初值列表
:负责初始化新创建的对象的一些成员。形式是成员名字的列表,每个名字后紧跟括号(或花括号)括起来的初值- 被构造函数初值列表忽略的成员,会执行类似合成默认构造函数的隐式初始化。(即初始化为
类内初始值
,或默认初始化
) - 成员
初始化
流程(优先级:构造函数初值列表->类内初始值->默认初始化
):- 如果成员在构造函数初值列表中,则初始化为
构造函数初值列表
提供的值 - 如果成员不在构造函数初值列表中但有类内初始值,则初始化为
类内初始值
- 如果成员既不在构造函数初值列表中又没有类内初始值,则
默认初始化
- 如果成员在构造函数初值列表中,则初始化为
- 每个内置类型成员都应被构造函数初值列表或类内初始值初始化,否则默认初始化为未定义
- 例子:构造函数初值列表
|
|
拷贝、赋值和析构
- 对象在一些情况下会被
拷贝
,如初始化变量,以及以传值方式传递一个对象等 - 使用赋值运算符
=
时,对象会被赋值
- 对象不存在时需要
析构
- 如果不手动定义拷贝、赋值、析构,编译器也会自动合成它们。这样的版本将对对象的每个成员分别拷贝、赋值、析构
- 对于某些类,合成的版本无法正常工作。特别是当类需要
分配对象之外的资源
时,例如管理动态内存的类 - 一些需要管理动态内存的类可通过
vector
或string
管理,它们可被合成的版本正确拷贝、赋值、析构 - 在不手动定义拷贝、赋值、析构的情况下,类中分配的资源都应以数据成员的形式存储。
访问控制与封装
访问说明符
可加强类的封装性- 定义在
public
说明符后的成员在整个程序内可被访问,即public定义接口
- 定义在
private
说明符后的成员只能在类内被访问,即private封装实现
- 定义在
- 一个类可包含0个或多个访问说明符,对它们的出现次数无限定。
- 每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或到达类结尾
- 用
struct
定义类,则定义在第一个访问说明符之前的成员默认是public。用class
定义类,则定义在第一个访问说明符之前的成员默认是private。struct和class的唯一区别是默认访问权限
友元
友元
:类可允许其他类或函数访问它的非公有成员,方法是令其他类或函数成为它的友元- 要把一个函数声明为类的友元,只需在类中加一条
friend
关键字开头的函数声明。 - 友元声明必须在类的内部,但最好在类的开始或结束处集中声明
- 友元不是类的成员,也不受它所在的区域的访问控制级别的约束
- 封装的好处:
- 确保用户代码不会无意间破坏类的状态
- 被封装的类的细节可随时改变。只要接口不变就不需要修改用户代码
- 友元的声明仅指定访问权限,不是通常意义的声明。如果要使用,还需要在类外独立声明一次。
- 为使友元对类的用户可见,通常把友元的独立声明和类的声明放在同一头文件
类的其他特性
类成员再探
- 类可以自定义某种类型在其中的别名,这些类型别名和其他名字一样受访问控制的约束
- 用于定义类型的类型成员必须先定义后使用,这与普通成员不一样。因此
类型成员通常出现在类开始处
- 定义在类内的成员函数是隐式inline函数,在类外定义的成员函数也可使用inline关键字显式定义为inline函数
- 可以但没必要在成员函数声明和定义处都说明inline,最好只在类外定义时才用
- inline成员函数可重复定义,只需多个定义相同。因此应和类定义在同一头文件中
- 成员函数可
重载
,其匹配过程类似于非成员函数的匹配 可变数据成员
:这种成员永远不会是const,即使是在const对象内。只需在成员声明前加mutable
关键字- const成员函数可修改mutable数据成员
类内初始值
:如果构造函数初值列表中无此成员,但该成员在类内声明时提供了一个初始值,则该成员被初始化为该初始值。- 类内初始值只能用
=
或{}
的初始化形式,不可用()
,因为会和成员函数声明混淆。
返回*this的成员函数
- 如果返回
*this
的成员函数其返回类型是引用,则返回的是对象本身而不是副本,是左值
- 如果返回
*this
的成员函数其返回类型不是引用,则返回类型是*this
的副本,是对象的拷贝 - 若const成员函数以引用形式返回
*this
,则其返回的是常量引用。因为传入const成员函数的*this
就是指向常量的指针 - 通过区分成员函数是否是const的,可对其重载。原因是传入的隐式形参
*this
有底层const
的差异 - 将返回
*this
的const成员函数重载为非const成员函数是有必要的,因为非const对象调用它时希望返回一个非const引用 - const对象上只能调用const成员函数,非const对象优先调用非const成员函数
- 例子:重载出返回*this的const和非const的成员函数
|
|
- 如上例这种在底层实现函数外套一层接口的方式不会增加运行时开销,因为定义于类内的成员函数都是内联的
类类型
- 每个类定义了唯一的类型,两个类即使成员一样也是不同的类型
- 可以把类的名字作为类型直接使用,也可把类名跟在关键字class或struct后
- 例子:声明类对象的两种方式
|
|
前向声明
:可以仅声明类而不定义它,只是向程序中引入名字并指明它是一种类类型不完全类型
:在声明之后,定义之前,类是不完全类型- 不完全类型使用的场景很狭窄,只用于
既不需要成员也不需要实体
的情形,如:可以定义指向不完全类型的指针/引用,可以声明(但不能定义)以不完全类型作为形参/返回类型的函数 - 在创建类对象前,类必须有完整的定义,编译器才能知道它需要多少空间。同理,类必须先定义才能用指针/引用访问类的成员
- 一旦类名出现,就认为声明过了(而非定义),因此
类允许包含指向它自身类型的指针/引用
(此时类是不完全类型) - 例子:类包含指向它自身类型的指针/引用
|
|
友元再探
- 类可将其他类、其他类的成员函数定义为友元。
- 友元可被定义在类内部,这时是内联的
- 如果一个类指定了
友元类
,则友元类的成员函数都可访问此类包括private成员在内的所有成员 友元关系没有传递性
,即A是B的友元,B是C的友元,这时A并不是C的友元- 可把另一个类的成员函数作为友元,只是要用类名指定作用域
- 如果一个类要把一组重载函数声明为友元,则需对每一个函数分别声明才行
类
和非成员函数
的声明并不需要在友元声明之前。当一个名字第一次出现在友元声明中时,隐式假定该名字在当前作用域中可见。但友元不一定真的要声明在当前作用域中。- 但
类的成员函数
的声明必须在友元声明之前(因为需要类提供作用域?)。即,如果类A
的成员函数f
是类B
的友元,则声明顺序为:- 定义
A
类并在其中声明成员函数f
,但此时不能定义。因为定义需要用到B
的成员。 - 定义
B
类并在其中声明A::f
为友元 - 定义
A::f
,这时它可使用B
的成员
- 定义
- 友元声明的作用是影响访问权限,它不是普通意义上的声明
- 即使友元函数在类的内部被定义了,它也必须在类外独立声明,这样才能可见。
类的作用域
- 每个类都定义自己的作用域,一个类就是一个作用域
- 类外定义成员函数时,一旦遇到了类名,则定义的其余部分(形参列表和函数体)就都在类的作用域内
- 类外定义成员函数时,返回类型在类名之前,故不在类的作用域中,若需用到类中的类型需手动指定
名字查找与类的作用域
名字查找
的过程:- 在名字所在的块中寻找声明,只考虑在使用处之前的声明
- 如果没找到,继续查找外层作用域
- 如果最终没找到声明,报错
类的定义
过程:- 编译成员的声明
- 直到类全部可见后才编译函数体
- 成员函数体直到整个类可见后才会被处理,因此可使用类中定义的任何名字
- 声明中使用的名字,包括返回类型/形参列表中使用的名字,都应该在使用前确保可见
- 内层作用域中可重新定义外层作用域中的名字
- 在类中,如果成员使用了外层作用域中定义的
类型
,则类不能在之后再定义该名字 - 类型名通常在类的开始处定义,用于确保所有使用它的成员都出现在类型的定义之后
- 例子:类型名特殊处理
|
|
- 成员函数中使用的
名字查找
过程:- 在成员函数内部查找,只考虑使用前的声明
- 若成员函数内没找到,在类内继续查找,类的所有成员都被考虑
- 若类内没找到,则在成员函数定义之前(只看该函数定义的位置,与类定义的位置无关)的作用域内查找
- 不建议用其他成员的名字作为成员函数的形参,因为在函数体内会屏蔽外面的同名实体(包括类的成员)。这时可手动指定作用域
- 例子:手动指定作用域
|
|
- 当成员函数定义在类外部时,名字查找第3步不仅要考虑类定义之前的全局作用域,还要考虑成员函数定义之前的全局作用域
构造函数再探
构造函数初值列表
- 如果未在
构造函数初值列表
中显式初始化成员,且未提供类内初始值
,则该成员在执行构造函数体之前被默认初始化
。 - 在构造函数初值列表中显式初始化成员相当于
初始化
,在构造函数体内赋值相当于赋值
- 如果成员是
引用
、const成员
等不可被赋值的类型,或是某种不可被默认初始化
的类型(未提供默认构造函数),则它们只能放在构造函数初值列表中初始化,或提供类内初始值 - 最佳实践:类的成员都放在构造函数初值列表中初始化,或提供类内初始值
- 构造函数初值列表只说明用于初始化的值,并未规定
求值顺序
- 成员的初始化顺序只与成员在类定义中出现的
声明顺序
一致,与在构造函数初值列表中的顺序无关 - 最佳实践:
- 最好令构造函数初值列表的顺序与成员声明顺序一致
- 尽量避免用构造函数初值列表中的一个名字初始化其中的另一个名字
- 例子:构造函数初值列表未规定求值顺序
|
|
- 构造函数中也可用
默认实参
。若一个构造函数为所有形参提供了默认实参,它实际上定义了默认构造函数
委托构造函数
委托构造函数
:使用它所属类的其他构造函数执行初始化。即,将其职责委托给其他构造函数- 在委托构造函数的初值列表内只允许有一个名字,即类名,它的参数列表必须与另一个构造函数匹配,表示初始化时是在调用这个构造函数
- 例子:委托构造函数
|
|
执行顺序
:当一个构造函数委托给另一个构造函数时,受委托的构造函数的初值列表和函数体依次执行,然后才执行委托构造函数的函数体
默认构造函数的作用
- 当对象被默认初始化时执行默认构造函数。
默认初始化
发生的情形:- 块定义域内不使用初始值就定义一个非静态变量/数组
- 类本身含有类类型成员并使用合成的默认构造函数
- 类类型成员没有在构造函数初值列表中显式初始化
值初始化
发生的情形- 数组初始化时提供的初始值小于数组大小
- 不使用初始值定义一个局部静态变量
- 书写
T()
的表达式进行显式值初始化,例如vector仅指定元素数量时
- 使用默认构造函数时,不可用括号
- 例子:使用默认构造函数
|
|
隐式的类类型转换
转换构造函数
:如果构造函数只接受一个实参,则它实际上定义了由那种类型转换为该类类型的隐式转换- 编译器只能执行一步隐式类型转换
- 例子:转换构造函数一步转换
|
|
- 可将构造函数声明为
explicit
以禁止发生隐式类型转换,但仍可用static_cast
做显式类型转换,此时会创建一个临时量 - explicit只对一个实参的构造函数使用,其他构造函数也不会被隐式转换。
- 只能在类内声明构造函数时使用explicit,类外定义时不应重复
- explicit声明构造函数时,只能直接初始化(调用构造函数),不能用隐式转换拷贝初始化
- 例子:explicit
|
|
- 接受单一参数
const char *
的string构造函数不是explicit - 接受单一容量参数的vector构造函数是explicit
聚合类
聚合类
:满足以下条件:- 所有成员都public
- 没有定义构造函数
- 没有类内初始值
- 没有基类和虚函数
- 可以提供花括号的初值列表用于
列表初始化
聚合类的数据成员,初始值的顺序必须和声明顺序一致 - 如果花括号初始化聚合类时参数较少,则靠后的成员被
值初始化
,类似数组 - 像这样显式列表初始化类的对象的成员的缺点:
- 要求成员都public
- 将正确初始化的任务交给用户
- 修改成员时需要修改用户代码
字面值常量类
- 一般的字面值常量是算术类型、引用/指针,某些类也可以是字面值类型
- 字面值类型的类可能含有constexpr成员,这样的成员必须符合constexpr的所有要求,它是隐式的const成员
- 数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类但满足以下要求,也是字面值常量类:
- 数据成员都是字面值类型
- 至少有一个constexpr构造函数
- 若数据成员有类内初始值,则内置类型成员的初值必须是常量表达式,类类型成员的初值必须用它自己的constexpr构造函数
- 必须用析构函数的默认定义
- 虽然构造函数不能是const的,但字面值常量类的构造函数可以是constexpr的,且必须提供至少一个constexpr构造函数
- constexpr构造函数可声明为
=default
或删除函数
。否则,constexpr构造函数必须既是构造函数(不能return)又是constexpr函数(唯一可执行的语句就是return)。因此constexpr函数体一般是空的
,只使用初值列表 - constexpr构造函数必须初始化所有数据成员,初始值或者用常量表达式(内置类型),或者用constexpr构造函数(类类型)
- constexpr构造函数用于生成constexpr对象以及constexpr函数的参数/返回类型
- 例子:字面值常量类和constexpr构造函数
|
|
类的静态成员
- 类的
静态成员
只与类本身相关,与其任何对象都无关。形式是在成员声明前加static
关键字 - 静态成员可以是public或private,类型可是常量、引用、指针、类类型等
- 类的静态成员存在于任何对象之外,任何对象中都不包含与之相关的数据
- 静态成员不与任何对象绑定,故不存在
this指针
。因此既不能在函数体内使用this指针,也不能被声明为const成员函数。 - 静态成员函数调用非静态成员时,并不知道是哪个对象的成员
- 可用类的
作用域运算符
直接访问静态成员,也可用类的对象、引用、指针来访问静态成员 - 成员函数不用通过作用域运算符就可访问静态成员
静态成员函数
可在类内或类外定义,在类外定义时不可重复static关键字,static只出现在声明中
。静态数据成员
并非在创建类时被定义,因此静态数据成员不由构造函数初始化
。- 不能在类内部初始化
静态数据成员
,静态数据成员必须在类外定义和初始化
,一个静态数据成员只能被定义一次 - 静态数据成员定义在任何函数之外,一旦被定义就存在于程序整个生命周期。
- 为确保静态数据成员只被定义一次,最好将其定义与其他非内联函数的定义放在同一头文件
静态成员函数可在类内和类外定义,静态数据成员只能在类外定义和初始化
- 例子:声明、定义、访问静态成员
|
|
- 通常,类的静态数据成员不应在类内初始化。特例是,可为静态数据成员提供
const整型
的类内初始值
,且该静态数据成员必须是constexpr类型
,初值必须是常量表达式。它们可用到任何需要常量表达式的地方 - 例子:类内初始化的静态数据成员必须是字面值常量类型的constexpr
|
|
- 如果某个静态成员的应用场景仅限于编译器可替换其值的情况,则一个初始化的const或constexpr static不需要分别定义。相反,如果将其用于值不能替换的场景,则该成员必须有定义语句。
- 例子:必须在类外定义的情形
|
|
- 如果类内部提供了一个初值,则成员定义不可再提供初值。
- 即使一个常量静态数据成员在类内被初始化了,通常也应在类外部定义一下(不提供初值)
- 在某些非静态成员非法的场合,静态成员可正常使用,如静态成员可以是不完全类型,特别的,静态数据成员的类型可以是他所属的类类型。而非静态数据成员只能被声明为它所属的类的指针或引用
- 例子:静态数据成员的类型可以是他所属的类类型
|
|
- 可用静态成员做默认实参,非静态不可,因为它的值属于对象的一部分,读到声明时类未完全定义
- 例子:静态成员做默认实参
|
|