C++源文件通常以.cc、.cxx、.cpp、.cp、.C作为后缀来命名
C++语言未定义输入输出语句,而是提供了一个全面的标准库来提供IO机制,对应 iostream、fstream、sstream
std::cout<<"Enter Two Numbers"<<std::endl;。这条语句执行了一个表达式,在C++中,一个表达式产生一个计算结果,它由一个或多个运算对象和一个运算符组成,其中,::为作用域运算符,std代表命名空间
C++注释:双斜线注释//,界定符注释/* */
控制流语句:while语句、for语句、if语句,在Unix系统中,Ctrl+D代表文件结束符
一般而言,C++类的作者决定了类类型对象上可以使用的所有操作
item1.isbn(),其中涉及到点运算符、调用运算符
术语:公共语法特征的实现细节
C++内置类型:整型、浮点型、布尔型、字符型、空类型
带符号类型和无符号类型:除去布尔型和扩展的字符型之外,其他整型可以划分为带符号的和无符号的两种。类型int、short、long和long long都是带符号的,通过在这些类型名前添加unsigned就可以得到无符号类型,例如unsigned long。类型unsigned int 可以缩写为 unsigned。与其他整型不同,字符型被分为了三种,char、signed char、unsigned char。类型char实际上会表现为上述两种形式中的一种,具体是哪种由编译器决定。C++标准并没有规定带符号类型应如何表示,但是约定了在表示范围内正值和负值的量应该平衡,因此,8比特的signed char实际表示范围为-128至127
类型转换:对象的类型定义了对象能包含的数据和能参与的运算,其中一种运算被大多数类型支持,就是将对象从一种给定的类型转换为另一种相关类型
C++支持有损精度的类型转换,这方面Java会更严格一些,需要强转
在C++中,我们要尽量避免依赖于实现环境的行为,如果我们把int的尺寸看成是一个确定不变的已知值,那么这样的程序是不可移植的
含有无符号类型的表达式
不要给无符号对象赋一个负值,但是有一些隐含场景需要注意
- 无符号数和带符号的负值相加,带符号的负值会转换成无符号数
- 从无符号数中减去一个值时,需要确保结果不能是一个负值,特别是用在循环判断的场景
- Java中不存在无符号类型
字面值常量
每个字面值常量都对应一种数据类型,字面值常量的形式和值决定了它的数据类型
整型字面值:以0开头的整数代表八进制数,以0x或0X开头的代表十六进制,对应20、024、0x14,默认情况下,十进制字面值是带符号值,八进制和十六进制字面值即可能是带符号的也可能是无符号的。十进制字面值的类型时int、long、long long中尺寸最小的那个,前提是这种类型能容纳下当前的值。八进制和十六进制字面值的类型是能容纳其数值的int、unsigned int、long、unsigned long、long long、unsigned long long中的尺寸最小者。注意,类型short没有对应的字面值
浮点型字面值是一个double,字面值表现为一个小数或以科学计数法表示的指数,其中指数部分用E或e标识:3.14、3.14E0、0.、0e0、.001
字符和字符串字面值
由单引号括起来的一个字符称为char型字面值,双引号括起来的零个或多个字符构成字符串型字面值。字符串字面值的类型实际上是由常量字符构成的数组,编译器在每个字符串的结尾处添加一个空字符(‘\0'),因此字符串字面值的实际长度要比它的内容多1。如果两个字符串字面值位置紧邻且由空格、缩紧和换行符分隔,则他们实际上是一个整体
转义序列
有两类字符程序员不能直接使用,需要转义:一类是不可打印的字符,一类是C++语言中有特殊含义的字符(单引号、双引号、问号、反斜线)。我们也可以使用泛化的转义序列,其形式是\x后紧跟1个或多个十六进制数字,或者\后紧跟1个、2个或3个八进制数字
指定字面值的类型
布尔字面值和指针字面值
true和false是布尔值的字面值;nullptr是指针字面值
变量定义:类型说明符+变量声明符
变量初始化不是赋值,初始化的含义是创建变量时赋予其一个初始值,而赋值的含义是把对象的当前值擦除,而以一个新值来代替
初始化类型:直接初始化、列表初始化、拷贝初始化、值初始化、默认初始化
变量初始化的4种方式:
int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);
其中,用花括号来初始化变量的方式称为列表初始化,当用于内置类型的变量时,如果初始值存在丢失信息的风险,则编译器将报错
默认初始化
如果是内置类型的变量未被显示初始化,它的值由定义的位置决定。定义于任何函数体之外的变量被初始化为0,定义在函数体内部的内置类型变量将不被初始化
定义于函数体内的内置类型的对象如果没有初始化,则其值未定义。类的对象如果没有显示地初始化,则其值由类确定
变量声明和定义的关系
为了允许把程序拆分成多个逻辑部分来编写,C++语言支持分离式编译机制,为了支持分离式编译,C++语言将声明和定义区分开来。声明使得名字为程序所知,一个文件如果想使用别处定义的名字则必须包含对那个名字的声明。而定义负责创建与名字关联的实体
变量声明规定了变量的类型和名字,在这一点上定义与之相同。但是除此之外,定义还申请存储空间,也可能会为变量赋一个初始值。如果想声明一个变量而非定义它,就在变量名前添加关键字extern,而且不要显示地初始化变量
变量能且只能被定义一次,但是可以被多次声明
标识符
C++的标识符由字母、数字和下划线组成,其中必须以字母或下划线开头。标识符的长度没有限制,但是对大小写敏感。C++语言保留了一些名字供语言本身使用,这些名字不能被用作标识符。同时,C++也为标准库保留了一些名字。用户自定义的标识符不能连续出现两个下划线,也不能以下划线紧连大写字母开头。此外,定义在函数体外的标识符不能以下划线开头
作用域
作用域是程序的一部分,C++语言中大多数作用域都以花括号分隔
同一个名字在不同的作用域中可能指向不同的实体。名字的有效区域始于名字的声明语句,以声明语句所在的作用域末端为结束
嵌套作用域
作用域能彼此包含,被包含的作用域称为内层作用域,包含着别的作用域的作用域称为外层作用域
作用域中一旦声明了某个名字,它所嵌套着的所有作用域中都能访问该名字(内部类可以访问外部类的私有成员,但外部类无法访问内部类的私有成员)。同时,允许在内层作用域中重新定义外层作用域已有的名字。我们也可以显示的访问外层的变量,例如::reused
复合类型
引用(左值引用):引用为对象起了另外一个名字,通过将声明符写成&d的形式来定义引用类型,其中d是声明的变量名。一般在初始化变量时,初始值会被拷贝到新建的对象中,然而定义引用时,程序把引用和它的初始值绑定在一起,而不是将初始值拷贝给引用。一旦初始化完成,引用将和它的初始值对象一直绑定在一起。因为无法令引用重新绑定到另外一个对象,因此引用必须初始化
引用并非对象,它只是为一个已经存在的对象所起的另外一个名字
所有引用的类型都要和与之绑定的对象严格匹配,但有两种情况除外,我们可以将const引用绑定到非const对象,也可以将基类引用绑定到派生类对象
除了const引用外,引用不能与字面值或某个表达式的计算结果绑定在一起
指针本身就是一个对象,允许对指针赋值和拷贝,而且在指针的生命周期内它可以先后指向几个不同的对象
指针无须在定义时赋值,和其他内置类型一样,在块作用域内定义的指针如果没有被初始化,也将拥有一个不确定的值
所有指针的类型都要和它指向的对象严格匹配,但有两种情况除外,我们可以将const指针指向非const对象,也可以将基类指针指向派生类对象
解引用操作仅适用于那些确实指向了某个对象的有效指针
空指针:nullptr、0、NULL。其中,NULL为预处理变量,当用到一个预处理变量时,预处理器会自动地将它替换为实际值,因此用NULL初始化指针和用0初始化指针是一样的,但在新标准下,最好使用nullptr
任何非0指针对应的条件值都是true。如果两个指针存放的地址值相同,则它们相等
void*是一种特殊的指针类型,可用于存放任意对象的地址,但是我们对该地址中到底是个什么类型的对象并不了解
指向指针的指针:int **,指向指针的引用:int *&
const对象:const对象一旦创建后就不能再改变,所以const对象必须初始化
对象的类型决定了其上的操作,只能在const类型的对象上执行不改变其内容的操作
默认状态下,const对象仅在文件内有效
当以编译时初始化的方式定义一个const对象时(const int bufSize = 512;),编译器将在编译过程中把用到该变量的地方都替换成对应的值。为了执行上述替换,编译器必须知道变量的初始值。如果程序包含多个文件,则每个用了const对象的文件都必须得能访问到它的初始值才行。要做到这一点,就必须在每一个用到变量的文件中都有对它的定义。为了支持这一用法,同时避免对同一个变量的重复定义,默认情况下,const对象被设定为仅在文件内有效。当多个文件中出现了同名的const变量时,其实等同于在不同文件中分别定义了独立的变量
某些时候有这样一种const变量,它的初始值不是一个常量表达式,但又确实有必要在文件间共享。解决办法是,对于const变量不管是声明还是定义都添加extern关键字,这样只需定义一次就可以了
对常量的引用:允许为一个常量引用绑定非常量的对象、字面值,甚至是一个一般表达式
double dval = 3.14; const int &ri = dval;
此处 ri 引用了一个int型的数。对ri的操作应该是整数运算,但 dval 却是一个双精度浮点数而非整数。因此为了确保让 ri 绑定一个整数,编译器把上述代码变成了如下形式:const int temp = dval; const int &ri = temp; 在这种情况下,ri 绑定了一个临时量对象。当 ri 不是常量时,我们可以通过 ri 来改变 dval 的值,因此,编译器无法将引用绑定到临时量上,C++语言也就把这种行为归为非法
对const的引用可能引用一个并非const的对象,必须认识到,常量引用仅对引用可参与的操作做出了限定,对于引用的对象本身是不是一个常量未做限定
指向常量的指针:和常量引用一样,指向常量的指针也没有规定其所指的对象必须是一个常量,const double *cptr;
常量指针:指针是对象而不是引用,允许把指针本身定为常量。常量指针必须初始化,而且一旦初始化完成,它的值就不能再改变了,const int *const curErr = &errNumb;
顶层 const
顶层const表示指针本身是一个常量,而底层const表示指针所指的对象是一个常量。更一般的,顶层const可以表示任意的对象是常量,这一点对任何数据类型都适用。底层const则与指针和引用等复合类型的基本类型部分有关
当执行对象的拷贝操作时,常量是顶层const还是底层const区别明显,其中,顶层const不受什么影响,但是底层const的限制却不能忽略,当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层const资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行
常量表达式:指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的const对象也是常量表达式,一个对象或表达式是不是常量表达式由它的数据类型和初始值共同决定:
- const int max_files = 20; // max_files是常量表达式
- const int limit = max_files + 1; // limit是常量表达式
- int staff_size = 27; // staff_size不是常量表达式
- const int sz = get_size(); // sz不是常量表达式
constexpr变量
将变量声明为constexpr类型以便由编译器来验证变量的值是否是一个常量表达式。声明为constexpr的变量一定是一个常量,而且必须用常量表达式初始化。新标准允许定义一种特殊的constexpr函数,这种函数应该足够简单以使得编译时就可以计算其结果,这样就能用constexpr函数去初始化constexpr变量了
字面值类型
常量表达式的值需要在编译时就得到计算,因此对声明constexpr时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见、容易得到,就把它们称为“字面值类型”。其中,算数类型、引用和指针都属于字面值类型。自定义类、IO库、string类型则不属于字面值类型,也就不能被定义成constexpr
尽管指针和引用都能定义成constexpr,但它们的初始值却受到严格限制。一个constexpr指针的初始值必须是nullptr或者0,或者是存储于某个固定地址中的对象(所有定义于函数体之外的对象、函数体内的局部静态对象)
指针和constexpr
限定符constexpr仅对指针有效,与指针所指的对象无关:
- const int *p = nullptr; // p是一个指向整型常量的指针
- constexpr int *q = nullptr; // q是一个指向整型的常量指针
关键在于constexpr把它所定义的对象置为了顶层const:cosntexpr const int *p = &i
类型别名
- typedef double wages; // wages 是 double 的同义词
- typedef wages base, *p; // base 是 double 的同义词,p 是 double* 的同义词
- const p cp = 0; // cp 是指向 double 的常量指针
新标准规定了一种新的方法,使用别名声明来定义类型的别名:
using SI = Sales_item; // SI 是 Sales_item 的同义词
auto类型说明符
auto 让编译器通过初始值来推算变量的类型,显然,auto定义的变量必须有初始值。使用auto也能在一条语句中声明多个变量,因为一条声明语句只能有一个基本数据类型,所有该语句中所有变量的初始基本数据类型都必须一样
当引用被用作初始值时,真正参与初始化的其实是引用对象的值。此时编译器以引用对象的类型作为auto的类型
auto一般会忽略掉顶层const,同时底层const则会保留下来,如果希望推断出的auto类型是一个顶层const,需要明确指出
const auto f = ci; const auto &j = 42;
decltype类型指示符
选择并返回操作数的数据类型,在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值
decltype(f()) sum = x; // sum的类型就是函数f的返回类型
decltype处理顶层const和引用的方式与auto不同,如果decltype使用的表达式是一个变量,则decltype返回该变量的类型(包括顶层const和引用在内)
- int i = 42, *p = &i, &r = i;
- decltype(r + 0) b; // 加法的结果是int,因此b是一个未初始化的int
- decltype(*p) c; // 错误:c是int&,必须初始化
切记:decltype((variable))(注意是双括号)的结果永远是引用,而decltype(variable)结果只有当variable本身就是一个引用时才是引用
类数据成员:可以为数据成员提供一个类内初始值。创建对象时,类内初始值将用于初始化数据成员。没有初始值的成员将被默认初始化。类内初始值或者放在花括号里,或者放在等号右边,记住不能使用圆括号
编写自己的头文件:类一般都不定义在函数体内,当在函数体外部定义类时,在各个指定的源文件中可能只有一处该类的定义。而且,如果要在不同文件中使用同一个类,类的定义就必须保持一致。为了确保各个文件中类的定义一致,类通常被定义在头文件中,而且类所在头文件的名字应与类的名字一样
头文件中通常包含那些只能被定义一次的实体,如类、const和constexpr变量等
预处理器概述
确保头文件多次包含仍能安全工作的常用技术是预处理器,它由C++语言从C语言继承而来。预处理器是在编译之前执行的一段程序,可以部分地改变我们所写的程序。之前已经用到的一项预处理功能是#include,当预处理器看到#include标记时就会用指定的头文件的内容代替#include
C++程序还会用到的一项预处理功能是头文件保护符,头文件保护符依赖于预处理变量。预处理变量有两种状态:已定义和未定义。#define指令把一个名字设定为预处理变量,#ifdef和#ifndef分别检查某个指定的预处理变量是否已经定义。一旦检查结果为真,则执行后续操作直到遇到#endif指令为止。预处理变量无视C++语言中关于作用域的规则
using声明:using namespace::name;一旦声明了上述语句,就可以直接访问命名空间中的名字。每个名字都需要独立的using声明。头文件不应包含using声明
直接初始化&拷贝初始化
如果使用等号初始化一个变量,实际上执行的是拷贝初始化,编译器把等号右侧的初始值拷贝到新创建的对象中去。与之相反,如果不使用等号,则执行的是直接初始化
- string s5 = “hiya"; // 拷贝初始化
- string s6("hiya"); // 直接初始化
- string s7(10, 'c'); // 直接初始化
- string s8 = string(10, 'c'); // 拷贝初始化
对于string类的size函数来说,返回一个int或者unsigned似乎都是合理的,但其实size函数返回的是一个string::size_type类型的值。在具体使用的时候,通过作用域操作符来表明名字size_type是在类string中定义的。尽管我们不太清楚string::size_type类型的细节,但有一点是肯定的:它是一个无符号类型的值。如果一条表达式中已经有了size()函数就不要再使用int了,这样可以避免混用int和unsigned可能带来的问题
当把string对象和字符字面值及字符串字面值混在一条语句中使用时,必须确保每个加法运算符的两侧的运算对象至少有一个是string
- string s7 = ("hello" + ", ") + s2; // 错误:不能把字面值直接相加
使用C++版本的C标准库头文件:cctype和ctype.h的内容是一样的,只不过从命名规范上更符合C++语言的要求。特别的,在名为cname的头文件中定义的名字从属于命名空间std,而定义在名为.h的头文件中的则不然
范围for语句
string str("some string");
for (auto c : str)
cout << c < endl;
使用范围for语句改变字符串中的字符
string str("some string");
for (auto &c : str)
c = toupper(c);
cout << s < endl;
下标运算符接收的参数是string::size_type类型的值,这个参数表示要访问的字符的位置:返回值是该位置上字符的引用
下标必须大于0而小于s.size(),超出此范围将引发不可预知的结果
C++语言既有类模版,也有函数模版,其中vector是一个类模版。编译器根据模版创建类或函数的过程称为实例化,当使用模版时,需要指出编译器应把类或函数实例化成何种类型
vector是模版而非类型,由vector生成的类型必须包含vector中元素的类型,如vector<int>,vector<vector<string>>
vector能容纳绝大多数类型的对象作为其元素,但是因为引用不是对象,所以不存在包含引用的vector。除此之外,其他大多数内置类型和类类型都可以构成vector对象,甚至组成vector的元素也可以是vector
C++语言提供了几种不同的初始化方式,在大多数情况下这些初始化方式可以相互等价地使用,但存在一些例外情况:
- 使用拷贝初始化时,只能提供一个初始值;
- 如果提供的是一个类内初始值,则只能使用拷贝初始化或使用花括号的形式初始化
-
如果提供的是初始元素值的列表,则只能把初始值都放在花括号里进行列表初始化,而不能放在圆括号里
- vector<string> v2("a", "b"); // 错误
值初始化
通常情况下,可以只提供vector对象容纳的元素数量而略去初始值。此时库会创建一个值初始化的元素初值,并把它赋给容器中的所有元素。这个初值由vector对象中元素的类型决定。如果vector对象的元素是内置类型,比如int,则元素初始值自动设为0。如果元素是某种类类型,比如string,则元素由类默认初始化
- 有些类要求必须明确提供初始值,如果vector对象中元素的类型不支持默认初始化,我们就必须提供初始的元素值。对这种类型的对象来说,只提供元素的数量而不设定初始值无法完成初始化工作
-
如果只提供了元素的数量而没有设定初始值,只能使用直接初始化
- vector<int> vi = 10; //错误:必须使用直接初始化的形式指定向量大小
列表初始值还是元素数量
- 圆括号提供的值用来构造(construct)vector对象
-
花括号用来列表初始化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有两个元素,值分别是10和1
- vector<string> v5{"hi"}; // 列表初始化,v5有一个元素
- vector<string> v6("hi"); // 错误:不能使用字符串字面值构建vector对象
- vector<string> v7{10}; // v7有10个默认初始化的元素
- vector<string> v8{10, "hi"}; // v8有10个值为“hi”的元素
对vector对象来说,直接初始化的方式适用于三种情况:初始值已知且数量较少、初始值是另一个vector对象的副本、所有元素的初始值都一样,但更多的时候是通过push_back动态添加元素
范围for语句体内不应改变其所遍历序列的大小
只有当元素的值可比较时,vector对象才能被比较
只要vector对象不是一个常量,就能向下标运算符返回的元素赋值
vector对象以及string对象的下标运算符可用于访问已存在的元素,而不是用于添加元素
迭代器:所有标准库容器都可以使用迭代器,但是其中只有少数几种才同时支持下标运算符
迭代器有有效和无效之分,有效的迭代器或者指向某个元素,或者指向容器中尾元素的下一位置
和指针不一样的是,获取迭代器不是使用取地址符,有迭代器的类型同时拥有返回迭代器的成员。比如,这些类型都拥有名为begin和end的成员,其中begin成员负责返回指向第一个元素的迭代器,end成员则负责返回指向容器“尾元素的下一位置”的迭代器,也被称为“尾后迭代器”
迭代器支持的运算符:*iter、iter->mem、++iter、iter1 == iter2、iter1 != iter2
迭代器类型:拥有迭代器的标准库类型使用iterator和const_iterator来表示迭代器的类型:
- vector<int>::iterator it1; // it1能读写vector<int>的元素
- string::iterator it2; // it2能读写string对象中的字符
- vector<int>::const_iterator it3; // it3只能读元素,不能写元素
- string::const_iterator it4; // it4只能读字符,不能写字符
如果vector对象或string对象是一个常量,只能使用const_iterator,如果vector对象或string对象不是常量,那么既能使用iterator也能使用const_iterator
begin和end返回的具体类型由对象是否是常量决定,如果对象是常量,begin和end返回const_iterator;如果对象不是常量,返回iterator。C++11新标准引入了两个新函数,分别是cbegin和cend
vector动态增长的副作用:不能在范围for循环中向vector对象添加元素;任何一种可能改变vector对象容量的操作都会使该vector对象的迭代器失效
内置数组:数组是一种复合类型,大小确定不变,不能随意向数组中增加元素,如果不清楚元素的确切个数,请使用vector
数组维度说明了数组中元素的个数,因此必须大于0。数组中元素个数也属于数组类型的一部分,编译的时候维度应该是已知的。也就是说,维度必须是一个常量表达式
和内置类型的变量一样,如果在函数内部定义了某种内置类型的数组,那么默认初始化会令数组含有未定义的值。Java数组会被初始化为零值(int[] ia = new int[3];)
- int a[] = {0, 1, 2}; //维度是3的数组,元素值分别是0,1,2
- 字符数组有一种额外的初始化方式,我们可以用字符串字面值对此类数组初始化,当用这种方式时,一定要记得字符串字面值结尾处还有一个空字符
- char a1[] = {'C', '+', '+'}; // 列表初始化,没有空字符
- char a2[] = {'C', '+', '+', '\0'}; // 列表初始化,含有显式的空字符
- char a3[] = “C++"; // 自动添加表示字符串结束的空字符
- char a4[6] = “Daniel"; // 错误:没有空间可存放空字符!
数组不允许拷贝和赋值
- int a[] = {0, 1, 2}; // 含有3个整数的数组
- int a2[] = a; // 错误:不允许使用一个数组初始化另一个数组
- a2 = a; // 错误:不允许把一个数组直接赋值给另一个数组
理解复杂的数组声明
- int * ptrs[10]; // ptrs是含有10个整型指针的数组
- int &refs[10]; = /* ? */; // 错误:不存在引用的数组
- int (*Parray)[10] = &arr; // Parray指向一个含有10个整数的数组
- int (&arrRef)[10] = arr; // arrRef引用一个含有10个整数的数组
要理解数组声明的含义,最好的办法是从数组的名字开始按照由内向外的顺序阅读
与vector和string一样,数组的下标是否在合理范围内由程序员负责检查,否则会有缓冲区溢出错误
数组特性:在很多使用到数组名字的地方,编译器都会自动地将其替换为一个指向数组首元素的指针。在大多数表达式中,使用数组类型的对象其实是使用一个指向该数组首元素的指针
- int ia[] = {0, 1, 2, 3}; // ia 是一个整型数组
- auto ia2(ia); // ia2 是一个整型指针,指向ia的第一个元素
- ia2 = 42; // 错误:ia2是一个指针,不能用int值给指针赋值
- decltype(ia) ia3 = {0, 1, 2, 3}; // 使用decltype关键字时上述转换不会发生,ia3还是数组类型
指向数组元素的指针也是迭代器,C++11新标准引入了begin和end函数,用于支持数组迭代
只要指针指向的是数组中的元素或尾后元素,都可以执行下标运算
- int ia[] = {1,2,3,4};
- int *p = &ia[2];
- int j = p[1]; // p[1]等价于*(p+1),就是ia[3]表示的那个元素
- int k = p[-2]; // p[-2]是ia[0]表示的那个元素
内置的下标运算符所用的索引值不是无符号类型,这一点与vector和string不同
对大多数应用来说,使用标准库string要比使用C风格字符串更安全、更高效,比如,strcat的目标字符串的大小还需要由调用者指定
C++语言中没有多维数组,通常说的多维数组其实是数组的数组
在初始化多维数组时并非所有元素都必须包含在初始化列表中,如果仅仅想初始化每一行的第一个元素,通过如下的语句即可:
int ia[3][4] = {{0, 1}, {4}, {8}};
其他未列出的元素执行值初始化
使用范围for语句处理多维数组时,外层循环的控制变量一定要声明成引用类型,这样能够避免数组被自动转成指针
int (*p)[4] = ia; // p指向含有4个整数的数组
表达式由一个或多个运算对象组成,对表达式求值将得到一个结果,字面值和变量是最简单的表达式,其结果就是字面值和变量的值。把一个运算符和一个或多个运算对象组合起来可以生成较复杂的表达式
组合运算符和运算对象:对于含有多个运算符的复杂表达式来说,要想理解它的含义首先要理解运算符的优先级、结合律以及运算对象的求值顺序。括号无视优先级与结合律
运算对象转换:在表达式求值的过程中,运算对象常常由一种类型转换成另外一种类型。尽管一般的二元运算符都要求两个运算对象的类型相同,但是很多时候即使运算对象的类型不同也没有关系,只要它们能被转换成同一种类型即可
重载运算符:当运算符作用于类类型的运算对象时,用户可以自行定义其含义,这种自定义的过程事实上是为已存在的运算符赋予了另外一层含义,所以称之为重载运算符。我们使用重载运算符时,其包括运算对象的类型和返回值的类型,都是由该运算符定义的,但是运算对象的个数、运算符的优先级和结合律是无法改变的
左值和右值:当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置),接下来在介绍运算符的时候,我们会注明该运算符的运算对象是否必须是左值以及其求值结果是否是左值
求值顺序:逻辑与运算符、逻辑或运算符、条件运算符(?:)、逗号运算符,只有这4种运算符明确规定了求值顺序
算术运算符:满足左结合律,运算对象和求值结果都是右值。除非另作特殊说明,算术运算符都能作用于任意算数类型以及任意能转换为算术类型的类型。在除法运算中,如果两个运算对象的符号相同则商为正,否则商为负,商一律向0取整
逻辑运算符:短路求值,运算对象和求值结果都是右值
关系运算符:满足左结合律,但很少写出 i < j < k 的语句
赋值运算符:满足右结合律。左侧运算对象必须是一个可修改的左值,求值结果为左侧运算对象,并且是一个左值。如果赋值运算符的左右两个运算对象类型不同,则右侧运算对象将转换成左侧运算对象的类型
C++11新标准允许使用花括号括起来的初始值列表作为赋值语句的右侧运算对象,如果左侧运算对象是内置类型,那么初始值列表最多只能包含一个值,而且该值即使转换的话其所占的空间也不应该大于目标类型的空间;对于类类型来说,赋值运算的细节由类本身决定。对于vector来说,vector模版重载了赋值运算符并且可以接收初始值列表,当赋值发生时用右侧运算对象的元素替换左侧运算对象的元素。无论左侧运算对象的类型是什么,初始值列表都可以为空,此时,编译器创建一个值初始化的临时量并将其赋给左侧运算对象
递增递减运算符:作用于左值运算对象,前置版本将对象本身作为左值返回,后置版本则将对象原始值的副本作为右值返回
成员访问运算符:箭头运算符作用于一个指针类型的运算对象,结果是一个左值。点运算符分成两种情况:如果成员所属的对象是左值,那么结果是左值,反之是右值
条件运算符:满足右结合律。当条件运算符的两个表达式都是左值或者能转换成同一种左值类型时,运算的结果是左值;否则运算的结果是右值
位运算符:满足左结合律。如果运算对象是带符号的且它的值为负,那么右移运算符如何处理运算对象的“符号位”依赖于机器。而且,此时左移操作可能会改变符号位的值,因此是一种未定义的行为,所以强烈建议仅将位运算符用于处理无符号类型
sizeof运算符:sizeof不实际计算其运算对象的值,因此我们可以使用作用域运算符或者通过解引用无效指针来获取类成员的大小,其中,对引用类型执行sizeof运算得到被引用对象所占空间的大小,对指针执行sizeof运算得到指针本身所占空间的大小,对数组执行sizeof运算相当于对数组中所有元素各执行一次sizeof运算并将所得结果相加,sizeof运算不会把数组转换成指针来处理,对string对象或vector对象执行sizeof运算只返回该类型固定部分的大小(总分配的大小),不会计算对象中的元素占用了多少空间。sizeof返回值是一个常量表达式
逗号运算符:首先对左侧的表达式求值,然后将求值结果丢弃掉,逗号运算符真正的结果是右侧表达式的值。如果右侧运算对象是左值,那么最终的求值结果也是左值
算术转换
整型提升负责把小整数类型转换成较大的整数类型。对于bool、char、signed char、unsigned char、short和unsigned short等类型来说,只要它们所有可能的值都能存在int里,它们就会提升成int类型;否则,提升成unsigned int类型。较大的char类型(wchat_t、char16_t、char32_t)提升成int、unsigned int、long、unsigned long、long long和unsigned long long中最小的一种类型
如果一个运算对象是无符号类型,另外一个运算对象是带符号类型,需要比较两种类型的大小,如果大小相等,优先转化为无符号类型
在大多数场景下,数组自动转换成指向数组首元素的指针,当数组被用作decltype关键字、作为取地址符、sizeof、typeid等运算符的运算对象时,上述转换不会发生。同样的,如果用一个引用来初始化数组,上述转换也不会发生
指针转换:常量整数值0或者字面值nullptr能转换成任意指针类型,指向任意非常量的指针能转换成void*,指向任意对象的指针能转换成const void*
转换为布尔类型:如果指针或算数类型的值为0,转换结果是false,否则转换结果是true
转换成常量:允许将指向非常量类型的指针转换成指向相应的常量类型的指针,对于引用也是这样
类类型定义的转换:类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型转换
- string s = "a value";
- while(cin >> s)
显式转换:static_cast(任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast)、dynamic_cast、const_cast(只能改变运算对象的底层const)、reinterpret_cast(为运算对象的位模式提供较低层次上的重新解释)
表达式语句:表达式末尾加上分号就变成了表达式语句
空语句:只含有一个单独的分号
复合语句(块):用花括号括起来的语句和声明的序列,复合语句也被称为块,块不以分号作为结束
条件语句:else与离它最近的尚未匹配的if匹配
switch语句:如果某个case标签匹配成功,将从该标签开始往后顺序执行所有case分支,除非程序显式地中断了这一过程(break、return),否则直到switch的结尾处才会停下来。如果没有任何一个case标签能匹配上switch表达式的值,程序将执行紧跟在default标签后面的语句。在标签内尽量不要定义变量,除非在子块中
范围for语句:这种语句可以遍历容器或其他序列的所有元素,语法形式是:
- for(declaration : expression)
- statement
跳转语句
- break:负责终止离它最近的while、do while、for、switch语句,并从这些语句之后的第一条语句开始继续执行
- continue:终止最近的循环中的当前迭代并立即开始下一次迭代,只能出现在for、while、do while循环的内部
- goto:无条件跳转到同一函数内的另一条语句
- return
异常处理:和Java Class中的Exception属性不同,C++方法签名不需要说明要抛出哪些异常
形参和实参:实参是形参的初始值,C++没有规定实参的求值顺序,编译器能以任意可行的顺序对实参求值
局部对象:名字的作用域是程序文本的一部分,名字在其中可见,对象的生命周期是程序执行过程中该对象存在的一段时间。函数体是一个语句块,块构成一个新的作用域,我们可以在其中定义变量,形参和函数体内部定义的变量统称为局部变量,它们仅在函数的作用域可见,同时局部变量还会隐藏在外层作用域中同名变量
自动对象:对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它。我们把只存在于块执行期间的对象称为自动对象。我们用传递给函数的实参初始化形参对应的自动对象。对于局部变量对应的自动对象来说,如果变量定义本身含有初始值,就用这个初始值进行初始化,否则,如果变量定义本身不含初始值,执行默认初始化,这意味着内置类型的未初始化局部变量将产生未定义的值
局部静态对象:在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。如果局部静态变量没有显示初始化,它将执行值初始化,内置类型的局部静态变量初始化为0
函数声明:函数只能定义一次,但可以声明多次,如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义。函数声明和函数定义的唯一区别是函数声明无需函数体,用一个分号替代即可
函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息,函数声明也称作函数原型
在头文件中进行函数声明:我们之前建议变量在头文件中声明,在源文件中定义,与之类似,函数也应该在头文件中声明而在源文件中定义
分离式编译:
- CC -c factMain.cc
- CC -c fact.cc
- CC factMain.o fact.o
- CC factMain.o fact.o -o main
参数传递:引用传递(传引用调用)、值传递(传值调用,需要注意指针形参,虽然两个指针是不同的指针,但我们可以间接地访问它所指的对象,所以通过指针可以修改它所指对象的值)
使用引用避免拷贝:如果函数无须改变引用形参的值,最好将其声明为常量引用
使用引用形参返回额外信息:一个函数只能返回一个值,引用形参为我们一次返回多个结果提供了有效的途径
const形参和实参:和其他初始化过程一样,当用实参初始化形参时会忽略掉顶层const,因此通过const形参来区分重载是错误的
数组形参:不允许拷贝数组、使用数组时会将其转换成指针。尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:
- 尽管形式不同,但这三个print函数是等价的,每个函数都有一个const int* 类型的形参
- void print(const int*);
- void print(const int[]);
- void print(const int[10]); //这里的维度表示我们期望数组含有多少元素,实际不一定
- 当编译器处理对print函数的调用时,只检查传入的参数是否是const int*类型
- int i = 0, j[2] = {0, 1};
- print(&i); //正确
- print(j); //正确
因为数组是以指针的形式传递给函数的,因此调用者需要提供一些额外的信息来说明数组的大小,常用方法有三种,分别是:
- 使用标记指定数组长度:C风格字符串
- 使用标准库规范
- 显示传递一个表示数组大小的形参
数组引用形参
void print(int (&arr)[10]); 这一用法限制了print函数的可用性,我们只能将函数作用于大小为10的数组
含有可变形参的函数:为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法:如果所有的实参类型相同,可以传递一个名为initializer_list的标准库类型;如果实参的类型不同,我们可以使用可变参数模版。还有一种省略符的形式,这种形式使用了名为varargs的C标准库功能,一般只用于与C函数交互的接口程序
和vector一样,initializer_list也是一种模版类型,定义initializer_list对象时,必须说明列表中所含元素的类型
不要返回局部对象的引用或指针、调用一个返回引用的函数得到左值,其他返回类型得到右值
列表初始化返回值:函数可以返回花括号包围的值的列表,此处的列表用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化,否则,返回的值由函数的返回类型决定
- vector<string> process() {
- return {"a", "b"};
- }
声明一个返回数组指针的函数:
- 最简单的办法是通过类型别名或者是别名声明
- int(*func(int i))[10]
- 使用尾置返回类型:auto func(int i) -> int(*)[10];
-
使用decltype
- int odd[] = {1,2,3};
- decltype(odd) *arrPtr(int i)
函数重载:不允许两个函数除了返回类型外其他所有的要素都相同
一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来,但底层const是可以的
如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法实现函数重载
在C++语言中,名字查找发生在类型检查之前,因此内层作用域中定义的同名变量也会屏蔽掉外层的方法名
默认实参:调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值
默认实参声明:对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的,不过在给定的作用域中一个形参只能被赋予一次默认实参
- string screen(sz, sz, char = ' ');
- string screen(sz, sz, char = '*'); // 错误:重复声明
- string screen(sz, sz = 80, char); // 正确:添加默认实参
默认实参初始化:默认实参的名字在函数声明所在的作用域内解析,而求值过程发生在函数调用时
内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求
constexpr函数是指能用于常量表达式的函数,需要遵循几项约定:函数的返回类型及所有形参的类型都的是字面值类型,而且函数体中必须只有一条return语句
constexpr size_t scale(size_t cnt) { return new_sz() * cnt; },当scale的实参是常量表达式时,它的返回值也是常量表达式,反之则不然
所以:constexpr函数不一定返回常量表达式
把内联函数和constexpr函数放在头文件中:和其他函数不一样,内联函数和constexpr函数可以在程序中多次定义。毕竟,编译器想要展开函数声明是不够的,还需要函数定义。不过,对于某个给定的内联函数或者constexpr函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和constexpr函数通常定义在头文件中
调试帮助:assert预处理宏、NDEBUG预处理变量、__func__、__FILE__、__LINE__、__TIME__、__DATE__
函数匹配:确定候选函数和可行函数,如果没有找到可行函数,编译器将报告无匹配函数的错误
在寻找最佳匹配时,如果有且只有一个函数满足下列条件,则匹配成功,否则编译器将报告二义性错误:
- 该函数每个实参的匹配都不劣于其他可行函数需要的匹配
- 至少有一个实参的匹配优于其他可行函数提供的匹配
实参类型转换:
-
精确匹配:
- 实参类型和形参类型相同
- 实参从数组类型或函数类型转换成对应的指针类型
- 向实参添加顶层const或者从实参中删除顶层const
- 通过const转换实现的匹配
- 通过类型提升实现的匹配
- 通过算术类型转换或指针转换实现的匹配
- 通过类类型转换实现的匹配
函数指针:当我们把函数名作为一个值使用时,该函数自动地转换成指针
- bool lengthCompare(const string&, const string &);
- 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 &)); // 等价声明,显示地定义成指针
我们可以直接把函数作为实参使用,此时它会自动转换成指针:useBigger(s1, s2, lengthCompare);
- typedef bool Func(const string&, const string&); // 函数类型
- typedef decltype(lengthCompare) Func2; // 函数类型
- typedef bool(*FuncP)(const string&, const string&); // 指向函数的指针
- typedef decltype(lengthCompare) *Func2; // 指向函数的指针。decltype返回函数类型,此时不会将函数类型自动转换成指针
返回指向函数的指针:和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理
- using F = int(int*, int); // F是函数类型,不是指针
- using PF = int(*)(int*, int); // PF是指针类型
- PF f1(int); //正确
- F f1(int); //错误
- F *f1(int); //正确
- int (*f1(int))(int*, int); //原始声明
- auto f1(int) -> int(*)(int*, int); //尾置返回
封装:封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型。在抽象数据类型中,由类的设计者负责考虑类的实现过程,使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节
定义在类内部的函数是隐式的inline函数
this总是指向“这个”对象,所以this是一个常量指针,我们不允许改变this中保存的地址
const成员函数
std::string isbn() const { return this->bookNo; } 通过在参数列表之后添加const关键字来声明const成员函数
默认情况下,this的类型是指向类类型非常量版本的常量指针。尽管this是隐式的,但它仍然需要遵循初始化规则,意味着我们不能把this绑定到一个常量对象上,这一情况也就使得我们不能在一个常量对象上调用普通的成员函数
如果isbn是一个普通函数而且this是一个普通的指针参数,则我们应该把this声明成const Sales_data *const。毕竟,在isbn的函数体内不会改变this所指的对象,所以把this设置为指向常量的指针有助于提高函数的灵活性。然而,this是隐式的并且不会出现在参数列表中,C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。向这样使用const的成员函数被称作常量成员函数
常量对象以及常量对象的引用或指针都只能调用常量成员函数
类作用域和成员函数:编译器分两步处理类,首先编译成员的声明,然后才轮到成员函数体。因此,成员函数可以随意使用类中的其他成员而无须在意这些成员出现的次序
在类的外部定义成员函数:当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员被声明成常量成员函数,那么它的定义也不许在参数列表后明确指定const属性。同时,类外部定义的成员的名字必须包含它所属的类名
定义类相关的非成员函数:类的作者常常需要定义一些辅助函数,尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明在同一个头文件中。在这种方式下,用户使用接口的任何部分都只需要引入一个文件
IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们,而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数,默认构造函数无须任何参数,如果我们的类没有显示地定义构造函数,那么编译器会为我们隐式地定义一个合成的默认构造函数,对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内初始值,用它来初始化成员
- 否则,默认初始化该成员
某些类不能依赖于合成的默认构造函数
- 只有在不包含任何构造函数的情况下,编译器才会替我们生成一个默认构造函数。一旦我们定义了其他的构造函数,那么除非我们再定义一个默认构造函数,否则类将没有默认构造函数。这条规则的依据是,如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制
- 合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或复合类型的对象被默认初始化,则它们的值是未定义的。因此如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内初始值,这个类才适合于使用合成的默认构造函数。这点和Java好像不一样,Java更安全一些
- 有时候编译器不能为某些类合成默认的构造函数
Sales_data() = default;
= default 的含义,我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数,我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。= default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果 = default在类的内部,则默认构造函数是内联的,如果它在类的外部,则该成员默认情况下不是内联的
构造函数初始值列表:当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式初始化类成员。没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始化
某些类不能依赖于合成的版本:当类需要分配类对象之外的资源时,合成的版本常常会失效
访问控制与封装:在C++语言中,我们使用访问说明符加强类的封装性。struct和class的唯一区别就是默认的访问权限
友元:类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元。如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限,一般来说,最好在类定义开始或结束前的位置集中声明友元
封装有两个重要的优点:
- 确保用户代码不会无意间破坏封装对象的状态
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码
友元的声明:友元声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)
类型成员:除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public或者private中的一种
- class Screen {
- public:
- typedef std::string::size_type pos; // 类型别名
- using pos = std::string::size_type; // 别名声明
- private:
- pos cursor = 0;
- pos height = 0, width = 0;
- std::string contents;
- };
用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别,因此,类型成员通常出现在类开始的地方
可变数据成员:我们希望能修改类的某个数据成员,即使是在一个const成员函数内,可以通过在变量的声明中加入mutable关键字做到这一点
一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用,因而无法继续调用非const成员函数
类的声明:就像可以把函数的声明和定义分离开一样,我们也能仅声明类而暂时不定义它:
class Screen; // Screen类的声明
这种声明被称作向前声明,它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型。不完全类型只能在有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但不是定义)以不完全类型作为参数或者返回类型的函数
除了把普通的非成员函数定义成类的友元,类还可以把其他的类定义成友元,也可以把其他类的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的
友元不存在传递性,每个类负责控制自己的友元类和友元函数
尽管重载函数的名字相同,但它们仍然是不同的函数,因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明
友元的声明和作用域
- 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的声明在作用域中了
名字查找:编译器处理完类中的全部声明后才会处理成员函数的定义
类型名的定义:一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能再之后重新定义该名字。因此类型名的定义通常出现在类的开始处
成员函数中的普通块作用域的名字查找:
- 在成员函数内查找该名字的声明,只有在函数使用之前出现的声明才被考虑
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑
- 如果类内也没有找到该名字的声明,在成员函数定义之前的作用域内继续查找
构造函数初始值列表有时必不可少:如果成员是const、引用、或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值
成员初始化顺序:与它们在类定义中出现的顺序一致,构造函数初始化列表中初始值的前后位置关系不会影响实际的初始化顺序
默认实参和构造函数:如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数
委托构造函数:委托构造函数也有一个成员初始值列表和一个函数体,但是成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配
默认构造函数的作用:当对象被默认初始化或值初始化时自动执行默认构造函数
-
默认构造函数在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时
- 当类类型的成员没有在构造函数初始值列表中显示地初始化时
-
值初始化在以下情况下发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时
- 当我们不使用初始值定义一个局部静态变量时
- 当我们通过书写形如T()的表达式显示地请求值初始化时,其中T是类型名
隐式的类类型转换:如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数
只允许一步类类型转换:
- // 错误:需要用户定义的两种转换:"9-999-9999-9"转换成string,string转换成Sales_data
- item.combie("9-999-9999-9");
- // 正确:显示地转换成string,隐式地转换成Sales_data
- item.combie(string("9-999-9999-9"));
- // 正确:隐示地转换成string,显式地转换成Sales_data
- item.combie(Sales_data("9-999-9999-9"));
抑制构造函数定义的隐式转换:可以通过将构造函数声明为explicit加以阻止。发生隐式转换的一种情况时当我们执行拷贝形式的初始化时,此时,我们只能使用直接初始化而不能使用explicit构造函数。尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显示地强制进行转换
类静态成员:静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据,类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this指针,作为结果,静态成员函数不能声明成const的(但是静态数据成员可以奥)
我们使用作用域运算符直接访问静态成员,也可以使用类的对象、引用或指针来访问静态成员
因为静态数据成员不属于类的任何一个对象,所以他们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员,相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次
- // 定义并初始化一个静态成员
- double Account::interestRate = initRate();
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中
静态成员要想在类内初始化,则必须是字面值常量类型的constexpr
标准库使我们能忽略不同类型的流之间的差异,这是通过继承机制实现的
IO对象无拷贝或赋值、IO类所定义的一些函数和标志,可以帮助我们访问和操纵流的条件状态
刷新输出缓冲区:endl、flush、ends
unitbuf操纵符:如果我们想在每次输出操作后都刷新缓冲区,我们可以使用unitbuf操纵符,而nounitbuf操纵符则重置流
流关联:也可以用于控制流刷新
当一个fstream对象被销毁时,close会自动被调用
文件模式:每个流都有一个关联的文件模式,用来指出如何使用文件(in、out、app、ate、trunc、binary)
sstream头文件定义了三个类型来支持内存IO,这些类型可以向string写入数据,从string读取数据,就像string是一个IO流一样
顺序容器
- vector:可变大小数组。支持快速随机访问。在尾部之外的位置插入或删除元素可能很慢
- deque:双端队列。支持快速随机访问。在头尾位置插入/删除速度很快
- list:双向链表。只支持双向顺序访问。在list中任何位置进行插入/删除操作速度都很快
- forward_list:单向链表。只支持单向顺序访问。在链表任何位置进行插入/删除操作速度都很快
- array:固定大小数组。支持快速随机访问。不能添加或删除元素
- string:与vector相似的容器,但专门用于保存字符。随机访问快。在尾部插入/删除速度快
不同的容器由于底层实现原理不同,且对效率有一定的要求,因此不同的容器支持的操作不同,例如forward_list不支持size操作
容器上的操作形成了一种层次:
- 某些操作是所有容器类型都提供的
- 另外一些操作仅针对顺序容器、关联容器或无序容器
- 还有一些操作只适用于一小部分容器
虽然我们可以在容器中保存几乎任何类型,但某些容器操作对元素类型有其自己的特殊要求。我们可以为不支持特定操作需求的类型定义容器,但这种情况下就只能使用那些没有特殊要求的容器操作了。例如,顺序容器构造函数的一个版本接受容器大小参数,它使用了元素类型的默认构造函数。但某些类没有默认构造函数。我们可以定义一个保存这种类型对象的容器,但我们在构造这种容器时不能只传递给它一个元素数目参数
迭代器:与容器一样,迭代器有着公共的接口:如果一个迭代器提供某个操作,那么所有提供相同操作的迭代器对这个操作的实现方式都是相同的
forward_list不支持递减运算符(--)
迭代器范围:迭代器范围的概念是标准库的基础,这两个迭代器通常被称为[begin,end),这种元素范围被称为左闭合区间
容器类型成员:size_type、iterator、const_iterator、reverse_iterator
begin & end成员:begin、rbegin、cbegin、crbegin
为了创建一个容器为另一个容器的拷贝,两个容器的类型及其元素类型必须匹配。不过,当传递迭代器参数来拷贝一个范围时,就不要求容器类型是相同的了。而且,新容器和原容器中的元素类型也可以不同,只要能将要拷贝的元素转换为要初始化的容器的元素类型即可
在新标准中,我们可以对一个容器进行列表初始化,当这样做时,我们就显示地指定了容器中每个元素的值。对于除array之外的容器类型(array大小是类型的一部分),初始化列表还隐含地指定了容器的大小:容器将包含与初始值一样多的元素
顺序容器(array除外)还提供另一个构造函数,它接受一个容器大小和一个(可选)元素初始值,如果我们不提供元素初始值,则标准库会创建一个值初始化器。如果元素类型是内置类型或者是具有默认构造函数的类类型,可以只为构造函数提供一个容器大小参数。如果元素类型没有默认构造函数,除了大小参数外,还必须指定一个显示的元素初始值
与内置数组一样,标准库array的大小也是类型的一部分。与其他容器不同,一个默认构造的array是非空的:它包含了与其大小一样多的元素,这些元素都被默认初始化,就像一个内置数组中的元素那样。如果我们对array进行列表初始化,初始值的数目必须等于或小于array的大小,如果初始值数目小于array的大小,则它们被用来初始化靠前的元素,所有剩余元素都会进行值初始化。在这两种情况下,如果元素类型是一个类类型,那么该类必须有一个默认构造函数,以使得值初始化能够进行
赋值运算符将其左边容器中的全部元素替换为右边容器中元素的拷贝。与内置数组不同,array允许赋值,赋值号左右两边的运算对象必须具有相同的类型,由于右边运算对象的大小可能与左边运算对象的大小不同,因此array类型不支持assign,也不允许用花括号包围的值列表进行赋值
除array外,swap不对任何元素进行拷贝、删除或插入操作,因此可以保证在常数时间内完成。在新标准中,容器既提供成员函数版本的swap,也提供非成员版本的swap,非成员版本的swap在泛型编程中是非常重要的,统一使用非成员版本的swap是一个好习惯
当我们用一个对象来初始化容器时,或将一个对象插入到容器中时,实际上放入到容器中的是对象值的一个拷贝,而不是对象本身
当我们调用一个emplace成员函数时,emplace成员使用传入的参数在容器管理的内存空间中直接构造元素,因此,传递给emplace函数的参数必须与元素类型的构造函数相匹配
提供快速随机访问的容器(string、vector、deque、array)也都提供下标运算符,但是保证下标有效是程序员的责任,如果我们希望确保下标合法,可以使用at成员函数,如果下标越界,at会抛出一个out_of_change异常
删除元素的成员函数并不检查其参数,在删除元素之前,程序员必须确保他们是存在的
向容器中添加元素和从容器中删除元素的操作可能会使指向容器元素的指针、引用或迭代器失效。一个失效的指针、引用或迭代器将不再表示任何元素。使用失效的指针、引用或迭代器是一种严重的程序设计错误,很可能引起与未使用初始化指针一样的问题
如果在一个循环中插入/删除deque、string或vector中的元素,不要缓存end返回的迭代器
空间分配策略:
- shrink_to_fit:将capacity减少为与size相同大小
- capacity:不重新分配内存空间的话,还可以保存多少元素
- reserve(n):分配至少能容纳n个元素的内存空间
容器适配器:stack、queue、priority_queue
默认情况下,stack和queue是基于deque实现的,priority_queue是在vector之上实现的。我们可以在创建一个适配器时将一个命名的顺序容器作为第二个类型参数来重载默认容器类型
stack<string, vector<string>> str_stk;
对于一个给定的适配器,可以使用哪些容器是有限制的,所有适配器都要求容器具有添加和删除元素的能力。因此,适配器不能构造在array之上。类似的,我们也不能用forward_list来构造适配器,因为所有适配器都要求容器具有添加、删除以及访问尾元素的能力。stack只要求push_back、pop_back和back操作,因此可以使用除array和forward_list之外的任何容器类型来构造stack。queue适配器要求back、push_back、front和pop_front,因此它可以构造于list或deque之上,但不能基于vector构造。priority_queue除了front、push_back和pop_back操作之外还要求随机访问能力,因此它可以构造于vector和deque之上,但不能基于list构造
标准库并未给每个容器都定义成员函数来实现复杂操作,而是定义了一组泛型算法,它们实现了一些经典算法的公共接口,如搜索和排序。一般情况下,这些算法并不直接操作容器,而是遍历由两个迭代器指定的一个元素范围来进行操作
迭代器令算法不依赖于容器,但算法依赖于元素类型的操作。泛型算法本身不会执行容器的操作,它们只会运行于迭代器之上,执行迭代器的操作,因此算法永远不会改变底层容器的大小,算法可能改变容器中保存的元素的值,也可能在容器内移动元素,但永远不会直接添加或删除元素
向目的位置迭代器写入数据的算法假定目的位置足够大,能容纳要写入的元素。一种保证算法有足够元素空间来容纳输出数据的方法是使用插入迭代器,插入迭代器是一种向容器中添加元素的迭代器,通常情况,当我们通过一个迭代器向容器元素赋值时,值被赋予迭代器指向的元素。而当我们通过一个插入迭代器赋值时,一个与赋值号右侧值相等的元素被添加到容器中
消除重复单词:
- void elimDups(vector<string> &words) {
- sort(words.begin, words.end)); // 按字典序排序words,以便查找重复单词
- auto end_unique = unique(words.begin(), words.end()); //unique重排输入范围,使得每个单词只出现一次
- words.erase(end_unique, words.end()); //使用向量操作erase删除重复单词
- }
标准库算法对迭代器而不是容器进行操作,因此,算法不能(直接)添加或删除元素
谓词:谓词是一个可调用的表达式,其返回结果是一个能用作条件的值
find_if接受一元谓词,没有任何办法能传递给它第二个参数来表示长度,为了解决此问题,需要使用另外一些语言特性
我们可以向一个算法传递任何类别的可调用对象,到目前为止,我们使用过的仅有的两种可调用对象是函数和函数指针,还有其他两种可调用对象:重载了函数调用运算符的类以及Lambda表达式
一个lambda表达式表示一个可调用的代码单元,与任何函数类似,一个lambda具有一个返回类型、一个参数列表和一个函数体,但与函数不同,lambda可能定义在函数的内部,一个lambda表达式具有如下形式:
[capture list](parameter list) -> return type { function body }
其中,捕获列表是一个lambda所在函数中定义的局部变量的列表,我们可以忽略参数列表和返回类型,但必须永远包含捕获列表和函数体:auto f = [] { return 42;};
捕获列表只用于局部非static变量,lambda可以直接使用局部static变量和在它所在函数之外声明的名字
默认情况下,从lambda生成的类都包含一个对应该lambda所捕获的变量的数据成员,类似任何普通类的数据成员,lambda的数据成员也在lambda对象创建时被初始化
类似参数传递,变量的捕获方式也可以是值或引用,与参数值传递不同,被捕获的变量的值是在lambda创建时拷贝,而不是调用时拷贝。引用捕获与返回引用有着相同的问题和限制,如果我们采用引用方式捕获一个变量,就必须确保被引用的对象在lambda执行的时候是存在的,如果lambda可能在函数结束后执行,捕获的引用指向的局部变量已经消失
隐式捕获:除了显示列出我们希望使用的来自所在函数的变量之外,还可以让编译器根据lambda体中的代码来推断我们要使用哪些变量,为了指示编译器推断捕获列表,应在捕获列表中写一个&或=,&告诉编译器采用捕获引用方式,=则表示采用值捕获方式
当我们混合使用隐式捕获和显示捕获时,捕获列表中的第一个元素必须是一个&或=,此符号指定了默认捕获方式为引用或值,而显示捕获的变量必须使用与隐式捕获不同的方式
可变lambda:默认情况下,对于一个值被拷贝的变量,lambda不会改变其值,如果我们希望能改变一个被捕获的变量的值,就必须在参数列表首加上关键字mutable。而一个引用捕获的变量是否可以修改依赖于此引用指向的是一个const类型还是一个非const类型
指定lambda返回类型:如果一个lambda体包含return之外的任何语句,则编译器假定此lambda返回void,因此,复杂lambda表达式需要指定返回值类型
参数绑定:调用bind的一般形式为:auto newCallable = bind(callable, arg_list);,其中,newCallable本身是一个可调用对象,arg_list是一个逗号分隔的参数列表,对应给定的callable的参数,arg_list中的参数可能包含形如_n的名字,这些参数是“占位符”,表示newCallable的参数,_1为newCallable的第一个参数,_2为第二个参数,以此类推
用bind重排参数顺序:sort(words.begin(), words.end(), bind(isShorter, _2, _1));
绑定引用参数:默认情况下,bind的那些不是占位符的参数被拷贝到bind返回的可调用对象中,但是有些绑定的参数我们希望以引用方式传递,或是要绑定参数的类型无法拷贝
for_each(words.begin(), words.end(), bind(print, ref(os), _1, ' '));
函数ref返回一个对象,包含给定的引用,此对象是可以拷贝的。标准库中还有一个cref函数,生成一个保存const引用的类
迭代器类型:插入迭代器、流迭代器、反向迭代器、移动迭代器
set的迭代器是const的,与不能改变一个map元素的关键字一样,一个set中的关键字也是const的,可以用一个set迭代器来读取元素的值,但不能修改
无序关联容器不使用比较运算符来组织元素,而是使用一个哈希函数和关键字类型的==运算符,相比Java来说,C++对哈希桶的操作更为细化
无序容器对关键字类型的要求:标准库为内置类型(包括指针)、标准库类型(string、智能指针)提供了hash模版,但是我们不能直接定义关键字类型为自定义类类型的无序容器,而必须提供我们自己的hash模版版本
动态内存:全局对象在程序启动时分配,在程序结束时销毁。局部自动对象在进入其定义所在的程序块时被创建,在离开块时销毁。局部static对象在第一次使用前分配,在程序结束时销毁。除了自动和static对象外,C++还支持动态分配对象。动态分配的对象的生存期与在哪里创建无关,只有当显示地被释放时,这些对象才会销毁
我们的程序到目前为止只使用过静态内存和栈内存。静态内存用来保存局部static对象、类static数据成员以及定义在任何函数之外的变量。栈内存用来保存定义在函数内的非static对象。分配在静态或栈内存中的对象由编译器自动创建和销毁。除了静态内存和栈内存,每个程序还拥有一个内存池,这部分内存被称作堆
在C++中,动态内存的管理是通过new和delete这两个运算符完成的,为了更容易的使用动态内存,标准库提供了两种智能指针类型来管理动态对象。shared_ptr允许多个指针指向同一个对象,unique_ptr则独占所指向的对象,标准库还定义了一个名为weak_ptr的伴随类,它是一种弱引用,指向shared_ptr所管理的对象
最安全的分配和使用动态内存的方法是调用一个名为make_shared的标准库函数
可以通俗理解智能指针封装并维护一个引用计数的指针和一个真实对象的指针,一旦计数器变为0,它就会自动释放自己所管理的对象
使用动态内存的一个常见原因是允许多个对象共享相同的状态,但如果两个对象共享底层的数据,当某个对象被销毁时,我们不能单方面地销毁底层数据
默认情况下,使用new动态分配的对象是默认初始化的,int *pi = new int;,我们可以使用直接初始化来初始化一个动态分配的对象,也可以使用传统的构造方式(使用圆括号),也可以使用列表初始化(使用花括号),也可以使用值初始化:
- int *pi = new int(1024);
- string *ps = new string(10, '9');
- vector<int> *pv = new vector<int>{1,2,3};
- string *ps1 = new string();
- int *pi1 = new int();
内存耗尽:
- int *p1 = new int; //分配失败抛出std::bad_alloc异常
- int *p2 = new (nothrow) int; //如果分配失败,返回空指针
我们称这种形式的new为定位new(palcement new),定位new表达式允许我们向new传递额外的参数,在此例中,我们传递给它一个由标准库定义的名为nothrow的对象
我们传递给delete的指针必须指向动态分配的内存,或者是一个空指针。释放一块并非new分配的内存,或者将相同的指针释放多次,其行为是未定义的
动态内存常见错误:忘记delete内存、使用已经释放掉的内存、同一块内存释放两次、混用普通指针和智能指针、使用get初始化另一个智能指针或为智能指针赋值
shared_ptr<int> p(new int(42));,默认情况下,一个用来初始化智能指针的普通指针必须指向动态内存,因为智能指针默认使用delete释放它所关联的对象,我们可以将智能指针绑定到一个指向其他类型的资源的指针上,但是必须提供自己的操作来代替delete
unique_ptr没有类似make_shared的标准库函数返回一个unique_ptr,初始化unique_ptr必须采用直接初始化形式
- unique_ptr<int> p1; //可以指向一个double的unique_ptr
- unique_ptr<int> p2(new int(42)); // 指向一个值为42的int
由于一个unique_ptr拥有它指向的对象,因此unique_ptr不支持普通的拷贝或赋值操作
- unique_ptr<string> p1(new string("Hello"));
- unique_ptr<string> p2(p1); //错误:unique_ptr不支持拷贝
- unique_ptr<string> p3;
- p3 = p2; //错误:unique_ptr不支持赋值
不能拷贝unique_ptr的规则有一个例外,我们可以拷贝或赋值一个将要被销毁的unique_ptr,最常见的例子是从函数返回一个unique_ptr,在这种情况下,编译器知道要返回的对象将要被销毁,在此情况下,编译器执行一种特殊的“拷贝”
类似shared_ptr,unique_ptr默认情况下用delete释放它指向的对象,与shared_ptr一样,我们可以重载一个unique_ptr中默认的删除器,但是,我们必须在尖括号中unique_ptr指向类型之后提供删除器类型,在创建或reset一个这种unique_ptr类型的对象时,必须提供一个指定类型的可调用对象
weak_ptr是一种不控制所指向对象生存期的智能指针,它指向由一个shared_ptr管理的对象,将一个weak_ptr绑定到一个shared_ptr不会改变shared_ptr的引用计数
动态数组:为了一次为大量对象分配内存,C++语言和标准库提供了两种一次分配一个对象数组的方法,C++语言定义了另一种new表达式语法,可以分配并初始化一个对象数组。标准库中包含一个名为allocator的类,允许我们将分配和初始化分离,使用allocator通常会提供更好的性能和更灵活的内存管理能力
int *pia = new int[get_size()]; // pia指向第一个int,分配一个组数会得到一个元素类型的指针
要记住动态数组并不是数组类型,这是很重要的
默认情况下,new分配的对象,不管是单个分配的还是数组中的,都是默认初始化的,可以对数组中的元素进行值初始化,方法是在大小之后跟一对空括号
- int *pia = new int[10]; // 10个未初始化的int
- int *pia2 = new int[10](); // 10个值初始化为0的int
- string *psa = new string[10]; //10个空string
- string *psa = new string[10](); //10个空string
- int *pia3 = new int[3]{1,2,3}; // 3个int分别用列表中对应的初始化器初始化
- string *psa3 = new string[10]{"a", "an", "the"}; //前三个用给定的初始化器初始化,剩余的进行值初始化
动态分配一个空数组是合法的
- char arr[0]; //错误:不能定义长度为0的数组
- char *cp = new char[0]; //正确,但cp不能解引用
释放动态数组:
- delete p; // p必须指向一个动态分配的对象或为空
- delete [] pa; // pa必须指向一个动态分配的数组或为空
指针数组和动态数组:
- unique_ptr<int[]> up(new int[10]);
- up.release(); // 自动用delete[]销毁其指针
- for (size_t i = 0; i != 10; ++i)
- up[i] = i; // 为每个元素赋予一个新值
与unique_ptr不同,shared_ptr不直接支持管理动态数组
- shared_ptr<int> sp(new int[10], [](int *p) { delete[] p; });
- sp.reset();
shared_ptr不直接支持动态数组管理这一特性会影响我们如何访问数组中的元素:
- for(size_t i = 0; i != 10; ++i)
- *(sp.get() + i) = i; //使用get获取一个内置指针
shared_ptr未定义下标运算符,而且智能指针类型不支持指针算数运算
new有一些灵活性上的局限,其中一方面表现在它将内存分配和对象构造组合在了一起,类似的,delete将对象析构和内存释放组合在了一起。我们分配单个对象时,通常希望将内存分配和对象初始化组合在一起,因为在这种情况下,我们几乎肯定知道对象应有什么值
当分配一大块内存时,我们通常计划在这块内存上按需构造对象,在此情况下,我们希望将内存分配和对象构造分离,这意味着我们可以分配大块内存,但只在真正需要时才真正执行对象创建操作
为了使用allocate返回的内存,我们必须用construct构造对象,使用未构造的内存,其行为是未定义的。我们只能对真正构造了的元素进行destroy操作
在C++中,我们通过定义构造函数来控制在类类型的对象初始化时做什么,类还可以控制在对象构造、赋值和销毁时做什么。在这方面,C++与其他语言是不同的,其他很多语言都没有给予类设计者控制这些操作的能力
当定义一个类时,我们显示地或隐式地指定在此类型的对象构造、赋值和销毁时做什么。一个类通过定义五种特殊的成员函数来控制这些操作,包括:拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。拷贝和移动构造函数定义了当用同类型的另一个对象初始化本对象时做什么。拷贝和移动赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么。析构函数定义了当此类型对象销毁时做什么。我们称这些操作为拷贝控制操作
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义缺失的操作,但是对一些类来说,依赖这些操作的默认定义会导致灾难
拷贝构造函数:如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数,拷贝构造函数的第一个参数必须是一个引用类型。拷贝构造函数在几种情况下都会被隐式地使用,因此拷贝构造函数通常不应该是explicit的
合成拷贝构造函数:如果我们没有为一个类定义拷贝构造函数,编译器也会为我们定义一个。与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会为我们合成一个拷贝构造函数。一般来说,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,编译器从给定对象中依次将每个非static成员函数拷贝到正在创建的对象中
每个成员的类型决定了它如何拷贝:对类类型的成员,会使用其拷贝构造函数来拷贝,内置类型的成员则直接拷贝。虽然我们不能直接拷贝一个数组,但合成拷贝构造函数会逐元素地拷贝一个数组类型的成员,如果数组元素是类元素,则使用元素的拷贝构造函数来进行拷贝
拷贝初始化通常使用拷贝构造函数来完成,但是,如果一个类有一个移动构造函数,则拷贝初始化有时会使用移动构造函数而非拷贝构造函数来完成
编译器可以绕过拷贝构造函数:在拷贝初始化过程中,编译器可以跳过拷贝/移动构造函数,直接创建对象。即,编译器被允许将下面的代码
- string null_book = "9-999-99999-9"; // 拷贝初始化
改写为
string null_book("9-999-99999-9"); // 编译器略过了拷贝构造函数
但是,即使编译器略过了拷贝/移动构造函数,但在这个程序点上,拷贝/移动构造函数必须是存在且可访问的
拷贝赋值运算符:为了与内置类型的赋值保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用
合成拷贝赋值运算符:与处理拷贝构造函数一样,如果一个类未定义自己的拷贝赋值运算符,编译器会为它生成一个合成拷贝赋值运算符,它会将右侧运算对象的每个非static成员赋予左侧运算对象的对应成员,这一工作是通过成员类型的拷贝赋值运算符来完成的,对于数组类型的成员,逐个赋值数组元素
析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员
由于析构函数不接受参数,因此它不能被重载,对一个给定类,只会有唯一一个析构函数
在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员,成员按初始化顺序的逆序销毁
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数,内置类型没有析构函数,因此销毁内置类型成员什么也不需要做
隐式销毁一个内置指针类型的成员不会delete它所指向的对象,与普通指针不同,智能指针是类类型,所以具有析构函数,因此,与普通指针不同,智能指针成员在析构阶段会被自动销毁
合成析构函数:当一个类未定义自己的析构函数时,编译器会为它定义一个合成析构函数,合成析构函数的函数体为空,在析构函数体执行完毕后,成员会被自动销毁
C++语言并不要求定义所有拷贝操作,可以只定义其中一个或两个。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的
- 需要析构函数的类也需要拷贝和赋值操作
- 需要拷贝操作的类也需要赋值操作,反之亦然(为类生成一个独一无二的序号)
我们可以通过将拷贝控制成员定义为=default来显示地要求编译器生成合成的版本。当我们在类内用=default修饰成员的声明时,合成的函数将隐式地声明为内联的。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default
阻止拷贝:可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数来阻止拷贝。删除的函数是这样一种函数:我们虽然声明了它们,但不能以任何方式使用它们,在函数的参数列表后面加上=delete来指出我们希望将它定义为删除的
与=default不同,=delete必须出现在函数第一次声明的时候,这个差异与这些声明的含义在逻辑上是吻合的。一个默认的成员只影响为这个成员而生成的代码,因此=default直到编译器生成代码时才需要。而另一方面,编译器需要知道一个函数是删除的,以便禁止试图使用它的操作
与=default的另一个不同之处是,我们可以对任何函数指定=delete(我们只能对编译器可以合成的默认构造函数或拷贝控制成员使用=default),虽然删除函数的主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配过程时,删除函数有时也是有用的
析构函数不能是删除的成员:对于删除了析构函数的类型,我们不能定义这种类型的变量或成员,但可以动态分配这种类型的成员,但是,不能释放这些对象
合成的拷贝控制成员可能是删除的:
如果我们未定义拷贝控制成员,编译器会为我们定义合成的版本。类似的,如果一个类未定义构造函数,编译器会为其合成一个默认构造函数。对某些类来说,编译器将这些合成的成员定义为删除的函数:
- 如果类的某个成员的析构函数是删除的或不可访问的,则类的合成析构函数被定义为删除的
- 如果类的某个成员的拷贝构造函数是删除的或不可访问的,则类的合成拷贝构造函数被定义为删除的。如果类的某个成员的析构函数是删除的或不可访问的,则类的合成拷贝构造函数也被定义为删除的
- 如果类的某个成员的拷贝赋值运算符是删除的或不可访问的,或是类有一个const的或引用成员,则类的合成拷贝赋值运算符被定义为删除的
- 如果类的某个成员的析构函数是删除的或不可访问的,或是类有一个引用成员,它没有类内初始化器,或是类有一个const成员,它没有类内初始化器且其类型未显示定义默认构造函数,则该类的默认构造函数被定义为删除的
本质上,这些规则的含义是:如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的
一旦一个类需要析构函数,那么它几乎也需要一个拷贝构造函数和一个拷贝赋值运算符。为了定义这些成员,我们首先必须确定此类型对象的拷贝语义,一般来说,有两种选择:可以定义拷贝操作,使类的行为看起来像一个值或者像一个指针
- 类的行为像一个值,意味着它应该也有自己的状态,当我们拷贝一个像值的对象时,副本和原对象是完全独立的,改变副本不会对原对象有任何影响
- 行为像指针的类则共享状态,当我们拷贝一个这种类的对象时,副本和原对象使用相同的底层数据,改变副本也会改变原对象
当你编写赋值运算符时,有两点需要记住:如果将一个对象赋予它自身,赋值运算符必须能正确工作。大多数赋值运算符组合了析构函数和拷贝构造函数的工作
void swap(Foo &lhs, Foo &rhs) {
using std::swap;
swap(lhs.h, rhs.h); //使用HasPtr版本的swap
}
每个swap调用应该都是未加限定的,即每个调用都应该是swap,而不是std::swap,如果存在类型特定的swap版本,其匹配程度会优于std中定义的版本(参见模版),因此,如果存在类型特定的swap版本,swap调用会与之匹配,如果不存在类型特定的版本,则会使用std中的版本(假定作用域中有using声明)
对象移动:在重新分配内存的过程中,从旧内存将元素拷贝到新内存是不必要的,更好的方式是移动元素。使用移动而不是拷贝的另一个原因源于IO类或unique_ptr这样的类,这些类都包含不能被共享的资源,因此,这些类型的对象不能拷贝但可以移动
我们可以用容器保存不可拷贝的类型,只要它们能被移动即可。标准库容器、string和shared_ptr类既支持移动也支持拷贝。IO类和unique_ptr类可以移动但不能拷贝
右值引用:为了支持移动操作,新标准引入了一种新的引用类型——右值引用,所谓右值引用就是必须绑定到右值的引用。我们通过&&而不是&来获得右值引用,右值引用有一个重要的性质——只能绑定到一个将要销毁的对象,因此,我们可以自由地将一个右值引用的资源“移动”到另一个对象中
右值引用相比于常规引用(左值引用)有着完全相反的绑定特性:对于常规引用,我们不能将其绑定到要求转换的表达式、字面常量或是返回右值的表达式。但是我们可以将一个右值引用绑定到这类表达式上,但不能将一个右值引用直接绑定到一个左值上
返回左值引用的函数,连同赋值、下标、解引用和前置递增/递减运算符,都是返回左值的表达式,我们可以将一个左值引用绑定到这类表达式的结果上
返回非引用类型的函数,连同算术、关系、位以及后置递增/递减运算符,都生成右值,我们不能将一个左值引用绑定到这类表达式上,但我们可以将一个const的左值引用或一个右值引用绑定到这类表达式上
由于右值引用只能绑定到临时对象,我们得知:所引用的对象将要被销毁、该对象没有其他用户,这两个特性意味着:使用右值引用的代码可以自由地接管所引用的对象的资源
变量是左值:我们不能将一个右值引用绑定到一个右值引用类型的变量上
- int &&rr1 = 42; // 正确:字面常量是右值
- int &&rr2 = rr1; // 错误:表达式rr1是左值
标准库move函数:虽然不能将一个右值引用直接绑定到一个左值上,但我们可以显示地将一个左值转换为对应的右值引用类型。我们通过调用一个名为move的新标准库函数来获得绑定到左值上的右值引用
- 我们可以销毁一个移后源对象,也可以赋予它新值,但不能使用一个移后源对象的值
- 使用move的代码应该使用std::move而不是move,这样做可以避免潜在的名字冲突
移动构造函数和移动赋值运算符:
类似拷贝构造函数,移动构造函数的第一个参数是该类类型的一个引用,不同于拷贝构造函数的是,这个引用参数在移动构造函数中是一个右值引用。与拷贝构造函数一样,任何额外的参数都必须有默认实参
移动操作、标准库容器和异常:由于移动操作“窃取”资源,它通常不分配任何资源,因此,移动操作通常不会抛出任何异常。当编写一个不抛出异常的移动操作时,我们应该将此事通知标准库,一种通知标准库的方法是在我们的构造函数中指明noexcept。如果不指明noexcept,标准库会认为移动我们的类对象时可能会抛出异常,因此会选用拷贝构造函数来实现扩容(vector)
除了将移后源对象置为一个可析构的状态,移动操作还必须保证对象仍然是有效的,一般来说,对象有效就是指可以安全地为其赋予新值或者可以安全地使用而不依赖其当前值,另一方面,移动操作对移后源对象中留下的值没有任何要求
如果我们不声明自己的拷贝构造函数或拷贝赋值运算符,编译器总会为我们合成这些操作。拷贝操作要么被定义为逐成员拷贝,要么被定义为对象赋值,要么被定义为删除的函数。与拷贝操作不同,编译器根本不会为某些类合成移动操作,特别是,如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。编译器可以移动内置类型的成员,如果一个成员是类类型,且该类有对应的移动操作,编译器也能移动这个成员
与拷贝操作不同,移动操作永远不会隐式定义为删除的函数。但是,如果我们显示地要求编译器生成=default的移动操作,且编译器不能移动所有成员,则编译器会将移动操作定义为删除的函数。除了一个重要的例外,什么时候将合成的移动操作定义为删除的函数遵循与定义删除的合成拷贝操作类似的原则:
- 与拷贝构造函数不同,移动构造函数被定义为删除的函数的条件是:有类成员定义了自己的拷贝构造函数且未定义移动构造函数,或者是有类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数
- 如果有类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的,则类的移动构造函数或移动赋值运算符被定义为删除的
- 类似拷贝构造函数,如果类的析构函数被定义为删除的或不可访问的,则类的移动构造函数被定义为删除的
- 类似拷贝赋值运算符,如果有类成员是const的或是引用,则类的移动赋值运算符被定义为删除的
移动右值,拷贝左值。但如果没有移动构造函数,右值也被拷贝,因为我们可以将一个Foo&&转换为一个const Foo&
拷贝并交换赋值运算符和移动操作:此运算符有一个非引用参数,这意味着此参数要进行拷贝初始化。依赖于实参的类型,拷贝初始化要么使用拷贝构造函数,要么使用移动构造函数——左值被拷贝,右值被移动,因此,单一的赋值运算符就实现了拷贝赋值运算符和移动赋值运算符两种功能:
- class HasPtr {
- public:
- HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) { p.ps = 0; }
- HasPtr& operator=(HasPtr rhs) { swap(*this, rhs); return *this; }
- };
区分移动和拷贝的重载函数通常有一个版本接受一个const T&,而另一个版本接受一个T&&
在旧标准中,我们没有办法阻止这种使用方式:s1 + s2 = “wow!";,为了维持向后兼容性,新标准库仍然允许向右值赋值。但是,我们可能希望在自己的类中阻止这种用法,在此情况下,我们希望强制左侧运算对象(即this指向的对象)是一个左值
我们指出this的左值/右值属性的方式与定义const成员函数相同,即在参数列表后放置一个引用限定符:引用限定符可以是&或&&,分别指出this可以指向一个左值或右值,类似const限定符,引用限定符只能用于(非static)成员函数,且必须同时出现在函数的声明和定义中
就像一个成员函数可以根据是否有const来区分其重载版本一样,引用限定符也可以区分重载版本。引用限定和const限定不同的是,如果我们定义两个或两个以上具有相同名字和相同参数列表的成员函数,就必须对所有函数都加上引用限定符,或者所有都不加
明智地使用运算符重载能令我们的程序更易于编写和阅读
当一个重载的运算符是成员函数时,this绑定到左侧运算对象。成员运算符函数的(显式)参数数量比运算对象的数量少一个。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参
对于一个运算符函数来说,它或者是类的成员,或者至少含有一个类类型的参数。这一约定意味着当运算符作用于内置类型的运算对象时,我们无法改变该运算符的含义
直接调用一个重载的运算符函数
- // 一个非成员运算符函数的等价调用
- data1 + data2; // 普通的表达式
- operator+(data1, data2); //等价的函数调用
- // 一个成员运算符函数的等价调用
- data1 += data2;
- data1.operator+=(data2);
某些运算符不应该被重载
- 因为使用重载的运算符本质上是一次函数调用,所以这些关于运算对象求值顺序的规则无法应用到重载的运算符上。因此不建议重载逻辑与、逻辑或和逗号运算符。除此之外,逻辑与和逻辑或的重载版本也无法保留内置运算符的短路求值属性,两个运算对象总是会被求值
- 还有一个原因使得我们一般不重载逗号运算符和取地址运算符:C++语言已经定义了这两种运算符用于类类型对象时的特殊含义,这一点与大多数运算符都不相同。因为这两种运算符已经有了内置的含义,所以一般来说它们不应该被重载
选择作为成员或者非成员
- 赋值(=)、下标([ ])、调用(())和成员访问箭头(->)运算符必须是成员
- 复合赋值运算符一般来说应该是成员,但并非必须
- 改变对象状态的运算符或者与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员
- 具有对称性的运算符可能转换任意一端的运算对象,例如算数、相等性、关系和位运算符等,因此它们因该是普通的非成员函数
输入运算符重载:输入运算符只设置failbit(类型不兼容等偏业务上的错误),而其他错误最好由IO标准库自己来标示
区分前置和后置运算符:为了解决普通的重载形式无法区分前置和后置的情况,后置版本接受一个额外的int类型的形参,当我们使用后置运算符时,编译器为这个形参提供一个值为0的实参
显示地调用后置运算符:
- p.operator++(0);
- p.operator++();
箭头运算符必须是类的成员,解引用运算符通常也是类的成员,尽管并非必须如此
如果类定义了调用运算符,则该类的对象称作函数对象,lambda是函数对象
可调用对象与function:C++语言中有几种可调用对象:函数、函数指针、lambda表达式、bind创建的对象以及重载了函数调用运算符的类,但是不同类型可能具有相同的调用形式,但是却无法转换成函数指针类型统一存储到map中
标准库function类型:function<int(int, int)> f1 = divide();
转换构造函数和类型转换运算符共同定义了类类型转换,这样的转换有时也被称作用户定义的类型转换
类型转换运算符是类的一种特殊成员函数,它负责将一个类类型的值转换成其他类型。类型转换函数的一般形式如下:
operator type() const;
一个类型转换函数必须是类的成员函数;它不能声明返回类型,形参列表也必须为空。类型转换函数通常应该是const的
尽管编译器一次只能执行一个用户定义的类型转换,但是隐式的用户定义类型转换可以置于一个标准(内置)类型转换之前或之后,并与其一起使用
- int i = 42;
- cin << i; // 如果向bool的类型转换不是显示的,则该代码在编译器看来将是合法的,cin -> bool -> int
为了防止这样的异常情况发生,C++11新标准引入了显式的类型转换运算符和显式的构造函数,当类型转换运算符是显式的时,我们也能执行类型转换,不过必须通过显式的强制类型转换才可以。该规定存在一个例外,即如果表达式被用作条件,则编译器将显式的类型转换自动应用于它。换句话说,当表达式出现在下列位置时,显式的类型转换将被隐式地执行:
- if、while及do语句的条件部分
- for语句头的条件表达式
- 逻辑非运算符( ! )、逻辑或运算符( || )、逻辑与运算符( && )的运算对象
- 条件运算符( ?: )的条件表达式
避免有二义性的类型转换:如果类中包含一个或多个类型转换,必须确保在类类型和目标类型之间只存在唯一一种转换方式,否则的话,我们编写的代码将很可能会具有二义性,在两种情况下可能产生多重转换路径:
- 第一种情况是两个类提供相同的类型转换:例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符
- 第二种情况是类定义了多个转换规则,而这些转换涉及的类型本身可以通过其他类型转换联系在一起。最典型的例子是算数运算符
当调用重载函数时,如果两个(或多个)用户定义的类型转换都提供了可行匹配,则我们认为这些类型转换一样好。在这个过程中,我们不会考虑任何可能出现的标准类型转换的级别。只有当重载函数能通过同一个类型转换函数得到匹配时,我们才会考虑其中出现的标准类型转换
表达式中运算符的候选函数既应该包括成员函数,也应该包括非成员函数
面向对象程序设计的三个基本概念:数据抽象、继承和动态绑定。通过使用数据抽象,我们可以将类的接口与实现分离;使用继承我们可以定义相似的类型并对其相似关系建模;使用动态绑定可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象
对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数,Java默认都是虚函数
派生类必须通过使用类派生列表明确指出它是从哪个基类继承而来的,类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符
在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此
如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数,即使派生类中没有添加virtual修饰
成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时
如果一个派生是公有的,则基类的公有成员也是派生类接口的组成部分,此外,我们能将公有派生类型的对象绑定到基类的引用或指针上
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在
每个类控制它自己的成员初始化过程,派生类必须使用基类的构造函数来初始化它的基类部分
派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员
Bulk_quote(const std::string &book, double p, double disc) : Quote(book, p), discount(disc) { };
必须明确一点:每个类负责定义各自的接口,要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此,因此,派生类对象不能直接初始化基类的成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义
派生类的声明:
- class Bulk_quote : public Quote; // 错误:派生列表不能出现在这里
- class Bulk_quote; // 正确:声明派生类的正确方式
被用作基类的类必须已经定义而非仅仅声明
为了防止类被继承,可以在类名后跟一个关键字final:class NoDerived final { /*. */ }
可以将基类的指针或引用绑定到派生类对象上有一层极为重要的含义:当使用基类的引用或指针时,实际上我们并不清楚该引用或指针所绑定对象的真实类型
当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开来。表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型,动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才可知
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉
一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致,同样,派生类中虚函数的返回类型也必须与基类函数匹配(基类返回自身的指针或引用时,派生类可以返回派生类的指针或引用)
和其他函数一样,虚函数也可以拥有默认实参,但是实参值由本次调用的静态类型决定。因此,如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致
如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归,正确用法:baseP->Quote::net_price(42);
我们通过在函数体的位置(即在声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数,其中,=0只能出现在类内部的虚函数声明语句处,值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。含有纯虚函数的类是抽象基类
派生类构造函数只初始化它的直接基类:
Bulk_quote(const std::string &book, double p, double disc) : Quote(book, p), discount(disc) { };
派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。这一点同样也适用于Java,只不过Java多了一个包访问权限,如果两个类在同一个包里,会混淆这个概念
某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响,派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限
派生类向基类转换的可访问性,假定D继承自B:
- 只有当D公有继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换
- 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换,派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的
- 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之则不能使用
友元关系不能传递,同样也不能继承,每个类负责控制各自成员的访问权限。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员
- class Base {
- // 添加friend声明,其他成员与之前的版本一致
- friend class Pal; // Pal 在访问 Base 的派生类时不具有特殊性
- }
- class Pal {
- public:
- int f(Base b) { return b.prot_mem; } // 正确:Pal是Base的友元
- int f2(Sneaky s) { return s.j; } // 错误:Pal不是Sneaky的友元
- // 对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此
- int f3(Sneaky s) { return s.prot_mem; } //正确,Pal是Base的友元,如果Sneaky覆盖了prot_mem,需要显示访问:s.Base::prot_mem;
- }
通过 using 声明可以改变派生类继承的某个名字的访问级别
- public:
- using Base::size;
PS:这里也涉及到了Java和C++的严重分歧,Java不允许子类缩小权限(我理解就是功能阉割,和违背里氏替换不相关),C++允许子类随意改变权限,当使用多态特性时,一切以静态类型权限为准
默认情况下,使用class关键字定义的派生类是私有继承的,而使用struct关键字定义的派生类是公有继承的
当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内,如果一个名字在派生类的作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的,即使静态类型与动态类型可能不一致,但是我们能使用哪些成员仍然是由静态类型决定的
派生类的成员将隐藏同名的基类成员,但可以通过作用域运算符来使用隐藏的成员。除了覆盖继承而来的虚函数之外,派生类最好不要重用定义在基类中的名字
如果派生类的成员与基类的某个成员同名,则派生类将在其作用域内隐藏该基类成员,一如既往,名字查找先于类型检查
如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为
派生类中删除的拷贝控制与基类的关系:
- 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,因为编译器不能使用基类成员来执行派生类对象基类部分的相关操作
- 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分
- 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动
大多数基类都会定义一个虚析构函数,因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义
派生类构造函数在其初始化阶段中不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员
- D(const D& d) : Base(d) { /* ... */ }
- D(D&& d) : Base(std::move(d)) { /* ... */ }
- D(const D& d) { /* ... */ } // 这个拷贝构造函数很可能是不正确的定义,基类部分被默认初始化,而非拷贝
类似的,派生类赋值运算符也必须为其基类部分的成员赋值
- D &D::operator=(const D &rhs) {
- Base::operator=(rhs); // 为基类部分赋值
- return *this;
- }
和构造函数及赋值运算符不同的是,析构函数只负责销毁派生类自己分配的资源
~D() { /* 该处由用户定义清除派生类成员的操作 */ } // Base::~Base 被自动调用执行
如果在构造函数或析构函数中访问了派生类的成员,很可能会崩溃
在C++11新标准中,派生类能够重用其直接基类定义的构造函数,但是不能继承默认、拷贝和移动构造函数,如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。如果派生类含有自己的数据成员,则这些成员将被默认初始化
using Disc_quote::Disc_quote; // 继承Disc_quote的构造函数
和普通成员的using声明不一样,一个构造函数的using声明不会改变该构造函数的访问级别
当一个基类构造函数含有默认实参时,这些实参并不会被继承,相反,派生类将获得多个继承的构造参数,其中每个构造函数分别省略掉一个含有默认实参的形参
如果基类含有几个构造函数,则除了两个例外情况,大多数时候派生类会继承所有这些构造函数:
- 第一个例外是派生类可以继承一部分构造函数,如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数将不会被继承
- 第二个例外是默认、拷贝和移动构造函数不会被继承,这些构造函数按照正常规则被合成。继承的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数
当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式(指针),因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器中(这一点在Java中表现不明显,因为我们持有的就是指针)
面向对象编程(OOP)和泛型编程都能处理在编写程序时不知道类型的情况。不同之处在于:OOP能处理类型在程序运行之前都未知的情况;而在泛型编程中,在编译时就能获取类型了。模版是C++中泛型编程的基础,一个模版就是一个创建类或函数的蓝图或者公式
函数模版:
- template <typename T> //class 和 typename可以互换使用
- int compare(const T &v1, const T &v2) {
- if (v1 < v2) return -1;
- if (v2 < v1) return 1;
- return 0;
- }
模版定义以关键字template开始,后跟一个模版参数列表,这是一个逗号分隔的一个或多个模版参数的列表,用小于号和大于号包围起来。在模版定义中,模版参数列表不能为空
当使用模版时,我们(隐式地或显式地)指定模版实参,将其绑定到模版参数上
实例化函数模版:
当我们调用一个函数模版时,编译器(通常)用函数实参来为我们推断模版实参。编译器用推断出的模版参数来为我们实例化一个特定版本的函数,这些编译器生成的版本通常被称为模版的实例
模版类型参数:
一般来说,我们可以将模版类型参数看作类型说明符,就像内置类型或类类型说明符一样使用。特别是,类型参数可以用来指定返回类型或函数的参数类型,以及在函数体内用于变量声明或类型转换。相比之下,Java就逊色很多了
模版非类型参数:
除了定义类型参数,还可以在模版中定义非类型参数。一个非类型参数表示一个值而非一个类型。我们通过一个特定的类型名而非关键字class或typename来指定非类型参数。当一个模版被实例化时,非类型参数被一个用户提供的或编译器推断出的值所代替,这些值必须是常量表达式,从而允许编译器在编译时实例化模版
一个非类型参数可以是一个整型,或者是一个指向对象或函数类型的指针或(左值)引用。绑定到非类型整型参数的实参必须是一个常量表达式,绑定到指针或引用非类型参数的实参必须具有静态的生存期
在模版定义中,模版非类型参数是一个常量值,在需要常量表达式的地方,可以使用非类型参数,例如,指定数组大小
模版编译:当编译器遇到一个模版定义时,它并不生成代码。只有当我们实例化出模版的一个特定版本时,编译器才会生成代码,这一特性影响了我们如何组织代码以及错误何时被检测到
通常,当我们调用一个函数时,编译器只需要掌握函数的声明。类似的,当我们使用一个类类型的对象时,类定义必须是可用的,但成员函数的定义不必已经出现。因此,我们将类定义和函数声明放在头文件中,而普通函数和类的成员函数定义放在源文件中
模版则不同:为了生成一个实例化版本,编译器需要掌握函数模版或类模版成员函数的定义。因此,与非模版代码不同,模版的头文件通常既包括声明又包括定义
类模版是用来生成类的蓝图的。与函数模版的不同之处是,编译器不能为类模版推断模版参数类型,为了使用类模版,我们必须在模版名后的尖括号中提供额外信息——用来代替模版参数的模版实参列表
一个类模版的每个实例都形成一个独立的类,类型Blob<string>与任何其他Blob类型都没有关联,也不会对任何其他Blob类型的成员有特殊访问权限
类模版的成员函数:与其他任何类相同,我们既可以在类模版内部,也可以在类模版外部为其定义成员函数,且定义在类模版内的成员函数被隐式声明为内联函数。类模版的成员函数本身是一个普通函数,但是类模版的每个实例都有其自己版本的成员函数,因此,类模版的成员函数具有和模版相同的模版参数,因此,定义在类模版之外的成员函数就必须以关键字template开始,后接类模版参数列表
类模版成员函数的实例化:默认情况下,一个类模版的成员函数只有当程序用到它时才进行实例化,这一特性使得即使某种类型不能完全符合模版操作的要求,我们仍然能用该类型实例化类
当我们使用一个类模版类型时必须提供模版实参,但这一规则有一个例外,在类模版自己的作用域中,我们可以直接使用模版名而不提供实参
当一个类包含一个友元声明时,类与友元各自是否是模版是相互无关的。如果一个类模版包含一个非模版友元,则友元被授权可以访问所有模版实例。如果友元自身是模版,类可以授权给所有友元模版实例,也可以只授权给特定实例
在新标准中,我们可以将模版类型参数声明为友元:
- template <typename Type> class Bar {
- friend Type; // 将访问权限授予用来实例化Bar的类型(与内置类型的友好关系是允许的)
- };
模版类型别名:
- typedef Blob<string> StrBlob;
新标准允许我们为类模版定义一个类型别名:
- template<typename T> using twin = pair<T, T>;
- twin<string> authors; // authors是一个pair<string, string>
类模版的每个实例都有一个独有的static对象,因此,与定义模版的成员函数类似,我们将static数据成员也定义为模版:
- template<typename T>
- size_t Foo<T>::ctr = 0; //定义并初始化ctr
模版参数遵循普通的作用域规则。一个模版参数名的可用范围是在其声明之后,至模版声明或定义之前
- typedef double A;
- template<typename A, typename B> void f(A a, B b) {
- A tmp = a; // tmp的类型为模版参数A的类型,而非double
- double B; // 错误:重声明模版参数B
- }
模版声明必须包含模版参数:
- template<typename T> int compare(const T&, const T&);
- template<typename T> class Blob;
使用类的类型成员:我们用作用域运算符( :: )来访问static成员和类型成员。在普通(非模版)代码中,编译器掌握类的定义,因此,它知道通过作用域运算符访问的名字是类型还是static成员。但是对于模版代码就存在困难。假定T是一个模版类型参数,当编译器遇到如下形式的语句时:T::sizetype * p;,我们不清楚是定义一个名为p的变量还是将一个名为sizetype的static数据成员与名为p的变量相乘
默认情况下,C++语言假定通过作用域运算符访问的名字不是类型。因此,如果我们希望使用一个模版类型参数的类型成员,就必须显示告诉编译器该名字是一个类型,我们通过使用关键字typename来实现这一点:(当我们希望通知编译器一个名字表示类型时,必须使用关键字typename,而不能使用class)
- template<typename T>
- typename T::value_type top(cosnt T& c) {
- if (!c.empty())
- return c.back();
- else
- return typename T::value_type();
- }
就像我们能为函数参数提供默认实参一样,我们也可以为函数和类模版提供默认模版实参,与函数默认实参一样,对于一个模版参数,只有当它右侧的所有参数都有默认实参时,它才可以有默认实参
- // compare 有一个默认模版实参less<T>和一个默认函数实参F()
- template<typename T, typename F = less<T>>
- int compare(const T &v1, const T &v2, F f = F()) { ... }
- bool i = compare(0, 42);
- bool j = compare(item1, item2, compareIsbn);
无论何时使用一个类模版,我们都必须在模版名之后接上尖括号
- template<class T = int> class Numbers { T默认为int
- }
- Numbers<long double> lots_of_precision;
- Numbers<> average_precision;
成员模版:一个类(普通类、模版类)可以包含本身是模版的成员函数。这种成员被称为成员模版。成员模版不能是虚函数
控制实例化:当模版被使用时才会进行实例化这一特性意味着,相同的实例可能出现在多个对象文件中。当两个或多个独立编译的源文件使用了相同的模版,并提供了相同的模版参数时,每个文件中就都会有该模版的一个实例。在大系统中,在多个文件中实例化相同模版的额外开销可能非常严重。在新标准中,我们可以通过显示实例化来避免这种开销,一个显示实例化有如下形式:
- extern template declaration; // 实例化声明
- template declaration; // 实例化定义
declaration是一个类或函数声明,其中所有模版参数已被替换为模版实参,例如:
- // 实例化声明与定义
- extern template class Blob<string>; //声明
- template int compare(const int&, const int&); //定义
当编译器遇到extern模版声明时,它不会在本文件中生成实例化代码。将一个实例化声明为extern就表示承诺在程序其它位置有该实例化的一个非extern声明(定义)。对于一个给定的实例化版本,可能有多个extern声明,但必须只有一个定义
由于编译器在使用一个模版时自动对其实例化,因此extern声明必须出现在任何使用此实例化版本的代码之前:
- extern template class Blob<string>;
- extern template int compare(const int&, const int&);
- Blob<string> sa; //实例化会出现在其他位置
- Blob<int> a1 = {0,1,2,3,4}; //在本文件中实例化
- int i = compare(a1[0], a2[0]); //实例化会出现在其他位置
实例化定义会实例化所有成员:一个类模版的实例化定义会实例化该模版的所有成员,包括内联的成员函数。当编译器遇到一个实例化定义时,它不了解程序使用哪些成员函数,因此,处理类模版的普通实例化不同,编译器会实例化该类的所有成员,即使我们不使用某个成员,它也会被实例化,因此,我们用来显示实例化一个类模版的类型,必须能用于模版的所有成员
通过在编译时绑定删除器,unique_ptr避免了间接调用删除器的运行时开销。通过在运行时绑定删除器,shared_ptr使用户重载删除器更为方便
模版实参推断
与非模版函数一样,我们在一次调用中传递给函数模版的实参被用来初始化函数的形参。只有很有限的几种类型转换会自动地应用于这些实参。编译器通常不是对实参进行类型转换,而是生成一个新的模版实例
- const转换:可以将一个非const对象的引用(或指针)传递给一个const的引用(或指针)形参
- 数组或函数指针转换:如果函数形参不是引用类型,则可以对数组或函数类型的实参应用正常的指针转换
其他类型转换,如算术转换、派生类向基类的转换以及用户定义的转换都不能应用于函数模版
如果函数参数类型不是模版参数,则对实参进行正常的类型转换
在某些情况下,编译器无法推断出模版实参的类型。其他一些情况下,我们希望允许用户控制模版实例化。对于模版类型参数已经显式指定了的函数实参,也可以进行正常的类型转换
由于尾置返回出现在参数列表之后,它可以使用函数的参数:
- template<typename It>
- auto fcn(It beg, It end) -> decltype(*beg) {
- return *beg;
- }
进行类型转换的标准库模版类:以remove_reference为例,remove_reference::type脱去引用,剩下元素类型本身
- template<typename It>
- auto fcn(It beg, It end) -> typename remove_reference<decltype(*beg)>::type {
- return *beg;
- }
当我们用一个函数模版初始化一个函数指针或为一个函数指针赋值时,编译器使用指针的类型来推断模版实参
- template<typename T> int compare(const T&, const T&);
- int (*pf1)(const int*& const int&) = compare // pf1中参数的类型决定了T的模版实参的类型
如果不能从函数指针类型确定模版实参,我们可以通过使用显式模版实参来消除func调用的歧义
- void func(int(*)(const string&, const string&));
- void func(int(*)(const int&, const int&));
- func(compare<int>);
模版实参推断和引用
从左值引用函数参数推断类型
- template<typename T> void f1(T&);
- f1(i); // i 是一个int;模版参数类型T是int
- f1(ci); // ci 是一个const int;模版参数T是const int
- f1(5); // 错误:传递给一个&参数的实参必须是一个左值
- template<typename T> void f2(const T&); // 可以接受一个右值
- f1(i); // i 是一个int;模版参数类型T是int
- f1(ci); // ci 是一个const int;模版参数T是int
- f1(5); //一个const &参数可以绑定到一个右值;T是int
从右值引用函数参数推断类型
- template<typename T> void f3(T&&);
- f3(42); //实参是一个int类型的右值;模版参数T是int
引用折叠和右值引用参数
假定i是一个int对象,我们可能认为像f3(i)这样的调用是不合法的,毕竟,i是一个左值,而通常我们不能将一个右值引用绑定到一个左值上。但是,C++语言在正常绑定规则之外定义了两个例外规则,允许这种绑定。这两个例外规则是move这种标准库设施正确工作的基础
第一个例外规则:当我们将一个左值传递给函数的右值引用参数,且此右值引用指向模版类型参数(如T&&)时,编译器推断模版类型参数为实参的左值引用类型。通常,我们不能(直接)定义一个引用的引用,但是通过类型别名或通过模版类型参数间接定义是可以的
第二个例外规则:如果我们间接创建一个引用的引用,则这些引用形成了“折叠”。对于一个给定类型X:
- X& &、X& &&、X&& &都折叠成类型X&
- X&& &&折叠成X&&
如果一个函数参数是指向模版参数类型的右值引用(如,T&&),则可以传递给它任意类型的实参。如果将一个左值传递给这样的参数,则函数参数被实例化为一个普通的左值引用(T&)
- f3(i); -> void f3<int&>(int& &&); -> void f3<int&>(int&);
在实际中,右值引用通常用于两种情况:模版转发其实参或模版被重载。目前应该注意的是,使用右值引用的函数模版通常使用下面的方式来进行重载:
- template<typename T> void f(T&&); // 绑定到非const右值
- template<typename T> void f(const T&); // 左值和const右值
std::move原理
- template<typename T>
- typename remove_reference<T>::type&& move(T&& t) {
- return static_cast<typename remove_reference<T>::type&&>(t);
- }
- template <class _Tp> struct remove_reference { typedef _Tp type; };
- template <class _Tp> struct remove_reference<_Tp&> { typedef _Tp type; };
- template <class _Tp> struct remove_reference<_Tp&&> { typedef _Tp type; };
转发:某些函数需要将其一个或多个实参连同类型不变地转发给其他函数,在此情况下,我们需要保持被转发实参的所有性质,包括实参类型是否是const的以及实参是左值还是右值
- template<typename F, typename T1, typename T2>
- void flip1(F f, T1 t1, T2 t2) {
- f(t2, t1);
- }
这个函数一般情况下工作得很好,但当我们希望用它调用一个接受引用参数的函数时就会出现问题:
- void f(int v1, int &v2) { // v2是一个引用
- cout << v1 << ++v2 << flush;
- }
- f(42, i); // f改变了实参i
- flip(f, j, 42); //通过flip调用f不会改变j,问题在于j被传递给flip1的参数t1是一个普通的、非引用的类型int,而非int&
定义能保持类型信息的函数参数:
- template<typename F, typename T1, typename T2>
- void flip2(F f, T1 &&t1, T2 &&t2) { // 函数参数是指向模版类型参数的右值引用,实参的const属性和左值/右值属性将得到保持
- f(t2, t1);
- }
这个版本的flip2解决了一半问题,它对于接受一个左值引用的函数工作的很好,但不能用于接受右值引用参数的函数。例如:
- void g(int &&i, int &j) {
- cout << v1 << v2 << flush;
- }
- flip2(g, i, 42); // 错误:不能从一个左值实例化 int&&
如果我们试图通过flip2调用g,则参数t2将被传递给g的右值引用参数。但函数参数与其他任何变量一样,都是左值表达式。因此,flip2中对g的调用将传递给g的右值引用参数一个左值
在调用中使用std::forward保持类型信息:forward返回该显示实参类型的右值引用,即,forward<T>的返回类型是T&&
- template<typename F, typename T1, typename T2>
- void flip1(F f, T1 &&t1, T2 &&t2) {
- f(std::forward<T2>(t2), std::forward<T2>(t1));
- }
- template <class _Tp>
- _Tp&& forward(typename remove_reference<_Tp>::type& __t) _NOEXCEPT {
- return static_cast<_Tp&&>(__t);
- }
- template <class _Tp>
- _Tp&& forward(typename remove_reference<_Tp>::type&& __t) _NOEXCEPT {
- return static_cast<_Tp&&>(__t);
- }
- int i = 2;
- std::forward<int&&>(i); // 正确,左值转换成右值
- std::forward<int&>(2); // 错误,右值不能转换成左值
重载与模版:函数模版可以被另一个模版或一个普通非模版函数重载,与往常一样,名字相同的函数必须具有不同数量或类型的参数。如果设计函数模版,则函数匹配规则会在以下几方面受到影响:
- 对于一个调用,其候选函数包括所有模版实参推断成功的函数模版实例
- 候选的函数总是可行的,因为模版实参推断会排除任何不可行的模版
- 与往常一样,可行函数(模版与非模版)按类型转换来排序。当然,可以用于函数模版的类型转换是非常有限的
-
与往常一样,如果恰有一个函数提供比任何其他函数都更好的匹配,则选择此函数。但如果有多个函数提供同样好的匹配则:
- 如果同样好的函数中只有一个是非模版参数,则选择此函数
- 如果同样好的函数中没有非模版函数,而有多个函数模版,且其中一个模版比其他模版更特例化,则选择此模版
- 否则,此调用有歧义
可变参数模版:一个可变参数模版就是一个接受可变数目参数的模版函数或模版类
- template<typename... Args>
- void g(Args... args) {
- cout << sizeof... (Args) << endl; // 类型参数的数目
- cout << sizeof... (args) << endl; // 函数参数的数目
- }
可变参数函数通常是递归的:
- template<typename T>
- ostream &print(ostream &os, const T &t) {
- return os << t;
- }
- template<typename T, typename... Args>
- ostream &print(ostream &os, const T &t, const Args&... rest) { // 扩展 Args
- os << t << ", "; // 打印第一个实参
- return print(os, rest...); // 递归调用,打印其他实参 // 扩展 rest
- }
对于一个参数包,除了获取其大小外,我们能对它做的唯一的事情就是扩展它
- template<typename... Args>
- ostream &errorMsg(ostream &os, const Args&... rest) {
- // print(os, debug_rep(a1), debug_rep(a2), ... , debug_rep(an));
- return print(os, debug_rep(rest)....);
- }
编写单一模版,使之对任何可能的模版实参都是最合适的,都能实例化,这并不总是能办到。例如,我们希望compare通过调用strcmp比较两个字符指针而非比较指针值
- // 第一个版本;可以比较任意两个类型
- template<typename T> int compare(const T&, const T&);
- // 第二个版本处理字符串字面常量
- template<size_t N, size_t M>
- int compare(const char (&)[N], const char (&)[M]);
为了处理字符指针(而不是数组),可以为第一个版本的compare定义一个模版特例化版本
函数模版特例化
- template<>
- int compare(const char* const &p1, const char* const &p2) {
- return strcmp(p1, p2);
- }
特例化的本质是实例化一个模版,而非重载它,因此,特例化不影响函数匹配
模版及其特例化版本应该声明在同一个头文件中,所有同名模版的声明应该放在前面,然后是这些模版的特例化版本
类模版特例化
- namespace std { // 打开std命名空间,以便特例化std::hash
- template<>
- struct hash<Sales_data> {
- typedef size_t result_type;
- typedef Sales_data argument_type;
- size_t operator() (const Sales_data& s) const;
- };
- size_t hash<Sales_data>::operator() (const Sales_data& s) const {
- return hash<string>()(s.bookNo) ^ hash<unsigned>()(s.units_sold) ^ hash<double>()(s.revenue);
- }
- }
- template<class T> class std::hash // 友元声明所需要的
- class Sales_data {
- friend class std::hash<Sales_data>;
- // 其他成员定义
- };
类模版部分特例化:部分特例化的模版参数列表是原始模版的参数列表的一个子集或者是一个特例化版本
特例化成员而不是类:我们可以只特例化特定成员函数而不是特例化整个模版
正则表达式
除了条件状态外,每个iostream对象还维护一个格式状态来控制IO如何格式化的细节,当操纵符改变流的格式状态时,通常改变后的状态对所有后续IO都生效
- cout << boolalpha // 改变打印布尔值的方式
- cin >> noskipws; // 设置cin读取空白符
到目前为止,我们的程序只使用过格式化IO操作,输入和输出运算符根据读取或写入的数据类型来格式化它们。输入运算符忽略空白符,输出运算符应用补白、精度等规则。标准库还提供了一组底层操作,支持未格式化IO。这些操作允许我们将一个流当作一个无解释的字节序列来处理
随机访问:istream和ostream类型通常不支持随机访问,随机访问只适用于fstream和sstream类型
异常处理机制允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应的处理。异常使得我们能够将问题的检测与解决过程分离开来
栈展开过程中对象被自动析构,如果异常发生在构造函数中,我们也要确保已构造的成员能被正确地销毁
在栈展开的过程中,已经引发了异常但是我们还没有处理它,如果异常抛出后没有被正确捕获,则系统将调用terminate函数。因此,出于栈展开可能使用析构函数的考虑,析构函数不应该抛出不能被它自身处理的异常
Java好像不会存在这种由于析构函数导致异常没有被正确捕获的情况,我们可以在catch中继续抛出异常,甚至finally中也可以抛出新的异常,只是编译器会提示风险
如果catch的参数是基类类型,则我们可以使用其派生类类型的异常对象对其进行初始化。此时,如果catch的参数是非引用类型,则异常对象将被切掉一部分,这与将派生类对象以值传递的方式传给一个普通函数差不多。另一方面,如果catch的参数是基类的引用,则该参数将以常规方式绑定到异常对象上
如果在多个catch语句的类型之间存在着继承关系,则我们应该把继承链最底端的类放在前面
重新抛出仍然是一条throw语句,只不过不包含任何表达式:throw;,空的throw语句只能出现在catch语句或catch语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw语句,编译器将调用terminate
catch(...),我们使用省略号作为异常声明,这样的处理代码称为捕获所有异常
处理构造函数初始值异常的唯一方法是将构造函数写成函数try语句块(关键字try出现在表示构造函数初始值列表以及表示构造函数体的花括号之前)
我们可以通过提供noexcept说明指定某个函数不会抛出异常,其形式是关键字noexcept紧跟在函数的参数列表后面。尽管函数声明了它不会抛出异常,但实际上还是抛出了。一旦一个noexcept函数抛出了异常,程序就会调用terminate以确保遵守不在运行时抛出异常的承诺
noexcept运算符:一元运算符,它的返回值是一个bool类型的右值常量表达式,用于表示给定的表达式是否会抛出异常
尽管noexcept说明符不属于函数类型的一部分,但是函数的异常说明仍然会影响函数的使用
函数指针及该指针所指的函数必须具有一致的异常说明。如果我们为某个指针做了不抛出异常的声明,则该指针将只能指向不抛出异常的函数,相反,如果我们显示或隐式地说明了指针可能抛出异常,则该指针可以指向任何函数
如果一个虚函数承诺了它不会抛出异常,则后续派生出来的虚函数也必须做出同样的承诺;与之相反,如果基类的虚函数允许抛出异常,则派生类的对应函数既可以允许抛出异常,也可以不允许抛出异常
当编译器合成拷贝控制成员时,同时也生成一个异常说明。如果对所有成员和基类的所有操作都承诺了不会抛出异常,则合成的成员是noexcept的,否则是noexcept(false)。而且,如果我们定义了一个析构函数但是没有为它提供异常声明,则编译器将合成一个。合成的异常说明将与假设由编译器为类合成析构函数时所得的异常说明一致
命名空间为防止名字冲突提供了更加可控的机制,命名空间分割了全局命名空间,其中每个命名空间是一个作用域
命名空间作用域后面无须分号;命名空间既可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部
命名空间可以是不连续的,这一特性使得我们可以像管理自定义类和函数的方式来管理组织命名空间:头文件声明、源文件定义
模版特例化必须定义在原始模版所属的命名空间中
全局命名空间、嵌套命名空间、内联命名空间(定义内联命名空间的方式是在关键字namespace前添加关键字inline,内联命名空间中的名字可以被外层命名空间直接使用,无须添加表示该命名空间的前缀,适用于版本控制)
未命名的命名空间:在标准C++引入命名空间的概念之前,程序需要将名字声明成static的以使得其对于整个文件有效。在文件中进行静态声明的做法是从C语言继承而来的。在C语言中,声明为static的全局实体在其所在的文件外不可见。目前在C++中的做法是使用未命名的命名空间
namespace local { namespace { int i; } }
命名空间别名:namespace primer = cplusplus_primer;
using声明一次只引入命名空间的一个成员,而using指示使得某个特定的命名空间中所有的名字都可见。简写的名字从using指示开始,一直到using指示所在的作用域结束都能使用。using指示主要用在命名空间本身的实现文件中
对命名空间内部名字的查找遵循常规的查找规则:即由内而外依次查找每层作用域,只有位于开放的块中且在使用点之前的声明的名字才被考虑
当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类所属的命名空间
- std::string s; std::cin >> s; => operator>>(std::cin, s);
operator>>函数定义在标准库string中,string又定义在命名空间std中。但是我们不用std::限定符和using声明就可以调用operator>>
友元声明与实参相关的查找:当类声明了一个友元时,该友元声明并没有使得友元本身可见。然而,一个未声明的类或函数如果第一次出现在友元声明中,则我们认为它是最近的外层命名空间的成员
此时,f和f2都是命名空间A的成员。即使f不存在其他声明,我们也能通过实参相关的查找规则调用f:
因为f接受一个类类型的实参,而且f在C所属的命名空间进行了隐式的声明,所以f能被找到,相反,因为f2没有形参,所以它无法被找到。A::f2();这种调用形式也无法通过编译
重载与using声明:using声明语句声明的是一个名字,而非一个特定的函数,当我们为函数书写using声明时,该函数的所有版本都被引入到当前作用域中。一个using声明引入的函数将重载该声明语句所属作用域中已有的其他同名函数。如果using声明出现在局部作用域中,则引入的名字将隐藏外层作用域的相关声明。如果using声明所在的作用域中已经有一个函数与新引入的函数同名且形参列表相同,则该using声明将引发错误
重载与using指示:using指示将命名空间的成员提升到外层作用域中,如果命名空间的某个函数与该命名空间所属作用域的函数同名,则命名空间的函数将被添加到重载集合中。与using声明不同的是,对于using指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可
namespace wrapper {
namespace libs_R_us {
extern void print(double);
}
}
void print(Parent parent); // 正确
namespace work1 {
void print(Parent parent); // 会导致 print(1.1); 编译错误 "No matching function"
namespace work2 {
void print(Parent parent); // 会导致 print(1.1); 编译错误 "No matching function"
namespace work3 {
void print(Parent parent); // 会导致 print(1.1); 编译错误 "No matching function"
using namespace wrapper::libs_R_us;
void fooBar(int iVal) {
print(1.1);
wrapper::libs_R_us::print(iVal);
}
}
}
}
void print(Parent parent); // 正确
namespace work1 {
void print(Parent parent); // 正确
namespace work2 {
void print(Parent parent); // 正确
namespace wrapper {
namespace libs_R_us {
extern void print(double);
}
}
namespace work3 {
void print(Parent parent); // 会导致 print(1.1); 编译错误 "No matching function"
using namespace wrapper::libs_R_us;
void fooBar(int iVal) {
print(1.1);
wrapper::libs_R_us::print(iVal);
}
}
}
}
void print(Parent parent); // 正确
namespace work1 {
void print(Parent parent); // 正确
namespace work2 {
void print(Parent parent); // 正确
namespace wrapper {
namespace libs_R_us {
extern void print(double);
}
}
using namespace wrapper::libs_R_us;
namespace work3 {
void print(Parent parent); // 会导致 print(1.1); 编译错误 "No matching function"
void fooBar(int iVal) {
print(1.1);
wrapper::libs_R_us::print(iVal);
}
}
}
}
void print(Parent parent); // 正确
namespace work1 {
void print(Parent parent); // 正确
namespace work2 {
void print(Parent parent); // 正确
namespace wrapper {
namespace libs_R_us {
extern void print(double);
}
}
namespace work3 {
void print(Parent parent); // 会导致 print(1.1); 编译错误 "No matching function"
namespace work4 {
using namespace wrapper::libs_R_us;
void fooBar(int iVal) {
print(1.1);
wrapper::libs_R_us::print(iVal);
}
}
}
}
}
多重继承是指从多个直接基类中产生派生类的能力,多重继承的派生类继承了所有父类的属性
- class Panda : public Bear, public Endangered { /* ... */ }
和只有一个基类的继承一样,多重派生的派生列表也只能包含已经被定义过的类,而且这些类不能是final的
与从一个基类进行的派生一样,多重继承的派生类的构造函数初始值也只能初始化它的直接基类。其中基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关
- Panda::Panda(string name) : Bear(name), Endangered(Endangered::critical) { } // 显式地初始化所有基类
- Panda::Panda() : Endangered(Endangered::critical) { } // 隐式地使用Bear的默认构造函数初始化Bear子对象
一个Panda对象按照如下次序进行初始化:
- ZooAnimal是整个继承体系的最终基类,Bear是Panda的直接基类,ZooAnimal是Bear的基类,所以首先初始化ZooAnimal
- 接下来初始化Panda的第一个直接基类Bear
- 然后初始化Panda的第二个直接基类Endangered
- 最后初始化Panda
如果一个类从它的多个基类中继承了相同的构造函数(即形参列表完全相同),则这个类必须为该构造函数定义它自己的版本:
- struct D2 : public Base1, public Base2 {
- using Base1::Base1; // 从Base1继承构造函数
- using Base2::Base2; // 从Base2继承构造函数
- // D2必须自定义一个接受 string 的构造函数
- D2(const string &s) : Base1(s), Base2(s) { }
- D2() = default; // 一旦D2定义了它自己的构造函数,则必须出现
- };
和单继承一样,派生类的析构函数只负责清除派生类本身分配的资源,派生类的成员及基类都是自动销毁的。析构函数的调用顺序正好与构造函数相反,以Panda为例,析构函数的调用顺序是`Panda、Endangered、Bear、ZooAnimal`
在多重继承中,编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。与只有一个基类的继承一样,对象、指针和引用的静态类型决定了我们能够使用哪些成员
当一个类拥有多个基类时,有可能出现派生类从两个或更多基类中继承了同名成员的情况。此时,不加前缀限定符直接使用该名字将引发二义性。即使同名成员在一个类中是私有的,而在另一个类中是公有的或受保护的同样也可能发生错误。和往常一样,先查找名字后进行类型检查
虚继承:尽管在派生类列表中同一个基类只能出现一次,但派生类可以通过不同的直接基类分别继承同一个间接基类,也可以直接继承某个基类,然后通过另一个基类再一次间接继承该类
虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类,在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象
在实际的编程过程中,位于中间层次的基类将其继承声明为虚基类一般不会带来什么问题,虚派生只影响从制定了虚基类的派生类中进一步派生出的类,他不会影响派生类本身。通常情况下,使用虚继承的类层次是由一个人或一个项目组一次性设计完成的
在虚派生中,虚基类是由最底层的派生类初始化的。含有虚基类的对象的构造顺序与一般的顺序稍有区别:首先使用提供给最底层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次序依次对其进行初始化。销毁的顺序正好相反
当我们使用一条new表达式时实际执行了三步操作:
- new表达式调用名为operator new(或者operator new[])的标准库函数,该函数用于分配原始的、未命名的内存空间
- 编译器运行相应的构造函数以构造这些对象,并为其传入初始值
- 对象被分配了空间并完成构造,返回一个指向该对象的指针
当我们使用一条delete表达式删除一个动态分配的对象时:
- 对指针所指的对象或指针所指的数组中的元素执行对应的析构函数
- 编译器调用名为 operator delete(或者operator delete[])的标准库函数释放内存空间
如果应用程序希望控制内存分配的过程,则需要定义自己的operator new函数和operator delete函数,即使在标准库中已经存在这两个函数的定义,我们仍旧可以自定义自己的版本,编译器不会对这种重复的定义提出异议,相反,编译器将使用我们自定义的版本替换标准库定义的版本
标准库定义了operator new函数和operator delete函数的8个重载版本,其中前四个版本可能抛出bad_alloc异常,后四个版本则不会抛出异常:
下面这个函数不能被用户重载:void *operator new(size_t, void*);,该函数用于定位new的场景,定位new允许我们在一个特定的、预先分配的内存地址上构造对象
定位new与allocator的construct成员非常相似,但也有也写重要区别:我们传给construct的指针必须指向同一个allocator对象分配的空间,但是传给定位new的指针无须指向operator new分配的内存,实际上,传给定位new表达式的指针甚至不需要指向动态内存
显式的析构函数调用:string *sp = new string("a value"); sp->~string();
调用析构函数会销毁对象,但是不会释放内存
运行时类型识别RTTI
dynamic_cast运算符:用于将基类的指针或引用安全地转换成派生类的指针或引用。通常该类型需要含有虚函数,且派生类需要是公有派生类才能转换成功
typeid运算符:typeid表达式的形式是typeid(e),其中e可以是任意表达式或类型的名字。typeid操作的结果是一个常量对象的引用,该对象的类型是标准库类型type_info或者type_info的公有派生类型
typeid运算符可以作用于任意类型的表达式。和往常一样,顶层const被忽略,如果表达式是一个引用,则typeid返回该引用所引对象的类型。不过当typeid作用于数组或函数时,并不会执行向指针的标准类型转换
当运算对象不属于类类型或者是一个不包含任何虚函数的类时,typeid运算符指示的是运算对象的静态类型。而当运算对象是定义了至少一个虚函数的类的左值时,typeid的结果直到运行时才会求得
当typeid作用于指针时(而非指针所指的对象),返回的结果是该指针的静态编译时类型
嵌套类:一个类可以定义在另一个类的内部,前者称为嵌套类,嵌套类常用于定义作为实现部分的类
嵌套类是一个独立的类,与外层类基本没有什么关系。特别是,外层类的对象和嵌套类的对象是相互独立的。在嵌套类的对象中不包含任何外层类定义的成员;类似的,在外层类的对象中也不包含任何嵌套类定义的成员
嵌套类的名字在外层类作用域中是可见的,在外层类作用域之外不可见。和其他嵌套的名字一样,嵌套类的名字不会和别的作用域中的同一个名字冲突
嵌套类中成员的种类与非嵌套类是一样的。和其他类类似,嵌套类也使用访问限定符来控制外界对其成员的访问权限。外层类对嵌套类的成员没有特殊的访问权限。同样,嵌套类对外层类的成员也没有特殊的访问权限(好像不是这么回事,例子如下:)
class Outer {
private:
int pri_i;
public:
class Inner {
public:
static int getOuterI(Outer outer) {
return outer.pri_i;
}
};
};
嵌套类在其外层类中定义了一个类型成员。和其他成员类似,该类型的访问权限由外层类决定
和成员函数一样,嵌套类必须声明在类的内部,但是可以定义在类的内部或者外部。但在嵌套类在其外层类之外完成真正的定义之前,它都是一个不完全类型
局部类:类可以定义在某个函数的内部,我们称这样的类为局部类。局部类定义的类型只在定义它的作用域内可见。和嵌套类不同,局部类的成员受到严格限制
局部类的所有成员(包括函数)都必须完整定义在类的内部。因此,局部类的作用与嵌套类相比相差甚远。类似的,局部类中也不允许声明静态数据成员,因为我们没有办法定义这样的成员文章来源:https://www.toymoban.com/news/detail-519201.html
局部类对其外层作用域中名字的访问权限受到很多限制,局部类只能访问外层作用域定义的类型名、静态变量以及枚举成员。如果局部类定义在某个函数内部,则该函数的普通局部变量不能被该局部类使用文章来源地址https://www.toymoban.com/news/detail-519201.html
到了这里,关于C++ Primer(读书笔记)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!