《Effective C++》 笔记 1. 让自己习惯 C++
Notes Effective Cpp
Lastmod: 2020-09-16 周三 22:02:26

1. 让自己习惯 C++

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

C++ 是一个多重泛型编程语言(multiparadigm programming language)

C++ 同时支持过程procedural)形式、面向对象object-oriented)形式、函数functional)形式、泛型generic)形式、元编程metaprogramming)形式的语言。

C++ 的次语言sublanguage):

  1. C
  2. Object-Oriented C++
  3. Template C++
  4. STL

每个次语言有自己的规约。

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

因为 #define 是预处理器处理的部分,编译器不会认识对应的符号,则对应的符号不会进入符号表symbol table),这样在 debug 时就会变得难以追踪。

字符串常量

用 const 定义 char-based 字符串常量的指针需要写两遍 const

const char* const str = "str";

通常这种情况下 string 会好一些

const std::string str("str");

类的常量成员可以定义为

class C {
    static const int M = 5;
    int arr[M];
};

上面是 M 的声明式,而非定义式。对于整数类型(integral type)的类的常成员,如果只使用值则不需要给出定义式,否则需要提供如下的定义式。

const int C::M;

将这个式子放入实现文件,而非头文件,因为在声明时已经有初值,一次此处不能再设值。

#define 无法定义 class 的专属常量,因为 #define 不重视作用域(scope),也不能提供任何封装性。

旧式编译器如果不支持上述语法,那么需要如下写法

// 头文件中
class C {
    static const int M;
};
// 实现文件中
const int C::M = 5;

the enum hack

一个属于枚举类型的数值可以充当 int 使用。

class C {
    enum { M = 5 };
    int arr[M];
};

有时需要这种手段,因为无法取到一个 enum 的地址(通常 #define 也无法取到地址,但 const 的变量是可能取到地址的)。enum 也不会产生非必要的内存。

同时这也是模板元编程的基础。

#define 实现宏

#macros 经常被用来实现宏,但对于

#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

需要很多括号保证不会发生一些意外。但即便有括号还是会发生意外。

CALL_WITH_MAX(++a, b);

这会变成如下代码

f((++a) > (b) ? (++a) : (b));

a 和 b 的大小关系导致了 a 不同的自增数量。

此时可以选择使用

template<typename T>
inline void callWithMax(const T& a, const T& b) {
    f(a > b ? a : b);
}

预处理器的其他重要功能

#define 的上述功能可以被替代,但其他问题中预处理器依然扮演重要的角色,不能被替换。

#include #ifdef #ifndef

条款 03:尽可能使用 const

尽量指定不想更改的东西是 const 的。

const 作用于指针

char str[] = "str";
char* p = str;             // non-const pointer, non-const data
const char* p = str;       // non-const pointer, const data
char* const p = str;       // const pointer, non-const data
const char* const p = str; // const pointer, const data

void f1(const C* p); // 指向常量的指针
void f2(C const* p); // 指向常量的指针

const 作用于 STL 迭代器

// 迭代器不得指向别的东西,但可以更改迭代器指向对象的内容
// 例如不能 ++it
const std::vector<int>::iterator it = vec.begin(); 

// 迭代器所指向的内容不能更改,但可以更改指向的内容
// ++it 是可以的
std::vector<int>::const_iterator it = vec.begin(); 

令函数返回值是常量

令函数返回值是常量可以避免修改函数的返回值。

const A f();
// 避免本意想书写 if(f() == b) 但写成了如下形式 
if(f() = b) 

const 成员函数

注意两个常量性constness)不同的函数是可以被重载的。

class C {
public:
    const char& operator[](std::size_t pos) const {
        return text[pos];
    }
    char& operator[](std::size_t pos) {
        return text[pos];
    }
private:
    std::string text;
};

C c();
std::cout << c[0]; // 调用 char& C::operator[]()

const C cc();
std::cout << cc[0]; // 调用 const  char& C::operator[]()

void print(const C& c) {
    std::cout << c[0]; // 调用 const  char& C::operator[]()
}

bitwise const 与 logical constness

bitwise const 又称 physical constness:成员函数不改变任何(static以外)的成员变量时才可以说是 const 的。这也是 C++ 对常量性的定义。

但是很多函数可以通过 bitwise 测试却不是真的具有 const 的特性。例如一个更改了“指针所指物”的成员函数,虽然不符合 const,但只要“所指物”不属于对象,则可以通过 bitwise 测试。

例如

#include <iostream>
using namespace std;

class C {
public:
    C() {
        pText = new char[1];
        pText[0] = 'A';
    }

    // 这里虽然用了 const 但是实际上 pText 指向的内容可改
    char& operator[](std::size_t pos) const {
        return pText[pos];
    }
private:
    char* pText; 
};

int main()
{
    const C obj;
    cout<< obj[0] << "\n";
    obj[0] = 'Z';
    cout<< obj[0] << "\n";
    return 0;
}

logical constness:一个 const 成员函数可以修改它所处理的对象内的某些 bits,但只有在客户端监测不出的时候才行。

例如

class C {
public:
    std::size_t length() const {
        if (!lenIsValid) {
            len = std::strlen(pText);
            lenIsValid = true;
        }
        return len;
    }
private:
    char* pText;
    std::size_t len;
    bool lenIsValid;
};

但是上述代码显然不是 bitwise constness,因此无法使用 const 关键字。

此时可以使用 mutable 关键字释放掉 non-static 成员变量的 bitwise constness。

class C {
public:
    std::size_t length() const {
        if (!lenIsValid) {
            len = std::strlen(pText);
            lenIsValid = true;
        }
        return len;
    }
private:
    char* pText;
    mutable std::size_t len;
    mutable bool lenIsValid;
};

两个版本的 operator[] 的重复代码

注意只相差 const 的两个版本的函数可能包含很多重复操作。例如 operator[] 中其实可能包含边界检查(bounds checking)、日志访问(log access data)、检验数据完整性(verify data integrity)等等。此时我们需要常量性移除(casting away constness)。这样我们就可以用一个版本调用另一个版本了。

#include <iostream>
using namespace std;

class C {
public:
    C(){
        text = "ABC";
    }
    const char& operator[](std::size_t pos) const {
        return text[pos];
    }
    char& operator[](std::size_t pos) {
        return
            const_cast<char&>(               // 将 operator[] 返回值的 const 移除
                static_cast<const C&>(*this) // 为 *this 加上 const
                    [pos]
            );
    }
private:
    std::string text;
};

int main()
{
    const C const_obj;
    cout<< const_obj[0] << "\n";
    // obj[0] = 'B'; cannot compile

    C obj;
    obj[0] = 'B';
    cout<< obj[0] << "\n";

    return 0;
}

这里注意不要用 const 调用 non-const。因为 non-const 并不保证不更改任何东西。

编写代码时尽量要使用概念上的常量性(conceptual constness)。这样我们就可以用一个版本调用另一个版本了。

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

永远在使用对象前进行初始化。

对于内置类型,必须手工完成,其他类型初始化由构造函数完成。

注意赋值(assignment)和初始化(initialization)的区别。

class C {
    int a;
public:
    C(int a_) {
        a = a_; // 赋值 而非 初始化
    }
};

应该使用成员初值列表(member initialization list)。

class C {
    int a;
public:
    C(int a_)
        : a(a_)
    { }
};

虽然两个构造函数最终结果相同,但是通常后者效率较高。

编译器会对没有在成员初值列表的用户自定义类型(user-defined types)的成员变量自动调用默认构造函数。因此如果采用赋值方式,则这些变量会先被默认构造,之后再被赋值。

C++ 总有固定的初始化顺序。基类(base classes)总是先于派生类(derived classes)被初始化。成员变量按照声明次序被初始化,因此成员初值列表最好以声明顺序排列。

初始化 non-local static 对象

static 对象包括:

  • global 对象
  • 定义于 namespace 作用域内的对象
  • 在 class 内,函数内,file 作用域内被声明为 static 的对象

其中除了函数内的 static 对象称为 local static (对函数是 local 的),其他的对象称为 non-local static。

程序结束,也就是 main 函数结束时 static 对象的析构函数将会被调用。

编译单元(translation unit):产出单一目标文件(single object file)的源码,基本上它是单一源码文件加上其所含入的头文件。

如果一个编译单元的 non-local static 对象初始化时调用了另一编译单元的 non-local static 对象,则所用的对象可能尚未初始化,C++ 对定义于不同编译单元内的 non-local static 对象的初始化相对次序无明确定义。

可以将每个 non-local static 对象搬到自己的专属函数内,这些函数返回一个 reference 指向它所包含的对象。用户使用函数而不直接使用对象。这是 Singleton 模式的常见实现手法。

C++ 保证函数内的 local static 对象会在该函数被调用期间,首次遇上该对象定义式时被初始化。

// file A
class FS { ... };
FS & tfs() {
    static FS fs;
    return fs;
}

// file B
class Dir { ... };
Dir::Dir() {
    a = tfs().f();
}
Dir& tmpDir() {
    static Dir td;
    return td;
}

注意所有的 non-const static 对象,在多线程环境都会有和初始化有关的竞速形式(race condition)。一个麻烦的做法是,可以早单线程启动阶段(single-threaded startup portion)手工调用所有的 reference-returning 函数。

当然单线程中的初始化次序必须是合理的,不能存在相互依赖的初始化。

Prev: 《汇编语言》 笔记 实验 1 查看 CPU 和内存,用机器指令和汇编指令编程
Next: 《概率、统计与随机过程》 笔记 第 1 章 概率论导论