此篇文章是本人学习 C++ 时所记录,不是正规教程,阅读需要有一定的编程基础。
学习 C++ 之前最好先学一点 C,这样上手更快,这是我的 C 语言基础练习程序
这里还有一些额外的 C++ 练习程序,适合伴随本文章阅读。

标识符

标识符

编程时常常需要把表达式的值赋给变量,这就要求在声明变量的时候清楚地知道表达式的类型。然而要做到这一点并非那么容易, 有时甚至根本做不到。为了解决这个问题,C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。和原来那些只对应一种特定类型的说明符(比如 double )不同,auto 让编译器通过初始值来推算变量的类型。显然,auto 定义的变量必须有初始值:

1
2
// 由 vall 和 val2 相加的结果可以推断出 item 的类型
auto item = vall + val2; // item 初始化为 vall 和 val2 相加的结果

编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。

设置一个类型为auto的引用时,初始值中的顶层常量属性仍然保留。和往常一样,如果我们给初始值绑定一个引用,则此时的常量就不是顶层常量了。要在一条语句中定义多个变量,切记,符号&和*只从属于某个声明符,而非基本数据类型的一部分,因此初始值必须是同一种类型:

1
2
3
auto k = ci,&l = i; // k是整数,i 是整型引用
auto &m = ci,*p = &ci; // m 是对整型常量的引用,p 是指向整型常量的指针
auto &n = i,*p2 = &ci; // 错误: i 的类型是 int 而 &ci 的类型是 const int

declttype 标识符

有时会遇到这种情况:希望从表达式的类型推断出要定义的变量的类型,但是不想用该表达式的值初始化变量。为了满足这一要求, C++11 新标准引入了第二种类型说明符 decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器分析表达式并得到它的类型,却不实际计算表达式的值:

1
decltype(f()) sum = X; // sum 的类型就是函数 f 的返回类型

编译器并不实际调用函数 f,而是使用当调用发生时 f 的返回值类型作为sum的类型。换句话说,编译器为 sum 指定的类型是什么呢? 就是假如 f 被调用的话将会返回的那个类型。

decltype 处理项层 const 和引用的方式与 auto 有些许不同。如果 decltype 使用的表达式是一个变量,则 decltype 返回该变量的类型(包括顶层 const 和引用在内):

1
2
3
4
const int ci = 0, &cj = ci;
decltype(ci) x = 0; // x 的类型是 const int
decltype(cj) y = x; // y 的类型是 const int&,y 绑定到变量 x
decltype(cj) z; // 错误:z 是一个引用,必须初始化

decltype 和 auto 的另一处重要区别是,decltype 的结果类型与表达式形式密切相关。有一种情况需要特别注意:对于 decltype 所用的表达式来说,如果变量名加上了一对括号,则得到的类型与不加括号时会有不同。如果 decltype 使用的是一个不加括号的变量,则得到的结果就是该变量的类型:如果给变量加上了一层或多层括号,编译器就会把它当成是一个表达式。变量是一种可以作为赋值语句左值的特殊表达式,所以这样的 decltype 就会得到引用类型:

1
2
3
4
5
6
7
// decltype 的结果可以是引用类型
int i = 42, *p = &i, &r = i;
decltype(r + 0) b; // 正确:加法的结果是 int,因此 b 是一个(未初始化的) int
decltype(*p) c; // 错误:c 是 int&,必须初始化
// decltype 的表达式如果是加上了括号的变量,结果将是引用
decltype((i)) d; // 错误:d 是 int&,必须初始化
decltype(i) e; // 正确: e 是一个(未初始化的) int

切记: decltype ( (variable)) (注意是双层括号)的结果永远是引用,而 decltype (variable) 结果只有当 variable 本身就是一个引用时才是引用。

字符串

字符串相加

1
2
3
4
5
string s1 = "Hello";
string s2 = "World";
string s3 = s1 + " " + s2; // 正确
string s4 = s1 + s2; // 正确
string s5 = "Hello" + "World"; // 错误

因为某些历史原因,也为了与 C 兼容,所以 C++ 语言中的字符串字面值并不是标准库类型 string 的对象。切记,字符串字面值与 string 是不同的类型。

C++ 标准库中除了定义 C++ 语言特有的功能外,也兼容了C语言的标准库。C语言的头文件形如name.h,C++ 则将这些文件命名为 cname。也就是去掉了 .h 后缀,而在文件名 name 之前添加了字母 C,这里的 c 表示这是一个属于 C语言标准库的头文件。因此,cctype 头文件和 ctype.h 头文件的内容是一样的,只不过从命名规范上来讲更符合 C++ 语言的要求。特别的,在名为 cname 的头文件中定义的名字从属于命名空间 std,而定义在名为 .h 的头文件中的则不然。
一般来说,C++ 程序应该使用名为 cname 的头文件而不使用 name.h 的形式,标准库中的名字总能在命名空间 std 中找到。如果使用 .h 形式的头文件,程序员就不得不时刻牢记哪些是从C语言那儿继承过来的,哪些又是 C++ 语言所独有的。

C 风格字符串

尽管 C++ 支持C风格字符串,但在 C++ 程序中最好还是不要使用它们。这是因为C风格字符串不仅使用起来不太方便,而且极易引发程序漏洞,是诸多安全问题的根本原因。

C风格字符串不是一种类型, 而是为了表达和使用字符串而形成的一种约定俗成的写法。按此习惯书写的字符串存放在字符数组中并以空字符结束。以空字符结束的意思是在字符串最后一个字符后面跟着一个空字符(‘ \0’ ),一般利用指针来操作这些字符串。

以下这些 C 风格字符串不负责校验参数,使用时应注意:

  • strlen(p) :返回 p 的长度,空字符不计算在内。
  • strcmp(p1, p2) :比较 p1 和 p2 的相等性。如果 p1==p2,返回 0;如果 p1>p2,返回一个正值;如果 p1<p2,返回一个负值。
  • strcat (p1, p2):将 p2 附加到 p1 之后,返回 p1。
  • strcpy(p1, p2):将 p2 拷贝给 p1,返回 p1。

有些 C++ 程序在标准库出现之前就已经写成了,它们肯定没用到 string。或者,有些 C++ 程序实际上是与C语言或其他语言的接口程序,当然也无法使用 C++ 标准库,为此,C++ 还提供了一组功能来方便与这些 C 风格字符串的转换。

一般情况下,任何出现字符串字面值的地方都可以用以空字符结束的字符数组来替代:

  • 允许使用以空字符结束的字符数组来初始化 string 对象或为 string 对象赋值。
  • 在 string 对象的加法运算中允许使用以空字符结束的字符数组作为其中一个运
    算对象(不能两个运算对象都是)。
  • 在string对象的复合赋值运算中允许使用以空字符结束的字符数组作为右侧的运算对象。

上述性质反过来就不成立了: 如果程序的某处需要一个C风格字符串,无法直接用 string 来代替它。为此,C++ 为其提供了 c_str 的成员函数来进行这种转换。

1
2
3
string s("Hello World!");
char *str = s; // 错误:不能用 string 对象初始化 char*
const char *str = s.c_str(); // 正确

但我们无法保证 c_str 函数返回的数组一直有效,事实上,如果后续的操作改变了 s 的值就可能让之前返回的数组失去效用。如果执行完 c_str() 函数后程序想一直都能使用其返回的数组,最好将该数组重新拷贝一份。

迭代器

1
2
3
4
vector<int>::iterator it; // it 能读写 vector<int> 的元素
string::iterator it2; // it2 能读写 string 对象中的字符
vector<int>::const_iterator it3; // it3 只能读元素,不能写元素
string::const_iterator it4; // it4 只能读字符,不能写字符

const_iterator 和常量指针差不多,能读取但不能修改它所指的元素值。相反,iterator 的对象可读可写。如果 vector 对象或 string 对象是一个常量, 只能使用 const_iterator; 如果 vector 对象或 string 对象不是常量,那么既能使用 iterator 也能使用 const_iterator。

begin 和 end 返回的具体类型由对象是否是常量决定,如果对象是常量,begin 和 end 返回 const_iterator; 如果对象不是常量,返回 iterator。使用 cbegin 和 cend 可以指定获得 const_iterator。

1
2
3
4
5
vector<int> v;
const vector<int> cv;
auto it5 = v.begin(); // it5 的类型是 vector<int>::iterator
auto it6 = cv.begin(); // it6 的类型是 vector<int>::const_iterator
auto it7 = v.cbegin(); // it7 的类型是 vector<int>::const_iterator

解引用并访问成员

1
2
(*it).empty(); // 解引用 it,然后调用结果对象的 empty 成员
*it.empty(); // 错误:试图访问 it 的名为 empty 的成员,但 it 是个迭代器,没有 empty 成员

为了简化上述表达式,C++ 语言定义了箭头运算符(->)。箭头运算符把解引用和成员访问两个操作结合在一起,也就是说,it->mem 和 (*it) .mem 表达的意思相同。

1
2
3
4
//依次输出 text 的每一行直至遇到第一个空白行为止
for (auto it = text.cbegin(); it != text.cend() && !it->empty(); ++it) {
cout << *it << endl;
}

谨记,但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素,因为这样的操作会使迭代器失效

迭代器运算

Vector 和 string 迭代器支持的运算
iter + n 迭代器加上一个整数值仍得一个迭代器,迭代器指示的新位置与原来相比向前移动了若干个元素。结果迭代器或者指示容器内的一个元素,或者指示容器尾元素的下一位置
iter - n 迭代器减去一个整数值仍得一个迭代器,迭代器指示的新位置与原来相比向后移动了若干个元素。结果迭代器或者指示容器内的一个元素,或者指示容器尾元素的下一位置
iter1 += n 迭代器加法的复合赋值语句,将 iter1 加 n 的结果赋给 iter1
iter1 -= n 迭代器减法的复合赋值语句,将 iter1 减n的结果赋给 iter1
iter1 - iter2 两个迭代器相减的结果是它们之间的距离,也就是说,将运算符右侧的迭代器向前移动差值个元素后将得到左侧的迭代器。参与运算的两个迭代器必须指向的是同一个容器中的元素或者尾元素的下一位置
>、>=、<、<= 迭代器的关系运算符,如果某迭代器指向的容器位置在另一个迭代器所指位置之前,则说前者小于后者。参与运算的两个迭代器必须指向的是同一个容器中的元素或者尾元素的下一位置

利用迭代器的运算来完成二分查找算法的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
// text 必须是有序的
// beg 和 end 表示我们搜索的范围
auto beg = text.begin(), end = text.end();
auto mid = text.begin() + (end - beg) / 2; // 初始状态下的中间点
// 当还有元素尚未检查并且我们还没有找到 sought 时执行循环
while (mid != end && *mid != sought) {
if (sought < *mid) { // 我们要找的元素在前半部分吗?
end = mid; // 如果是,调整搜索范围使得忽略掉后半部分
} else { // 我们要找的元素在后半部分;
beg = mid + 1; // 在mid之后寻找
}
mid = beg + (end - beg) / 2; // 新的中间点.
}

数组

初始化

可以对数组的元素进行列表初始化,此时允许忽略数组的维度。如果在声明时没有指明维度,编译器会根据初始值的数量计算并推测出来;相反,如果指明了维度,那么初始值的总数量不应该超出指定的大小。如果维度比提供的初始值数量大,则用提供的初始值初始化靠前的元素,剩下的元素被初始化成默认值。

1
2
3
4
5
6
const unsigned sz = 3;
int ial[sz] = {0, 1, 2}; // 含有 3 个元素的数组,元素值分别是 0, 1, 2
int a2[] = {0, 1, 2}; // 维度是 3 的数组
int a3[5] = {0, 1, 2}; // 等价于 a3[] = {0,1,2,0,0}
string a4[3] = {"hi", "bye"}; // 等价于 a4[] = {"hi", "bye", ""}
int a5[2] = {0, 1, 2}; // 错误:初始值过多

注意:如果用字符串初始化字符数组,不要忘记字符串最后有一个隐藏的 ‘\0’

1
2
char a3[] = "C++"; // 自动添加表示字符串结束的空字符,长度为 4
const char a4[6] = "Daniel"; // 错误:没有空间可存放空字符!

不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值

1
2
3
int a[] = {0, 1, 2}; // 含有 3 个整数的数组
int a2[] = a; // 错误:不允许使用一个数组初始化另一个数组
a2 = a; // 错误:不能把一个数组直接赋值给另一个数组

一些编译器支持数组的赋值,这就是所谓的编译器扩展。但一般来说,最好避免使用非标准特性,因为含有非标准特性的程序很可能在其他编译器上无法正常工作。

特性

在很多用到数组名字的地方,编译器都会自动地将其替换为一个指向数组首元素的指针:

1
string *p2 = nums; // 等价于 p2 = &nums[0]

在大多数表达式中,使用数组类型的对象其实是使用一个指向该数组首元素的指针。

由上可知,在一些情况下数组的操作实际上是指针的操作,这一结论有很多隐含的意思。其中一层意思是当使用数组作为一个 auto 变量的初始值时,推断得到的类型是指针而非数组:

1
2
3
int ia[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}; // ia 是一个含有 10 个整数的数组
auto ia2(ia); // ia2 是一个整型指针,指向 ia 的第一个元素
ia2 = 42; // 错误: ia2 是一个指针,不能用 int 值给指针赋值

尽管 ia 是由 10个 整数构成的数组,但当使用 ia 作为初始值时,编译器实际执行的初始化过程类似于下面的形式:

1
auto ia2(&ia[0]); // 显然 ia2 的类型是 int*

必须指出的是,当使用 decltype 关键字时上述转换不会发生,decltype(ia) 返回的类型是由 10 个整数构成的数组:

1
2
3
4
5
6
// ia3 是一个含有 10 个整数的数组
decltype(ia) ia3 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
int num = 10;
int *p = &num;
ia3 = p; // 错误:不能用整型指针给数组赋值
ia3[4] = 10; // 正确:赋值给 ia3 的一个元素

初始化 vector

不允许使用一个数组为另一个内置类型的数组赋初值,也不允许使用 vector 对象初始化数组。但允许使用数组来初始化 vector 对象。只需指明要拷贝区域的首元素地址和尾后地址就可以了:

1
2
3
int int_arr[] = {0, 1, 2, 3, 4, 5};
// ivec 有 6 个元素,分别是 int_arr 中对应元素的副本.
vector<int> ivec(begin(int_arr), end(int_arr));

建议:直接使用标准库类型而非数组

现代的C++程序应当尽量使用 vector 和迭代器,避免使用内置数组和指针;应该尽量使用 string,避免使用C风格的基于数组的字符串。

运算符

递增递减运算符

建议:除非必须,否则不用递增递减运算符的后置版本
有C语言背景的读者可能对优先使用前置版本递增运算符有所疑问,其实原因非常简单:前置版本的递增运算符避免了不必要的工作,它把值加1后直接返回改变了的运算对象。与之相比,后置版本需要将原始值存储下来以便于返回这个未修改的内容。如果我们不需要修改前的值,那么后置版本的操作就是一种浪费。
对于整数和指针类型来说,编译器可能对这种额外的工作进行一定的优化;但是对于相对复杂的迭代器类型,这种额外的工作就消耗巨大了。建议养成使用前置版本的习惯,这样不仅不需要担心性能的问题,而且更重要的是写出的代码会更符合编程的初衷。

花括号

1
2
3
4
5
6
int i = 5;
if (i < 10)
cout << "i 小于 10,输出一下" << endl;
else
cout << "i 不小于 10,输出一下" << endl;
cout << "这句话无论 i 为何值都会被打印,这就是要加花括号的重要性" << endl;
1
2
3
4
5
6
7
int i = 5;
if (i < 100)
cout << "i 小于 100,输出一下" << endl;
if (i > 10)
cout << "i 大于 10,输出一下" << endl;
else
cout << "i 不小于 100,输出一下" << endl; // 这句话会被打印,因为 else 总是找最近的 if 结合,得加花括号

switch 语句

switch 内部变量定义

如果在某处一个带有初值的变量位于作用域之外,在另一处该变量位于作用域之内,则从前一处跳转到后一处的行为是非法行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool ret = false;
switch (ret) {
case true : // 因为程序的执行流程可能绕开下面的初始化语句,所以该 switch 语句不合法
string file_name; // 错误:控制流绕过一个隐式初始化的变量
int ival = 0; // 错误:控制流绕过一个显式初始化的变量
int jval; // 正确:因为 jval 没有初始化
break;
case false: // 正确:jval 虽然在作用域内,但是它没有被初始化
jval = 666; //正确:给 jval 赋一个值
if (file_name.empty()) { // file_name 在作用域内,但是没有被初始化
cout << "file_name is empty" << endl;
}
break;
}

假设上述代码合法,则一旦控制流直接跳到 false 分支,也就同时略过了变量 file_name 和 ival 的初始化过程。此时这两个变量位于作用域之内,跟在 false 之后的代码试图在尚未初始化的情况下使用它们,这显然是行不通的。因此C++语言规定,不允许跨过变量的初始化语句直接跳转到该变量作用域内的另一个位置。如果需要为某个 case 分支定义并初始化一个变量,我们应该把变量定义在块内,从而确保后面的所有 case 标签都在变量的作用域之外。