[译]GotW #4 Class Mechanics
你对写一个类的细节有多在行?这条款不仅注重公然的错误,更多的是一种专业的风格。了解这些原则将会帮助你设计易于使用和易于管理的类。
JG Question
1. 什么使得接口“容易正确使用,错误使用却很难”?解释一下。
Guru Question
2. 你正在代码审查,一个程序员写了下面这个类,里面有一些不良的风格和一写具体的错误。你能发现多少?如何修正?
class complex { public: complex( double r, double i = 0 ) : real(r), imag(i) { } void operator+ ( complex other ) { real = real + other.real; imag = imag + other.imag; } void operator<<( ostream os ) { os << "(" << real << "," << imag << ")"; } complex operator++() { ++real; return *this; } complex operator++( int ) { auto temp = *this; ++real; return temp; } // ... more functions that complement the above ... private: double real, imag; };
Stop and thingking….
Solution
1. 什么使得接口“容易正确使用,错误使用却很难”?解释一下。
我们想要可能的“pit of success”,当用户以一种很自然的正确方式使用。他们很自然地写出有用,正确和高效的代码。
另一方面,我们想对于我们的用户来说很难在使用方面引起麻烦,我们想使代码的不正确使用和低效是无效的(可能的话出现编译错误)或至少是不方便和困难的。这样我们可能保护我们的用户远离意外的结果。
Scott Meyer 这方面有篇很受欢迎的文章。
2. 你正在代码审查,一个程序员写了下面这个类,里面有一些不良的风格和一写具体的错误。你能发现多少?如何修正?
这个类有很多问题,甚至比我在这说出来的还多。这个条款的重点是主要强调类的结构,(类似“operator<<的规范形式什么什么样的”和“operator+应该是一个成员吗?”这样的问题)而不是指出此类在设计的缺陷。不管怎样,我会从两个可能是最重要的观察开始:
首先,这是代码审查,但是这个开发者看起来甚至对没有对代码进行单元测试,否则的话他会发现一些很显眼的问题。
其次,为什么他要写一个在标准库已经存在的complex类?而且,再说,标准库里类没有下面的那么多困扰。自谦一些,重用它。
Guideline:重用代码,特别是标准库中的代码,而不是自己纯手工做一个。因为它更快速、简单和安全。
可能修正complex类代码中的问题的最好方式是完全避免使用它,使用std::complex。话虽如此,这是一个很有启发性的例子,让我们看看在个例子,然后修正其中的问题。首先是构造函数:
1. 缺失默认的构造函数
complex( double r, double i = 0 ) : real(r), imag(i) { }
一旦我们提供了用户编写的构造函数,那么隐式产生的默认构造函数就会被抑制。为了“正确地易于使用”,没有一个默认的构造函数就很烦人的。在这个例子中,我们要不默认所有的参数或者提供一个complex()=default,并且使用类似double real = 0,imag = 0的初始化来声明数据成员或者只是使用构造委托complex() : complex(0){}.在这我们只是简单设置默认参数。同样,像在GotW #1中说的那样,坚持倾向于使用{}来初始化来作为一个好的习惯而不是()。在这例子中它们的意思完全相同,但是{}使得我们更一致,且在维护过程中国可能会捕捉到一些细微错误,例如打错字的情况下可能使得double 到float的变窄转换。
2.operator+使用值语义
void operator+ ( complex other ) { real = real + other.real; imag = imag + other.imag; }
尽管我们对这个函数进行其他修改,我们应该传入const&给参数,因为我们只需要读取数据。
Guideline:倾向于通过使用const&来设置只读参数,如果只准备从参数做读取动作(不是从它拷贝)
3.operator+修改了this对象的值
operator+应该返回一个包含了和的complex对象而不是void和修改this对象的值。用户写类似val1 + val2不太可能通过那么怪异的语法观察val1改变了内容。Scott Meyer习惯说,在写一个值类型时,要想内建类型一个方便,比如和int一样。
4.operator+不是依据operator+=(缺失)编写
在这,operator+试着成为operator+=,它应该被拆分成operator+和operator+=,前者调用后者。
Guideline:如果你提供一个独立的operator版本(比如:operator+),总是提供一个相同的赋值版本(比如:operator+=)且依据后者来实现前者。同样,应该总是在op和op=之间保持自然的关系(op代表任意操作符)
有+=是好的,因为用户应该更喜欢使用它。即使在上面的代码中,real = real + other.real; 应该是 real += other.real;同样对于第二行。
Guideline:倾向于编写a op= b替代 a = a op b这样的语句,因为它更清晰且通常更高效。
operator+=更高效的理由是直接操作在左手端的对象,且只返回对象的引用,不是临时对象。另一方面,operator+必须返回一个临时对象。为了明白为什么,考虑下面operator+=和operator+的规范格式。
T& T::operator+=( const T& other ) { //... return *this; } T operator+( T a, const T& b ) { a += b; return a; }
注意到其中一个参数是传值,另一个是传引用了吗?那是因为如果你准备对一个参数进行拷贝,通常传值是好的,如果调用方传入一个临时变量时使得移动操作有效,比如:(val1 * val2) + val3。这是一个可以遵循的好习惯,即使在类型complex这样的例子中,移动和拷贝的代价是一样的,因为它不花任何效率当移动和复制都是相同的。且比传入引用然后追加一个额外的命名局部变量来说,代码更清晰。在未来的GotW中我们会看多更多关于参数传递的问题
Guideline:如果你无论如何都要从参数进行拷贝,倾向于传入一个传值的只读参数,因为这样可以对右值参数进行移动操作
使用+=来实现+让代码更简单且保证了一致的语义,在维护过程中也很少会出现分歧。
5.operator+不应该是一个成员函数
如果让operator+称为成员函数,就像这做的一样。当你决定允许从其他类型进行隐式转换,那么它在使用时就不会和我们期待的那样自然。在这,从一个double隐式转换到complex是有意义的,但是这里存在着不对称:特别是,当把complex对象加到一个数值对象时,你可以写a=b+1.0,但是a=1.0+b就是错的。因为成员函数operator+要求一个complex对象(不是double)作为它的左手参数。
最后,operator+不作为成员函数的其他理由是提供更好的封装,像Scott Meyer说的那样
Guideline:在对一个操作应该是成员和非成员之间倾向于使用这个指南:一元操作时成员;=()[]和 ->必须是成员;赋值操作(+= –= /= *=等)必须是成员;所有其他的二元操作作为非成员
6.operator<<不应该是一个成员函数
这个代码的作者应该不会想要类似my_complex << cout这样的语句吧?
void operator<<( ostream os ) { os << "(" << real << "," << imag << ")"; }
相同的理由已经在operator+不应该是一个成员函数中阐述过了,这里对于operator<<也类似。还有一个就是第一个参数必须是ostream,不是complex。而且,这些参数应该是引用:(ostream&,const complex&)
这里有点需要注意的是,非成员operator<<应该自然地依据一个(经常是virtual)const成员来完成具体工作,通常命名为类似print的这么一个函数
7.operator<<应该返回ostream&
进一步,operator<<应该有一个类型为ostream&的返回值,返回一个流的引用是为了可以链式输出。这样的话,用户使用你的operator<<很自然地就可以写这样的代码:cout << a << b;
Guideline:从operator<<和operator>>中总是返回流的引用
8.前缀递增操作符的返回值不正确
complex operator++() { ++real; return *this; }
先忽略对于一个complex进行前缀递增是否有意义。如果存在这样的一个函数,那么它应该返回一个引用。这让用户代码操作更直观,且避免了不必要的低效。
Guideline:当return *this时,返回类型通常应该是引用
9.后缀递增应该依据前缀递增实现
complex operator++( int ) { auto temp = *this; ++real; return temp; }
优先调用++*this,而不是重复。参考GotW #2
Guideline:为了一致性,后缀递增总是根据前缀递增实现,否则你的用户将会得到惊喜的结果。
Summary
就是这样。还有一些其他的现代C++特性可以利用在这,但对于一个一般性建议显然有点不合适。比如,这是一个值类型,不是为层级而设计的,因此我们可以通过final来防止继承。但是对于一个一般性建议没有必要告诉每个人在值类型的类应该写成final。那只会很乏味。
这有一个修正后的版本,如上面所说忽略了设计和一些风格问题:
class complex { public: complex( double r = 0, double i = 0 ) : real{r}, imag{i} { } complex& operator+=( const complex& other ) { real += other.real; imag += other.imag; return *this; } complex& operator++() { ++real; return *this; } complex operator++( int ) { auto temp = *this; ++*this; return temp; } ostream& print( ostream& os ) const { return os << "(" << real << "," << imag << ")"; } private: double real, imag; }; complex operator+( complex lhs, const complex& rhs ) { lhs += rhs; return lhs; } ostream& operator<<( ostream& os, const complex& c ) { return c.print(os); }