Effective C++笔记

01:把c++视为一个语言联邦

02:尽量以const, enum, inline替代#define

1. 对于单纯变量,最好用const对象或enum替换#define

​ #define由预处理器进行处理,因此可能不被编译器看见。当获得编译器错误信息时无法追踪(通过#define定义的变量可能未进入记号表)。而使用const对象则不会出现这样的问题。

​ 并且,#define无法限制作用域。当需要定义class的专属常量时,可以通过const定义,并且为了保证此常量至多一个实体,可以将其定义为static类型。

​ 旧式编译器可能不支持static在声明式中获得初值,那么可以将初值放在定义式。在此情况下,如果class编译期间需要用到class的常量(例如用class常量来定义class内数组的大小),则可以使用“the enum hack”补偿做法,这是因为一个属于枚举类型的数值可以被当作int使用。例如:

1
2
3
4
5
class GamePlayer {
private:
enum { NumTurns = 5};
int scores[NumTurns];
}

​ “enum hack”某些方面比较像#define,比如取地址不合法,也不会导致非必要的内存分配。

2. 对于形似函数的宏,最好用inline函数替代#define

​ 宏不会带来函数调用的额外开销,但是容易出问题。比如必须记住给所有实参加上小括号,但即时加上括号也会出现问题,例如:

1
2
3
4
5
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

int a = 5, b = 0;
CALL_WITH_MAX(++a, b); //a被累加2次
CALL_WITH_MAX(++a, b + 10); //a被累加1次

​ 可以使用template inline函数代替,这样不需要给每个实参加上小括号,也不需要操心参数被运算多次,例如:

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

03:尽可能使用const

1. 将某些东西声明为const可以帮助编译器侦测出错误用法。const可被施加于任何作用域内的对象、函数参数、函数返回类型和成员函数本体。

​ const允许指定一个语义约束,即该对象不可被改变,而编译器会确保这项约束。通过返回const对象,可以避免一些错误,例如:

const Taritonal operator* (const Rational& lhs, const Rational& rhs)返回const对象,如果用户将 if (a * b == c) 误输入成 if (a * b = c) 时,编译器可以察觉这种错误。若不返回const对象,则对返回值赋值的行为是允许的,也就是编译器会通过这种错误的行为。

​ 因此,除非需要改变参数或对象,否则应该将其声明为const。

​ 可以将成员函数声明为const,这将能改动对象和不可以改动对象的函数区分开来。并且,将成员函数声明为const有利于提高c++程序效率(通过pass by reference-to-const的方式传递对象)。两个只有常量性不同的成员函数(即一个为const成员函数,另一个不是)可以被重载。具体调用哪个函数取决于处理的对象是否为const。

2. 编译器强制实行“bitwise constness”,但编程时应该更多采用“logical constness”。

​ “bitwise constness”指的是const成员函数不改变任何一个non-static的成员变量。但是一些成员函数不具备const性质却不会引起编译器错误。例如:

若指向非常量的常量指针,只有指针属于对象,则改变该指针所指向的对象不会引起编译器错误(常量指针只确保指针指向同一个对象,不保证指向的对象的内容不改变)。例如:

1
2
3
4
5
6
7
class CTextBlock {
public:
char& operator[] (std::size_t position) const
{ return pText[position]; }
private:
char* pText;
}

这样一个const成员函数,它的返回值实际上可以被改变。这就导致了编译器无法提示的错误。

​ “logical constness”指的是const成员函数可以修改其对象内的某些内容,但是客户端无法侦察到(读了好多遍没读懂,应该是这个意思吧)

(这里有个例子,但我没搞明白为什么这个成员函数需要修改对象但是却声明为const,然后再通过mutable来消除const的约束)

3. 当const和non-const版本成员函数有着实质等价的实现时,令non-const版本调用const版本可以避免代码重复。

​ 当const和non-const版本成员函数有着实质等价的实现时,令non-const版本调用const版本是个安全的做法,即使在这个过程中需要转型。因为const成员函数相当于保证了绝不改变对象,但是non-const成员函数并没有保证。因此令non-const版本调用const版本成员函数不会带来风险,但是反之则可能会使const成员函数改变对象,造成错误。

​ 例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TextBlock {
public:
const char& operator[](std::size_t position) const
{
//边界检测
//日志数据访问
//检验数据完整性
return text[position];
}
char& operator[](std::size_t position)
{
return
const_cast<char&>(
static_cast<const TextBlock&>(*this)
[position]
);
}
}

​ 添加const的转型为安全转型,因此使用static_cast,移除const是通过const_cast完成。

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

1. 为内置型对象进行手工初始化,因为C++不保证初始化它们。

​ 读取未初始化的值会导致不确定的行为。如果使用C part of C++且初始化可能带来运行成本时,就无法保证初始化。

​ 对于内置类型,手工完成初始化;对于其他类型,在构造函数内保证将对象的每一个成员初始化。

1
int x = 0; //内置类型初始化
2. 构造函数最好使用成员初始列,而不要在构造函数内使用赋值操作。初始列列出的成员变量,其排列顺序应该和它们在class中的声明次序相同。

​ 赋值和初始化操作容易混淆。例如:

1
2
3
4
5
6
7
8
9
class ABEntry {
public:
ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones);
private:
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
};

赋值操作:

1
2
3
4
5
6
ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones) {
theName = name;
theAddress = address;
thePhones = phones;
numTimesConsulted = 0;
}

成员初始列:

1
2
3
4
5
6
ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones)
:theName(name),
theAddress(address);
thePhones(phones);
numTimesConsulted(0)
{ }

​ 两个构造函数的结果相同,但是成员初始列的效率较高。赋值操作首先调用默认构造函数为theName、theAddress和thePhones设初值,然后再赋予新值。而成员初始列是以name、address和phones为初值对theName、theAddress和thePhones调用拷贝构造函数。因此后者较为高效。

​ 并且,成员初始列也能够调用默认构造函数,只需要不指定初始化实参即可。例如:

1
2
3
4
5
6
ABEntry::ABEntry()
:theName(),
theAddress();
thePhones();
numTimesConsulted(0) // 这里不知道为什么不调用默认初始化函数
{ }

​ 如果成员变量是const或者引用类型,则一定需要初值,而不能被赋值。

​ 如果class内有多个构造函数,可以将一些“赋值表现和初始化一样好”的成员变量不使用成员初始列,而是改用赋值操作。并且可以将赋值操作移到某个函数(通常private)来供所有构造函数调用。

3. 为避免“跨编译单元的初始化次序”问题,使用local static对象替换non-local static对象。

​ C++有固定的“成员初始化”次序:基类早于派生类被初始化,成员变量以声明的次序初始化。因此,成员初始列最好以成员变量的声明次序为次序。但是对于不同编译单元的“non-local static”对象,仍然可能出现初始化次序的问题。

​ 编译单元指的是产出单一目标文件的源码,通常是单一源码文件加上包含的头文件。static对象的生命周期是从构造出来到程序结束。函数内的static对象称为local static对象,其他称为non-local static对象。因此,可能遇到的问题是:某个编译单元对象的初始化用到了另一个编译单元的non-local static对象,但是不能保证该non-local static对象已经被初始化,因此可能结果出错。例如:

1
2
3
4
5
6
class FileSystem {
public:
std::size_t numDisks() const;
...
};
extern FileSystem tfs;
1
2
3
4
5
6
7
8
9
10
class Directory {
public:
Directory( params );
...
};
Directory::Directory( params ) {
std::size_t disks = tfs.numDisks();
...
}
Directory tempDir( params );

​ 如上,想要程序正确运行就需要确保tfs在tempDir之前初始化,但是C++对这里的次序并没有明确定义,也就是无法保证。

​ 解决方式是使用单例模式的一个常见实现方法:将non-local static对象放在函数内实现,函数再返回一个引用对象指向它所含的对象。因为C++保证函数内的local static对象会在“函数调用期间”“首次遇到该对象的定义式”的时候被初始化,因此可以保证通过函数调用获取的对象的引用一定完成了初始化。并且,如果不调用这个函数,那么构造函数和析构函数的成本也不需要了,相比于local static对象可以节省开销。用例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class FileSystem { ... };
FileSystem& tfs() {
static FileSystem fs;
return fs;
}
class Directory { ... };
Directory::Directory( params ) {
std::size_t disks = tfs().numDisks(); // 调用tfs对象改为调用tfs()函数
...
}
Directory& tempDir() {
static Directory td;
return td;
}

​ 但是这种含有static对象的函数在多线程系统中存在不确定性:任何一种non-const static对象(不管local还是non-local)在多线程环境下“等待某件事发生”都会存在麻烦。处理这种麻烦的方式之一是:在程序的单线程启动阶段手工调用所有这种返回引用对象的函数,这样就可以消除初始化有关的“竞速形势”(race conditions)。

05:C++默默编写并调用的函数

编译器可以为class自动创建默认构造函数、拷贝构造函数、拷贝赋值函数和析构函数。
  • 所有构造的这些函数都是public且inline的。

  • 只有当这些函数被调用的时候才会被编译器创建出来。

  • 除非class的基类自身声明了virtual析构函数,否则编译器产生的析构函数是non-virtual类型。

  • 只要自己声明了一个构造函数,那么编译器就不会自动创建默认构造函数。

  • 编译器创建的拷贝构造函数和拷贝赋值函数只是单纯将来源对象的每一个non-static成员变量拷贝到目标对象。例如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template<typename T>
    class NamedObject {
    public:
    NamedObject(const char* name, const T& value);
    NamedObject(const std::string& name, const T& value);
    ...
    private:
    std::string nameValue;
    T objectValue;
    };
    1
    2
    NamedObject<int> no1("Smallest Prime Number", 2);
    NamedObject<int> no2(no1); // 调用拷贝构造函数

    ​ no2是通过拷贝构造函数初始化的:no2.nameValue以no1.nameValue调用string的拷贝构造函数,no2.objectValue这里的类型为内置类型int,因此通过“拷贝no1.objectValue内的每一个bit”来完成初始化。

  • 如果编译器自动创建的拷贝构造函数和拷贝赋值函数不合法,那么就不会自动创建。例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<typename T>
    class NamedObject {
    public:
    NamedObject(std::string& name, const T& value);
    ...
    private:
    std::string& nameValue; // 修改为指向string的引用
    const T objectValue; // 修改为const类型
    };
    1
    2
    3
    4
    5
    std::string newDog("Persephone");
    std::string oldDog("Satch");
    NamedObject<int> p(newDog, 2);
    NamedObject<int> s(oldDog, 36);
    p = s;

    ​ 如果自动创建了拷贝赋值函数,那么p.nameValue就改为s.nameValue,也就是引用的指向被改变了,但是这是不合法的。因此C++不会为NamedObject自动创建拷贝赋值函数。对于含有const成员的class,编译器也不会自动创建。此外,对于将拷贝赋值函数声明为private的基类,其派生类将不会被自动创建拷贝赋值函数。

06:若不想使用编译器自动生成的函数,则该明确拒绝

作者

Hyeee

发布于

2023-02-01

更新于

2023-02-01

许可协议