[Effective C++] 读书笔记 I 书读百遍,其义自现

本文深入探讨了C++编程中的核心原则与最佳实践,包括语言联邦概念、资源管理技巧、构造与析构函数的正确使用等,旨在帮助开发者构建高质量的C++应用程序。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

总觉得 C++ 的经典书籍得对照着看,很多时候在一本书上简简单单几句话带过的内容,另一本书可能会有贴切的例子,对照着看更容易理解并运用起来。

Effective C++ 以条款的形式将开发实践中应该注意的方方面面娓娓道来,而 Accelerated C++ 则是通过开发一个贯通全书的项目把这些内容融入到实践中去,两者简直就是珠联璧合。当然,在有了语法的基础并实际编写过一些程序才能来攻克这两本书。 // ye_create 06/24/2015


一、让自己习惯C++

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

现代的C++ 已经是一个多重范式编程语言,同时支持 procedural、object-oriented、functional、 generic、 metaprogramming (元编程)范式。

为了更好的理解C++,我们将C++分解为四个次语言:

  • C : 区块,语句,预处理器,内置数据类型,数组,指针;但没有模板,异常,重载…… Object-Oriented
  • C++ : class(构造+析构),封装,继承,多态,虚函数…
  • Template C++ : 泛型编程部分,也导出了模板元编程范式。函数模板,类模板,特化,偏特化……
  • STL : 容器(vector,list,map……),迭代器,算法,函数对象,配接器……

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


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

这个条款或许可以改为“宁可 以编译器替换预处理器”。即尽量少用预处理。

  • 对于单纯常量,最好以const对象或enums替换#defines
  • 对于形似函数的宏,最好改用inline函数替换#defines

条款03:尽可能使用const

const可以用来修饰以下这些对象或量:

  • class 外部修饰 globalnamespace 作用域中的常量
  • 文件、函数或区块作用域中被声明为static 的对象
  • class内部的staticnon-static 变量
  • 指针:指针自身、指针所指物,或两者都是 const

说明:

  • 如果关键字 const出现在星号左边,表示被指物事常量。const char *pchar const *p 两种写法意义一样,都说明所致对象为常量;
  • 如果关键字 const 出现在星号右边,表示指针自身是常量。

由于 STL是以指针为基础的,因此 const_iterator 就像一个const指针一样,比如:

const std::vector<int>::iterator iter = vec.begin();
*iter = 10;         // right
++iter;             // wrong
std::vector<int>::const_iterator citer = vec.begin();
*citer = 10;        // wrong
++citer;            // right

constnon-const 成员函数中避免重复的办法

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

class TextBlock {
public:
    ...
    const char* operator[](std::size_t position) const
    {
        ...
        ...
        return text[position];
    }
    // non-const 调用 const 版本 -> 消除重复部分
    char* operator[](std::size_t position)  
    {
        ...
        return 
                const_cast<char&>(             // 将op[]返回值的const移除
                    static_cast<const textBlock&>(*this)  // 为*this加上const
                        [position]             // 调用const op[]
                );
    }
};

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

我们知道读取未初始化的值会导致未定义行为,然而对象初始化何时一定发生的规则却很复杂,比如 int x; 在某些语境下可以保证初始化为0,但在其它语境下并不一定。

最佳的处理办法是: 永远在使用对象之前将它初始化。 需要注意的一点是不要混淆了赋值(assignment)初始化(initialization)。举个栗子:

class ABEntry;
ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones)
{ 
    theName = name;                // 这些都是赋值,而非初始化
    theAddress = address;          // 这些成员变量在进入函数体之前已调用默认构造函数,接着又调用赋值函数
    thePhones = phones;            // 即要经过两次的函数调用
    numTimesConsulted = 0;         // 默认构造函数的作为浪费了
    } 

最简单的方法是:总是使用成员初始化列表 member initialization list。 非内置类型变量的初始化应在初始化列表中完成,以提高效率。而对于内置类型对象,如numTimesConsulted(int),其初始化和赋值的成本相同,但为了一致性最好也通过成员初始化表来初始化。如果成员变量时const或reference,它们就一定需要初值,不能被赋值。成员初始化列表有一点需要注意的是: 它有着十分固定的成员初始化次序。基类总是在派生类之前被初始化,而类的成员变量总是以其声明次序被初始化。所以:当在成员初始化列表中列各成员时,最好总是以其声明次序为次序


但是还存在一种 “不同编译单元内定义的non-local static对象”的初始化次序 问题,也就是在至少两个源码文件内含至少一个non-local static对象的情况下,怎么安排这些对象的初始化次序才能避免“未初始化对象调用”的问题。

幸运的是,只需要这么做就可以消除这个问题:将每个 non-local static 对象搬到自己的专属函数内,并在函数内local static 对象替换 non-local static 对象,函数最后返回一个 reference 指向这个 local static 对象。


二、类的基础:构造 / 析构 / 赋值运算

也就是每个calss 都会存在的脊柱函数:Constructors, Destructors, and Assignment Operators,它们负责一些基础操作:产出新对象并确保其初始化、摆脱旧对象并确保它被适当清理、以及赋予对象新值。


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

  • 编译器可以暗自为类创建默认构造函数、拷贝构造函数、拷贝赋值操作符,以及析构函数。
  • 所有这些函数都是 publicinline
  • 惟有当这些函数被需要(被调用),它们才会被编译器创建出来。

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

由于编译器会“自作多情”的声明Constructors, Destructors, and Assignment Operators,并在需要的时候调用它们,因此,不声明class中的这些函数以图 class 不支持这些机能是无法奏效的。

为驳回编译器自动(暗自)提供的机能,可以这么做:

  • 将相应的成员函数 声明为private并且不予实现
  • 使该 class 继承自 Uncopyable 这样的 base class
  • C++ 11 中提供了 delete 关键字,表示这个方法已被删除,如 ABEntry::ABEntry(const ABEntry&) = delete;

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

我们知道,多态的基本机制是 使用 base class pointer 来对 derived class object 进行操作,当派生类对象经由基类指针调用delete 来进行释放的时候,其结果将是未有定义—— base class pointer只能对它负责的基类成分进行销毁,而派生类的成分可能还留在堆里,这就造成了一个诡异的 局部销毁对象。

消除以上问题的做法很简单:给基类一个 virtual 析构函数。此后删除派生类对象就会销毁整个 derived对象。对象需要包含一些用来在运行期决定哪一个virtual函数该被调用的信息,这由一个虚函数表指针vptr(virtual table pointer)维护,事实上,这部分内容牵涉到 C++ object layout,详细内容可以阅读 Inside The C++ Object Model ,一些细节也可以参阅这篇文章(尚未完工)


那么什么时候需要将析构函数声明为virtual呢?只需记住一点:任何类只要带有virtual函数都几乎确定应该也有一个virtual析构函数

如果一个类不含virtual函数,通常表示它并不意图被用做一个基类,当类不企图被当做基类的时候,令其析构函数为virtual往往是个馊主意。因为实现virtual函数,需要额外的开销(指向虚函数表的指针vptr)。STL容器以及string类都不带virtual析构函数,所以最好别派生它们。

析构函数的运作方式是,最深层派生的那个class 析构函数最先调用,然后是其每一个base class 的析构函数被调用,就是与构造函数的调用顺序正好相反。

记住:

  • 带有多态性质的基类应该声明一个virtual析构函数。如果一个类带有任何virtual函数,它就应该拥有一个virtual析构函数。
  • 一个类的设计目的不是作为基类使用,或不是为了具备多态性,就不该声明virtual析构函数。
  • pure virtual函数导致 abstract classes,也就是不能被实体化(instantiated)创造对象的类。

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

  • 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序
  • 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类应该提供一个普通函数(而非在析构函数中)执行该操作。

条款09:决不让构造和析构过程中调用 virtual函数

上一个条款中提到了继承体系中析构函数和构造函数的调用次序,derived class 对象内的 base class成分会在derived class 自身成分被构造之前先构造妥当。假如在base class的构造期间调用了virtual函数,则它将被视为一个 virtual函数,对象的作为就像隶属base类型一样,绝不会下降到derived class 阶层。

究其原因,是当基类的构造函数执行时,派生类的成员变量尚未初始化。派生类的成员变量没初始化,即为指向虚函数表的指针vptr没被初始化又怎么去调用派生类的virtual函数呢?这种情况下,C++视它们仿佛不存在。析构函数也相同,派生类先于基类被析构,又如何去找派生类相应的虚函数?

  • 在构造和析构函数期间不要调用虚函数,因为这类调用从不下降至派生类。

条款10:令operator=返回一个reference to *this

考虑“连锁赋值”情形,如 x = y = z = 15;这里的赋值操作符必须返回一个reference指向操作符的左侧实参。

具体实现如下

// 赋值操作符重载
Widget & operator = (const Widget &rhs)
{
    ...
    return *this;
}

// 赋值相关运算,一般先定义 operator+= 再考虑双目运算符 operator+,这样更方便
Widget& operator+=(const Widget& rhs)
{
    ...                     // 适用于 +=,-=,*=
    return *this;      
}
  • 令赋值操作符返回一个reference to *this
  • 所有内置类型和标准程序库提供的类型如string,vector,complex或即将提供的类型共同遵守。

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

这一项主要是为了避免* 在停止使用资源之前意外释放了它 *的陷阱,假如要对一个reference 或者 pointer赋值,有可能写出这样的代码

 Widget& Widget::operator=(const Widget& rhs) 
{     
    delete pb;                    // 对pb指向内存对象进行delete,如果*this == rhs
    pb = new Bitmap(*rhs.pb);     // rhs(也就是pb)已经被释放了,那么new出来的是什么呢?
    return *this; 
}

当我们用对象(内含指向实际对象的智能指针)来管理资源时,可以很好的规避这种错误,然而如果考虑自行管理资源,就必须处理好这种陷阱。具体有三种做法

  • 传统做法是在operator=的最前面加一个“证同测试”,来进行自我赋值检验,如if (this == rhs) return *this;
  • 考虑异常安全性,不管是否自我赋值,采用先构造一个副本,用副本赋值,最后删除原对象的办法来处理。
  • 使用* copy and swap*技术,先构造一个副本(by value方式接收实参的时候本身就是副本),再调用swap函数。
 class Widget {
    ...
    Widget& operator=(const Widget& rhs);
    void swap(Widget& rhs);
    ...
}
Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs);
    swap(temp);
    return *this;
}

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

设计良好的面向对象系统(OO-systems)会将对象的内部封装起来,只留两个函数负责对象复制,即两个copying函数:copy构造函数copy assignment操作符

如果你在类中添加一个成员变量,你必须同时修改相应的copying函数(所有的构造函数,拷贝构造函数以及拷贝赋值操作符)。

当你编写一个copying函数,请确保:

  • 复制所有local成员变量
  • 调用所有base class内的适当copying函数

我们不该令拷贝赋值操作符调用拷贝构造函数,这就像试图构造一个已经存在的对象,这是因为,拷贝构造实质还是建立对象,而赋值是对已经存在的对象进行操作。同样的道理,反过来用copy构造函数调用 copy assignment operator也是不科学的。

  • Copying函数应该确保复制“对象内的所有成员变量”及“所有基类成员”;
  • 不要尝试以某个copying函数实现另一个copying函数。应该将共同机能放进第三个函数(往往是private而且常被命名为init)中,并由两个copying函数共同调用。

三、资源管理

C++程序中最常使用的资源就是动态分配内存(小心分配内存与释放内存不对等导致内存泄露),但内存只是你必须管理的众多资源之一。其它常见的有文件描述符(file descriptors)、互斥器(mutex)、图形界面中的字型和笔刷、数据库连接、以及网络sockets。一旦用了它,当你不使用它们时,记得还给系统。

使用基于对象(object-based)的资源管理办法,经过合理设计与维护,可以几乎消除资源管理问题。


条款13:以对象管理资源

将资源放入对象内,当控制流离开对象,该对象的析构函数会自动释放资源,其依赖的是C++的 “析构函数自动调用机制” 确保资源被释放。

void f()
{ 
    // auto_ptr是个“类指针对象”,也就是所谓的“智能指针”,其析构函数自动对其所指对象调用delete
    std::auto_ptr<Investment> pInv(createInvestment());   
    ... 
}          //函数退出,auto_ptr调用析构函数自动调用delete,删除pInv

“以对象管理资源”的关键想法除了以上介绍的依赖“析构函数自动调用机制”来实现 管理对象managing object运用析构函数确保资源被释放 外,还有一点—— 获得资源后立刻放进管理对象内 ,实际上这个观念常被称为“ 资源取得时机便是初始化时机(Resource Acquisition Is Initialization: RAII”,也就是说每一笔资源都在获得的同时立刻被放进管理对象中。

用于管理资源的对象推荐尽量使用tr1::shared_ptr, tr1::weak_ptr,分别用于可以使用多个指针指向同一个对象和一个指针独享一个对象的情况,这两者的copy操作相比于标准库的 auto_ptr更直观,在独占对象等别的细节上也有诸多完善,参见auto_ptr、shared_ptr、weak_ptr、scoped_ptr用法小结。而且目前C++ 11已经把它们都加进来了。

记住

  • 为防止资源泄漏,请使用RAII对象,它们在构造函数中获得资源并在析构函数中释放资源。
  • 两个常被使用的RAII类分别是auto_ptr和tr1::shared_ptr。后者通常是较佳选择,因为其拷贝行为比较直观。若选择auto_ptr,复制动作会使他(被复制物)指向NULL。

条款14:在资源管理类中小心拷贝行为

RTTI可以说是“资源管理类”的脊柱,shared_ptr等智能指针正是依此作用于heap-based (new 出来的)资源 上的。而对于heap-based资源 而言,这些智能指针往往不适合作为资源掌管者resource handlers,对此我们需要建立自己的资源管理类,比如使用C API函数处理类型为 Mutex的互斥器对象 mutex object,共有两个函数可用:void lock(Mutex* pm); void unlock(Mutex* pm);

我们建立的用来管理锁定与解锁互斥器的class可能是这样的:

class Lock 
{
public: 
    explicit Lock(Mutex *pm) : mutexPtr(pm) 
    {
        lock(mutexPtr); 
    } 
    ~Lock() 
    { 
        unlock(mutexPtr); 
    } 
private: 
    Mutex *mutexPtr; 
    }; 

但是,如果Lock对象被复制,会发生什么事?虽然对于上面的简单互斥器例子并没有复制的需求,但是RAII对象的copy行为确实是一个需要考虑的问题。具体的copying 行为策略 有以下四种:

1.禁止复制。上面的Lock复制操作确实不合理,应该将其禁止。具体实现见条款06:private 办法 或者 Uncopyable base class

2.对底层资源使用”引用计数法“。有时候我们又希望保有资源,直到它的最后一个使用者被销毁。这种情况下复制RAII对象时,应该将资源的”被引用计数“递增。tr1::shared_ptr便是如此。

然而tr1::shared_ptr的缺省行为是 “当引用次数为0时删除其所指对象”,对于Lock对象而言,采用的释放动作应该是 解除锁定unlock,幸运的是tr1::shared_ptr允许使用 deleter删除器,可以是一个 函数或者函数对象 。这种情况下,Lock class析构函数是没必要存在的,因为编译器为我们生成的析构函数会自动调用其 non-static成员变量(mutexPtr)的析构函数 ,也就是说tr1::shared_ptr的删除器(unlock)会在互斥量”引用计数“为0时自动调用。

 class Lock 
{
public: 
    explicit Lock(Mutex *pm) 
        : mutexPtr(pm, unlock)      // 设置删除器(unlock),将在析构函数中自动调用
        { 
            lock(mutexPtr.get());   // 显示调用资源类型转换
        } 
private:
    std::tr1::shared_ptr<Mutex> mutexPtr; 
     }; 

3.复制底部资源

复制资源管理对象时,进行的是 深度拷贝deeping copy。比如将 “指向heap内存”之指针及其所指向的heap内存 一并制作出一个复件。这种情况下可能会采用 copy-on-write技术,深入了解可参阅 C++ STL string的Copy-On-Write技术

4.转移底部资源的拥有权。某些罕见场合下你可能确保永远只有一个RAII对象指向一个未加工资源raw resource。此时资源的拥有权会从被复制物转移到目标物,具体实现我猜测可能采用右值语义下的move 方法


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

当一些APIs直接指涉资源时候,我们需要绕过资源管理对象resource-managing objects,直接访问原始资源raw resource

我们可以设计一个函数将RAII对象(如tr1::shared_ptr)转换为其所内含之原始资源,有两种做法可以达成目标:显示转换和隐式转换

1.显式转换

  • tr1::shared_ptr 和 auto_ptr都提供一个get成员函数,用来执行显示转换,也就是返回智能指针内部的原始指针(的复件)。
  • 就像所有智能指针一样, tr1::shared_ptr和auto_ptr重载了指针取值操作符operator->和operator*),它们允许隐式转换至底部原始指针。(即在对智能指针对象实施->*操作时,实际被转换为被封装的资源的指针。)

2.隐式转换

class Font 
{
public: 
    ... 
    FontHandle get() const         // 显示转换函数
    { 
        return f; 
    }
    operator FontHandle() const    // 隐式转换:这个值得注意,可能引起“非故意之类型转换”
    { 
        return f; 
    } 
    ... 
}; 

一般而言显示转换比较安全,可能是比较受欢迎的路子,但是需要不停的get,get;而隐式转换又可能引起“非故意之类型转换” ,但对客户比较方便。


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

一个很可能导致误会的例子是对new出来的数组释放资源时到底应该使用delete还是delete[]。这涉及到数组的内存布局以及delete对该内存布局的理解,可参阅 “C++的数组不支持多态”?

记住

  • 如果你在new表达式中使用[],必须在相应的delete表达式中也使用[]。如果你在new表达式中不使用[],一定不要在相应的delete表达式中使用[]
  • 最好尽量不要对数组形式作typedef或其它类似动作。因为这样容易引起delete操作的“疑惑”(delete or delete[])。
  • 使用标准程序库中的string, vector等,来降低数组的需求。

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

考虑一种使用“对象管理资源”也有可能产生资源泄漏的情况:一个资源优先权函数再加上一个资源动态分配函数的处理过程

 int priority();
 class Widget;
 void processWidget(std::tr1::shared_ptr<Widget> pw, priority());

 // 考虑调用如此形式的处理函数,应该写成显式转换
 processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());

显然,在调用这个processWidget之前,编译器必须做以下三件事来创建代码:

  • 调用 priority
  • 执行 new Widget
  • 调用 tr1::shared_ptr构造函数

但是这三者之间的 完成次序并不是规定的,因此有可能出现这种操作序列: 执行 new Widget -> 调用 priority -> 调用 tr1::shared_ptr构造函数。而万一在调用 priority过程中出现异常,那么会发生什么?tr1::shared_ptr构造函数无法被执行了,我们的new Widget并不能被放进资源管理对象之中,于是资源泄漏发生了。

避免这类问题的方法很简单:使用分离语句,将new Widget置入shared_ptr 与调用 priority分开来执行

std::tr1::shared_ptr<Widget> pw(new Widget);  // 在传入函数之前对智能指针初始化
processWidget(pw, priority());                // 解决操作序列不可知的问题

记住

  • 以独立语句将newed 对象存储于(置入)智能指针内。如果不这样做,一旦异常抛出,有可能导致难以察觉的资源泄漏。

待续 >>>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值