C++学习笔记
在之前也算是学习过C++,当时教学用的还是VC6.0,学了一学期吧。不过说真的,因为之前搞过一段时间算法竞赛,对于我而言,那一个学期的内容里面,也就类是不大熟悉的,并且上完课也不知道应该在什么时候用。最后做课程设计也是用了最简单的过程式流程,没有任何面向对象的思想。后来有很长时间没有碰代码,直到近两年才重新开始。接手的第一个任务是一个mfc程序,因为基础很差,也没有学过mfc以及windows开发,基本算是摸着石头过河。后来开始写控制台程序,需要使用第三方库,那段时间光是编译这些类库就遇到了很多坑,最后可算是学会了看readme,使用cmake,当然叫我使用cmake之类创建一个项目还是不行的。在之后的时间里,逐渐了解了stl以及boost。期间也写过一些js,看了下汇编以及编译原理,拓宽了些许眼界。只是,现在还是越发觉得力不从心。
最近趁着放假,决定重新从c++入门书籍看起,踏踏实实重新学习一遍。流程就是c++语言规范,stl库,Windows API,之后就是看代码了。数据结构、算法等等,就不算在c++里面了。
C++标准委员会网站
C++参考手册(内容很全了)网站
MSDN关于VS2017的C++支持文档参考(东西更多了)网站
前言
首先,c++是编译型语言,与之相对的是解释型语言。编译型语言的优势在于能够在编译时进行检查,避免把错误带到运行时。另一方面它的输出是机器码,可以直接运行,效率更高。
c++的局限也很明显:
- 第一,它不适合开发界面。c++在创立时还没有图形化系统,到现在标准库中也只有控制台和文件的输入输出。在windows平台,微软只提供了一个半死不活的MFC,做出来的界面也只能是提供一个显示,至于好看就别想了。当然,有些大公司可能开发了基于c++的界面框架,此外也有一些商业框架,只是这些对于个人是没半毛钱关系的,在学校老师也不会掏钱去买这些,只会跟你说去找找免费的呗,有问题自己解决啊。
- 第二,c++的开源库使用起来很复杂。那些解释型语言,基本上都有包管理工具,要用啥开源项目直接一句指令就安装完了。高贵的c++要自己编译,这里面还牵扯到好几个项目工具,编译时还要配置预处理定义。特别是当你用的库还包含别的库时,人家能够自动一并下下来,而你要手动一个个找,一个个编译。
- 最后,免费的库没有python、js多,这也没办法,毕竟c++程序员少,而且不容易被逆向。解释型语言的代码直接能看,版权什么的全看自觉,开源了一方面能增加成就感,另一方面还可能吸引到牛人的加入。
和别的语言一样,c++是给人用的。只要是经得起考验的语言,每个语言都有适用的场景。在选择编程语言时,语言本身的优点是一方面,语言社区同样重要。现在大部分学校入门都是教c++,它效率确实高,给程序员的操作空间确实很大,但是最后大部分人的工作并不是追求性能。
实际上,大部分人去做前端或者APP,也就是做界面去了。另一部分人去做服务器应用了,少部分人去做AI。真正做底层开发的很少。你说做嵌入式系统,那只能用c,c++算是超纲了。
如果想要入门快速,现阶段看,python是最好的选择,解释型语言更适合初学者。如果奔着高工资去,以后还是会学c++的。当然,一个写代码的,如果不会几种语言,还好意思说出去吗。不需要精通,但能够阅读和简单使用还是有必要的。
在这里,我的目的是学习c++,不会涉及到c++的实现。c++里面的条条框框,也是到编译期为止。如果想要跳出这些框框,你可以直接在里面插汇编码。c++跟汇编的关系,和语法跟语言的关系一样,只是你可以随意地说病句,c++不允许你说病句。如果想要探索c++的实现原理,可以去学习编译原理。
入门:c++语言基础
教材选择
在入门阶段,教材的质量对学习效果有着决定性的影响。作为一本教材,在章节的安排上,必须符合逻辑,这一点大部分书可以做到。在内容上,一方面要照顾到语言的发展,另一方面不能有太多跟语言无关的东西,比如讲着就出现了算法。最后,内容必须完整。
另外,对于纯小白而言,由于尚未熟悉代码的基本规矩,也不知道查找和解决问题的途径(其实是根本不知道问题是啥),一对一的指导是不可或缺的。
去网上查了一下,决定用《C++ Primer plus(第6版)》这本书。和《C++ Prime(第5版)》比较了一下,感觉plus虽然内容少了些,但是章节安排更适合初学者,描述也更详细。最重要的是,plus在文中提供了完整的代码,抄下来就能运行,充分为初学者考虑。
### 关键字的出现时间
学习c++规范时,基本上所有的特性都有与之相关的关键字,因此可以使用对关键字的了解程度衡量进度。将Plus看完应该能了解绝大部分,剩下的可以翻翻Prime,或者直接查阅相关资料,相信在那个时候查找资料已经不是问题了。
在这里统计一下各关键字在书中的出现位置。
- c++98的63个:
asm | else | CPP.6 | new | CPP.4 | this | CPP.10 | |
auto | CPP.3 | enum | CPP.4 | operator | CPP.11 | throw | CPP.15 |
bool | CPP.3 | explicit | CPP.11 | private | CPP.10 | true | CPP.3 |
break | CPP.6 | export | protected | CPP.14 | try | CPP.15 | |
case | CPP.6 | extern | CPP.9 | public | CPP.10 | typedef | CPP.5 |
catch | CPP.15 | false | CPP.3 | register | CPP.9 | typeid | CPP.15 |
char | CPP.3 | float | CPP.3 | reinterpet_cast | CPP.15 | typename | CPP.8 |
class | CPP.10 | for | CPP.5 | return | CPP.2 | union | CPP.4 |
const | CPP.3 | friend | CPP.11 | short | CPP.3 | unsigned | CPP.3 |
const_cast | CPP.15 | goto | signed | CPP.3 | using | CPP.2 | |
continue | CPP.6 | if | CPP.6 | sizeof | CPP.3 | virtual | CPP.13 |
default | CPP.6 CPP.18 | inline | CPP.8 | static | CPP.9 | void | CPP.2 |
delete | CPP.4 CPP.18 | int | CPP.3 | static_cast | CPP.15 | volatile | CPP.9 |
do | CPP.5 | long | CPP.3 | struct | CPP.4 | wchar_t | CPP.3 |
double | CPP.3 | mutable | CPP.9 | switch | CPP.6 | while | CPP.5 |
dynamic_cast | CPP.15 | namespace | CPP.2 | template | CPP.8 |
- c++11增补的:
alignas | CPP.18 | char32_t | CPP.3 | noexcept | CPP.15 | thread_local | CPP.9 |
alignof | CPP.18 | constexpr | CPP.9 | nullptr | CPP.12 | ||
char16_t | CPP.3 | decltype | CPP.8 | static_assert | CPP.18 |
auto的意义有改变。
register过时。
export暂无法使用。
- 具有特殊含义的标识符(这些不是关键字):
override | CPP.18 | final | CPP.18 |
学习摘要
在这里对一些重要的和自以为冷门的东西做一下摘要。
第1章
按照惯例,第一章花一定的篇幅啰嗦了一下c++的历史以及特点,虽然这些对于初学者来说毫无意义,反正也是不明所以。简介中简要提了一下面向对象与范型。这本书(第六版)介绍了一部分C++11的特性,主要以C++98为准,可以说是能够满足目前的需求了。
在这一章中,作者花了一定的篇幅说明了在不同的平台创建程序的方法,windows平台使用vs全家桶的用户是十分有必要看一下如何创建项目的。作者在文中说可以选择创建空项目,只是这样的话之后还要新建一个cpp文件。事实上,vs用户只要不在创建项目时勾选使用预编译头,就不会有什么大问题。
既然提到了,就解释一下预编译头。预编译头,也就是默认名字为stdafx(vc6.0下文件名有所不一样)的一对文件,是vs推出的用于在多次编译时提高编译速度的东西。原理就是当包含的文件没有变动时,这些文件可以不用重新编译。为了确保该机制的有效性,有两个基本条件:第一个是预编译头内包含的库文件必须是很少变动的;第二个是这个头文件在所有其它源文件中都最先引入(通俗说就是写在文件顶部)。对于第一点,不用说明,第二点需要解释一下。编译器在引入头文件是按从上至下的顺序,如果将预编译头文件放在下方,可能会被之前引入的文件影响(比如说前面的文件中有个宏定义WIN32_LEAN_AND_MEAN,预编译头中并没有但是包含了windows.h),这样的话,在这个文件中,预编译头就废了。不过可以在设置中对特定文件取消预编译头。
总而言之,vs用户在看这本书时,就不要使用预编译头了,这对学习c++语法没有任何帮助。此外,知道IDE的相关按钮(编译、链接、生成、调试)的位置以及这些按钮的含义,还有如何让黑框不一闪而过,就是这一章的重点了。对于其它平台的用户,本来就是在黑框中生成和运行,不会遇到win平台的一系列坑,这一章就显得不那么重要了。
第2章
这一章就进入正题了,通过这一章,可以了解c++代码的主要要素,知道程序的入口点等等。在这里,强调一下在抄代码时不要抄错单词,不要抄漏标点,以及不要使用中文的标点符号。这里要重新提一下这本书的优势:只要完整抄一遍代码,程序就能跑起来。当然,书中有些代码有错误,这一章就有,不知道是原版还是翻译的问题。
最后一个注意点,要找到编译器的报错信息,并从一开始就试着根据错误原因改正代码中的错误。上一章有提到,在改错时需要从上往下改。对于vs,错误的地方一般用波浪线标出,在信息栏中点击(双击?)错误会转到相应的代码处。在程序不是太复杂时,不太可能在对的地方出现波浪线。由于编译器在发现一个错误后就不会继续分析当前代码块的后续部分,在改正一个错误后,可能错误输反而变多了,这是不要以为刚刚改错了,只要错误的位置在所改正代码的下方就表明之前的修改没有问题。
在第一段代码中,含有头文件、标准输入输出、运算符、命名空间、注释等元素。这些都做了比较详细的说明。在后续部分,作者简要介绍了一下类的概念并花大量篇幅描述了函数的基本使用。下面重复一下重点。
-
main函数说到底也是一个函数,和别的函数没什么区别。并且main不是关键字,也就是说可以用作变量名。
-
以#开头的指令称为预处理器指令,这些指令是编译器在编译代码前处理的,根据这些指令先源文件进行处理,然后进行编译。处理后的代码不再包含这些语句。
在这里,include命令是最基本的一个指令,用于将所涉及头文件的内容添加至当前文件中。所有的函数和变量必须声明后才能使用,在代码中使用了变量cin、运算符函数«、成员函数cin.get等等,这些都没有在我们的代码中没有声明,我们能够直接使用就是因为它们被声明在文件iostream中,当我们使用#include指令包含该文件后,在编译前,编译器将iostream中的代码复制到了我们的代码文件中,这样在编译时所有的变量和函数都有出处了。
除了include指令,以后会接触很多实用的指令,用来给开发提供便利。
这些指令和关键字没有任何关系。
-
在有的教材中,命名空间语句会跟随在include指令之后,给人一种只能这么写的错觉。在这本书中,作者将using语句放在main函数中,体现了using语句也遵循作用域的概念。文中也提到了可以只简化某个特定成员的名称空间:
using std::cout;
-
关于运算符«以及»,不可将它们与cout和cin绑定,可以将它们看作一种特殊的成员函数,左右两个变量为参与者。有的人会死记箭头的方向,这就是吃力不讨好。
举个例子,一件包裹存在发送方(Source)和接收方(Destination),在这里,包裹就是信息,cin属于发送方,cout属于接收方,运算符«和»都方向表示信息的流向,因此这里变量与运算符恰好能凑成两对,就给了人一种错觉。
这时,有的人就会想有没有如下操作:
int val = 0; val >> std::cout; val << std::cin; std::cin >> std::cout;
以上都是不可行的。cin与cout都只能放在左边。
之前说过,可以将«和»当成函数,和main函数不同,这个函数需要使用两个参数,分别是符号左边的和符号右边的。在不同的参数下,所对应的函数并不是同一个(就好像人洗澡和猫洗澡,虽然都是洗澡,但两者是不一样的),以后就会知道c++将这种特性称为重载。就算是同样的参数,在不同的顺序下,也会被看作不同的函数。所以说,上述代码虽然只是换了下顺序,在逻辑上能够解释的通,但是在c++中,它们是完全不同的函数(参数不一样)。C++在遵守规则方面很是严格。
可以试着编译代码,发现错误为找不到与之对应的函数声明。在iostream中,正过来写能过匹配到函数,反过来写则匹配不到。对于最后一句,cout(ostream) 不包含在»能够接受的对象中,最终原因也是没有对应的函数。
如果真的想实现上面的语句,可以把cin与cout魔改一下,将所需的函数添加进去。只是这样cin也就不能算是原来的cin了,不在目前的讨论范畴之内。
在这一章中,出现了如下关键字:return,void,using,namespace。此外int以及double在第三章中会有更正式的介绍。
第3章
0. 总览
介绍了c++中的基本数据类型。
变量的起名规则并不复杂,但是开发团队为了维持代码的一致性和提高可读性,通常会对变量名称有额外的规定。
此外介绍了运算符以及优先级,在不确定优先级时多加几个括号就行,这边唯一需要注意的是除法的自动整除。
最后是类型转换,为了减少警告,还是多多使用显式强制转换吧。
这一章涉及到的关键字虽然多,但都不复杂。
1. 基本类型
c++中基本类型的名称都是关键字或由多个关键字(unsigned int)组成。这些关键字包括:
- 布偶:bool(true,false)
- 浮点数:double,float
- 整数:int,long,short
- 字符:char,wchar_t。在C++11中添加了两个:char16_t和char32_t。
- 符号(作用于整数和字符):signed,unsigned
2. sizeof关键字和climits库文件
在不同的系统位数下,这些基本类型占据的空间(字节数)可能有所不同。在程序中可以使用sizeof(这也是一个关键字)指令获取当前类型或变量的长度。一般来说,sizeof在编译时确定,可以当成常量使用。
对于整数类型的范围,可以查询头文件climits中的常量。此文件也在标准库中。
在这个文件中,我们遇到了另一个预处理器命令define。define命令是一个替换指令,并且可以带参替换,用途还是很广的。
3. 初始化
在声明变量时,如果没有进行初始化,变量的值为内存中现存的值,而不是0,所以必须及时初始化。
对于变量的初始化,以下两种方法在实现时是一样的:
class A;
A val1 = 1;
A val2(1);
上面那句虽然用的赋值,编译器在编译时同样会调用拷贝构造函数而不是等于的重载。对于基本变量,两者本身没有区别。
C++11增加了使用大括号初始化单值变量(43页)。
4. 进制相关
- 所有的数据,在计算机中都是以二进制的形式存储的。
- 在源代码中书写整数数值时,可以使用多种进制。这只是方便阅读,不影响存储的数据。不同数据的书写方法如下:
- 二进制(加前缀0b):0b10000
- 八进制(加前缀0):020
- 十进制(没有前缀):16
- 十六进制(加前缀0x):0x10
- 在输出时,可以使用cout的控制符dec、hex、oct改变数据的显示进制。
5. 字符
书中提到了编码表,并给出了字符与整数互转的范例,以及常用的转义指令。看完这些,对于字符的本质应该不会有什么疑问了。
事实上,数值(包括整数和小数)在显示时首先通过一系列函数将其转化为了字符串。在C语言中,输出函数为printf,可以明确看到格式化的过程。前面提到了«实际上是函数,C++使用了输入输出流,看起来没有进行任何转化,正是通过重载插入符号«的方式适配了各种类型的数据。对于输入同样如此。如果在输入时变量的类型与实际输入的内容不符,程序可能会崩溃。
在关键字中,包括增补的,共有4个关键字表示字符类型。其中char是最基本的,长度为一个字节。wchar_t占用的字节数不固定,为了解决这个问题,C++11中增加了char16_t和char32_t,分别为2字节和4字节。编码等问题暂且放一边。
char是否有符号由编译器决定。在通信中,往往使用字符串处理数据,这时就必须指定使用signed char或unsigned char,否则在读取数据时会出现错误。
6. const关键字
使用了该关键字的变量在初始化后就不能修改,当它做用于非指针类变量的声明时,不会产生歧义。但是将它用于指针变量时,就需要考虑修饰的位置。另外,const还可以修饰函数的参数和返回值。这些内容会在第七章与第八章得到详细的说明,这里就先不提钱总结。
作者在这里推荐使用const代替#define定义常量。但是const常量不是常数,无法用于静态数组的声明。
7. 浮点数
别的没什么好说的,浮点数的精度是相对精度,浮点数的数值越大,相邻两个可表示数字的差值越大。如果需要表示大数而对精度要求很高,浮点数就不太适合。
浮点数的范围等信息可参考标准库中的cfloat文件。
8. 类型转换
那些自动转换就不提了,提一下强制转换。先举个例子:
double dval;
int ival;
ival = (int)dval;
ival = int(dval);
ival = static_cast<int>(dval);
第一种是C风格,第二种是C++风格,第三种使用了运算符。这里推荐使用第三种。
在进行强制转换时,编译器无法检测数据是否超出范围,需要自己注意。
9. auto关键字
这里指的是C++11中的新用法。但是还是不建议使用。
由auto声明的变量在编译期确定其类型,对运行没有影响,可以说是一个语法糖。为了确定它的数据类型,变量必须在声明时被初始化。
在第8章中介绍了将auto与后置返回类型一起使用的情况。
第4章
这一章讲了数组和动态分配内存。字符串说到底就是数组。然后是不含成员函数的结构体,比较冷门的共用体。最后是stl中的模版类。
1. 数组的初始化
先写个例子:
int items1[5] = {1};
int items2[5] {};
char str1[] = "string";
char str1[] {"string"};
第一句为传统的初始化方法,如果数据少于定义的元素数,剩余的元素会被初始化为0。第二句使用了两个C++11的特性,一个是初始化时可省略等号,另一个是大括号内可以为空,即将所有元素初始化为0。说到底也是语法糖。
第三句说明在初始化时可以由编译器自动推断长度。这一般用于字符串常量,在其他情况下并不推荐。第四句同样是C++11规范新增的初始化方式。
在声明数组时如果没有进行初始化,编译器并不会将数组自动初始化为0。
2. 拼接字符串常量
这只是方便阅读,不管是连着写还是分段写,对程序来说是一样的。
3. cin的输入函数
使用操作符»输入时,遇到空格就会结束,这在输入数值时很管用,但是在输入字符串时就显得很碍事了。因此在输入字符串时可以使用cin.getline或cin.get。函数getline默认读取一整行,并将换行符从缓冲区中清除。函数get在不带参数时的作用时读取缓冲区中的第一个字符,在参数为字符串并且长度足够时功能与getline相似只是会将换行符保留在缓冲区中。操作符同样会将换行符保留在缓冲区中。因此在混合使用这些输入函数时,需要注意缓冲区中遗留的换行符。
操作符»具有返回值,因而可以和连等一样连续使用»。这个返回值同样是istream类型因而可以直接使用它的成员函数,从而代码可以这样写:
(cin >> ival).get();
4. 标准库string
和传统的char字符串相比,这个类型(string)重载了更多的运算符,用起来更方便。毕竟前者也能算是c++的基本类型,只需要并且只能够实现语言规范中的功能,而后者是一个类,是对前者的包装,本意就是方便人们使用。
在iostream库中并没有与string相关的函数,因此函数cin.getline等无法传入string类型的参数。使用操作符«和»还是可以操作string类型的,因为这些函数在string库文件中得到了补充,这用到了类的友元特性。另外也可以使用STD空间下非成员函数的getline()。
库文件stdio.h中的printf函数更古老,也是不支持string类型的。
5. 关键字struct
这种关键字用于创建一种自定义的复合类型,书中在这个位置并没有在结构中放入函数,这是后面的内容。在看书前,我并不知道结构也能初始化,还有一些别的东西。下面先看一个例子:
#include <iostream>
#include <string>
int main() {
// defination
struct structBOX1{
// values
int ival;
char chstr[12];
// function
~structBOX1() {}
void show(){
using namespace std;
cout << ival << "\t"
<< chstr << "\t"
<< str << "\t"
<< dval << endl;
}
// values
std::string str;
double dval;
} item0 = {};
// rename
typedef struct structBOX1 sBox1;
// statement
struct structBOX1 item1; // 1
sBox1 item2; // 2
structBOX1 item3; // 3
sBox1 itemlist[3] = {
{},
{2, "chbox2", "sbox2", 2.2}
};
using namespace std;
cout << "Size of struct: " << sizeof(struct structBOX1) << endl;
cout << "Size of item: " << sizeof(itemlist[1]) << endl;
return 0;
}
-
结构可以放在函数内声明,这样的话这个结构体只能在当前函数使用。一般不会这么做。
-
如果结构体没有定义任何构造函数,这个结构体可以使用默认的初始化(使用大括号的初始化方法),否则只能使用构造函数初始化。结构体中的析构函数以及其它函数不受此影响。
-
使用默认初始化时,变量的顺序(从左到右)必须和定义的顺序(从上到下)一一对应。在结构体的定义中,变量的声明可以隔开。不管在写的时候多么凌乱,编译器最终还是会把变量放在一块,即放在一块连续的变量中。结构体的初始化规则与基本变量相同,只是不能省略等于号(书中说可以,但是在g++中报错)。
-
对于结构体也可以使用sizeof获取占用空间的大小。在结构体内有字符串或布偶值的情况下,结构体的总大小可能会大于各变量大小之和。
这是因为不同的类型对内存地址的起始值有不同的对齐要求,例如int是4对齐,double是8对齐。上面的例子正好可以做到对齐(string的大小为24),因此大小正好为48没有浪费空间。
可以使用关键字alignof获取变量的对齐方式,说明见附录E.4。
-
对于结构体变量的声明,可以直接在定义后声明并初始化,也可以随后声明。语句1是C中的语法,语句2使用了重命名的标识符声明这样可以不用写struct。c++对规则进行了优化,可以直接使用结构体定义的名字声明而不需要额外使用typedef重命名,语句3使用了这种方法。
-
在C语言中,可以将重命名与定义合并起来,这种情况下无法在定义的同时声明变量:
typedef struct structBOX2 { }sBox2;
-
也可以省略定义的名字:
struct { }item4; typedef struct { } sBox3; sBox3 item5;
在这种情况下,如果没有重命名,只能在定义结构体时声明变量,使用typedef可以解决这个问题。
但是这样做会有一个更大的问题:由于结构体没有名字,我们就没办法声明构造函数和析构函数,普通函数也只能将实现放在类中。
-
关于结构体中的位字段,测试的时候还是有些疑问。
6. 关键字union
这个关键字用来创建共用体,第一次见到这种用法是在socket编程中。平时很少使用这种特性。随便举个例子:
#include <iostream>
int main() {
struct sBox1 {
union {
int opt;
struct {
char ch[4];
}vals;
};
};
sBox1 item1 = {0x00313233};
using namespace std;
cout << item1.opt << endl;
cout << item1.vals.ch << endl;
return 0;
}
这边有个和章节内容无关的注意点:在g++编译的程序中,int类型在存储的时候是低位在前的,在有的系统中是高位在前。因此union,能不用就不用吧。
7. 关键字enum
这个关键字创建枚举类型,没什么好说的。
8. 关键字new和delete
这用于给指针分配和取消分配内存。在这里有两个运算符:取地址&和取值*。数组的实现原理就是指针。在这一章里,指针也没什么好说的。指针分为指向变量的和指向函数的,这些在后面会分别提到。
程序在运行时,所占据的内存被分为多个区域,包括栈区和堆区。直接声明的变量存储在栈区,动态创建的变量存储在堆区。栈区容量比较小,当需要用到大数组时,应当动态创建。
指针虽然相对比较难理解,但冷门的知识点并不多。
9. 模版类vector和array
vector是动态数组的升级版,array是数组的升级版。两者都是类,包含很多成员函数,通过调用函数可以在越界时抛出异常。
第5章
这一章介绍了循环,分别是for循环,while循环,do-while循环,很基础的东西。
在149页比较了#define与typedef关键字在建立别名时的区别。
C++11对for循环做了补充,书上的例子(5.4)足够了。(用于迭代器?)
第6章
这一章介绍了条件语句:
- if-else系列
- switch-case-default系列
- 循环中使用break跳出或continue跳过
- 三元运算符?:
比较冷门的内容就是字符函数库cctype。
作者把文件的读写放到了这一章中,却没有介绍goto,是因为实在是不推荐么。
第7章
这章开始详细地讲函数了,这还只是上半部分。在前面,已经一直在用函数了,基本概念只需要巩固一下。这一章的重点是函数的参数,在文中穿插了一些小窍门。文中将函数的声明翻译成了原形,这并没有什么大碍。在这一章中,没有涉及到引用的概念。
这一章对指针做了进一步的介绍,并且详细阐述了做用于指针变量上的const关键字的正确用法。
int ival1 = 1;
const int *ptri1;
int *const ptri2 = &ival1;
const int *const ptri3 = &ival1;
第一条语句中,const修饰int,表示指针本身不是常量,但是指向的整型数据为常量,这主要用于防止数据被篡改。第二条语句中,const修饰星号,表示指针为常量必须初始化,但是可以修改数据。第三条语句使用了两个const,表示两者皆不可修改。
函数的返回值也可以使用const修饰,特别是在返回指针时能够使外部代码无法修改内容。
多维数组实际上是定制化的多级指针。
这一章的重点是函数指针。函数指针的用途很广泛,是回调函数的根基。对于计算机而言,函数和变量都是数据,在程序运行时,表示函数的数据也存放在内存中,因此也会有一个地址。之前我们都是在写代码时就决定了程序运行时该调用的函数。但是如果我们的代码中有一个函数不是由我们维护,我们又不愿意把代码给别人修改,那就只能使用函数指针了。和变量指针一样,函数指针也是保存了一个地址,这个地址指向一个函数。在前面的需求中,对方可以将他们代码中的函数地址传递给我们的代码,这样就能在双方不操作对方代码的情况下让程序运行起来了。
和变量指针相同,函数指针也是在正常的函数声明中加一个星号表示这是一个指针:
typename functionname(parameterlist); // 函数原型(声明)
typename (*pfunctionname)(parameterlist); // 函数指针原型(声明)
这里必须加括号,因为星号的优先级非常低,不加括号就会自动和前面的返回值类型合并。可以看到在声明函数指针时必须将函数原型写一遍,而且由于变量的位置在中间,在阅读上非常别扭。如果需要声明一个指针数组,这种写法很容易犯错。因而可以先用typefef给该指针原型创建一个别名:
typedef typename (*prename)(parameterlist);
prename pfunctionname;
在通过函数指针调用函数时,存在两种可行的写法:
(*pfun)(...);
pfun(...);
换句话说,可以直接调用,也可以解引用后调用。在我看来,解引用后调用能使代码更清晰。
第8章
这一章进一步探讨了函数,介绍了函数的高级用法。
1. 关键字inline
使用这个关键字修饰的函数可能变成内联函数。这个只对编译阶段产生影响。正常情况下,在一个程序中,每个函数都拥有一段独立的代码并有一个入口地址。内联函数并不存在独立的代码,而是将整段代码一一插入到了所有调用到它的代码段。可以将其理解为在写代码时,你并没有写这个函数,所有用到它的地方都直接复制了整个函数代码,换句话说,内联函数并不是一个函数了。
因此,如果这段函数很短,使用内联方法会比调用单独的代码段更有效率。之所以说可能,是因为当代码比较复杂时,编译器会自作主张不让它以内联方式编译。
2. 形参、实参与引用
关于这部分内容,这本书已经讲得很清楚了。很多人有这么一个困扰:
void fun1(int *val);
void fun2(int &val);
这两个都可以改变值的内容,特别是在学了数组之后,会找不到这两者的区别。事实上,int*是一种类型,而引用(&)是一种方法,两个函数的参数类型是不同的。前者是形参,只是人家复制的内容是地址,值可以修改,但是这个地址不可能修改了被带到外面去。
在使用实参时,如果目的是减少复制而不是修改数据,应添加const修饰符,在这种情况下一方面能够保护数据,另一方面能够传入其它类型的参数(编译器会进行强制转换)。
函数的返回值也可以是引用,考虑到变量的生存周期,不可以返回临时变量的引用。
3. 右值引用
这是C++11的特性,这一章8.2.4前面有简单的介绍,详细说明在第18章。
4. 默认参数
规则要求必须从右到左添加参数值,即从第一个带有默认值的参数到最后一个参数,都必须带有默认值。这是为了避免歧义。
5. 函数重载
这个特性就是一直使用的cout«能够接受不同参数类型的原因。函数重载仅针对函数的参数,如果两个同名函数具有相同的参数只是返回值不同,这样的操作是不被允许的。
const与非const,&与&&,都是可以共存的。书8.4.1前方有说明。
6. 函数模版
模版是c++中很受欢迎的东西,加上后面的模版类,大大提高了我们的效率。这边多了个范型的概念,学过数学的应该很快就能理解。通过使用模版,我们可以让一个函数适配不同的参数类型,只要这些类型能够完成函数中的操作。
将其称之为模版,是因为在编译时,编译器会对每一种参数类型实例化一个单独的函数。换句话说,在写代码时,我们运用了同一个函数模版;在运行时,程序根据参数调用了的多种函数实例。因此如果使用的参数无法完成函数中的操作,编译器能够发现错误。
模版需要使用到两个关键字:template和typename。前者用来表明我们编写的是一个模版,后者用于给模版添加范型。这段额外的说明在函数的原型和定义中都需要存在。
函数模版在使用时和正常函数一样,编译器会根据参数类型自动适配函数实例。
函数模版也能重载,因为这样做并不会与现有规则冲突。
为了让该函数名能够被不受支持的变量类型调用,一方面可以重载该变量的一些函数使其能够使用函数模版,另一方面可以同时编写它的非模版函数或将模版函数显式具体化(手动实现模版函数对于这个变量的实现),见章节8.5.3。
实例化(显式和隐式)表示使用函数模版生成实例,具体化指使特供的代码生成实例。一个函数模版对于同一种范型不能同时显式实例化和具体化。
在C++11标准中,添加了关键字decltype用于让编译器在编译阶段自动推断表达式的返回值类型。这大大提高了模版函数的适用范围。可以使用typedef给decltype表达式取别名。
另外,可以将模版函数的返回值置为auto,并使用后置返回类型(通过箭头标记->),以解决由于作用域的逻辑返回类型无法使用decltype自动推断的问题。
第9章
这一章讲的内容和本书前面的内容基本无关,介绍了使用多个文件组织代码的方法。如果是初学者,可能会觉得这一章的内容非常复杂。但是从下一章开始,代码量将会爆炸,将代码模块化会变成常规操作。这一章全是干货。
1. 头文件与原文件与引用
在使用多个文件时,不能将同一个变量多次包含。在c++中有两种最基本的后缀:h和cpp,约定俗成的规矩就是h文件内包含函数原型、模版等内容,变量只能放在cpp文件中。为了提高代码的可读性,或者保护代码,函数定义等内容通常被放在cpp文件中,头文件仅含有必要的原型和声明。对于头文件,可通过#ifdef命令保证只被包含一次。
尽量不要让两个头文件互相包含。在一个文件中,我们知道变量或类型要声明后使用。在头文件中也是如此,两个头文件互相包含时,在编写类时很容易犯下互相引用的错误,导致编译器无法编译,这部分内容从下一章开始讲。
2. 变量的生存周期与作用域
在c++中,数据的生存周期有几种:自动存储,静态存储,线程存储,动态存储,还有罕见的寄存器存储。前三种是栈存储,由程序自动管理生存周期。动态存储就是之前提到的堆存储,由new和delete管理。寄存器存储直接存放在寄存器中,这在下面单独介绍。
自动存储的生命周期为当前代码段,换句话说是当前大括号内。静态存储适用于全局变量(不管是否使用static修饰),生存周期为程序的整个运行阶段。线程存储的生存周期为当前线程,使用关键词thread_local修饰,在本文中没有做介绍。
任何有效的变量都带有作用域,只有在作用域内的代码能够操作变量。比如在函数内创建的变量只能被当前函数操作,就算是同一个函数的不同副本也不行(单线程下同时只会有一条语句被执行,不存在这种问题,多线程不在c++规范的范畴,它由平台提供,这本书没有做介绍)。
静态存储变量有多种作用域,书中称之为链接性。如果它在函数内以static的形式(没有的话就是自动变量了)声明,作用域和自动存储变量一样,这叫无链接性。当它是全局变量时,如果使用了关键字static,作用域就是当前文件,因为其他文件中的代码无法看到这个变量的定义,这叫内部链接性。反之,没有使用static修饰就具有外部链接性,其它文件在引用时使用extern关键字修饰声明语句表示引用。静态存储的生存周期与链接性无关。
书中表9.1总结了这些内容,很清晰。无链接性的静态存储在以后会很常用。
C++11中添加了关键字constexpr,表示常量表达式,书中这部分没有介绍。
下面列举一些细节性的东西:
- 不同文件中,两个具有外部链接性的静态存储变量不能重名。(单定义规则)
- 可以不带前缀使用域解析运算符(::)表明使用全局变量。
- 使用const修饰的全局变量默认具有内部链接性,也就是说可以将它直接放到头文件中而不用担心重定义。可使用extern显示表明使用其它文件的const类型变量,使其改为外部链接性。
3. 关键字register
本书对于这部分内容是一笔带过,可能是因为使用率太低。
如果变量在声明时使用register关键字修饰,编译器会优先将它存放到处理器的寄存器中。堆区和栈区的变量在被使用时才会被放到寄存器中,使用完后就会被移出,因此直接存放在寄存器会提高效率。只是寄存器数量有限,并不是用了register就会如愿,这一点和inline类似但有区别。相同点是两者都由编译器做决定。不同之处在于register一定会提高效率,限制是硬性的;而inline并不一定会提高效率,强行实现说不定反而拖后腿。
4. 关键字volatile以及mutable
const和volatile统称为cv-限定符。编译器在编译时会对代码进行优化,比如:
// 示例1
int val;
val = 1;
val = 2;
// 优化后
int val = 2;
// 示例2
for (int i = 0; i < 10; i++);
// 优化后
int i = 10;
这种优化在正常情况下不会有问题,但是在操作硬件时,比如控制一个GPIO引脚,代码一旦被优化,软件就无法正确操作硬件了。关键字volatile就是用来让编译器对所修饰的变量停止优化。
关键字mutable用在结构中,在结构被声明为常量const的情况下,结构内使用mutable修饰的变量仍然保持可写的属性。
5. 函数的生存周期和作用域
无法在函数中定义函数,函数都属于全局存储。函数默认具有外部链接性,可使用static将函数的链接性设置为内部。
6. 语言的链接性
c和c++在编译代码时对函数的命名规则不同,当需要同时使用c和c++编译的文件时,需要指定库文件的语言类型。
7. new运算符
可以通过包含头文件new实现一些高级功能,比如使用现有的数组空间给指针分配内存。
在c语言中使用函数malloc实现new的功能。
运算符new在分配内存失败时会抛出std::bad_alloc异常,本书在15章有说明。
8. 名称空间
就是之前使用的namespace,可以嵌套。定义时通过namespace。使用时通过域解析运算符(::)并配合using指令。这部分内容很简单。
第10章
从这一章开始,本书开始介绍c++比c多出来的东西:类。前面的内容或多或少提到了一些概念,当时可能没有注意。这一章主要介绍类的基本概念以及使用成员变量、成员函数的方法。接下来的几章将逐个介绍类的特性。
在章节的开头,作者通过举例解释了类的设计思想。由于我并不是初学者,看不出来是不是真的易于理解。不过书中的例子还是很好的。
之前已经介绍过了结构体struct,在c语言中,结构体是不能包含函数的。在c++中,我们可以将结构体看成是简化版的类。结构体中的成员默认为公有public,而类中的成员默认为私有private。和结构体一样,类的每一个对象都有一块单独的内存存放成员变量,类中的函数存放在函数区域。
在第8章提到了内联函数,这对类中的函数同样适用。在类中,除了显式定义内联函数,对于直接定义在类声明中的函数,会自动被当成内联函数。
和普通函数相比,类函数在使用成员变量时不需要将其作为参数传入函数中(在语言层面是这样),并且在类作用域中不用担心名称污染。因此在程序中应多使用类以增强逻辑。
以const修饰的类对象只能调用以const修饰的成员函数,改修饰符放在函数末尾(分号之前)以表示修饰的是函数而不是变量。
初始化
我们知道struct在没有构造函数时可以使用默认初始化,而类并不支持这种初始化。当我们没有提供构造函数时,编译器会生成默认构造函数,需要注意的是默认构造函数并不将成员变量初始化为0。类的构造函数能够重载以支持不同形式的初始化。另外,只要定义了构造函数,默认构造函数就不存在。如果编译器支持C++11,可以使用结构体的初始化形式提供初始化参数,但是程序在执行时同样是调用构造函数,这只是在形式上做了统一。
构造函数和析构函数需要是公有的。
this指针
有的书里把this放到很后面去讲,这就有点奇怪了,这个指针解决了一个大问题。我们知道获取一个变量的地址只需要取引用,获取一个类变量的地址也可以这样做。但是在成员函数中,不存在这么一个变量让我们取引用(你在变量的里面,然而你看不到它),但是获取自身的地址又是一个迫切的需求。这时,this解决了我们的燃眉之急。可将this理解为类的一个隐藏变量,它永远表示自身的地址。
类成员运算符
在前面的章节中,在介绍struct时,没有介绍如何使用类指针变量获取成员变量。在这一方面,struct和class一致。获取变量的成员时使用直接成员运算符(.),获取指针变量的成员时使用间接成员运算符(->),不通过变量获取static成员时可使用作用域解析运算符(::)。这几个名称我也是第一次知道。
static常量
这种类型的成员变量不存放在类对象中,它和其它static变量存放在一起,就算这个类没有定义变量,它也存在。这个类的所有实例对象共享该static变量。
作用域内枚举
这是C++11新特性。以往的枚举变量属于全局变量,除非将它限定在命名空间中。现在可以使用class修饰符指定作用域名称。枚举的底层类型为int。
第11章
这一章介绍了类函数重载的特性,包括运算符的重载,以及使用重载函数时必然会遇到的类型转换方面的知识。
在介绍新内容前,重申一下与函数有关的几个小技巧:
- 对于未修改成员变量的函数应添加const修饰
- 对于返回值非临时变量的函数应返回引用,优先添加常量运算符
- 函数传入复杂参数(比如类对象)时应采用引用的方式,优先添加常量运算符
认真看书的话可以发现书中的例子基本遵循上述规则。
1. 运算符重载(operator)
绝大部分运算符都能重载,表11.1将它们都列举出来了。逻辑、数学运算符(除了那个三元的)都能被重载。除此之外还有几个特殊的运算符:
-
[]:下标运算符
放在第一个是一个在特殊的里面只有它我是见过的,用在JSON库里面。
-
():函数调用运算符
这个在包装器中很管用,可以参考16.5和18.5。
- ->、->*:间接访问运算符
- =:赋值运算符
以上四个只能以成员函数的形式重载
- ,:逗号也能算运算符吗
- new、delete、new[]、delete[]一家子
- «、»:这个原本是位运算符,但是在cout和cin中有了另一种意思。
书中也列举了不能重载的运算符,这个比能重载的要少多了,记起来比较方便:
-
sizeof、typeid以及四个强制类型转换运算符
这些竟然也算是运算符
-
.:成员运算符;.*:成员指针运算符
这边要注意间接成员运算符(->)以及间接成员指针运算符(->*)能够被重载
-
:::作用域解析运算符
-
?::条件运算符(唯一的三元运算符)
虽然没有规定,但是重载时最好保持运算符原有的意义。
对于运算符++和–,由于有两种情境,因此规定不带参数的重载为前缀版本,带参数int的重载为后缀版本,参数只是标记没有实际用途。
2. 友元函数(friend)
友元分为友元函数,友元类,友元成员函数,后两个在第15章。把友元函数拿到这是因为它是运算符重载的极大补充,原因书里说了。
- 友元函数的作用是能够访问对象的私有成员。
- 友元函数在类中声明,但它是非成员函数,不能重载上面提到的几个特殊的运算符在定义时也不需要带上类域。
- 在重载运算符时要将左右两个参数都写在参数列表中。
最常见的应用场景是使用友元函数重载插入运算符(«)用于控制台输出(cout类型为ostream)。
3. 类型转换与explicit
只有一个参数的构造函数可以在隐式类型转换中被使用,当这个构造函数被关键字explicit修饰时,它只能被显式调用。
可以使用operator重载转换函数实现将类类型到基本类型的转换(11.6.1)。转换函数的对象就是自身,所以不能有参数。另外,要转换的类型已在重载语句中说明,因此声明中不需要写返回值,在定义中需要return所需的类型。使用方法如下:
operator typeName();
将类转换为基本类型会丢失很多信息,一般不要这么做。
第12章
这一章描述了在类中使用new分配内存的情况以及在这种情况下的注意事项。换句话说就是将浅复制变为深复制。
在小节12.1.2的开头作者列举了编译器自动帮我们定义的一些函数。在构造函数中,除了默认构造函数,复制构造函数是另一个用得最多的函数,这也是在使用动态内存时出现错误的根源。
在正常情况下,我们通过显示构造创建对象,并通过析构函数销毁对象,这一切看上去都没有问题。只是当我们需要将变量以非引用的方式传入函数时,为了创建形参,程序会先通过拷贝构造函数生成一个副本,并在函数结束时销毁它。这里并没有新的对象被创建,但最终销毁了一个对象,程序就出问题了。
除了拷贝构造函数,赋值运算也有同样的问题。因此,如果需要使用带有动态内存分配的类,在不会进行赋值操作并且保证传参全用引用的方式时,可以不重载这两个函数。只是这是很难保证的,所以还是乖乖多写几行,当成保险吧。
指针和nullptr
在12.2.1的末尾,作者介绍了空指针的变化。在C++11中,关键字nullptr表示空指针,在之前使用宏定义NULL表示,两者在数值上都是0。
delete运算符在实现中具有对空指针的判断,因此空指针也可以大胆使用delete。只要在初始化和delete后将指针置为空,就不会有问题。重要的还是在销毁后及时置为空,不然神仙也救不了。
静态类成员函数
之前讲过静态变量,静态函数,现在在类中多了一个静态类函数。声明静态类函数需要在函数声明时包含关键字static,在定义时不需要。
根据静态的概念,这个函数除了还具有类作用域,就和类没什么关系了。它不能被类对象调用,只能通过域运算符调用(它的存在与类对象无关了)。此外,它没有this指针,也不能操作类中的非静态变量,结合上一点这是显而易见的。
静态成员函数一般结合传递this指针用在线程中。
显式调用析构
根据章节12.5.3,在使用现有的缓冲区创建对象,即使用定位new运算符时,由于实际上没有增加新的内存,不能够使用delete析构对象。在这种情况下需要手动调用析构函数。
构造函数的初始化列表
在构造函数中初始化属于赋值,这时是不能给常量以及类中包含的类型赋值的。在进构造函数之前,有一个真正的初始化阶段,初始化列表就用于这个阶段。这个阶段就和正常的初始化基本类型一个性质。
初始化语句如下:
className::className(agruments...):
argument1(typeName val1),
argument2(typeName val3) {
// normal initialize
}
初始化列表位于构造函数的参数之后,代码语句之前,以冒号开头,以逗号分隔。初始化列表比在构造函数中赋值更早,效率也更高,应优先选择。这种初始化方法在MFC中很常见。
- 使用初始化列表的初始化顺序取决于成员变量定义的顺序,而不是列表的顺序。
- C++11允许在成员变量被声明时提供初始化数值,这只是语法上的变化,它的实现还是初始化列表。
第13章
这一章介绍了类的公有继承以及虚函数。这部分内容是类里面的基础,几乎所有的书都会花大量篇幅介绍,而且也没什么难点。
一个容易被忽略的地方就是在使用多态时,基类需要使用虚析构函数(13.3最后)。当使用基类指针delete派生类时,如果不能调用派生类的析构函数,可能会出现严重错误。
纯虚函数多用于接口类中,如果一个类只含有纯虚函数且不包含成员函数,它就被称为纯虚类。
这一章简要地介绍了成员变量的保护属性,并推荐在多态中多使用具有保护属性的成员函数。
这一章最后对这几章的内容做了个总结,在13.9上面的表13.1列举了几个重要的成员函数的属性,表格里的大部分在一般情况下不会涉及到。
第14章
这一章对类继承做了更深入的介绍,此外介绍了类模版。
保护继承
书中表14.1总结了不同继承的情况:
公有继承 | 保护继承 | 私有继承 | |
---|---|---|---|
公有成员 | 公有成员 | 保护成员 | 私有成员 |
保护成员 | 保护成员 | 保护成员 | 私有成员 |
私有成员 | 无法访问 | 无法访问 | 无法访问 |
隐式向上转换 | 能 | 只能在派生类中 | 不能 |
在进行保护或私有继承后,如果想要在外面使用基类的方法,除了重新定义接口函数,可以在派生类的公有部分使用using语句声明基类的成员函数(14.2.4)。如果在派生类中具有同名称同参数的函数,基类中的相应函数还是会被覆盖。
多重继承
c++的类有一个罕见的特性:它支持多重继承。多重继承就是说可以有多个基类,这在java中是不被允许的。CEF的handler类就是通过多重继承实现自定义事件处理的。
在进行多重继承时,如果两个基类的基类中有相同的部分,在派生类中会造成二义的问题。为了使两个基类中的相同基类使用同一个副本,c++提出了虚基类的概念,注意这和上一章里面的纯虚类不是一个东西。使用方法就是在基类的基类声明中添加virtual关键字修饰,派生类引用基类时不需要做修改。虽然同样是使用virtual,但是这和虚函数不是一个意思。
在初始化时,对于虚基类需要在派生类中单独初始化。因为若是按照往常的规则,共享的虚基类会被多个基类初始化。
在多重继承中,如果基类有相同名称的函数,在使用时需要明确指出函数的来源以避免二义问题。
类模版
与函数模版类似,我们可以给整个类添加一个或多个模版参数。类模版同样支持具体化,包括显示具体化和部分具体化(只指定一部分参数的类型)。在实际使用时编译器会优先选择针对性更强(具体化程度最高)的版本。除此之外,可以给指针与非指针提供不同的版本。
章节14.4.7表示类模版可以嵌套包含别的类模版成员变量以及函数模版。这样做通常是为了分割功能模块。
类模版的参数除了类型(typename)参数和非类型参数,还可以是模版参数,即参数本身是一个模版。例子14.21展示了将Stack模版当作参数传入模版类Crab中,类中的成员变量位Stack的实例化。
类模版同样可以定义友元,根据友元函数使用模版参数的情况,可以将其分为三种:非模版友元,约束模版友元以及非约束模版友元。
- 本身不是模版函数的友元函数为非模版友元。这种函数在传入参数中含有对应的类模版变量,在代码中无法使用模版参数。
- 在类模版外拥有函数模版定义,在类模版内根据类模版的参数实例化的友元函数为约束模版友元,即类模版被实例化时该友元函数也能够被实例化。比如当友元函数的参数为int时,它只是类型A<int>的友元,并不是类型A<double>的友元函数。它可以使用类模版的参数。
- 友元函数的模版参数与类模版的参数不同时,被称为非约束模版友元,这种情况下当类模版被实例化时,友元函数还不能被确定。比如当友元函数的参数为int和double时,它同时是类型A<int>和类型A<double>的友元函数。
好吧,这部分还是有点奇怪。
模版别名
可以用using语句给模版取别名,这是C++11的内容。
第15章
前面几章拆开来讲了类的一些列特性,这一章的内容就有些复杂了。这一章讲了多个东西,它们互相没什么联系。
1. 友元
之前的章节介绍的都是友元函数,之前也说了友元还包括友元类和友元成员函数。友元类就是将整个类声明为友元,如此作为友元的类就能访问这个类的私有变量了。但是在大多数时候,我们不需要把整个类当成友元,这时只需要将类中的某些成员函数声明为友元成员函数就好了。从名字也可以知道,友元函数和友元成员函数的区别就在于一个是全局函数,一个是类成员函数。在用法上两者是一致的。
一个函数可以被两个或多个类设为友元函数。
2. 嵌套类
可以在类的声明中声明另一个类,这种声明受到类作用域和访问控制(public等)的影响。在之前一章有说过在类模版中嵌套定义其它类模版,在类模版中也可以定义正常的类,这个类继承类模版的参数。
3. 异常和noexcept
传统(c语言)的错误处理包括使用abort函数终止程序,返回错误代码(windows的api中很常见)。
c++比c多出来一种传递错误的机制。这个机制使用try关键字和大括号包含需要检测异常的代码块,在代码块中通过throw抛出异常,在在代码块之后使用catch接收类型符合的异常。和传统的异常相比,这种处理模式更加灵活。
- try代码块中调用的子函数也可以通过throw抛出异常,该可以传递到子函数之外直接被try捕获。如果调用含有throw的子函数时没有使用try捕获异常,并且异常发生了,异常会一直往上传递直到被系统的处理模块捕获。
- try在获得异常后将异常传递给与其对应的catch模块,如果catch模块中没有和异常类型相匹配的处理程序,异常会被继续向上传递。
- 在catch模块中可以使用throw继续向上层模块抛出异常。
- 通过throw抛出的异常在传入catch时会生成一份拷贝,不管是不是引用(15.3.7)。
- 可以使用省略号(…)表明捕获任何类型的异常,避免异常向上传递。
- 在使用动态内存分配的程序段中使用异常处理需要妥善解决变量内存释放的问题。
- 在模版中使用异常会有较大的不确定性,不同的模版参数类型可能会带来不同的异常。
关键字noexcept用于修饰函数,表明这个函数不会发生异常。
4. exception类
这是c++标准库提供的异常类,基类为expection。标准库中的其它内容在引发异常后会抛出派生自该基类的特定异常信号。
在新的标准下,当new申请内存失败时会抛出bad_alloc异常,它也是派生自expection。如果不想抛出异常而是抛出空指针,可以在new后添加nothrow或nowthrow(感觉这边是写错了,应该都是nothrow):
int *pi = new (std::nothrow) int;
int *pa = new (std::nowthrow) int[500];
也可以使用malloc分配内存,这时需要使用free释放内存。关于new和malloc的区别有很多,抛出异常与否只是其中的一个。
5. 处理未捕获异常
如果异常未被处理,程序会调用unexpcected()函数,在这函数里面会调用terminate()函数,然后程序就结束了。可通过函数set_unexpected(f)以及set_terminate(f)替换默认的处理函数。这两个函数的形式是一致的,没有参数并且没有返回:
typedef void (*handler)();
6. RTTI
意思是运行阶段类型识别(Runtime Type Identification),包含两种方法。这个内容以前完全没见过……
-
dynamic_cast运算符
这个运算符用于安全地对类指针进行多态转换,如果原指针是所需对象的基类,它会转换失败并返回空指针。注意这个指针只能用于多态指针的转换。
-
typeid运算符
它会返回一个对type_info对象(在头文件typeinfo中)的引用,可以通过对两个变量(或者一个变量和一个类型,两个类型没有意义)进行typeid运算并比较返回的对象判断两个变量的类型是否一致。
作者在文中说这种运算在实际使用中价值不大,应更多地使用虚函数,实在不行可以使用dynamic_cast。
7. 类型转换运算符 c++有4个类型转换运算符,它们都是关键字并以cast结尾。前面提到的dynamic_cast就是其中之一:
- dynamic_cast
- const_cast
- static_cast
- reinterpret_cast
const_cast用于去除指针或引用的cv修饰符(在第9章有提到),即去除变量的const和volatile属性。对于去除const属性,如果数据本身定义的时候是const,写入操作仍然为失败。当一个函数实际未修改传入的参数而参数未声明为const时,为了传入const类型的变量需要使用该强制转换。关于去除volatile修饰,书中并没有举例,不知到底有没有效果。
static_cast作用的对象是可以进行隐式转换的对象,这个运算符和c中的强制转换功能一致,只是统一类风格。
reinterpret_cast可以进行绝大部分类型的转换,不过也有一些限制。它不能删除const修饰符,不能在函数指针和数据指针间转换,不能将指针类型转换为字节更少的数据类型(比如char)。它通常用于static_cast不能转换的时候,因此使用时要格外注意。
第16章
这一章介绍了c++标准库中的一些常用内容。标准模版库(STL)现在是标准库的一部分。标准库很复杂,可以在看完这本书后单独看一下。
1. string
这个没什么好啰嗦的,它是一个类模版的char实例,用typedef进行了重命名而已。它的弟兄们有这些:
template<
class charT,
class traits = char_traits<charT>, // 书中多了个空格
class Allocator = allocator<charT>
>
basic_string {...};
typedef basic_string<char> string;
typedef basic_string<wchar_t> wstring;
typedef basic_string<char16_t> u16string;
typedef basic_string<char32_t> u32string;
类模版char_traits提供字符串的一些操作函数,类模版allocator提供内存管理功能,这两个参数默认由字符类型决定。
2. 智能指针
这个内容有些书中没有,但非常实用。在一些复杂模型中,有些数据或内容会被不同的模块使用,我们希望它能够在不被需要时被及时释放以避免内存泄露。为此有的软件自己实现了一套引用计数的功能。C++11对标准库中的智能指针模版进行了完善,能够满足大部分这方面的需求了。
智能指针包括老式的auto_ptr,新式的unique_ptr、shared_ptr和weak_ptr,它们都能够在过期时自动释放空间。weak_ptr有些不一样,书中没有介绍它。新的标准将auto_ptr根据使用场景细分为了两个智能指针,使代码更加安全,况且被废弃的东西可能在若干年后不不被支持了,因此还是使用新的吧。
智能指针的构造函数含有explicit修饰,因此不能通过隐式转换将普通指针变为智能指针。
如果需要将智能指针指向已有的数据,要切记不能将它指向非堆内存,因为非堆内存是自动变量不由delete负责销毁。
auto_ptr和shared_ptr只能由new分配内存,unique_ptr可以由new[]分配内存。
-
unique_ptr
它是auto_ptr的安全版,除了将一个临时右值的unique_ptr赋值给另一个unique_ptr,其它的直接赋值行为将无法通过编译,这避免了意外的悬空指针。
可以通过函数move(在头文件utility中)将一个unique_ptr指针转移给另一个变量,可以参考18章的移动构造函数。
可以以引用(实参)的方式将非临时的unique_ptr指针传递给函数使用。
-
shared_ptr
在有多个位置需要使用同一个变量时,使用该指针。
3. STL范型
这里面有很多内容,不过在用到的时候看一下就行了。容器和迭代器是非常实用的。附录G有总结。
在使用迭代器时可以通过auto自动计算迭代器的类型。另外,可以使用C++11新增的for的用法(在第5章也有介绍)或for_each函数作为代替。
迭代器有多种类型:
- 输入迭代器
- 输出迭代器
- 正向迭代器
- 双向迭代器
- 随机访问迭代器
容器的迭代器类型由容器的需求而定,并不是一个容器具有所有类型的迭代器。
除了以上的常规迭代器,还有其它迭代器:
- ostream_iterator、istream_iterator
- reverse_iterator
- back_insert_iterator、front_insert_iterator、insert_iterator
迭代器都是作用于容器的,一个容器可以存放一种对象的变量。容器的对象必须是可复制构造和可赋值。在C++11中添加了可复制插入和可移动插入的规则。
容器可以分为序列容器和关联容器,关联容器根据组织结构分为有序(树结构)和无序(哈希表)。
4. STL函数对象
包括函数名,函数指针以及重载()运算符的对象,可能更多地被成为函数符(functor)。
关于函数符,有一些概念。根据参数的数量,分为生成器(generator)、一元函数(unary function)、二元函数(binary function)。取随机数的函数rand()就是一个生成器。如果函数符的返回值为bool型,它被成为谓词(predicate)。
重载()运算符的对象在声明时会调用构造函数,之后就可以当作函数使用了。也可以在需要时构造一个临时对象。和普通的函数相比,这种函数对象拥有自己的作用域,在作用域内具有独立的变量。普通函数想要实现同样的功能只能以参数的形式传入数值,在有些情况下这是无法满足需求的。也可以使用全局变量配合,但是这样就导致在同一时间函数只能在一种情形下工作,在多线程下完全无法使用,故将这种方法pass。
在程序16.15中,STL容器需要传入一个过滤器,它只接受一个参数,如果用普通的函数,必须为每一种情况定义一个函数。通过重载()运算符并为过滤器生成一个特定的函数对象,我们做到了代码的复用。
对于内置的运算符,STL提供了等价的函数符(表16.12),以被当成参数传入其它函数。
使用STL的函数适配器类,可以将二元函数转化为一元函数。当然这里的二元函数是重载了()的那种,并且还需要符合格式要求,随便写一个函数是不行的。STL提供的自适应二元函数都是符合要求的。
using namespace std;
// 现使用stl的greater模版类生成一个函数符变量
greater<int> f1 = greater<int>();
// 假设我们需要将一个参数设为固定值,这时就需要使用绑定器
// 我们可以使用类模版的绑定器
// 作为模版参数的函数的参数类型会被记录
binder1st<greater<int> > f2(greater<int>(), 10);
// 现在使用 f2(x) 就等价为 f1(10,x)
// 也可以使用模版函数构建一个对象
binder1st<greater<int> > f3 = bind1st(greater<int>(), 10);
// 可知它们的效果是一样的,使用函数写的字更少些
// 1st和2nd的区别就是绑定的参数是第一个还是第二个了
bind2nd(greater<int>(), 10);
书里712页那句话写错了,binder1st必须要提供模版参数,并且f1要写在括号之前,它是调用构造函数。
更多的内容在第18章。
5. STL算法 分为4组:
- 非修改式序列操作
- 修改式序列操作
- 排序和相关操作
- 通用数字运算
6. 其它
模版initializer_list相当于给构造函数一个数组。
第17章
这一章进一步介绍了c++的标准输入输出,别的可能已经很熟悉了,对于string的输入输出在有的教材中会被忽视。
1. cout
在向cout插入内容后并不是立即显示在屏幕上,而是存放在了缓冲区,当缓冲区满后一次性输出。可以通过控制符flush刷新缓冲区。控制符endl在换行的同时也会刷新缓冲区,这也是它和输出换行符的区别。
cout支持使用成员函数格式化:
- 函数width()可指定下一个显示项目的最小宽度。
- 函数fill()可以设置字段中空白部分的填充符号,默认为空格。
- 函数precision()可以设置浮点数的精度,不是小数位数。如果在前面有声明 ios::fixed ,这条语句的作用就变为指定小数位数。
- 函数setf()和unsetf()可以修改输出数据的一些格式,可修改的内容见表17.1和表17.2,两者一个是设置一个是取消。
也可以使用控制符格式化,例如可以通过dec、hex、oct修改输出的整数的进制,在第3章有提到。其它的可以参考表17.3。头文件iomanip提供了更多的控制符,包括了setw()、setfill()、setprecicion()。使用这些控制符可以完全替代前面的成员函数。
有了控制符,c语言中的printf等格式化输出函数能够被抛弃了,它们在写的时候更容易出错而且也不灵活。
2. cin
在前面有提到使用cin读取数值时如果输入了个字符,程序会有问题。
我们可以将cin的返回值转为bool类型,当输入正确时它是true,而当输入有误时它会是false。这里的有误只的是类型有误,输入0并没有问题。
流的状态位有eofbit、badbit、failbit,可通过成员函数eof()、bad()、fail()获取各状态位的信息(表17.4)。
在出错后cin无法继续工作,badbit状态被设置,需要先使用成员函数clear()清除错误状态。在重置状态后,错误的输入内容仍存在于输入缓冲区中,需要将错误的内容都取出来后才能继续正常的流程。
3. 文件
在c语言中,文件用类型FILE操作,在c++中用的是fstream系列。写文件是ofstream,它的基类是ostream;读文件是ifstream,它的基类是istream;同时读写可以用fstream。
文件输入输出同样具有状态位,在打开文件失败时会设置failbit。也可以通过成员函数is_open()判断。在关闭文件后,如果需要使用这个文件变量打开别的文件,最好先清除状态位,这样比较保险。
文件的打开模式见表17.7。当目标文件不存在时,只有在out或app模式下才会自动创建文件。
当以二进制模式打开文件时,最好使用read和write函数读写数据,只有这种方式不会自动处理数据。
函数seekg()和seekp()分别是将输入、输出指针移到文件的指定位置。方法tellg()和tellp()是查询各自当前的位置。修改文件时会覆盖当前位置的数据而不是插入新数据。
4. 内核格式化 除了标准输入输出和文件,对于string也存在相应的输入输出,它在头文件sstream中。它包括stringstream、ostringstream和istringstream。可以和使用cout、cin一样操作它们。
sstream的一家子都有两个成员函数str()、str(const string&),它们是一组重载。前者返回流中的全部内容(这里返回的是临时的右值,需要及时复制到变量中),后者给当前流赋值。
当然,在输出string时记得使用成员函数c_str(),它会返回一个与c语言兼容的临时字符串。
有了这东西,那sprintf就也可以扔一边去了。sscanf可能还会有用到的时候。
第18章
这就是最后一章了,系统地介绍了C++11的新特性。最后推销了一下BOOST。这边就较为详细地搬运一遍。
1. 新类型
整型添加了 long long 和 unsigned long long
字符添加了 char16_t 和 char32_t
2. 初始化 添加了使用大括号进行初始化的适用范围,在初始化时禁止缩窄转化,添加初始化列表类模版。
3. 类型声明 将auto的内容改为自动推断变量的类型
使用decltype计算表达式的最终类型
通过返回类型后置(->)的方式实现对返回值类型的自动推断
使用using和=为模版取别名
使用nullptr表示空指针,实际上还是0,增加了代码安全性
4. 智能指针
添加了新的智能指针类型unique_ptr、shared_ptr和weak_ptr,以替代auto_ptr。
5. 异常
添加关键字noexcept用于表示函数不会引发异常,逐步抛弃旧的异常规范语法。
6. 枚举
可以使用class和struct为enum类型添加作用域名称,避免出现在全局区域。
7. STL修改
添加了基于范围的for循环,实际效果和使用迭代器差不多。
新增了容器和方法。
关键字export的用法被抛弃。
嵌套模版时尖括号之间需要用空格隔开。
8. 右值引用
通过右值引用(&&),可以将表达式的值复制给变量,这个变量具有自己的地址。
这边好像看不到有什么意义,它展示身手的地方是下面的移动构造函数。
9. 移动语义
在原有的语法中,函数内部产生的变量在作为返回值时需要先拷贝给临时右值。在不涉及动态内存时这种操作没有什么问题。在对象使用动态内存时,完全可以将临时变量的指针传递给后面的变量,因为临时变量随后就会被销毁,这样就能够减少一次动态内存的创建,提高了效率。为了实现这种优化,c++添加了移动构造函数,它的参数是右值对象(&&)。
在这里有一个前提,即返回值必须为临时右值。此外,必须自己添加移动构造函数。由于原临时变量任然会调用析构函数,在移动构造函数中需要将原变量的指针改为空指针。因此移动构造函数的参数不能为const。
同样,赋值运算也分为了普通的深复制和新的移动复制两者。
typeName::typeName(const typeName &f);
typeName::typeName(typeName &&f);
typeName & typeName::operator=(const typeName &f);
typeName & typeName::operator=(typeName &&f);
可以通过两种手段强制使用移动构造或移动赋值:
- 通过 static_cast< typeName && >(f) 将变量强制转换为右值。
- 通过函数 move(f) 触发移动,该函数在头文件 utility 中。
如果将一个没有定义移动构造函数的对象转化右值,它最终还是会调用复制构造。
10. 对类的修改
添加关键字explicit禁止构造函数和类型转换函数被隐式调用。
在原有的4个默认函数的基础上添加了移动构造函数和移动赋值运算符,在上一点介绍了。
在类中关键字 default 有了新的用途,可通过该关键字将相关的成员函数显式声明为默认版本(一共有6个函数)。例如如果声明了其它构造函数,本来默认的构造函数就不存在了,现在可通过以下语句保留默认构造函数而不需要重新定义。
另外,关键字 delete 能够显式删除某个成员函数,包括默认成员函数。可以对某个重载函数标记为删除,在代码中当匹配到这个被删除的函数时,编译器会直接报错而不会通过隐式转换调用其它函数。
Someclass() = default;
Someclass() = delete;
可以在一个构造函数的初始化列表中调用另一个构造函数,减少重复的代码。
可在派生类中使用 using 语句继承基类的构造函数,包括自定义的,除了默认构造函数、复制构造函数和移动构造函数。在以前只能继承其它成员函数。
类成员可以在被声明时初始化(语法糖)。
在多态方面,添加了两个标识符 override 和 final ,它们不属于关键字。override指明这个函数是对基类函数的重载,如果因为笔误写错了导致变成了覆盖,编译器会报错。final指出在派生类中不能覆盖这个虚方法。这两个标识符都写在成员函数参数列表的后方,如果有const标记,需写在const后方。
11. Lambda函数 指匿名函数,以下是一组简单的例子:
[](int x) {return x;}
[](int x) -> int { int y = x; return y;}
中括号表示这是一个匿名函数,小括号内为参数。在只有一条语句时,返回类型可由decltype自动推断,否则需要使用到返回类型后置。没有return时返回类型为void。
可以给匿名函数取名:
auto f1 = [](int x) {return x;}
之后就可以和调用普通函数一样使用了。
可以使用当前作用域的任何动态变量,变量写在中括号中。使用=可以使函数按值访问作用域内的所有动态变量,使用&可以使函数按引用访问作用域内的所有动态变量。毫无疑问,这两个符号不能同时使用。但可以将单个符号和显式声明同时使用,显式声明的优先级高于符号。
[&count](int x) { count += x;}
[=, &count](int x) { count += x;}
[&, count]() { cout << count;}
12. 包装器
包装器(wrapper)也叫适配器(adapter),在第16章提到了两个包装器。STL有一系列包装器,在这里作者介绍了function。
假如我们有一系列结构相同但类型不同的函数(全局函数,类重载,lambda函数),现在将它们当成模版参数使一个模版实例化。因为类型不同,模版需要生成多个实例,这不是我们希望看到的。
下面先放一个例子:
#include <iostream>
#include <functional>
int fun(int v1, int v2) {
return v1+v2;
}
int main() {
using namespace std;
function<int(int,int)> f1(fun);
binder1st<function<int(int,int)> > f2 = bind1st(f1, 10);
cout << f2(1) <<endl;
return 0;
}
经过function的包装,原来的全局函数改变了类型,并且可以被适配器转化了。
在类库WebSocketpp中,作者就使用了function包装定义为成员函数的回调函数,将连接指针作为常量传入,这样就不需要额外记录指针信息。在这里他使用了更通用的std::bind()函数生成包装函数,将上述的两步操作简化为了一步。
13. 可变参数模版
可变参数模版和初始化列表不同,前者可用于任何函数,后者只能用于初始化并只能传入相同类型。
可变参数模版使用的是省略号元运算符(…),可以这么定义一个函数模版:
void fun() {}
template<typename T, typename... Args>
void fun(const T& value, const Args&... args) {
// ...
fun(args);
}
这里Args是模版参数包,存放传入的参数类型。args是函数参数包,存放传入的参数数据。
在调用函数时会将第一个参数剥离出来,通过递归逐步处理参数。直到参数都被处理完毕,调用无参的重载函数结束递归。
为了提高效率,将参数设为引用传值,要注意符号的位置。
14. 其它内容
- 添加对并行(多线程)的支持。
- 增加了新的库:更好的随机数,时间处理,正则表达式……
- 共用体成员可以有构造和析构函数
- 通过alignas和alignof实现内存对齐(见附录)
- 添加了关键字static_assert在编译阶段进行断言检查
- 加强对元编程(metaprogramming)的支持(?)
15. 语言的发展
有一个Boost项目,还有一个TR1项目(这个好像有TR2了)。简单地说就是要多看。
附录
附录E对一些运算符做了介绍,其中逻辑按位运算符还是很常用的。
之前在运算符重载时看到了两个奇怪的运算符:(.*)和(->*)。它可指向成员指针。成员指针可以是变量和函数,下面是用法:
ClassA ob1; // 有一个对象
// 在定义成员指针时需要加上类域 别的和正常指针一致
int ClassA::*pt = &ClassA::ival; // 这时指向成员变量的指针
ob1.*pt; // 等价于 ob1.ival
void (ClassA::*pf)() const; // 这是指向成员函数的指针
pf = &ClassA::show;
ob1.*pf(); // 等价于 ob1.show()
可见成员指针除了只能指向指定类的对象,别的和普通的指针以及函数指针是一致的。
运算符alignof能够计算类型的对齐方式。在第3章时有计算结构体的大小,会发现会有内存浪费的现象。不同的平台可能在对齐方式上存在差异,在写代码时应多加注意。
附录F介绍了和string有关的函数。别的就不说了,STL有现成的反向查找函数 rfind() ,看名字也知道是什么意思了。以前我一直以为没有,于是很多地方用了char字符串,现在可以换过来了。还是学艺不精啊。
附录G中的内容就在以后看的时候说吧。
附录H里的书都有些旧了,不知道有没有新版。如果没有新版的话就别看了。这本书是以C++11为准的,现在还能用。不过听说20年又会有大的变动,到时候就等新版吧。