C/C++ Primer 第 I 部分 C++基础
目录
- 第 2 章 变量和基本类型
- 第 3 章 字符串、向量和数组
- 第 4 章 表达式
- 第 5 章 语句
- 第 6 章 函数
- 第 7 章 类
第 2 章 变量和基本类型
2.1 基本内置类型
2.1.1 算术类型
wchat_t 类型用于确保可以存放机器最大扩展字符集中的任意一个字符,类型 char16_t 和 char32_t 则为 Unicode 字符集服务(Unicode 用于表示所有自然语言中字符的标准)。
大多数计算机以 2 的整数次幂个比特作为块来处理内存,可寻址的最小内存块称为“字节(byte)”,存储的基本单元称为“字(word)“,它通常由几个字节组成。
带符号类型和无符号类型
特别需要注意的是:类型 char 和类型 signed char 并不一样。尽管字符型有三种,但是字符的表现形式却只有两种:带符号的和无符号的。类型 char 实际上会表现为上述两种形式中的一种,具体是哪种由编译器决定。
2.1.2 类型转换
当我们赋给无符号类型一个超出它表示范围的值时,结果时初始值对无符号类型表示数值总数取模后的余数。
含有无符号类型的表达式
当一个算术表达式中既有无符号数又有 int 值时,那个 int 值就会转换成无符号数。
2.1.3 字面值常量
整型和浮点型字面值
尽管整型字面值可以存储在带符号类型中,但严格来说,十进制字面值不会时负数。如果我们适用了一个形如-42 的负十进制字面值,那个负号并不在字面值之内,它的作用仅仅是对字面值取负值而已。
字符和字符串字面值
由单引号括起来的一个字符称为 char 型字面值,双引号括起来的零个或多个字符则构成字符串字面值。
'a' // 字符字面值 "Hello World!" // 字符串字面值
如果两个字符串字面值位置紧邻且仅由空格、缩进和换行符分割,则它们实际上时一个整体。当书写的字符串字面值比较长,写在一行里不太合适时,就可以采取分开书写的方式:
// 分多行书写的字符串字面值 std::cout << "a really, really long string literal " "that spans two lines" << std::endl;
指定字面值类型
前缀 | 含义 | 类型 |
---|---|---|
u | Unicode16 字符 | char16_t |
U | Unicode32 字符 | char32_t |
L | 宽字符 | wchar_t |
u8 | UTF-8(仅用于字符串字面常量) | char |
后缀 | 最小匹配类型 |
---|---|
u or U | unsigned |
l or L | long |
ll or LL | long long |
后缀 | 类型 |
---|---|
f or F | float |
l or L | long double |
2.2 变量
2.2.1 变量定义
初始值
当一次定义了两个或多个变量时,对象的名字随着定义也就马上可以使用了。因此在同一条定义语句中,可以用先定义的变量值去初始化后定义的其他变量。
WARNING
初始化不是赋值,初始化的含义时创建变量时赋予其一个初始值,而赋值的含义时把对象的当前值擦除,而以一个新值来替代。
列表初始化
作为 C++11 新标准的一部分,用花括号来初始化变量得到了全面应用,而在此之前这种初始化形式仅在某些收线场合才能适用。这种初始化的形式被称为 列表初始化 。现在,无论时初始化对象还是某些时候为对象赋新值,都可以适用这样一组由花括号括起来的初始值了。
当用于内置类型的变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化且初始值存在丢失信息的风险,则编译器将报错:
long double ld = 3.1415926536; int a{ld}, b = {ld}; // 错误:转换未执行,因为存在丢失信息的风险 int c(ld), d = ld; // 正确:转换执行,切确实丢失了部分值
默认初始化
如果定义变量时没有执行初值,则变量被 默认初始化 ,此时变量被赋予了”默认值“。默认值到底是什么由变量类型决定,同时定义变量的位置也会对此有影响。
定义于任何函数体之外的内置类型的变量都被初始化未 0,定义在函数体内部的内质类型变量将 不被初始化 。
2.2.4 名字的作用域
嵌套作用域
因为全局作用域本身没有名字,所以当作用域操作符的左侧为空时,向全局作用域发出请求获取作用域操作符右侧名字对应的变量。
#include <iostream> // 该程序仅用于说明:函数内部不宜定义与全局变量同名的新变量 int reused = 42; int main() { // 42 std::count << reused << std::endl; int reused = 0; // 0 std::cout << reused << std::endl; // 42 std::count << ::reused << std::end; }
2.3 复合类型
2.3.3 理解复合类型的声明
面对一条比较复杂的指针或引用的声明语句时,从右向左阅读有助于弄清楚它的真实含义。
2.4 const 限定符
2.4.3 顶层 const
顶层 const 表示指针本身是个常量,而 底层 const 表示指针所指的对象是一个常量。
更一般的,顶层 const 可以表示任意对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层 const 则与指针和引用等复合类型的基本类型部分有关。
As we’ve seen, a pointer is an object that can point to a different object. As a result,we can talk independently about whether a pointer is const and whether the objectsto which it can point are const. We use the term top-level const to indicate that thepointer itself is a const. When a pointer can point to a const object, we refer tothat const as a low-level const.
2.4.4 constexpr 和常量表达式
常量表达式 是指不会改变并且在编译过程就能得到计算结果的表达式。
constexpr 变量
C++11 新标准规定,允许将变量声明为 constexpr 类型以便由编译器来验证变量值是否是一个常量表达式。声明为 constexpr 的变量一定是一个常量,而且必须用常量表达式初始化
2.5 处理类型
2.5.1 类型别名
有两种方法可用于定义类型别名。传统的方法是使用关键字 typedef 。
新标准规定了一种新的方法,使用 别名声明 来定义类型的别名:
using SI = Sales_item; // SI是Sales_item的同义词
这种方法用关键词 using 作为别名声明的开始,其后紧跟别名和等号,其作用是把等号左侧的名字规定成等号右侧类型的别名。
2.5.2 auto 类型说明符
C++11 新标准引入了 auto 类型说明符,用它就能让编译器替我们去分析表达式所属的类型。
因为一条语句只能有一个数据类型,所以该语句中所有变量的初始基本数据类型都必须一样:
auto i = 0, *p = &i; // 正确:i是整数、p是类型指针 auto sz = 0, pi = 3.14; // 错误:sz和pi的类型不一致
复合类型、常量和 auto
auto 一般会忽略掉顶层 const,同时底层 const 则会保留下来,比如当初始值是一个指向常量的指针时。如果希望推断出 auto 类型时一个顶层 const,则需要明确指出:
const auto f = ci; // ci的推演类型时int, f是const int
还可以将引用类型设为 auto,此时原来的初始化规则仍然适用。
2.5.3 decltype 类型指示符
C++11 新标准引入了第二种类型说明符 decltype ,它的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值:
decltype(f()) sum = x; // sum的类型就是函数f的返回类型
编译器并不实际调用函数 f,而且适用当调用发生时 f 的返回类型作为 sum 的类型。
decltype 处理顶层 const 和引用的方式与 auto 有些许不同。如果 decltype 使用的表达式时一个变量,则 decltype 返回该变量的类型(包括顶层 const 和引用在内)。
decltype 和引用
如果 decltype 适用的表达式不是一个变量,则 decltype 返回表达式对应的类型。
另一方面,如果表达式的内容是解引用,则 decltype 将得到引用类型。正如我们所熟悉的那样,解引用指针可以得到指针所指的对象,而且还能给这个对象赋值。因此,decltype (*p)的结果类型就是 int&,而非 int。
对于 decltype 所用的表达式来说,如果变量名加上了一对括号,则得到的类型与不加括号时会有不同。如果 decltype 使用的是一个不加括号的变量,则得到的结果就是该变量的类型;如果给变量加上了一层或多层括号,编译器就会把它当成是一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的 decltype 就会得道引用类型。
WARNING
切记:decltype((variable))(注意是双层括号)的结果永远是引用,而 decltype(variable)结果只有当 variable 本身就是一个引用时才是引用。
2.6 自定义数据结构
类体右侧的表示结束的花括号后必须写一个分号,这是因为类体后面可以紧跟变量名以示对该类型对象的定义,所以分号必不可少:
struct Sales_date { /* ... */ } accum, trans, *salesptr; // 与上一条语句等价,但可能更好一些 struct Sales_date { /* ... */ }; Sales_date accum, trans, *salesptr;
分号表示声明符(通常为空)的结束。一般来说,最好不要把对象的定义和类的定义放在一起。这么做无异吧两种不同实体的定义混在了一条语句里,一会儿定义类,一会儿又定义变量,显示这是一种不被建议的行为。
类数据成员
C++11 新标准规定,可以为数据成员提供一个 类内初始值 。创建对象时,雷内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化。
对类内初始值的限制与之前介绍的类似(参见 2.2.1 节):或者放在花括号里,或者放在等号右边,记住不能使用圆括号。
第 3 章 字符串、向量和数组
3.1 命名空间和 using 声明
有了 using 声明就无须专门的前缀(形如命名空间::)也能使用所需的名字了。 using 声明具有如下的形式:
using namespace::name;
#include <iosteam> using std::cin; int main() { int i; cin >> i; std::cout << i; return 0; }
头文件不应包含 using 声明
位于头文件的代码一般来说不应该使用 using 声明。这是因为头文件的内容会拷贝到所有引导它的文件中,如果头文件里有某个 using 声明,那么每个使用了该文件的文件都会有这个声明。对于某些程序来说,由于不经意间包含了一些名字,反而可能产生始料未及的名字冲突。
3.2 标准库类型 string
标准库类型 string 表示可变长的字符序列。作为标准库的一部分,string 定义在命名空间 std 中。
#include <string> using std::string;
3.2.1 定义和初始化 string 对象
string s1 | 默认初始化,s1 是一个空串 |
string s2(s1) | s2 是 s1 的副本 |
string s2 = s1 | 等价于 s2(s1) |
string s3("value") | s3 是字面值"values"副本,除了字面值最后的那个空字符外 |
string 3 = "value" | 等价于 s3("value") |
string s4(n, 'c') | 把 s4 初始化为由连续 n 个字符 c 组成的串 |
直接初始化和拷贝初始化
如果使用等号(=)初始化一个变量,实际上执行的是 拷贝初始化 ,编译器把等号右侧的初始值拷贝到新创建的对象中去。与之相反,如果不使用等号,则执行的是 直接初始化 。
string s5 = "hiya"; // 拷贝初始化 string s6("hiya"); // 直接初始化
3.2.2 string 对象上的操作
os<<s | 将 s 写道输出流 os 当中,返回 os |
is >> s | 从 is 中读取字符串赋给 s,字符串以空白分隔,返回 is |
getline(is, s) | 从 is 中读取一行赋给 s,返回 is |
s.empty() | s 为空返回 true,否则返回 false |
s.size() | 返回 s 中字符的个数 |
s[n] | 返回 s 中第 n 个字符的引用,位置 n 从 0 计起 |
S1+s2 | 返回 s1 和 s2 连接后的结果 |
s1=s2 | 用 s2 的副本代替 s1 原来的字符 |
s1==s2 | 如果 s1 和 s2 中所含字符完全一样,则它们相等; |
s1!=s2 | string 对象的相等性判断对字母的大小写敏感 |
<, <=, >, >= | 利用字符在字典中的顺序进行比较,且对字母的大小写敏感 |
读写 string 对象
在执行读取操作时,string 对象会自动忽略开头的空白(即空格符、换行符、制表符等)并从第一个真正的字符开始读起,直到遇见下一处空白为止。
int main() { string s; cin >> s; cout << s << endl; return 0; }
例如上述程序输入 " Hello World! " ,则输出为 "Hello" ,输出结果中没有任何空格。
使用 getline 读取一整行
如果希望能在最终得道的字符串中保留输入时的空白字符串,这时应该用 getline 函数替代原来的>>运算符。getline 只要一遇到换行符就结束读取操作并返回结果,哪怕输入的一开始就是换行符也是如此。如果输入真的一开始就是换行符,那么所得的结果是个空 string。(注意 getline 只读取而不存储换行符)。
Note:
触发 getline 函数返回的那个换行符实际上被丢掉了,得到的 string 对象并不包含换行符。
string::size_type 类型
其是 size 函数返回的时一个 string::size_type 类型的值。
string 类及其他大多数标准库类型都定义了几种配套的类型。这些配套类型体验了标准库类型与机器无关的特性。类型 size_type 即是其中一种。
尽管我们不太清除 string::size_type 类型的细节,但有一点是肯定的:它是一个无符号类型的值而且能足够存放下任何 string 对象的大小。所有用于存放 string 类的 size 函数返回值的变量,都应该是 string::size_type 类型的。
Tip:
如果一条表达式中已经有了 size()函数就不要再使用 int 了,这样可以避免混用 int 和 unsigned 可能带来的问题。
字面值和 string 对象相加
当把 string 对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符(+)的两侧的运算对象至少有一个时 string:
string s4 = s1 + ", "; // 正确 string s5 = "hello" + ", "; // 错误:两个运算对象都不是string string s6 = s1 + ", " + "world"; // 正确:每个加发运算符都一个运算对象是string string s7 = "hello" + ", " + s2; // 错误:不能把字面值直接相加
WARNING:
因为某些历史原因,也为了与 C 兼容,所以 C++语言中的字符串字面值并不是标准库类型 string 的对象。切记,字符串字面值与 string 是不同的类型。
使用 C++版本的 C 标准库头文件
C++标准库中除了定义 C++语言特有的功能外,也兼容了 C 语言的标准库。C语言的头文件形如 name.h,C++则将这些文件命名为 cname。也就是去掉了.h 后缀,而在文件名 name 之前添加了字母 c,这里的 c 表示这是一个属于 C 语言标准库的头文件。
因此,cctype 头文件和 ctype.h 头文件的内容是一样的,只不过从命名规范上来讲更符合 C++语言的要求。特别的,在名为 cname 的头文件中定义的名字从属于命名空间 std,而定义在名为.h 的头文件中的则不然。
一般来说,C++程序应该使用名为 cname 的头文件而不使用 name.h 的形式,标准库中的名字总能在命名空间 std 中找到。如果使用.h 形式的头文件,程序员就不得不时刻牢记哪些是从 C 语言那儿继承过来的,哪些又是 C++语言所独有的。
处理每个字符?使用基于范围的 for 语句
如果想对 string 对象中的每个字符做点什么操作,目前最好的办法是使用 C++11 新标准提供的一种语句: 范围 for 语句。这种语句遍历给定序列中的每个元素并对序列的每个值执行某种操作,其语法形式是:
for (declaration : expression) statement
其中,expression 部分是一个对象,用于表示一个序列。declaration 部分负责定义一个变量,该变量将被用于访问序列中的基础元素。每次迭代,declaration 部分的变量会被初始化为 expression 部分的 i 想啊一个元素。
string str("some string"); for (auto c : str) cout << c << endl;
使用范围 for 语句改变字符串中的字符
如果想要改变 string 对象中的字符的值,必须把循环变量定义成引用类型。记住所谓引用值是给定对象的一个别名,因此当使用引用作为循环控制变量时,这个变量实际上被依次绑定到了序列的每个元素上。使用这个引用,我们就能改变它绑定的字符。
只处理一部分字符?
下表运算符 ([ ])接收的输入参数时 string::size_type 类型的值。string 对象的下表从 0 计起。
3.3 标准库类型 vector
标准库类型 vector 表示对象集合,其中所有对象的类型都相同。集合中的每个对象都一个与之对应的索引,索引用于访问对象。因为 vector“容纳着”其他对象,所以它 也常被称为 容器 。第 II 部分将对容器进行更为详细的介绍。
模板本身不是类或函数,相反可以将模板模板看作为编译器生成类或函数编写的一份说明。编译器根据模板创建类或函数的过程称为 实例化 ,当使用模板时,需要指出编译器应把类或函数实例化成何种类型。
vector 能容纳绝大多数类型的对象作为其元素,但时因为引用不是对象(参见 2.3.1 节),所以不存在包含引用的 vector。
3.3.1 定义和初始化 vector 对象
vector<T> v1 | v1 是一个空 vector,它潜在的元素是 T 类型的,执行默认初始化 |
vector<T> v2(v1) | v2 中包含有 v1 所有元素的副本 |
vector<T> v2 = v1 | 等级于 v2(v1),v2 中包含有 v1 所有元素的副本 |
vector<T> v3(n, val) | v3 包含了 n 个重复的元素,每个元素的值都是 val |
vector<T> v4(n) | v4 包含了 n 个重复地执行了值初始化的对象 |
vector<T> v5{a,b,c…} | v5 包含了初始值个数的元素,每个元素被赋予相应的初始值 |
vector<T> v5={a,b,c…} | 等价于 v5{a,b,c…} |
值初始化
通常情况下,可以只提供 vector 对象容纳的元素数量而不用略去初始值。此时库会创建一个 值初始化 元素初值,并把它赋给容器中的所有元素。这个初值由 vector 对象中元素的类型决定。
如果 vector 对象的元素是内置类型,比如 int,则元素初始值自动设为 0.如果元素是某种类型,比如 string,则元素由类默认初始化。
对这种初始化的方式有两个特殊限制:其一,有些类要求必须明确地提供初始值(参见 2.2.1 节),如果 vector 对象中元素的类型不支持默认初始化,我们就必须提供初始化的元素值。对这种类型的对象来说,只提供元素的数量而不设定初始值无法完成初始化工作。
其二,如果只提供了元素的数量而没有设定初始值,只能使用直接初始化。
列表初始化还是元素数量?
vector<int> v1(10); // v1有10个元素,每个值都是0 vector<int> v2{10}; // v2有1个元素,该元素的值是10 vector<int> v3(10, 1); // v3有10个元素,每个的值都是1 vector<int> v4{10, 1}; // v4有2个元素,值分别是10和1
如果用的是圆括号,可以说提供的值用来构造 vector 对象的。如果用的是花括号,可以表述成我们想列表初始化该 vector 对象。
另一方面,如果初始化时使用了花括号的形式但提供的值又不能用来列表初始化,就要考虑用这样的值来构造 vector 对象了。
vector<string> v5{"hi"}; // 列表初始化,:v5有一个元素 vector<string> v6("hi"); // 错误:不能使用字符串字面值构建vector对象 vector<string> v7{10}; // v7有10个默认初始值的元素 vector<string> v8{10, "hi"} // v8有10个值为"hi"的元素
3.3.2 向 vector 对象中添加元素
更好的处理方法是先创建一个空 vector,然后在运行时再利用 vector 的成员函数 push_back 向其中添加元素。push_back 负责把一个值当成 vector 对象的尾元素”压到(push)“vector 对象的”尾端(back)“。
向 vector 对象添加元素蕴含的编程假定
如果徐缓体内部包含有向 vector 对象添加元素的语句,则不能使用范围 for 循环,具体原因将在 5.4.3 节详细解释。
WARNING:
方位 for 语句体内不应改变其所遍历序列的大小。
3.3.3 其他 vector 操作
v.empty() | 如果 v 不含任何元素,返回真;否则返回假 |
v.size() | 返回 v 中元素的个数 |
v.push_back(t) | 向 v 的尾端添加一个值为 t 的元素 |
v[n] | 返回 v 中第 n 个位置上元素的引用 |
v1 = v2 | 用 v2 中元素的拷贝替换 v1 中的元素 |
v1 = {a,b,c…} | 用列表中元素的拷贝替换 v1 的元素 |
v1 == v2 | v1 和 v2 相等当且仅当它们的元素数量相同且对应位置的元素值都相同 |
v1 !+ v2 | |
<, <=, >, >= | 顾名思义,以字典顺序进行比较 |
Note:
要使用 size_type,需首先指定它是由哪种类型定义的。vector 对象类型总是包含着元素的类型(参见 3.3 节)
vector<int>::size_type / 正确 vector::size_type / 错误
不能用下标形式添加元素
刚接触 C++语言的程序员也许会认为可以通过 vector 对象的下标形式来添加元素,事实并非如此。如前所述,正确的方式是使用 push_back。
WARNING:
vector 对象(以及 string 对象)的下标运算符可用于方位已存在的元素,而不能用于添加元素。
3.4 迭代器介绍
我们已经知道可以使用下标运算符来访问 string 对象的字符或 vector 对象的元素,还有另外一种更通用的机制也可以实现同样的目的,这就是 迭代器 。
类似于指针类型,迭代器也提供了对对象的间接访问。就迭代器而言,其对象是容器中的元素或 string 对象中的字符。使用迭代器可以访问某个元素,迭代器也能从一个元素移动到另一个元素。迭代器有有效和无效之分,这一点和指针差不多。有效的迭代器或者指向某个元素,或者指向容器中尾元素的下一位置;其他所有情况都属于无效。
3.4.1 使用迭代器可以访问某个元素
和指针不一样的是,获取迭代器不是使用取地址符,有迭代器的类型同时拥有返回迭代器的成员。比如,这些类型都拥有名为 begin 和 end 的成员,其中 begin 成员负责范围指向第一个元素的迭代器,end 成员则负责返回指向容器”尾元素的下一位置“的迭代器,也就是说,该迭代器指示的是容器的一个本不存在的“ 尾后 ”元素。这样的迭代器没什么实际含义,仅是个标记而已,表示我们已经处理完了容器中的所有元素。end 成员返回的迭代器常被称作 尾后迭代器 或者简称尾迭代器。特殊情况下如果容器为空,则 begin 和 end 返回的是同一个迭代器。
auto b = v.begin(), e = v.end();
迭代器运算符
*iter | 返回迭代器 iter 所指元素的引用 |
iter->mem | 解引用 iter 并获取该元素的名为 mem 的成员,等价于(*iter).mem |
++iter | 令 iter 指示容器中的下一个元素 |
–iter | 令 iter 指示容器中的上一个元素 |
iter1 == iter2 | 判断两个迭代器是否相等(不相等),如果两个迭代器指针的是同一个元素 |
iter1 != iter2 | 或者它们是同一个容器的尾后迭代器,则相等;反之,则相等 |
将迭代器从一个元素移动到另一个元素
迭代器使用递增(++)运算符来从一个元素移动到一下元素。从逻辑上来说,迭代器的递增和整数的递增类似,整数的递增是在整数值上”加 1“,迭代器的递增则是将迭代器”向前移动一个位置“。
NOTE:
因为 end 返回的迭代器并不实际指示某个元素,所以不能对其进行递增或解引用的操作。
关键概念:泛型编程
之前已经说过,只有 string 和 vector 等一些标准库类型有下标运算符,而并非全都如此。与此类似,所有标准库容器和迭代器都定义了==和!=,但它们中的大多数都没有定义<运算符。因此,只要我们养成使用迭代器和!=的习惯,就不用太再一用的到底是哪种容器类型。
迭代器类型
就像不知道 string 和 vector 的 size_type 成员到底是什么类型一样,一般来说我们也不知道(其实也无需知道)迭代器的精准类型。而实际上,哪些拥有迭代器的标准库类型使用 iterator 和 const_iterator 来表示迭代器的类型:
vector<int>::iterator it; // it 能读写vector<int>的元素 string::iterator it2; // it2能读写string对象中的字符 vector<int>::const_iterator it3; // it3只能读元素,不能写元素 string::const_iterator it4; // it4只能读元素,不能写元素
begin 和 end 运算符
为了便于专门得到 const_iterator 类型的返回值,C++新标准引入了两个新函数,分别是 cbegin 和 cend: ~auto it3 = v.cbegin(); // it3 的类型是 vector<int>::const_iterator
某些对 vector 对象的操作会使迭代器失效
虽然 vector 对象可以动态的增长,但是也会有一些副作用。已知的一个限制是不能在范围 for 循环中向 vector 对象添加元素。另外一个限制是任何一种可能改变 vector 对象容量的操作,比如 push_back,都会使该 vector 对象的迭代器失效。9.3.6 节将详细解释迭代器是如何失效的。
WARNING:
谨记,但凡使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。
3.4.2 迭代器运算符
iter + n | 迭代器加上一个整数值仍得一个迭代器,迭代器指示的新位置与原来相比向前移动了若干个元素。结果迭代器或者指示容器其内的一个元素,或者指示容器尾元素的下一位置 |
iter - n | 迭代器减去一个整数值仍得一个迭代器,迭代器指示的新位置与原来相比向后移动了若干个元素。结果迭代器或者指示容器其内的一个元素,或者指示容器尾元素的下一位置 |
iter += n | 迭代器加法的复合赋值语句,将 iter1 加 n 的结果赋给 iter1 |
iter -= n | 迭代器加法的复合赋值语句,将 iter1 减 n 的结果赋给 iter1 |
iter1 - iter2 | 两个迭代器相减的结构就是它们之间的距离,也就是说,将运算符右侧的迭代器向前移动差值个元素后将得到左侧的迭代器。参与运算的两个迭代器必须指向大是同一个容器中的元素或尾元素的下一位置 |
>、 >=、 <、 <= | 迭代器的关系运算符,如果某迭代器指向的容器位置在另一个迭代器所指位置之前,则说前者小于后者。参与运算的两个迭代器必须指向大是同一个容器中的元素或尾元素的下一位置 |
迭代器的算术运算
只要两个迭代器指向的是同一个容器中的元素或者尾元素的下一位置,就能将其相减,所得结果是两个迭代器的距离。所谓距离指的是右侧的迭代器向前移动多少位置就能追上左侧迭代器,其类型是名为 different_type 的带符号整数。string 和 vector 都定义了 different_type,因为这个距离可正可负,所以 different_type 是带符号类型的。
3.5 数组
与 vector 相似的地方是,数组也是存放类型相同的对象的容器,这些对象本身没有名字,需要通过其所在位置方位。与 vector 不同的地方是,数组的大小确定不变,不能随意向数组中增加元素。因为数组的大小固定,因此对某些特殊的应用来说程序的运行时性能较好,但是相应地也损失了一些灵活性。
Tip:
如果不清楚元素的确切个数,请使用 vector。
3.5.1 定义和初始化内置数组
定义数组的时候必须指定数组的类型,不允许用 auto 关键字由初始值的列表推断类型。另外和 vector 一样,数组的元素为对象,因此不存在引用的数组。
不允许拷贝和赋值
不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值:
int a[] = {0, 1, 2}; int a2[] = a; // 错误:不允许使用一个数组初始化另一个数组 a2 = a; // 错误,不能把一个数组直接赋给另一个数组
WARNING:
一些编译器支持数组的赋值,这就是所谓的 编译器扩展 。但一般来说,最好避免使用非标准特性,因为含有非标准它诶嗯的程序很可能在其他编译器上无法正常工作。
理解复杂的数组声明
和 vector 一样,数组能存放大多数类型的对象。又因为数组本身就是对象,所以允许定义数组的指针及数组的引用。在这几种情况中,定义存放指针的数组比较简单和直接,但是定义数组的指针或数组的引用就稍微复杂一点了:
int *ptrs[10]; // ptrs是含有10个整型指针的数组 int &refs[10] = /* ? */; // 错误:不不存在引用的数组 int (*Parray)[10] = &arr; // Parray指向一个含有10个整数的数组 int (&arrRef)[10] = arr; // arrRef引用一个含有10个整数的数组
Tip:
要想理解数组声明的含义,最好的办法是从数组的名字开始按照由内向外的顺序阅读。
3.5.2 访问数组元素
在使用数组下标的时候,通常将其定义为 size_t 类型。size_t 是一种机器相关的无符号类型,它被设计的足够大一遍能表示内容中任意对象的大小。
3.5.3 指针和数组
数组还有一个特定:在很多用到数组名字的地方,编译器都会自动将其替换为一个指向数组首元素的指针:
string *p2 = nums; // 等价于p2 = &nums[0]
Note:
在大多数表达式中,使用数组类型的对象其实是使用一个指向该数组首元素的指针。
int ia[] = {0,1,2,3,4,5,6,7,8,9}; auto ia2(ia); // ia2是一个整型指针,指向ia的第一个元素 ia2 = 43; // 错误:ia2是一个指针,不能用int值给指针赋值
尽管 ia 是由 10 个整数构成的数组,但当使用 ia 作为初始值时,编译器实际执行的初始化过程类型于下面的形式:
auto ia2(&ia[0]); // 显然ia2的类型是int *
必须指出的是,当使用 decltype 关键字时上述转换不会发生,decltype(ia)返回的类型时由 10 个整数构成的数组:
// ia3时一个含有10个整数的数组 decltype(ia) ia3 = {0,1,2,3,4,5,6,7,8,9}; ia3 = p; // 错误:不哪能用整型指针给数组赋值 ia3[4] = i; //正确:把i的值赋给ia3的一个元素
标准库函数 begin 和 end
C++11 新标准引入了两个名为 begin 和 end 的函数。这两个函数与容器中的两个同名成员功能类似,不过数组毕竟不是类类型,因此这两个函数不是成员函数。正确的使用形式时将数组作为它们的参数:
int ia[] = {0,1,2,3,4,5,6,7,8,9}; int *beg = begin(ia); // 指向ia首元素的指针 int *end = end(ia); // 指向arr尾元素的下一位置的指针
Note:
一个指针如果指向了某种内置类型数组的尾元素的“下一个位置”,则其具备与 vector 的 end 函数返回的迭代器类似的功能。特别要注意,尾后指针不能执行解引用和递增操作。
指针运算
两个指针相减的结果的类型时一种名为 ptrdiff_t 的标准库类型,和 size_t 一样,ptrdiff_t 也是一种定义在 cstddef 头文件中的机器相关类型。因为差值可能为负值,所以 ptrdiff_t 时一种带符号类型。
下标和指针
标准库类型限定使用的下标必须粗时无符号类型,而内置的下标运算无此要求。
WARNING:
内置的下标运算符所用的索引值不是无符号类型,这一点与 vector 和 string 不一样。
3.5.5 与旧代码的接口
混用 string 对象和 C 风格字符串
任何出现字符串字面值的地方都可以用以空字符串结束的字符数组来替代:
- 允许使用以空字符结束的字符数组来初始化 string 对象或为 string 对象赋值。
- 在 string 对象的加法运算中允许使用以空字符串结束的字符数组作为其中一个运算对象(不能两个运算对象都是);在 string 对象的复合赋值运算中允许使用以空字符结束的字符串数组作为右侧的运算对象。
上述性质反过来就不成立了。为了完成该功能,string 专门提供了一个名为 c_str 的成员函数:
char *str = s; // 错误:不能用string对象初始化char* const char *str = s.c_str(); // 正确
我们无法保证 c_str 函数返回的数组一直有效,事实上,如果后续的操作改变了 s 的值就可能让之前的数组失去效用。
WARNING:
如果执行完 c_str()函数后程序想一直都能使用其返回数组,最好将该数组重新拷贝一份。
使用数组初始化 vector 对象
可以使用数组来初始化 vector 对象。要实现这一亩的,只需要指明需要拷贝区域的首元素地址和尾后地址就可以了:
int int_arr[] = {0,1,2,3,4,5}; vector<int> ivec(begin(int_arr), end(int_arr));
3.6 多维数组
for (const auto &row : ia) for (auto col : row) cout << col << endl;
这个循环中并没有任何写操作,可是我们还是将外侧循环的控制变量声明成了引用类型,这是为了避免数组被自动转成指针(参见 3.5.3 节)
Note:
要使用范围 for 语句处理多维数组,除了最内层的循环外,其他所有循环的控制变量都应该时引用类型。
第 4 章 表达式
4.1 基础
4.1.1 基本概念
左值和右值
C++的表格是要不然是 右值 ,要不然就是 左值 。这两个名词是从 C 语言继承过来的,原本是为了帮助记忆:左值可以位于赋值语句的左侧,右值则不能。
在 C++语言中,二者的区别就没那么简单了。一个左值表达式的求值结果是一个对象或者一个函数,然而以常量对象为代表的某些左值实际上不能作为赋值语句的左侧运算对象。此外,虽然某些表示的求值结果是对象,但它们是右值而非左值。可以做一个简单的归纳:当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
一个重要的原则(参见 13.6 节将介绍一种例外的情况)是在需要右值的地方可以用左值来替代,但是不能把右值当成左值(也就是位置使用)。当一个左值被当成右值使用时,实际使用的时它的内容(值)。
4.4 赋值运算符
赋值运算满足右结合律
赋值运算满足右结合律,这一点与其他二元运算烦不太一样。
int ival, jval; ival = jval = 0; // 正确:都被赋值为0
因为赋值运算符满足右结合律,所以靠右的赋值运算 jval=0 作为靠左的赋值运算符的右侧运算对象。又因为赋值运算返回的时其左侧运算对象,所以靠右的赋值运算结果(即 jval)被赋给了 ival。
对于多重赋值语句中的每一个对象,它的类型或者与右边对象的类型相同、或者可由右边对象的类型转换得到(参见 4.11 节):
int ival, *pval; // ival的类型时int;pval时指向int的指针 ival = jval = 0; // 错误:不能把真值的值赋给int string s1, s2 s1 = s2 = "OK" // 字符串字面值"OK"转换成string对象
赋值运算优先级较低
Note:
因为赋值运算符的优先级低于关系运算符的优先级,所以在条件语句中,赋值部分通常应该加上括号。
复合赋值运算符
任何一种复合运算符都完全等价于
a = a op b;
以为的区别是左侧运算对象的求值次数:使用复合运算符只求值一次,使用普通的运算符则求值两次。这两次包括:一次是作为右边子表达式的一部分求值,另一次是作为赋值运算的左侧运算对象求值。其实在很多地方,这种区别除了对程序性能有些许影响外几乎可以忽略不计。
4.5 递增和递减运算符
递增和递减运算符有两种形式:前置版本和后只版本。到目前位置,本书使用的都是前置版本,这种形式的运算符首先将运算对象加 1(或减 1),然后将改变后的对象作为求值结果。后置版本也会将运算对象加 1(或减 1),但是求值结果是运算对象改变之前那个值的副本:
int i = 0, j; j = ++i; // j = 1, i = 1:前置版本得到递增之后的值 j = i++; // j = 1, i = 2:后置版本得到递增之前的值
建议:除非必须,否则不用递增递减运算符的后置版本
运算对象可按任意顺序求值
大多数运算符都没有规定运算对象的求值顺序(参见 4.1.3 节),这在一般情况下不会有什么印象。然而,如果一条子表达式改变了某个运算对象的值,另一条子表达式又要使用该值的话,运算对象的求值顺序就很关键了。因为递增运算符和递减运算符会改变运算对象的值,所以要提防在复合表达式中错误这两个运算符。
// 该循环的行为是未定义的 while (beg != s.end() && !isspace(*beg)) *beg = toupper(*beg++) // 错误:该赋值语句未定义
赋值运算符左右两端的运算对象都用到了 beg,并且右侧的运算对象还改变了 beg 的值,所以该赋值语句是未定义的。编译器可能按照下面任意一种思路处理该表达式:
*beg = toupper(*beg); // 如果先求左侧的值 *(beg + 1) = toupper(*beg); // 如果先求右侧的值
也可能采取别的什么方式处理它。
4.7 条件运算符
条件运算符按照如下形式使用:
cond ? expr1 : expr2
当条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果时左值;否则运算的结果时右值。
嵌套条件运算符
条件运算符满足右结合律,意味着运算对象(一般)按照从右向左的顺序结合。
在输出表达式中使用条件运算符
条件运算符的优先级非常低,因此当一条长表达式中嵌套了条件运算子表达式时,通常需要在它两端加上括号。
4.8 位运算符
运算符 | 功能 | 用法 |
---|---|---|
~ | 位求反 | ~ expr |
<< | 左移 | expr1 << expr2 |
>> | 右移 | expr1 >> expr2 |
& | 位与 | expr & expr |
^ | 位异或 | expr ^ expr |
| | 位或 | expr | expr |
如果运算对象时带符号的且它的值为负,那么位运算符如果处理运算对象的“符号位”依赖于机器。而且,此时的左移操作可能会改变符号位的值,因此时一种未定义的行为。
WARNING:
关于符号位如何处理没有明确的规定,所以强烈建议仅将位运算符用于处理无符号类型。
移位运算符
左移运算符 ( <<
)在右侧插入值为 0 的二进制位。 右移运算符 ( >>
)的行为则依赖于其左侧运算对象的类型:如果该运算对象时无符号类型,在左侧插入值为 0 的二进制位;如果该运算对象时带符号类型,在左侧插入符号位的副本或值为 0 的二进制位,如何选择要视具体环境而定。
位与、位或、位异或运算符
对于 位与运算符 (&)来说,如果两个运算对象的对应位置都是 1 则运算结果中该位为 1,否则为 0。对于 位或运算符 (|)来说,如果两个运算符对象的对应位置至少有一个为 1 则运算结果中该位为 1,否则为 0。对于 位异或运算符 (^)来说,如果两个运算对象的对应位置有且仅有一个为 1 则运算结果中该位为 1,否则为 0。
移位运算符(又叫 IO 运算符)满足左结合律
移位运算符的优先级不高不低,介于中间:比算术运算符的优先级低,但比关系运算符、赋值运算符和条件运算符的优先级高。因此在一次使用多个运算符时,有必要在适当的地方加上括号使其满足我们的要求。
4.9 sizeof 运算符
sizeof 运算符返回一条表达式或一个类型名字所占的字节数。sizeof 运算符满足右结合律,其所得的值是一个 size_t 类型(参见 3.5.2 节)的常量表达式(参见 2.4.4 节)。运算符的运算对象有两种形式:
sizeof (type) sizeof expr
在第二种形式中,sizeof 返回的时表达式结果类型的大小。与众不同的一点时,sizeof 并不实际计算其运算对象的值。
C++新标准允许我们使用作用域运算符来获取类成员的大小。通常情况下只有通过类的对象才能访问到类的成员,但 sizeof 运算符无须我们提供一个具体的对象,因为要想知道类成员的大小无须真的获取该成员。
4.10 逗号运算符
对于逗号运算符来说,首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果时右侧表达式的值。如果右侧运算对象是左值,那么最终的求值结果也是左值。
4.11 类型转换
在 C++语言中,某些类型之间有关联。如果两种类型有关联,那么当程序需要其中一种类型的运算对象时,可以用另一种关联类型的对象或值来替代。换句话说,如果两种类型可以 相互转换 ,那么它们就是关联的。
C++语言不会直接将两个不同类型的值相加,而是先根据类型转换规则设法将运算对象的类型统一后再求值。上述的类型转换时自动执行的,无须程序的接入,有时甚至不需要程序员了解。因此,它们被称作 隐式转换 。
4.11.1 算术转换
算术转换 的含义是把一种算术类型转换成另外一种算术类型,这一点在 2.1.2 节中已有介绍。算术转换的规则定义了一套类型转换的层次,其中运算符的运算对象将转换成最宽的类型。
整型提升
整型提升 负责把小整数类型转换成较大的整数类型。
无符号类型的运算对象
如果一个运算对象是无符号类型、另一个运算对象是带符号类型,而且其中的无符号类型不小于带符号类型,那么带符号的运算对象转换成无符号的。
剩下的一种情况是带符号类型大于无符号类型,此时转换的结果依赖于机器。如果无符号类型的所有值都能存在该带符号的类型中,则无符号类型的运算对象转换成带符号类型。如果不能,那么带符号类型的运算对象转换成无符号类型。
4.11.2 其他隐式类型转换
数组转换成指针 :在大多数用到数组的表达式中,数组字的自动转换成指向数组首元素的指针:
当数组被用多 decltype 关键字的参数,或者作为取地址符(&)、sizeof 及 typeid 等运算符的运算对象时,上述转换不会发生。
指针的转换 :C++还规定了几种其他的指针转换方式,包括常量整数值 0 或者字面值 nullptr 能转换成任意指针类型;指向任意非常量的指针能转换成 void*;指向任意对象的指针能转换成 const void*。
转换成常量 : 允许将指向非常量类型的指针转换成指向相应的常量类型的指针,对于引用也是这样。也就是说,如果 T 时一种类型,我们就能将指向 T 的指针或引用分别转换成指向 const T 的指针或引用。相反的转换并不存在,因为它试图删掉底层 const。
类类型定义的转换 :类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。
4.11.3 显式转换
WARNING:
虽然有时不得不使用强制类型转换,但这种方法本质上时非常危险的。
命名的强制类型转换
一个命名的强制类型转换具有如下形式:
cast-name<type>(expression);
其中 type 是转换的目标类型而 expression 是要转换的值。如果 type 是引用类型,则结果是左值。cast-name 是 static_cast 、 dynamic_cast 、 const_cast 、 reinterpret_cast 中的一种 dynamic_cast 支持类型识别,我们将在 19.2 节对其左更详细的介绍。cast-name 指定了执行的是哪种转换。
static_cast
任何具有明确定义的类型转换,只要不包含底层 const,都可以使用 static_cast。
const_cast
const_cast 只能改变运算对象的底层 const(参见 2.4.3 节)。
对于将常量对象转换成非常量对象的行为,我们一般称其为“去掉 const 性质”
reinterpret_cast
reinterpret_cast 通常为运算对象的位模式提供较低层次上的重新解释。例如:
int *ip; char *pc = reinterpret_cast<char*>(ip);
WARNING:
reinterpret_cast 本质上依赖于机器。要想安全地使用 reinterpret_cast 必须对涉及的类型和编译器实现转换的过程都非常了解。
旧式的强制类型转换
在早期版本的 C++语言中,显式地进行强制类型转换包含两种形式:
type (expr); // 函数形式的强制类型转换 (type) expr; // C语言风格的强制类型转换
根据所涉及的类型不同,旧式的强制类型转换分别具有于 const_cast、 static_cast 或 reinterpret_cast 相似的行为。当我们在某处执行旧式的强制类型转换时,如果换成 const_char 和 static_cast 也合法,则其行为与对应的命名转换一致。如果替换后不合法,则旧式强制类型转换执行与 reinterpret_cast 类似的功能:
char *pc = (char*) ip; // ip是指向整数的指针
的效果与使用 reinterpret_cast 一样。
WARNING:
于命名的强制类型转换相比,旧式的强制类型转换从表现形式来说不是那么清晰明了,容易被看漏,所以一旦转换过程出现问题,追踪起来也更加困难。
第 5 章 语句
5.1 简单语句
复合语句(块)
复合语句 是指用花括号括起来的(可能为空的)语句和声明的序列,复合语句也被称作 块 。一个块就是一个作用域(参见 2.2.4 节),再块中引入的名字只能在块内部以及嵌套在块中的子块里访问。通常,名字在有限的区域内可见,该区域从名字定义处开始,到名字所在的(最内层)块的节为为止。
5.2 语句作用域
定义在控制结构中的变量只在相应语句的内部可见,一旦语句结束,变量也就超出其作用范围了。
5.3 条件语句
5.3.1 if 语句
悬垂 else
当一个 if 语句嵌套在另一个 if 语句内部时,很可能 if 分支会多于 else 分支。这时候问题出现了:我们怎么知道某个给定 else 是和哪个 if 匹配呢?
这个问题通常称作 悬垂 else ,在那些即有 if 语句又有 if else 语句的编程语句中是个普遍存在的问题。不同语言解决该问题的思路也不同,就 C++而言,它规定 else 与离它最近的尚未匹配的 if 匹配,从而消除了程序的二义性。
5.3.2 switch 语句
case 关键字和它对象的值一起被称为 case 标签 。case 标签必须是整型常量表达式(参见 2.4.4 节):
char ch = getVal(); int ival = 42; switch(ch) { case 3.14; // 错误:case标签不是一个整数 case ival; // 错误:case标签不是一个常量 }
switch 内部的变量定义
如前所述,siwtch 的执行流程有可能会跨过某些 case 标签。如果程序跳转到了某个特定的 case,则 switch 结果中该 case 标签之前的部分会被忽略掉。这种忽略掉一部分代码的行为引出了一个有趣的问题:如果被略过的代码中含有变量的定义该怎么办?
答案是:如果在某处一个带有初值的变量位于作用域之外,在另一处该变量位于作用域之内,则从前一处跳转到后一处的行为是非法行为。
case true: // 因为程序的执行流程可能绕开下面的初始化语句,所以该switch语句不何方 string file_name; // 错误:控制流绕过一个隐式初始化的变量 int ival = 0; // 错误:控制流绕过一个显示初始化的变量 int jval; // 正确:因为jval没有初始化 break; case false: // 正确:jval虽然在作用域内,但它没有被初始化 jval = next_num(); // 正确:给jval赋一个值
C++语言规定,不允许跨过变量的初始化语句直接跳转到该变量作用域内的另一个位置。
5.4 迭代语句
5.4.1 while 语句
Note:
定义在 while 条件部分或者 while 循环体内的变量每次迭代都经历从创建到销毁的过程。
5.4.2 传统的 for 语句
Note:
牢记 for 语句头中定义的对象只在 for 循环体内可见。
5.4.3 范围 for 语句
C++11 新标准引入了一种更简单的 for 语句,这种语句可以遍历容器或其他序列的所有元素。 范围 for 语句 的语法形式是:
for (declaration : expression) statement
expression 表示的必须是一个序列,比如用花括号或起来的初始值列表(参见 3.3.1 节)、数组或者 vector 或 string 等类型的对象,这些类型的共同特点是拥有能返回迭代器的 begin 和 end 成员(参见 3.4 节)。
declaration 定义一个变量,序列中的每个元素都能转换成该变量的类型(参见 4.11 节)。确保类型相容最简单的办法是使用 auto 类型说明符(参见 2.5.2 节),这个关键字可以令编译器帮助我们指定合适的类型。如果需要对序列中的元素执行写操作,循环变量必须声明成引用类型。
每次迭代都会重新定义循环控制变量,并将其初始化成序列中的下一个值,之后才会执行 statement。像往常一样,statement 可以是一条单独的语句也可以是一个块。所有元素都处理完毕后循环终止。
5.4.4 do while 语句
do statement while (condition);
Note:
do while 语句应该在括号包围起来的条件后面用一个分号表示语句结束。
condition 使用的变量必须定义在循环体之外。
5.5 跳转语句
5.5.3 goto 语句
goto 语句的语法形式是
goto label;
其中,label 是用于标识一条语句的标识符。 带标签语句 是一种特殊的语句,在它之前有一个标识符以及一个冒号:
end: return; // 带标签语句,可以作为goto的目标
标签提示符独立于变量和其他提示符的名字,因此,标签提示符可以和程序中其他实体的提示符使用同一个名字而不会互相干扰。goto 语句和控制权专项的那条代表前的语句必须位于同一个函数内。
和 switch 语句类型,goto 语句也不能将程序的控制权从变量的作用域之外转义到作用域之内:
// ... goto end; int ix = 10; // 错误:goto语句绕过了一个带初始化的变量定义 end: // 错误:此处的代码需要使用ix,但是goto语句绕过了它的声明 ix = 42;
向后跳过一个已经执行的定义是合法的。跳回到变量定义之前意味着系统将销毁该变量,然后重新创建它:
// 向后跳过一个带初始化的变量定义是合法的 begin: int sz = get_size(); if (sz <= 0) { goto begin; }
5.6 try 语句块和异常处理
异常处理机制为程序中异常检测的异常处理这两部分的协作提供支持。在 C++语言中,异常处理包括:
- throw 表达式 ,异常检测部分使用 throw 表达式来表示它遇到了无法处理的问题。我们说 trhow 引发 了异常。
- try 语句块 ,异常处理部分使用 try 语句块处理异常。try 语句块以关键字 try 开始,并以一个或多个 catch 子句 结束。try 语句块中代码抛出的异常通常会被某个 catch 子句处理。因为 catch 子句“处理”异常,所以它们也被称作 异常处理代码 。
- 一套 异常类 ,用于在 throw 表达式和相关的 catch 子句之间传递异常的具体信息
5.6.1 throw 表达式
程序的异常检测部分使用 throw 表达式引发一个异常。throw 表达式包含关键字 throw 和紧随其后的一个表达式,其中表的是的类型就是抛出的异常类型。
5.6.2 try 语句块
try 语句块的通用语法形式是
try { program-statements } catch (exception-declaration) { handler-statements } catch (exception-declaration) { handler-statements } // ...
跟在 try 块之后的是一个或多个 catch 子句。catch 子句包括三个部分:关键字 catch、括号内的一个(可能未命名的)对象的声明(称作 异常声明 )以及块。当选中了某个 catch 子句处理异常之后,执行与之对应的块。catch 一旦完成,程序跳转到 try 语句块最后一个 catch 子句之后的那条语句继续执行。
try 语句块中的 program-statements 组成程序的正常逻辑,像其他任何块一样,program-statements 可以有包括声明在内的任意 C++语句。一如往常,try 语句块内声明的变量在块外部无法访问,特别是在 catch 子句内也无法访问。
函数在寻找处理代码的过程中退出
在复杂系统中,程序在遇到抛出异常的代码前,其执行路径可能已经经过了多个 try 语句块。例如,一个 try 语句块可能调用了包含另一个 try 语句块的函数,新的 try 语句块可能调用了包含又一个 try 语句块的新函数,以此类推。
寻找处理代码的过程与函数调用链刚好相反。当异常被抛出时,首先搜索抛出该异常的函数。如果没有找到匹配的 catch 子句,终止该函数,并在调用该函数的函数中继续寻找。如果还是没有找到匹配的子句,这个新的函数也被终止,继续搜索调用它的函数。以此类推,沿着调成的执行路径逐层回退,知道找到适当类型的 catch 子句为止。
如果最终还是没能找到匹配的 catch 子句,程序转到名为 terminate 的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。
5.6.3 标准异常
C++标准库定义了一组类,用于报告标准库函数遇到的问题。这些异常类也可以在用户编写的程序中使用,它们分别定义在 4 个头文件中:
- exception 头文件定一个了最通用的异常类 exception。它只报告异常的发生,不提供任何额外信息。
- stdexcept 头文件定义几种常用的异常类,详细信息在下表中列出。
- new 头文件定义了 bad_alloc 异常类型,这种类型将在 12.1.2 节详细介绍
- type_info 头文件定义了 bad_cast 异常类型,这种类型将在 19.2 节详细介绍。
exception | 最常见的问题 |
runtime_error | 只有在运行时才能检测出问题 |
range_error | 运行时错误:生成的结果超出了有意义的值域范围 |
overflow_error | 运行时错误:计算上溢 |
underflow_error | 运行时错误:计算下溢 |
logic_error | 程序逻辑错误 |
domain_error | 逻辑错误:参数对应的结果只不存在 |
invalid_argument | 逻辑错误:无效参数 |
length_error | 逻辑错误:试图创建一个超出该类型最大长度的对象 |
out_of_rang | 逻辑错误:使用一个超出有效范围的值 |
我们只能以默认初始化(参见 2.2.1 节)的方式初始化 exception、bad_alloc 和 bad_cast 对象,不允许为这些对象提供初始值。
其他异常类型的行为则恰好相反:应该使用 string 对象或者 C 风格字符串初始化这些类型的对象,但是不允许使用默认初始化的方法。当创建此类对象时,必须提供初始化值,该初始值含有错误相关的信息。
异常类型只定义了一个名为 what 的成员函数,该函数没有任何参数,返回值时一个指向 C 风格字符串(参见 3.5.4 节)的 const char*。该字符串的亩的时提供关于异常的一些文本信息。
what 函数返回的 C 风格字符串的内容与异常对象的类型有关。如果异常类型有一个字符串初始值,则 what 返回该字符串。对于其他无初始值的异常类型来说,what 返回的内容由编译器决定。
第 6 章 函数
6.1 函数基础
我们通过 调用运算符 来执行函数。调用运算符的形式是一对圆括号,它作用于一个表达式,该表达式是函数或者指向函数的指针;圆括号之内是一个用逗号隔开的实参列表,我们用实参初始化函数的形参。调用表达式的类型就是函数的返回类型。
6.1.1 局部对象
自动对象
对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在块末尾时销毁它。我们把只存在于块执行期间的对象称为 自动对象 。当块的执行结束后,块中创建的自动对象的值就编程未定义的了。
局部静态对象
局部静态对象 在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,再次期间即使对象所在的函数结束也不会对它有影响。
6.1.2 函数声明
因为函数的声明不包含函数体,所以也就无须形参的名字。事实上,在函数的声明中经常省略形参的名字。尽管如此,写上形参的名字还是有用初的,它可以帮助使用者更好地理解函数的功能:
void print(vector<int>::const_iterator beg, vector<int>::const_iterator end);
函数的三要素(返回类型、函数名、形参类型)描述的函数的接口,说明了调用该函数所需的全部信息。函数声明也被称作 函数原型 。
6.2 参数传递
当形参时引用类型时,我们说它对应的实参被 引用调用 或者函数被 传引用调用 。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参时它对应的实参的别名。
当实参的值被拷贝给形参时,形参和实参时两个互相独立的对象。我们说这样的实参被 值传递 或者函数被 传值调用 。
6.2.2 传引用参数
使用引用避免拷贝
拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括 IO 类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
Best Practies:
如果函数无须改变引用形参的值,最好将其声明为常量引用。
6.2.4 数组形参
数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:不允许拷贝数组(参见 3.5.1 节)以及使用数组时(通常)会将其转换成指针(参见 3.5.3 节)。因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会被转成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。
尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:
// 进款形式不同,但这三个print函数是等价的 // 每个函数都有一个const int*类型的形参 void print(const int*); void print(const int[]); // 可以看出来,函数的意图是作用域一个数组 void print(const int[10]); // 这里的维度表示我们期望数组含有多少元素,实际不一定
如果我们传给 print 函数的是一个数组,则实参自动低转换成指向数组首元素的指针,数组的大小对函数的调用没有影响。
数组引用形参
C++语言允许将变量定义成数组的引用(参见 3.5.1 节),基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定对应的实参上,俄语就是绑定到数组上:
// 正确:形参是数组的引用,未读是类型的一部分 void print(int (&arr)[10]) { for (auto elem : arr) cout << elem << endl; }
Note:
&arr 两端的括号必不可少(参见 3.5.1 节)
f(int &arr[10]) / 错误:将 arr 声明成了引用的数组 f(int (&arr)[10]) / 正确:arr 是具有 10 个整数的整型数组的引用
传递多维数组
和所有数组一样,当将多维数组传递给函数时,真正传递的是指向数组首元素的指针(参见 3.6 节)。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略:
// matrix指向数组的有元素,该数组的元素是由10个整数构成的数组 void print(int (*matrix)[10], int rowSize) { /* ... */ }
Note:
再一次强调,*matrix 两端的括号必不可少: int *matrix[10]; / 10 个指针构成的数组 int (*matrix)[10]; / 指向含有 10 个整数的数组的指针
我们也可以使用数组的语法定义函数,此时编译器会一如既往地忽略掉第一个维度,所以最好不要把它包括在形参列表内:
// 等价定义 void print(int matrix[][10], int rowSize) { /* ... */ }
matrix 的声明看起来是一个二维数组,实际上形参是指向含有 10 个整数的数组的指针。
6.2.6 含有可变形参的函数
为了编写能处理不同数量实参的函数,C++11 新标准提供了两种主要的方法:如果所有的实参类型型钢,可以传递一个名为 initializer_list 的标准库类型;如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板,关于它的细节将在 16.4 节介绍。
initializer_list 形参
如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用 initializer_list 类型的形参。initializer_list 是一种标准库类型,用于表示某种特定类型的值的数组(参见 3.5 节)。initializer_list 类型定义在同名头文件中,它提供的操作如下表所示。
initializer_list<T> lst; | 默认初始化;T类型元素的空列表 |
initializer_list<T> lst{a,b,c…}; | lst 的元素数量和初始值一样多;lst 的元素是对应初始值的副本;列表中的元素是 const |
lst2(lst) | 拷贝或赋值一个 initializer_list 对象不会拷贝列表中的元素; |
lst2 = lst | 拷贝后原是列表和副本共享元素 |
lst.size() | 列表中的元素数量 |
lst.begin() | 返回指向 lst 中首元素的指针 |
lst.end() | 返回 lst 中尾元素下一位置的指针 |
和 vector 一样,initializer_list 也是一种模板类型(参见 3.3 节)。和 vector 不一样的是,initializer_list 对象中的元素永远是常量值,我们无法改变 initializer_list 对象中元素的值。
省略符形参
省略符形参是为了便于 C++程序访问某些特殊的 C 代码而设置的,这些代码使用了名为 varargs 的 C 标准库功能。
WARNING:
省略符形参硬仅仅用于 C 和 C++通用的类型。特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。
省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种:
void foo(parm_list, ...); void foo(...);
6.3 返回类型和 return 语句
6.3.2 有返回值函数
return 语句返回值的类型必须与函数的返回值类型相同,或能隐式的转换成(参见 4.11 节)函数的返回类型。
值时如何被返回的
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
不要返回局部对象的引用或指针
函数完整后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域。
如前所述,返回局部对象的引用是错误的;同样,返回局部对象的指针也是错误的。一旦函数完成,局部对象被释放,指针将指向一个不存在的对象。
引用返回左值
函数的返回类型决定函数调用是否是左值(参见 4.1.1 节)。调用一个返回引用的函数得到左值,其他返回了类型得到右值。可以像使用其他左值那样来使用返回引用的函数调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值:
char &get_val(string &str, string::size_type ix){ return str[ix]; // get_val假定索引值是有效的 } int main { string s("a value"); cout << s << endl; // 输出a value get_val(s, 0) = 'A'; // 将s[0]的值改为A cout << s << endl; // 输出A value return 0; }
把函数调用放在赋值语句的左侧可能看起来有点奇怪,但其实这没什么特别的。返回值是引用,因此调用是个左值,和其他左值一样它也能出现在赋值运算符的左侧。
列表初始化返回值
C++11 新标准规定,函数可以返回花括号的值的列表。类似于其他返回结果,此初的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化(参见 3.3.1 节);否则,返回的值由函数的返回类型决定。
如果函数返回的内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间(参见 2.2.1 节)。如果函数返回的是类类型,由类本身定义初始值如何使用(参见 3.3.1 节)。
6.3.3 返回数组指针
因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。虽然从语法上来说,要想定义个返回数组的指针或引用的函数比较凡所,但是有一些方法可以简化这一任务,其中最直接的办法是使用类型别名(参见 2.5.1 节):
typedef int arrT[10]; // arrT是一个类型比诶嗯,它表示的类型是含有10个整数的数组 using arrT = int[10]; // arrT的等价声明 arrT* func(int i); // func返回一个指向含有10个整数的数组指针
声明一个返回数组指针的函数
要想在声明 func 时不适用类型别名,我们必须牢记被定义的名字后面数组的维度:
int arr[10]; // arr时一个含有10个整数的数组 int *p1[10]; // p1时一个函数10个指针的数组 int (*p2)[10] = &arr // p2时一个指针,它指向含有10个整数的数组
和这些声明一样,如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后。然而,函数的形参列表也跟在函数名字后面且形参列表应该先于数组的维度。因此,返回数组指针的函数形式如下所示:
Type (*function(parameter_list))[dimension]
类似于其他数组的声明,Type 表示元素的类型,dimension 表示数组的大小。(*function(parameter_list))两端的扩招 i 必须存在,就像我们定义 p2 时两端必须有括号一样。如果没有这对括号,函数的返回类型将是指针的函数。
举个具体点的例子。下面这个 func 函数的声明没有使用类型别名:
int (*func(int i))[10];
可以按照以下的顺序来逐层理解该声明的含义:
- func(int i)表示调用 func 函数时需要一个 int 类型的实参。
- (*func(int i))意味着我们可以对函数调用的结果执行解引用操作。
- (*func(int i))[10]表示解引用 func 的调用将得到一个大小时 10 的数组。
- int (*func(int i))[10]表示数组中的元素时 int 类型。
使用尾置返回类型
在 C++11 标准中还有一种可以简化上述 func 声明的方法,就是使用 尾置返回类型 。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是函数的指针或数组的引用。尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个 auto:
// func接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组 auto func(int i) -> int(*)[10];
使用 decltype
还有一种情况,如果我们知道函数返回的指针将指向哪个数组,就可以使用 decltype 关键字声明返回类型。例如,下面的函数返回一个指针,该指针根据参数 i 的不同指向两个已知数组中的一个:
int odd[] = {1,3,5,7,9}; int even[] = {0,2,4,6,8}; // 返回一个指针,该指针指向含有5整数的数组 decltype(odd) *arrPtr(int i) { return {i % 2} ? &odd : &even; // 返回一个指向数组的指针 }
有一个地方需要注意:decltype 并不负责把数组类型转换成对应的指针,所以 decltype 的结果是个数组,要想表示 arrPtr 返回指针还必须在函数声明时加一个*符号。
6.4 函数重载
如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为 重载函数 。
定义重载函数
不允许两个函数除了返回类型外其他所有的要素都相同。假设有两个函数,它们的形参列表一样但是返回类型不同,则第二个函数的声明是错误的:
Record looksup(const Account&); bool looksup(const Account&); // 错误:与上一个函数相比只有返回类型不同
重载和 const 形参
如果 6.2.3 节介绍的,顶层 const 不影响传入函数的对象。一个拥有顶层 const 的形参无法和另一个没有顶层 const 的形参区分开来。
Record looksup(Phone); Record looksup(Const Phone); // 重复声明了Record looksup(Phone) Record looksup(Phone*); Record looksup(Phone* const); // 重复声明了Record looksup(Phone*)
另一个方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的 const 是底层的。
// 对于接受引用或指针的函数来说,对象是常量还是非常量对应的形参不同 // 定义4个独立的重载函数 Record looksup(Account&); // 函数作用于Account的引用 Record looksup(const Account&); // 新函数,作用于常量引用 Record looksup(Account*); // 新函数,作用域指向Account的指针 Record looksup(const Account*); // 新函数,作用于指向常量的指针
在上面的例子中,编译器可以通过实参是否是常量来推断应该调用哪个函数。因为 const 不能转换成其他其类型(参见 4.11.2 节),所以我们只能把 const 对象(或指向 const 的指针)传递给 const 形参。相反的,因为非常量可以转换成 const,所以上面的 4 个函数都能作用于非常量对象或指向非常量对象的指针。不过,如 6.6.1 节降妖介绍的,当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。
const_cast 和重载
const string &shorterString(const string &s1, const string &s2) { return s1.size() <= s2.size() ? s1 : s2; }
string &shorterString(string &s1, string &s2) { auto &r = shorterString(const_cast<const string&>(s1), const_cast<const string&>(s2)); return const_cast<string&>(r); }
在这个版本的函数中,首先将它的实参强制转换成对 const 的引用,然后调用了 shorterString 函数的 const 版本。const 版本返回对 const string 的引用,这个引用事实上绑定在了某个初始的非常量实参上。因此,我们可以再将其转换回一个普通的 string&,这显示是安全的。
调用重载的函数
函数匹配 是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来,函数匹配也叫做 重载确定 。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
现在我们需要章武的是,当调用重载函数时有三种可能的结果:
- 编译器找到一个与实参 最佳匹配 的函数,并声称调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,此时编译器发出 无匹配 的错误信息
6.4.1 重载与作用域
一旦在当前作用域中找到了所需的名字,编译器就会忽略掉外层作用域中的同名实体。剩下的工作就是检查函数是否有效了。
Note:
在 C++语言中,名字找查发生在类型检查之前。
6.5 特殊用途语言特性
默认实参声明
对于函数的声明来说,通常的习惯时将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为值前那些没有默认值的形参添加默认实参,而且该形参右侧所有形参必须都有默认值。
默认实参初始值
局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参:
// wd、def和ht的声明必须出现在函数之外 sz wd = 80; char def = ' '; sz ht(); string screen(sz = ht(), sz = wd, char = def); string window = screen(); // 调用screen(ht(), 80, ' ')
用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时。
6.5.2 内联函数和 constexpr 函数
内联函数可避免函数调用的开销
将函数指定为 内联函数 ,通常就是将它在每个调用点上“内联地”展开。
Note:
内联说明只是向编译器发出一个请求,编译器可以选择忽略这个请求。
一般来说,内联机制用于优化规模较小,流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,而且一个 75 行的函数也不大可能在调用点内联地展开。
constexpr 函数
constexpr 函数 是指能用于常量表达式(参见 2.4.4 节)的函数。定义 constexpr 函数的方法与其他函数类似,不过要遵循几项约定:函数的返回类型及所有形参的类型都得是字面值类型(参见 2.4.4 节),而且函数体中必须有且只有一条 return 语句:
constexpr int new_sz() { return 42; } constexpr int foo = new_sz(); // 正确:foo是一个常量表达式
执行该初始化任何时,编译器把对 constexpr 函数的调用替换成其结果只。为了能在编译过程中随时展开,constexpr 函数被隐式地指定为内联函数。
constexpr 函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。例如,constexpr 函数中可以有空语句、类型别名(参见 2.5.1 节)以及 using 声明。
Note:
constexpr 函数不一定返回常量表达式。
把内联函数和 constexpr 函数放在头文件内
和其他函数不一样,内联函数的 constexpr 函数可以在程序中多次定义。毕竟,编译器想展开函数仅有函数声明是不够的,还需要函数的定义。不过对于某个给定的内联函数或者 constexpr 函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和 constexpr 函数通常定义在头文件中。
6.5.3 调试帮助
assert 预处理宏
assert 是一种 预处理宏 。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert 宏使用一个表达式作为它的条件:
assert(expr);
首先对 expr 求值,如果表达式为假(即 0),assert 输出信息并终止程序的执行。如果表达式为真(即非 0),assert 什么也不做。
NDEBUG 预处理变量
assert 的行为依赖于一个名为 NDEBUG 的预处理变量的状态。如果定义了 NDEBUG,则 assert 什么也不做。默认状态下没有定义 NDEBUG,此时 assert 将执行运行时检查。
我们可以使用一个#define 语句定义 NDEBUG,从而关闭调试状态。同时,很多编译器都提供了一个命令行选项使我们可以定义预处理变量:
$ CC -D NDEBUG main.c
这条命令的作用等级与在 main.c 文件的一开始写#define NDEBUG。
除了用于 assert 外,也可以使用 NDEBUG 编写自己的条件调试代码。如果 NDEBUG 未定义,将执行#ifndef 和#endif 之间的代码;如果定义了 NDEBUG,这些代码将被忽略掉:
void print(const int ia[], size_t size) { #ifndef NDEBUG // __func__ 是编译器定义的一个局部静态变量,用于存放函数的名字 cerr << __func__ << ": array size is " << size << endl; #endif // ... }
除了 C++编译器定义的__func__之外,预处理器还定义了另外 4 个对于程序调试很有用的名字:
__FILE__
存放文件名的字符串字面值
__LINE__
存放当前行号的整型字面值。
__TIME__
存当文件编译时间的字符串字面值。
__DATE__
存放文件编译时期的字符串字面值。
6.6 函数匹配
确定候选函数和可行函数
函数匹配的第一步是选定本次调用对应的重载函数集,集合中的函数称为 候选函数 。候选函数具备两个特征:一是与被调用的函数同名,二是其声明在调用点可见。
第二步考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为 可行函数 。可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。
寻找最佳匹配(如果有的话)
函数匹配的第三步是从可行函数中选择与本次调用最匹配的函数。在这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。下一节将介绍“最匹配”的细节,它的基本思想是,实参类型与形参类型越接近,它们匹配得越好。
含有多个形参的函数匹配
当实参的数量有两个或更多时,函数匹配就比较复杂了。
选择可行函数的方法和只有一个实参时一样,编译器选择那些形参数量满足要求且实参类型和形参类型能够匹配的函数。
如果有且只有一个函数满足下列条件,则匹配成功:
- 该函数每个实参的匹配都不劣于其他可行函数需要的匹配
- 至少有一个实参的匹配优于其他可行函数提供的匹配
如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用时错误的。编译器将报告二义性调用的信息。
Best Practies:
调用函数时应仅两避免强制类型转换。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
6.6.1 实参类型转换
为了确定最佳匹配,编译器将实参类型到形参类型的转换划分称为几个等级,具体排序如下所示:
- 精确匹配,包括以下情况:
- 实参类型和形参类型相同。
- 实参从数组类型或函数类型转换成对应的指针。
- 向实参添加顶层 const 或者从实参中删述顶层 const。
- 通过 const 转换实现的匹配(参见 4.11.2 节)。
- 通过类型体呈实现的匹配(参见 4.11.1 节)。
- 通过算术类型转换(参见 4.11.1 节)或者指针转换(参见 4.11.2 节)实现的匹配。
- 通过类类型转换实现的匹配(参见 14.9 节)
需要类型提供和算术类型转换的匹配
所有算术类型转换的级别都一样。例如,从 int 向 unsigned int 的转换并不比从 int 向 double 的转换级别高。
6.7 函数指针
函数的类型由它的返回类型和形参类型共同决定,与函数名无关。例如:
bool lengthCompare(const string &, const string &);
该函数的类型时 bool(const string&, const string&)。要想声明一个可以指向该函数的指针,只需要用指针替换函数名即可:
// pf指向一个函数,该函数的参数时两个const string的引用,返回值是bool类型 bool (*pf)(const string &, const string &); /// 未初始化
Note:
*pf 两端的括号必不可少。如果不写这对括号,则 pf 是一个返回值为 bool 指针的函数:
bool *pf(const string &, const string &);
使用函数指针
当我们把函数名作为一个值使用时,该函数自动地转换成指针。
pf = lengthCompare; // pf指向名为lengthCompare的函数 pf = &lengthCompare; // 等于的赋值语句:取地址符时可选的
此外,我们还可能着接使用指向函数的指针调用该函数,无须提前解引用指针:
bool b1 = pf("hello", "goodbye"); // 调用lengthCompare函数 bool b2 = (*pf)("hello", "goodbye"); // 一个等价的调用 bool b3 = lengthCompare("hello", "goodbye"); // 另一个等价的调用
重载函数的指针
当我们使用重载函数时,上下文必须清晰地界定到底应该选用哪个函数。
函数指针形参
和数组类型,虽然不能定义函数类型的形参,但形参可以时指向函数的指针。此时,形参看起来是函数类型,但实际上确实当成指针使用:
// 第三个形参是函数类型,它会自动地转换成指向函数的指针 void useBigger(const string &s1, const string &s2, bool pf(const string &, const string &)); // 等价的声明,显式地将形参定义成指向函数的指针 void useBigger(const string &s1, const string &s2, bool (*pf)(const string &, const string &));
直接使用函数指针类型显得于冗长烦琐。类型别名(参见 2.5.1 节)和 decltype 能让我们简化使用了函数指针的代码:
// Func和Func2是函数类型 typedef bool Func(const string&, const string&); typedef decltype(lengthCompare) Func2 // 等价的类型 // FuncP和FuncP2是指向函数的指针 typedef bool(*FuncP)(const string&, const string&); typedef decltype(lengthCompare) *FuncP2; // 等价的类型
需要注意的是,decltype 返回函数类型,此时不会将函数类型自动转换成指针类型。因为 decltype 的结果是函数类型,所以只有在结果前面加上*才能得到指针。
返回指向函数的指针
和数组类型,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把函数类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。
int (*f1(int))(int*, int);
按照由内向外的顺序阅读这条声明语句:我们看到 f1 有形参列表,所以 f1 是个函数;f1 前面有*,所以 f1 返回一个指针;进一步观察发现,指针的类型本身也包含形参列表,因此指针指向函数,该函数的返回类型是 int。
出于完整性考虑,有必要提醒读者我们还可以使用尾置返回类型的方式(参见 6.3.3 节)声明一个返回函数指针的函数:
auto f1(int) -> int (*)(int*, int);
第 7 章 类
类的基本思想是 数据抽象 和 封装 。数据抽象是一种依赖于 接口 和 实现 分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义累所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现的部分。
类要想实现数据抽象和封装,需要首先定义一个 抽象数据类型 。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。
7.1 定义抽象数据类型
7.1.2 定义该进的 Sales_data 类
struct Sales_data { std::string isbn() const ( return bookNo; ) Sales_data& combine(const Sales_data&); double avg_price() const; std::string bookNo; unsigned units_sold = 0; double revenue = 0.0; };
Note:
定义在类内部的函数是隐式的 inline 函数。
定义成员函数
尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。
引入 this
成员函数通过一个名为 this 的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化 this。例如,如果调用
total.isbn()
则编译器负责把 total 的地址传递给 isbn 隐式形参 this,可以等价地认为编译器将该调用重写成了如下的形式:
Sales_date::isbn(&total)
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为 this 所指的正是这个对象。任何对类成员的直接访问都被看作 this 的隐式引用。也就是说,当 isbn 使用 bookNo 时,它隐式第使用 this 指向的成员,就像我们书写了 this->bookNo 一样。
因为 this 的亩的总是指向“这个”对象,所以 this 是一个常量指针,我们不允许改变 this 中保存的地址。
引入 const 成员函数
isbn 函数的另一个关键之处时紧随参数列表之后的 const 关键字,这里,const 的作用是修改隐式 this 指针的类型。
默认情况下,this 的类型是指向类类行非常量版本的常量指针。例如在 Sales_data 成员函数中,this 的类型是 Sales_data *const。尽管 this 是隐式的,但它仍然需要遵循初始化规则,意味着(在默认情况下)我们不能把 this 绑定到一个常量对象上。这一情况也使得我们不能在一个常量对象上调用普通的成员函数。
如果 isbn 是一个普通函数而且 this 是一个普通的指针参数,则我们应该把 this 声明称 const Sales_data *const。毕竟,在 isbn 的函数体内不会改变 this 所指的对象,所以把 this 设置为指向常量的指针有助于提高函数的灵活性。
然而,this 是隐式的并且不会出现在参数列表中,所以在哪儿将 this 声明成指向常量的指针成为我们必须面对的问题。C++语言的做法是允许把 const 关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的 const 表示 this 是一个指向常量的指针,像这样使用 const 的成员函数被称作 常量成员函数 。
可以把 isbn 的函数体想像成如下的形式:
// 伪代码,说明隐式的this指针是如何使用的 // 下面的代码是非法的:因为我们不能显式地定义自己的this指针 // 谨记此处的this是一个指向常量的指针,因为isbn是一个常量指针 std::string Sales::isbn(const Sales_data *const this) { return this->isbn; }
因为 this 是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。在上例中,sibn 可以读取调用它的对象的数据成员,但是不能写入新值。
在类的外部定义成员函数
像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员被声明成常量函数,那么它的定义也必须在参数列表后面指定 const 属性。同时,类外外部定义的成员的名字必须包含它所属的类名:
double Sales_data::avg_price() const { if (units_sold) return revenue/units_sold; else return 0; }
7.1.3 定义类相关的非成员函数
Note:
一般来说,如果非成员函数时类接口的组成部分,则这些函数声明应该与类在同一个头文件内。
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做 构造函数 。构造函数的任务时初始化类的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同。和其他函数不一样的时,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的函数体)。类可以包括多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
不同于其他函数成员,构造函数不能声明成 const 的(参见 7.1.2 节)。当我们创建一类的一个 const 对象时,知道构造函数完成初始化过程,对象才能真正取得其“常量属性”。因此,构造函数在 const 对象的构造过程中可以向其写值。
合成的默认构造函数
Sales_data total; Sales_data trans;
我们没有为这些对象提供初始值,因此我们知道它们执行了默认初始化(参见 2.2.1 节)。类通过一个特殊的构造函数来控制默认值初始化过程,这个函数叫做 默认构造函数 。默认构造函数无须任何实参。
如我们所见,默认构造函数有很多方便都有其特殊性。其中之一时,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
编译器创建的构造函数又被称为 合成的默认构造函数 。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值(参见 2.6.1 节),则用它来初始化成员
- 否则,默认初始化(参见 2.2.1 节)该成员。
某些类不同依赖于合成的默认构造函数
合成的默认构造函数适合非常简单的类。对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:第一个原因也是最容易理解的一个原因就是编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。这条规则的依据时,如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。
第二个原因是对于某些类来说,合成的默认构造函数可能执行错误的操作。回忆我们值前介绍过的,如果定义在块中的内置类型或者复合类型(比如数组和指针)的对象被默认初始化(参见 2.2.1 节),则它们的值是未定义的。该准则同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时可能得到未定义的值。
第三个原因是有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的函数,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。
= default 的含义
我们从解释默认构造函数的含义开始:
Sales_data() = default
首先请明确一点:因为该构造函数不接受任何实参,所以它是一个默认构造函数。我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于值前使用的合成默认构造函数。
在 C++新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = defualt 来要求编译器生成构造函数。其中,= default 既可以和声明一起出现在类的内部,也可以作用定义出现在类的外部。和其他函数一样,如果= default 在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。
构造函数的初始值列表
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) { }
这两个定义中出现了新的部分,即冒号以及冒号和花括号之间的代码,其中花括号定义了(空的)函数体。我们把新出现的部分称为 构造函数初始值列表 ,它负责为新创建的对象的一个或几个数据成员赋初值,构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。
当某个数据成员被构造函数初始值列表忽略时,它将以于合成默认构造函数相同的方式隐式初始化。
Best Practies:
构造函数不应该轻易覆盖掉类内初始值,除非新赋值的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
有一点需要注意,在上面的两个构造函数中函数体都是空的。这是因为这些构造函数的唯一目的就是为数据成员赋初值,一旦没有其他任务需要执行,函数体也就为空了。
在类的外部定义构造函数
构造函数没有返回类型,所以定义从指定的函数名字开始。和其他成员函数一样,当我们在类的外部定义构造函数时,必须指明该构造函数时哪个类的成员。
没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始值。
7.1.5 拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等(参见 6.2.1 节和 6.3.2 节)。当我们使用赋值运算符(参见 4.4 节)时会发生对象的赋值操作。当对象不再存在时执行销毁的操作,比如一个局部对象会在创建它的块结束时被销毁,当 vector 对象(或者数组)销毁时存储在其中的对象也会被销毁。
如果我们不主动定义这些操作,则编译器将替我们合成它们,一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。
我们将在第 13 章中介绍如何自定义上述操作。
某些类不能依赖于合成的版本
尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。
不过值的注意的是,很多需要动态内存的类能(而且应该)使用 vector 对象或者 string 对象管理必要的存储空间。使用 vector 或者 string 的类能避免分配和释放内存带来的复杂性。
7.2 访问控制与封装
在 C++语言中,我们使用 访问说明符 加强类的封装行:
- 定义在 public 说明符之后的成员在整个程序内可被访问,public 成员定义类的接口
- 定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private 部分封装了(即隐藏了)类的实现细节。
每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者达到类的结尾为止。
使用 class 或 struct 关键字
类可以在它第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。如果我们使用 struct 关键字,则定义在一个访问说明符之前的成员是 public 的;相反,如果我们使用 class 关键字,则这些成员是 private 的。
WARNING:
使用 class 和 struct 定义类唯一的区别就是默认访问权限。
7.2.1 友元
类可以允许其他类或者函数访问它的非共有成员,方法是令其他其类或者函数成为它的 友元 。如果类想把一个函数作为它的友元,只要添加一条 friend 关键字开始的函数声明语句即可:
class Sales_data { // 为Sales_data的非成员函数所做的友元声明 friend Sales_data add(const Sales_data&, const Sales_data&); friend std::istream &read(std::istream&, Sales_data&); friend std::ostream &print(std::ostream&, const Sales_data&); public: Sales_data() = default; Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { } Sales_data(const std::string &s): bookNo(s) { } Sales_data(std::istream&); std::string isbn() const { return bookNo; } Sales_data &combine(const Sales_data&); private: std::string bookNo; unsigned units_sold = 0; double revenue = 0.0; }; // Sales_data接口的非成员组成部分的声明 Sales_data add(const Sales_data&, const Sales_data&); std::istream &read(std::istream&, Sales_data&); std::ostream &print(std::ostream&, const Sales_data&);
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。
Tip:
一般来说,最好在类定义开始或结束前的位置几种声明友元。
关键概念:封装的益处
封装有两个重要的优点:
- 确保用户代码不会无意间破坏封装对象的状态。
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。
友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。
为了使友元对类的用户可见,我们通常把友元的声明与类本身放置再同一个头文件中(类的外部)。
7.3 类的其他特性
7.3.1 类成员再探
定义一个类型成员
除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存当访问限制,可以是 public 或者 private 中的一种:
class Screen { public: typedef std::string::size_type pos; private: pos curosr = 0; pos heigh = 0, width = 0; std::string contents; };
用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别,具体原因将在 7.4.1 节解释。因此,类型成员通常出现在类开始的地方。
令成员作为内联函数
我们可以在类的内部把 inline 作用声明的一部分显式地声明成员函数,同样的,也能在类的外部用 inline 关键字修时函数的定义。
虽然我们无须在声明和定义的地方同时说明 inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明 inline,这样可以使类更加容易理解。
Note:
和我们在头文件中定义 inline 函数的原因一样(参见 6.5.2 节),inline 成员函数也应该与相应的类定义在同一个头文件中。
可变数据成员
有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员,即是在一个 const 成员函数内。可以通过在变量的声明中加入 mutable 关键字做到这一点。
一个 可变数据成员 永远不会是 const,即是它是 const 对象的成员。因此,一个 const 成员函数可以改变一个可变成员的值。
class Screen { public: void some_member() const; private: mutable size_t access_ctr; // 即是在一个const对象内也能呗修改 // ... }; void Screen::some_member() const { ++access_ctr; // ... }
类数据成员的初始值
Note:
当我们提供一个类内初始值时,必须以符号=或者花括号表示
从 const 成员函数返回*this
Note:
一个 const 成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。
7.3.3 类类型
Note:
即使两个类的成员列表完全一致,他们也不是不同的类型。对于一个类来说,它的成员和其他任何类(或者任何其他其作用域)的成员都不是一回事儿。
类的声明
就像可以把函数的声明和定义完全分离开来(参见 6.1.2 节),我们也能仅仅声明类而暂时不定义它:
class Screen; // Screen类的声明
这种声明有时被称作 前向声明 ,它向程序中引入了名字 Screen 并且指明 Screen 时一种类类型。对于类型 Screen 来说,在它声明之后定义之前时一个 不完全类型 ,也就是说,此时我们已知 Screen 时一个类类型,但时不清楚它刀第包含哪些成员。
不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
在 7.6 节中我们将描述一种例外的情况:直到类被定义之后数据成员才能被声明成这种类类型。换句话说,我们必须首先完成类的定义,然后编译器才能直到存储该数据成员需要多少空间。因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然而,一旦以各类的名字出现后,它就是被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:
class Link_screen { Screen window; Link_screen *next; Link_screen *prev; };
7.3.4 友元再探
类之间的友元关系
class Screen { // Windows_mgr的成员可以访问Screen类的私有部分 friend class Windows_mgr; // ... };
如果一个类指定了友元类,则友元类的成员函数可以访问词类包括非公有成员在内的所有成员。
必须要注意的一点是,友元关系不存在传递性。也就是说,如果 Windows_mgr 有它自己的友元,则这些友元并不能理所当然地具有访问 Screen 的特权。
Note: 每个类负责控制自己的友元类或友元函数。
令成员函数作为友元
当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:
class Screen { // Windows_mgr::clear必须在Sceen类之前被声明 friend void Windows_mgr::clear(ScreenIndex); // .... };
要想令某个成员函数作为友元,我们必须仔细组织程序的结果以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:
- 首先定义 Window_mgr 类,其中声明 clear 函数,但是不定义它。在 clear 使用 Screen 的成员之前必须先声明 Screen。
- 接下来定义 Screen,包括对于 clear 的友元声明
- 最后定义 clear,此时它才可以使用 Screen 的成员。
函数重载和友元
尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每个分别声明。
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中(参见 7.2.1 节)。
甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:
struct X { friend void f() { /* 友元函数可以定义在类的内部 */ } X() { f(); } // 错误:f还没又被声明 void g(); void h(); }; void X::g() { return f(); } // 错误:f还没又被声明 void f(); // 声明那个定义在X中的函数 void X::h() { return f(); } // 正确:现在f声明在作用域中了
Note:
请注意,有的编译器并不强制执行上述有关友元的限制规则(参见 7.2.1 节)。
7.4 类的作用域
作用域和定义在类外部的成员
一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名(参见 7.1.2 节)。在类的外部,成员的名字被隐藏起来了。
一旦与到了类名,定义的剩余部分就在类的作用域之内的,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无法再次授权了。
另一方面,函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这是,返回类型必须指明它是哪个类的成员。
class Window_mgr { public: ScreenIndex addScreen(const Screen&); // ... }; // 首先处理返回类型,之后我们才进入Window_mgr的作用域 Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s) { screens.push_back(s); return screens.size() - 1; }
7.4.1 名字查找与类的作用域
在目前为止,我们编写的程序中, 名字查找 (寻找与所用名字最匹配的声明的过程)的过程比较直接了当:
- 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
- 如果没有找到,继续查找外层作用域。
- 如果最终没有找到匹配的声明,则程序报错。
对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别,不过在当前的这个例子中体现得不太明显。类的定义分两步处理:
- 首先,编译成员的声明。
- 直到类全部可见后才编译函数体。
Note:
编译器处理完类中的全部声明后才会处理成员函数的定义。
按照这种两阶段的方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。相反,如果函数的定义和成员的声明被同时处理,那么我们将不得不在成员函数中只使用那些已经出现的名字。
用于类成员声明的名字查找
这两种阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即是该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。
Tip:
类型名的定义通常出现在类的开始初,这样就能确保所有使用该类的成员都出现在类名的定义之后。
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员函数都可以被考虑。
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
在文件中名字的出现处对其进行解析
当成员定义在类的外部时,名字查找的第三不不仅要考虑定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。
7.5 构造函数再探
7.5.1 构造函数初始值列表
当我们定义变量时习惯于立即对其进行初始化,而非先定义、再赋值。就对象的数据成员而言,初始化和赋值也有类似的区别。如果没有再构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。例如:
// Sales_data构造函数的一种写法,虽然合法但比较草率:没有使用构造函数初始值 Sales_data::Sales_data(const string &s, unsigned cnt, double price) { bookNo = s; units_sold = cnt; revenue = cnt * price; }
这段代码和我们在 7.1.4 节的原始定义效果是相同的:当构造函数完成后,数据成员的值相同。区别是原来的版本初始化了它的数据成员,而这个版本是对数据成员执行了赋值操作。这一区别刀第会有什么深层次的影响完全依赖于数据成员的类型。
构造函数的初始值有时必不可少
有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是 const 或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员函数初始化。
随着构造函数体一开始执行,初始化就完成了。我们初始化 const 或者引用类型的数据成员的唯一机会就是通过构造函数初始值。
Note:
如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
建议:使用构造函数初始值
在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。
除了效率问题外更重要的是,一些数据成员必须被初始化。建议读者养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。
成员初始化的顺序
成员的初始化顺序与它们在类定义中出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
Best Practies:
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
默认实参和构造函数
Note:
如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。
7.5.2 委托构造函数
C++11 新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的 委托构造函数 。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
class Sales_data { public: // 非委托构造函数使用对应的实参初始化成员 Sales_data(std::string s, unsigned cnt, double price): bookNo(s), units_sold(cnt), revenue(cnt*price) { } // 其余构造函数全都委托给另一个构造函数 Sales_data(): Sales_data("", 0, 0) { } Sales_data(std::string s): Sales_data(s, 0, 0) { } Sales_data(std::istream &is): Sales_data() { read(is, *this); } };
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。
7.5.3 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:
- 当我们在块作用域内不适用任何初始值定义一个非静态变量或数据时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
- 当类类型的成员没有在构造函数列表中显式地初始化时。
值初始化在以下情况下发生:
- 在数组初始化的过程中我们提供的初始值数量少于数组的大小时。
- 当我们不适用初始值定一个局部静态变量时
- 当我们通过书写形如 T( )的表达式显式地请求值初始化时,其中 T 时类型名(vector 的一个构造函数只接受一个实参用于说明 vector 大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。
类必须包含一个默认构造函数以便在上述情况下使用,其中大多数情况非常容易判断。
Best Practies:
在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数
7.5.4 隐式的类类型转换
我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作 转换构造函数 。
Note:
能通过一个实参调用的构造函数定义了一条从构造函数的类型参数向类类型隐式转换的规则。
在 Sales_data 类中,接受 string 的构造函数和接受 istream 的构造函数分别定义了从这两种类型向 Sales_data 隐式转换的规则。也就死后说,在需要使用 Sales_data 的地方,我们可以使用 string 或者 istream 作为替代:
string null_book = "9-999-99999-9"; // 构造一个临时的Sales_data对象 // 该对象的units_sold和revenue等于0,bookNo等于null_book item.combine(null_book);
在这里我们用一个 string 实参调用了 Sales_data 的 combine 成员。该调用是合法的,编译器用给定的 string 自动创建了一个 Sales_data 对象。新生成的这个(临时)Sales_data 对象被传递给 combine。因为 combine 的参数时一个常量引用,所以我们可以给该函数传递一个临时量。
只允许一步类类型转换
在 4.11.2 节中我们指出,编译器只会自动地执行一步类型转换。
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为 explicit 加以阻止:
class Sales_data { public: Sales_data() = default; Sales_data(const std::string &s, unsigned n, double p): bookNo(s), units_sold(n), revenue(p*n) { } explicit Sales_data(const std::string &s) : bookNo(s) { } explicit Sales_data(std::istream&); // ... };
此时,没有任何构造函数能用于隐式地创建 Sales_data 对象,之前的两种用法都无法通过编译:
item.combine(null_book); // 错误:string构造函数时explicit的 item.combine(cin); // 错误:istream构造函数时explicit的
关键字 explicit 只对一个实参的构造函数有效。需要多个实参的构造函数不能用于隐式转换,所以无法将这些构造函数指定为 explicit 的。只能在类内声明构造函数时使用 explicit 关键字,在类外部定义时不应重复。
explicit 构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用 explicit 构造函数。
Note:
当我们用 explicit 关键字声明构造函数时,它将只能以直接初始化的形式使用。而且编译器将不会在自动转换过程中使用该构造函数。
为转换显式地使用构造函数
尽管编译器不会将 explicit 的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换。
标准库中含有显示构造函数的类
我们用过的一些标准库中的类含有单参数的构造函数:
- 接受一个单参数的 const char*的 string 构造函数不是 explicit 的。
- 接受一个容量参数的 vector 构造函数时 explicit 的。
7.5.5 聚合类
聚合类 时的用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是 public 的
- 没有定义任何构造函数
- 没有类内初始值(参见 2.6.1 节)
- 没有基类,也没有 virtual 函数。
例如,下面的类是一个聚合类:
struct Data { int ival; string s; };
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:
Data vall = { 0, "Anna" };
初始值的顺序必须与声明的顺序一致,也就是说,第一个成员的初始值要放在第一个,然后是第二个,以此类推。
与初始化数据元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。
7.5.6 字面值常量类
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:
- 数据成员必须都是字面值类型。
- 类必须至少有一个 constexpr 构造函数。
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式(参见 2.4.4 节);或者如果成员属于某种类类型,则初始值必须使用成员自己的 constexpr 函数。
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象。
constepxr 构造函数
尽管构造函数不能是 const 的,单字面值常量类的构造函数可以是 constexpr 函数。事实上,一个字面值常量类必须至少提供一个 constexpr 构造函数。
constexpr 构造函数可以声明称=default 的形式(或者是删除函数的形式)。否则,constexpr 构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又复合 constexpr 函数的要求(意味着它能拥有的唯一可执行语句就是返回语句(参见 6.5.2 节))。综合这两点可知,constexpr 构造函数体一般来说应该是空的。我们通过前置关键字 constexpr 就可以声明一个 constexpr 构造函数了。
constexpr 构造函数必须初始化所有数据成员,初始值或者使用 constexpr 构造函数,或者是一条常量表达式。
7.6 类的静态成员
有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。
声明静态成员
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。
类似的,静态成员函数也不与任何对象绑定在一起,它们不包含 this 指针。作为结果,静态成员函数不能声明称 const 的,而且我们也不能在 static 函数体内使用 this 指针。这一限制既适用于 this 的显式使用,也对调用非静态成员的隐式使用有效。
使用类的静态成员
我们使用作用域运算符直接访问静态成员:
double r = Account::rate(); // 使用作用域运算符访问静态成员
虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:
Account ac1; Account *ac2 = &ac1; // 调用静态成员函数rate的等价形式 r = ac1.rate(); r = ac2->rate();
成员函数不用通过作用域运算符就能直接使用静态对象。
定义静态成员
和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复 static 关键字,该关键字只出现在类内部的声明语句。
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一致存在于程序的整个声明周期。
Tip:
要想确保对象只定义一次,最好的办法就是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr(参见 7.5.6)。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能在所有适合于常量表达式的地方。
Best Practies:
即使一个常量静态成员在类内部被初始化了,通常情况下也应该在类的外部定义以下该成员。
静态成员能用于某些场景,而普通成员不能
如我们所见,静态成员独立于任何对象。因此,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。举个例子,静态数据成员可以是不完全类型(参见 7.3.3)。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明称它所属类的指针或引用:
class Bar { public: // ... private: static Bar mem1; // 正确:静态成员可以是不完整类型 Bar *mem2; // 正确:指针成员可以是不完全类型 Bar mem3; // 错误:数据成员必须是完全类型 };
静态成员和普通成员的另一个区别是我们可以使用静态成员作为默认实参(参见 6.5.1 节)。
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。