第一章见 Effective C++ 学习笔记 第一章:让自己习惯 C++
第二章见 Effective C++ 学习笔记 第二章:构造、析构、赋值运算
第三章见 Effective C++ 学习笔记 第三章:资源管理
第四章见 Effective C++ 学习笔记 第四章:设计与声明
第五章见 Effective C++ 学习笔记 第五章:实现
第六章见 Effective C++ 学习笔记 第六章:继承与面向对象设计
第七章见 Effective C++ 学习笔记 第七章:模板与泛型编程
第八章见 Effective C++ 学习笔记 第八章:定制 new 和 delete
第九章见 Effective C++ 学习笔记 第九章:杂项讨论
文章目录
条款 18: 让接口容易被正确使用,不易被误用
Make interfaces easy to use correctly and hard to use incorrectly.
首先需要考虑用户可能会如何误用你的接口,尽可能避免被误用。
- 限制参数的值范围,用特殊类型替代一些值。
- 约束 const。
- 让你的接口的用法与内置类型接口保持一致。
- 避免返回资源的接口用户忘记释放资源或错误释放的办法:直接返回智能指针类型。
总结
- 好的接口很容易被正确使用,不容易被误用,应该努力达到这个目标。
- 适用办法:接口一致性,与内置类型行为兼容。
- 适用办法:建立新类型,限制类型范围,消除用户管理资源的动作。
- 巧用 shared_ptr。
条款 19:设计 class 犹如设计 type (重要)
Treat class design as type design.
C++ 开发过程中,很多时间是在设计类型系统,class 就像你的自定义类型。应该带着语言设计者的思路来对待你设计的 class。
需要考虑以下几个问题
- 新的类型对象应该如何被创建和销毁。谨慎设计你的构造函数、析构函数和内存分配与释放函数。
- 注意新类型的对象初始化和对象赋值的差别。你的构造函数和 copy 函数的行为是否有差异,不要混淆二者。
- 新的类型对象如果以值传递,会遇到什么问题。
- 什么是新类型对象的合法输入。你应该为其设计约束条件,并做异常检查工作。
- 新的类型是否需要使用继承。你的 class 是否可以继承其他 class 来完成,或者你的 class 是否会将来被其他 class 所继承,考虑 virtual 的使用。
- 新的类型需要哪些类型转换。是否允许隐式类型转换,是否提供类型转换函数,或使用 explicit 约束构造函数。
- 什么样的操作符和函数对这个类型是合理的。你将为你的类型提供哪些操作。
- 什么样的操作这个类型不应该提供。你应当将这些操作放到 private 中。
- 什么样的操作这个类型应当提供。你应该决定各种接口的属性,灵活使用 public, protected,或使用 friend。
- 决定未声明接口。对效率、异常安全、资源应用上提供保证,为特定的代码设计约束条件。
- 新的类型有多么一般化。所谓一般化是指其通用性,如果你是在定义一个类型类别,应该使用 class template 代替 class。
- 你是否需要设计这个类型。是否通过继承其他类型便可实现功能。
总结
- class 的设计就是类型的设计,在定义新类型之前,你需要考虑以上内容。
条款 20:宁以 pass-by-reference-to-const 替换 pass-by-value (重要)
Prefer pass-by-reference-to-const to pass-by-value.
将函数参数传值方式改为传递 const 引用方式。
话题 1:避免传参拷贝动作
为了避免传参时昂贵的拷贝操作,用引用替代传值。同时,const 也是必要的,这样保证了传值方式下,形参修改不会影响实参值的语义不变(或者说,避免参数被函数内操作所修改)。如果需要修改实参值,去掉 const 修饰,引用也可以取代传递指针方式。
这是个习惯问题,养成习惯了会改不掉。
话题 2:发挥多态特性
传引用方式也可以避免对象切割问题。想象我们想实现一个多态特性:
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 对象完整的传入
话题 3:传值有时更好
引用的底层实现是指针,但其不是单纯的指针,所以会比指针传递大一些(为了实现引用特性)。
对于小型对象,比如内置类型,传值会更好。编译器更偏爱对小型对象做特殊操作。除内置类型外,还有 STL 的迭代器 和 函数对象。
总结
- 尽量使用 pass-by-reference-to-const 替换 pass-by-value。前者通常更高效,而且可以避免切割问题。
- 上一条并不适用于小型对象,如内置类型、STL 迭代器 和 函数对象。对他们而言, pass-by-value 更合适。
条款 21:必须返回对象时,别妄想返回其 reference
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 动作的高代价问题。但这个条款中,返回引用会导致的问题,依然值得我们深入思考。
总结
- 绝对不要返回指向局部栈对象的指针或引用,也不要返回指向堆对象的引用,这都会导致问题,更不要使用 static 对象来干这种事情。
条款 22: 将成员变量声明为 private
Declare data members private.
好处:
- 不需要用户去记住哪些是属性(不需要加小括号),哪些是函数(需要加小括号);
- 对属性的灵活控制,比如只读、只写、可读写访问;
- 封装性,可以灵活修改属性内容;
- 与其他事务绑定,如访问属性时的额外动作,多线程控制等,本质上还是封装带来的灵活性;
能用 private 就不要用 protected。从封装的角度看,只有两种访问权限,private 和其他。
总结
- 切记将成员变量声明为 private。这带给用户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供 class 作者以充分的实现弹性。
- protected 并不比 public 更具封装性。
条款 23:宁以 non-member、non-friend 替换 member 函数
Prefer non-member non-friend functions to member functions.
有些时候,使用普通函数替代 成员函数 或 友元函数 更好。
比如,class 提供了多个不同的接口功能函数(成员函数),当我们希望将这些函数打包,完成一个统一的功能时,使用普通函数。
原因:
- 普通函数 相比于 成员函数,其封装性更好。这里,我们将要设计的这个统一功能看做用户功能,而将其他功能看做要封装的内容,对于成员函数,其本身对 class 内信息的访问权限要大于普通函数,权限越严格,封装性越好,所以普通函数封装性更好;
- 我们可以将这个普通函数与 class 放到一个 namespace,不同于 class,namespace 可以跨文件在多个地方声明同一个名称的 namespace,这就大大提高了扩展的灵活性;另外,这个普通函数也可作为其他函数的成员函数,灵活性更好;
- 我们可以将这个函数提供给用户,由用户来修改完善其内容;同时用户也可以自己定义类似的统一功能函数,提高了可扩充性。
C++ STL 库的实现便有此类思想,在不同的文件,如<vector> <string> 等文件中,都将操作统一放到 namespace std 中,由用户来控制编译相关性。
总结
- 宁可用 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性和机能扩充性。
条款 24:若所有参数皆需类型转换,请为此采用 non-member 函数 (重要)
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* 放到一个命名空间中,达到一定的包装效果。
总结
- 如果你需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member。
条款 25:考虑写出一个不抛出异常的 swap 函数
Consider support for a non-throwing swap.
这个条款可能有点难以理解。
另外,标题内容不完全表示本条款内容,我认为本条款主要讲清了如何设计一个高效且实用的 swap 功能。
话题 1:设计一个高效的 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>(){} 的全特例化版本,特例化的 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。
话题 2:再将类改成模板类试试
如果改为:
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<typename T> std::swap(T& a, T& b);
的重载版本,重载参数类型,由 T &, T &
转为 W<T> & W<T> &
。
不过,这里依然不合理,因为 std 内不允许做这些修改,可能会导致未知的问题。
所以,我们应该把这个重载的 swap 从 std 命名空间中拿出来,可以放到另一个我们自定义的命名空间中。
话题 3:用户的使用
对用户来说,为了能找到最佳的那个 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。
话题 4:简单总结
如果默认的 std::swap 在对待大资源对象时,效率不足,可按如下 3 点处理:
- 提供一个 public swap 成员函数,让它能高效的置换类型对象,比如交换资源指针。
- 如果你的待交换对象是类(而不是模板类),针对你的对象提供 std 中的一个全特例化版本的 swap。
- 但为了能实现模板类对象的交换,则需要提供一个公共的 swap 函数,它的设计类似于 std::swap 的一种重载,并让该函数来调用 public swap 函数。为了代码整洁,这个普通 swap 函数因和你的类代码放到一个 namespace 中。
最后,回到标题,类成员变量的 swap 实现中一定不能抛出异常。因为公认的 swap 函数应该为 class 提供尽可能好的异常安全性而设计。这种要求不施加于普通的 swap 函数。
总结
- 当 std::swap 对你的类型转换效率不高时,提供一个 swap 成员函数,并确保这个函数不抛出异常。
- 如果你提供一个成员函数 swap,也应该提供一个普通的 swap 函数来调用前者,如果是普通类(非模板类),应该特例化 std::swap。
- 调用 swap 时注意加上
using std::swap;
,在调用 swap 时,不要做 std 约束。 - 除了对 std 中的类型做全特例化以外,不要对 std 中增加其他内容。