Effective C++ 读书笔记03
前言
本文是阅读《Effective C++ 改善程序与设计的55个具体做法(第三版)》的心得笔记第三部分,文章也会按照原书的顺序依次记录各个条款。
第一部分的阅读笔记参见effective C++ 读书笔记01。
第二部分的阅读笔记参见effective C++ 读书笔记02。
设计与声明
条款18:让接口容易被正确使用,不易被误用
想要开发一个容易被正确使用,不易被误用的接口,首先要考虑使用者会犯什么错误,并设法阻止。
导入新(自定义)类型,预防接口被误用
考虑如下一个Date
类:
class Date { |
可以通过封装Month
,Day
和Year
的struct/class,构建新的数据类型,并限制其数值范围。如下:
// 封装结构体类型 |
但上述类型封装并不没有约束类型的数值范围,还是可能出现Date d(Month(13), Day(24), Year(2022))
这样的情况,文中提到enum
可以实现数值枚举,但不是类型安全的(可以隐式转换成整型),所以实现了如下类:
class Month { |
其实,C++11已经支持了枚举的强化,即类型安全的enum class
,它不能隐式地转换为整数;也无法与整数数值做比较。故Month
类型也可实现成:
enum class Month { |
限制类型内什么事情可做,什么事情不可做
最常见的就是使用const
限定符,如条款03中提到的用const
修饰operator*
的返回类型可以避免a * b = c
这样的错误。
尽量让你的自定义类型的行为与内置类型一致
这句话的进一步延申就是提供行为一致的接口,除非你有更好的理由。道理也很简单,不一致就意味着有用错的风险。任何接口如果要求使用者必须记住某件事,那它就有着“不被正确使用”的倾向,因为使用者总有可能忘了做那件事。这也引申出了下一点:
不要寄希望于使用者为你的接口“擦屁股”
如条款13中提到的一个factory函数会对象指针:
Investment* createInvestment(); |
这就是把对象析构的责任推给了使用者,当然使用者如果看过条款13,知道将返回的对象指针存储于一个智能指针中,将对象析构的责任再次推给指针指针。但你不能期望使用者永远不犯错,使用者也许不会用智能指针,也许根本没有意识到自己要对delete
对象指针负责。所以不如先发制人,自己返回一个智能指针:
std::shared_ptr<Investment> createInvestment(); |
使用智能指针的好处之一在于可以自己指定用于销毁对象的deleter,而不是简单地使用delete
对象指针。这又进一步杜绝了使用者企图使用错误的资源析构机制的可能性。
指定每个智能指针专属的deleter的还有一个好处:可以消除潜在的cross-DLL problem,即对象在某个DLL中被new
创建,却在另一个DLL中被delete
销毁。在许多平台上,这样的跨DLL之间new/delete成对运用会导致Runtime-Error。
总而言之,接口内创建的资源,接口内负责销毁。智能指针是个好东西,能用则用。
条款19:设计class犹如设计type
想要设计一个优秀的class,往往需要回答以下问题:
-
- **新 type 的对象应当如何被创建和销毁?**决定构造函数和析构函数的实现。
-
- **对象的初始化和对象的赋值该有什么样的差别?**决定构造函数和赋值操作符的实现。
-
- **新 type 的对象如果被 passed by value(传值),意味着什么?**决定拷贝构造函数的实现。
-
- **什么是新 type 的“合法值”?**决定class必须维护的约束条件(invariants),必须进行的错误检查工作,异常抛出,非法值拦截,返回错误码等。
-
- **新 type 需要配合某个继承图谱(inheritance graph)吗?**如果继承自某个基类,则受限于基类virtual和non-virtual函数的设计;若允许其他class继承你的类,则要考虑你所声明的函数,特别是析构函数(条款07),是否应当为virtual。
-
- 你的新 type 需要什么样的类型转换?若希望
T1
类型可以隐式的转换为T2
类型,要么在class T1
内实现一个类型转换函数operator T2()
,要么在class T2
中实现一个non-explicit-one-argument构造函数。若你不希望隐式转换存在,则得专门实现负责执行转换操作的函数。关于隐式转换和显示转换的讨论还可见条款15。
- 你的新 type 需要什么样的类型转换?若希望
-
- 什么样的操作符和函数对此新 type 而言是合理的?决定class将要声明哪些函数,函数是成员函数还是非成员函数,参见条款23,24,26。
-
- 什么样的标准函数应当被驳回?参见条款06,不希望被拷贝的类应当将copy构造函数和copy赋值操作符声明为private,或使用C++新特性
=delete
。
- 什么样的标准函数应当被驳回?参见条款06,不希望被拷贝的类应当将copy构造函数和copy赋值操作符声明为private,或使用C++新特性
-
- **谁该取用新type的成员?**决定成员该为private/protected/public,以及决定哪一个外部类或函数能成为friend。
-
- 什么是新 type 的“未声明清楚的接口”(undeclared interface)?参见条款29。
-
- **新 type 有多么一般化?**对于一般化的问题处理,考虑定义一个新的class template而不是一个新的class。
-
- **你真的需要一个新 type 吗?**如果只是定义一个派生类为既有的类添加新的功能,说不定单纯定义一个或多个非成员函数或者模板更合适。
条款20:宁可pass-by-reference-to-const代替pass-by-value
通常情况下,前者会比后者要高效。
以值传递参数时,函数形参是实参的一个副本(确保函数不会修改实参),这意味着要执行拷贝构造函数,然后在函数退出时,函数形参生命周期结束,会再执行析构函数。这些可能是不小的开销,尤其是当类存在成员对象或者继承关系时,成员对象和基类的构造和析构也在这个过程中。
以const引用传递参数时,没有任何新对象被创建,因此不涉及构造和析构操作。其中const是关键,其同样可以保证函数不会修改实参。
不只是函数参数,返回值也遵循同样的规则。但请注意,不要返回局部变量的引用,因为局部变量会在函数退出时到达生命周期的终点,返回局部变量的引用只会得到一个悬空的变量。
以 by reference 方式传递参数可以避免对象切割问题
所谓对象切割,指的是当一个派生类对象以by value的方式传递给一个基类参数时,基类的copy构造函数会被调用,从而导致该对象作为派生类的特性被切割掉,完全**“退化”**成一个基类对象。而以 by reference-to-const 的方式传递参数则可以避免对象切割问题。
对于C++内置类型(如int),以值传递通常效率更高
从C++编译器的底层实现来看,references往往以指针形式来实现,因此pass by reference通常意味着真正传递的是指针。所以对于内置数据类型,直接传值反而效率更高。
这一规则还适用于STL的迭代器和函数对象。
因为内置类型通常比较小,所以所有小型types都更适合于pass-by-value的推论是不合理的。因为小型对象的copy构造的代价不一定低,且自定义的小型types有可能是变化的,目前是小的,将来也许会变大。所以,除了C++内置类型和STL的迭代器和函数对象外,请尽量遵守以pass-by-reference-to-const代替pass-by-value的准则。
条款21:必须返回对象时,别妄想返回其reference
通常情况下,不要返回如下几种对象的引用(指针也同理):
不要返回局部栈对象(局部变量)的引用
局部变量会在函数退出时到达生命周期的终点,返回局部变量的引用只会得到一个悬空的变量。这将会掉入未定义行为的深渊。
不要返回局部堆对象(函数中new的对象)的引用
这样做的不合理之处在于,将销毁对象的责任推到了函数之外,甚至有时候连销毁的机会都没有。考虑如下情况:
class Rational { |
不要返回局部static对象(静态局部变量)的引用
和所有static对象一样,首先能够想到的就是多线程安全性的顾虑。除此之外,静态局部变量只有一份,只初始化一次,不同函数更新的是同一份变量。继续上面的例子:
const Rational& operator* (const Rational& lhs, const Rational& rhs) { |
上述例子中,a * b
和c * d
都返回同一个local static 对象,所以结果总是相等。
就没有适合返回引用的场景吗?
absolutely not,条款10种提到copy赋值操作符可以返回*this
的引用;类成员作为返回值时,也可以返回其引用,但最好时const。
条款22:将成员变量声明为 private
一般情况下,应当将成员变量声明为 private,好处如下:
- 语法一致性:访问对象成员变量的唯一方法就是通过get成员函数;
- 精确控制成员变量的访问权限:根据是否有get/set成员函数来控制成员的读写权限;
- 提供实现弹性:成员变量的计算方法的调整,移除和变更名称等操作都隐藏在函数接口之后,不会被类的使用者感知到;
- 封装性:成员变量的封装性与成员变量的内容改变时所破坏的代码数量成反比。public和protected都不具备封装性。怎么理解呢?比如,一个public成员变量被移除(或改为private),所有直接使用它的客户代码都会被破坏,无法通过编译;一个protected成员变量被移除(或改为private),所有使用它的派生类(派生类可以访问基类的protected成员变量)都会被破坏,无法通过编译。也就是说,一旦你将一个成员变量公开且被广泛使用,后面再想对它做修改就异常艰难。
条款23:宁以 non-member、non-friend替换 member 函数
考虑如下例子:
class WebBrowser { |
封装性考虑
从面向对象守则考虑,你也许会选择1,认为数据及操作数据的函数应当捆绑在一起,但这是一种误解。因为面向对象守则是要求数据(而不是函数)被尽可能的封装。数据封装性即其不可见程度,越少的函数能够看到/访问它,则其封装性就越强。条款22中提到,成员变量应当为private,否则就有无限量的函数可以访问它们,也就毫无封装性。而对private成员变量而言,能访问它的就只有成员函数和友元函数。故成员函数和友元函数越少,成员变量的封装性更强。当能够提供相同的功能时,non-member、non-friend函数并不增加能够访问类内成员变量的数量,与member函数相比,能为class提供更强的封装性,故应当选择2。
需要注意的是,考虑封装性而让函数成为class的non-member函数,并不意味着它不可以成为另一个类的成员函数。这句话有点绕,还以上述例子为例,可以让clearBrowser
成为某个工具类(utility class)的一个static member函数,只要它不是WebBrowser类的成员函数或友元函数,就不影响WebBrowser成员变量的封装性。
在C++中,比较自然的做法是让clearBrowser
成为一个non-member函数,并且与WebBrowser位于同一namespace内,如下:
namespace WebBrowserStuff { |
编译依赖考虑
namespace
可以跨越多个源码文件,而class
不能,因此可以将不同功能的non-member函数放在不同的.h
头文件的同一namespace
下,这样可以让模块划分更清晰,还可以降低不必要的编译依赖关系。这也是C++标准程序库的组织方式,使用者只需要包含自己需要使用的模块的头文件(如#include <vector>
)。
// 头文件 webbrowser.h |
条款24:若所有参数皆需类型转换 ,请为此采用 non-member 函数
考虑如下这个例子:
// 有理数类 |
上述实现中有几点需要注意:
- 构造函数是non-explicit的,是为了支持整数到有理数的隐式转换;
- 重载的
operator*
实现为成员函数,参数passed-by-reference-to-const,返回一个const-by-value,具体可参考条款20和条款21; - 上述实现在混合运算时会有问题:
Rational oneEighth(1, 8); |
为了让混合运算支持交换律,可以将operator*
改实现为non-member函数,如下:
const Rational operator*(const Rational &lhs, const Rational &rhs) { |
这里需要注意,operator*
没有必要实现为Rational
类的友元函数,因为其完全可以通过Rational
类的public接口(numerator()
方法和denominator()
方法)来完成功能实现。因此也导出一个结论:成员函数的反面是非成员函数,而不是友元函数;应当尽可能避免使用友元函数,除非没有别的选择,因为这为封装性打开了一个缺口,可能会导致不可控的局面。
条款25:考虑写出一个不抛异常的swap函数
所谓swap,就是将两个对象的值置换。std中提供了swap的典型实现如下:
namespace std{ |
只要类型T
支持copying,即实现了拷贝构造函数和拷贝赋值操作符,则std::swap
就能正常工作。
但是,能正常工作不代表效率高。比如当需要swap一个用pimpl(point to implementation)手法设计的类时:
class WidgetImpl { |
对于Widget
类而言,要置换其两个对象,实际只需要交换两个对象的pImpl
指针即可。但std::swap
并不知道这一点,他还是会严格执行深复制,不止复制了3个Widget
对象,还复制了其包含的3个WidgetImpl
对象。
要解决这个问题,与STL容器一致的做法是,为Widget
类提供一个public swap成员函数,并基于此,实现一个**std::swap
的特化版本**。
class Widget { |
上述解决办法在处理类时可以工作的很好,但是当Widget
和WidgetImpl
作为类模板实现时,就会出现新的问题。
template <typename T> |
其中,public swap成员函数的实现照旧,不会有任何问题。但std::swap
的特化版本将会遇到问题:
namespace std { |
上述实现是不合法的。如果你看过我对特化的讨论,应该能意识到这是一个偏特化的实现(确定了部分信息)。而函数模板是没有偏特化的,所以这段代码是无法编译通过的。然后,很容易想到的是,偏特化的替代手段就是函数模板重载。因此我们可以改为实现成如下:
namespace std { |
上述实现也是不合法的。对函数模板做重载是没有问题的,但问题出在std命名空间。std是个特殊的命名空间,用户可以全特化std内的函数模板,但不可以添加新的函数模板(即重载)到std中。C++标准委员会禁止我们膨胀那些已经声明好的东西。那到底要怎么做呢?答案很简单,将这个swap函数模板作为一个non-member函数模板置于Widget
类模板所在的命名空间(置于global命名空间也合法,但不优雅)。
namespace WidgetStuff { |
上述实现我们姑且称之为 swap 之于 Widget 的专属版本。这种实现其实对于类和类模板都是适用,那是不是就代表着不用再针对类实现其对应的std::swap
的特化版本了呢?答案是:为了尽可能的让你的swap优化版本适用于各种情况,建议即在类所在的命名空间实现一个non-member专属版本,有实现一个std::swap
的特化版本。考虑如下两种情况:
// 用户调用方法1 |
这种调用方式的好处是,编译器会基于类型去匹配最合适的调用。如果T
是Widget
类型,且其命名空间中存在专属版本的swap
,则会调用它;若不存在,则由于using std::swap
的存在,编译器还可以用std::swap
作为保底答案,若std::swap
的特化版本存在,则会选择特化版本。
// 用户调用方法2 |
这种调用方式不推荐,因为这限制编译器只能选择std::swap
及其特化版本(若有)。这时实现一个std::swap
的特化版本的作用就体现出来了:即便用户使用了不被推荐的调用方式,也能够让你的swap优化版本得以被调用。
总结
- 首先,如果缺省版本的
std::swap
能够提供可接受的效率,则你只需要定义好你的对象的拷贝动作即可; - 其次,如果缺省版本的效率不足(几乎总是意味着你的class或class template使用了某种pimpl手法),则可以考虑如下:
- 提供一个 public swap 成员函数,能够高效的处理置换操作,且该函数绝不该抛出异常(条款29对此有进一步讨论)(不抛出异常的约束仅针对成员函数,不施加于下面两种非成员函数,因为缺省版本的swap是以copy构造函数和copy赋值操作符为基础实现的,而一般情况下两者都允许抛出异常);
- 在类或类模板所在的命名空间中提供一个 non-member swap 函数,并令其调用上述成员函数;
- 如果你在实现一个类,而非类模板,则为你的类特化
std::swap
函数,令其调用上述成员函数;
- 最后,在调用时,请包含一个
using
声明式,让std::swap
可用,并在调用swap
时不加任何namespace修饰符。