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

变量类型

和 C 语言一样,C++ 的设计准则之一也是尽可能地接近硬件。C++ 的算术类型必须满足各种硬件特质,所以它们常常显得繁杂而令人不知所措。事实上,大多数程序员能够(也应该)对数据类型的使用做出限定从而简化选择的过程。以下是选择类型的一些经验准则:

  • 当明确知晓数值不可能为负时,选用无符号类型。
  • 使用 int 执行整数运算。在实际应用中,short 常常显得太小而 long 一般和 int 有一样的尺寸。如果你的数值超过了 int 的表示范围,选用 long long。
  • 在算术表达式中不要使用 char 或 bool,只有在存放字符或布尔值时才使用它们。因为类型 char 在一些机器上是有符号的,而在另一些机器上又是无符号的,所以如果使用 char 进行运算特别容易出问题。如果你需要使用一个不大的整数,那么明确指定它的类型是 signed char 或者 unsigned char。
  • 执行浮点数运算选用 double,这是因为 float 通常精度不够而且双精度浮点数和单精度浮点数的计算代价相差无几。事实上,对于某些机器来说,双精度运算甚至比单精度还快。long double 提供的精度在一般情况下是没有必要的,况且它带来的运行时消耗也不容忽视。

变量命名

C++ 的标识符由字母、数字和下画线组成,其中必须以字母或下画线开头。标识符的长度没有限制,但是对大小写字母敏感。

1
2
// 定义 4 个不同的 int 变量
int somename, someName, SomeName, SOMENAME;

同时,C++ 也为标准库保留了一些名字。用户自定义的标识符中不能连续出现两个下画线,也不能以下画线紧连大写字母开头。此外,定义在函数体外的标识符不能以下画线开头。

变量命名规范

  • 标识符要能体现实际含义。
  • 变量名一般用小写字母,如 index, 不要使用 Index 或 INDEX。
  • 用户自定义的类名一般以大写字母开头,如 Sales_item。
  • 如果标识符由多个单词组成,则单词间应有明显区分,如 student_loan 或 studentLoan,不要使用 studentloan。

对于命名规范来说,若能坚持,必将有效

类型转换

1
2
unsigned char C = -1; // 假设 char 占 8 比特,c 的值为 255
signed char c2 = 256; // 假设 char 占 8 比特,c 2的值是未定义的
  • 当我们赋给无符号类型一个超出它表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数。例如,8 比特大小的 unsigned char 可以表示 0 至 255 区间内的值,如果我们赋了一个区间以外的值,则实际的结果是该值对 256 取模后所得的余数。因此,把 -1 赋给 8 比特大小的 unsigned char 所得的结果是 255。
  • 当我们赋给带符号类型一个超出它表示范围的值时,结果是未定义的(undefined)。此时,程序可能继续工作、可能崩溃,也可能生成垃圾数据。

建议:避免无法预知和依赖于实现环境的行为

无法预知的行为源于编译器无须(有时是不能)检测的错误。即使代码编译通过了,如果程序执行了一条未定义的表达式,仍有可能产生错误。不幸的是,在某些情况和或某些编译器下,含有无法预知行为的程序也能正确执行。但是我们却无法保证同样一个程序在别的编译器下能正常工作,甚至已经编译通过的代码再次执行也可能会出错。此外,也不能认为这样的程序对一组输入有效,对另一组输入就一定有效。

程序也应该尽量避免依赖于实现环境的行为。如果我们把 int 的尺寸看成是一个确定不变的已知值,那么这样的程序就称作不可移植的。当程序移植到别的机器上后,依赖于实现环境的程序就可能发生错误。要从过去的代码中定位这类错误可不是一件轻松愉快的工作。

1
2
3
4
5
6
7
8
unsigned u = 10;
int i = -42;
std::cout << i + i << std::endl; // 输出 -84
std::cout << u + i << std::endl; // 如果 int 占 32 位,输出 4294967264

unsigned u1 = 42, u2 = 10;
std::cout << u1 - u2 << std::endl; // 正确:输出 32
std::cout << u2 - u1 << std::endl; // 正确:不过,结果是取模后的值,4294967264

尽管我们不会故意给无符号对象赋一个负值,却可能(特别容易)写出这么做的代码。例如,当一个算术表达式中既有无符号数又有 int 值时,那个 int 值就会转换成无符号数。把 int 转换成无符号数的过程和把 int 直接赋给无符号变量一样。

初始化

C++语言定义了初始化的好几种不同形式,这也是初始化问题复杂性的一个体现。例如,要想定义一个名为 units_sold 的 int 变量并初始化为 0,以下的 4 条语句都可以做到这一点:

1
2
3
4
int units_sold = 0;
int units_sold = {0};
int units_sold{0};
int units_sold(0);

作为 C++11 新标准的一部分, 用花括号来初始化变量得到了全面应用,而在此之前,这种初始化的形式仅在某些受限的场合下才能使用。这种初始化的形式被称为列表初始化。现在,无论是初始化对象还是某些时候为对象赋新值,都可以使用这样一组由花括号括起来的初始值了。

当用于内置类型的变量时,这种初始化形式有一个重要特点:如果我们使用列表初始化且初始值存在丢失信息的风险,则编译器将报错。

1
2
3
long double ld = 3.1415926536;
int a{ld}, b = {ld}; // 错误:转换未执行,因为存在丢失信息的危险
int c(ld), d = ld; // 正确:转换执行,且确实丢失了部分值

如果是内置类型的变量未被显式初始化,它的值由定义的位置决定。定义于任何函数体之外的变量被初始化为 0。一种例外情况是,定义在函数体内部的内置类型变量将不被初始化。一个未被初始化的内置类型变量的值是未定义的,如果试图拷贝或以其他形式访问此类值将引发错误。

建议初始化每一个内置类型的变量。虽然并非必须这么做,但如果我们不能确保初始化后程序安全,那么这么做不失为一种简单可靠的方法。

作用域

1
2
3
4
5
6
7
8
9
10
11
12
13
// 该程序仅用于说明:函数内部不宜定义与全局变量同名的新变量
int reused = 42; // reused 拥有全局作用域
int main() {
int unique = 0; // unique 拥有块作用域
// 输出#1:使用全局变量 reused; 输出 42 0
std::cout << reused << " " << unique << std::endl;
int reused = 0; //新建局部变量reused,覆盖了全局变量reused
// 输出#2:使用局部变量 reused; 输出 0 0
std::cout << reused << " " << unique << std::endl;
// 输出#3:显式地访问全局变量 reused; 输出 42 0
std::cout << ::reused << " " << unique << std::endl;
return 0;
}

输出#3使用作用域操作符来覆盖默认的作用域规则,因为全局作用域本身并没有名字,所以当作用域操作符的左侧为空时,向全局作用域发出请求获取作用域操作符右侧名字对应的变量。结果是,第三条输出语句使用全局变量 reused,输出42 0。

指针

指针通常难以理解,即使是有经验的程序员也尝尝因为调试指针引发的错误而备受折磨

1
2
3
4
5
double dval;
double *pd = &dval; // 正确:初始值是 double 型对象的地址
double *pd2 = pd; // 正确:初始值是指向 double 对象的指针
int *pi = pd; // 错误:指针 pi 的类型和 pd 的类型不匹配
pi = &dval; // 错误:试图把 double 型对象的地址赋给 int 型指针

指针的值(即地址)应属下列 4 种状态之一:

  1. 指向一个对象。
  2. 指向紧邻对象所占空间的下一个位置。
  3. 空指针,意味着指针没有指向任何对象。
  4. 无效指针,也就是上述情况之外的其他值。

试图拷贝或以其他方式访问无效指针的值都将引发错误。编译器并不负责检查此类错误

建议:初始化所有指针!
使用未经初始化的指针是引发运行时错误的一大原因。和其他变量一样,访问未经初始化的指针所引发的后果也是无法预计的。通常这一行为将造成程序崩溃,而且一旦崩溃,要想定位到出错位置将是特别棘手的问题。在大多数编译器环境下,如果使用了未经初始化的指针,则该指针所占内存空间的当前内容将被看作一个地址值。访问该指针,相当于去访问一个本不存在的位置上的本不存在的对象。糟糕的是,如果指针所占内存空间中恰好有内容,而这些内容又被当作了某个地址,我们就很难分清它到底是合法的还是非法的了。
因此建议初始化所有的指针,并且在可能的情况下,尽量等定义了对象之后再定义指向它的指针。如果实在不清楚指针应该指向何处,就把它初始化为 nullptr 或者 0,这样程序就能检测并知道它没有指向任何具体的对象了。

指针和引用的区别

指针和引用都能提供对其他对象的间接访问,然而在具体实现细节上二者有很大不同,其中最重要的一点就是引用本身并非一个对象。一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。

有时候要想搞清楚一条赋值语句到底是改变了指针的值还是改变了指针所指对象的值不太容易,最好的办法就是记住赋值永远改变的是等号左侧的对象。当写出如下语句时,

1
>pi = &ival; // pi 的值被改变,现在 pi 指向了 ival。

意思是为 pi 赋一个新的值,也就是改变了那个存放在 pi 内的地址值。相反的,如果写出如下语句,

1
>*pi = 0; // ival 的值被改变,指针 pi 并没有改变

则 *pi (也就是指针 pi 指向的那个对象)发生改变。

void* 指针

void* 是一种特殊的指针类型,可用于存放任意对象的地址。

1
2
3
double obj = 3.14, *pd = &obj; // 正确: void* 能存放任意类型对象的地址
void *pv = &obj; // obj 可以是任意类型的对象
pV = pd; // pv 可以存放任意类型的指针

利用 void* 指针能做的事儿比较有限:拿它和别的指针比较、作为函数的输入或输出,或者赋给另外一个 void* 指针。不能直接操作 void* 指针所指的对象,因为我们并不知道这个对象到底是什么类型,也就无法确定能在这个对象上做哪些操作。

指向指针的引用

引用本身不是一个对象,因此不能定义指向引用的指针。但指针是对象,所以存在对指针的引用:

1
2
3
4
5
int i=42;
int *p; // p 是一个 int 型指针
int *&r = p; // r 是一个对指针 p 的引用
r=&i; // r 引用了一个指针,因此给 r 赋值 &i 就是令 p 指向 i
*r=0; // 解引用 r 得到 i,也就是 p 指向的对象,将 i 的值改为 0

要理解 r 的类型到底是什么,最简单的办法是从右向左阅读 r 的定义。离变量名最近的符号(此例中是 &r 的符号&)对变量的类型有最直接的影响,因此 r 是一个引用。声明符的其余部分用以确定 r 引用的类型是什么,此例中的符号 * 说明 r 引用的是一个指针。最后,声明的基本数据类型部分指出 r 引用的是一个 int 指针。

1
2
3
4
5
6
int i=0;
int *const p1 = &i; //不能改变p1的值,这是一个顶层const
const int ci = 42;
//不能改变ci的值,这是一个顶层const
const int *p2 = &Ci;
//允许改变p2的值,这是一个底层const