首页 > 代码库 > Exceptional C++: [Item 47. Control Flow] [条款47 控制流]

Exceptional C++: [Item 47. Control Flow] [条款47 控制流]

条款47控制流

难度:6

你到底有多了解C++代码的执行顺序呢?通过这个问题来测试一下你的知识。

“恶魔藏在细节里。”尽量指出下面(人为)代码的问题,请集中在控制流相关的问题上。

#include <cassert> 
#include <iostream>
#include <typeinfo>
#include <string>
using namespace std;

//  The following lines come from other header files.
//
char* itoa( int value, char* workArea, int radix );
extern int fileIdCounter;

//  Helpers to automate class invariant checking.
//
template<class T>
inline void AAssert( T& p )
{
  static int localFileId = ++fileIdCounter;
  if( !p.Invariant() )
  {
    cerr << "Invariant failed: file " << localFileId
         << ", " << typeid(p).name()
         << " at " << static_cast<void*>(&p) << endl;
    assert( false );
  }
}

template<class T>
class AInvariant
{
public:
  AInvariant( T& p ) : p_(p) { AAssert( p_ ); }
  ~AInvariant()              { AAssert( p_ ); }
private:
  T& p_;
};
#define AINVARIANT_GUARD AInvariant<AIType> invariantChecker( *this )

//-------------------------------------------------------------
template<class T>
class Array : private ArrayBase, public Container
{
  typedef Array AIType;
public:
  Array( size_t startingSize = 10 )
  : Container( startingSize ),
    ArrayBase( Container::GetType() ),
    used_(0),
    size_(startingSize),
    buffer_(new T[size_])
  {
    AINVARIANT_GUARD;
  }

  void Resize( size_t newSize )
  {
    AINVARIANT_GUARD;
    T* oldBuffer = buffer_;
    buffer_ = new T[newSize];
    copy( oldBuffer, oldBuffer+min(size_,newSize), buffer_ );
    delete[] oldBuffer;
    size_ = newSize;
  }

  string PrintSizes()
  {
    AINVARIANT_GUARD;
    char buf[30];
    return string("size = ") + itoa(size_,buf,10) +
           ", used = " + itoa(used_,buf,10);
  }

  bool Invariant()
  {
    if( used_ > 0.9*size_ ) Resize( 2*size_ );
    return used_ <= size_;
  }
private:
  T*     buffer_;
  size_t used_, size_;
};

int f( int& x, int y = x ) { return x += y; }
int g( int& x )            { return x /= 2; }

int main( int, char*[] )
{
  int i = 42;
  cout << "f(" << i << ") = " << f(i) << ", "
       << "g(" << i << ") = " << g(i) << endl;
  Array<char> a(20);
  cout << a.PrintSizes() << endl;
}

解答

“狮子,老虎和熊,天哪!”

--桃乐西

比起本条款中代码的问题,桃乐西没什么可抱怨的。让我们一行一行的看。

#include <cassert> 
#include <iostream>
#include <typeinfo>
#include <string>
using namespace std;

//  The following lines come from other header files.
//
char* itoa( int value, char* workArea, int radix );
extern int fileIdCounter;

全局变量的存在已经让我们担心客户端代码可能会在它初始化之前使用它。编译单元间全局变量(包括类静态变量)的初始化顺序是没有定义的。

方针:避免使用全局或静态对象。如果你必须使用一个全局或静态对象,一定要非常小心初始化顺序的规则。

//  Helpers to automate class invariant checking. 
//
template<class T>
inline void AAssert( T& p )
{
  static int localFileId = ++fileIdCounter;

啊哈!这里我们发现了一个问题。如果fileIdCounter的定义象下面这样:

int fileIdCounter = InitFileId();  // starts count at 100
假如编译器正好在初始化任何AAssert<T>::localFileId之前初始化fileIdCounter,那当然好,localFileId将得到预期的值。否则,被设值将基于fileIDCounter初始化前的值,也就是说,内置类型是0,然后localFileId将得到一个小于预期值100的值。

  if( !p.Invariant() ) 
  {
    cerr << "Invariant failed: file " << localFileId
         << ", " << typeid(p).name()
         << " at " << static_cast<void*>(&p) << endl;
    assert( false );
  }
}

template<class T>
class AInvariant
{
public:
  AInvariant( T& p ) : p_(p) { AAssert( p_ ); }
  ~AInvariant()              { AAssert( p_ ); }
private:
  T& p_;
};
#define AINVARIANT_GUARD AInvariant<AIType> invariantChecker( *this )

这些辅助工具有一个有趣的主意,任何客户端类想要在函数调用前后自动检查它的类不变式,只需写一个AIType的typedef,然后在成员函数的最开始写一句AINVARIANT_GUARD;。这本身没什么问题。

在如下的客户端代码中,这些主意不幸地走上了歧途。主要原因是AInvariant隐藏起了对assert()的调用,而当以非debug模式进行编译时,这些调用将被编译器自动删除。下面的客户端代码很可能由一个没有注意到编译依赖性及其带来的副作用变化的程序员所写。

//------------------------------------------------------------- 
template<class T>
class Array : private ArrayBase, public Container
{
  typedef Array AIType;
public:
  Array( size_t startingSize = 10 )
  : Container( startingSize ),
    ArrayBase( Container::GetType() ),
构造函数的初始化列表有两个潜在错误。第一个不至于构成一个错误,但是有点混淆是非。
  1. 如果GetType()是一个静态成员函数,或者是一个不用this指针(也就是说,不使用数据成员),并且不依赖于任何构造过程副作用(比如静态使用计数)的成员函数,那么这不过是糟糕的编程风格,但是可以正确运行。
  2. 否则(基本上,如果GetType()是一个普通的非静态成员函数),我们就有麻烦了。非虚基类以他们声明时从左到右的顺序初始化,所以ArrayBase将在Container之前被初始化。不幸的是,这意味着我们正在试图使用一个尚未初始化的Container基类子对象的成员。
方针:保持构造函数初始化列表中基类的顺序与类定义中的顺序一致。

used_(0), 
size_(startingSize),
buffer_(new T[size_])

这是一个严重错误,因为变量实际上将以它们稍后出现在类定义中的顺序被初始化。

buffer_(new T[size_]) 
used_(0),
size_(startingSize),

这样写错误就很明显了。调用new[]将创建一个未知大小的缓冲区,通常是0或者一个很大的值,依赖于编译器是否会在调用构造函数之前将对象内存初始化为null。无论如何,初始分配不太可能准备好正好实际会使用的startingSize个字节。

方针:保持构造函数初始化列表中数据成员的顺序与类定义中的顺序一致。

{ 
  AINVARIANT_GUARD;
}

我们有个小小的效率问题:Invariant()将被不必要地调用两次,分别在隐藏临时变量的构造中和析构中。不过这是个小毛病,不能算是一个真正的问题。

void Resize( size_t newSize ) 
{
  AINVARIANT_GUARD;
  T* oldBuffer = buffer_;
  buffer_ = new T[newSize];
  copy( oldBuffer, oldBuffer+min(size_,newSize), buffer_ );
  delete[] oldBuffer;
  size_ = newSize;
}

这里有一个控制流问题。在继续往下读之前,请再次检查一下这个函数,看看你能不能指出这个控制流问题(提示:很明显)。

答案是:这个函数不是异常安全的。如果调用new[]抛出bad_alloc异常,一切都好:这种情况下没有资源泄露。然而,如果T的拷贝构造函数抛出了一个异常(在copy()操作的过程中),不光当前对象处于无效状态,原来的缓冲区将发生资源泄露,因为指向它的指针已丢失,导致永远无法被删除。

这个函数的要点是要展示,很少有程序员养成了编写异常安全代码的习惯。

方针:永远努力编写异常安全代码。永远构造代码使得即使发生异常,资源也会被正确的释放,并且数据处于一致的状态。

string PrintSizes() 
{
  AINVARIANT_GUARD;
  char buf[30];
  return string("size = ") + itoa(size_,buf,10) +
             ", used = " + itoa(used_,buf,10) ; 
}

itoa()函数的原型使用传入的缓冲区作为数据缓冲。然后,这里有一个控制流问题。我们无法预测最后一行表达式的执行顺序,因为函数参数的求值顺序是没有定义且依赖于实现的。(书生注:内置类型的运算符操作不存在这个问题。)

最后一行实际上相当于下面的代码,因为operator+()仍然是从左到右调用的:

return 
  operator+(
    operator+(
      operator+( string("size = "),
                 itoa(size_,buf,10) ) ,
      ", used = " ) ,
    itoa(used_,buf,10) );

假设size_是10,used_是5。那么,如果外层operator+()的第一个参数被首先求值,输出应该是正确的"size = 10,used = 5",因为在第二个itoa()使用同一个缓冲区之前,第一个itoa()的结果被使用并保存于一个临时string中。如果外层operator+()的第二个参数被首先求值(某流行编译器也确实是这么做的),输出将是不正确的"size = 10,used = 10",因为外层itoa()被首先执行,然后内层itoa()将会截断外层itoa()的结果,在任一方的结果被使用之前。

常见错误:永远不要写依赖于函数参数求值顺序的代码。

bool Invariant() 
{
  if( used_ > 0.9*size_ ) ; Resize( 2*size_ ) ;
  return used_ <= size_;
}

Resize()的调用有两个问题:

  1. 在这种情况下,程序根本就无法运行。因为如果if条件为true,Resize()将会被调用,并立即再次调用Invariant(),然后发现条件仍然为true并再次调用Resize(),然后。。。你懂了吧。
  2. 假如为了效率,AAssert()的作者决定删除错误报告并只写"assert( p->Invariant() );",会怎么样?那么客户端代码会变成悲剧,因为附带副作用的代码被置于assert()的调用中。这意味着程序行为在debug模式编译与release模式编译时会不一样。即使没有第一个问题,这也是糟糕的事情,因为这意味着Array对象调整缓冲区大小的次数会不一样,取决于编译模式。这会使测试者的生活受尽折磨,当他们试图在拥有不同运行时内存映像特征的debug编译模式下重现客户的问题时。

结果就是:永远不要在assert()(及类似东西)的调用中编写带有副作用的代码,并确保你的递归会结束。

private: 
  T*     buffer_;
  size_t used_, size_;
};

int f( int& x, int y = x ) { return x += y; }

第二个参数的缺省值至少不是合法的C++代码,所以在一个遵守规则的编译器上将无法编译(尽管某些系统会接受(书生注:但是还是会有函数参数求值顺序的问题))。为了能继续讨论,假设y的缺省值是1。

int g( int& x )            { return x /= 2; } 

int main( int, char*[] )
{
  int i = 42;
  cout << "f(" << i << ") = " << f(i) << ", "
       << "g(" << i << ") = " << g(i) << endl;

这里我们再次碰到了参数求值顺序问题。因为f(i)和g(i)的执行顺序并不确定(以及,两个对i自身求值的顺序),打印结果很可能不正确。一个例子是MSVC的"f(22) = 22,g(21) = 21",这意味着编译器很可能以从右到左的顺序对所有函数参数进行求值。

但是结果不是错误的吗?不,编译器是正确的。并且其他编译器可能打印出其它结果并且也都是正确的,因为编程者正依赖于一些C++中未定义的东西。

常见错误:永远不要写依赖于函数参数求值顺序的代码。

  Array<char> a(20); 
  cout << a.PrintSizes() << endl;
}

这应该打印出"size = 20, used = 0",但是由于已经讨论过的PrintSizes()中的bug,某些流行编译器会打印出"size = 20, used = 20",这明显是不对的。

有关动物园里的动物桃乐西说的也许不太对,下面这句话可能更准确:

“参数,全局变量和异常,天哪!”

--桃乐西,中级C++课程后