写在前面
缓慢补上老博客中未填完的坑。
本文与前文是对 Scott Meyers 所著的 Effective C++ 的摘录以及小部分个人的拙见。可以把它视作学习笔记。
这本著作的阅读并不是一件容易的事,考虑到 C++ 本身的复杂性,加之我自身编写 C++ 经验的不足,其中的理解可能有偏差,我会在未来的重温中予以矫正。
前文地址:https://endsieg77.github.io/2021/01/03/Effective-C/
References for C++ Terminologies: https://offensive77.plus/index.php/c-terminology-annotations/
传常引用而不是传值
- 当对象庞大时,避免不必要的拷贝损耗;
- 当对象是一个类的派生类时,如果函数形式参数为基类,传值时派生类会被切割 (slicing),导致派生类被 cast 为基类,而作出意料外的行为;而传常量引用,由于引用为变量的别名,则不会有这种问题;
- 需要注意的是,即使是那些较小的类,包括一部分 STL 容器,可能它们大小仅仅比指针大一些,我们也最好用传常量引用,因为我们复制这些类的时候需要承担复制指针指向的每一个东西的开销;
- 即使是现在较小的类,以后也可能随着代码的修改进一步扩大,使用传常量引用可以让我们不必大量修改底层的代码。
必须返回对象时,不要返回引用
以一个类 Rational 为例:
class Rational { public: Rational(int numerator = 0, int denominator = 1); ... private: int n, d; friend const Rational &operator*(const Rational &lhs, const Rational &rhs); };
每当我们看到 reference,我们都该问问自己:它的原名是什么?一个引用应该指向一个本就存在的对象,但我们不能总期待这个对象原本就存在。
于是我们就想了个馊主意:
const Rational &operator*(const Rational &lhs, const Rational &rhs) { Rational result(lhs.n * rhs.n, lhs.d * rhs.d); return result; }
我们在函数内部创建了一个 local 对象,并试图把它返回。然而 local 对象的 life-span 是函数内部,当我们离开函数体,它就已经被销毁了。也就是说,我们返回的引用,指向了一块非法内存,此时我们对该引用进行的一切操作,都是无定义的行为。
又如果我们在函数内部用 new 分配一块内存,并将相应的指针解引用作为返回值返回呢?这样会让事情更糟。
首先,new 出来的空间,需要我们的客户谨慎使用 delete 来析构掉;
其次,如果考虑下面的情况:
Rational x, y, z; auto a = x * y * z;
这么写,必然有一次 new 出的对象无法被正常析构,也就会导致我们的内存泄漏。
于是我们又想,既然这样做不可以,那我返回一个函数内部创建的 static 对象呢?依旧不可以,你每次调用 operator* 时 static 对象都会被更改,这样会导致诸多荒谬的结果,即使用 static 数组,许多问题也是难以避免的。
所以,我们究竟该怎么做呢?老老实实返回对象,而不是引用。
inline const Rational &operator*(const Rational &lhs, const Rational &rhs) { return Rational(lhs.n * rhs.n, lhs.d * rhs.d); }
大部分情况使用 private 而非 public,protected
private 的好处是数据的封装性。这一点是 public 以及 protected 都无法做到的。
良好的封装性在于,尽管我们自己可能修改了一部分代码,删除或增加了一部分变量,更改了内部算法,但客户端在调用接口的时候却毋需作出代码更改,至多重新编译一遍罢了。
如果我们先前设置了 public 变量,并在之后把它删除了,用户所有涉及了这个变量的代码都会报废,而 protected 亦是如此。我们应该多利用类的函数接口,来达成我们的目的。 private + 函数接口允许我们对变量设置各级权限:只读,只写,可读可写,不可读不可写。考虑如下代码:
class Sample { public: int getReadOnly() { return ReadOnly; } int setWriteOnly(int val) { WriteOnly = val; } int getReadWrite() { return ReadWrite; } int setReadWrite(int val) { ReadWrite = val; } private: int ReadOnly; int WriteOnly; int ReadWrite; int NoAccess; };
偏袒 non-member non-friend 函数而不是 member 函数
从数据的封装性角度说起,non-member non-friend 函数的访问权限是类的 public 成员,而 member 函数的访问权限是类的一切成员。面向对象的思想指导我们尽可能多地使用 member 函数,直观上我们也认为这样会有更好的封装性,但实际上 non-member non-friend 函数提供了更好的封装性。
我们在上文提到,封装性的好坏评价准则是,当我们修改类的 member 时,我们影响更少的代码,non-member non-friend 函数由于访问权限的原因,其无法访问到类的 private 成员,这就使得我们修改类的 member 时毋需再修改该函数中的代码。
由于 friend 的访问权限和 member 函数相同,我们大部分情况只需要在 member 和 non-member non-friend 作出二择。
另外,我们虽然要使这个函数是一个类的 non-member,但未必不能让它成为另一个类(例如工具类,utility class)的 member。
为了更自然,我们可以把类和它相关的 non-member non-friend 函数定义在同一 namespace 下。
这样做的好处还有,namespace 是跨越多源码文件的,因此我们可以把不同的功能函数放在不同的文件,使得我们的代码有着更加清晰的条理,也方便客户选择其中自己感兴趣的功能使用。
考虑写出一个不抛异常的 swap 函数
swap 是 C++ 中异常安全性的脊柱(copy-and-swap策略),然而编写优秀的 swap 函数并不是一件容易的事。
考虑这样的类:“以一个指针指向一个对象,内含真正数据”。这种设计的常见表现形式是“pimpl 手法”(pointer to implementation)。如果用这种手法设计 Widget 类,会像这样:
class WidgetImpl { public: // private: int a, b, c; std::vector<double> v; // }; class Widget { public: Widget(const Widget &rhs); Widget &operator=(const Widget &rhs) { // *pImpl = *(rhs.pImpl); // } // private: WidgetImpl *pImpl; };
若我们要 swap 这两个 Widget 对象,我们要做的仅仅是交换两个 pImpl 指针而已。而缺省的 swap 不会这么做,它会复制 pImpl 所有指向地址的所有数据,这样会造成许多不必要的开销。
于是我们便想要编写一个 swap 的特化版本,来做出我们想要的行为。
void Widget::swap(Widget &rhs) { std::swap(pImpl, rhs.pImpl); } namespace std { template<> void swap<Widget>(Widget &a, Widget &b) { a.swap(b); } }
于是乎,在 Widget 类内加入一个针对 Widget 对象的 swap 函数,然后在std命名空间中添加我们特化的 swap 版本,在函数体内调用我们 Widget 类的 public 成员 swap 函数。
这样的做法不但能够通过编译,而且与 STL 容器具有一致性: STL 容器也都提供有 public swap 成员函数和 std::swap 特化版本(以调用前者)。
假如我们的 Widget 和 WidgetImpl 是 class templates 而非 classes 呢。
我们企图进行如下的偏特化(partially specialize):
namespace std { template<typename T> void swap< Widget<T> >(Widget &a, Widget &b) { a.swap(b); } }
可惜的是,这样写也是无法通过编译的。因为 C++ 只允许对 class templates 进行偏特化,却不允许对 function templates 进行偏特化。有的人又会想,那我在 namespace std 中重载一个自己的 swap,是否可以呢?遗憾的是,依旧不行,std 是一个特殊的命名空间,有着一套不同的管理规则,我们可以在 std 中提供自己的全特化模板,却不应也不能往里面添加其他东西。
于是,我们可以这么做:
namespace WidgetStuff { template <typename T> class WidgetImpl { public: // private: int a, b, c; std::vector<double> v; // }; template <typename T> class Widget { public: Widget(const Widget &rhs); Widget &operator=(const Widget &rhs) { // *pImpl = *(rhs.pImpl); // } void swap(Widget &rhs); // private: WidgetImpl<T> *pImpl; }; template <typename T> void Widget<T>::swap(Widget &rhs) { std::swap(pImpl, rhs.pImpl); } template<typename T> void swap(Widget<T> &a, Widget<T> &b) { a.swap(b); } }
将 Widget 类模板和其对应的特化版本 swap 置于同一个命名空间 WidgetStuff 下。
如果我们要在某个函数中调用 swap,怎么让函数作出“先寻找是否存在类型 T 的特化版 swap,如果没有再调用 std::swap 的行为”呢?
我们在函数中:
using std::swap; ... swap(obj1, obj2); // don't modify it by a 'std::'
C++ 的名称查找法则(name lookup rule)确保将找到 global 作用域或 T 所在命名空间内的 特化版本swap。如果 T 是 Widget 且处于 WidgetStuff 命名空间内,编译器会使用 实参取决之查找规则(argument-dependent lookup)找出 WidgetStuff 内的 swap 专属版本,如果不存在这样的版本,那么就调用 std::swap。
关于 C++ 的转型
有的人认为,转型实际上什么事情也没做,只是告诉编译器把一种类型视作另一种而已。这种想法是错误的。
例如:我们把 int 类型转换成 double 类型时,编译器必然会产生一些代码。因为 int 和 double 类型在底层表述上是不同的。
再看一个例子:
class Base { ... }; class Derived: public Base { ... }; Derived der; Base *pBase = &der;
我们建立一个 Base * 指针指向一个 Derived 对象,在有些时候这两个指针指向的地址并不相同。在这个时候地址会有个偏移量(offset)。实际上一旦使用多重继承,这样的情况几乎一直发生着。即使是单一继承,也可能发生。
我们还要注意的是,我们对一个变量进行转型,实际上是产生了一个该对象的另一个类型的副本,因此对其调用的任何函数都不会影响原来的对象。这个时候就要应用 域运算符 ‘::’ 。
还有一个例子:
我们有一个类 Window 和一个具有闪烁 blink 函数的 SpecialWindow。我们试图用基类的句柄来调用子类的函数,比较糟糕的做法是使用 dynamic_cast。dynamic_cast 的许多实现版本运行都十分缓慢,我们可以采用以下做法:
- 用一个类型安全容器存储所有 SpecialWindow 对象,让它们挨个 blink;
- 运用虚函数,使用多态的方法,使不同的子类表现出不同的行为。
上面的两种做法都不是放之四海皆准的,我们应该审慎选择。
我们一定要避免的是,连串(cascading)dynamic_casts。例如在不同的条件分支,都进行针对不同的类的 dynamic_cast。这样的代码又大又慢,而且基础不稳,每次继承体系出现改动,我们都要再次检阅,这是不可取的。我们可以采取上述的做法2来进行优化。
避免返回指向类内部的 handles
我们以下面的类 MusicPlayer 为例:
class PlayMode { private: bool autoplay; int sequenceSetting; public: PlayMode(bool autoplay = false, int sequence = 0) : autoplay(autoplay), sequenceSetting(sequence) {} void setAutoplay(bool state) { autoplay = state; } void setSequenceSetting(bool sequence) { sequenceSetting = sequence; } }; struct PlayerData { PlayMode settings; int volume; }; class MusicPlayer { private: PlayerData *playerData; // public: PlayMode &getVolume() const { return playerData->settings; }; };
函数 getVolume 返回了对 volumeData->settings 的句柄,这使得我们可以修改 autoplay 和 sequenceSetting 的值,而函数的本意是什么呢?我们对函数加了 const 限定符,本打算让它不修改原对象,而我们的函数作出的行为实际上违背了我们的初衷。
这启示了我们:
- 成员变量的封装性最多只等于返回 reference 的函数的访问级别。上例中虽然我们把 autoplay 和 sequenceSetting 设置成了 private,但它的实际访问级别是 public,因为它的句柄被一个 public 的函数返回,然后利用成员函数修改数据;
- 如果 const 成员函数传出一个 reference,后者所指的数据和对象本身具有一定的关联,而又被存储于对象之外,那么其值便可能遭到篡改。这就是 bitwise constness 的一个附带结果。
异常安全(Exception Safety)
异常安全函数(Exception-safe functions)
“异常安全”函数的两个条件
- 不泄漏任何资源。保证发生异常的时候,所有函数内分配的内存都被合理释放了;
- 不败坏任何数据。在发生异常后,任何程序需求的数据必须是合理有效的,不能被败坏。
首先解决内存泄漏的问题,也就是问题1,我们可以引入资源管理类,要么是 C++ 的智能指针,要么是自己精心编写的资源管理类。
然后便可以专心解决资源败坏的问题。
异常安全函数提供的三个保证
- 基本承诺:如果异常被抛出,程序内的任何事物仍然保持有效的状态。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态。然而程序的实际情况(exact state)不可预料,可能会保持不变,也可能使用缺省的实现;
- 强烈保证:如果异常被抛出,程序状态不发生改变。这样的函数,要么函数成功,那就是完全成功;要么函数失败,那么就完全回复到“调用函数之前”的状态;
- 不抛掷(nothrow)保证:这个函数永远不可能抛出异常。pre-C++ 11 时期我们使用 nothrow 关键字,而在 C++ 11 之后我们有了 noexcept。
nothrow 看似很完美,但实际上几乎不可能实现。任何在 C++ 中使用动态内存的东西,几乎都会在内存不足时抛出一个 bad_alloc 异常。
实现强烈保证,我们一般会使用 copy-and-swap 策略,这个策略的详细在我的术语介绍里面有提到,这里就略去了。
异常安全性取决于函数中最不安全的那一个部分,只要函数有任何部分的代码可能并不异常安全,那么这个函数就不是异常安全的。
另外还要注意的是,即使一个函数调用的所有函数都提供强烈保证,也不代表这个函数本身是提供强烈保证的,看下面的例子:
void doSomething { ... f1(); ... f2(); ... }
在 doSomething 中,我们调用 f1,并且其间未发生异常,然后再调用 f2,f2 运行过程中发生了异常,这个时候,f1 所波及的数据成员已经被修改了,而 f2 的部分却回退到修改前的状态,整个对象的数据依然被败坏了。
但尽管如此,我们还是应该在力所能及的范围内提高保证等级。
减少文件的相互依赖性
两种策略,handle class 和 interface class。
handle class
使用一种 pImpl idiom,也就是我们上文提到的,类中存储一个 handle,利用 handle 来完成类的行为。
下面是一个简单的实例,一个基于上述策略编写的 MusicPlayer 类:
namespace MusicPlayerStuff { class Song { private: std::string songName; std::string url; mutable bool playState = false; public: Song(std::string songName, std::string url) : songName(songName), url(url) {} void play() const { cout << songName << " is played\n"; playState = true; } void pause() const { cout << songName << " is paused\n"; playState = false; } bool isPlaying() const { return playState; } }; class MusicPlayerImpl { private: std::shared_ptr<Song> current_song; int volume; double duration; public: MusicPlayerImpl(std::string songName, std::string url, int volume, double duration) : current_song(std::make_shared<Song>(songName, url)), volume(volume), duration(duration) {}; void play() const { current_song->play(); }; void pause() const { current_song->pause(); }; int getVolume() const { return volume; } double getDuration() const { return duration; } bool isPlaying() const { return current_song->isPlaying(); } const Song &getCurrentSong() const { return *current_song; } }; class MusicPlayer { private: std::shared_ptr<MusicPlayerImpl> pimpl; public: MusicPlayer(std::string songName, std::string url, int volume, double duration) : pimpl(std::make_shared <MusicPlayerImpl>(songName, url, volume, duration)) {} void play() const { pimpl->play(); } void pause() const { pimpl->pause(); } int getVolume() const { return pimpl->getVolume(); } double getDuration() const { return pimpl->getDuration(); } bool isPlaying() const { return pimpl->isPlaying(); } const Song &getCurrentSong() const { return pimpl->getCurrentSong(); } }; }
经常使用 QT 或者其他 C++ 库的人应该对这一策略并不陌生:我们经常会用到。
main 函数中执行如下程序:

输出:
