Guru of the Week 条款12:控制流(Control Flow)

    技术2022-05-11  255

     

    GotW #12 Control Flow

    著者:Herb Sutter     

    翻译:kingofark

    [声明]:本文内容取自www.gotw.ca网站上的Guru of the Week栏目,其著作权归原著者本人所有。译者kingofark在未经原著者本人同意的情况下翻译本文。本翻译内容仅供自学和参考用,请所有阅读过本文的人不要擅自转载、传播本翻译内容;下载本翻译内容的人请在阅读浏览后,立即删除其备份。译者kingofark对违反上述两条原则的人不负任何责任。特此声明。

    Revision 1.0

    Guru of the Week 条款12:控制流(Control Flow

    难度:6 / 10

    (你到底对C++代码的执行顺序了解多少?用下面的问题来考查你的学识吧!)

     

    [问题]

    所谓“妖藏巨细(The devil is in the details.)。”,现在就请你从下面的(多少是有些故意设计的)代码中找出尽可能多的有关控制流(control flow)的毛病。

         #include <cassert>

        #include <iostream>     #include <typeinfo>     #include <string>     using namespace std;      //  下面的几句来自其它的头文件      char* itoa(int value, char* workArea, int radix );     extern int fileIdCounter;      //  使类的不变量检查自动化的辅助函数      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 )      //-------------------------------------------------     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 char[size_])         {             AINVARIANT_GUARD;         }          void Resize( size_t newSize ) {             AINVARIANT_GUARD;             char* oldBuffer = buffer_;             buffer_ = new char[newSize];             memset( buffer_, ' ', 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:         char*  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 a(20);         cout << a.PrintSizes() << endl;     }

      

    [解答]

    [“Lions and tigers and bears, oh my!”-- Dorothy]

    [“我的天哪,狮子、老虎还有狗熊!” 桃乐茜叫道。]

        #include <cassert>     #include <iostream>     #include <typeinfo>     #include <string>     using namespace std;     //  下面的几句来自其它的头文件     char* itoa(int value, char* workArea, int radix );     extern int fileIdCounter;

    全局变量的出现应该早已引起了我们的警觉,使我们特别留意那些企图在它被初始化之前就使用它的代码。在各翻译单元(translation units)之间的那些全局变量(包括类的静态变量)的初始化顺序并未被定义。

     

        //  使类的不变量检查自动化的辅助函数     template<class T>     inline void AAssert( T& p ) {         static int localFileId = ++fileIdCounter;

    啊哈,这里出问题了。如果编译器恰好是在初始化任何Aassert<T>::localFileId之前对fileIdCounter进行了初始化,那么还算好。否则的话,这里的数值将会是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 )

    使用这些辅助性的函数是个很有意思的主意,这样可以使一个类在函数调用的前后自动的进行不变量检查。只要简单的对其来一个AITypetypedef,然后再把“AINVARIANT_GUARD;”作为成员函数的第一条语句就可以了。本质上而言,这样并不完全是不好的。 

    然而在下面的代码中,这种做法就很不幸的变得一点都不有趣了。主要原因是Ainvariant隐藏了对assert()的调用,而当程序在non-debug模式下被建立(build)的时候,编译器会自动的删掉assert()。像编写下面这样的代码的程序员就很可能没有认识到这种对建立(build)模式的依赖性及其产生的相应的不同结果。

        //-------------------------------------------------     class Array : private ArrayBase, public Container {         typedef Array AIType;     public:         Array( size_t startingSize = 10 )           : Container( startingSize ),             ArrayBase( Container::GetType() ),

           这里的构造函数(constructor)的初始化表有两个潜在的错误。第一个错误或许不必称其为错误,但让其留在代码里面又会形成一个扑朔迷离的遮眼法(a red herring)。我们分两点说明这个错误:

    1.  如果GetType()是一个静态成员函数,或者是一个既不使用‘this’指针(即不使用任何数据成员)又不受构造操作的副作用(比如静态的使用计数)影响的成员函数,那么这里就仅仅只是有着不良的编码风格而已,仍然能正确的运行。

    2.  否则的话(一般来说是指GetType()是普通的非静态成员函数的情况),我们就有麻烦了。非virtual的基类会被从左到右按照它们被声明的顺序来初始化。因此在这里,ArrayBase会赶在Container之前先被初始化。不幸的是,这意味着企图使用一个尚未被初始化的Container subobject之成员。

     

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

        这第二个错误是无庸置疑的,因为实际上变量会以它们在类定义里面出现的顺序被初始化:

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

    像这样写,错误就很明显了。对new[]的调用会使buffer得到一个无法预测的大小——一般为0或者很大,这取决于编译器在调用构造函数(constructor)之前是否恰好将对象的内存区初始化为空(null)。无论如何,初始化分配的空间大小都几乎不可能为startingSize

     

            {             AINVARIANT_GUARD;         } 效率方面的小问题:在这里,Invariant()函数没有必要被调用两次(分别是在潜在的临时对象的构造过程和析构过程中)。当然这只是小问题, 不会引起大麻烦。           void Resize( size_t newSize ) {             AINVARIANT_GUARD;             char* oldBuffer = buffer_;             buffer_ = new char[newSize];             memset( buffer_, ' ', newSize );             copy( oldBuffer, oldBuffer+min(size_,newSize),                   buffer_ );             delete[] oldBuffer;             size_ = newSize;         }

    这里存在一个严重的控制流(control flow)方面的问题。我从未见人指出过这一点(如果你曾指出过的话,那么我真的很抱歉),而其实这是我故意设计的,就是想看看有没有人会指出来。 

    在叙述这一点之前,你可以再读一遍代码,看看自己到底能不能发现这个问题(提示:其实是显而易见的)。

                                    * * * * * * * * * * * * *

           好了,答案即:代码不是异常-安全的(exception-safe)。如果对new[]的调用导致抛出一个异常的话,那么不但当前的对象会处在一个无效的状态,而且原来的buffer还会出现内存泄漏的情况,因为所有指向它的指针都丢失了从而导致不能将其删除掉。 

    这个函数的问题说明了,迄今为止,几乎还没有程序员养成了编写异常-安全的(excepton-safe)代码的习惯——即使是在前不久的GotW条款中对异常安全性(exception safety)作了广泛的讨论之后!

     

            string PrintSizes() {

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

           其中,itoa()原型函数使用buf作为存放结果的地方(译注:意即这一次调用该函数时会覆盖上一次调用该函数时所设置的buf的内容,从而引起了潜在的顺序问题)。这段代码里也存在着控制流(control flow)方面的问题。我们无法预计最后那个返回语句中对表达式的求值顺序,因为对函数参数的操作顺序是没有明确规定的,其完全取决于特定的实现方案。(这里还要注意,内建(built-in)的operator+不存在这种问题,然而一旦你提供了自己的operator+版本,那么你的版本就会视为函数调用。) 

    最后那个返回语句所表现出来的问题的严重性在下面这个语句中得到了更好的展示(就算对operator+的调用是从左到右的):

         return

          operator+(         operator+(           operator+( string("size = "),                      itoa(size_,buf,10) ) ,           ", used = " ) ,  //译注:这里原文是", y = " ) ,应该是写错了         itoa(used_,buf,10) ); 这里我们假设size_值为10,used_值为5。如果最外面的operator+() 第一个参数先被求值的话,那么结果将会是正确的“size = 10,used = 5”, 因为第一个itoa()函数存放在buf里的结果会在第二个itoa()函数复用buf 之前就被读出使用。但如果最外面的operator+()的第二个参数先被求值的话 (例如在MSVC 4.X上就是这样),那么结果将会是错误的“size = 10, used = 10”, 因为外层的那个itoa()函数先被求值,但其结果(译注:即字符‘5’)会在被 使用之前就又被内层的那个itoa()函数毁掉了(译注:buf里面变成了‘10’)。           bool Invariant() {             if( used_ > 0.9*size_ ) Resize( 2*size_ );             return used_ <= size_;   Resize()的调用存在两个问题:  1.  这种情况下,程序压根儿就不会正常工作,因为如果条件判断为真,那么 Resize()会被调用,这又会导致立即再次调用Invarient();接着条件判断 仍然会为真,然后再调用Resize()这又会导致立即再次调用Invarient() 接着……你一定明白这是什么问题了(译注:这是一个无法终止的递归调用)。 2.  如果AAssert()的编写者出于效率方面的考虑把错误报告(error reporting 的代码删掉并取而代之以“assert(p->Invarient());”,那又会如何呢? 其结果只会使这里的代码变得更可悲,因为其在assert()调用中加入了会产生 副作用的代码。这意味着程序在debug mode和release mode两种不同的编译模式 下产生的可执行代码在执行时会具有不同的行为。即使没有上面第1点中说明的 问题,这也是很不好的,因为这就意味着Array对象会依据建立模式(build mode) 的不同而Resize不同的次数,并使软件测试人员从此过上人间地狱般的生活——当 他试图在一个以debug mode建立的程序里重现客户遇到的问题(译注:当然,客户 拿到的程序是以release mode建立并发布的)时,他得到的运行期内存映像之特征 与release mode下的运行期内存映像之特征是不一样的。[概要]:绝不要在对 assert()的掉用中加入有副作用的代码,并且总是确认递归过程肯定会终止。           }     private:         char*  buffer_;         size_t used_, size_;     };     int f( int& x, int y = x ) { return x += y; }

    那第二个设置缺省值的参数无论如何都不能算是一个合法的C++用法,在一个理想的编译器下应该编译不通过(尽管现实中有些编译器可以将其编译通过)。说这个用法不好还是因为编译器可以采用任意的顺序来对函数参数求值,y可能赶在x之前先被初始化。

     

        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 a(20);         cout << a.PrintSizes() << endl;     }

         也许桃乐茜在本条款开头说的话并不完全正确……下面这句话大概更接近真相:

    [“Parameters and globals and exceptions, oh my!”—Dorothy, after an intermediate C++ course]

    [“我的天哪,参数、全局变量还有异常处理!” 桃乐茜在进修了中级C++课程之后叫道。]

    (完)


    最新回复(0)