第一章见 Effective C++ 学习笔记 第一章:让自己习惯 C++
第二章见 Effective C++ 学习笔记 第二章:构造、析构、赋值运算
第三章见 Effective C++ 学习笔记 第三章:资源管理
Make interfaces easy to use correctly and hard to use incorrectly.
首先需要考虑用户可能会如何误用你的接口,尽可能避免被误用。
Treat class design as type design.
C++ 开发过程中,很多时间是在设计类型系统,class 就像你的自定义类型。应该带着语言设计者的思路来对待你设计的 class。
Prefer pass-by-reference-to-const to pass-by-value.
将函数参数传值方式改为传递 const 引用方式。
为了避免传参时昂贵的拷贝操作,用引用替代传值。同时,const 也是必要的,这样保证了传值方式下,形参修改不会影响实参值的语义不变(或者说,避免参数被函数内操作所修改)。如果需要修改实参值,去掉 const 修饰,引用也可以取代传递指针方式。
这是个习惯问题,养成习惯了会改不掉。
传引用方式也可以避免对象切割问题。想象我们想实现一个多态特性:
class W { ... } // 定义一个基类 W
class WW : public W { ... } // 定义一个派生类 WW
void prints(W w) { ... } // 欲想实现一个多态特性的函数
WW ww;
prints(ww); // 我们本想传入一个 WW 的对象,但实际上,发生了对象切割
上例中,实际传入 prints 的只有 W 类所定义的那部分内容, WW 派生出来的特性都被切割掉了,本质上没有实现多态的特性。
应当修改为引用传参:
void prints(W& w) { ... } // 正确的多态特性函数
WW ww;
prints(ww); // 正确的将 ww 对象完整的传入
引用的底层实现是指针,但其不是单纯的指针,所以会比指针传递大一些(为了实现引用特性)。
对于小型对象,比如内置类型,传值会更好。编译器更偏爱对小型对象做特殊操作。除内置类型外,还有 STL 的迭代器 和 函数对象。
Don’t try to return a reference when you must return an object.
虽然条款 20 中说,参数尽量使用 带 const 的 reference 替代 传值方式,但对于函数返回来说,传引用却是个糟糕的设计,即使返回引用可以节省 copy 的代价。
reference 代表一个别名,如果这个 reference 对应的那个对象,是在函数内定义的,就会有问题。比如:
// 这个函数实现 R 对象的乘法操作,返回一个 R 对象的引用
const R& operator* (const R& lhs, const R& rhs) {
R result(lhs, rhs);
return result;
}
当函数返回时,放到栈上的临时变量 result 会被析构,所以,它所返回的值(接收引用的对象)将引用到非法位置。
const R& operator* (const R& lhs, const R& rhs) {
R* result = new R(lhs * rhs);
return *result;
}
这样做,就把 delete 的任务交给调用者来完成了,很可能调用者会忘记。另外,如果是这种:
R w, x, y, z;
w = x * y * z;
两次乘法就调用了两次 operator*,从而就在堆上产生了两个资源,但调用者连调用 delete 释放资源的机会都没有。
const R& operator* (const R& lhs, const R& rhs) {
static R result;
result = lhs * rhs;
return result;
}
这样更糟糕,static 变量的生存期是全局作用域,如果调用者调用了两次 operator*,那么返回的值将是两次的连乘:
R w, x, y, z, a, b;
w = x * y; // 结果是 x * y
z = a * b; // 结果是 x * y * a * b
另外,在多线程程序中,也会有类似的问题。
当然,如果你的函数只是用来被调用一次 (如按顺序初始化 static 对象的 hack 操作,见 条款 4),而不是像本例中,被频繁调用,这么写只是有危险,但不是不可以。
综上,没啥办法能通过返回引用的方式来返回由函数内定义的值。
所以,乖乖的返回值就好,即使 copy 的代价比较大,但总比各种 hack 之后导致更麻烦的问题要好。
但也要注意,对于自定义类型,写好你的 copy 函数。
当然,现代编译器会对返回过程做优化,会把两次 copy 改成 一次(函数返回是两次 copy)。
题外话,为什么函数返回是两次 copy?对于:
const R operator* (...) {
...
return result;
}
R w, x, y;
w = x * y;
调用 operator* 时,计算结果先放到 result 中,这个可能是构造函数,也可能就是 copy 函数,我们假设是构造函数。然后,result 会把内容 copy 到一个匿名临时对象中(第一次 copy,对应到硬件上,可能是放到栈上,也可能放堆上),然后析构函数内的资源,函数返回,函数返回后,再将这个匿名临时对象放到 w 对象中(第二次 copy),再析构这个匿名临时对象。所以是两次 copy。
另外注:在 C++ 11 中引入了 右值引用 的特性,提供了 移动构造函数的语义,解决了 C++ copy 动作的高代价问题。但这个条款中,返回引用会导致的问题,依然值得我们深入思考。
Declare data members private.
好处:
能用 private 就不要用 protected。从封装的角度看,只有两种访问权限,private 和其他。
Prefer non-member non-friend functions to member functions.
有些时候,使用普通函数替代 成员函数 或 友元函数 更好。
比如,class 提供了多个不同的接口功能函数(成员函数),当我们希望将这些函数打包,完成一个统一的功能时,使用普通函数。
原因:
C++ STL 库的实现便有此类思想,在不同的文件,如
Declare non-member functions when type conversions should apply to all parameters.
当一个自定义的类型,需要类型转换,不要用成员函数或 friend 函数来实现类型转换定义,而是用普通函数。
举例来说,如果你定义了复数类型,并且想定义一个支持类型转换的乘法操作,将复数与整数相乘,如果将乘法操作作为成员函数,操作为:
result = rat * 2; // 编译正确
result = 2 * rat; // 编译错误,但乘法应支持交换律,无法忍受这种缺憾
如果我们将 operator* 实现在复数类型内部,作为成员函数,这两个运算本质上应该为:
result = rat.operator* (2); // 正常运算 <1>
result = 2.operator* (rat); // 出错,整数常量不能调用其 operator* <2>
// 当然,编译器也会寻找下边这种函数:
result = operator* (2, rat); // 但实际上这种函数我们也没定义
这样的话,我们实际上不能完整支持这种类型的乘法运算。
在 <1>
中,实际上有个隐式类型转换,将 2 转换成复数类型后,完成复数与复数的乘法(当然前提是不能对构造函数做 explicit 修饰)。而在 <2>
中,放在前边的那个整形常量,无法做类型转换。
解决方法就是,将 operator* 实现为一个普通函数。如:
class Rat { ... }; // 复数类型定义
const Rat operator* (const Rat& lhs, const Rat* rhs) {
// 将实部和虚部分别相乘后,构造新对象返回
return Rat(lhs.num() * rhs.num(), lhs.den() * rhs.den());
}
另外,不应该将其定义为 friend 函数,作者的观点应该是 friend 会破坏类的封装性,能不用 friend 就不用,成员函数的反面是非成员函数,而不是 friend 函数。
我认为,可以用 namespace 来将上例中的 class 和 普通函数 operaotr* 放到一个命名空间中,达到一定的包装效果。
Consider support for a non-throwing swap.
这个条款可能有点难以理解。
另外,标题内容不完全表示本条款内容,我认为本条款主要讲清了如何设计一个高效且实用的 swap 功能。
最简单的 swap 函数是进行值拷贝,std 中是如此实现如下:
namespace std {
template<typename T>
void swap(T& a, T& b) {
T temp(a);
a = b;
b = temp;
}
}
然而,如果遇到 T 类型比较大时,copy 构造函数和 copy 赋值运算符会占用比较大的运行时间和内存。
一种能想到的改进措施是,将资源单独放在内存中,而使 T 类型中只包含指向资源的指针,然后在做交换时,只交换指针。
class WI { ... }; // 存放大量资源的类
class W { // 我们要 swap 的类
public:
W(const W& rhs);
W& operator=(const W& rhs) {
*pImpl = *(rhs.pImpl);
}
private:
WI* pImpl;
};
namespace std {
template<> // 表示 std::swap(){} 的全特例化版本,特例化的 T 是 W
void swap<W>(W& a, W& b) {
swap(a.pImpl, b.pImpl); // pImpl 是 W 类中指向资源的指针,但因为 pImpl 是私有成员,编译报错
}
}
不允许在 std 中的内容做改动,但允许对 std 中的模板类型提供全特例化版本。
因为无法编译,所以需要做一个针对 W 类的成员 swap 函数。如下设计:
class W {
public:
void swap(W& other) {
using std::swap; // 这个声明是必要的
swap(pImpl, other.pImpl);
}
private:
WI* pImpl;
};
namespace std {
template<>
void swap<W>(W& a, W& b) {
a.swap(b); // 调用类内提供的成员 swap 函数
}
}
以上设计是标准设计,在 STL 中的一些其他设计,也有此实现方式,即提供一个类内的 swap,再提供一个 std 内公有的全特例化版本 swap。
using std::swap;
这里的作用是,能够让成员 swap 函数检查到 std 中的 swap。
如果改为:
template<typename T>
class WI { ... };
template<typename T>
class W { ... };
namespace std {
template<typename T>
void swap<W<T>>(W<T>& a, W<T>& b) { // 会编译报错
a.swap(b);
}
}
原书中把这种写法叫偏特例化,C++ 只允许对类的偏特例化,不允许对函数的偏特例化,所以这里无法编译成功。一种改进是直接重载 swap 函数:
namespace std { // 放在 std 中不合理
template<typename T>
void swap(W<T>& a, W<T>& b) {
a.swap(b);
}
}
这样虽然可以,它是 template
的重载版本,重载参数类型,由 T &, T &
转为 W
。
不过,这里依然不合理,因为 std 内不允许做这些修改,可能会导致未知的问题。
所以,我们应该把这个重载的 swap 从 std 命名空间中拿出来,可以放到另一个我们自定义的命名空间中。
对用户来说,为了能找到最佳的那个 swap 函数,需要编写如下类似代码:
template<typename T>
void doSomething(T& a, T& b) {
using std::swap; // 让编译器可以找到 std 那个 swap
swap(a, b); // 由编译器自由选择最佳 swap,所以不要写作 std::swap(a, b);
编译器会根据 参数依赖查找,优先查找和参数相匹配的 swap 函数,如果没有找到,它会使用 std::swap。
如果默认的 std::swap 在对待大资源对象时,效率不足,可按如下 3 点处理:
最后,回到标题,类成员变量的 swap 实现中一定不能抛出异常。因为公认的 swap 函数应该为 class 提供尽可能好的异常安全性而设计。这种要求不施加于普通的 swap 函数。
using std::swap;
,在调用 swap 时,不要做 std 约束。