Filling in the Gaps¶
本节我们会继续填补一些 C 与 C++ 之间的差异,这些差异点对理解很多 C++ 概念都是至关重要的。
命名空间与作用域解析运算符¶
C++ 引入了命名空间的概念,例如标准库定义的所有函数都在 std 命名空间中。命名空间解决了 C 中的全局命名冲突问题,也能够实现代码的逻辑分组。:: 称为作用域解析运算符,用于指定不同作用域中的成员,包括命名空间、类、结构体、枚举等。
a.c 与 b.c 无法链接在一起,因为它们都定义了名为 var 的全局变量,解决办法只能是给其中至少一个变量加上 static 修饰符,使其变为文件作用域。在 C++ 中,可以使用命名空间来解决这个问题:
现在就有两个完全独立的变量:A::var 与 B::var,分别意味着命名空间 A 与 B 中的 var。当然,仍然可以有全局变量,如 c.cpp 中的 var,这时可以使用 ::var 来指代全局命名空间。这种方式不只在解决命名冲突时有用:
这里发生了遮蔽:每一个作用域都有自己的 var 并且遮蔽了上一层作用域的 var。如果变量能通过作用域解析运算符访问,那么就可以这样做,例如 A::var 与 ::var;但如果是局部变量,就束手无策了,例如 func 中 if 块中的 var 遮蔽了上一层同样为局部变量的 var,这种情况只能通过改变变量名来解决。
命名空间可以任意嵌套:
可以使用 using 语句来引入命名空间中的某个成员,或使用 namespace 赋值来引入命名空间的别名,或通过 using namespace 引入命名空间的所有成员。using 语句并没有定义新的变量,只是将原有的变量引入到当前作用域中——命名空间是编译时的概念,运行时并不存在,不要将其与作用域混淆。
上面的所有函数全部返回同一个变量 A::B::C::var。但是函数 s 与其他函数不同,它依赖于它前面的全局 using namespace 语句。应该避免在全局作用域中使用 using namespace 语句——最臭名昭著的例子就是 using namespace std;,它会引入大量的标准库成员,完全破坏了命名空间存在的意义。我们已经在 Bootstrap 中提到过,最好使用单独的 using 语句来只引入需要的成员。
还有一些较为小众的细节,请参阅 cppreference 中名字查找的相关内容。
另外,using 同样是 C 中 typedef 的上位替代品。它的语法要比 typedef 更符合直觉,并且在涉及模板时,只能使用 using 语句。
匿名命名空间¶
匿名命名空间,顾名思义,就是没有名字的命名空间。它的作用类似于使用 static 修饰,在匿名命名空间中定义的变量或函数只能在当前文件中访问,不会影响其他文件。
初始化¶
C 中的初始化就是简单的赋值,而 C++ 中的初始化则更加复杂。例如,自 C++11 起,要初始化一个 int 变量可以有下面 4 种写法:
好消息是,第 2 种写法在 C++11 之前被称作“聚合初始化”,自 C++11 起同样被归为列表初始化,所以实际上只有 3 种初始化方法;坏消息是,这 3 种初始化方法在某些情况下仍然会有不同的行为。不同初始化方式背后还受到一些其他因素的影响,例如 explicit 关键字,或者类的构造函数1。一般而言,如果确定自己在写 C++11 以后的版本,就应该尽量采用第 4 种写法。目前可以认为第 4 种写法是第 1 和 3 种写法的某种综合。下面 5 种写法全都产生同一个字符串 "Hello":
不过可以相信自己,在这里 b c、d e 之间分别是等价的,但并不总是这样。我们会在后面 STL 的 std::initializer_list 中看到区别的情况。
变长数组¶
C 支持变长数组:
然而很遗憾,VLA 并不包含在 C++ 标准中,也就是标准 C++ 不支持 VLA,只是 GCC 与 Clang 都将其作为扩展语法提供支持,在 C++ 中应当尽量避免。合理的替代品是 std::vector 甚至 std::unique_ptr。
| C++ | |
|---|---|
关键字¶
decltype¶
尽管课上不会涉及,但 decltype 是 C++11 引入的非常有用的关键字之一,它的作用与 GCC 扩展语法 typeof 类似2,用于获取表达式的类型:
但上面的例子实在太刻意,实际上 decltype 更多的用于模板元编程和 lambda 表达式中。上面的例子用 auto 更好,其与 decltype 也有密切联系。
auto¶
在 C 中,auto 指定一个变量为自动变量,即创建与销毁都与作用域绑定。auto 与 static、extern 和 register 一起被称为存储类别说明符。然而,由于局部变量默认就是自动变量,所以 auto 几乎没有存在的必要。自 C++11 起,auto 有了新的含义:自动类型推导。在声明变量时使用 auto 关键字,编译器会尝试根据变量的初始化表达式推导出变量的类型。
当然,这要求必须存在初始化表达式,否则编译器无法推导出变量的类型。下面的写法是错误的:
auto 还可以作为函数返回值的类型,这时需要后随 -> 与返回类型(自 C++14 起,返回类型同样可以省略):
这在某些复杂的模板类型推导中非常有用(不必理解):
Info
实际上,decltype、auto 与引用,任意两者组合都能牵扯出庞大的语法规则。
const¶
const 是那种在 C 和 C++ 中作用基本相同,但底层原理完全不同的关键字。实际上 const 这个关键字是 C 从 C++ 中借鉴过来的,但其实在 C 中这个关键字改名叫 readonly 会更好,因为它只是声明一个只读变量,而不是一个常量。区别在哪呢?例如在 C 中,const 变量作为数组长度时,数组仍然被视作一个变长数组:
- 但编译器仍然可能会做优化,使得
sizeof(a)在编译时就能得到正确的值。
而 C++ 中,const 变量作为数组长度时,得到的数组是定长的:
再比如,在必须需要常量表达式的地方:
| C | |
|---|---|
还是有必要知道这个区别的,尽管大多数时候不需要关心。
nullptr¶
在 C23 以及 C++11 以前,表示空指针的方法是使用定义在 stddef.h 或者 cstddef 中的 NULL 宏,通常它会被定义为 #define NULL 0。0 可以隐式转换为任何指针类型,C 的简单语法允许这种转换发生,但这种转换与 C++ 的理念相悖,并且在某些情况下会引发问题。于是,C++11 引入了 nullptr 关键字用于表示空指针,它是 std::nullptr_t 类型的唯一一个值,可以隐式转换为任何指针类型。
一个 NULL 会出问题的例子
在 C 中,NULL 通常可能有多种定义的方式,例如:
C 允许 NULL 的这种松散的定义,并且这三种定义在 C 中一般都可以正常工作,但 C++ 不同。首先,在 C++ 中一定不能采用 (void *)0 这种定义,因为 C++ 不允许 void * 隐式转换为其他指针类型。我们一定不想让这样的写法报错:
那么如果,NULL 被定义为 0L,考虑下面的例子:
这里涉及函数重载。若 NULL 被定义为 0L,那么 f(NULL) 就会产生编译错误,因为 0L 既可以隐式转换为 int 也可以隐式转换为 char *,编译器无法确定调用哪一个 f。这是好的结果。更糟糕的是,如果 NULL 被定义为 0,那么 f(NULL) 就会调用 f(int),问题就会被推迟到运行时,并且极难排查。
类型转换¶
C++ 中的类型转换比 C 复杂得多。传统的 C 风格类型转换仍然可以使用,但是 C++ 向下细分出了 4 种类型转换。需要注意它们都是关键字而非函数或宏,只是语法上有点像模板函数:
static_cast:用于一些“普通”的转换,以及有继承关系的类之间的转换。- 用于基本类型之间的转换,例如
static_cast<int>(3.14)。 - 用于指针类型与
void *之间的转换,例如static_cast<void *>(ptr),其中ptr是任意指针类型。 - 用于兼容类型之间的转换,例如
static_cast<int &&>(i),其中i是int类型。 - 用于有继承关系的类之间的转换,例如
static_cast<Derived *>(base),其中base是Base *类型,并且Derived是Base的派生类。- 如果
Base与Derived之间没有继承关系,那么产生编译错误。 - 如果
base的对象实际上不是Derived类型,那么行为未定义。
- 如果
- 用于基本类型之间的转换,例如
dynamic_cast:利用运行时的额外信息(RTTI)来进行有继承关系的多态类之间的转换。- 例如
dynamic_cast<Derived *>(base)。- 如果
Base不是多态类,那么产生编译错误。 - 如果
Base与Derived之间没有继承关系,或base的对象实际上不是Derived类型,那么结果是nullptr。
- 如果
- 例如
const_cast:能够去掉 cv 限定符3的转换。- 例如
const_cast<int *>(i),其中i是const int *类型,或者volatile int *类型,或者const volatile int *类型。 - 用
const_cast通常不是个好主意,如果代码中存在很多const_cast,那么肯定存在更深层的设计问题。尽量避免使用它,除非万不得已。
- 例如
reinterpret_cast:用于不同类型之间的转换。- 例如
reinterpret_cast<void *>(0x10200030),或者reinterpret_cast<const uint8_t *>(p),其中p是任意非volatile限定的指针类型。
- 例如
上面列出的情况只是简单情况的描述,并非完整。可以看到上面四种转换的职责各不重叠,都有自己的应用场景。需要注意的是,当转换涉及到 OOP 时,操作的对象总是指针或引用,而非对象本身,这是因为多态性只有在指针或引用的情况下才能发挥作用,对应的细节及应用我们会在介绍类以及引用的时候详细讨论。
C 风格转换仍然可以使用,但应该极力避免,因为它经常被滥用、容易出错且完全不把类型当回事。在正确的场合使用正确的转换更好,并且更能体现出你是个 C++ 高手。
内存分配¶
C 中动态内存分配的常规做法是 malloc / free 函数,它们在 C++ 中仍然可用,但问题在于 malloc / free 无法与 C++ 的 OOP 方面很好地结合,只能用于分配未初始化的内存,并且 void * 仍然是类型系统的大漏洞。所以 C++ 引入了 new / delete 运算符,它们是 C++ 动态内存管理的基础,并且会考虑到 OOP 的方方面面。对于数组,C++ 使用 new[] / delete[] 运算符。需要注意使用 new[] 分配的内存必须使用 delete[] 释放,而不能使用 delete,否则产生未定义行为。反之亦然。
需要注意,对于基础类型,new 并不会替你做初始化;正如 int v; 声明的变量是没有初始化的并且具有不确定的值,在上面的例子中 *a 也是一样。如果需要初始化,可以使用 () 或 {} 语法,如 b 所示。在这种情况下 auto 就非常有用了,能够让我们免去一次类型声明的麻烦,如 b 与 l 所示。另外,c 就是 new[] 的例子,分配了足够容纳 10 个 double 值的空间,对应地需要使用 delete[] 释放。
但必须说明,new / delete 仍然是 C++ 中的底层内存管理方式,相比 malloc / free 来说,它适配了 OOP 的方面,但仅限于此;内存仍然需要手动管理,内存泄漏、越界、二次释放等问题仍然可能发生,所以应该谨慎使用。C++ 有更好的方式来管理内存,即使用智能指针,背后其实还涉及到称作 RAII 的设计模式,以后我们会看到。
重载¶
在 C++ 中,我们可以定义多个同名函数,只要它们的参数列表不同,这就是函数重载,这是 C++ 一个极其重要的特性。例如在 C 中,没有函数重载,所以我们只能使用不同的函数名来区分不同的函数:
| math.h | |
|---|---|
| inttypes.h | |
|---|---|
| tgmath.h | |
|---|---|
仅以计算绝对值为例,对于 7 个不同的类型,C 就需要 7 个不同的函数名!相信我们所有人都经历过给 abs 传递浮点数的痛苦。尽管 C11 引入了 _Generic 宏,但“宏”仍然是一种超脱 C 本身力量的工具。而在 C++ 中,我们可以使用同一个函数名 abs 来表示所有的绝对值函数:
| cmath | |
|---|---|
| cinttypes | |
|---|---|
我们在上面介绍 NULL 会出问题的例子中,也演示了函数重载。总而言之,编译器能够根据调用函数时参数的类型来选择正确的函数。需要注意的是,返回类型并不是函数重载的一部分,所以不能仅仅通过返回类型来区分不同的函数。下面的都是正确的重载:
而下面的则是错误的重载:
另外,需要区分重载与遮蔽。重载的函数一定在同一个作用域中,而遮蔽则发生在不同作用域中的同名变量或函数上:
这里发生的是遮蔽,而非重载;A::func 遮蔽了 ::func,所以在 A 的作用域中,func 总会被解析为 A::func,而不会去考虑 ::func。而 A::func 并不接受任何参数,所以第二个调用会产生编译错误,这种情况下必须调用 ::func。历年期末确实出现过这样的题目,特别是如果还涉及到继承的话。我们会在后面的章节中看到这样的问题。
结构体¶
在 C 中,结构体就仅仅是一系列类型的组合,并且在使用时需要显式写明 struct,或者使用 typedef:
| C | |
|---|---|
另外注意第 6 行的初始化方式,即 C99 引入的指派初始化式。C 的语法有时会导致一些微妙且令人困惑的问题:
在这个例子中,A 是一个变量名而非类型名,要引用结构体 A 必须使用 struct A。C++ 做出的一个改动是,结构体的定义与使用更加简洁,不再需要显式写出 struct 或者 typedef。下面的例子中,struct A 与 A 是等价的:
考虑 Bootstrap 中 vector 的例子:
在 C++ 中,结构体从“一系列数据的组合”进化为类,它可以包含数据成员、函数成员,以及控制“谁可以访问这些成员”的能力。C++ 同时引入了 class 关键字,但其与 struct 并无本质区别,只是默认的访问权限不同。在 C++ 中,我们就可以这样定义 vector:
我们可能还不熟悉这种写法。Construct & Destruct 会详细介绍对象的构造、析构与访问控制。
另外,与 C 一样,结构体与类都可以嵌套,也可以在函数内部定义,同样可以有位域。
默认参数¶
C++ 中的函数可以有默认参数,这是 C 没有的特性。默认参数必须从右向左依次出现,且只能在声明中出现一次。
用重载也可以实现类似的效果,但默认参数更加简洁。
有默认参数的函数同样可以被重载,但需要注意调用时可能会产生二义性:
所以在设计函数时,应该避免重载与默认参数的混用。
另外,需要注意的是,默认参数是在调用点确定的,而非在声明点。简单来说,这意味着当函数调用时,编译器会将默认参数替换到调用点,然后在运行到这里时才会真正计算默认参数的值。这样的设计使得默认参数可以是任意表达式:
但这是一个可以成为陷阱的地方!例如:
问题在于“宏”是一种更早的预处理阶段的概念,而默认参数是在编译阶段确定的。所以 __LINE__ 的值在 f 被声明时已经被替换为了一个确定的整数,在上面的例子中即 void f(int x = 3),这就是为什么每次调用 f() 时都会输出 3。
默认参数的用途
实际上,C++20 引入的 std::source_location 解决了这个问题,它反映了调用点的信息,可以用于调试。例如:
这样就可以在调试时输出调用点的文件名与行号。在 C++20 之前的传统写法是同样用宏来实现调试信息的输出,但总归不够优雅。
另外,还可以给已经有默认参数的函数再添加默认参数:
杂项¶
字符字面量的类型¶
sizeof('A') 在 C 与 C++ 会给出不同的结果;在 C 中结果通常是 4,而在 C++ 中结果一定是 1。这是因为在 C 中,字符字面量的类型是 int,而在 C++ 中是 char。这是少数 C 与 C++ 之间不兼容之处,尽管一般来说不会有什么问题。
字符串字面量的类型¶
此事在地址、指针、数组中亦有记载。C++ 中字符串字面量的类型是 const char [],而在 C 中是 char []。这也与历史原因有关,之前已经提到过 const 是 C 后期从 C++ 借鉴而来,如果 C 将字符串字面量的类型更改为 const char [],会破坏很多现有代码,于是 C 保持了原样。不管怎么说,在哪种语言中字符串字面量都不可被修改,否则是未定义行为。C++ 从语法上保证了这一点。
函数的 void 参数¶
在 C 中,与大多数人的认知不同的是,如果函数的参数列表为空,则表示函数接受的参数个数是未知的。必须要明确写出参数列表为空,即 void,这样才能表示函数不接受任何参数:
前一种写法是 K&R C 的历史遗留问题,而后一种写法是 ANSI C 以后的标准写法。在 C++ 中不存在这个区别,这两种写法是完全等价的。