尽可能地使用const

    技术2022-05-11  68

    关于const的奇妙的事情就是它允许你指定一个语义约束——一个特别的不应该被修改的对象——并且编译器将执行这个约束。它允许你同编译器和其它程序员交流说一个值应该保持不变。每当这种情况时,你应确定地这样说,因为这样你就在确保这个约束不被违反上得到了你的编译器的帮助。

    const关键字非常通用。在类的外面,你能把它用在全局的或名字空间范围内的常量上,还可用在在文件中、函数中或代码块内被声明static的对象上。在类的里面,你能把它用在staticnon-static的数据成员上。对于指针,你能指定指针本身是否为const,指针所指数据是否为const,都是或都不是const

    char greeting[] = "Hello";

    char *p = greeting;                    // non-const pointer,

                                           // non-const data

    const char *p = greeting;              // non-const pointer,

                                           // const data

    char * const p = greeting;             // const pointer,

                                           // non-const data

    const char * const p = greeting;       // const pointer,

                                           // const data

        这项语法并不像看起来那样反复无常。如果const出现在星号左边,被指向的东西是常量;如果const出现在星号右边,指针本身是常量。如果const在星号两边都出现,被指向的东西和指针本身都是常量。

           当被指向的东西是常量时,一些程序员把const写在类型之前,另一些把它写在类型之后、星号之前。在意义上这两者没有区别,因此下面的函数使用相同的参数类型:

    void f1(const Widget *pw);         // f1 takes a pointer to a constant Widget object

    void f2(Widget const *pw);         // so does f2

    因为在实际的代码中两种形式都会存在,你应该使自己对两种形式都习惯。

    STL迭代器模仿指针,因此一个迭代器表现得很像一个T*指针。声明一个const迭代器就像声明一个const指针(就是说,声明一个T* const指针):迭代器不允许指向不同得东西,但它指向得东西却可以被修改。如果你想要一个指向不能被修改的东西的迭代器(就是说,一个const T*指针的STL对等物),即是你想要一个const_iterator

    std::vector<int> vec;

    ...

    const std::vector<int>::iterator iter =     // iter acts like a T* const

      vec.begin();

    *iter = 10;                                 // OK, changes what iter points to

    ++iter;                                     // error! iter is const

    std::vector<int>::const_iterator cIter =    //cIter acts like a const T*

      vec.begin();

    *cIter = 10;                                // error! *cIter is const

    ++cIter;                                    // fine, changes cIter

           一些最强大的对const的使用来自它对函数声明的应用。在一个函数声明中,const能引用函数返回值、独立的参数,并且,对于成员函数而言,能从整体上引用这个函数。

           使一个函数返回一个常量值经常使得在不放弃安全性和效率的前提下减小客户错误的影响范围成为可能。例如,考虑在第24条款中开发的用于有理数的operator*函数的声明:

    class Rational { ... };

    const Rational operator*(const Rational& lhs, const Rational& rhs);

    好多程序员斜视当他们最初看到这个声明时。为什么operator*的结果应该是一个const对象?因为如果不是的话,客户就可以像这样写下代码:

    Rational a, b, c;

    ...

     (a * b) = c;                            // invoke operator= on the result of a*b

    我不知道为什么有的程序员想给两个数的乘积赋值,但我知道好多程序员已经在没有被警告的情况下尝试这样做。它所需的全部是一行简单的打字(和一个能被隐式转换为bool的类型):

    if (a * b = c) ...                       // oops, meant to do a comparison!

        这些代码明显违法如果ab是内建类型。良好的用户自定义类型的一个特点是它们避免了无理由的同内建类型的不兼容性(见条款18),并且,允许赋值给两个数的乘积对我而言看起来相当无理由。声明operator*的返回值为const会阻止这个行为,这也是为什么应该这样做的理由。

             关于const参数没有什么特别的新东西——他们表现得就像局部const对象,并且你应该尽可能地使用他们。除非你需要能修改一个参数或局部对象,否则应确定地声明它为const。它花费你仅输入6个字母地努力,并且能使你避免讨厌的错误,就像“我想输入‘==’却意外地输入了‘=’”这个我们刚才看见的错误。

    const成员函数

    const用在成员函数上的目的是识别哪些成员函数可以在const对象上调用。这些成员函数很重要,因为两个原因。第一,它们使一个类的接口更容易被理解。懂得那些函数可以修改一个对象而哪些不能是很重要的。第二,它们使得使用const对象成为可能。这是写有效代码的一个重要方面,因为,正如条款20解释的那样,一个提高C++程序性能的基本方法就是用const引用传递对象。这项技术仅当存在用来操作可做为结果的const的对象的const成员函数时才可行。

    很多人没有注意到仅仅常量性不同的成员函数可以被重载的这个事实,但这是C++的一个重要特征。考虑一个表示文本块的类:

    class TextBlock {

    public:

      ...

      const char& operator[](std::size_t position) const   // operator[] for

      { return text[position]; }                                                // const objects

      char& operator[](std::size_t position)                        // operator[] for

      { return text[position]; }                                               // non-const objects

    private:

       std::string text;

    };

    TextBlock类的operator[]函数可以像这样使用:

    TextBlock tb("Hello");

    std::cout << tb[0];                   // calls non-const

                                                      // TextBlock::operator[]

    const TextBlock ctb("World");

    std::cout << ctb[0];                // calls const TextBlock::operator[]

            顺便一提的是,const对象因为传递const指针或const引用而经常出现在实际的程序里。上述ctb的例子是人为的。下面这个更真实些:

    void print(const TextBlock& ctb)       // in this function, ctb is const

    {

      std::cout << ctb[0];                             // calls const TextBlock::operator[]

      ...

    }

            重载operator[]并给不同版本以不同的返回类型,你能使constnon-constTextBlock对象分别得到处理:

    std::cout << tb[0];                    // fine — reading a

                                                        //non-const TextBlock

    tb[0] = 'x';                                   // fine — writing a

                                                        // non-const TextBlock

    std::cout << ctb[0];                 // fine — reading a

                                                       // const TextBlock

    ctb[0] = 'x';                                // error! — writing a

                                                       // const TextBlock

            注意这里的错误只和被调用的operator[]的返回类型有关;对operator[]调用的本身并没有错。这个错误源于想对const char&做赋值,因为这是从const版本的operator[]的返回类型。

            同样,注意non-constoperator[]的返回类型是一个char&——char就不行。如果operator[]简单地返回一个char,像这样地语句则不能编译:

    tb[0] = ‘x’;

            这是因为修改一个返回内建类型的返回值的函数的返回值是非法的。即使合法,C++按值返回对象(见条款20)的含义是tb.text[0]的一份拷贝被修改,而不是tb.text[0]本身,并且这不是你想要的行为。

            让我们休息一下看看哲学。一个const成员函数意味着什么?有两种流行的观点:位常量性(也称物理常量性)和逻辑常量性。

            位常量性阵营相信一个成员函数是const当且仅当它不修改该对象的任何数据成员(除了静态数据成员外),也就是说,如果它不修改该对象的任何位。关于位常量性的一个好处是它很容易检测到违反的情况:编译器只寻找对数据成员的赋值。事实上,位常量性正是C++对常量性的定义,并且一个const成员函数不被允许修改该对象的任何非静态数据成员。

            不幸的是,很多表现得不是很const的成员函数通过了位测试。具体地,一个修改指针所指东西的成员函数经常表现得不是const。但如果这个对象中仅含指针,则编译器不会抱怨。这将导致违反直觉的行为。例如,假设我们有一个像TextBlock的类用char*而不是string来存储自己的数据,因为它需要使用C的不理解string对象的API来交流:

    class CTextBlock {

    public:

      ...

      char& operator[](std::size_t position) const   // inappropriate (but bitwise

      { return pText[position]; }                                 // const) declaration of

                                                                                     // operator[]

    private:

      char *pText;

    };

            这个类不恰当地将operator[]声明为const成员函数,尽管该函数返回一个对象内部数据(一个在条款28中深入讨论的主题)的引用。把它放在一边并注意operator[]的实现没有在任何形式上修改pText。结果,编译器将乐意地为operator[]产生代码;它是,毕竟,位常量,并且这就是编译器要检查的全部。但看看它允许发生什么:

    const CTextBlock cctb("Hello");          // declare constant object

    char *pc = &cctb[0];                              // call the const operator[] to get a

                                                                       // pointer to cctb's data

    *pc = 'J';                                                   // cctb now has the value "Jello"

        当然,当你用一个特定值创建一个常量对象并仅在其上调用const成员函数时出现了错误,然而你仍然改变了它的值。     这导致了逻辑常量性的观点。这一哲学的追随者讨论说一个const成员函数可以改变对象的一些位,但仅仅以客户不能检测到的方式。例如,你的CTextBlock类可能想在不管什么时候被请求都缓存文本块长度:

    class CTextBlock {

    public:

      ...

      std::size_t length() const;

    private:

      char *pText;

      std::size_t textLength;            // last calculated length of textblock

      bool lengthIsValid;                 // whether length is currently valid

    };

    std::size_t CTextBlock::length() const

    {

      if (!lengthIsValid) {

        textLength = std::strlen(pText);  // error! can't assign to textLength

        lengthIsValid = true;                    // and lengthIsValid in a const

      }                                                        // member function

    return textLength;

    }

            这个length的实现当然不是位常量性——textLength和lengthIsValid都可能被修改——然而它看起来好像对const的CTextBlock对象应该有效。编译器不同意。他们坚持位常量性。怎么办?

           解决的方法很简单:利用C++关键字mutable。mutable把非静态数据成员从位常量性中解放出来。

    class CTextBlock {

    public:

      ...

      std::size_t length() const;

    private:

      char *pText;

      mutable std::size_t textLength;         // these data members may

      mutable bool lengthIsValid;              // always be modified, even in

    };                                                             // const member functions

    std::size_t CTextBlock::length() const

    {

      if (!lengthIsValid) {

        textLength = std::strlen(pText);      // now fine

        lengthIsValid = true;                        // also fine

      }

      return textLength;

    }

    避免在constnon-const成员函数间复制代码

           mutable对于“我想的不是位常量性”的问题是个不错的解决方案,但它不能解决所有与const有关的难题。例如,假设在TextBlock(和CTextBlock)中的operator[]不仅返回一个适当字符的引用,它还进行边界检查,登记访问信息,甚至可能做数据完整性和有效性检查。把所有这些放在constnon-constoperator[]函数中会产生这种畸形:

    class TextBlock { public:   ...   const char& operator[](std::size_t position) const   {     ...                                 // do bounds checking     ...                                 // log access data     ...                                 // verify data integrity     return text[position];   }   char& operator[](std::size_t position)   {     ...                                 // do bounds checking     ...                                 // log access data     ...                                 // verify data integrity     return text[position];   } private:    std::string text; };

           哎呀!你能说出代码副本,连同它的编译时间、维护以及代码膨胀令你感到的头痛吗?当然,把所有为了进行边界检查等等的代码移入一个单独的两种版本的operator[]都调用的成员函数(自然是私有的)是可能的,但你仍将复制调用该函数的代码并且你仍将复制renturn语句的代码。

           你真正想要做的是实现operator[]的功能一次并且使用两次。也就是,你想用一个版本的operator[]调用另一个版本。这样将使我们转换掉常量性。

           作为一个通用的规则,转换是一个不好的注意,我已经用整整一个条款来告诉你不要这样做(条款27),但复制代码也不是轻松的事。这种情况下,const版本的operator[]non-const版本的operator[]所做的完全一样,它只是要一个const的返回类型。在这种情况下,在返回值上转换掉常量性是安全的,因为不管谁调用non-constoperator[]必须首先有一个non-const的对象。否则他们不能调用一个non-const函数。因此让non-constoperator[]调用const版本是一个避免复制代码的安全方式,尽管它需要一个转换。下面是代码,但它可能在你阅读了随后的解释后会更清楚些:

    class TextBlock { public:   ...   const char& operator[](std::size_t position) const     // same as before   {     ...     ...     ...     return text[position];   }   char& operator[](std::size_t position)         // now just calls const op[]   {     return       const_cast<char&>(                         // cast away const on                                                  // op[]'s return type;         static_cast<const TextBlock&>(*this)     // add const to *this's type;           [position]                             // call const version of op[]       );   } ... };

           正如你所看到的,代码有两个转换,而不是一个。我们想让non-constoperaror[]调用const的,但如果,在non-constoperaror[]中,我们只是调用operaror[],我们将递归地调用自己。为了避免无限递归,我们不得不指定我们想调用的是constoperaror[],但没有直接这样做的方法。作为代替,我们将*this从它最初的类型TextBlock&转换为const TextBlock&。对,我们用了一个转换来增加const!因此我们有两个转换:一个给*this增加const(因此我们对operaror[]的调用将调用const版本),第二个从constoperaror[]的返回值去掉const

           增加const的转换只是强制进行一个安全的转换(从一个non-const对象到一个const对象),因此我们用了一个static_cast。去掉const的转换只有通过一个const_cast来实现,因此我们在这里其实没有选择。(从技术上说,我们是这样。一个C风格的转换也可以工作,但是,正如我在条款27解释的那样,这样的转换很少是正确的选择。如果你对static_castconst_cast不熟悉,条款27有一个总的看法)。

           在其它所有事之上的是,我们在这个例子中是在调用一个操作符,因此语法有点奇怪。这个结果不会赢得任何漂亮的争辩,但它有所期望的用根据const版本的operaor[]来实现non-const的版本来避免代码复制的效果。你唯一能决定的事情是实现这个目标是否值得用这个笨拙的语法,但是用一个const成员函数来实现它的non-const版本是的确值得了解的技术。

           更值得了解的是从相反方向去尝试做一件事情——用让const版本调用non-const版本来避免代码复制——不是你想做的事情。记住,一个const成员函数承诺从不改变它的对象的逻辑状态,但一个non-const成员函数不做这样的承诺。如果你准备从一个const函数中调用一个non-const函数,你将冒你承诺不修改的对象会被修改的险。这就是为什么让一个const成员函数调用一个non-const函数是错误的原因:该对象可能被修改。事实上,要让代码能编译,你不得不使用一个const_cast来去除*thisconst,一个麻烦的明显征兆。相反的调用顺序——我们以上所用的那个——是安全的:non-const成员函数可以对对象做任何它想做的事情,因此调用一个const成员函数没有强加任何冒险。这就是为什么在那种情况下一个在*this上的static_cast可以工作的原因:那里并没有与const有关的危险。

           正如我在本条款开始所注明的那样,const是一件奇妙的事情。在指针和迭代器上;在用指针、迭代器和引用所引用的对象上;在函数参数和返回类型上;在局部变量上;在成员函数上,const是一个强大的支持。尽可能地使用它。你将为曾你这样做而高兴。

    要记住的事情

    声明某事为const会帮助编译器来检测错误的使用。const能被应用到任何域内的对象上、函数参数和返回类型上、作为整体的成员函数上。

    编译器强制执行位常量性,但你应该用概念常量性编程。

    constnon-const成员函数拥有本质上相同的实现时,代码复制可以用让non-const版本调用const版本来避免。

     

    (本文译自Scott Meyers著《Effective C++,Third Edition》中的Item 3:Use const whenever possible一文)


    最新回复(0)