• 所谓软件设计,是 “令软件做出你希望它做的事情” 的步骤和做法,通常以颇为一般性的构想开始,最终演变成十足的细节,以允许特殊接口的开发。 这些接口而后必须转换为 C++ 声明式。
  • “让接口容易被正确使用,不容易被误用”。这个准则设立了一个舞台,让其他更专精的准则对付一大范围的题目,包括正确性、高效性、封装性、维护性、延展性,以及协议的一致性。

条款18: 让接口容易被正确使用,不易被误用

理想上,如果客户企图使用某个接口而却没有获得他所预期的行为,这个代码不该通过编译;如果代码通过了编译,它的作为就该是客户所想要的。

首先必须考虑客户可能做出什么样的错误

1
2
3
4
5
6
7
class Date { 
public:
Date (int month, int day, int year);
};
// 可能的错误
Date d(30, 3, 1995);
Date d(2, 30, 1995);
  • 导入新类型。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    struct Day { 
    explicit Day(int d) : val(d) { }
    int val;
    };
    struct Month { ... };
    struct Year { ... };
    class Date {
    public:
    Date(const Month& m, const Day& d, const Year& y); };
    Date d(30, 3, 1995); //错误!不正确的类型
    Da七e d(Day(30), Month(3), Year(l995));//错误!不正确的类型
    Date d(Month(3), Day(30), Year(l995)); //OK, 类型正确
  • 限制其值

    办法之一是利用 enum 表现月份,但 enums 不具备我们希望拥有的类型安全性。 比较安全的解法是预先定义所有有效的 Months

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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));

    如果 “以函数替换对象,表现某个特定月份“ 让你觉得诡异,或许是因为你忘记了 non-local static 对象的初始化次序有可能出问题 (条款4)。

预防客户错误的另一个办法是,限制类型内什么事可做,什么事不能做。常见的限制是加上 const。

另一个一般性准则是:”除非有好理由,否则应该尽量令你的 types 的行为与内置 types 一致“ —— 很少有其他性质比得上 “一致性” 更能导致 “接口容易被正确使用”,也很少有其他性质比得上 “不一致性” 更加剧接口的恶化。

任何接口如果要求客户必须记得做某些事情,就是有着 “不正确使用” 的倾向, 因为客户可能会忘记做那件事。

例如条款 13 导入了一个 factory 函数,它返回一个指针指向 Investment 继承体系内的一个动态分配对象:

1
Investrnent* createInvestrnent(); //来自条款 13; 为求简化暂略参数。

为避免资源泄漏, createInvestment 返回的指针最终必须被删除,客户可能犯下两个错误机会:没有删除指针,或删除同一个指针超过一次。条款 13 表明客户如何将 createInvestment 的返回值存储于一个智能指针如 auto_ptrtr1::shared _ptr 内,因而将 delete 责任推给智能指针。但万一客户忘记使用智能指针怎么办?

许多时候,较佳接口的设计原则是先发制人,令 factory 函数返回一个智能指针:

1
std::tr1::shared_ptr<Investment> createInvestment();

tr1::shared_ptr 有一个特别好的性质是:它会自动使用它的“每个指针专属的删除器”,因而消除另一个潜在的客户错误: “cross-DLL problem“——对象在动态连接程序库 (DLL) 中被 new 创建,却在另一个 DLL 内被 delete 销毁。

  • 好的接口很容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。
  • “促进正确使用” 的办法包括接口的一致性,以及与内置类型的行为兼容。
  • “阻止误用” 的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。
  • tr1::shared_ptr 支持定制型删除器。这可防范 DLL 问题,可被用来自动解除互斥锁等等。

条款 19: 设计 class 犹如设计 type

几乎每一个 class 都要求你面对以下提问,而你的回答往往导致你的设计规范:

  • 新 type 的对象应该如何被创建和销毁?这会影响到你的 class 的构造函数和析构函数以及内存分配函数和释放函数。
  • 对象的初始化和对象的赋值该有什么样的差别?这个答案决定你的构造函数和赋值操作符的行为,以及其间的差异。
  • 新 type 的对象如果被 passed by value,意味着什么?copy构造函数用来定义一个 type 的 pass-by-value 该如何实现。
  • 什么是新 type 的合法值 ?对 class 的成员变量而言,通常只有某些数值集是有效的。那些数值集决定了你的 class 必须维护的约束条件,也就决定 了你的成员函数(特别是构造函数、赋值操作符和 “setter” 函数)必须进行的错误检查工作。它也影响函数抛出的异常。
  • 你的新 type 需要配合某个继承图系吗?如果你继承自某些既
    有的 classes,你就受到那些 classes 的设计的束缚,特别是受到 “它们的函数是 virtual 或 non-virtual” 的影响 。如果你允许其他 classes 继承你的 class,那会影响你所声明的函数是否为 virtual。
  • 你的新 type 需要什么样的转换?如果你希望允许类型 T1 的对象被隐式转换为类型 T2,就必须在 class T1 内写一个类型转换函数 (operator T2) 或在 class T2 内写一个 non-explicit-one-argument的构造函数。如果你只允许 explicit构造函数存在,就得写出专门负责执行转换的函数,且不得为类型转换操作符或 non-explicit-one-argument 构造函数。
  • 什么样的操作符和函数对此新 type 而言是合理的?这个问题的答案决定你将为你的 class 声明哪些函数。
  • 什么样的标准函数应该驳回?那些正是你必须声明为 private 者。
  • 谁该取用新 type 的成员?这个提问可以帮助你决定哪个成员为 public, 哪个为 protected, 哪个为 private。它也帮助你决定哪一个 classes 或 functions 应该是 friends,以及将它们嵌套于另一个之内是否合理。
  • 什么是新 type 的“未声明接口” ?它对效率、异常安全性以及资源运用提供何种保证?你在这些方面提供的保证将为你的 class 实现代码加上相应的约束条件。
  • 你的新 type 有多么一般化?或许你其实并非定义一个新type,而是定义一整个types家族。果真如此你就不该定义一个新 class, 而是应该定义一个新的 class template 。
  • 你真的需要一个新 type 吗?如果只是定义新的 derived class 以便为既有的 class 添加机能,那么说不定单纯定义一或多个 non-member 函数或 templates, 更能够达到目标。

Class 的设计就是 type 的设计。在定义一个新 type 之前,请确定已经考虑过本条款覆盖的所有讨论主题。

条款 20: 以 pass-by-reference-to-const 替换 pass-by-value

缺省情况下 C++以 by value 方传递对象至 (或来自) 函数。函数参数都是以实际实参的复件为初值,调用端所获得的亦是函数返回值的一个复件。这些复件由对象的 copy 构造函数产出。

Pass by reference-to-canst 可以回避所有这些构造和析构动作。这个参数声明中的 const 是重要的,因为不这样做的话调用者会担心函数会不会改变他们传入的那个对象。

以 by reference 方式传递参数也可以避免 slicing (对象切割) 问题。当一个派生类对象以 by value 方式传递并被视为一个基类对象,基类的 copy 构造函数会被调用,使这个对象派生的特化性质全被切割掉了,仅仅留下一个基类对象。

References 往往以指针实现出来,因此 pass by reference 通常意味真正传递的是指针。因此如果你有个对象属于内置类型 (例如 int), pass-by-value 往往比 pass by reference 的效率高。对内置类型而言,一般应该选择 pass-by-value。这也适用于 STL 的迭代器和函数对象,习惯上它们都被设计为 pass-by-value。

对象小并不就意味其 copy 构造函数不昂贵。某些编译器对待 “内置类型” 和 “用户自定义类型” 的态度截然不同。一个用户自定义类型的大小容易有所变化。

一般而言,你可以合理假设 “pass-by-value 并不昂贵” 的唯一对象就是内置类型、 STL 的迭代器和函数对象。

  • 尽量以 pass-by-reference-to-const 替换 pass-by-value。前者通常比较高效,并可避免切割问题。
  • 以上规则并不适用千内萱类型,以及 STL 的迭代器和函数对象。对它们而言, pass-by-value 往往比较适当。

条款 21: 必须返回对象时,别妄想返回其 reference

函数创建新对象的途径有二:在 stack 空间或在 heap 空间创建。

如果定义一个本地变量,就是在 stack 空间创建对象。如果函数返回一个 reference 指向一个本地对象,这个对象会在函数退出前被销毁。因此,函数返回的 reference 指向一 个空的地址。任何调用者对此函数的返回值做任何一点点运用,都将出现无定义行为。如果函数返回指针指向一个本地对象,也是一样。

如果函数在 heap 内构造一个对象,并返回 reference 指向它。现在又有了另一个问题:谁该对着被你 new 出来的对象实施 delete?

1
2
3
4
5
6
const Rational& operator* (const Rational& lhs, const Rational& rhs)
Rational* result= new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
return *result;
}
Rational w, x, y, z;
W = X * y * z; //没有合理的办法让 operator* 使用者进行 delete

如果让 operator* 返回的 reference 指向一个被定义千函数内部的 static Rational 对象,当函数被多次调用时,结果会出错。

1
((a*b) == (c*d)) //总是为 true

一个必须返回新对象的函数的正确写法是:就让那个函数返回一个新对象。当你必须在返回一个 reference 和返回一个 object 之间抉择时,你的工作就是挑出行为正确的那个。

绝不要返回 pointer 或 reference 指向一个 local stack 对象,或返回 reference 指向一 个 heap-allocated 对象,或返回 pointer 或 reference 指向一个 local static 对象而有可能同时需要多个这样的对象。

条款 22: 将成员变置声明为 private

首先成员变量不该是 public:

语法一致性 (条款 18) : 如果成员变晕不是 public, 客户唯一能够访问对象的办法就是通过成员函数, 客户就不需要在打算访问 class 成员时迷惑地试着记住是否该使用小括号,因为每样东西都是函数。

使用函数可以对成员变量的处理有更精确的控制。如果你令成员变量为 public, 每个人都可以读写它,但如果你以函数取得或设定其值,你就可以实现出 “不准访问”、 “只读访问” 以及 “读写访问”。

封装。如果你通过函数访问成员变量,日后可以修改替换这个成员变量,而 class 客户一点也不会知道 class 的内部实现已经起了变化。

将成员变量隐藏在函数接口的背后,可以为所有可能的实现提供弹性。例如这可使得成员变量被读或被写时轻松通知其他对象、可以验证 class 的约束条件以及函数的前提和事后状态、可以在多线程环境中执行同步控制……

Public 意味不封装,几乎可以说,不封装意味不可改变,特别是对被 广泛使用的 classes 而言。

protected 成员变量的封装性并不比 public 成员变量高。条款 23 说,某些东西的封装性与当其内容改变时可能造成的代码破坏量成反比。因此,成员变量的封装性与成员变量的内容改变时所破坏的代码数量成反比。从封装的角度看,其实只有两种访问权限: private (提供封装) 和其他 (不提供封装)。

  • 切记将成员变量声明为 private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并给 class 作者提供充分的实现弹性。
  • protected 并不比 public 更具封装性。

条款 23: 以 non-member、 non-friend 替换 member 函数

在许多方面 non-member 做法比 member 做法好。

从封装开始讨论。如果某些东西被封装,它就不再可见。越多东西被封装, 越少人可以看到它,我们就有越大的弹性去变化它,因为我们的改变仅仅直接影响看到改变的那些人事物。因此,越多东西被封装,我们改变那些东西的能力也就越大。封装使我们能够改变事物而只影响有限客户。

现在考虑对象内的数据。越少代码可以看到数据,越多的数据可被封装,我们也就越能自由地改变对象数据。我们计算能够访问该数据的函数数量,量测有多少代码可以看到某一块数据。越多函数可访问它,数据的封装性就愈低。

成员变量应该是 private。能够访问 private 成员变量的函数只有类的 member 函数和 friend 函数。如果在一个 member 函数和一个 non-member, non-friend 函数 (无法访问 private 变量) 之间做抉择,non-member non-friend 函数有更大封装性的是 ,因为它并不增加能够访问类内的 private 成分的函数数量。

只因在意封装性而让函数成为类的 non-member 并不意味它不可以是另一个类的成员函数 。这对那些习惯所有函数都必须定义在类内的程序员而言,可能是个温暖的慰藉。 我们可以令 non-member 函数成为某工具类的一个静态函数。

在 C++ 中,比较自然的做法是让 non-member 函数位于相关类所在的同一个命名空间内。

一般而言这类函数目的是为用户提供方便。一般组织方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//头文件 ”webbrowser.h” —这个头文件针对 class WebBrowser 自身及 WebBrowser 核心机能。
narnespace WebBrowserStuff {
class WebBrowser {... };
//核心机能,例如几乎所有客户都需要的 non-member 函数。
}
//头文件 ”webbrowserbookmarks.h"
namespace WebBrowserStuff {
... //与书签相关的便利函数
}
//头文件 ”webbrowsercookies.h"
namespace WebBrowserStuff {
... //与 cookie 相关的便利函数
}

将所有便利函数放在多个头文件内但隶属同一个命名空间,客户可以轻松扩展这一组便利函数。他们需要做的就是添加更多 non-member non-friend 函数到此命名空间内。这是 class 无法提供的另一个性质,因为 class 定义式对客户而言是不能扩展的。

  • 以 non-member non-friend 函数替换 member 函数。这样做可以增加封装性、包裹弹性 (packaging flexibility) 和机能扩充性。

条款 24: 若所有参数皆需类型转换,请为此采用 non-member 函数

令 classes 支持隐式类型转换通常是个糟糕的主意。当然这条规则有其例外,最常见的例外是在建立数值类型时。假设你设计一个 class 用来表现有理数,允许整数“隐式转换”为有理数似乎颇为合理。

1
2
3
4
5
6
7
8
9
class Rational{ 
public:
Rational(int numerator = 0, int denominator= l);//构造函数刻意不为 explicit,允许 int-to-Rational 隐式转换。
int numerator () const;
int denominator() const;
////分子 (numerator) 和分母 (denominator) 的访问函数
private:
...
} ;

explicit 构造函数

C++中的 explicit 关键字用于修饰只有一个参数的类构造函数, 它的作用是表明该构造函数是显示的, 而非隐式的, 跟它相对应的另一个关键字是implicit, 意思是隐藏的,类构造函数默认情况下即声明为implicit.

explicit 构造函数表示该类无法进行隐式类型转换。

1
2
3
4
5
6
7
8
9
10
11
12
class Point {
public:
int x;
Point(int x = 0)
: x(x) {}
};

int main()
{
Point q(1);
Point p = 1;
}

其中 Point p = 1; 发生了隐式类型转换,先将1转化为 Point 对象,再使用拷贝构造函数。而如果将构造函数声明为 explicit,则不允许隐式类型转换发生。此时,这个语句会报错。

effective c++中说:被声明为explicit的构造函数通常比 implicit 更受欢迎。因为它们禁止编译器执行非预期(往往也不被期望)的类型转换。除非有一个好理由允许构造函数被用于隐式类型转换,否则把它声明为explicit,鼓励大家遵循相同的政策。

由于隐式类型转换只会发生在只有一个参数的情况下,explicit关键字只对有一个参数的类构造函数有效, 如果类构造函数参数大于或等于两个时, explicit关键字也就无效了。

然而,当除了第一个参数以外的其他参数都有默认值的时候, explicit关键字依然有效。调用构造函数时只传入一个参数, 等效于只有一个参数的类构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using namespace std;

class Point {
public:
int x, y;
explicit Point(int x, int y = 0)
: x(x), y(y) {}
};

int main()
{
Point q(1); //正确
Point p = 1; //出错!
}

考虑有理数乘法 (operator*)。假设以 member 函数实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Rational { 
public:
...
const Rational operator* (const Rational& rhs) const;
}
//这个设计使你能够将两个有理数以最轻松自在的方式相乘:
Rational oneEighth(l, 8);
Rational oneHalf(l, 2);
Rational result = oneHalf * oneEighth; //很好
result = result* oneEighth; //很好
//然而当你尝试混合式算术,只有一半行得通:
result = oneHalf * 2; //很好
result = 2 * oneHalf; //错误!

解决办法是让 operator* 成为一个 non-member 函数,允许编译器在每一个实参身上执行隐式类型转换:

1
2
3
4
5
6
7
8
9
10
11
class Rational {
... ///不包括 operator*
};
const Rational operator*(const Rational& lhs, const Rational& rhs) //non-member 函数
{
return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs. denominator()) ;
}
Rational oneFourth(l, 4);
Rational result;
result = oneFourth * 2; //正确
result = 2 * oneFourth; //正确

member 函数的反面 是 non-member 函数,不是 friend 函数。无论何时如果你可以避免 friend 函数就该避免。

  • 如果你需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member 。

条款 25: 考虑写出一个不抛异常的 swap 函数

swap 两对象值,是将两对象的值彼此赋予对方。缺省情况下 swap 动作可由标准程序库提供的 swap 算法完成。

1
2
3
4
5
6
7
namespace std { 
template<typenameT> void swap(T& a, T& b) {//std:: swap 的典型实现;
T temp (a);
a= b;
b =temp;
}
}

只要类型 T 支持 copying (copy 构造函数和 copy assignment 操作符),缺省的 swap 实现代码就会帮你置换类型为 T 的对象,你不需要为此另外再做任何工作。

这个 swap 实现版本涉及三个对象的复制:a 复制到 temp, b 复制到 a, 以及 temp 复制到 b。但是对某些类型而言,这些复制动作无一必要。

其中最主要的就是 “以指针指向一个对象,内含真正数据” 那种类型。这种设计 的常见表现形式是所谓 pimpl 手法 (pimpl 是 “pointer to implementation” 的缩写) 。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class WidgetImpl { //针对Wdget 数据而设计的 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 对象
...
*pImpl = *(rhs.pImpl);
}
private:
WidgetImpl* pImpl;//指针,所指对象内含Widget数据。
};

一旦要置换两个 Widget 对象值,我们唯一需要做的就是置换其 pImpl 指针,但缺省的 swap 算法不知道这一点。它不只复制三个 Widget, 还复制三个 WidgetImpl 对象。我们希望能够告诉 std: :swap, 当 Widgets 被置换时置换其内部的 pImpl 指针。确切实践这个思路的一个做法是:将 std: :swap 针对Widget 特化。

如果 swap 的缺省实现码对你的 class 或 class template 提供可接受的效率, 你不需要额外做任何事。

如果 swap 缺省实现版的效率不足,试着做以下事情:

  1. 提供一个 public swap 成员函数,让它高效地置换你的类型的两个对象值。这个函数绝不该抛出异常。
  2. 在你的 class 或 template 所在的命名空间内提供一个 non-member swap, 并令它调用上述 swap 成员函数。
  3. 如果你正编写一个 class (而非 class template) ,为你的 class 特化 std:: swap。并令它调用你的 swap 成员函数。

如果你需要调用 swap, 请确定包含一个 using 声明式,以便让 std: :swap 在你的函数内曝光可见,然后不加任何 namespace 修饰符,赤裸裸地调用 swap 。

成员版 swap 绝不可抛出异常。那是因为 swap 的一个最好的应用是帮助 classes (和 class templates) 提供强烈的异常安全性 (exception-safety) 保障 (条款 29)。

  • 当 std:: swap 对你的类型效率不高时,提供一个 swap 成员函数,并确定这个函数不抛出异常。
  • 如果你提供一个 member swap, 也该提供一个 non-member swap 用来调用前者。对于 classes (而非 templates) ,请特化 std::swap 。
  • 调用 swap 时应针对 std::swap 使用 using 声明式,然后调用 swap 并且不带任何 “命名空间资格修饰”。
  • 为用户定义类型进行 std templates 全特化是好的,但千万不要尝试在 std 内加入某些对 std 而言全新的东西。

读书笔记,来自 《Effective C++》第 3 版