前言:软件设计,是令软件做出你希望它做的事情的步骤和做法。通常以颇为一般性的构想开始,最终演变成十足的细节,以允许特殊接口的开发。这些接口而后必须转换为C++声明式。而如何实现良好C++接口的设计和声明呢?通常的一个准则是:“让接口容易被正确使用,不容易被误用”。
理想上,如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不该通过编译;如果代码通过了编译,它的作为就该是客户所想要的。
一个例子:假设你为一个用来表现日期的class设计构造函数。
class Data {
public:
Data(int month, int day, int year);
// ...
};
咋见之下,这个接口通情达理(至少在美国如此),但它的客户很容易犯下至少两个错误。
1. 他们也许会以错误的次序传递参数。
2. 他们可能传递一个无效的月份或天数。
好的做法:
许多客户端错误可以因为导入新类型而获得预防
。在防范”不值得拥有的代码”上,类型系统(type system)
是你的主要同盟国。既然这样,我们可以导入简单的外覆类型(wrapper types)
来区别天数,月份和年份,然后于Data构造函数
中使用这些类型。
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 d(30, 3, 1995); // 错误,类型不匹配
Date d(Day(30), Month(3), Year(1995)); // 错误,类型不匹配
Date d(Month(3), Day(30), Year(1995)); // OK
可见,明智而审慎地导入新类型
对预防”接口被误用”有神奇疗效。但是,当保证了正确的类型后,如何限制其合理的值呢。例如,一年只有12个月,所以Month应该反映这一事实。一个办法是利用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));
其他预防方法还包括:
const
。types
的行为与内置types
一致。size
的成员函数,它会告诉调用者目前容器内有多少个对象。有些开发人员会以为IDE能使这些不一致变得不重要,但他们错了。不一致性对开发人员造成的心理和精神上的摩擦与争执,没有任何一个IDE可以完全抹除。注意:任何接口,如果要求客户必须记得做某些事情,就是有着“不正确使用”的倾向。因为客户可能会忘记做那件事。
例如:
// 返回一个指针指向一个动态分配对象,为避免资源泄漏,返回的指针最终必须被删除,但客户有可能忘记
Investment* createInvestment();
// 将返回值存储于一个智能指针,因而将delete责任推给智能指针
std::tr1::shared_ptr createInvestment();
返回tr1::shared_ptr
让接口设计者得以阻止一大群客户犯下资源泄漏的错误。因为,tr1::shared_ptr
允许当智能指针被建立起来时指定一个资源释放函数(所谓删除器,deleter)绑定于智能指针身上(但是,auto_ptr
没有这个能力)。
比如,下面的方法:
std::tr1::shared_ptr createInvestment()
{
std::tr1::shared_ptr retVal(static_cast(0), getRidofInvestment);
retVal = ...; // 令retVal指向正确对象
return retVal;
}
tr1::shared_ptr
构造函数坚持其第一个参数必须是个指针
,而0不是指针,是个int。是的,它可以被转换为指针,使用转型(cast)
可以解决这个问题。
任何事情都有两面性,
tr1::shared_ptr
使用上有什么副作用吗?
最常见的tr1::shared_ptr
实现品来自Boost
,Boost
的shared_ptr
是原始指针(raw pointer)的两倍大,以动态内存作为辅助。在许多应用程序中,这些额外的执行成本并不显著,然而其“降低客户错误”的成效却是每个人都可以看到的。
请记住:
1. 好的接口,很容易被正确使用,不容易被误用。
2. “促进正确使用”的办法包括,接口的一致性,内置类型的行为兼容。
3. “阻止误用”的办法包括,建立新类型,限制类型上的操作,束缚对象值,消除客户的资源管理责任。
4.tr1::shared_ptr
支持定制删除器(custom deleter)。这可防范cross-DLL problem
,可被用来自动解除互斥锁等等。
C++就像在其他OOP语言一样,当你定义一个新class
,也就定义了一个新type
。身为C++程序员,你的许多时间主要用来扩张你的类型系统。重载(overloading)函数和操作符、控制内存的分配和归还、定义对象的初始化和终结,等等,全部在你手上。
因此,你应该带着和”语言设计者当初设计语言内置类型时”一样的谨慎来研讨class的设计。
设计优秀的classes
是一项艰巨的工作,因为设计好的types
是一项艰巨的工作。好的types
有自然的语法,直观的语义,以及一或多个高效实现品。
那么,如何设计高效地classes
呢?几乎每一个class
都要求你面对以下提问,你的回答往往导致你的设计规范:
- 新type的对象应该如何被创建和销毁?
这会影响到你的class的构造函数和析构函数,以及内存分配函数,和释放函数的设计。- 对象的初始化和对象的赋值该有什么样的差别?
这个答案决定你的构造函数和赋值操作符的行为,以及期间的差异。很重要的是别混淆了“初始化”和”赋值”,因为它们对应于不同的函数调用。- 新type的对象如果被passed by value(以值传递),意味着什么?
记住,copy构造函数用来定义一个type的pass-by-value
该如何实现。- 什么是新type的“合法值”?
对class的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的class必须维护的约束条件(invariants
),也就是决定了你的成员函数必须进行的错误检查工作。- 你的新type需要配合某个继承图系(inheritance graph)吗?
如果你继承自某系既有的classes,你就受到那些classes的设计的束缚,特别是受到“它们的函数是virtual
或non-virtual
”的影响。如果你允许其他classes继承你的class,那会影响你所声明的函数,尤其是析构函数,是否为virtual
。- 你的新type需要什么样的转换?
你的type生存于其他一海票types之间,因而彼此该有转换行为吗?如果你希望允许类型T1之物被隐式转换为类型T2之物,就必须在class T1内写一个类型转换函数,或在class T2内写一个non-explicit-one-argument
(可被单一实参调用)的构造函数。- 什么样的操作符和函数对此新type而言是合理的?
这个问题的答案决定你将为你的class声明哪些函数。其中,某些该是memeber函数,某些则否。- 什么样的标准函数应该驳回?
那些正是你必须声明为private
者。- 谁该取用新type的成员?
这个问题可以帮助你决定哪个成员为public
,哪个为protected
,哪个为private
。它也帮助你决定哪一个classes,functions应该是friends
,以及将它们嵌套于另一个之内是否合理。- 什么是新type的”未声明接口”(undeclared interface)?
它对效率,异常安全性,以及资源运用提供何种保证?你在这些方面提供的保证将为你的class实现代码加上相应的约束条件。- 你的type有多么一般化?
或许你其实并非定义一个新type,而是定义一整个types家族。果真如此,你应该定义一个新的class template
。- 你真的需要一个新type吗?
如果只是定义新的derived class
以便为既有的class添加机能,那么说不定单纯定义一或多个non-member函数或templates,更能够达到目标。请记住:
上述这些问题都不容易回答,所以定义出高效地classes是一种挑战。然而如果能够设计出至少像C++内置类型一样好的用户自定义classes,一切汗水便都值得。
缺省情况下,C++以by value
方式(一个继承自C的方式)传递对象至函数。除非你另外指定,否则函数参数都是以实际实参的副本为初值,而调用端所获得的亦是函数返回值的一个副本。这些副本
系由对象的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); // 函数以by value方式接受学生
Student plato;
bool platoIsOK = validateStudent(plato);
当上述函数被调用时,发生什么事?
无疑地Student的copy构造函数
会被调用,以plato为蓝本将s初始化。同样,当validateStudent返回,s会被销毁。因此,对此函数而言,参数的传递成本是:一个Student copy构造函数调用,加上一次Student析构函数调用。
但那还不是故事的全部:
Student对象内有两个string对象,所以每次构造一个Student对象也就构造了两个string对象。此外,Student对象继承自Person对象,所以每次构造Student对象,也必须构造出一个Person对象。一个Person对象又有两个string对象在其中。
最终结果是:以
by value
方式传递一个Student对象,会导致调用一次Student的copy构造函数
、一次Person的copy构造函数
、四次string的copy构造函数
,同时,当函数内的那个Student*复件被销毁*,每一个构造函数调用动作都需要一个对应的析构函数
调用动作。因此,总体成本是,六次构造函数和六次析构函数。
优化方法:
虽然上面的行为是正确的,但是不是推荐的。如何回避所有那些构造和析构呢?
// pass by reference-to-const
bool validateStudent(const Student& s);
这种传递方式的效率高很多:没有任何构造函数和析构函数被调用,因为,没有任何新对象被创建。原先的的by value
方式,调用者知道参数受到保护,函数内绝不会对传入的参数做任何改变,而只能对参数的复件(副本)做修改。而现在的by reference
方式,将它声明为const
是必要的,因此不这样的话调用者会忧虑函数内部会不会改变他们传入的参数。
同时,以by reference
方式传递参数,也可以避免slicing(对象切割)问题
。
当一个derived class对象以by value方式传递,并被视为一个base class对象。base class的copy构造函数会被调用,而”造成此对对象的行为像个derived class对象”的那些特化性质全被切割掉了,仅仅留下了一个base class对象。
void printNameAndDisplay(const Window& w) // 很好,参数不会被切割
{
std::cout << w.name();
w.display(); // 现在,传进来的窗口是什么类型,w就表现出那种类型
}
如果窥视C++编译器的底层,references
往往以指针实现出来,因此,pass by reference
通常意味真正传递的是指针
。如果对象为内置类型
(例如,int),pass by value
往往比pass by reference
的效率高些。
例外:上面这个忠告,也适用于STL的迭代器和函数对象,因为,习惯上它们都被设计为passed by value
。它们的设计者有责任看看它们是否高效且不受切割问题的影响。
请记住:
一般而言,你可以合理假设pass-by-value
并不昂贵的唯一对象,就是内置类型,以及STL的迭代器和函数对象。至于其他任何东西都请尽量以pass-by-reference-to-const
替换pass-by-value
。
虽然pass-by-value
存在传值效率的问题,但是在某些情况下必须使用pass-by-value
。
一个“必须返回新对象”的函数的正确写法是:就是让那个函数返回一个新对象
inline const Rational operator * (const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}
你需要承受operator *
返回值的构造成本和析构成本,然而长远来看那只是为了获得正确行为而付出的一个小小代价。
但是,别忘了C++允许编译器实现者施行最优化,用以改善产出码的效率却不改变其可观察的行为。因此,某些情况下operator *
返回值的构造和析构可被安全地消除。
总结:
当你必须在”返回一个reference和返回一个object之间抉择时”,你的工作就是选出行为正确的那个。让编译器厂商为”尽可能降低成本”鞠躬尽瘁,你可以享受你的生活。
请记住
绝不要返回pointer或reference指向一个local stack对象,或返回reference指向一个heap-allocated对象,或返回pointer或
切记将成员变量声明为private
。这可赋予客户访问数据的一致性、可细微划分访问控制、许诺约束条件获得保证,并提供class作者以充分的实现弹性。
protected
并不比public
更具封装性。
有时,选择member
函数,还是non-member
函数好呢?
能够访问private
成员变量的函数只有class的member
函数,以及friend
函数。如果你要在一个member
函数和一个non-member
,non-friend
函数之间做抉择,而且两者提供相同机能,那么,导致较大封装的是non-member
和non-friend
函数,因为它并不增加“能够访问class内之private成分”的函数数量。
在C++,比较自然的做法是让clearBrowser
成为一个non-member
函数,并且位于WebBrowser
所在的同一个namespace
内。
namespace WebBrowserStuff {
class WebBrowser { // ... };
void clearBrowser(WebBrowser& wb);
// ...
}
namespace
和classes
不同,前者可以跨多个源码文件,而后者不能。
将所有便利函数,放在多个头文件内,但隶属同一个命名空间。意味着客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多non-member
和non-friend
函数到此命名空间内。
混合式算数运算:
方法:让operator*
成为一个non-member
函数,这样允许编译器在每一个实参上执行隐式类型转换。
class Rational {
// ...
};
const Rational operator* (const Rational& lhs, const Rational& rhs)
{
return Rational(lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator());
}
Rational oneFourth(1, 4);
Rational result;
result = oneFourth * 2; // ok
result = 2 * oneFourth; // 也可以支持
缺省情况下,标准程序库提供的swap算法如下:
namespace std {
template<typename T>
void swap(T& a, T& b)
{
T temp(a);
a = b;
b = temp;
}
}
只要类型T
支持copying
(copy构造函数和copy assignment操作符),缺省的swap实现代码就会帮你置换类型T
的对象,你不需要为此另外再做任何工作。
问题
但是,缺省的swap实现版本会涉及三个对象的复制,对某些类型而言(pimpl),这些复制操作无一必要。
pimpl手法
:就是pointer to implementation
,即,以指针指向一个对象,内含真正的数据。
class WidgetImpl {
public:
// ...
private:
int a, b, c;
std::vector<double> v;
// 更多数据,意味着复制时间很长
};
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) {
*pImpl = *(rhs.pImpl);
// ...
}
private:
WidgetImpl* pImpl; // 指针,所指对象内含Widget数据
};
问题
置换两个Widget对象值,我们唯一需要做的就是置换其pImpl
指针,但是,缺省的swap算法不知道这一点,它不只复制三个Widgets,还复制三个WidgetImpl对象,效率非常低。
我们希望,能够告诉std::swap
,当Widgets
被置换时真正该做的是置换其内部的pImpl
指针。
一种做法是:将std::swap
针对Widget
特化。
class Widget {
public:
void swap(Widget& other) {
using std::swap; // 必要
swap(pImpl, other.pImpl);
}
};
// 特化
namespace std {
template<>
void swap(Widget& a, Widget& b) {
a.swap(b);
}
}