文章目录
四、设计与声明
条款18:让接口容易被正确使用,不易被误用
想要开发出这样的接口,需预判客户可能做出什么样的错误。
防止误用
预防传递参数的顺序错误与输入错误:导入新的类型
class Date {
public:Date(int month, int day, int year);
...;
};
//导入新的类型
struct Day {explicit Day(int d) : val(d) { }
int val;};
class Month {explicit Month(int m) : val(m) { }
static Month Jan() { return Month(1): } //函数,返回有效的月份
...;
static Month Dec() { return Month(12);}
private:explicit Month(int m);
};
struct Year {explicit Year(int y) : val(y) { }
int val;};
class Date {
public:
Date(const Month& m, const Day& d, const Year& y);
...;
};
Date d(Month::Mar(), Day(30), Year(1995)); //正确了!
预防错误的另一个方法:限制类型内可做/不可做的事(条款03例子operator=()加上const)
促进正确使用
尽量让自定义type和内置type行为/接口保持一致;避免无端与内置类型不兼容(提供行为一致的接口)。
任何接口如果让用户记得做某事,就有“不正确使用”的倾向。
//条款13例
Investment* createInvestment();//需放入智能指针,避免忘记删除指针及多次删除指针
//忘了放入智能指针也很糟糕,直接放入智能指针会是更好的选择
std::tr1::shared_ptr<Investment> createInvestment()
{
//shared_ptr使用者可能直接使用delete释放资源,这与智能指针的思路相违背。
//可在函数内就指定资源释放函数getRidOfInvestment
std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestment);//为空指针指定删除器
retVal = ...; //令retVal指向正确的对象。当然如果已经知道所要放入的指针,直接放入更好
return retVal;
}
cross-DLL problem(不同模块间申请和释放内存)
对象的new与delete来着不同的DLL(动态链接库),这会导致运行期错误。使用shared_ptr会避免这一问题,会自动使用原DLL的delete。
shared_ptr开销巨大(动态分配内存记录用途和删除器数据、virtual调用删除器、多线程需要修改引用次数)是原始指针的两倍,但是降低接口误用相关极好。
条款19:设计class犹如设计type
本条款基本照抄,较难总结。
定义一个class就是在定义一个新的type。重载函数、操作符、控制内存、定义对象等都有你设计。因此你要带着和“语言设计者当初设计语言内置类型时”一样的谨慎来研讨class的设计。几乎每一个class都要面临如下问题:
-
**新type的对象应该如何被创建和销毁?**这会影响到class的构造函数和构造函数以及内存分配函数和释放函数(operator new, operator new[], operator deleter和operator deleter[]),可见第八章。
-
**对象的初始化和对象的赋值应该有什么差别?这个问题的答案,决定了构造函数和赋值(assignment)操作符的行为,以及它们之间的差异。注意!“初始化”和“赋值”**是不同的,因为他们应用于不同的函数调用,可见条款4。
-
新type的对象如果被passed by value(以值传递),会怎样?需要记住的是,copy构造函数用来定义一个type的pass-by-value应该如何去实现。
-
**什么是type的“合法值”?**对class的成员变量来说,只有部分数值集是有效的。这决定了class需要维护的约束条件(invariants),也决定了成员函数(特别是构造函数、赋值操作符以及所谓的“setter”函数)必须要进行错误检查工作。同时,影响函数抛出异常以及函数异常明细列。
-
你的新type需要配合某一个继承图系(inheritance graph)吗?
- 继承来自某些既有的class,那么设计的新class就收到了束缚,特别是受到“它们的函数是virtual或者non-virtual”的影响。
- 如果我们定义的class允许其他class去继承,这样会影响我们所声明的函数——尤其是析构函数——是否为virtual(详见条款7)。
-
你的新type需要怎样的转换?
你的type与其他type需要转换吗?
- 如果我们希望允许类型T1可以被隐式地转换为类型T2,就必须在class T1中写一个类型转换函数(operator T2)或者在class T2内写一个non-explicit-one-argument(可被单一实参调用)的构造函数。
- 如果我们只允许explicit构造函数存在,就必须写出专门负责执行转换操作的函数,且不得为类型转换操作符或non-explicit-one-argument构造函数(条款15和16)。
-
**什么样的操作符和函数对新创建的type而言是合理的?**需决定class声明哪些函数,在这些函数中,哪些是/不是member 函数。(条款23/24/46)
-
**什么样的标准函数应该被驳回?**这些函数是必须声明为private的函数(详见条款7)。
-
**谁该取用新的type成员?**可帮助我们决定成员是public、protected或private;帮忙决定class/function是否为friend,以及将它们嵌套到另一个中合理吗?
-
**什么是新type的“未声明接口”(undeclared interface)?**它会对效率、异常安全性(条款29)以及资源运用(例如多任务锁定和动态内存)提供何种保证?这些保证将为你的class实现代码加上相应约束条件。
-
**你的新的type有多么一般化?**可能需要的不是一个type,而是一整个types家族,那么更应该定义一个新的class template。
-
**真的需要定义一个新的type吗?**如果定义derived class只是为base class添加功能,那么定义一个或多个non-member函数或者template也可以。
条款20:宁以pass-by-reference-to-const替换pass-by-value
默认情况下C++以by value方式(继承自C的方式)传递对象至函数;调用端获得的也是返回值的一个复件。
继承体系中,按值传递需要调用大量构造和析构函数。
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);
调用了一次Student copy构造函数、一次Person copy构造函数、四次string copy构造函数及六次对应析构函数。
解决方案:pass-by-reference-to-const可大大减少调用次数,const可避免传入对像的修改。
Slicing(对象切割)问题:当derived class对象以by value方式传递并视为base class对象,会导致derived部分被切割掉。
解决方案:pass-by-reference-to-const
在C++编译器底层,reference以指针实现,pass by reference通常传递的是指针。
因此,如传递的是内置类型、STL迭代器和函数对象,pass by value更高效;其他情况下pass-by-reference-to-const是更好的选择。
编译器对自定义类型和内置类型会有完全不同的处理,即使两者放的东西一样。自定义类型在将来或许会变大。
条款21:必须返回对象时,别妄想返回其reference
过度强调pass-by-reference-to-const(条款20),会犯一个致命错误:传递一些reference指向其实并不存在的对象。
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);//不加explicit,详见条款24
...;//分子numerator和分母denominator
private:
int n, d;
friend const Rational operator* (const Rational& lhs, const Rational& rhs);
};
operator*()通过by value传递,必然会有构造和析构成本。采用reference则无需负担该成本,但是使用时需明确reference的对象是什么?
Rational a(1, 2); //a = 1/2
Rational b(3, 5); //b = 3/5
Rational c = a * b; //c应该是3/10
不可能期望一个(内含乘积)Rational对象在调用operator*之前就存在,不存在就无法返回其reference,想要这么写必须自己创建Rational对象。
创建对象的途径:
-
在stack空间
这种方法返回的是loacl对象,在退出函数体后对象就会销毁,这会导致无定义行为。
-
在heap空间
这需要调用构造函数并new一个对象,谁来delete对象?没有方法让他们取得operator*返回的reference背后隐藏的指针。
-
static对象
const Rational& operator* (const Rational& lhs, const Rational& rhs){ static Rational result;//烂代码!定义static对象,该函数将返回其reference result = ... ;//将lhs乘以rhs,并将结果置于result之内 return result; } //一个针对Rational而写的operator== bool operator==(const Rational& lhs, const Rational& rhs); Rational a, b, c, d; if ((a * b) == (c * d))乘积相等所执行的动作; else 乘积不等所执行的动作; //将(a * b) == (c * d)展开 if (operator==(operator*(a, b), operator*(c, d))
两次operator*都各自改变了static Rational对象值,但是都返回了相同的reference,因此该判断永远为真。
以上三种都不是好方法,最好的方法是直接返回新对象,当然必须承担构造和析构成本。
incline const Rational operator* (const Rational& lhs, const Raitonal& rhs){
return Rational(lhs.n * rhs.n, lhs.d * rhs.n);
}
C++允许编译器进行优化,通常operator*返回值的构造和析构可安全消除。
总结:当必须在“返回reference和一个object”间抉择,选择行为正确的那个,让编译器进行优化。决不让pointer或reference指向local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象。条款4已为“单线程环境中合理返回reference指向一个local static对象”提供一份设计实例。
条款22:将成员变量声明为private
成员变量声明为private的优点:
- 语法一致性:非public成员变量,可都用函数来访问,避免了要不要加圆括号的纠结。
- 细微划分之访问控制:成员变量可实现“不准访问”、“只读访问”、“读写访问”及“只写访问”。每个成员变量都需要一个getter函数和setter函数控制也是很少见。
- 封装:更改内部实现而不改接口,提供class作者充分的实现弹性(如果遵守条款31甚至不用重新编译),例如可使成员变量被读或被写时轻松通知其他对象、可验证class的约束条件及函数的前提及事后状态、可在多线程环境中执行同步控制等。可确保class的约束条件总获得维护。public指不封装也就不可改变。
protected成员同样适用于语法一致性及细微划分之访问控制,就像对public一样适用。
但是**protected成员并不比public成员更具封装性。**当修改public成员,所有使用它的代码都要修改。当修改protected成员,所有使用它derived class的代码都要修改。因此,protected和public同样缺乏封装性。从封装的角度来看,只有两种访问权限:private(提供封装)和其他(不提供封装)。
条款23:宁以non-member non-friend函数替换member函数
以浏览器清理为例:
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
void clearEverything(); //调用上面三个函数
};
//也可以通过non-member且non-friend函数实现
void clearBrowser (WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
比较member函数和non-member non-friend函数哪种更好?
对于面向对象守则的要求,数据及操作数据的那些函数应该被捆绑在一起。这就意味着使用member函数可能是一个比较好的选择。然而这个建议是一种误解!
**面向对象守则要求数据应该尽可能地被封装。**越少的代码可以访问数据,那封装越好。如何测量“有多少代码可访问一块数据”?可粗略计算能访问该数据的函数数量,越多封装越差。
在member函数和non-member non-friend函数中,member函数还可以访问private数据及函数、enums、typedef等,而non-member+non-friend函数都不行,反而封装性更好,是更好的选择。
有两件事值得注意:
- 以上论述只适用于non-member+non-friend函数。public无封装性;private只有member函数和friend函数能访问,两者对封装的冲击也相同。从封装角度看,选择的关键在在member函数和non-member non-friend函数之间。(封装并不是唯一要考虑的,条款24解释了当考虑隐式类型转换,应在member和non-member之间抉择)。
- 一个class的non-member并不意味着不能是另一个class的member,包裹弹性较好。
最自然的做法是将non-member non-friend函数放入同一个namespace中,机能拓展性较好。类似于C++标准库的组织方式,用于数个头文件都在命名空间std中,需要用到相关组件时才会添加相关头文件。这种做法降低了编译的依赖性(条款31)。
总结:宁可拿non-member non-friend函数替换member。这样就可以增加封装性、包裹弹性(packing flexibility)和机能扩展性。
条款24:若所有参数皆需类型转换,请为此采用non-member函数
导读部分建议不支持class的隐式类型转换,但有时不得不需要隐式转换。例外,当要设计一个有理数计算类,允许整数“隐式转换”为有理数颇为合理。
class Rational {
public:
Rational(int numerator = 0, //构造函数刻意不是explicit
int denominator = 1); //允许int-to-rational进行隐式转换
int numerator() const; //分子的访问函数
int denominator() const; //分母的访问函数
private:
...
};
按照面向对象的思想operator*()应该放入类中,但条例23指出这反而会对面向对象守则发生冲突。
这里假设用member函数的写法:
class Rational {
public:...;
const Rational operator* (const Rational& rhs) const;//参考条款3、20和21
};
Rational oneEight(1, 8);
Rational oneHalf(1, 2);
Rational result = oneHalf * oneEight; //成功!
result = result * oneEight; //成功!
//但是,这种写法不能实现混合计算
result=oneHalf*2;//成功!oneHalf.operator*(2);这里发生了隐式类型转换
result=2*oneHalf;//错误!2.operator*(oneHalf);*this无法进行隐式类型转换,只有参数列中参数才能转换
用non-member函数所有参数都可以进行隐式类型转换:
//构成了一个non-member函数
const Rational operator*(const Rational& lhs, const Rational& rhs)
{return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());}
Rational oneForth(1, 4);
Rational result;
result = oneForth * 2; //成功了!
result = 2 * oneForth; //成功了!!
考虑要不要作为friend函数?
本例是否定的,所有计算均可在public接口完成。member函数的反面是non-member函数,不是friend函数(如果可以避免friend函数尽量不用)。
当从Object-Oriented C++变为Templete C++,该条款需要重新考虑(见条款46)。
条款25:考虑写出一个不抛异常的swap函数
原本只是STL的一部分,后来成为异常安全性编程的核心(条款29),以及用来处理自我赋值可能性(见条款11),也因此复杂性极高。
复习模板特化:特化必须在同一命名空间下进行,可以特化类模板也可以特化函数模板,但类模板可以偏特化和全特化,而函数模板只能全特化。 偏特化定义了一个参数集合模板,需要进一步实例化才能确定签名。
swap在标准库的典型实现
只要类型T支持copying(copy构造和copy赋值)函数,可以抛出异常。
namsespace std {
tempate<typename T>
void swap(T& a, T& b){
T temp(a);
a = b;
b =temp;
}
}
swap交换使用“pimpl手法”的对象
如要交换的对象,使用“pimpl手法”(pointer to implement即条款31)效率反而不高。
原本交换指针就能高效完成交换,swap函数会复制所指向的对象,例子如下:
class WidgetImpl {
public:
...;
private:
int a, b, c; //可能有许多数据,意味着复制时间很长
std::vector<double> v;
...;
};
class Widget { //该class使用pimpl手法
public:
Widget(const Widget& rhs);//复制Widget时,令它复制其WidgetImpl对象
Widget& operator=(const Widget& rhs){//operator=的实现见条款10~12
...;
*pImpl = *(rhs.pImpl);
...;
}
...;
private:
WidgetImpl* pImpl; //指针,所指的对象就是内含Widget数据
};
解决方案:使用std::swap的Widget特化版本,考虑到pImpl是private成员,调用分两步。
-
先在Widget声明一个swap的public成员函数(不能抛出异常为异常安全性核心);
-
然后将std::swap全特化(函数禁止偏特化),令其调用该成员。
class Widget {
public:
...;
void swap(Widget& other){
using std::swap;//这个声明是非常必要的
swap(pImpl, other.pImpl);//若要置换Widget,就置换其pImpl指针
}
...;
};
namespace std {//修订后的std::swap特化版本
tempate<> void swap<Widget>(Widget& a, Widget& b){
a.swap(b);//如果要置换Widgets,调用其swap成员函数
}
}
该做法与STL容器一致,同时提供public swap成员函数和std::swap特化版本(调用前者)。
swap交换class templete对象
如果Widget和WidgetImpl都是class template而非class,按照上面方案在第二步swap特化将遇到问题!
C++只允许class template偏特化,无法对function template偏特化(部分编译器会错误的接受该写法)
template<typename T> class WidgetImpl { ... };
template<typename T> class Widget { ... };
namespace std {
template<typename T>
void swap< Widget<T> > ( Widget<T>& a, Widget<T>& b){//偏特化错误!
a.swap(b);}
}
改写为全特化:
namespace std {
template<typename T> //std::swap的一个重载版本
void swap(Widget<T>& a, //需要注意的是,swap后面没有" <...> "
Widget<T>& b) //但是这样也是不合法的!
{ a.swap(b); }
}
仍然不合法原因:可全特化std内template,但不能加新template到std。这样做能通过编译,但是行为不明确不建议使用。
再次改写,既然std不能加template,换个namespace放template函数就好了:
namespace WidgetStuff {
...; //模板化的WidgetImpl等等
template<typename T> //和前面一样,内含swap成员函数
class Widget { ... };
...;
template<typename T> //non-member swap函数
void swap(Widget<T>& a, //这里并不属于std命名空间
Widget<T>& b){ a.swap(b); }
}
以上写法似乎在暗示WidgetStuff::swap就都能用,不需要写class全特化版std::swap,实际上还是有反例存在的,当用户乱加修饰符时:
std::swap(obj1,obj2);
这将强制使用std内的版本,这也是要再写一个**class全特化版本std::swap(非class template)**的含义。
swap在客户角度调用
从客户角度来看,希望为T型对象调用最佳swap版本,次之是std::swap()。
根据C++名称查找法则(argument-dependent loopup或Koenig lookup),首先将查找global作用域和T所在namespace任何T专属swap;然后用“实参取决之查找规则”挑选函数。
- 如果T是Widget并位于命名空间WidgetStuff内,编译器就会使用**“实参取决的查找规则”(argument-dependent lookup)**找出WidgetStuff内的swap。
- 如果已有T专属的std::swap存在(全特化),这个特化版本将优先使用(前提调用using std::swap;)。
- 如果没有T专属的std::swap存在,编译器就会使用std内的swap(前提调用using std::swap;)。
总结
首先,如果swap的默认实现对我们的class或class template效率较好,不需要做其他的。
其次,如果swap的默认实现效率不足(因class或class template用了某种pimpl手法)则:
- 提供一个public swap成员函数,让它高效地置换对应类型的两个对象值。这个函数绝不能抛出异常!
- 在我们的class或template所在的命名空间内提供一个non-member swap,并令它调用上述swap成员函数。
- 如果我们正在编写一个class(非class template),为我们的class特化std::swap。并令它调用我们的swap成员函数。
最后,调用时记得写using std::swap;
并直接使用swap,编译器会帮忙挑选最符合的。
其他事项:
-
默认版swap却可以抛出异常,因为必然会用到copying函数(可抛出异常);
-
成员版swap绝不能抛出异常,它是异常安全性的基石(见条款29)。
-
自定义版swap(成员版)提供高效置换及不抛出异常。(高效置换通常指内置类型操作,内置类型必不抛出异常)。