《Effective C++》第四章:接口的设计

本章对应着原书的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);

我们优先考虑实现二,两者的区别是

    1. 传递值需要在函数体中复制一份对象,执行拷贝构造函数与析构函数。传递常引用则不需要,本质上是传递一个指针
    1. 传递常引用可以调用多态机制。

至于为什么要使用常引用而不是引用,是为了避免函数对其内容进行修改。

只有基本数据类型优先选择传值,因为指针可能消耗更大。
对于自定义数据类型,即便这个数据类型非常小,但由于构造和析构函数有可能开销大、编译器的优化可能不好等等原因,最好是传递常引用。

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

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值