【C++】《Effective C++》第四章
第四章 设计与声明
条款18:让接口容易被正确使用,不易被误用
请记住
- 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达到这些性质。
- "促进正确使用"的办法包括接口的一致性,以及与内置类型的行为兼容。
- "阻止误用"的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
shared_ptr
支持定制型删除器(custom deleter
)。这可防范DLL
问题,可被用来自动解除互斥锁(mutexes
)等等。
条款19:设计class
犹如type
C++中,当定义一个新的class,也就是定义了一个新type。
那么,如果设计高效的classes呢?以下是需要面对的问题:
- 新
type
的对象应该如何被创建和销毁?- 这会影响到你的
class
的构造函数和析构函数以及内存分配函数的释放函数(operator new
,operator new []
,operator delete
,operator [] delete
)等的设计,不过前提是如果你自定义它们。
- 这会影响到你的
- 对象的初始化和对象的赋值该有什么样的差别?
- 这会决定你的构造函数和赋值操作符的行为以及其间的差异。很重要的是别混淆了"初始化"和"赋值",因为它们对应于不同的函数调用。
- 新
type
的对象如果被passed-by-value(以值传递)
,意味这什么?- 这会让你思考
copy构造函数
用来定义一个type
的pass-by-value该
如何实现。
- 这会让你思考
- 什么是新
type
的"合法值"?- 对
class
的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的class
必须维护的约束条件,也就决定了你的成员函数必须进行的错误检查工作,它也影响函数抛出的异常、以及函数异常明细。
- 对
- 你的新
type
需要配合某个继承体系吗?- 如果你的类继承自某些既有的类,那么你就受到那些
classes
的设计的束缚,特别是受到"它们的函数是virtual
还是non-virtual
"的影响。如果你的类允许其他classes
继承,那会影响你所声明的函数,尤其是析构函数是否为virtual
。
- 如果你的类继承自某些既有的类,那么你就受到那些
- 你的新
type
需要什么样的转换?- 如果你允许类型
T1
被隐式转换为T2
,那么就必须在class T1
内写一个类型转换函数(operator T2
)或者在class T2
内写一个non-explicit-one-argument
(可被单一实参调用)的构造函数。如果你允许explicit构造函数
存在,就得写出专门负责执行转换的函数,且不能为类型转换操作符或on-explicit-one-argument构造函数
。
- 如果你允许类型
- 什么样的操作符和函数对此新
type
而言是合理的?- 这会决定你将为你的
class
声明哪些函数,其中某些该是member
函数,某些则不是。
- 这会决定你将为你的
- 什么样的标准函数应该被驳回?
- 这会决定你必须声明为
private
的函数。
- 这会决定你必须声明为
- 谁该取用新
type
的成员?- 这会决定你哪个成员为
public
,哪个为protected
,哪个为private
。也会决定你哪一个classes
或functions
应该是friends
,以及将它们嵌套于另一个之内是否合理。
- 这会决定你哪个成员为
- 什么是新
type
的"未声明接口"?- 这会决定你对效率、异常安全性以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的
class
实现代码加上相应的约束条件。
- 这会决定你对效率、异常安全性以及资源运用(例如多任务锁定和动态内存)提供何种保证?你在这些方面提供的保证将为你的
- 你的新
type
有多么一般化?- 或许你其实并非定义一个新
type
,而是定义一整个types
家族。果真如此你就不该定义一个新class
,而是应该定义一个新的class template
。
- 或许你其实并非定义一个新
- 你真的需要一个新
type
吗?- 如果只是定义新的派生类以便为既有的添加机能,那么说不定单纯定义一个或多个
non-member
函数或者templates
更能达到目标。
- 如果只是定义新的派生类以便为既有的添加机能,那么说不定单纯定义一个或多个
请记住
class
的设计就是type
的设计。在定义一个新type
之前,请确定你已经考虑过本条款覆盖的所有讨论主题。
条款20:宁以pass-by-reference-to-const
替换pass-by-value
缺省情况下C++
以by value
方式传递对象至函数,传递过程中副本由对象的copy
构造函数产出,这可能使得pass-by-value
成为昂贵的操作。
class Person {
public:
Person();
virtual ~Person();
private:
std::string name;
std::string address;
};
class Student: public Person {
public:
Student();
~Student();
private:
std::string schoolName;
std::string schoolAddress;
};
// 使用
bool validateStudent(Student s);
Student plato;
bool platoIsOk = validateStudent(plato);
分析上述代码,以by value
方式传递一个Student
对象会导致调用一次Student copy构造函数
、一次Person copy构造函数
、四次string copy构造函数
,并且当函数内的那个Student
副本被销毁,每一个构造函数调用动作都需要一个对应的析构函数调用动作,这就是昂贵的操作了。并且当参数接受一个基类对象但是传入一个子类对象时,传入的子类对象会被切割,只保有基类对象的部分,从而无法表现出多态。
这两个问题,通常可以通过pass-by-reference-to-const
解决。因为reference
往往以指针实现出来,因此它通常意味真正传递的是指针。
// 使用
bool validateStudent(const Student &s);
请记住
- 尽量以
pss-by-reference-to-const
替换pass-by-value
。前者通常比较高效,并可避免切割问题(slicing problem
)。 - 以上规则并不适用与内置类型,以及
STL
的迭代器和函数对象。对它们而言,pass-by-value
往往比较恰当。但是不是所有小型对象都是pass-by-value
的合格候选者。
条款21:必须返回对象时,别妄想返回其reference
必须返回对象的最常见是运算符函数:
const Rational operator*(const Rational&lhs, const Rational&rhs);
在必须返回对象时,不要企图放回reference
,不然就会造成下面的情况:
- 使用stack构造一个局部对象,返回局部函数的reference:
const Rational& operator*(const Rational&lhs, const Rational&rhs) {
Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
这样做的问题:使用reference
的本意是避免构造新对象,但是一个新的对象result
还是经由构造函数构造。更严重的是,这个局部对象在函数调用完成后就被销毁了,reference
将指向一个被销毁的对象。
- 使用heap构造一个局部对象,返回局部函数的reference:
const Rational& operator*(const Rational&lhs, const Rational&rhs) {
Rational *result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
}
Rational w, x, y, z;
w = x * y * z;
这样做的问题:虽然不再引用一个被销毁的对象,但是多了动态内存分配的开销。而且,谁该为delete
负责也成为问题。并且当多次动态分配内存时只返回最后一个的指针,这就造成了资源泄漏,比如上面的连乘操作。
- 构造一个static局部对象,每次计算结果保存在这个对象中,返回其reference:
const Rational& operator*(const Rational&lhs, const Rational&rhs) {
static Rational result;
result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return result;
}
Rational w, x, y, z;
if((w * x) == (y * z)) {
// ...
}
这样做的问题:显而易见的问题是这个函数在多线程情况是不安全的,多个线程会修改相同的static
对象。并且,在上面的判断语句中,不管传入的w, x, y, z
是什么,由于operator*
返回的reference
都指向同一个static
对象,因此上面的判断永远为真。
请记住
- 绝不要返回
pointer
或者reference
指向一个local stack
对象,或返回reference
指向一个heap-allocated
对象,或返回pointer
或reference
指向一个local static
对象而有可能同时需要多个这样的对象。
条款22:将成员变量声明为private
为什么不能是public:
- 语法一致性:如果成员函数和成员变量一样,都是
public
,那么调用时会困惑于该不该使用括号,比如想获取大小时使用size
,但是这到底是一个成员变量还是一个成员函数呢? - 更精确的访问控制:通过将成员变量声明为
private
,通过成员函数提供访问,可以实现更精确的访问控制。 - 封装特性(主要):如果通过
public
暴露,在需要改成员变量的大量实现代码中,会直接使用当这个成员变量被修改或删除时,这样所有直接访问该成员的代码可能将会变得不可用。
为什么不能是protected:
- 理由同上面的三个。
请记住
- 切记应该讲成员变量声明为
private
。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class
作者以充分的实现弹性。 protected
并不public
更具封装性。
条款23:宁以non-member、non-friend
替换member
函数
假设有个浏览器类,包含一些功能用来清除下载元素高速缓存区、清除访问过的历史记录、以及移除系统中所有的cookies
。
class WebBrowser {
public:
// ...
void clearCache();
void clearCookies();
void clearHistory();
// ...
};
此时,如果想整个执行所有这些动作,那么有两个选择,一种实现成member
函数,一种实现成non-member
函数。
class WebBrowser {
public:
// ...
void clearCache();
void clearCookies();
void clearHistory();
// 实现成member函数,可以访问private成员
void clearEverything() {
clearCache();
clearCookies();
clearHistory();
}
// ...
};
// 实现成non-member函数,不可以访问private成员
void clearEverything(WebBrowser& wb) {
wb.clearCache();
wb.clearCookies();
wb.clearHistory();
}
关于这两种选择的抉择在于封装性。
对于对象内的代码,越少代码可以看到数据(也就是访问它),越多的数据可被封装,也就越能自由地改变对象数据。作为一种粗糙的测量,越多的函数也可访问它,数据的封装性就越低。
请记住
- 宁可拿
non-member
、non-friend
函数替换member
函数。这样做可以增加封装性、包裹弹性(packaging flexibility
)和机能扩充性。
条款24:如所有参数皆需类型转换,请为此采用non-member
函数
为类支持隐式类型转换不是个好主意,但是在数值类型之间颇为合理。考虑有理数和内置类型之间的相乘运算。
具有如下有理数:
class Rational {
public:
Rational(int n = 0, int d = 0); // 构造函数可以不为explicit,提供了int-to-Rational的隐式转换
int numerator() const; // 分子的访问函数
int denominator() const; // 分母的访问函数
private:
// ...
};
现在提供了隐式转换方式,那么operator*
应该实现成member
还是non-member
呢?
class Rational {
public:
// member
const Rational operator*(const Rational&rhs) const;
}
// non-member
const Rational operator*(const Rational&lhs, const Rational&rhs);
区别在于混合运算上,如果是member
,那么下面的混合运算只有一半行得通:
result = oneHalf * 2; // 等价于oneHalf.operator*(2) Success
result = 2 * result; // 等价于2.operator*(oneHalf) Error
因为内置类型没有相应的class
,也就没有operator*
成员函数,所以会错误。但是当实现为non-member
时,具有两个参数,都能通过int-to-Rational
,所以会正常。
请记住
- 如果你需要为某个函数的所有参数(包括被
this
指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member
。
条款25:考虑写出一个不抛异常的swap
函数
swap
原先只是STL
的一部分,而后成为异常安全性编程的脊柱,以及用来处理自赋值的常见机制,可以参考条款12。
- STL的swap实现:
namespace std{
template<typename T>
void swap(T& a,T& b){
T temp(a);
a=b;
b=temp;
}
}
只要类型T
支持copying
操作,上述实现就没问题,但是对于某些类型而言,其copying
行为是不必要的,降低了程序运行的性能。
- 最好的方案:
class Widget{
public:
void swap(Widget& other){
using std::swap; // 必须声明
swap(pImpl, other.pImpl);
}
private:
Widget *pImpl;
};
namespace std{
template<>
void swap<Widget>(Widget& a,Widget& b){
a.swap(b);
}
}
这种实现不仅高效还能与STL
容器兼容,因为STL
容器也提供了public swap
成员函数与std::swap
特例化版本。
- template下的swap实现:
namespace WidgetStuff{
template<typename T>
class Widget { }
template<typename T>
void swap(Widget<T>& a,Widget<T> &b){
a.swap(b);
}
}
请记住
- 当
std::swap
对你的类型效率不高时,提供一个swap
成员函数,并确定这个函数不抛出异常。 - 如果你提供一个
member swap
,也该提供一个non-member swap
用来调用前者。对于classes
(而非templates
),也请特化std::swap
。 - 调用
swap
时应该针对std::swap
使用using
声明式,然后调用swap
并且不带任何"命名空间资格修饰"。 - 为"用户定义类型"进行s
td templates
全特化是好的,但不要尝试在std
内加入某些对std
而言全新的东西。