本章对应着原书的item18~item25,主要介绍C++接口的设计。
C++中有大量的接口,比如函数接口、类接口、模板接口。如何设计清晰易用、不易出错的接口非常重要。
Item 18. 如何尽可能避免接口被用错
首先以一个Date类举例:
class Date {
public:
Date(int month, int day, int year);
...
};
Date类可能发生如下的错误:
Date d1(30, 3, 1995);//3月30号还是30月3号?
Date d2(3, 40, 1995);//3月40号存在吗?
问题可以概括为:
- 1.调用者不知道每个参数所代表的含义
- 2.调用者知道参数的含义,但是输错了数据
18.1 将含有某种语义的数据封装成类
上面例子中的参数可以封装如下:
这样就可以使得调用者必须显示说明参数的类型,不易发生错误。
Date d(30, 3, 1995); // 参数错误,不能编译
Date d(Day(30), Month(3), Year(1995)); // 参数错误,不能编译
Date d(Month(13), Day(30), Year(1995)); // 正确
但我们也发现,Month的参数是13明显不合理,我们可以进一步增加限制:
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); // 避免直接调用Month的构造函数。
};
这样函数的调用就可以是:
Date d(Month::Mar(), Day(30), Year(1995));
进一步降低了出错的可能性。
不过,可以看到Day类型就很难限制构造函数的错误,并且这样的调用导致参数的形式不统一
18.2 保证接口的一致性
18.2.1 与内置数据类型一致
因为大部分人都熟悉内置数据类型的特性,自己设计的接口最好与内置数据类型保持一致,否则容易引起使用者的困扰。
比如假如数据全是int类型,(a*b)=c显然是错误的,所以我们可以得到一个设计原则:乘法的计算结果类型应当为const
18.2.2 统一的接口
以STL为例,每个容器都有一个size()函数,这样的统一性可以让使用者用得相对舒服一点儿。
18.3 减少接口调用者的负担
如果你的接口必须要调用者“记得”执行某个操作,那么这个操作有相当大的概览被使用者遗忘。
比如说前面提到的静态工厂方法:
Investment* createInvestment(); //创建一个Investment对象,并返回指针,该指针需要使用者释放
使用者有相当大的概览忘记delete该指针。
那么我们可以将指针封装到std::tr1::shared_ptr中,就减少了调用者的负担,避免了指针忘记被释放的问题。
std::tr1::shared_ptr是个非常有用的工具,最常见的实现来自于Boost,需要注意它的开销大于普通指针,具体包括:
- 占用更大的空间
- 调用虚函数开销
- 同步线程开销
当然,很多情况下它是弊大于利的。
Item 19. 定义类时小心谨慎
定义类实际上就是在定义一个类型,类似编程语言设计者定义基本类型,你在定义类时需要特别小心谨慎。
在定义一个类时,可以思考下面这些问题:
- 对象该如何创建和回收?
这个问题主要涉及到构造函数、析构函数、资源申请和回收的问题 - 对象如何初始化以及赋值
注意这是不同的两个问题,一个对应构造函数,另一个对应赋值函数 - 按值传参时对象需要做什么?
按值传参是调用的是拷贝构造函数,仔细想一想它的行为 - 对象的值有什么限制?
也许你需要一些措施避免对象取到非法的值 - 考虑继承的问题
假如你的类继承于其他的类,你需要考虑它的类使用限制,尤其是虚函数。
假如你的类会被其他类继承,你也要好好设计自己的虚函数。 - 考虑类型转换
类型转换可以通过构造函数以及类型转换函数实现,如果不允许隐式转换记得加上explict修饰 - 设计的类有哪些“未声明接口”
(这段我看得云里雾里)你需要对你的类型做出哪些隐性保证?包括动态存储、多线程等等问题,要求你需要给自己的类增加很多限制 - 你的类足够泛化吗
假如你想定义一族的类型,而不是单个类型,考虑使用template吧! - 你真的需要这个新类吗?
假如你做的功能比较简单,也许你应该写几个普通函数就可以了。
Item 20. 传递const& 而不是直接传递值
对比以下两个函数声明:
传递常引用:int test(BaseClass base);
传递值:int test(const BaseClass& base);
我们优先考虑实现二,两者的区别是
-
- 传递值需要在函数体中复制一份对象,执行拷贝构造函数与析构函数。传递常引用则不需要,本质上是传递一个指针
-
- 传递常引用可以调用多态机制。
至于为什么要使用常引用而不是引用,是为了避免函数对其内容进行修改。
只有基本数据类型优先选择传值,因为指针可能消耗更大。
对于自定义数据类型,即便这个数据类型非常小,但由于构造和析构函数有可能开销大、编译器的优化可能不好等等原因,最好是传递常引用。
Item 21. 函数返回值要传值,而不是引用
下面举出几个反例来说明为什么不能传递引用,例子中的BigInt
是一个自定义的256位大整数。
21.1反例一:
const BigInt& getSum(const BigInt& a, const BigInt& b){
BigInt sum;
/*执行一些加和的操作*/
return sum;
}
int main(){
BigInt a("2718"),b("88172");
BigInt result = getSum(a,b);
}
一个比较显然的问题是:返回局部变量的引用。由于getSum
函数返回时内部的所有变量都会销毁,此时得到的引用其实指向一个野指针。
另一个更傻的问题是——我们我们返回引用的初衷是为了减少构造函数,但是这样写压根不能减少构造函数的调用
21.2 反例2
const BigInt& operator + (const BigInt& a, const BigInt& b){
BigInt *sum = new BigInt();
/*执行一些加和的操作*/
return *sum;
}
int main(){
BigInt a("2718"),b("88172"),c("32123")
BigInt result = a+b+c;
}
似乎解决了反例1的问题,但引入了新问题:new的数据谁来delete?
21.3 反例3
const BigInt& operator + (const BigInt& a, const BigInt& b){
static BigInt sum();
/*执行一些加和的操作*/
return sum;
}
int main(){
BigInt a("2718"),b("88172"),c("32123")
BigInt result1 = a+b+c;
BigInt result2 = a+b;
if(result1 == result2){//始终会输出"Equal"
std::cout << "Equal" << std::endl;
}
}
似乎解决了前面的问题,但由于每次取的实际上是同一个对象,所以此时该函数的返回值像是一个大小为1的存储区,该存储区不可避免地会导致覆盖读写的问题。
说了这么多,其实就一个问题:大家都习惯函数返回的是值,也都这么用,传递引用没办法完美代替传递值。
一般情况下,函数返回值都应当时以值的形式传递
Item 22. 将类成员变量设置为private
所有的成员变量都应当设置为private。这样就避免外界直接访问,可以使用类成员函数来对其进行访问。
22.1 使用private代替public的好处
保持统一的接口
统一的接口可以降低使用者对类的理解和学习成本。假设你写了一系列的容器类,这些类的成员变量有时候是直接访问,有时候是函数访问,那么使用者使用的时候有时候会困扰:加不加括号呢?
假如统一使用函数,就可以避免这种疑惑。
不过这不是一个说服力很强的理由(因为完全放弃使用函数也能保持统一的接口),接下来会说更本质的理由。
控制访问权限
假如把变量设置为public,那意味它是可读可写的。
而使用函数,就可以精确地控制读写权限。
class AccessLevels {
public:
...
int getReadOnly() const { return readOnly; }
void setReadWrite(int value) { readWrite = value; }
int getReadWrite() const { return readWrite; }
void setWriteOnly(int value) { writeOnly = value; }
private:
int noAccess; // no access to this int
int readOnly; // read-only access to this int
int readWrite; // read-write access to this int
int writeOnly; // write-only access to this int
};
书中没有提控制访问权限的意义,我写点儿自己的看法:1. 很多变量不可以开放写权限。比如说std::vector的size变量,更改后势必导致对象的内容分配出现问题。 2. 很多变量没有必要开放读/写权限。这些变量一般仅仅涉及到底层,对调用者来说毫无意义。考虑一种极端情况:IDE代码补全显示出100多个候选字段…里面大部分其实都用不到。
增加设计弹性
首先是降低耦合。所有public/protected的成员都有可能被其他人调用,都会增加类之间的耦合。假如这些成员发生了修改,意味着所有使用它的代码都要进行修改。
并且相对于变量,使用函数也能降低耦合,比如说对数据库中的数据建模,其中的“派生属性”可以有两种策略:直接存储值,或者间接从其他属性中计算得出。假如你一开始的实现是直接存储值,但后来又更改为间接计算得出,这样就需要删除一个变量——这个变量如果可以被外界直接访问,删除它会带来巨大的代码修改。但是假如通过函数间接访问,就几乎不需要任何修改,只需要修改函数实现重新编译就可以了。
可以实现一些复杂的控制机制
比如说多线程访问同步问题、非法值赋值时的处理问题等等,都必须要使用函数来解决。
22.2 protected的问题
简要地说,前面讨论的public的缺陷在protected上全都存在,因为protected变量在派生类中可以被自由访问修改。
所以可以得出最终结论:所有的成员变量都应该设置为private
Item 23. 抉择:类成员函数还是普通函数?
假设有一个表示浏览器的类,类中包括处理Cache、历史记录、Cookie的函数。
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};
现在又需要做一个功能:删除所有浏览痕迹。这个功能可以由类成员函数实现或者普通函数实现。
void clearBrowser(WebBrowser& wb)
{
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}
//或者写在类中,很简单这里不写了
现在需要做出抉择:写成类成员函数还是普通函数呢?
根据常理,应当把数据和操作封装在一个类中,所以应该写在类里。 但是事实不是这样,实际上应该写在普通函数中。
从封装性角度考虑
首先引入一个衡量数据封装性的方法:有多少代码可以访问到该数据。封装性好意味着该数据耦合越小。public/protected的数据完全没有封装性,而private的数据封装性取决于访问它的类成员函数以及友元函数的个数。
所以可以看出:普通函数更有利于增加系统的封装性。
不过需要另外注意两点:一是友元函数和类成员函数没有什么区别,二是相对于当前类而言,其他类的成员函数也没有访问private变量的权限,所以也可以当作是普通函数。某些不允许声明普通函数的语言(比如Java)也可以声明为其他类的成员函数。
推荐写法
namespace WebBrowserStuff {
class WebBrowser { ... };
void clearBrowser(WebBrowser& wb);
...
}
另一个原因:降低编译依存度
写在类外的函数有一个好处:可以划分为多个文件来编译。对于不同的用户,有可能只需要类的一部分功能,这时就只需要在不同头文件中分别声明执行某类功能的函数。
// 头文件 “webbrowser.h” 定义WebBrowser类
// 以及一些核心的功能函数
namespace WebBrowserStuff {
class WebBrowser { ... };
... // “core” related functionality, e.g.
// non-member functions almost
// all clients need
}
/ “webbrowserbookmarks.h”
namespace WebBrowserStuff {
... // 与书签相关的函数
}
// header “webbrowsercookies.h”
namespace WebBrowserStuff {
... // cookie相关的函数
}
...
类不能在多个文件中定义,但普通函数可以。并且可以把这些普通函数放在同一个namespace中。
其实stl就是这样做的。vector、list各自包含了不同的函数,需要使用哪个就使用哪个,他们其实共同用到了某些类。
并且,客户端可以自由地扩展namespace,以实现自己的功能,而类的扩展有种种限制。
Item 24. 如果需要所有参数都支持隐式转换,请把他写成普通函数
假设你定义了一个有理数类Rational
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const; // accessors for numerator and
int denominator() const; // denominator — see Item 22
private:
...
};
现在需要给它写一个乘法运算符,需要怎么写呢?
大概可能要这样写:
class Rational {
public:
...
const Rational operator*(const Rational& rhs) const;
};
看起来很正确。
由于有理数可以和整数相乘,所以我们希望这种乘法支持int类型。因为构造函数没有声明explict,所以int可以隐式转为Rational类型,比如说:
Rational a(1,2);
a*2;//允许,因为2可以隐式转为Rational
2*a;//不允许
第二个为什么不允许?如果写成函数的形式,a*2
等价于a.operator*(2)
, 2可以被隐式转换为Rational,但是2*a
等价于2.operator*(a)
——2这个数字没有任何成员函数。
但是或许也可以这么理解:类成员函数里其实有一个隐含的Rational
类型的参数,它的值是*this
, 一旦这样想好像2*a
好像也合理起来:2可以对应*this
这个对象呀!
没那么美。*this
这个隐含对象不会参与隐式类型转换。
所以就回到标题这个问题:你应该把他声明为普通函数。
注意这里的普通函数最好不是友元函数。比如这个例子,乘法运算符不需要直接访问内部成员变量(而是通过函数间接访问)。
一个参考实现如下:
class Rational {
... // 不包含 operator*
};
const Rational operator*(const Rational& lhs, // now a non-member
const Rational& rhs) // function
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; // fine
result = 2 * oneFourth; // it works
Item 25. 写一个不会抛出异常的swap函数
书中该部分虽然表面上是为了实现swap, 实际上讲的是模板接口的设计问题。
swap的默认实现是这样的:
namespace std {
template<typename T> // typical implementation of std::swap;
void swap(T& a, T& b) // swaps a’s and b’s values
{
T temp(a);
a = b;
b = temp;
}
}
这个实现有很多问题。
它是以赋值和拷贝构造函数为基础的,调用了两次赋值和一次拷贝构造函数。但是赋值和拷贝有时候有很多问题。
比如说:效率低。 尤其是对于某些靠指针和堆内存来组织的对象,赋值和拷贝会复制堆中的内容,但实际上只需要交换一下指针就可以了。
再比如说,赋值和拷贝有可能抛出异常,而swap不应该抛出异常(这一点之后会讲)
所以我们需要写一个用户自定义的swap。
对于非模板类,解决方案:
假设Widget是自定义的对象,其中pImpl是Widget的成员变量,是一个堆内存指针。
class Widget { // same as above, except for the
public: // addition of the swap mem func
...
void swap(Widget& other)
{
using std::swap; //为了保持让std::swap在该函数内曝光
swap(pImpl, other.pImpl); //
} // pImpl pointers
...
};
namespace std {
template<> // revised specialization of
void swap<Widget>(Widget& a, // std::swap
Widget& b)
{
a.swap(b); // to swap Widgets, call their
} // swap member function
}
这是一个比较完备的版本。类的内部实现一个swap(因为需要操作内部指针,所以要使用类成员函数),外部全特化std::swap。
实际上,STL中的swap采用的就是这种思路。
对于模板类,解决方案
如果Widget是一个模板类,前面的方法肯定需要做出更改。
方案1
namespace std {
template<typename T>
void swap<Widget<T> >(Widget<T>& a, Widget<T>& b)
{ a.swap(b); }
}
该方案无法通过编译,原因是它偏特化了模板函数,这在C++标准中是不允许的。
方案2
使用函数重载
namespace std {
template<typename T> // an overloading of std::swap
void swap(Widget<T>& a, // (note the lack of “<...>” after
Widget<T>& b) // “swap”), but see below for
{ a.swap(b); } // why this isn’t valid code
}
注意它与方案1不同,swap之后没有<…>,它是一个函数重载。
很遗憾,它也不能使用。一般情况函数重载没有问题,但是std名称域内比较特殊,只允许全特化,不允许重载。
方案3
namespace WidgetStuff {
... // templatized WidgetImpl, etc.
template<typename T> // as before, including the swap
class Widget { ... }; // member function
...
template<typename T> // non-member swap function;
void swap(Widget<T>& a, // not part of the std namespace
Widget<T>& b)
{
a.swap(b);
}
}
这种方案是比较完善的。 由于swap和Widget在一个命名空间WidgetStuff,当使用Widget时会优先调用WidgetStuff中的swap。
但比较怕的时有些不按常理出牌的程序员,非要这样使用:std::swap(obj1, obj2);
,这种情况蛮常见的,因为很多人会在程序之前写上:using namespace std
。这种情况其实没办法避免,所以在非模板类中要全特化std::swap,这样就可以避免调用默认的std::swap