《Effective C++》第一部分:让自己习惯C++

C 是一种简单的语言。它真正提供的只有有宏、指针、结构、数组和函数。不管什么问题,C 都靠宏、指针、结构、数组和函数来解决。而 C++不是这样。

条款1:视 C++ 为一个语言联邦

提要

  1. C++ 高效编程守则视状况而变化,取决于你使用 C++ 的哪一个部分。

解释

C++ 非常复杂,我们可以将其看作是一个多重泛型编程语言,同时支持过程式、面向对象、函数式、泛型、以及元编程。因此我们最好将 C++ 看作是多个纯粹的次级语言的复合而非单一语言。次语言内部规则简单而明确,这有助于我们理解 C++。主要的次语言包括:

  • C。C 终究是 C++ 的基础,因此不可能不谈到 C,C++ 中的基本语法基于代码块、预处理器、内置数据类型、指针等等元素都来自与C。但是如果像使用 C 一样的使用 C++ ,就会暴露出 C 自身的一些局限性,比如没有模板、没有异常、没有重载。
  • Object-Oriented C++。是 C++ 主要对 C 的升级部分,是面向对象原则在 C++ 上的最直接实施。
  • Template C++。他带来了全新的编程泛型,也就是所谓的template metaprogramming(TMP,模板元编程)。TMP规则很少与 C++ 主流变成相互影响。【$$$\Rightarrow Item48$$$】
  • STL。主要涉及对容器、迭代器、算法以及函数对象的规约。

四个次语言各自有自身的最佳编程范式,因此 C++ 高效编程守则取决于你使用 C++ 的哪一部分。比如,纯 C 部分中使用内置类型时,传值比传引用更高效,然而在 object-oriented C 中由于构造和析构函数的存在,传常量引用的方式右边的更好。而在 STL 中,迭代器和函数都是基于指针的,因此,传值方式又变成较优方式了。【$$$\Rightarrow Item20$$$】

条款2:尽量以 const,enum,inline 替换 #define

提要

  1. 对于单纯常量,最好以 const 对象或者 enum 替换 #define。
  2. 对于形似函数的宏,最好改用 inline 函数替换 #define。

解释

该原则的实质其实是尽量用编译器而不用预处理。宏定义导致符号在编译阶段已经完全消失,因为都被预处理替换为对应的值了。这样的话如果出错,编译器无法给我们原始的变量名信息来帮助我们定位错误。

宏定义可以简单的用常量来替换。除此之外,用宏来实现函数的效果也不是好的做法,直接用类型模板的inline函数可以实现同样的效果,好处是可预计的行为和类型安全。因为用了类型模板,为了防止拷贝构造带来的代价,可以使用常量引用的方式传参以及返回。【$$$\Rightarrow Item20$$$】

需要说的一种特殊情况是常量指针。常量丁一式通常放在头文件内以被不同的文件引用,因此有必要将指针自身修饰为const类型。【$$$\Rightarrow Item3$$$】

另外一种特殊情况是类内的常量。为了确保该常量只存在一份拷贝,需要 static 关键字。一般类的静态成员需要在类内(头文件中)声明,类外(实现文件中)定义并给出初值(有的编译器也可以在类内直接提供初值)。有的时候,我们在编译时就需要使用一些类内常量的值,比如定义一个常量表示长度,然后另一个数组变量需要使用该长度,但是此时还没有初值。补偿做法是使用“enum hack”,enum 的行为十分类似于 define。【$$$\Rightarrow Item18; 48$$$】

条款3:尽可能使用 const

提要

  1. 使用 const 声明可以帮助编译器检测出错误用法。const 可被施加于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
  2. 编译器强制实施bitwise constness,但是我们在编写程序时应考虑到logical constness。
  3. 当 const 成员和 non-const 成员函数有着实质等价的表现时,令 non-const 版本调用 const 版本可以避免代码重复。

解释

使用 const 的好处在于它允许指定一种语意上的约束————“某种对象不能被修改”————并且由编译器具体来实施这种约束。

const和指针的结合初学者很容易记混,看到这里的读者按理来说不应分不清这两者,如果真的有点忘了,可以参考我的C++备忘录的第26条。

STL迭代器的作用就像一个T*指针,将迭代器用const修饰是让迭代器自身为常量,即不可以改变其指向,但是指向的东西可以改变。如果希望迭代器指的东西不可改动,那就使用const_iterator

对于函数,const也有很多用武之地。const作用于参数和局部变量的情况无需多说,const作用于函数返回值的情况却不多见,但是这有时可以避免很多无意中导致的错误,并且可以保证我们定义出“良好的用户自定义类型”。【$$$\Rightarrow Item24;18$$$】

最复杂的场景,莫过于const成员函数了,const 使得该成员函数可以作用于 const 对象身上。更详细的说,第一,他们可以使接口变得清晰:我们可以放心的使用 const 成员函数并相信他们不会修改对象的局部数据。第二,我们都知道“pass-by-reference-to-const”方式可以改善效率,而const成员函数是唯一可以对常量对象进行操作的办法。请注意,const成员函数是会导致重载的发生的。【$$$\Rightarrow Item20$$$】

等等!回头看上面写的第一条:“我们可以放心的使用 const 成员函数并相信他们不会修改对象的局部数据。”但是…我们真的可以这么放心吗?

那要看我们对于“const”如何定义规则。对于这个问题,主要有两种声音,“physical constness”派和“logical constness”派,前者其实就是形式主义,后者更像是实质主义。在法律上形式合法和实质合法同等重要,但是通常,形式审查更加简单、效率更高;而实质审查会更灵活、更正义,但是也更复杂。C++ 遵循的规则呢,正是“physical constness”,也就是说,一个 const 成员函数,只要没有对 non-static 成员变量进行赋值,那就符合规则。

但是,这就有漏洞可循了,看下面的代码片。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Text{
public:
Text(char* pp): pText(pp) {};
char& operator[](std::size_t position) const {return pText[position]; }
private:
char* pText;
};

int main(){
const Text t("hello");
char* pc = &t[0];
*pc = 'H';
}

可以看到上面的const成员函数没有赋值操作,不违反编译器的“形式审查”,因此编译通过,但是在我的环境中,执行时却出现了令人恼火的“段错误”,这是因为,尽管该函数自身保持了对象的物理上的常量性,但是却将内部环境暴露出来。【$$$\Rightarrow Item28$$$】

另外,“logical constness”派主张只要类的使用者察觉不到对象改变队可以,比如一些从const成员方法,会修改自身出于效率考虑的缓存或计数器等变量时,外界是感受不到这种改动的。对于这种情况,C++提供了关键字mutable来修饰成员变量,对于mutable的变量,编译器在 const 方法内不再对其进行形式审查。

最后,我们提到过,const成员函数会导致重载的发生,假设一个函数非常量和常量两个版本中有大量的重复代码(比如参数检验,记录日志等等)怎么办?【$$$\Rightarrow Item30$$$】

很简单的答案:代理。但是是在const版本中调用non-const版本,还是反过来?答案是唯一的,是在non-const版本中调用const版本,原因应该不用多说,大家想一想就能明白。但是思路很简单,代码确有一定难度,因为这个过程中要用到强制转型(casting),而使用 casting 一般是一个糟糕的想法。【$$$\Rightarrow Item27$$$】

1
2
3
4
5
6
7
8
9
10
11
12
class A{
const char& operator[](std::size_t position) const{
// 一大堆业务代码
return text[position];
}
const char& operator[](std::size_t position) {
return const_cast<char&>( // 将返回值的const去除
static_cast<const TextBlock&>(*this) //将*this加上const,否则op[]会递归调用非const版本
[position] // 调用 const 版本
);
}
};

条款4:确定对象被使用前已先被初始化

提要

  1. 为内置型对象进行手工初始化,因为C++不保证初始化他们。
  2. 构造函数使用member initialization list,而不是在函数体内进行赋值。并且初始化顺序应当与类内的声明次序一致。
  3. 为了避免跨编译单元的初始化次序问题,使用local static对象替换non-local static对象。

解释

首先,你可以搞清楚“定义”、“声明”、“初始化”之间的关系吗?如果不行,请看一下C++备忘录的第27条。其次,你可以分清局部静态变量和非局部静态变量吗?如果不行,请移步C++备忘录的第29条。

纯C程序员可能认为初始化会带来运行时成本,但是 C++ 中应该记住一个简单的规则:永远在使用对象前将其初始化。对于没有成员的内置类型,必须立刻手工完成此事。对于内置类型之外的其他东西,责任落在了构造函数的头上。

但是使用构造函数中的初始化而不是赋值,简单地说就是使用成员初始列。区别在于,如果在成员在进入构造函数前会调用各自的default构造函数,而使用成员初始列的话,则会利用初始值各自调用拷贝构造。这里的原则是,为了清晰,对于所有的成员变量,都放在初始列中,对于一些没有初值的成员,也要放进去,区别是让其括号内没有处置,这样就调用成员的default构造函数了。

又时有的类有大量的成员,还有多个构造函数,这样的话,可能会有很多初始列是重复的。一个小技巧是对于内置变量,不放在初始列中也可以,因为他们的复制和初始化的代价是一样的,可以统一把他们放到一个小的私有函数中,在后再构造函数中调用它来避免丑陋的重复。

最后一种更复杂的情况就是不同编译单元内定义的非局部静态变量的初始化次序问题。问题就出现在,不同编译单元之间的费局部静态对象的初始化顺序是不被编译器保证的。这样的话,当一个编译单元内的non-local static对象的初始化涉及其他的编译单元内的non-local static对象时,就会有问题,因为那个对象可能还没有初始化。

解决方法就是将non-local static对象变成local static对象。即,把它们放到函数中,然后返回引用。你看出来了吗,这其实就是一个singleton模式!这有效的原因是:C++保证,函数内的局部静态对象会在“函数调用期间且首次遇到该对象的定义时”进行初始化。这还带来一个额外的好处就是,如果这个singleton函数不被真正调用,就绝不会带来构造和析构成本。

这种singleton函数是如此简单,使得他们很容易成为一个inline函数。【$$$\Rightarrow Item30$$$】

本站总访问量