[译]GotW #6b Const-Correctness, Part 2
const和mutable对于书写安全代码来说是个很有利的工具,坚持使用它们。
Problem
Guru Question
在下面代码中,在只要合适的情况下,对const进行增加和删除(包括一些微小的变化和一些相关的关键字)。注意:不要注释或者改变程序的结构。这个程序只作为演示用途。
另外:程序的哪些地方是由于错误地使用const而导致的未定义行为或不可编译?
class polygon { public: polygon() : area{-1} {} void add_point( const point pt ) { area = -1; points.push_back(pt); } point get_point( const int i ) { return points[i]; } int get_num_points() { return points.size(); } double get_area() { if( area < 0 ) // if not yet calculated and cached calc_area(); // calculate now return area; } private: void calc_area() { area = 0; vector<point>::iterator i; for( i = begin(points); i != end(points); ++i ) area += /* some work using *i */; } vector<point> points; double area; }; polygon operator+( polygon& lhs, polygon& rhs ) { auto ret = lhs; auto last = rhs.get_num_points(); for( auto i = 0; i < last; ++i ) // concatenate ret.add_point( rhs.get_point(i) ); return ret; } void f( const polygon& poly ) { const_cast<polygon&>(poly).add_point( {0,0} ); } void g( polygon& const poly ) { poly.add_point( {1,1} ); } void h( polygon* const poly ) { poly->add_point( {2,2} ); } int main() { polygon poly; const polygon cpoly; f(poly); f(cpoly); g(poly); h(&poly); }
Stop and thinking….
Solution
当我提出这类问题的时候,我发现大多数人认为这个问题很容易,并且通常解决的只是一般的const问题。但是这里面有很多细微的差别我们应该知道,所有有了这篇blog
1.point对象按值传递,因此这里声明为const有一点点好处
void add_point( const point pt )
在这种特殊情况下,因为函数定义为inline,这里的const值参数(value parameter)就变得有意义了。这是因为inline函数的声明和定义是在同一处,否则,const值参数只应该出现在定义中,而不是声明中。让我们来看看为什么。
在函数声明中,往值参数中添加const对于函数来说是无关重要的,它对于调用者来说毫无意义且常常会起到迷惑作用。对于编译器来说,函数的签名不管是否在值参数前加入const都是相同的。
// value parameter: top-level const is not part of function signature int f( int ); int f( const int ); // redeclares f(int): this is the same function // non-value parameter: top-level const is part of function signature int g( int& ); int g( const int& ); // overloads g(int&): these are two functions
在值参数前加const的确会影响到它在函数体内的实际定义。记住,在函数体内,形参只是第一组局部变量。因此在值参数前加const仅仅意味着在函数内不能修改这个局部变量,这个只发生在参数上。下面是一个例子。
int f( int ); // declaration: no const int f( const int i ) { // definition: use const to express "read-only" vector<int> v; v.push_back(i); // ok, only reads from i i = 42; // error, attempts to modify i }
Guideline:在向前声明一个函数时,不要再传值参数前加入const。你可以在定义处加上const来表达一个只读参数。
2.get_point和get_num_points应该是const
point get_point( const int i ) { return points[i]; } int get_num_points() { return points.size(); }
以上函数应该被标识为const,因为他们没有改变对象的状态。
3.get_area应该是const
double get_area() { if( area < 0 ) // if not yet calculated and cached calc_area(); // calculate now return area; }
尽管这个函数在内部修改了对象的内部状态,我们也应该考虑将它标识为const,为什么?因为这个函数没有修改这个对象的可观察状态(observable state),我们只是在这做了写缓存动作,这只是内部的一些实现细节。这个对象在逻辑上依然是const,尽管它在物理上(physically)不是。
4.根据3,calc_area也应该是const
void calc_area() { area = 0; vector<point>::iterator i; for( i = begin(points); i != end(points); ++i ) area += /* some work using *i */; }
一旦我们把get_area标识为const,这个私有的辅助函数也应该是const的,反过来说,一旦将这个函数标识为const,编译器就会告知你同样应在成员变量area上做出改变:
· 声明为mutable,这样它在const函数中就具有可写性(writable)
· 使用mutex或使之为atomic<>来同步,这样就具有并发安全性,像GotW #6a中讨论的那样。
5.同样,calc_area应该使用const_iterator
迭代器不应该改变points集合的状态,因此它应该是const_iterator。如果我们将calc_area标识为const成员函数的话,那我们无论如何都会做出这个改变。但是有一点要注意的是,如果我们在for中为迭代器使用auto的话,那么我们在这个上可以完全不做改变。当我们在cal_area内做for循环时,我们应该优先使用range-based for循环,同样包括auto.
组合上述所说,我们得到了下面的代码:
for( auto& pt : points ) area += /* some work using pt */;
Guidline: 优先使用auto来声明变量。
Guideline: 当要顺序访问集合元素时,优先使用rang-based for循环。
6.area应该是mutable和同步的
double area;
像上述所说,联合其他内部的变化,这个内部缓存变量area应该是mutable的,这样就可以在const成员函数中被安全和正确地使用,同时因为它是潜在的共享变量,那么就可能被多个const操作并发执行,因此它必须是同步的,使用mutex或使之为atomic。
额外提问:在继续阅读之前,它应该是:使用mutex来保护,还是使之为atomic<double>?
你有考虑过吗?我们继续...
上述两者都行,但是使用mutex对于单个变量来说有点过度(overkill)。
选项1是使用mutex,可能很快成为标准的"mutable mutex mutables"模式
// Option 1: Use a mutex double get_area() const { auto lock = unique_lock<mutex>{mutables}; if( area < 0 ) // if not yet calculated and cached calc_area(); // calculate now return area; } private: // ... mutable mutex mutables; // canonical pattern: mutex that mutable double area; // covers all mutable members
如果在未来要增加更多的数据成员的话,选项1会表现的不错。如果你在未来增加更多的使用了area变量的const成员函数的话,那么这个选项就变得很具有入侵性且变得不那么好了。因为在const成员函数内部应该在使用area之前在mutex请求锁。
选项2只是将double变成mutable atomic<double>。这个是很吸引人的,因为polygon的"mutable"部分只是一个单一变量。它能达到要求,但是你必须小心,因为这不是唯一必要的改变,原因有二:
· 次要原因是atomic<double>不支持+=操作。因此我们只是改变area的类型的话,calc_area是不会编译通过的。这有个变通方案,但也导致了主要原因。
· 主要原因是,因为calc_area是个组合操作,且必须能安全运行在多线程并发的情况下,我们必须重构calc_area函数,让它能够安全地并发执行。特别是它不应该执行完一次操作立马更新area,同时要确保多个并发竞争跟新area不会引起覆盖导致写入的值丢失。
有几个方法来达到上述要求,但是最简单的可能是在并发调用calc_area的情况下允许良性的冗余再计算。因为它不可能比阻塞并发调用(无论如何都必须等待)更差。
// Option 2: Use an atomic void calc_area() const { auto tmp = 0.0; // do all the work off to the side for( auto& pt : points ) tmp += /* some work using pt */; area = tmp; // then commit with a single write } private: // ... mutable atomic<double> area;
需要注意的是,调用calc_area的并发const操作依然会重叠和覆盖相互间的结果。但它是良性的,因为这些操作是并发的const操作,因此它们全部计算相同的值。同样,在并发的calc_area调用的循环中使用共享points变量,这会使得我们考虑检查它不会导致缓存竞争,因为这些都是读操作,所以不会。
7.operator+的rhs参数应该是const引用
polygon operator+( polygon& lhs, polygon& rhs ) {
rhs参数应该是const引用。
Guideline:如果你只是准备进行读取(而不是拷贝),那么优先使用只读参数,通过const&。
对于lhs:
8.operator+的lhs应该是传值
这个关键部分是我们无论如何都要对它进行拷贝:
auto ret = lhs;
当你处在“无论如何都要对一个只读参数进行拷贝”的特殊情况下,有几种方式可以接受这样的参数,我会在其他GotW中详细讨论其中的细节。但是对于现在的情况来说,不需要考虑的太多,简单地使用传值就足够了。其中有些优点我们已经在GotW #4中讨论过了。
· 如果调用方传入一个命名的polygon对象(一个左值),这不会有区别。传const引用紧随其后是一个显式的拷贝,传值将会执行一次拷贝
· 如果调用方传入的是一个临时polygon对象(一个右值),编译器会自动地移动构造(move-constructs)lhs,对于一些小的类型来说可能不会有太大区别,比如polygon,但是对于其他类型来说却是相对“便宜”的
Guideline: 如果无论如何都需要对参数进行拷贝,优先使用传值参数。因为它可以从rvalue参数进行移动操作。
9.在operator+中,last应该是const
auto last = rhs.get_num_points(); for( auto i = 0; i < last; ++i ) // concatenate ret.add_point( rhs.get_point(i) ); return ret; }
因为last不应该被改变,所以可是使之为const
Guideline:如果变量不会被改变,那么优先选择使这些变量为const,包括局部变量。
顺便说一下,一旦我们把rhs改变成const引用,我们也能明白为什么get_point变为const成员函数的另一个原因。
10.f的const_cast可能会导致未定义行为
void f( const polygon& poly ) { const_cast<polygon&>(poly).add_point( {0,0} ); }
如果引用的对象声明为const的话,那么const_cast的结果是未定义的。就像在f(cpoly)这种情况。
这个参数不是真正的const,所以没有声明为const,接着试图去修改它。这是在欺骗编译器,可能对于调用者来说没有关系,但是个坏主意。
11.g的const是非法且无用的
void g( polygon& const poly ) { poly.add_point( {1,1} ); }
这个const是非法的:不能直接将const应用在引用本身,除了引用本身已经是const,因为它们不能不能被复位去引用到另一个对象。
void h( polygon* const poly ) { poly->add_point( {2,2} ); }
h的const仅仅只是确保在h函数体内不会修改指针。和add_pont与get_point的const参数是一样的。
12.检查主程序
int main() { polygon poly; const polygon cpoly; f(poly);
没问题。
f(cpoly);
就像上面说的那样,当f试图去擦除参数的常量性后修改其值会导致未定义的结果。
g(poly);
没问题。
h(&poly);
没问题。
Summary
下面是一个修改后的版本。不要试图去修改任何的差的代码风格。因为现在修改成了atomic成员,它是不可拷贝的(copyable),所以现在提供了一个copy和move操作。
class polygon { public: polygon() : area{-1} {} polygon( const polygon& other ) : points{other.points}, area{-1} { } polygon( polygon&& other ) : points{move(other.points)}, area{other.area.load()} { other.area = -1; } polygon& operator=( const polygon& other ) { points = other.points; area = -1; return *this; } polygon& operator=( polygon&& other ) { points = move(other.points); area = other.area.load(); other.area = -1; return *this; } void add_point( point pt ) { area = -1; points.push_back(pt); } point get_point( int i ) const { return points[i]; } int get_num_points() const { return points.size(); } double get_area() const { if( area < 0 ) // if not yet calculated and cached calc_area(); // calculate now return area; } private: void calc_area() const { auto tmp = 0.0; for( auto& pt : points ) tmp += /* some work using pt */; area = tmp; } vector<point> points; mutable atomic<double> area; }; polygon operator+( polygon lhs, const polygon& rhs ) { const auto last = rhs.get_num_points(); for( auto i = 0; i < last; ++i ) // concatenate lhs.add_point( rhs.get_point(i) ); return lhs; } void f( polygon& poly ) { poly.add_point( {0,0} ); } void g( polygon& poly ) { poly.add_point( {1,1} ); } void h( polygon* poly ) { poly->add_point( {2,2} ); } int main() { auto poly = polygon{}; f(poly); g(poly); h(&poly); }
原文链接:http://herbsutter.com/2013/05/28/gotw-6b-solution-const-correctness-part-2/