条款18、让接口容易被正确使用,不易被误用
假设设计一个用来表现日期的class设计构造函数,需要设计成以下:
struct Day{
explicit Day(int d):val(d){}
int val;
};
struct Month{
explicit Month(int m):val(m){}
int val;
};
struct Year{
explicit Year(int y):val(y){}
int val;
};
class Date{
public:
Date(const Month& m,const Day& d,const Year& y);
...
};
上面的代码中,我们导入简单的外覆类型来区别天数、月份和年份,然后于Date构造函数中使用这些类型。
令Day、Month和Year成为成熟且经充分锻炼的classes并封装其内数据,比简单使用上述struct好。
利用enum表现月份,但enum不具备我们希望拥有的类型安全性,例如enum可以被拿来当一个int使用,比较安全的解法是预先定义所有有效的Month:
class Month{
public:
static Month Jan(){ return Month(1);}//函数,返回有效月份
static Month Feb(){ return Month(2);} //稍后解释为什么,这些是函数而非对象
//...
static Month Dec(){ return Month(12);}
//...
private:
explicit Month(int m);//阻止生成新的月份。
... //这是月份专属数据
};
Date d(Month::Mar(),Day(30),Year(1995));
预防客户错误的另外一个办法是,限制类型内什么事情可以做,什么事情不能做。
任何接口如果要求客户必须记得做某件事情,就是有着“不正确使用”的倾向,因为客户可能会忘记做那件事。例如在heap申请一个对象指针,客户有可能忘记delete对象,释放其内存。
shared_ptr允许当智能指针被建立起来时指定一个资源释放函数(所谓删除器,“deleter”)绑定于智能指针身上(auto_ptr没有这种性能)。
shared_ptr提供的某个构造函数接受两个实参:一个是被管理的指针,另一个是引用次数变为0时将被调用的“删除器”。
因此,我们创建一个null shared_ptr并以getRidOfInvestment作为删除器,如下。
shared_ptr<Investment> pInv(0,getRidOfInvestment);
//企图创建一个null shared_ptr
//并携带一个自定的删除器。此式无法通过编译。
但是0是int类型,不是指针,可以被转换为指针。因此,转型可以解决这个问题:
shared_ptr<Investment> pInv(static_cast<Investment*>(0),getRidOfInvestment);
// 创建一个null shared_ptr 并以 getRidOfInvestment为删除器
因此,如果我们要实现createInvestment使它返回一个shared_ptr并夹带getRidOfInvestment函数作为删除器,因此代码如下:
shared_ptr<Investment> createInvestment();
{
shared_ptr<Investment> retVal(static_cast<Investment*>(0),getRidOfInvestment);
retVal = ...;//令retVal指向正确对象
return retVal;
}
shared_ptr有个性质:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在的客户错误:所谓的“cross-DLL problem”。这个问题发生于“对象在动态链接库(DLL)中被new创建,却在另一个DLL内被delete销毁”。许多平台上,这一类“跨DLL之new/delete成对运用”会导致运行期错误。shared_ptr没有这个问题,因为它缺省的删除器是来自“shared_ptr诞生所在的那个DLL”的delete。例如下面的例子:
Stock派生自Investment,
shared_ptr<Investment> createInvestment();
{
return shared_ptr<Investment>(new Stock);
}
返回的那个shared_ptr可以被传递到任何其他DLL,无需在意“cross-DLL problem”。这个指向Stock的shared_ptr会追踪记录“当Stock的引用次数变成0时该调用的那个DLL的delete”。
请记住:
1、好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
2、”促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容。
3、“阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
4、shared_ptr支持定制型删除器。这可防范DLL问题,可被用来自动解除互斥锁等等。
条款19:设计class犹如设计type
当你定义一个新的class,也就定义了一个新的type。
每一个class的设计,你都要面对以下问题:
1、新type的对象应该如何被创建和销毁?
2、对象的初始化和对象的赋值该有什么样的差别?
3、新type的对象如果被passed by value(以值传递),意味着什么?
4、什么是新type的“合法值”?
5、新的type需要配合某个继承图系吗?
6、新的type需要什么样的转换?
7、什么样的操作符和函数对此新type而言是合理的?
8、什么样的标准函数应该驳回?
9、谁该取用新type的成员?
10、什么是新type的“未声明接口”?
11、新type有多么一般化?
12、是否有必要设计一个新type?
请记住:
class的设计就是type的设计。在定义一个新的type之前,请确定你已经考虑过本条款覆盖的所有讨论主题。
条款20:宁以pass-by-reference-to-const替换pass-by-value
假设你有by value传递参数的validateStudent函数调用如下:
bool validateStudent(Student s);//函数以by value方式接受学生
Student plato; //柏拉图,苏格拉底的学生
bool platoIsOK = validateStudent(plato);//调用函数
对此函数而言,参数的传递成本是“一次Student copy构造函数调用,加上一次Student析构函数调用”。
因此修改成如下方式,pass by reference-to-const的传递参数方式:
bool validateStudent(const Student& s);
这种传递方式效率高很多:没有任何构造函数或析构函数被调用,因为没有任何新的对象被创建。
假设你在一组classes上工作,用来实现一个图形窗口系统:
#include <iostream>
#include<string>
using namespace std;
class Window{
public:
...
string name() const;//返回窗口名称
virtual void display() const;//显示窗口和其内容
};
class WindowWithScrollBars:public Window{
public:
virtual void display() const;
...
};
void printNameAndDisplay(Window w){
cout<<w.name();
w.display();
}
int main()
{
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
return 0;
}
display是个virtual函数,这意味着简易朴素的base class Window对象的显示方式和华丽高贵的的WindowWithScrollBars 对象的显示方式不同。
当以下两行代码被执行之后,发生什么事情呢?
WindowWithScrollBars wwsb;
printNameAndDisplay(wwsb);
参数w会被构造成为一个Window对象;它是passed by value。而造成wwsb“之所以是个WindowWithScrollBars对象”的所有特化信息都会被切除。在printNameAndDisplay函数内无论传递过来的对象原本是什么类型,参数w就像一个Window对象(因为其类型是Window)。因此在printNameAndDisplay内调用display的调用的总是Window::display,绝不会WindowWithScrollBars::display。
解决切割问题的方法,就是by reference-to-const 的方式传递w;
void printNameAndDisplay(const Window &w){//很好,参数不会被切割
cout<<w.name();
w.display();
}
现在,传进来的窗口是什么类型,w就表现出那种类型。
如果了解C++编译器的底层,reference往往以指针实现出来,因此,pass by reference通常意味真正传递的是指针。因此如果数据类型属于内置类型(例如int),pass by value往往比pass by reference的效率高些。用户自定义类型往往选择by reference方式,减少copy 构造函数的执行,提高效率。一般而言,"pass-by-value"主要使用在内置类型和STL的迭代器和函数对象。至于其他类型,尽量选择pass-by-reference-to-const替换pass-by-value。
请记住:
1、尽量以pass-by-reference-to-const替换pass-by-value。前者通常比较高效,并可避免切割问题。
2、以上规则并不适用于内置类型,以及STL的迭代器和函数对象。对它们而言,pass-by-value往往比较适合。
条款21:必须返回对象时,别妄想返回其reference
在pass-by-reference的纯度中,程序员往往犯下一个错误:开始传递一些reference指向其实并不存在的对象。
下面是一个用以表现有理数的class,内含一个函数用来计算两个有理数的乘积:
class Rational{
public:
Rational(int numerator=0,int denominator=1);//条款24说明为什么这个构造函数不声明为explicit
private:
int n,d;// numerator 分子; denominator:分母
//条款3说明为什么返回类型是const
friend const Rational operator*(const Rational& lhs,const Rational& rhs);
};
这个版本的operator*以by value方式返回其计算结果(一个对象),该对象的构造函数和析构函数会消耗一定的内存资源。因此,可以考虑operator *返回一个reference,后者一定指向某个既有的Rational对象,内含两个Rational对象的乘积。
在stack空间或heap空间创建之。如果定义一个local变量,就是在stack空间创建对象。根据这个策略写下operator*如下:
const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
Rational result(lhs.n*rhs.n,lhs.d*lhs.d);//警告,糟糕的代码
return result;
}
这个函数有个问题:函数返回一个reference指向result,但result是个local对象,而local对象在函数退出前被销毁了。如果对这个函数的返回值进行运用,会出现“无定义行为”的报错。任何函数如果返回一个reference指向某个local对象,或者函数返回指针指向一个local对象,都是出现这种“无定义行为”。
如果在heap内构造对象,并返回reference指向它。如下:
const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
Rational * result=new Rational(lhs.n*rhs.n,lhs.d*lhs.d);//警告,糟糕的代码
return *result;
}
这里“构造函数调用”的消耗,还有一个问题:谁负责把new出来的对象实施delete?因此存在内存泄漏的风险。
因此在heap和stack空间创建对象的方法都不可以通过。因此,让operator* 返回的reference指向一个被定义于函数内部的static Rational对象。代码如下
const Rational& operator*(const Rational& lhs,const Rational& rhs)
{
static Rational result;//警告,又是一堆烂代码。static对象,此函数将返回其reference。
result=...;//将lhs乘以rhs,并将结果置于result内。
return result;
}
bool operator==(const Rational& lhs,const Rational& rhs);//一个针对Rational而写的operator==
Rational a,b,c,d;
...
if((a*b)==(c*d)){
//乘积相等,执行相应动作
}else {
//乘积不等,执行相应动作
}
表达式(ab)==(cd)总是被核算为true,这是为什么呢?
一旦将代码重新写为等价的函数形式,很容易就可以了解出了什么意外:
if(operator==(operator*(a,b),operator*(c,d)))
注意,在operator == 被调用前,已经有两个operator* 调用式起作用,每一个都返回reference指向operator* 内部定义的static Rational对象。因此operator == 被要求将“operator* 内的static Rational对象值”拿来和“operator* 内的static Rational对象值”比较,如果比较结果不相等,这才奇怪。(两次operator* 调用的确各自改变了static Rational对象值,但由于它们返回的都是reference,因此调用端看到的永远是static Rational对象的“现值”。)
一个“必须返回新对象”的函数的正确写法是:就让那个函数返回一个新对象,对Rational的operator*而言意味着以下写法(或其他本质上等价的代码):
inline const Rational operator*(const Rational& lhs,const Rational& rhs)
{
return Rational(lhs.n*rhs.n,lhs.d*lhs.d);
}
当然,这种情况要承受operator* 返回值的构造成本和析构成本,然而长远来看那只是为了获得正确行为而付出的一个小小的代价。
当你必须在“返回一个reference和返回一个object”之间抉择时,你的工作就是挑出行为正确的那个。
请记住:
1、绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或reference指向一个local static对象而有可能同时需要多个这样的对象。条款4已经为“在单线程环境中合理返回reference指向一个local static对象”提供了一份设计实例。
条款22:将成员变量声明为private
如果成员变量表示public,客户唯一能够访问对象的办法就是通过成员函数。如果public接口内的每样东西都是函数,客户就不需要在打算访问class成员时迷惑地试着记住是否该使用小括号。
假设你正在写一个自动测速程序,当汽车通过,其速度便被计算并填入一个速度收集器内:
class SpeedDataCollection{
...
public:
void addValue(int speed);//添加一笔新数据
double averageSoFar() const;//返回平均速度
...
};
成员函数averageSoFar有两种做法:1、记录至今以来所有速度的平均值,当averageSoFar被调用,只需返回那个成员变量就好;2、令
averageSoFar每次被调用时重新计算平均值,此函数有权力调取收集器内的每一笔速度值。
相对而言,方法1会使每一个SpeedDataCollection对象变大,但是averageSoFar函数比较高效;方法2的averageSoFar执行比较慢,但每一个SpeedDataCollection对象比较小。
到底哪一种比较好?在一部内存吃紧的机器上,或是在一个并不常常需要平均值的应用程序中,方法2或许比较好。但在一个频繁需要平均值的应用程序中,如果反应速度非常重要,内存不是重点,这时候方法1或许比较好。
“成员变量的封装性与“成员变量的内容改变时所破坏的代码数量”成反比。
protected成员变量就像public成员变量一样缺乏封装性,因为在这两种情况下,如果成员变量被改变,都会有不可预知的大量代码受到破坏。一旦你将一个成员变量声明为public或protected而客户开始使用它,就很难改变那个成员变量所涉及的一切。太多代码需要重写、重新测试、重新编写文档、重新编译。从封装的角度观之,其实只有两种访问权限:private(提供封装)和其他(不提供封装)。
请记住:
1、切记将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供class作者以充分的实现弹性。
2、protected并不比public更具封装性。
条款23:宁以non-member、non-friend替换member函数
假设有个class用来表示网页浏览器。这样的class可能提供的众多函数中,有一些用来清除下载元素高速缓存区、清除访问过的URLs的历史记录、以及移除系统中的所有cookies:
class WebBrowser{
public:
//...
void clearCache();
void clearHistory();
void removeCookies();
//...
void clearEverything();//调用 clearCache ,clearHistory和removeCookies
};
当然,clearEverything函数的功能也可以由一个non-member函数调用适当的member函数而提供出来:
void clearBrowser(WebBrowser& wb){
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
那么,哪一个比较好呢?是member函数clearEverything还是non-member函数的clearBrowser呢?
member函数clearEverything带来的封装性比non-member函数的clearBrowser低。此外,提供non-member函数可允许对WebBrowser相关性能有较大的包裹弹性,而那最终导致较低的编译相依度,增加WebBrowser的可延伸性。
愈多东西被封装,我们改变那些东西的能力也就愈大。这就是我们首先推崇封装的原因:它使我们能够改变事物而只影响有限客户。
愈多函数可访问它,数据的封装性就愈低。
导致较大封装性的是non-member non-friend函数,因为它并不增加“能够访问class内之private成分”的函数数量。这就解释了为什么clearBrowser(一个non-member non-friend函数)比clearEverything(一个member函数)更受欢迎的原因:它导致WebBrowser class有较大的封装性。
在C++,比较自然的做法是让clearBrowser成为一个non-member函数并且位于WebBrowser所在的同一个namespace(命名空间)内:
namespace WebBrowserStuff {
class WebBrowser{//...};
void clearBrowser(WebBrowser &wb);
//...
}
要知道,namespace和class不同,前者可以跨越多个源码文件而后者不能。
一个像WebBrowser这样的class可能拥有大量便利函数,某些与书签有关,某些与打印有关,某些与cookies管理有关…通常大多数客户只对其中某些感兴趣。没道理一个只对书签相关便利函数感兴趣的客户却与…呃…例如一个cookies相关便利函数发生编译相依关系。分离它们的最直接做法就是将书签相关便利函数声明于一个头文件,将cookies相关便利函数声明于另一个头文件,再将打印相关便利函数声明于第三个头文件,依次类推:
//头文件"webbrowser.h"——这个头文件针对class WebBrowser自身及WebBrowser核心机能
namespace WebBrowserStuff {
class WebBrowser{...};
...//核心机能,例如几乎所有客户都需要的non-member函数。
}
//头文件"webbrowserbookmarks.h"
namespace WebBrowserStuff {
...//与书签相关的便利函数
}
//头文件"webbrowsercookies.h"
namespace WebBrowserStuff {
...//与cookie相关的便利函数
}
...
将所有便利函数放在多个头文件内但隶属同一个命名空间,意味着客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多non-member non-friend函数到此命名空间内。
请记住:
1、宁可拿non-member non-friend函数替换member函数。这样做可以增加封装性、包裹弹性和机能扩充性。
条款24:若所有参数皆需类型转换,请为此采用non-member 函数
假设有一个Rational class:
class Rational{
public:
Rational(int numerator=0,int denominator=1);//构造函数刻意不为explicit;
//允许int-to-Rational隐式转换。
int numerator() const;//分子 (numerator)和 分母(denominator)
int denominator() const;//的访问函数
const Rational operator* (const Rational& rhs) const;
private:
...
};
这个设计是你能够将两个有理数以最轻松自在的方式相乘:
Rational oneEighth(1,8);
Rational oneHalf(1,2);
Rational result = oneHalf* oneEighth;//很好
result= result*oneEighth;//很好
当你尝试混合式计算,你会发现只有一半行得通:
result = oneHalf*2;//如果是explict构造函数,无法将2转换为一个Rational;很好 允许int-to-Rational隐式转换
result =2*oneHalf;//错误
下面是以函数形式重写上面两个式子,因此问题就一目了然:
result = oneHalf.operator*(2);//很好
result =2.operator*(oneHalf);//错误!
result = oneHalf*2;其原理等同于下面
const Rational temp(2);
result = oneHalf*temp;//等同于 oneHalf.operator*(temp);
是的,oneHalf是一个内含operator * 函数的class 的对象,所以编译器调用该函数。然而整数2并没有相应的class ,也就没有operator *成员函数。编译器也会尝试寻找可被以下这般调用的non-member operator * (也就是在命名空间内或在global作用域内):
result =operator*(2,oneHalf);//错误
但是本例不存在这样一个接受int 和Rational作为参数的non-member operator * ,因此查找失败。
当参数被列于参数列表内,这个参数才是隐式类型转换的合格参与者。
如果一定要支持混合式算术运算,让operator* 成为一个non-member 函数,俾允许编译器在每一个实参身上执行隐式类型转换:
class Rational{
public:
Rational(int numerator=0,int denominator=1);//构造函数刻意不为explicit;
//允许int-to-Rational隐式转换。
int numerator() const;//分子 (numerator)和 分母(denominator)
int denominator() const;//的访问函数
//不包括operator *
private:
...
};
const Rational operator* (const Rational& lhs,const Rational& rhs) //现在成为一个non-member函数
{
return Rational(lhs.numerator()*rhs.numerator(),
lhs.denominator()*rhs.denominator());
}
Rational oneFourth(1,4);
Rational result;
result= oneFourth*2;//没问题
result =2*oneFourth;//通过编译
operator*是否应该是Rational class 的一个friend 函数呢?
这是否定的,member函数的反面是non-member函数,不是friend函数。无论何时如果你可以避免friend函数就该避免,因为就像真实的世界一样,朋友带来的麻烦往往多过其价值。
请记住:
1、如果你需要为某个函数的所有参数(包括被this指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个non-member。
条款25:考虑写出一个不抛异常的swap函数
典型的swap函数如下:
namespace {
template<typename T> //std::swap的典型实现
void swap(T &a,T &b){//置换a和b的值
T temp(a);
a=b;
b=temp;
}
}
只要类型T支持copying,缺省的swap实现代码就会帮你置换类型为T的对象,你不需要为此另外在做任何工作。
“以指针指向一个对象,内含真正数据”的类型。这种设计的常见表现形式就是所谓的“pimpl(pointer to implementation (指向实现的指针)的缩写)手法”。如果以这种方式设计Widget class ,代码如下:
class WidgetImpl{ //针对Widget数据而设计的class
public: //细节不重要
...
private:
int a,b,c;//可能有许多数据
std::vector<double> v;//意味复制时间很长。
...
};
class Widget{//这个class 使用pimpl手法
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)//复制Widget时,令它复制其WidgetImpl对象。
{
//... //关于operator=的一般性实现细节,见条款10,11和12
*pImpl=*(rhs.pImpl);
}
private:
WidgetImpl *pImpl;//指针,所指对象内含Widget数据。
};
一旦要置换两个Widget对象值,我们唯一需要做的就是置换其pImpl指针,但缺省的swap算法不知道这一点。它不只复制三个Widgets,还复制了三个WidgetImpl对象,非常缺乏效率。
我们的思路是,当Widget被置换时真正该做的是置换其内部的pImpl指针。实践方法是:将std::swap针对Widget特化。具体代码如下:
class Widget{//这个class 使用pimpl手法
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs)//复制Widget时,令它复制其WidgetImpl对象。
{
//... //关于operator=的一般性实现细节,见条款10,11和12
*pImpl=*(rhs.pImpl);
}
void swap(Widget& other)
{
using std::swap;//这个声明之所以必要,稍后解释
swap(pImpl,other.pImpl);//若要置换Widget 就置换其pImpl指针。
}
private:
WidgetImpl *pImpl;//指针,所指对象内含Widget数据。
};
namespace std{
template<> void swap<Widget>(Widget &a,Widget &b)
{
a.swap(b);//若要置换Widget,调用其swap成员函数
}
}
这种做法不仅能够通过编译,还与STL容器有一致性,因为所有STL容器也都提供public swap成员函数和std::swap特化版本(用以调用前者)。
我们声明一个non-member swap让它调用member swap,但不再将那个non-member swap声明为std::swap的特化版本或重载版本。假设Widget的所有相关机能都被置于命名空间WidgetStuff内,整个结果看起来便像这样:
namespace WidgetStuff {
template<typename T> //模板化的WidgetImpl等等
class WidgetImpl{...};
template<typename T> //模板化的Widget,内含swap成员函数
class Widget{...};
//...
template<typename T>//non-member swap函数
void swap(Widget<T>& a,Widget<T>& b)//这里不属于std命名空间
{
a.swap(b);
}
}
现在,任何地点的任何代码如果打算置换两个Widget对象,因而调用swap,C++的名称查找法则会找到WidgetStuff内的Widget专属版本。
如果你想让你的“class 专属版”swap在尽可能多的语境下被调用,你需要同时在该class所在命名空间内写一个non-member版本以及一个std::swap特化版本。
如果swap的缺省实现版的效率不足(那几乎总是意味着你的class或template使用了某种pimpl(指向实现的指针)手法),试着做以下事情:
1、提供一个public swap成员函数,让它高效地置换 你的类型的两个对象值。稍后我将解释,这个函数绝不该抛出异常。
2、在你的class或template所在命名空间内提供一个non-member swap,并令它调用上述swap成员函数。
3、如果你在编写一个class(而非class template),为你的class 特化std::swap。并令它调用你的swap成员函数。
成员函数版swap绝不可抛出异常。
请记住:
1、当std::swap对你的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。
2、如果你提供一个member swap,也该提供一个non-member swap用来调用前者。对于class(而非template),也请特化std::swap。
3、调用swap时应针对std::swap使用using 声明式,然后调用swap并且不带任何“命名空间资格修饰”。
4、为“用户定义类型”进行std template全特化是好的,但千万不要尝试在std 内加入某些对std 而言全新的东西。
来源:C++ Effective
仅供学习,侵删