Loading

Effective C++ 读书笔记(条款01~17)

写在前面:在读书笔记中,记录一些书中没有学习过的内容。方便以后不翻书直接看笔记,因此可能会少一些我之前就会的内容。

为了更好的阅读体验,请点击 这里

第一章:让自己习惯 C++

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

  • C
  • 面向对象
  • 模板
  • STL

这是当年(1998)年时候的认识,当然以我现在的认识而言,也没有变化多少,仍需继续学习。

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

替换宏常量

常量替换 #define 有两种特殊情况:

  1. 常量指针可能需要写 const 两次,一次 const 给指针,另一次 const 给指向的内容。另外请记住 const 是先修饰左后修饰右的修饰顺序。
  2. class 专属常量。
    1. 为确保常量作用域在类内,因此让它成为类的一个成员
    2. 为确保此常量至多只有一份实体,于是让它成为一个 static 成员
    3. 如果是一个类的专属常量、static、整型(int, char, bool)那么可以如下定义,在交叉编译时不会出现错误:
class GamePlayer {
private:
    static const int NumTurns = 5;
    int scores[NumTurns];
};

但是如果是其他类型(如 double、非 static 等),则无法如此定义。后续如果要取某个类专属常量的地址,那么则必须在实现(.cpp)文件中提供不给予数值的定义式:const int GamePlayer::NumTurns;

但假如编译器不支持上面的写法,也可以使用 enum:enum { NumTurns = 5 };

替换宏函数

用 template 模板来替换宏函数。至于书中的 inline 来修饰函数,现在近乎是由编译器自主决定,因此写不写差别不大。

条款 03:尽可能使用 const

STL 中的 const_iterator 可使指向内容无法被改变。

令函数返回 const

令函数返回 const 好处如下:

class Rational {...};
const Rational operator* (const Rational& lhs, const Rational& rhs);

Rational a, b, c;
if (a * b = c) // 这里显然有问题

代码最后一行显然有问题,如果返回 const 可以避免这种比较!

在现代 C++ 中,应当设计明确的接口(返回值、智能指针或 const 引用)来控制对象的可修改性,以此来替代返回 const。不过运算符重载仍建议返回 const,不过缺点在于后面初始化别的变量无法移动语义,只能拷贝构造。

常量成员函数

常量成员函数表示该函数不会修改调用对象的非静态数据成员。

两个成员函数如果只是常量性不同,可以被重载。
因此有如下代码,分别重载了常量和变量形式的 []

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

如果希望某些成员变量在常量成员函数中也能发生变化,那么请使用 mutable 来修饰该成员变量。

在 const 和 non-const 成员函数中避免重复

现在面临这样一个问题,如果上面的代码中,两个 [] 运算符要执行的内容都很长而且重复内容很多怎么办?

可以运用 const 成员函数来实现出其 non-const 孪生兄弟。比如上面的非常量成员函数可以利用常量性转除做到下面的事情:

char& operator[](std::size_t position) {
    return const_cast<char&>(
        static_cast<const TextBlock&>(*this)[position];
    )
}

与此同时,反向做法令 const 版本调用 non-const 版本,请不要这么做!

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

如果在构造函数中挨个给成员变量赋值,这样不够快,因为初始化的发生于这些成员的构造函数被自动调用之时(比进入类构造函数本体的发生时间更早),因此使用成员初始化列表(member initialization list)替换赋值动作。

A::A(std::string name):name(name) {}

书中建议:请规定总是在初始化列表中列出所有成员变量,以免还得记住哪些成员变量可以无需初值。

为避免需要记住成员变量何时必须在初始化列表中初始化,什么时候不需要,最简单的做法是:总是使用初始化列表。

如果类有多个构造函数,又不想疯狂地重复初始化列表,那么可以在初始化列表中遗漏那些“赋值表现像初始化一样好”的成员变量,改用它们的赋值操作,并将那些操作移往某个函数(通常是 private),供所有构造函数调用。

C++ 中的成员变量初始化次序是以其声明次序被初始化的。在初始化列表中,最好也以声明次序为次序。

不同编译单元内定义 non-local static 对象的初始化次序

还有一个新问题:不同编译单元内定义 non-local static 对象的初始化次序是怎样的?(non-local 指的是非函数内部)

假设 A 和 B 都是 non-local 静态对象,但是 B 初始化中需要调用 A,此时就产生了问题,A 初始化了吗?

可使用单例模式中比较常见的一种实现手法解决它:把这个对象搬到一个专属函数内(声明一个 static),然后返回一个引用指向这个 static 对象。如:

class FileSystem { ... };
FileSystem& tfs () {
    static FileSystem fs;
    return fs;
}
class Directory { ... };
Directory::Directory ( params ) {
    ...
    std::size_t disks = tfs().numDisks();
    ...
}
Directory& tempDir() {
    static Directory td;
    return td;
}

上面是书中的代码。这里代码建立在构造函数是 public 权限的基础上。

单例模式(Singleton)

如果要写单例模式的话,请把构造函数权限改为 private,并在成员函数 getInstance 前加上 static,代码如下:

class Singleton {
private:
    Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

public:
    static Singleton& getInstance() {
        static Singleton instance;  // 静态局部变量
        return instance;
    }
};

// 调用
Singleton a = Singleton::getInstance();

C++11 及以后的版本中,保证了静态局部变量的初始化是线程安全的。因此书中的担忧已是过去式。

我有一个疑惑,为什么单例模式必须用 static 的成员函数?原因在于,只有 static 的成员函数才能在不定义对象的前提下调用该成员函数。如果单例类 getInstance() 函数不加 static,那么私有权限的构造函数会导致无法定义对象。

第二章:构造/析构/赋值运算

条款 05:了解 C++ 默默编写并调用了哪些函数

声明类时如果不特殊注明,编译器会自动为它生命一个 copy 构造函数、一个 copy 赋值操作符和一个析构函数。此外如果没有声明任何构造函数,编译器会声明一个 default 构造函数。

编译器生成的 copy 赋值操作符行为基本上与 copy 构造函数一致。但是只有生成出的代码合法且有适当机会证明它有意义,才会生成,否则会拒绝生成 operator=。比如有个成员变量是个引用,然后写出了对象赋值这种语句,会报错。

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

将自动生成的函数声明到 private 中而且故意不实现它们。

C++11 之后,可以明确注明 = delete 的方式,表示不需要生成这个函数:

#include <iostream>

class NonCopyable {
public:
    // 默认构造函数
    NonCopyable() = default;
    // 析构函数
    ~NonCopyable() = default;

    // 显式删除拷贝构造函数
    NonCopyable(const NonCopyable&) = delete;
    // 显式删除拷贝赋值运算符
    NonCopyable& operator=(const NonCopyable&) = delete;
};

int main() {
    NonCopyable obj1;
    // 以下两行代码会导致编译错误,因为拷贝构造和拷贝赋值已被禁用
    // NonCopyable obj2(obj1);
    // NonCopyable obj3 = obj1;
    return 0;
}

在以前(C++11 之前)通常会使用 std::noncopyable(来自 Boost 库)来防止类的拷贝。在 C++11 之后,标准库中没有直接提供 std::noncopyable,但可以手动实现一个类似的基类。

#include <iostream>

// 手动实现一个 noncopyable 基类
class NonCopyableBase {
protected:
    NonCopyableBase() = default;
    ~NonCopyableBase() = default;
private:
    NonCopyableBase(const NonCopyableBase&) = delete;
    NonCopyableBase& operator=(const NonCopyableBase&) = delete;
};

class MyClass : private NonCopyableBase {
public:
    MyClass() = default;
};

int main() {
    MyClass obj1;
    // 以下两行代码会导致编译错误,因为拷贝构造和拷贝赋值已被禁用
    // MyClass obj2(obj1);
    // MyClass obj3 = obj1;
    return 0;
}

条款 07:为多态基类声明 virtual 析构函数

只有当 class 内含至少一个虚函数才为它声明虚析构函数。

  • 如果没有虚函数就声明虚析构函数那么会导致指向虚表的指针(vptr, virtual table pointer)多占用内存。
  • 如果有一个虚函数而不声明需析构函数的时候,想要发挥动态多态的特点析构的时候就会很难受,因为调用的是基类的析构函数而不是派生类的析构函数。

给基类一个虚析构函数,这个规则只适用于带多态性质的基类身上。当然,不是所有基类设计目的都是为了多态用途。

条款 08:别让异常逃离析构函数

有一种情况,多个对象的析构函数中发生异常,程序可能过早结束也可能出现不明确行为。

一种较好的策略是设计接口,提供一个 close 函数,可以让调用该类的程序员自行处理异常,如果不自行处理,那么在析构函数中再调用该函数。(反正已经甩锅了,谁让你不处理呢)在以前的 C++ 中,一般这么处理:

class DBConn {
public:
    ...
    void close() {
        db.close();
        closed = true;
    }
    ~DBConn() {
        if (!closed) {
            try {
                db.close();
            }
            catch (...) {
                记下对 close 的调用失败 // 记录下来
                // 分支选项 1. 结束程序
                // 分支选项 2. 吞下异常,继续执行
                ...
            }
        }
    }
}

当然,C++11 之后引入了 noexcept 选项,表示函数内部不会抛出异常,如果抛出异常,立刻触发崩溃,不会进行栈展开。而析构函数被默认添加了 noexcept(true),也就是说现在析构函数中出现异常默认情况会崩溃。

条款 09:绝不在构造和析构过程中调用虚函数

由于基类中在构造和析构过程中调用的虚函数是在基类中定义的虚函数,因此绝不能直接或间接在构造和析构过程中调用虚函数。

解决方式有很多,如书中要求先把虚函数改成非虚,派生类构造函数传递必要信息给基类的构造函数,而后那个构造函数即可安全地调用非虚函数。

class Transaction {
public:
    explicit Transaction(const std::string& logInfo);
    void logTransaction(const std::string& logInfo) const;
    ...
};
Transaction::Transaction(const std::string& logInfo) {
    ...
    logTransaction(logInfo);
}
class BuyTransaction: public Transaction {
public:
    BuyTransaction( parameters ): 
        Transaction(createLogString( parameters )) { ... }
    ...
private:
    static std::string createLogString( parameters );
};

代码中令函数为 static,这就不能指向没初始化的成员变量。

除此之外,还有别的书中没提及的方式,比如:

  • 二段式:构造函数仅完成​​非多态的基础初始化,然后再显式调用一个函数来完成多态逻辑
  • 工厂模式:通过工厂函数统一管理对象的构造和多态初始化,​​保证对象完全构造后再触发多态行为​​。比如 createXXX()

条款 10:令 operator= 返回一个 *this 的引用

为了能够实现这个句子:x = y = z = 15,因此成员函数中重载赋值运算符应写成:

Widget& operator= (int rhs) {
    ...
    return *this
}

条款 11:在 operator= 中处理“自我赋值”

莫名奇妙的自我赋值可能出现在代码的某个角落,如 i=j 时的 a[i] = a[j]

传统的防止方法是加入一个证同测试达到“自我赋值”检验的目的:

widget& widget::operator=(const widget& rhs) {
    if (this == &rhs) return *this; // 证同测试
    delete pb;
    pb = new bitmap(*rhs.pb);
    return *this;
}

不过作者希望能有“自我赋值安全性”,也有“异常安全性”。具体做法是先复制再删除:

widget& widget::operator=(const widget& rhs) {
    Bitmap* pOrig = pb;
    pb = new bitmap(*rhs.pb);
    delete pOrig;
    return *this;
}

这样在 new 的时候如果抛了异常炸出来,至少 this 中 pb 的内容是正常的,不会是被 delete 掉后的内容。

还有一种作者有些担忧的 copy and swap 技巧。代码如下:

class Widget {
    ...
    void swap(Widget& rhs);
    ...
};
widget& widget::operator=(const widget& rhs) {
    Widget temp(rhs);
    swap(temp);
    return *this;
}

如果赋值中的形参非引用而是值的话,那么可以不创建 temp,直接 swap 即可。

条款 12:复制对象时勿忘其每一个成分

设计对象时会把两个函数暴露出来用于拷贝,分别是拷贝构造函数和拷贝赋值操作符。

做复制对象时,如如下两点需要注意:

  1. 对象内所有成员变量都需要拷贝。默认生成的函数会全部拷贝,但是如果你要实现这样的函数,如果缺少了成员变量没有复制,编译器不会提示。因此如果你为类添加一个成员变量,必须同时修改拷贝函数。
  2. 所有基类的成分也需要拷贝。用派生类来调用相应的基类函数。代码如下:
class PriorityCustomer: public Customer {
public:
    ...
    PriorityCustomer(const PriorityCustomer& rhs);
    PriorityCustomer& operator=(const PriorityCustomer& rhs);
    ...
private:
    int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
  : Customer(rhs), priority(rhs.priority) {
    logCall("PriorityCustomer copy constructor");
}
PriorityCustomer&
PriorityCustomer::operator=(const PriorityCustomer& rhs) {
    logCall("PriorityCustomer copy assignment operator");
    Customer::operator=(rhs);
    priority = rhs.priority;
    return *this;
}

此外请注意,不要将两个 copy 操作混用,用 copy 构造函数调用 copy 赋值函数或是反过来都不建议这么操作。如果有大量的共同操作,请放到第三个函数中供两个函数调用。

第三章:资源管理

条款 13:以对象管理资源

本章介绍了 std::auto_ptrstd::tr1::shared_ptr 两个智能指针,不过第一个智能指针在 C++11 被弃用,C++17 中被移除。第二个转变成了今天的 shared_ptr

除此之外,它们在析构函数内做 delete 而不是 delete[] 动作。因此如果 new 了一个静态数组,请不要这样写std::auto_ptr<std::string> aps(new std::string[10]); 这样无法完全析构。为了解决这个问题,std::unique_ptr 在 C++11 后能够写成 std::auto_ptr<std::string[]> aps(new std::string[10]);,但是 std::shared_ptr 得在 C++20 才能这样写解决这个问题!

因此简单的绕过这个问题的方法就是使用 STL 容器即可。比如拿个 std::vector 或者 std::array 包装一下。

条款 14:在资源管理类中小心复制行为

假如说一个遵守 RAII 的资源管理类中有一个互斥锁或者控制其他资源的成员变量,如果它被复制了,应该怎么处理呢?

  1. 禁止复制,继承一下 Uncopyable
  2. 对底层资源使用 shared_ptr 来管理,不过要记得指定其删除器为解锁(或是其他释放资源的方式),这样在引用次数为 0 的时候会使用删除器。
  3. 复制底层资源。
  4. 转移底部资源的拥有权

条款 15:在资源管理类中提供对原始资源的访问

资源管理类中可能要提供对原始资源的访问,如 shared_ptr 提供了 get() 函数用于返回智能指针内部的原始指针(的复制),同时也重载了运算符 ->*

如果设计了一个 RAII 对象,要取得该对象内的原始资源,有两种做法:

  1. 显式提供 get() 函数
  2. 提供隐式转换函数,如书中的例子:
    class Font {
    public:
        operator FontHandle() const {
            return f;
        }
    }
    
    这样就可以隐式把 Font 转换为 FontHandle,不过这样的缺点在于可能会写错。

RAII 类不是为了封装某物而存在,而是确保资源释放一定会发生而存在。

读完本节,我觉得还是显式写个 get() 函数会更好一些

条款 16:成对使用 new 和 delete 时要采取相同形式

单一对象的内存布局一般而言不同于数组的内存布局。数组所用的内存通常包括“数组大小”的记录,一般放在地址最前面,这以便 delete 知道需要调用多少次析构函数。

因此,如果调用 new 时使用 [],那么必须在调用 delete 时也使用 []。如果调用 new 时没有使用 [],那么也不该在对应调用 delete 时使用 []。

由于 typedef 时容易搞出数组,如:

typedef std::string AddressLines[4];
std::string* pal = new AddressLines;
delete [] pal;

这时最后必须加上 []。因此为避免这种错误,请最好不要对数组形式做 typedef/using 动作。可以使用 STL 容器避免。

条款 17:以独立语句将 new 后的对象置入智能指针

以独立语句将 new 后的对象置入智能指针内。如果不这样做,一旦异常被抛出,有可能导致难以察觉的资源泄露。

请写成下面的格式,防止 priority() 出现异常。不过在现代 C++ 环境中建议写成 make_shared<Widget>()

std::shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());
posted @ 2025-07-04 23:56  bringlu  阅读(16)  评论(0)    收藏  举报