大家好!我是大聪明-PLUS!
介绍
C++ 有不少特性都可能存在潜在危险------设计上的缺陷或粗心的编码很容易导致错误。其中一些可以追溯到它艰难的 C 语言背景,一些可以追溯到过时的 C++98 标准,但另一些则是现代 C++ 本身固有的特性。让我们来看看其中几个主要问题,并尝试提供一些建议来减轻它们的负面影响。
1. 类型
1.1 条件语句和运算符
为了与 C 语言兼容,if(...)任何数值表达式或指针(而不仅仅是类似 `[]` 的表达式)都可以替换到 `[]` 指令和类似语句中。算术表达式中`[]` 到 `[]`bool的隐式转换以及某些运算符的优先级问题加剧了这一问题。例如,这会导致如下错误:bool``int
if(a=b)当它正确时if(a==b),
if(a<x<b)当它正确时if(a<x && x<b),
if(a&x==0)当它正确时,if((a&x)==0)当它正确时,当它正确时。
if(Foo)``if(Foo())
if(arr)``if(arr[0])
if(strcmp(s,r))``if(strcmp(s,r)==0)
有些错误会生成编译器警告,但不会报错。代码分析器有时也能提供帮助。在 C# 中,这类错误几乎不可能发生;语句if(...)和类似类型都要求使用特定类型bool,并且bool在算术表达式中混合使用数值类型是被禁止的。
如何战斗:
- 程序运行过程中不会发出警告。遗憾的是,这并非总是有效;上述某些错误并不会生成警告。
- 使用静态代码分析器。
- 一个古老的 C 语言技巧:比较常量时,将其放在左边,像这样
if(MAX_PATH==x):。它看起来很传统(甚至有自己的名称------"尤达符号"),并且在我们讨论过的少数情况下有效。 - 尽可能广泛地使用限定词
const。不过,它并非总是有效。 - 训练自己编写正确的逻辑表达式:
if(x!=0)而不是使用if(x)`.`(尽管这里你可能会陷入运算符优先级的陷阱,请参见第三个示例。) - 务必格外小心。
1.2. 隐式转换
C++ 是一种强类型语言,但它大量使用隐式类型转换来缩短代码。这些隐式转换有时会导致错误。
最棘手的隐式转换是数值类型或指针与 `T` 之间的转换bool。bool这些int转换(为了与 C 兼容而必须进行)会导致 1.1 节中描述的问题。此外,可能导致数值精度损失的隐式转换(例如从 ` doubleT` 到 `T`的转换)int也并非总是合适的。在许多情况下,编译器会发出警告(尤其是在可能损失数值精度时),但警告并不等同于错误。在 C# 中,禁止数值类型之间的转换bool(即使是显式转换),而可能导致数值精度损失的转换几乎总是错误。
程序员还可以添加其他隐式转换:(1)定义一个不带关键字的单参数构造函数explicit;(2)定义一个类型转换运算符。这些转换会给基于强类型原则的保护机制带来额外的漏洞。
在 C# 中,内置的隐式转换数量要少得多;用户定义的隐式转换必须使用implicit.
如何战斗:
- 程序运行过程中未发出任何警告。
- 务必谨慎使用上述结构,除非绝对必要,否则不要使用。
2. 名称解析
2.1 隐藏嵌套作用域中的变量
在 C++ 中,适用以下规则。令
`
{
`int` `x`;
`// ...`
{
`int` `x`;
`// ...`
}
}`
根据 C++ 规则х,在 `init` 块中声明的变量会隐藏在 `init` 块中声明的Б变量。第一个声明不必在 `init` 块中:它可以是类成员或全局变量,只需在 `init` 块中可见即可。х``А``x``Б
现在让我们设想一下,我们需要重构以下代码。
`
{
`int` `x`;
`// ...`
{
}
}`
更改是误操作造成的:
`
{
`int` `x`;
}`
现在,代码"do something with хfrom "将对fromА执行某些操作!显然,一切都不再像以前那样运行了,而且找到问题所在往往非常困难。难怪 C# 禁止隐藏局部变量(尽管允许隐藏类成员)。值得注意的是,几乎所有编程语言都以某种形式使用了变量隐藏。х``Б
如何战斗:
- 尽可能在最小的作用域内声明变量。
- 不要编写过长且嵌套过深的代码块。
- 使用编码约定在视觉上区分不同作用域的标识符。
- 务必格外小心。
2.2 函数重载
函数重载是许多编程语言的固有特性,C++ 也不例外。然而,必须谨慎使用这一特性,否则可能会导致问题。在某些情况下,例如构造函数重载,程序员别无选择,但在其他情况下,避免重载则完全合理。让我们来看看使用函数重载时会出现哪些问题。
在考虑重载解析过程中可能出现的所有情况时,重载解析规则变得相当复杂,因此难以预测。模板函数和内置运算符重载进一步增加了复杂性。C++11 引入了右值引用和初始化列表的相关问题。
在嵌套作用域中搜索重载解析候选方案的算法可能会出现问题。如果编译器在当前作用域中找到任何候选方案,则停止进一步搜索。如果找到的候选方案不合适、相互冲突、已被删除或无法访问,则会抛出错误,但不会尝试进行进一步搜索。只有当当前作用域中没有候选方案时,搜索才会转移到下一个更大的作用域。
函数重载会降低代码的可读性,从而引入错误。
使用带有默认参数的函数表面上与使用重载函数类似,当然,前者潜在问题更少。然而,可读性降低和潜在错误的问题依然存在。
应特别注意虚函数的重载和默认参数的使用,请参阅第 5.2 节。
C# 也支持函数重载,但重载解析规则略有不同。
如何战斗:
- 避免过度使用函数重载,并避免设计带有默认参数的函数。
- 如果函数有重载,请使用不会在解析重载时引起疑问的签名。
- 不要在嵌套作用域中声明同名函数。
- 不要忘记,
=deleteC++11 中引入的删除函数机制()可以用来禁止某些重载选项。
3. 构造函数、析构函数、初始化、删除
3.1 编译器生成的类成员函数
如果程序员没有定义以下列表中的类成员函数------默认构造函数、复制构造函数、复制赋值运算符、析构函数------编译器可以为其生成这些函数。C++11 将移动构造函数和移动赋值运算符添加到了此列表中。这些成员函数被称为特殊成员函数。它们仅在被使用且满足特定于每个函数的附加条件时才会生成。请注意,这种用法可能相当隐蔽(例如,在实现继承时)。如果无法生成所需的函数,则会抛出错误。(移动操作除外,它们会被复制操作替代。)编译器生成的成员函数是公共的且内联的。
在某些情况下,编译器提供的这种辅助功能反而会适得其反。缺少用户自定义的特殊成员函数可能导致创建平凡类型,进而引发未初始化变量的问题(参见 3.2 节)。生成的成员函数是公共的,这并不总是与类设计一致。在基类中,构造函数必须是受保护的,有时还需要受保护的析构函数来更精确地控制对象的生命周期。如果一个类拥有原始资源句柄作为成员并拥有该资源,那么程序员必须实现复制构造函数、复制赋值运算符和析构函数。众所周知的"三大规则"指出,如果程序员定义了复制构造函数、复制赋值运算符或析构函数这三个操作中的至少一个,那么他们必须定义所有这三个操作。编译器生成的移动构造函数和移动赋值运算符也远非总是所需的。编译器生成的析构函数在某些情况下会导致非常隐蔽的问题,从而造成资源泄漏(参见 3.7 节)。
程序员可以禁止生成特殊成员函数;在 C++11 中,声明这些函数时必须使用构造"=delete";在 C++98 中,相应的成员函数必须声明为私有且未定义。
如果程序员对编译器生成的成员函数感到满意,在 C++11 中,他们可以显式地表明这一点,而无需简单地省略声明。为此,可以使用声明结构"=default"。这可以提高代码的可读性,并提供额外的访问控制功能。
在 C# 中,编译器可以生成默认构造函数,这通常不会引起任何问题。
如何战斗:
- 控制编译器生成特殊成员函数的过程。您可以自行实现这些函数,或者根据需要禁用它们。
3.2 未初始化的变量
构造函数和析构函数是 C++ 对象模型的关键要素。构造函数在对象创建时总是被调用,析构函数在对象删除时被调用。然而,由于与 C 语言的兼容性问题,C++ 引入了一种例外情况,称为平凡类型。平凡类型旨在模拟 C 类型和 C 变量生命周期,而无需强制调用构造函数和析构函数。如果 C 代码在 C++ 中编译和执行,其行为应与在 C 中相同。平凡类型包括数值类型、指针、枚举,以及由平凡类型构成的类、结构体、联合体和数组。类和结构体必须满足某些附加条件:不能使用用户自定义的构造函数、析构函数、复制或虚函数。对于平凡类,编译器可以生成默认的构造函数和析构函数。默认构造函数会重置对象,而析构函数不执行任何操作。但是,只有在初始化变量时显式调用该构造函数,才会生成并使用该构造函数。除非使用某种形式的显式初始化,否则平凡类型的变量将处于未初始化状态。初始化语法取决于变量声明的类型和上下文。静态变量和局部变量在声明时初始化。对于类,直接基类和非静态类成员在构造函数的初始化列表中初始化。(C++11 允许在声明时初始化非静态类成员;参见下文。)对于动态对象,表达式new T()会创建一个由默认构造函数初始化的对象,但new T对于平凡类型,它会创建一个未初始化的对象。当创建一个平凡类型的动态数组时,new T[N]其元素始终未初始化。如果创建或扩展一个实例std::vector<T>,并且没有提供显式初始化元素的参数,则保证会调用默认构造函数。C++11 引入了一种新的初始化语法------使用花括号。一对空花括号表示使用默认构造函数进行初始化。这种初始化方式适用于所有使用传统初始化的地方,并且现在可以在声明时初始化非静态类成员,这取代了在构造函数的初始化列表中进行初始化的方式。
未初始化的变量结构如下:如果它是全局定义的namespace,则所有位都设置为零;如果它是局部定义或动态定义的,则其位是随机的。显然,使用这样的变量会导致程序行为不可预测。
然而,技术进步并非停滞不前。现代编译器有时会检测到未初始化的变量并生成错误。代码分析器在检测未初始化变量方面做得更好。
C++11 标准库包含名为类型属性(头文件<type_traits>)的模板。其中一个属性允许您确定一个类型是否为平凡类型。该表达式的std::is_trivial<Т>::value值为真表示类型为平凡类型,true否则为假。T``false
C 结构体也常被称为普通旧数据 (POD)。POD 和"简单类型"在实际应用中可以被视为等价术语。
在 C# 中,未初始化的变量会引发错误,编译器会对此进行检查。引用对象字段默认已初始化,除非显式初始化。值对象字段要么全部默认已初始化,要么必须全部显式初始化。
如何战斗:
- 养成显式初始化变量的习惯。未初始化的变量应该很容易被发现。
- 尽可能在最小的作用域内声明变量。
- 使用静态代码分析器。
- 不要设计过于简单的类型。为了确保类型并非过于简单,只需定义一个用户自定义构造函数即可。
3.3 基类和非静态类成员的初始化顺序
在实现类构造函数时,需要初始化类的直接基类和非静态成员。初始化顺序由标准规定:首先,按照基类列表中声明的顺序初始化基类;然后,按照非静态成员的声明顺序初始化它们。当需要显式初始化基类和非静态成员时,可以使用构造函数初始化列表。然而,该列表中的元素顺序并不一定与实际初始化的顺序一致。如果列表元素在初始化期间引用了其他列表元素,则必须考虑到这一点。如果发生错误,则该引用可能指向未初始化的对象。C++11 允许在声明时(使用花括号)初始化非静态类成员。在这种情况下,它们无需在构造函数初始化列表中进行初始化,从而部分解决了这个问题。
在 C# 中,对象的初始化方式如下:首先,从基类对象到最后一个派生类对象依次初始化字段,然后按相同顺序调用构造函数。上述问题不会出现。
如何战斗:
- 按声明顺序维护构造函数初始化列表。
- 尽量使基类和类成员的初始化相互独立。
- 声明非静态成员时,请使用初始化方法。
3.4. 静态类成员和全局变量的初始化顺序
静态类成员以及namespace在不同编译单元(文件)中全局定义的变量,其初始化顺序由实现决定。如果这些变量在初始化期间相互引用,则必须考虑到这一点。否则,引用可能会指向尚未初始化的变量。
如何战斗:
- 采取特殊措施来防止这种情况发生。例如,使用首次使用时初始化的局部静态变量(单例)。
3.5.析构函数中的异常
析构函数不能抛出异常。违反此规则可能导致未定义行为,最常见的是程序崩溃。
如何战斗:
- 不要允许在析构函数中抛出异常。
3.6 删除动态对象和数组
如果创建了某种类型的动态对象T
T*` `pt` `=` `new` `T`(`/* ... */`);`
然后由操作员删除。delete
delete` `pt`;`
如果创建了一个动态数组
T*` `pt` `=` `new` `T`[`N`];`
然后由操作员删除。delete[]
delete`[] `pt`;`
不遵守此规则可能会导致未定义行为,这意味着任何事情都可能发生:内存泄漏、崩溃等。
如何战斗:
- 请使用正确的形式
delete。
3.7. 类声明不完整时的删除
该运算符的通用性可能会导致一些问题delete。它可以应用于类型指针void*,也可以应用于指向具有不完整(前向)声明的类的指针。delete当应用于类指针时,该运算符是一个两阶段操作:首先调用析构函数,然后释放内存。当应用于delete具有不完整声明的类指针时,不会发生错误;编译器会直接跳过析构函数调用(尽管会发出警告)。让我们来看一个例子:
class` `X`;
`X*` `CreateX`();
`void` `Foo`()
{
`X*` `p` `=` `CreateX`();
`delete` `p`;
}`
即使调用点delete缺少完整的类声明,这段代码也能编译通过X。Visual Studio 会发出以下警告:
warning C4150: deletion of pointer to incomplete type 'X'; no destructor called
如果存在X`and`的实现CreateX(),则代码会被链接;如果CreateX()它返回指向由 `and` 运算符创建的对象的指针new,Foo()则调用成功,析构函数不会被调用。显然,这可能导致资源泄漏,因此再次强调,密切关注警告信息至关重要。
这种情况并非人为造成;当使用智能指针类或描述符类时,就可能出现这种情况。编译器生成的析构函数也可能触发这种情况。标准智能指针受到保护,不会出现此错误,因此编译器会发出错误消息,但自定义类(例如智能指针)可能只会发出警告。
如何战斗:
- 程序运行过程中未发出任何警告。
- 显式声明析构函数,并将其定义在完整的类声明的范围内。
- 使用编译时检查。
4. 运算符、表达式
4.1 操作符优先级
C++ 中有很多运算符,它们的优先级并不总是很明确。结合律也不容忽视。而且编译器并非总能检测到这类错误。第 1.1 节中描述的问题使情况更加复杂。
我们举个例子:
std::сout<<c?x`:`y`;`
这条指示其实毫无意义。
`(`std::сout<<c`)`?x`:`y`;`
而不是
std::сout<<`(`c?x`:`y`);`
正如程序员最可能预期的那样。
以上所有语句都能编译通过,没有错误或警告。本例中的问题在于,`write` 运算符的优先级意外地高于其他<<运算符的优先级?:,并且存在从 `write`std::сout到 `write`的隐式转换void*。C++ 没有专门用于向流写入数据的运算符,因此我们只能使用重载,但这并不会改变优先级。理想情况下,用于向流写入数据的运算符的优先级应该非常低,与赋值运算符的优先级相同。低优先级运算符?:在其他情况下也会导致问题。事实上,当它是子表达式时(最简单的赋值运算符除外),都应该用括号括起来。
再举一个例子:表达式x&f==0实际上是 `a + x&(f==0)b`,而不是(x&f)==0程序员通常预期的 `a + b`。不知何故,位运算符的优先级很低,尽管从常识角度来看,它们应该属于算术运算符组,位于比较运算符之前。
另一个例子。整数乘以/除以 2 的幂可以用位移操作代替。然而,乘法/除法的优先级高于加法/减法,而位移操作的优先级低于乘法/除法。因此,如果我们x/4+1用位移操作代替表达式x>>2+1,我们会得到x>>(2+1),而不是(x>>2)+1我们想要的。
C# 的运算符集与 C++ 的运算符集几乎相同,优先级和结合性也相同,但由于类型和重载规则更严格,因此问题更少。
如何战斗:
- 不要吝啬使用括号;如有疑问,请务必使用。顺便一提,这样做通常可以提高代码的可读性。
4.2 操作员过载
C++ 允许重载几乎所有运算符,但应谨慎使用此特性。重载运算符的含义必须清晰明确。运算符优先级和结合性也应牢记在心;重载不会改变这些特性,并且应符合用户的预期(参见 4.1 节)。字符串连接时使用 ` +&`运算符就是一个很好的重载示例+=。某些运算符不建议重载。例如,以下三个运算符:`&` ,(逗号)、`& &&`、`& ||`。这是因为标准规定了这些运算符的操作数求值顺序(从左到右),并且对于后两个运算符,还规定了所谓的短路求值语义。然而,重载运算符不再保证这一点,这可能会给程序员带来意想不到的麻烦。重载 `&`(寻址)运算符也不建议。使用重载的 `&` 运算符的类型进行模板操作非常危险,因为模板可以使用该运算符的标准语义。
几乎所有运算符都可以同时以两种方式重载:既可以作为成员运算符,也可以作为自由(非成员)运算符。这种特性会使编程变得非常复杂。
如果进行重载,则必须遵守若干规则,具体取决于被重载的运算符。
C# 也支持运算符重载,但重载规则更严格,因此潜在的问题更少。
如何战斗:
- 请仔细考虑操作员过载问题。
- 不要对不建议过载的运算符进行过载操作。
4.3 子表达式的求值顺序
C++ 标准通常不定义复杂表达式中子表达式的求值顺序,包括调用函数时参数的求值顺序。(例外情况是四个运算符:,逗号、逗号、&&逗号||、逗号?:。)这会导致不同编译器编译的表达式具有不同的值。以下是一个此类表达式的示例:
int` `x=0`;
`int` `y=`(`++x*2`)`+`(`++x*3`);`
该值y取决于增量的计算顺序。
如果在计算子表达式时抛出异常,在不利的情况下可能会发生资源泄漏。以下是一个示例。
class` `X`;
`class` `Y`;
`void` `Foo`(`std::shared_ptr<X>`, `std::shared_ptr<Y>`);`
暂且Foo()称之为:
Foo`(`std::shared_ptr<X>`(`new` `X`()), `std::shared_ptr<Y>`(`new` `Y`()));`
参数按如下方式求值:构造函数X,构造函数Y,构造函数std::shared_ptr<X>,构造函数std::shared_ptr<Y>。如果构造函数Y抛出异常,X则不会删除实例。
正确的代码可以这样写:
auto` `p1` `=` `std::shared_ptr<X>`(`new` `X`());
`auto` `p2` `=` `std::shared_ptr<Y>`(`new` `Y`());
`Foo`(`p1`, `p2`);`
更好的选择是使用模板std::make_shared<Y>(但它也有局限性,不支持自定义删除器):
Foo`(`std::make_shared<X>`(), `std::make_shared<Y>`());`
如何战斗:
- 仔细思考复杂表达式的构造。
5. 虚拟功能
5.1 重写虚函数
在 C++98 中,当派生类中的函数与虚函数在名称(析构函数除外)、参数、常量和返回值上完全匹配时,就会发生函数重写(返回值方面有一定的放宽,称为协变返回值)。关键字 `overrid` 的使用更令人困惑virtual;它可以被使用,也可以被省略。如果出现错误(例如简单的拼写错误),则不会发生函数重写;有时会发出警告,但通常是静默执行。程序员自然会得到与预期完全不同的结果。幸运的是,C++11 引入了关键字 `overrid` override,这大大简化了操作:所有错误都会被编译器检测到,代码的可读性也显著提高。然而,为了向后兼容,旧的虚函数重写方式仍然保留了下来。
如何战斗:
- 使用关键词
override。 - 使用纯虚函数。如果某个虚函数没有被重写,编译器在尝试创建类的实例时会检测到这一点。
5.2 重载和使用默认参数
应极其谨慎地使用虚函数的重载和默认参数。重载解析和默认参数更新基于调用虚函数的变量的静态类型。这与虚函数的动态特性相冲突,可能导致意想不到的结果。
如何战斗:
- 使用虚函数的重载和默认参数时务必谨慎。
5.3. 在构造函数和析构函数中调用虚函数
有时,在设计多态类层次结构时,需要在对象创建或销毁时执行多态操作。例如,可以暂且称之为"构造后"或"销毁前"的操作。人们首先想到的可能是在构造函数或析构函数中插入对虚函数的调用。但这其实是错误的。事实上,多态性在构造函数和析构函数中不起作用:总是会调用对应类的重写(或继承)函数。(因此,该函数可以是纯虚函数。)如果不这样做,就会对尚未创建(在构造函数中)或已经销毁(在析构函数中)的对象调用虚函数。需要注意的是,对虚函数的调用可以隐藏在另一个非虚函数中。
解决此问题的一种方法是使用工厂函数创建对象,并使用特殊的虚函数删除对象。
有趣的是,在 C# 中,基类构造函数中调用的虚函数实际上是继承链末端重写的虚函数。C# 中对象的初始化顺序如下:首先,从基类子对象到最后一个派生类子对象依次初始化字段;然后,按相同的顺序调用构造函数。因此,这样的虚函数可以应用于部分初始化的对象(字段已初始化,但构造函数尚未调用)。
如何战斗:
- 不要在构造函数和析构函数中调用虚函数,包括通过其他函数间接调用虚函数。
5.4 虚拟销毁器
如果设计多态类层次结构,基类必须具有虚析构函数。这确保当运算符应用于delete基类指针时,实际对象类型的析构函数会被调用。违反此规则可能会导致基类析构函数被调用,从而造成资源泄漏。
如何战斗:
- 将基类析构函数声明为虚函数。
6. 直接使用记忆进行工作
通过指针直接访问内存是 C/C++ 的关键特性之一,但同时也是最危险的特性之一。一个细微的错误就可能导致代码在分配的内存范围之外运行。最具破坏性的后果就是这类写入错误,通常被称为"内存溢出"。
在 C# 中,只有在不安全模式下才能直接访问内存,而不安全模式默认情况下是禁用的。
6.1 缓冲区溢出
C/C++ 标准库包含许多可以将数据写入目标缓冲区边界之外的函数,例如 `std::write()` strcpy()、strcat()` std::write()`、`std::write()` 等。sprinf()标准库容器(例如 `std std::vector<>::buffer()`、`std::write()` 等)在某些情况下不会检查缓冲区溢出。(但是,可以使用所谓的调试版本标准库,它实现了更严格的数据访问控制,当然,这会降低效率。请参阅MSDN 中的**"检查迭代器"部分**。)此类错误有时可能不易察觉,但也可能导致不可预测的结果:如果缓冲区基于栈,则可能发生任何事情,例如,程序可能会静默崩溃;如果缓冲区是动态的或全局的,则可能会发生内存保护错误。
在 C# 中,如果禁用不安全模式,则无法保证不会发生内存访问错误。
如何战斗:
- 使用字符串的对象版本,即向量。
- 使用标准容器的调试版本。
- 对于以 z 结尾的字符串,请使用安全函数;它们带有后缀
_s(请参阅相应的编译器警告)。
6.2. 以 Z 结尾的字符串
如果这样一行代码中丢失了终端空字符,那就糟了。例如,你可能会像这样丢失它:
strncpy`(`dst`,`src`,`n`);`
如果strlen(src)>=n,则dst不会出现终止空字符(当然,除非采取了额外的措施)。即使终止空字符没有丢失,也很容易将数据写入目标缓冲区末尾之外;参见上一节。不要忘记效率问题------查找终止空字符是通过扫描整个字符串来完成的。显然,比更高效if(*str),if(strlen(str)>0)对于大量长字符串,这种差异可能非常显著。
在 C# 中,该类型string能够以绝对可靠和尽可能高效的方式运行。
如何战斗:
- 使用字符串的对象版本。
- 处理以 z 结尾的字符串时,请使用安全函数;它们带有后缀
_s(请参阅相应的编译器警告)。
6.3. 参数个数可变的函数
...这类函数的参数列表末尾会有一个参数。最著名的例子是printf标准 C 库中包含的所谓"类函数"。在这种情况下,程序员必须仔细检查参数类型,通常需要比平时更频繁地进行显式类型转换,而编译器对此不提供任何帮助。错误通常会导致内存访问冲突,但有时也可能只是产生错误的结果。
C# 也有类似的printf功能,但运行更可靠。
如何战斗:
- 尽可能避免使用此类函数。例如,
printf使用 I/O 流代替类似函数。 - 务必格外小心。
7. 语法
7.1 复杂声明
C++ 在声明指针、引用、数组和函数方面有着相当独特的语法,这使得编写出难以阅读的声明变得很困难。例如:
const` `int` `N` `=` `4`, `M` `=` `6`;
`int` `x`, `// 1`
`*px`, `// 2`
`ax`[`N`], `// 3`
`*apx`[`N`], `// 4`
`F`(`char`), `// 5`
`*G`(`char`), `// 6`
(`*pF`)(`char`), `// 7`
(`*apF`[`N`])(`char`), `// 8`
(`*pax`)[`N`], `// 9`
(`*apax`[`M`])[`N`], `// 10`
(`*H`(`char`))(`long`); `// 11
这些变量可以描述如下:
int类型为;的变量- 指向
int; - 包含若干个元素的数组,
N元素类型为int; - 大小为 n 的数组,
N元素类型为指向 n 的指针int; char一个接收并返回值的函数int;- 一个接受
char并返回指向某个对象的指针的函数int; char指向接收并返回值的函数的指针int;- 数组的大小为 ,
N元素类型为指向接收char和返回函数的指针int; N指向类型为 elements size 的数组的指针int;- 指向数组的指针,数组元素的大小为
M,数组元素的大小为,N类型为int; - 一个函数,它接受
char并返回一个指向另一个函数的指针,该函数接受long并返回一个指针int。
请记住,函数不能返回函数或数组,也不能声明函数数组。(否则就更可怕了。)
在许多示例中,该符号*可以替换为 ` &&&`,这将导致引用声明。(但你不能声明引用数组。)
typedef可以使用中间语句(或别名语句)简化此类声明using。例如,最后一个声明可以重写如下:
typedef` `int`(`*P`)(`long`);
`P` `H`(`char`);`
解读这类广告需要一定的技巧,但不应滥用这种技巧。
C# 的声明语法略有不同,因此无法给出这样的示例。
如何战斗:
- 使用中间别名。
7.2 语法歧义
在某些情况下,编译器无法明确确定某些指令的语义。假设存在某一类指令。
class` `X`
{
`public`:
`X`(`int` `val` `=` `0`);
`// ...`
};`
在这种情况下,指令
X` `x`(`5`);`
x这是一个类型为 `T` 的变量的定义X,其初始值为 5。以下是指令。
X` `x`();`
x`` 是一个返回类型为 `` 的值且不接受任何参数的函数声明,而不是一个初始化为默认值的 ` ` 类型X变量的定义。要定义一个初始化为默认值的 `` 类型变量,您必须选择以下方式之一:x``X``X
X` `x`;
`X` `x` `=` `X`();
`X` `x`{}; `
这是一个老问题了,当时的决定是,如果一个构造既可以解释为定义又可以解释为声明,那么就选择声明。
请注意,在 C++ 中,函数可以局部声明(尽管这种风格并不常见)。此类函数必须在全局作用域中定义。(C++ 不支持局部函数定义。)
当然,这类错误不会导致严重后果;编译器肯定会在后续代码中检测到错误,但错误信息可能会造成一些困惑。
在 C# 中不存在这个问题,函数只能在类的作用域内声明,定义变量的语法也略有不同。
如何战斗:
- 记住这个问题。
8. 其他
8.1 关键词inline和在线争议解决率
许多程序员认为 `--line` 关键字inline是请求编译器尽可能将函数体直接内联到调用点。但实际上并非如此。`--line` 关键字inline还会影响编译器和链接器如何实现单一定义规则 (ODR)。我们来看一个例子。假设有两个文件定义了两个同名同签名但函数体不同的函数。在编译和链接过程中,链接器会生成重复符号错误;此时 ODR 生效。如果我们在函数定义中添加 `--line` 关键字static:就不会再出现错误,每个文件都会使用各自的函数版本,并且启用了本地链接。现在我们将其替换static为 `--line` inline。编译和链接过程会继续进行,不会出错,但两个文件都会使用相同的函数版本;ODR 仍然生效,但版本不同。显然,这可能会带来意想不到的麻烦。在类声明期间直接定义的类成员函数、模板函数和成员函数的处理方式类似。然而,在这种情况下,出现此类问题的概率要低得多。
如何战斗:
- 避免使用"裸
inline函数"。尽量将它们定义在类中,或者至少在代码块中定义namespace。这并不能完全保证不会出现此类错误,但可以显著降低其发生的概率。 - 使用本地绑定或更高级的技术------匿名
namespace。
8.2 头文件
不谨慎地使用头文件会导致很多问题。项目的所有部分都会变得相互依赖。头文件最终会出现在不需要的位置,用未使用的名称污染作用域,造成不必要的冲突,并增加编译时间和代码大小。
如何战斗:
- 仔细考虑头文件中的代码,特别是包含其他头文件的情况。
- 使用减少头文件依赖性的技术:前向声明、接口类和描述符类。
- 永远不要在头文件中或在其他头文件之前包含
using-指令:或-声明。using namespaceимяusing - 在头文件中使用带有本地链接的函数和变量时要谨慎。
8.3. 说明switch
一个典型的错误是break代码末尾缺少分支case(称为"向下传递")。在 C# 中,这类错误会在编译期间被检测到。
如何战斗:
- 务必格外小心。
8.4 按值传递参数
在 C++ 中,程序员必须自行决定函数参数的传递方式,是按引用传递还是按值传递;语言和编译器不会提供任何帮助。用户自定义类型的对象(声明为 `U`class或 `T` struct)通常按引用传递,但很容易犯错,导致按值传递。(对于习惯 C# 或 Java 编程风格的程序员来说,这类错误可能更为常见。)按值传递会复制参数,这可能会导致以下问题。
- 复制对象几乎总是比复制引用效率低。如果参数类型拥有资源并使用深度复制策略(例如,`getitem`
std::string、std::vector`getitem` 等),则会复制该资源,这通常是完全不必要的,并会导致进一步的效率损失。 - 如果一个函数修改了一个对象,这些更改将应用于本地副本;调用上下文将看不到它。
- 如果参数类型相对于实参类型具有派生类型,则会发生所谓的切片,所有关于派生类型的信息都会丢失,也就没有必要讨论任何多态性了。
如果函数必须修改对象,则参数必须按引用传递;否则,应按常量引用传递。异常必须始终按引用捕获。虽然按值传递参数是可行的,但很少需要这样做,并且应该仔细论证其合理性。例如,在标准库中,迭代器和函数对象都是按值传递的。设计类时,可以禁止按值传递该类的实例。一种比较粗略的方法是声明复制构造函数为 `deleted( =delete)`;一种更巧妙的方法是声明复制构造函数为 ` explicit.`
在 C# 中,引用类型参数按引用传递,但对于值类型参数,程序员必须控制传递类型。
如何战斗:
- 务必格外注意,实现正确的参数传递类型。
- 如有必要,请禁用按值传递参数的功能。
8.5 资源管理
C++ 缺乏垃圾回收等自动资源管理功能。程序员必须自行决定如何释放未使用的资源。C++ 的面向对象特性允许实现必要的资源释放机制(通常有多种实现方式),并且 C++11 标准库包含了智能指针。然而,程序员仍然可以像 C 语言那样手动管理资源。在这种情况下,避免资源泄漏的唯一方法是格外注意并做到精确无误。
C# 内置了垃圾回收器,可以解决大部分资源管理问题。然而,垃圾回收器并不适用于操作系统内核对象等资源。对于这类资源,通常需要使用基于基本 Dispose 模式的手动或半自动(using 代码块)资源管理方法。
如何战斗:
- 使用面向对象特性(例如智能指针)来管理资源。
8.6. 自有链接和非自有链接
本节中,"引用"一词将作广义使用。它可以是原始指针、智能指针、C++ 引用、STL 迭代器或类似的东西。
引用可以分为拥有引用和非拥有引用。拥有引用保证其所指向对象的存在。只要至少有一个指向该对象的引用可用,该对象就不能被删除。非拥有引用则不提供这种保证。非拥有引用随时可能变成悬空引用,即指向一个已被删除的对象。拥有引用的例子包括指向 COM 接口的指针和标准库中的智能指针(当然,前提是正确使用)。尽管存在固有的风险,非拥有引用在 C++ 中仍然被广泛使用。其中一个主要例子是标准库中的容器和迭代器。标准库中的迭代器就是一个典型的非拥有引用。容器可以被删除,而迭代器却毫不知情。此外,由于容器内部结构的改变,迭代器在容器的生命周期内可能会失效("悬空"或指向另一个元素)。但程序员们已经使用这种引用几十年了。
在 C# 中,几乎所有引用都由所有者拥有,垃圾回收器负责清理这些引用。委托封送处理是少数例外之一。
如何战斗:
- 使用自有链接。
- 使用非自有链接时,请务必谨慎准确。
8.7 二进制兼容性
C++ 标准对对象的内部结构以及其他实现方面(例如函数调用机制、虚函数表格式和异常处理机制)的规定非常有限。(甚至内置类型的大小也不是固定的!)所有这些都由平台和编译器决定。模块之间通常通过头文件进行交互,而头文件在每个模块内部是单独编译的。因此,不同编译器编译的模块之间自然会出现兼容性问题。即使是使用同一编译器但采用不同编译选项编译的模块,也可能不兼容。(例如,结构体成员的偏移量可能会因对齐参数的不同而有所差异。)
C 语言的二进制兼容性稍好一些(但仍然不完全),因此 C++ 模块经常使用 C 函数(在 <c> 代码块中声明)作为接口extern "C"。所有 C/C++ 编译器对这些函数的处理方式都相同。
为了解决结构体成员对齐方式不一致的问题,有时会添加存根成员。#pragma编译器指令可以用来控制对齐方式,但它们没有标准化,并且取决于编译器。
异常机制还假定模块之间具有高度兼容性,因此如果无法保证这一点,则必须使用返回码。
例如,COM 标准的制定就解决了二进制兼容性问题。不同模块中使用的 COM 对象是二进制兼容的(即使它们使用不同的编程语言编写,更不用说不同的编译器了)。然而,COM 并非一项非常流行的技术,而且并非所有平台都实现了它。
C# 几乎没有二进制兼容性问题。或许唯一的例外是对象封送处理,但这与其说是 C# 本身的问题,不如说是 C# 与 C/C++ 交互方面的问题。
如何战斗:
- 了解这个问题并做出适当的决定。
8.8 宏
编写宏时需要格外小心谨慎;由于预处理器的原始性,这里的问题会成倍增加。代码会变得难以阅读,潜在的错误也极难检测。C++ 提供了许多替代宏的方法。
#define XXL 32
你可以写
const` `int` `XXL=32`;`
或者使用枚举。除了使用带参数的宏之外,您还可以定义inline函数和模板。
C# 中没有宏(条件编译指令除外)。
如何战斗:
- 除非绝对必要,否则不要使用宏。
9. 结果
- 利用编译器特性来预防错误。将编译器配置为发出最大数量的警告。尽量避免发出警告。如果警告数量过多,就很难发现真正危险的错误。
- 使用静态代码分析器。
- 不要使用过时的 C 语言。请使用 C++ 编程,最好是现代版本------C++11/14/17。
- 以面向对象的方式进行编程,使用面向对象的库和模式。
- 避免使用过于复杂和可疑的语言结构。