来呀,快活呀~

Effective CPP 阅读 - Chapter 4 设计与声明

良好的代码架构能够使得后续编码工作变的简单。尤其在OOP的世界中,如何能够设计良好的C++接口?我们的目标是高效,易用,易拓展。
带类的C?

18 让接口容易被使用,不易被误用

首先,考虑客户可能犯什么错误。书中提到可以构建类型系统防范客户输入不合理的数据。同时限制什么可以做,什么不可以做。例如加入const限定修饰符。

其次,尽量使接口与内建类型等保持一致。例如STL中统一使用size()方法获取容器的大小。

任何接口如果强制客户记得某件事情,那么就会有犯错的危险。较佳的方法是先发制人,例如预定函数的返回值为智能指针,防止客户接触裸指针。

19 设计class犹如设计type

设计自定义的class要慎重,就好比语言设计者小心翼翼地设计语言的内置类型。一般有如下考虑:

  • 新的对象如何创建和销毁?这关系到构造和析构函数。
  • 对象初始化和赋值有何区别?这关系到构造函数和赋值运算符。
  • 新的对象如何以pass-by-value方式传递,意味着什么?这关系到copying函数的实现。
  • 什么是新类型的合法值?可能需要对setter()函数进行参数检查。
  • 新类型在继承图中的位置?这关系到虚函数,以及析构函数是否为虚函数。
  • 新类型需要什么样的转换?只能显式构造还是允许隐式转换(意味着你需要自己实现隐式转换函数)。
  • 什么样的操作符和函数对此类型是合理的?这涉及到访问权限,以及新类型与外界的交互。
  • 什么样的标准函数应该驳回?是否要禁止编译器生成默认函数。
  • 谁该取用成员?这决定了成员的访问权限,以及某些类或函数是否为friend
  • 什么事新类型的未声明接口?
  • 新类型有多一般化?是否建立template class更好?
  • 新的类型真的必要吗?如果是要定义新的派生类来为已经存在的类添加功能,也许使用non-member函数或者模板技术是更好的选择。

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

对于较大对象,pass-by-value有可能成为费时的操作。而且,如果以派生类对象实参传入一个以基类为形参的函数,会导致切片发生,也就是函数内部可见的仍然是基类对象,无法实现多态。

总而言之,当按照值传递方法传入参数时,请再三考虑是否传入常值引用是更好的选择。但该条款不适用于内建类型和STL中的迭代器和函数对象。对它们而言,值传递通常更为恰当。

21 必须返回对象时,不要妄想返回其reference

函数返回时,存在局部对象析构和返回值的构造,不要妄图对此优化,返回局部non-static对象的引用几乎必然导致失败!

C++11中引入的移动构造也许是解决这个问题的可行之道,以后总结。

22 将成员变量声明为private

封装,封装,还是封装!

而且,请记住,其实只有两种访问权限:private(提供了封装)和其他(包括protected,不提供封装)。

23 宁以non-member和non-friend函数替换member函数

对于类中的数据进行操作时,常常可以使用成员函数的方法,也可以编写一个non-member函数,通过调用类的公开方法实现目的。作者认为应偏向后者。原因有三:

  • 封装性。我们以能够获取类私有成员变量的代码多少进行封装性的量度。如果引入类的成员函数,这个函数可以肆无忌惮地访问类内的所有成员,这使得封装被破坏。
  • 代码设计的弹性。使用成员函数需要对类进行修改,而使用后者,我们可以借助C++中的名字空间,将相似功能的函数组织在不同的hpp和cpp文件中。需要的时候可以随时添加(因为C++的名字空间支持跨文件,而类声明并不是)。
  • 编译开销。每次都要修改类的话,还要重新编译。而使用non-member函数,可以不断做加法,编译时完全可以只处理新文件。

24 若所有参数均需要类型转换,请为此采用non-member函数

作者举出自定义的有理数类与整型数做乘法的例子。首先,我们不将构造函数声明为explicit,可以完成整形到有理数类的隐式类型转换。

重载乘法的运算符可以被声明为有理数类的成员函数,如下所示:

1
2
3
4
5
6
class Rational {
// ...
public:
Rational(int numerator=0, int denominator=1);
const Rational operator*(const Rational& rhs) const;
};

然而,这样做的话,auto res = 2*Rational(4,5)就无法通过编译,因为int并没有实现operator*(const Rational&)操作。

更好的方法是将其作为non-member函数,

1
2
3
const Rational operator*(const Rational& lhs, const Rational& rhs) {
//...
}

25 考虑写出一个不抛出异常的swap()函数

这一条款更像是模板特化规则的大杂烩。

STL中的swap()函数是交换两个对象内容的不错选择。它的实现大致如下(平淡无奇):

1
2
3
4
5
6
7
8
namespace std {
template <typename T>
void swap(T& a, T&b) {
T tmp(a);
a = b;
b = tmp;
}
}

但是对于某些pImpl(pointer to implementation)手法的类(指类的数据成员实际死一个指针,而不是数据成员的实在值),标准库的这一实现未免效率较低,因为我们实际上一般只需要交换两个对象的指针即可。

如何对我们的对象Widget实现特化?

如果Widget不是模板类,那么我们需要进行全特化。加入以下:

1
2
3
4
5
6
namespace std {
template <>
void swap<Widget>(Widget& a, Widget& b) {
swap(pImpl, b.pImpl);
}
}

更好的解决方法是先将swap()定义为Widget类的公共成员函数,然后再全特化标准库的swap()方法时调用。这样与STL的约定保持一致。STL中vector等容器即是这样的。一方面提供了公开方法进行交换,另一方面特化了std名字空间的swap()方法。

Widget是模板类时,需要进行偏特化。也许看上去是这样:

1
2
3
4
5
6
namespace std {
template <typename T>
void swap(Widget<T>& a, Widget<T>& b) {
a.swap(b);
}
}

但是程序员可以全特化std中的模板,却不能加入新的类或函数进入std中。在实际中,这样写出的程序一般仍然能够编译运行,但是这种行为确实是未定义的。所以最好不要这样做。

所以,可以在Widget存在的名字空间内定义swap()(而不是加入std),这里涉及到C++中的模板实例化查找规则,不再多说了。作者在条款末尾总结了一般规则:

  • 一般使用标准库中的swap()即可。
  • 如果自己实现,首先提供一个publicswap()成员函数,注意这儿函数决不能抛出异常。
  • 在类或者模板在的名字空间中提供一个non-member的swap()函数,并令它调用上述的swap()成员函数。
  • 如果是类,而不是模板,那么特化std::swap(),并令它调用上述swap()成员函数。
  • 在客户端代码调用swap()时,确定包含一个using声明式,以便让std::swap()在你的函数内可见,然后不加任何名字空间修饰符,赤裸裸调用swap()