《C++ Primer 第五版》读书笔记 - 第一部分
C++基础
第2章 变量和基本类型
基本内置类型
算术类型
类型char
和类型signed char
并不一样。类型char
实际上会表现为有符号和无符号中的一种,具体是哪种由编译器决定。
字面值常量
十进制字面值的类型是能容纳其数值的int
、long
、long long
中尺寸最小的。八进制和十六进制字面值的类型是能容纳其数值的int
、unsigned int
、long
、unsigned long
、long long
和unsigned long long
中的尺寸最小者。
泛化的转义序列形式是\x
后紧跟1个或多个十六进制数字,或者\
后紧跟1个、2个或3个八进制数字,其中数字部分表示的是字符对应的数值。
如果反斜线后面跟着的八进制数字超过3个,只有前3个数字与构成转义序列。例如,\1234
表示2个字符,即八进制数123对应的字符以及字符4。相反,\x
要用到后面跟着的所有数字,例如,\x1234
表示一个16位的字符,该字符由这4个十六进制数所对应的比特唯一确定。
变量
变量定义
如果我们使用列表初始化且初始值存在丢失信息的风险,则编译器将报错:
1 | long double ld = 3.1415926536; |
获取全局作用域内的变量::variable
。
复合类型
引用
因为引用本身不是一个对象,所以不能定义引用的引用。
理解复合类型的声明
1 | int *&r = p; // r是一个对指针p的引用 |
从右向左阅读定义。
const限定符
默认情况下,const
对象被设定为仅在文件内有效。可以加extern
在多个文件内共享。
const的引用
对const的引用可能引用一个非const的对象:
1 | int i = 42; |
指针和const
指向常量的指针也没有规定其所指的对象必须是一个常量。所谓指向常量的指针仅仅要求不能通过该指针改变对象的值,而没有规定那个对象的值不能通过其他途径改变。
顶层const
用名词顶层const
表示指针本身是个常量,而用名词底层const
表示指针所指的对象是一个常量。
更一般的,顶层const
可以表示任意的对象是常量。底层const
则与指针和引用等复合类型的基本类型部分有关。
当执行对象的拷贝操作时,顶层const
不受什么影响;拷入和拷出的对象必须具有相同的底层const
资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行。
1 | int i = 0; |
constexpr和常量表达式
常量表达式是指值不会改变并且在编译过程就能得到计算结果的表达式。
将变量声明为constexpr
类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr
的变量一定是一个常量,而且必须用常量表达式初始化。
在constexpr
声明中如果定义了一个指针,限定符constexpr
仅对指针有效,与指针所指的对象无关。
1 | const int *p = nullptr; // p是一个指向整型常量的指针 |
处理类型
类型别名
使用别名声明:
1 | using SI = Sales_item; // SI是Sales_item的同义词 |
1 | typedef char *pstring; |
auto类型说明符
使用auto
声明多个变量时,所有变量的初始基本数据类型都必须一样。
因为使用引用实际上是使用引用的对象,所以以引用对象的类型作为auto
的类型。
auto
一般会忽略顶层const
,保留底层const
。如果希望推断出一个顶层const
,需要明确指出:
1 | const auto f = ci; |
decltype类型说明符
decltype
选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,但是不计算表达式的值。decltype
返回包括顶层const
和引用在内。
1 | const int ci = 0, &cj = ci; |
1 | int i = 42, *p = &i, &r = i; |
赋值是会产生引用的一类典型表达式,引用的类型就是左值的类型。
第3章 字符串、向量和数组
命名空间的using声明
每个using
声明引入命名空间中的一个成员。位于头文件的代码一般来说不应该使用using
声明。
标准库类型string
定义和初始化string对象
用=
执行拷贝初始化,不用=
执行直接初始化。如果对多个值进行拷贝初始化,需要创建一个临时对象:
1 | string s = string(10, 'c'); |
string对象上的操作
getline
函数的参数是一个输入流和一个string
对象,函数从给定的输入流中读入内容,直到遇到换行符为止(注意换行符也被读进来了),然后把所读的内容存入到那个string
对象中去(注意不存换行符)。
size
函数返回的是一个string::size type
类型的值,它是一个无符号类型的值而且能足够存放下任何string
对象的大小。
1 | string s = ("hello" + ", ") + s2; // 不合法:不能把字符串字面值相加 |
处理string对象中的字符
范围for
语句遍历给定序列中的每个元素并对序列中的每个值执行某种操作,其语法形式是:
1 | for (declaration: expression) |
expression
部分是一个对象,用于表示一个序列。declaration
部分负责定义一个变量,该变量将被用于访问序列中的基础元素。每次迭代,declaration
部分的变量会被初始化为expression
部分的下一个元素值。
如果想要改变序列中元素的值,必须把循环变量定义成引用类型。
标准库类型vector
定义和初始化vector对象
如果用的是花括号,可以表述成我们想列表初始化该vector
对象。也就是说,初始化过程会尽可能地把花括号内的值当成是元素初始值的列表来处理,只有在无法执行列表初始化时会考虑其他初始化方式。
1 | vector<int> v1(10); // 10个元素,每个都是0 |
向vector对象中添加元素
范围for
语向体内不应改变其所遍历序列的大小。
其他vector操作
vector
对象(以及string
对象)的下标运算符可用于访问已存在的元素,而不能用于添加元素。
迭代器介绍
使用迭代器
如果容器为空,则begin
和end
返回的是同一个迭代器,都是尾后迭代器。
const_iterator
和常量指针差不多,能读取但不能修改它所指的元素值。相反,iterator
的对象可读可写。如果vector
对象或string
对象是一个常量,只能使用const_iterator
;如果vector
对象或string
对象不是常量, 那么既能使用iterator
也能使用const_iterator
。
但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。
迭代器运算
只要两个迭代器指向的是同一个容器中的元素或者尾元素的下一位置,就能将其相减,所得结果是两个迭代器的距离。所谓距离指的是右侧的迭代器向前移动多少位置就能追上左侧的迭代器,其类型是名为difference_type
的带符号整型数。
数组
定义和初始化内置数组
不允许用auto
关键字由初始值的列表推断类型。
访问数组元素
数组下标是size_t
类型:机器相关的无符号类型,定义在cstddef
中。
指针和数组
auto
和decltype
推断出的类型不同:
1 | int ia[] = {0,1,2,3,4,5,6,7,8,9}; |
begin
函数返回指向ia
首元素的指针,end
函数返回指向ia
尾元素下一位置的指针, 这两个函数定义在iterator
头文件中。
两个指针相减的结果的类型是一种名为ptrdiff_t
的标准库类型,定义在cstddef
头文件中,是一种带符号类型。
1 | int ia = {0,2,4,6,8}; |
与旧代码的接口
允许使用以空字符结束的字符数组来初始化string
对象或为string
对象赋值。
在string
对象的加法运算中允许使用以空字符结束的字符数组作为其中一个运算对象(不能两个运算对象都是);在 string
对象的复合赋值运算中允许使用以空字符结的字符数组作为右侧的运算对象。
c_str
函数的返回值是一个C风格的字符串。也就是说,函数的返回结果是一个指针,该指针指向一个以空字符结束的字符数组,而这个数组所存的数据恰好与那个string
对象的一样。结果指针的类型是const char*
。如果执行完c_str()
函数后程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。
多维数组
要使用范围for
语句处理多维数组,除了最内层的循环外,其他所有循环的控制变量都应该是引用类型。
1 | for (auto &row : ia) |
1 | int ia[3][4]; |
第4章 表达式
基础
基本概念
一个左值表达式的求值结果是一个对象或者一个函数,然而以常量对象为代表的某些左值实际上不能作为赋值语句的左侧运算对象。此外,虽然某些表达式的求值结果是对象,但它们是右值而非左值。可以做一个简单的归纳:当一个对象被用作右值的时候,用的是对象的值(内容):当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
如果表达式的求值结果是左值,decltype
作用于该表达式(不是变量)得到一个引用类型。
算术运算符
除了-m
导致溢出的特殊情况,其他时候(-m)/n
和m/(-n)
都等于(m/n)
,m%(-n)
等于m%n
,(-m)%n
等于-(m%n)
。
递增和递减运算符
前置版本的递增运算符避免了不必要的工作,它把值加1后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。
sizeof运算符
在sizeof
的运算对象中解引用一个无效指针仍然是一种安全的行为,因为指针实际上并没有被真正使用。
对string
对象或vector
对象执行sizeof
运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间。
类型转换
隐式类型转换:
- 在大多数表达式中,比
int
类型小的整型值首先提升为较大的整数类型。 - 在条件中,非布尔值转换成布尔类型。
- 初始化过程中,初始值转换成变量的类型;在赋值语句中,右侧运算对象转换成左側运算对象的类型。
- 如果算术运算或关系运算的运算对象有多种类型,需要转换成同一种类型。
- 函数调用时也会发生类型转换。
算术转换
算术转换的规则定义了一套类型转换的层次,其中运算符的运算对象将转换成最宽的类型。
整型提升负责把小整数类型转换成较大的整数类型。对于bool
、char
、signed char
、unsigned char
、short
和unsigned short
等类型来说,只要它们所有可能的值都能存在int
里,它们就会提升成int
类型;否则,提升成unsigned int
类型。
较大的char
类型(wchar_t
、char16_t
、char32_t
)提升成int
、 unsigned int
、long
、unsigned long
、long long
和unsigned long long
中最小的一种类型,前提是转换后的类型要能容纳原类型所有可能的值。
如果某个运算符的运算对象类型不一致,这些运算对象将转换成同一种类型。但是如果某个运算对象的类型是无符号类型,那么转换的结果就要依赖于机器中各个整数类型的相对大小了。
像往常一样,首先执行整型提升。如果结果的类型匹配,无须进行进一步的转换。如果两个(提升后的)运算对象的类型要么都是带符号的、要么都是无符号的,则小类型的运算对象转换成较大的类型.
如果一个运算对象是无符号类型、另外一个运算对象是带符号类型,而且其中的无符号类型不小于带符号类型,那么带符号的运算对象转换成无符号的。
剩下的一种情况是带符号类型大于无符号类型,此时转换的结果依赖于机器。如果无符号类型的所有值都能存在该带符号类型中,则无符号类型的运算对象转换成带符号类型。如果不能,那么带符号类型的运算对象转换成无符号类型。
显式转换
一个命名的强制类型转换具有如下形式
1 | cast-name<type>(expression) |
其中,type
是转换的目标类型而expression
是要转换的值。如果type
是引用类型,则结果是左值。cast-name
是static_cast
、dynamic_cast
、const_cast
和reinterpret_cast
中的一种。
任何具有明确定义的类型转换,只要不包含底层const
,都可以使用static_cast
。
const_cast
只能改变运算对象的底层const
。对于将常量对象转换成非常量对象的行为,我们一般称其为“去掉 const
性质”。一旦我们去掉了某个对象的const
性质,编译器就不再阻止我们对该对象进行写操作了。如果对象本身不是一个常量,使用强制类型转换获得写权限是合法的行为。然而如果对象是一个常量,再使用const_cast
执行写操作就会产生定义的后果。只有const_cast
能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误。同样的,也不能用const_cast
改变表达式的类型。
reinterpret_cast
通常为运算对象的位校式提供较低层次上的重新解释。
运算符优先级表
第5章 语句
条件语句
if语句
else
与离它最近的尚未匹配的if
匹配。
switch语句
标签不应该孤零零地出现,它后面必须跟上一条语句或者另外一个case
标签。如果switch
结构以一个空的default
标签作为结束,则该default
标签后面必须跟上一条空语句或一个空块。
如果在某处一个带有初值的变量位于作用域之外,在另一处该变量位于作用域之内,则从前一处跳转到后一处的行为是非法行为。
1 | case true: |
如果需要为某个case
分支定义并初始化一个变量,我们应该把变量定义在块内,从而确保后面的所有case
标签都在变量的作用域之外。
跳转语句
goto语句
标签标示符独立于变量或其他标示符的名字,因此,标签标示符可以和程序中其他实体的标示符使用同一个名字而不会相互干扰。
goto
语句和控制权转向的那条带标签的语句必须位于同一个函数之内。和switch
语句类似,goto
语句也不能将程序的控制权从变量的作用域之外转移到作用域之内。
向后跳过一个已经执行的定义是合法的。跳回到变量定义之前意味着系统将销毁该变量,然后重新创建它。
try语句块和异常处理
throw表达式
throw
表达式包含关键字throw
和紧随其后的一个表达式,其中表达式的类型就是抛出的异常类型。throw
表达式后面通常紧跟一个分号,从而构成一条表达式语句。
类型runtime_error
是标准库异常类型的一种,定义在stdexcept
头文件中。我们必须初始化runtime_ error
的对象,方式是给它提供一个string
对象或者一个C风格的字符串,这个字符串中有一些关于异常的辅助信息。
try语句块
1 | try { |
catch
子句包括三部分:关键字catch
、括号内一个(可能未命名的)对象的声明(称作异常声明)以及一个块。当选中了某个catch
子句处理异常之后,执行与之对应的块。catch
一旦完成,程序跳转到try
语句块最后一个catch
子句之后的那条语句继续执行。
try
语句块内声明的变量在块外部无法访问,特别是在catch
子句内也无法访问。
每个标准库异常类都定义了名为what
的成员函数,这些函数没有参数, 返回值是C风格字符串。其中,runtime_error
的what成员返回的是初始化一个具体对象时所用的string
对象的副本。
寻找处理代码的过程与函数调用链刚好相反。当异常被抛出时,首先搜索抛出该异常的函数。如果没找到匹配的 catch
子句,终止该函数,并在调用该函数的函数中继续寻找。如果还是没有找到匹配的catch
子句,这个新的函数也被终止,继续搜索调用它的函数。以此类推,沿着程序的执行路径逐层回退,直到找到适当类型的catch
子句为止。 如果最终还是没能找到任何匹配的catch
子句,程序转到名为terminate
的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。
标准异常
我们只能以默认初始化的方式初始化exception
、bad_alloc
和bad_cast
对象,不允许为这些对象提供初始值。
其他异常类型的行为则恰好相反:应该使用string
对象或者C风格字符串初始化这些类型的对象,但是不允许使用默认初始化的方式。当创建此类对象时,必须提供初始值,该初始值含有错误相关的信息。
异常类型只定义了一个名为what
的成员函数,该函数没有任何参数,返回值是一个指向C风格字符串。该字符串的目的是提供关于异常的一些文本信息。
what
函数返回的C风格字符串的内容与异常对象的类型有关。如果异常类型有一个字符串初始值,则what
返回该字符串。对于其他无初始值的异常类型来说,what
返回的内容由编译器决定。
第6章 函数
函数基础
尽管实参与形参存在对应关系,但是并没有规定实参的求值顺序。
形参名是可选的,但是由于我们无法使用未命名的形参,所以形参一般都应该有个名字。偶尔,函数确实有个别形参不会被用到,则此类形参通常不命名以表示在函数体内不会使用它。
参数传递
const形参和实参
当形参有顶层const
时,传给它常量对象或者非常量对象都是可以的。
1 | void reset(int &i); |
要想调用引用版本的reset
,只能使用int
类型的对象,而不能使用字面值、求值结果为int
的表达式、需要转换的对象或者const int
类型的对象。类似的,要想调用指针版本的reset
只能使用int*
。
把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外,使用引用而非常量引用也会极大地限制函数所能接受的实参类型,此时函数不能接收const
对象、字面值或者需要类型转换的对象。
数组形参
1 | int &arr[10]; // 引用的数组 |
含有可变形参的函数
如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用initializer_list
类型的形参。 initializer_list
是一种标准库类型,用于表示某种特定类型的值的数组。
initiaizer_list
对象中的元素永远是常量值。
1 | void error_msg(initializer_list<string> il) |
传参序列要放在花括号之内:
1 | // expected和actual是string对象 |
返回类型和return语句
有返回值函数
返回局部对象的引用和指针是错误的,一旦函数完成,局部对象被释放,指针将指向一个不存在的对象。
调用一个返回引用的函数得到左值,其他返回类型得到右值。可以像使用其他左值那样来使用返回引用的函数的调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值。
函数可以返回花括号包围的值的列表。类似于其他返回结果,此处的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化;否则,返回的值由函数的返回类型决定。如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间。如果函数返回的是类类型,由类本身定义初始值如何使用。
1 | vector<string> process() |
返回数组指针
1 | Type (*function (parameter_list)) [dimension] |
尾置返回类型:任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用。尾置返回类型跟在形参列表后面并以一个->
符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个auto:
1 | auto func(int i) -> int(*)[10]; |
如果知道返回指针指向哪个数组,就可以使用decltype
:
1 | int odd[] = {1,3,5,7,9}; |
函数重载
对于重载的函数来说,它们应该在形参数量或形参类型上有所不同。不允许两个函数除了返回类型外其他所有的要素都相同。假设有两个函数,它们的形参列表一样但是返回类型不同,则第二个函数的声明是错误的。
顶层const
不影响传入函数的对象。一个拥有顶层const
的形参无法和另个没有顶层const
的形参区分开来。
如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的 const
是底层的。
如果同时存在有底层const
和非没有底层const
的函数,当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。
const_cast
和重载:
1 | const string &shorterString(const string &s1, const string &s2) |
当调用重载函数时有三种可能的结果
- 编译器找到一个与实参最佳匹配的函数,并生成调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配的错误信息。
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误, 称为二义性调用。
如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名。
特殊用途语言特性
我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。
可以多次声明同一个函数。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明不能修改已经存在的默认值,只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。
1 | string screen(sz, sz, char = ' '); |
局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参。用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时:
1 | sz wd = 80; |
constexpr
函数指能让用于常量表达式的函数。定义constexpr
函数的方法和其他函数类似,不过要遵循几项约定:函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须有且只有一条return
语句。
constexpr
函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。例如,constexpr
函数中可以有空语句、类型别名以及using
声明。
constexpr
函数不一定返回常量表达式:
1 | constexpr size_t scale(size_t cnt) { return new_sz() * cnt; } |
当scale
的实参是常量表达式时,它的返回值也是常量表达式;反之则不然。如上例所示,当我们给scale
函数传入一个形如字面值2的常量表达式时,它的返回类型也是常量表达式。此时,编译器用相应的结果值替换对scale
函数的调用。如果我们用一个非常量表达式调用scale
函数,比如int
类型的对象i
,则返回值是一个非常量表达式。当把scale
函数用在需要常量表达式的上下文中时,由编译器负责检查函数的结果是否符合要求。如果结果恰好不是常量表达式,编译器将发出错误信息。
内联函数和constexpr
函数可以在程序中多次定义。毕竟,编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。不过,对于某个给定的内联函数或者constexpr
函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和constexpr
函数通常定义在头文件中。
调试帮助
assert
宏使用一个表达式作为它的条件:
1 | assert (expr); |
首先对expr
求值,如果表达式为假(即0),assert
输出信息并终止程序的执行。如果表达式为真(即非0),assert
什么也不做。assert
宏定义在cassert
头文件中。
如果定义了NDEBUG
,则assert
什么也不做。默认状态下没有定义NDEBUG
,此时assert
将执行运行时检査。
同时,很多编译器都提供了一个命令行选项使我们可以定义预处理变量
定义NDEBUG
能避免检査各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。因此,assert
应该仅用于验证那些确实不可能发生的事情。我们可以把assert
当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检査,也不能替代程序本身应该包含的错误检査。
编译器为每个函数都定义了__func__
,它是const char
的一个静态数组,用于存放函数的名字。
另外4个对于程序调试很有用的名字:
__FILE__
存放文件名的字符串字面值。__LINE__
存放当前行号的整型字面值。__TIME__
存放文件编译时间的字符串字面值。__DATE__
存放文件编译日期的字符串字面值。
函数匹配
函数匹配的第一步是选定本次调用对应的重载函数集,集合中的函数称为候选函数。候选函数具备两个特征:一是与被调用的函数同名,二是其声明在调用点可见。
第二步考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数。可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。
函数匹配的第三步是从可行函数中选择与本次调用最匹配的函数。在这一过程中,逐一检査函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。编译器依次检査每个实参以确定哪个函数是最佳匹配。如果有且只有个函数满足下列条件,则匹配成功:
- 该函数每个实参的匹配都不劣于其他可行函数需要的匹配。
- 至少有一个实参的匹配优于其他可行函数提供的匹配。
如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报告二义性调用的信息。
实参类型转换
为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成个等级,具体排序如下所示:
- 精确匹配,包括以下情况
- 实参类型和形参类型相同。
- 实参从数组类型或函数类型转换成对应的指针类型
- 向实参添加顶层
const
或者从实参中删除顶层const
。
- 通过
const
转换实现的匹配。 - 通过类型提升实现的匹配。
- 通过算术类型转换或指针转换实现的匹配。
- 通过类类型转换实现的匹配。
有时候,即使实参是一个很小的整数值,也会直接将它提升成int
类型;此时使用short
版本反而会导致类型转换:
1 | void ff(int); |
所有算术类型转换的级別都一样。
如果重载函数的区别在于它们的引用类型的形参是否引用了const
,或者指针类型的形参是否指向const
,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数:
1 | Record lookup(Account&); |
指针类型的形参类似:如果两个函数的唯一区别是它的指针形参指向常量或非常量,则编译器能通过实参是否是常量决定选用哪个函数:如果实参是指向常量的指针,调用形参是const*
的函数:如果实参是指向非常量的指针,调用形参是普通指针的函数。
函数指针
当我们使用重载函数时,上下文必须清晰地界定到底应该选用哪个函数。如果定义了指向重载函数的指针,编译器通过指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配。
1 | bool lengthCompare(const string&, const string&); |
decltype
返回函数类型,此时不会将函数类型自动转换成指针类型。因为decltype
的结果是函数类型,所以只有在结果前面加上*才能得到指针。
和函数类型的形参不一样,返回类型不会自动地转换成指针。我们必须显式地将返回类型指定为指针:
1 | using F = int(int*, int); |
第7章 类
定义抽象数据类型
定义改进的Sales_data类
定义在类内部的函数是隐式的inline
函数。
成员函数通过一个名为this
的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化 this
。例如,如果调用total.isbn()
,则编译器负责把total
的地址传递给isbn
的隐式形参this
。
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为this
所指的正是这个对象。任何对类成员的直接访问都被看作this
的隐式引用。
对于我们来说,this
形参是隐式定义的。实际上,任何自定义名为this
的参数或变量的行为都是非法的。我们可以在成员函数体内部使用this
。
this
是一个常量指针,我们不允许改变this
中保存的地址。
在默认情况下我们不能把this
绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。
把const
关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const
表示this
是一个指向常量的指针。像这样使用const
的成员函数被称作常量成员函数。
编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
1 | Sales_data& Sales_data::combine(const Sales_data &rhs) // 实现+=运算符的功能,返回左侧运算对象,是一个引用 |
定义类相关的非成员函数
read
和print
分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。
一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。
构造函数
构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
不同于其他成员函数,构造函数不能被声明成const
的。当我们创建类的一个const
对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const
对象的构造过程中可以向其写值。
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数。默认构造函数无须任何实参。
如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。编译器创建的构造函数又被称为合成的默认构造函数。
对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员。
- 否则,默认初始化该成员。
定义默认构造函数的原因:
- 编译器只有在发现类不包含任何构造函数的情下才会替我们生成一个默认的构造函数。
- 含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。
- 有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。
如果我们需要默认的行为,那么可以通过在参数列表后面写上=default
来要求编译器生成构造函数。其中,= default
既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果=default
在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。
构造函数初始值列表负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。
1 | Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { } |
没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始化。
访问控制与封装
类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。如果我们使用struct
关键字,则定义在第一个访问说明符之前的成员是public
的;相反,如果我们使用class
关键字,则这些成员是private
的。
友元
类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元:增加一条以friend
关键字开始的函数声明语句即可。
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。
类的其它特性
类成员再探
类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public
或者private
中的一种。用来定义类型的成员必须先定义后使用,因此一般出现在类开始的地方。
我们可以在类的内部把inline
作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline
关键字修饰函数的定义。inline
成员函数也应该与相应的类定义在同一个头文件中。
通过在变量的声明中加入mutable
关键字声明一个可变数据成员。可变数据成员永远不会是const
,即使它是const
对象的成员。因此,一个const
成员函数可以改变一个可变成员的值。
返回*this的成员函数
1 | class Screen { |
因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const
成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本, 但显然此时非常量版本是一个更好的匹配。
1 | class Screen { |
当display
的非常量版本调用do_display
时,它的this
指针将隐式地从指向非常量的指针转换成指向常量的指针。当do_display
完成后, display函数各自返回解引用this
所得的对象。在非常量版本中,this
指向一个非常量对象,因此display
返回一个普通的(非常量)引用。
原因:
- 一个基本的愿望是避免在多处使用同样的代码。
- 我们预期随着类的规模发展,
display
函数有可能变得更加复杂。 - 我们很可能在开发过程中给
do_display
函数添加某些调试信息,而这些信息将在代码的最终产品版本中去掉。 - 这个额外的函数调用不会增加任何开销。因为我们在类内部定义了
do_display
,所以它隐式地被声明成内联函数。
类类型
可以仅仅声明类而暂时不定义它
1 | class Screen; |
这种声明有时被称作前向声明,它向程序中引入了名字Screen
并且指明Screen
是一种类类型。对于类型Screen
来说,在它声明之后定义之前是一个不完全类型。不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针。
1 | class Link_screen { |
友元再探
类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。
友元关系不存在传递性。
要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。把Window_mgr
的clear
成员函数作为Screen
类的友元:
- 首先定义
Window_mgr
类,其中声明clear
函数,但是不能定义它。在clear
使用Screen
的成员之前必须先声明Screen
。 - 接下来定义
Screen
,包括对于clear
的友元声明。 - 最后定义
clear
,此时它才可以使用Screen
的成员。
如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。
友元声明的作用是影响访问权限,它本身并非普通意义上的声明:
1 | struct X { |
类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符访问。
在类的外部,成员的名字被隐藏起来了。一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无须再次授权了。
1 | void Window_mgr::clear(ScreenIndex i) |
因为在处理参数列表之前已经明确了Window_mgr
的作用域,所以不用再专门说明ScreenIndex
和screens
的作用域。
函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外:
1 | class Window_mgr { |
名字查找与类的作用域
成员函数中可以使用类中定义的任何名字;而声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见:
1 | typedef double Money; |
在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。
成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续査找、
一般不用其他成员的名字作为某个成员函数的参数。
1 | int height; |
当成员定义在类的外部时,名字找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。
构造函数再探
构造函数初始值列表
当成员是const
或者引用,或者属于某种类类型且该类没有定义默认构造函数时,必须在初始值列表中将这个成员初始化。
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。
最好令构造函数初始值的顺序与成员声明的顺序保持一致,尽量避免使用某些成员初始化其他成员。
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
委托构造函数
一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行,然后控制权才会交还给委托者的函数体。
默认构造函数的作用
默认初始化在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时。
值初始化在以下情况下发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
- 当我们不使用初始值定义一个局部静态变量时。
- 当我们通过书写形如
T()
的表达式显式地请求值初始化时,其中T
是类型名。
类必须包含一个默认构造函数以便在上述情况下使用。
隐式的类类型转换
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数。
编译器只会自动地执行一步类型转换。
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit
加以阻止:
1 | class Sales_data { |
关键字explicit
只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit
的。只能在类内声明构造函数时使用explicit
关键字,在类外部定义时不应重复。
当我们用explicit
关键字声明构造函数时,它将只能以直接初始化的形式使用,不能以拷贝形式的初始化使用。
虽然explicit
的构造函数不能用于隐式的转换,但是可以显示的强制转换。
1 | item.combine(Sales_data(null_book)); // 合法:显示构造的对象 |
聚合类
当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是
public
的。 - 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有
virtual
函数。
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员,初始值的顺序必须与声明的顺序一致。与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化,初始值列表的元素个数绝对不能超过类的成员数量。
字面值常量类
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类
- 数据成员都必须是字面值类型。
- 类必须至少含有一个
constexpr
构造函数。 - 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的
constexpr
构造函数。 - 类必须使用析构函数的默认定义,该成员负责销类的对象。
一个字面值常量类必须至少提供一个constexpr
构造函数。constexpr
构造函数要么声明成=default
的形式,要么函数体是空的。constexpr
构造函数必须初始化所有数据成员,初始值或者使用constexpr
构造函数, 或者是一条常量表达式。
类的静态成员
我们通过在成员的声明之前加上关键字static
使得其与类关联在一起。和其他成员一样,静态成员可以是public
的或private
的。
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。
类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this
指针。作为结果,静态成员函数不能声明成const
的,而且我们也不能在static
函数体内使用this
指针:包括显式使用和调用非静态成员的隐式使用。
我们使用作用域运算符直接访问静态成员,也可以用累的对象、引用或者指针访问。
成员函数不用通过作用域运算符就能直接使用静态成员。
当在类的外部定义静态成员时,不能重复static
关键字,该关键字只出现在类内部的声明语句。
我们不能在类的内部初始化静态成员,必须在类的外部定义和初始化每个静态成员,一个静态数据成员只能定义一次。
可以为静态成员提供const
整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr
。
如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况(例如定义数组时作为下标),则一个初始化的const
或constexpr static
不需要分别定义。相反,如果我们将它用于值不能替换的场景中(比如给函数传引用),则该成员必须有一条定义语句。即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外 部定义一下该成员,在外部定义的时候不需要提供初始值。
静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
1 | class Bar { |
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参。非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分。
《C++ Primer 第五版》读书笔记 - 第一部分