《Effective C++》 第四章「设计与声明」学习笔记

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

欲开发一个“容易被正确使用,不容易被误用”的接口,首先必须考虑客户可能做出什么样的错误。

1. 明智而审慎地导入新类型对预防“接口被误用”有神奇疗效

每一个接口的参数传递必须被准确限制,不易被误用。例如:

1
Date(int month, int day, int year);

可以改写为:

1
Date(const Month& month, const Day& day, const Year& year);

其中 class 或者 struct 的 Month 等抽象数据类型的构造函数必须是 explicit,阻止其进行隐式转换。

这样修改后客户不需要去记住构造函数中年月日的顺序,并且如果参数传递错误,在编译期就会报错。

再例如 Month,它的值只能从 1 到 12。我们可以将它的构造函数私有化,然后提供十二个月份的静态函数,函数内构造并返回当前月份的对象。

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

这样可以限制客户传递错误的月份。

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

以 const 修饰 operator * 的返回类型,可阻止客户在使用自定义类型时犯错。

1
2
3
4
if (a * b = c)
{
// ...
}

考虑上面的 if 判断,如果我们以 const 修饰了该变量类型乘法操作符的返回值,那么上述语句在编译器就会报错,因为 const 常量不允许被赋值。如果没有使用 const 修饰,你可以想象的到会发生什么样的 bug。

3. 除非有好理由,否则应该尽量令你的 type 行为与内置 types 一致。

  • 就像前面提到的,如果我们编写自己的数值类,那么它的行为应该和 int 等内置类型一样。如果像 if (a * b = c) 这样使用数值类型,在编译期就应该报错。
  • 每个 STL 容器都有 size() 函数,返回容器的大小。当我们自己的类型提供类似功能时也应该使用 size() 函数命名。

4. 任何接口如果要求客户必须记得做某些事,就是有着不正确使用的倾向。

因为客户可能会忘记做那件事情。考虑下面的这个函数,它返回一个指针。

1
Investment* createInvestment();

该函数将释放资源的任务交给了接口的使用者,这可能会导致 bug,因为接口使用者可能会忘记释放资源。更好的做法是使用智能指针,将释放资源的任务交给智能智能,这样不仅可以预防使用接口的人犯错,也是比较良好的 API 设计。并且 shared_ptr 还有支持定制删除器等优点。

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

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

该条款主要是列举了一些我们在设计 class 时需要考虑的问题:

1. 新 type 的对象应该如何被创建与销毁?

这会影响到 class 的构造、析构、内存分配和释放等函数。

2. 对象的初始化和对象的赋值该有什么样的差别?

关键点是构造函数与赋值操作符的实现,初始化与赋值的区别。

3. 新 type 的对象如果被 pass-by-value,意味着什么?

拷贝构造函数用来定义一个 type 的 pass-by-value 实现。

4. 什么是新 type 的合法值?

需要在构造函数、赋值操作符和 setter 函数中注意使用。

5. 你的新 type 需要配合某个继承体系吗?

需要注意基类函数的 virtual 或 non-virtual 函数。以及若自己是多态基类,需要令析构函数是 virtual。

6. 你的新 type 需要什么样的转换?

你是否需要支持或提供隐式、显式转换。

7. 什么样的操作符和函数对此新 type 而言是合理的?

你应该实现哪些操作符,其中哪些应该是 member,哪些是 non-member。

8. 什么样的标准函数应该被驳回?

不需要的自动生成函数应该禁用。

9. 谁该取用新 type 成员?

这关系到成员的 public、private、protected 属性以及 friend 函数的设置。

10. 什么是新 type 的未声明接口?

11. 你的新 type 有多么一般化?

是否需要从 class 到 class template 的转变。

12. 你真的需要一个新 type 吗?

有时候其实仅仅是需要一个扩展功能的函数而已。

当我们需要新增一个类型时,我们应该先考虑一下这些问题,当考虑清楚后,然后再着手编码。

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

C++ 默认的情况下是使用 pass-by-value,这种方式有一些缺点:

  1. 开销大,可能涉及多个对象的构造和析构。

  2. 对象切割问题。

考虑到如上这两个问题,在大部分时候我们应该优先考虑是否能够以传引用的方式替换传值。传递引用效率很高,因为它不存在任何开销。同时也能避免对象切割问题。加上 const 关键字可以向函数调用者保证函数内不会修改该引用对象。

当然,优先考虑传递引用并不代表着我们不再使用值传递。对什么类型选用 pass-by-value 比较好:

  1. 内置类型

  2. STL的迭代器与函数对象

最后,自定义的 type 不适宜 pass-by-value,原因如下:

  1. 对象小并不意味其 copy 构造函数不昂贵,特别是涉及深拷贝的时候。

  2. 某些编译器对待内置类型和自定义类型的态度截然不同,即便两者的底层表述一致。如 double 和只有一个 double 成员的 class 是有区别的。

  3. 作为一个用户自定义的类型,它的大小很容易变化。

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

上一个条款说了我们应该优先使用引用。在函数内若想返回引用,对象必须先存在,而该对象也应该在函数中声明出来,这样可能会陷入下面列举的问题中:

错误1:返回 reference 指向一个 local stack 对象。

函数返回在栈上声明的对象,当退出函数的时候该对象会被 delete。这导致了函数返回的引用指向一个不存在的对象。

错误2:返回 reference 指向一个 heap-allocated 对象

函数返回在堆上声明的对象,这样又将释放资源的责任交给了客户,客户可能会忘记做这件事。即使对变量进行了 delete,有时候还是会出现内存泄漏。考虑如下代码:

1
2
Retional w, x, y, z;
w = x * y * z;

Retional 的乘法操作符的结果返回指向堆上分配的对象。在上面的这个例子中,即使 delete 了 w, x * y 分配的内存却没有办法 delete 掉。

错误3:返回 reference 指向一个 local static 对象

函数返回 local static 对象,存在两个问题。第一个是多线程安全问题,第二个是多次调用都是对同一个内存上的值作修改。当客户写下如下代码时,条件判断将会 always true。

1
2
3
4
5
6
7
8
9
Retional a, b, c, d;
if ((a * b) == (c * d))
{
// always true
}
else
{
}

综上所述,一个必须返回新对象的函数的正确写法是:就让那个函数返回一个新对象。

1
2
3
4
inline const Retional operator * (const Retional &lhs, const Retional &rhs)
{
return Retional(lhs.n * rhs.n, lhs.d * rhs.d);
}

总结:

  • 绝对不要返回 pointer 或 reference 指向一个 local stack 对象

  • 绝对不要返回 reference 指向一个 heap-allocated 对象

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

  • 一个必须返回新对象的函数的正确写法是:就让那个函数返回一个新对象。

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

将成员变量声明为 private 的好处有:

  1. public 的接口全是函数,调用的时候都得在后面加(),可以保持接口的一致性。

  2. 使用函数才能对变量修改,这样可以让你对成员变量的处理有更加精确的控制。可以定义变量为只读、只写、可读可写或不可读写。

  3. 保护类的封装性。方便日后修改其实现,或者针对不同客户提供不同的实现。

另外:

  • 逻辑的可变性与封装性成正比,不封装意味着不可改变。
  • public 与 protected 的封装性都是很差的,或者说根本不提供封装。因为在这两种情况下,如果成员变量被改变,都会有不可预知的大量代码受到破坏。

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

越多成员 / 友元函数可以访问类的数据,数据的封装性就越低。
因此非成员、非友元更能保护类的封装性。

上面的论述有两点需要注意:
1. 这个论述只适应于非成员且非友元的函数,友元函数和成员函数一样对类的封装性有同样的冲击力。

2. 因为在意封装性而让某函数成为 class 的非成员函数并不意味着不能使其成为别的 class 的成员函数

在 C++ 中,比较自然的做法是让一个扩展类功能的便利函数成为非成员非友元函数并且位于类所在的同一个 namespace 内。而且多个便利函数还可以分离编译(根据功能放在不同头文件里,但都位于同一个命名空间),扩大类的机能扩充性。

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

虽然构造函数支持隐式类型转换不是好的主意,但有时候会有例外,最常见的是建立数据类型的时候,往往需要隐式转换。

例如存在一个有理数类,通常会让它支持隐式转换:

1
2
3
4
5
6
7
class Rational
{
public:
Rational(int numerator = 0, int denominator = 1);
int numerator() const;
int denominator() const;
};

当需要两有理数相乘的时候,可以将其实现为成员函数:

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

这个函数大部分时候都能正常工作,不过它不能支持下面这样的语法:

1
Rational result = 2 * oneself

因为对 int 型来说,没有 * 操作符可以与 Rational 类型相乘,并且在作用域内,也找不到合适的函数来让 int 型和 Rational 类型相乘。

在这种情况下,我们可以使用非成员函数来替换成员函数:

1
2
3
4
5
6
7
8
class Rational
{
// ...
};
const Rational operator* (const Rational& lhs, const Rational& rhs)
{
// ...
}

这样前面提到的 2 * oneself 就能正常编译通过且以符合我们期望的方式运行。因为编译器能查找到适用于上述形式的乘法函数。

综上所述,如果某个函数的所有参数都需要进行类型转换,那么这个函数应该是个非成员函数。特别适用于数值类型的运算符函数。

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

虽然该条款的名字是考虑写出一个不抛异常的 swap 函数,但内容其实主要讲的是如何编写和使用 swap 函数。

1. 缺省情况下 swap 动作可由标准程序库提供的 swap 函数完成。

2. 何时应该编写自己的 swap 函数?

如果 swap 的缺省实现可以满足你的需求和效率,你不需要做任何事。
否则:

  1. 为你的类型提供一个 public swap 函数,让它高效的置换类型的两个对象的值。

  2. 在你的 class 或 template 所在的命名空间内提供一个非成员 swap 函数,并让它调用上述 swap 成员函数。

  3. 如果你正编写一个 class,为你的 class 特化 std::swap。并令它调用你的 swap 成员函数。(防止用户直接调用 std::swap)

3. 如果你调用 swap 函数,请确定包含一个 using std::swap 的声明。然后不加任何修饰符,赤裸裸的调用 swap 函数。

1
2
3
4
5
6
template<typename T>
void doSomething(T &a, T &b)
{
using std::swap; // 令 std::swap 在此函数内可用
swap(a, b); // 为 T 类型对象调用最佳的 swap 版本
}

当调用 swap 函数时,swap 函数的查找优先级如下:

  1. global 作用域或类型 T 的命令空间内
  2. std::swap 针对 T 的特化版本
  3. std::swap 一般版本

使用 swap 函数的错误方式:std::swap(obj1, obj2);

总结:

  • 当 std::swap 对你的类型效率不高时,提供一个 swap 成员函数,并确定这个函数不抛出异常。

  • 如果你提供一个成员 swap 函数,也应该提供一个非成员 swap 函数用来调用前者。对于 class,也请特化 std::swap。

  • 调用 swap 时应对 std::swap 使用 using 声明。

  • 为自定义类型进行 std template 全特化是好事,但千万不要尝试在 std 内加入某些对 std 而言全新的东西。

全文完

感谢阅读