设计与声明

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

所谓的 “cross-DLL problem”,这个问题发生于“对象在动态连接程序库中被new创建,却在另一个 DLL 内被 delete 销毁”。这许多平台上,这一类“跨DLL之 new/delete成对运用”会导致运行期错误。

小结:

  • 好的接口容易被正确使用,不容易被误用。你应该在你的所有接口中努力达成这些性质。

  • “促进正确使用”的办法包括接口一致性,以及与内置类型的行为兼容。

  • “阻止误用”的办法包括建立新类型、限制类型上的操作,束缚对象值,以及消除客户的资源管理责任。

  • shared_ptr 支持定制型删除器。这可防范 DLL 问题,可被用来自动解除互斥锁等等。

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

当你定义一个新的 class,也就定义了一个新 type。

如何设计高效的 classes 呢?首先必须了解你面对的问题。

  • 新 type 的对象如何被创建和销毁?会影响到构造函数和析构函数以及内存分配和释放函数。

  • 对象的初始化和对象的赋值该有什么样的差别?这个决定了构造函数和赋值操作符的行为以及其间的差异。

  • 新 type 的对象如果被 passed by value,意味着什么?copy构造函数用来定义一个type的 passed-by-value如何实现。

  • 什么是新 type 的“合法值”?对成员变量而言,通常只有某些数值集是有效的。

  • 你的新 type 需要配合某个继承图系吗?继承自既有的 classes,或者被继承。

  • 你的新 type 需要什么样的转换?如果你希望允许类型T1之物被隐式转换为类型T2之物,就必须在 class T1 内写一个类型转换函数(operator T2)或在 class T2 内写一个 non-explicit-one-argument(可被单一实参调用)的构造函数。如果你只允许 explicit 构造函数存在,就得写出专门负责执行转换的函数。

  • 什么样的操作符和函数对此新 type 而言是合理的?这个决定你jiang将为你的 class 声明哪些函数。

  • 什么样的标准函数应该驳回?那些正是你必须声明为 private 者。

  • 谁该取用新 type 的成员?这个提问可以帮助你决定哪个成员为 public或者private或者protected。它也帮助你决定哪个 class/function 应该是 friend,以及将它们嵌套于另一个之内是否合理。

  • 什么是新 type 的“未声明接口”?

  • 你的新 type 有多么一般化?或许你其实并非定义一个新 type,而是定义一整个 types 家族。果真如此就不该定义一个新 class,而是应该定义一个新的 class template。

  • 你真的需要一个新 type 吗?如果只是定义新的 derived class 以便为既有的 class 添加机能,那么说不定单纯定义一或多个 non-member 函数或 templates ,更能够达到目标。

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

缺省情况下 C++ 以 by value 方式传递对象至函数,或来自函数,即函数参数都是实参的副本,而调用端所获得的也是函数返回值的一个副本。这些副本由对象的 copy 构造函数产出,这可能使得 pass-by-value 成为昂贵的操作。

以 by value 的方式传递对象,会发生多次构造和析构。pass by reference-to-const,可以回避构造和析构动作,因为没有任何对象被创建。将它声明为 const 是必要的,因为不这样做的话调用者会忧虑函数会不会改变他们传入的那个对象。

by reference 方式传递参数也可以避免 slicing(对象切割)问题。

当一个 derived class 对象以 by value 方式传递并被视为一个 base class 对象,base class 的 copy 构造函数会被调用,而“造成此对象的行为像个 derived class 对象”的那些特化性质全被切割掉了,仅仅留下一个 base class 对象。因为正是 base class 构造函数建立了它,但这不是你想要的。

解决切割问题的办法,就是以 by reference-to-const 的方式传递。

如果窥视 C++ 编译器的底层,你会发现,reference 往往以指针实现出来,因此 pass by reference 通常意味真正传递的是指针。因此如果你有个对象属于内置类型,pass by value 往往比 pass by reference 的效率高些。这个忠告也适用于 STL 的迭代器和函数对象。

内置类型都相当小,因此有人认为,所有小型 type 都是 pass by value 的合格候选人,甚至它们是用户自定义的 class 亦然。这是个不可靠的推论。因为对象小并不意味其 copy 构造函数不昂贵,或者可能有效率上的争议,或者作为一个用户自定义类型,其大小容易有所变化。

小结:

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

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

在坚定追求 pass by reference 的纯度中,他们一定会犯下一个致命错误:开始传递一些 reference 指向其实并不存在的对象。

1
2
3
4
5
6
7
8
9
10
// 一个有理数的 class,内含一个函数用来计算两个有理数的乘积
class Rational {
public:
Retional(int numrator = 0, int denominator = 1);
...
private:
int n, d; // 分子和分母

friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};

记住,所谓 reference 只是个名称,代表某个既有对象。任何时候看到一个 reference 声明式,你都应该立刻问自己,它的另一个名称是什么?因为它一定是某物的另一个名称。

如果将返回值改为 reference ,后者一定指向一个既有的 Rational 对象,内含两个 Rational 对象的乘积。我们当然不可能期望这样一个 内含乘积的 Rational 对象在调用 operator* 之前就存在,这并不合理。
如果 operator* 要返回一个 reference 指向如此数值,它必须自己创建那个 Rational 对象。函数创建对象的途径有二:在 stack 空间或 heap 空间创建之。

如果是在 stack 空间创建的话,返回的结果仍然需要由构造函数构造起来,并不能避免调用构造函数,而且 local 对象在函数退出前被销毁了,它返回的 reference 指向一个已经被销毁的对象,显然是错误的。如果函数返回指针指向一个 local 对象,也是一样。

如果在 heap 空间构造对象的话,还是必须付出一个“构造函数调用”代价,因为分配所得的内存将以一个适当的构造函数完成初始化动作。但问题是:谁该对着被你 new 出来的对象实施 delete?比如出现下面这样的语句:w = x * y * z;
同一语句调用了两次 operator* ,因而两次使用 new,也就需要两次 delete,但却没有合理的办法让 operator* 使用者进行那些 delete 调用,会导致资源泄露。

还有一种方法可以避免任何构造函数被调用:让 operator* 返回的 reference 指向一个被定义于函数内部的 static Rational 对象。

1
2
3
4
5
const Rational& operator*(const Rational& lhs, const Rational& rhs) {
static Rational result;
result = ...;
return result;
}

当执行这样的表达式 ((a * b) == (c * d)) ,总是被核算为 true。两次 operator* 调用的确各自改变了 static Rational 对象值,但由于它们返回的都是 reference,因此调用端看到的永远是 static Rational 对象的“现值”。

当你必须在返回一个 reference 和返回一个 object 之间抉择时,你的工作就是挑出行为正确的那个。就让编译器厂商为“尽可能降低成本”鞠躬尽瘁吧!

小结:

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

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

首先从语法一致性考虑,如果成员变量不是 public ,客户唯一能够访问对象的办法就是通过成员函数。

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

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

将成员变量隐藏在函数接口的背后,可以为“所有可能的实现”提供弹性。

封装的重要性比你最初见到它时还重要。如果你对客户隐藏成员变量,你可以确保 class 的约束条件总是会获得维护,因为只有成员函数可以影响它们。尤有进者,你保留了日后变更实现的权利。如果不隐藏它们,改变任何 public 事物会破坏太多客户码。

假设有一个 public 成员变量,而我们最终取消了它。多少代码可能会被破坏呢?所有客户代码都会被破坏。因此 public 成员变量完全没有封装性。
假设有一个 protected 成员变量,而我们最终取消了它。多少代码可能会被破坏呢?所有使用它的 derived class 都会被破坏。因此 protected 成员变量也缺乏封装性。“语法一致性”和“细微划分之访问控制”等理由显然也适用于 protected 数据。

从封装的角度观之,其实只有两种访问权限:private(封装) 和其他(不提供封装)。

小结:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class WebBrowser {
public:
...
void clearCache();
void clearHistory();
void removeCookies();
...
};

class WebBrowser {
public:
...
void clearEverything();
...
};


void clearBrowser(WebBrowser& wb) {
wb.clearCache();
wb.clearHistory();
wb.removeCookies();
}

如果某些东西被封装,它就不再可见。越多的东西被封装,越少人可以看到它,这样就有更大的弹性去改变它,因为我们的改变仅仅直接影响看到改变的那些人事物。

通过计算能够访问该数据的函数数量,作为一种粗糙的量测。愈多函数可访问它,数据的封装性就愈低。

条款22说过,成员变量应该是 private,因为如果它们不是,就有无限量的函数可以访问它们,它们也就毫无封装性。能够访问 private 成员变量的函数只有 class 的 member 函数加上 friend 函数而已。如果要在一个 member 函数(它不只可以访问 class 内的 private 数据,也可以取用 private 函数、enums、typedef等等)和一个 non-membernon-friend 函数(它们无法访问上述任何东西)之间做抉择,而且两者提供相同机能,那么,导致较大封装性的是 non-member non-friend 函数,因为它并不增加“能够访问class 内之 private 成分”的函数数量。

在 C++ 中,比较自然的做法是让 clearBrowser 成为一个 non-member 函数并且位于 WebBrowser 所在的同一个 namespace 内。
将所有便利函数放在多个头文件内但隶属同一个命名空间,可以降低编译依赖,但这种切割机能并不适用于 class 成员函数,因为一个 class 必须整体定义,不能被分割为片片段段。
同时意味着客户可以轻松扩展这一便利函数,他们需要做的就是添加更多 non-member non-friend 函数到此命名空间。这是 class 无法提供的另一个性质,因为 class 定义式对客户而言是不能扩展的。

小结:

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

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

1
2
3
4
5
6
7
8
9
10
11
class Rational {
public:
// 构造函数刻意不为explicit,允许隐式类型转换
Rational(int numrator = 0, int denominator = 1);

int numrator() const; // 分子分母的访问函数
int denominator() const;
private:
int numrator;
int denominator;
};

实现有理数的乘法 ,先将 operator* 写成 member 函数。

1
2
3
4
5
class Rational {
public:
const Rational* operator*(const Rational& rhs) const;
...
};

两个有理数相乘:

1
2
3
4
Rational oneHalf(1, 2);
Rational result;
result = oneHalf * 2; // 没问题
result = 2 * oneHalf; // 错误!

结论是,只有当参数被列于参数列内,这个参数才是隐式类型转换的合格参与者。地位相当于“被调用的成员函数所隶属的那个对象”——即 this 对象——的那个隐喻参数,绝不是隐式类型转换的合格参与者。第一次调用伴随一个放在参数列内的参数,第二次调用则否。

operator* 成为一个 non-member 函数,便允许编译器在每一个实参身上执行隐式类型转换。

无论何时如果你可以避免 friend 函数就该避免,因为会带来很多麻烦。当然有时候 friend 有其正当性,但这个事实亦然存在:不能够只因函数不该成为 member ,就自动让它成为 friend。

小结:

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

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

1
2
3
4
5
6
7
8
9
// 缺省实现版
namespace std {
template<typename T>
void swap(T& a, T& b) {
T temp(a);
a = b;
b = temp;
}
}

缺省版本十分平淡,效率不高,其中最主要的就是“以指针指向一个对象,内含真正数据”那种类型。这种设计的常见表现形式是所谓的“pimpl手法”(pimpl 是“pointer to implementation”的缩写)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Widget {
public:
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs) {
...
*pImpl = *(rhs.pImpl);
...
}
...
void swap(Widget& other) {
using std::swap;
swap(pImpl, other.pImpl); // 若要置换Widgets就置换其pImpl指针
}
private:
WidgetImpl* pImpl;
};

// std::swap 特化版本
namespace std {
template<>
void swap<Widget>(Widget& a, Widget& b) {
a.swap(b);
}
}

// non-member swap 函数
template<typename T>
void swap(Widget<T>& a, Widget<T>& b) {
a.swap(b);
}

// 对 swap 的调用
template<typename T>
void doSomething(T& obj1, T& obj2) {
using std::swap; // 令 std::swap 在此函数内可用
...
swap(obj1, obj2); // 为T型对象调用最佳swap版本
...
}

首先,如果 swap 的缺省实现代码对你的 class 或 class template 提供可接受的效率,你不需要额外做任何事。任何尝试置换那种对象的人都会取得缺省版本,而那将有良好的运作。

其次,如果 swap 缺省实现版的效率不足(那几乎总是意味你的 class 或 template 使用了某种 pimpl 手法),试着做以下事情:

  1. 提供一个 public swap 成员函数,让它高效地置换你的类型的两个对象值。这个函数绝不该抛出异常。
  2. 在你的 class 或 template 所在的命名空间内提供一个 non-member swap,并令它调用上述 swap 成员函数。
  3. 如果你正在编写一个 class(而非 class template),为你的 class 特化 std::swap。并令它调用你的 swap 成员函数。
  4. 最后,如果你调用 swap,请确定包含一个 using 声明式,以便让 std::swap 在你的函数内曝光可见,然后不加任何 namespace 修饰符,赤裸裸地调用 swap。

小结:

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