返回

[Effective C++ 笔记]03.尽可能使用 const

简介

本篇是我在阅读 Effective C++ 中的第三篇笔记。本条款中介绍了 const 在实际应用中的几种推荐做法。

  • 将某些东西声明为 const 可帮助编译器侦测出错误用法。const 可被施加于任何作用域的对象、函数参数、函数返回类型、成员函数本体;
  • 编译器强制实施 bitwise constness,但我们编写程序时应该使用”概念上的常量性“(conceptual constness),而不是简单让其通过编译器检查
  • constnon-const 成员函数有着实质等价的实现,令 non-const 版本调用 const 版本避免代码重复。

const 和变量

const 运行程序员指定一个语义约束,即规定某值(变量值或者指针值等等)应该保持不变,而编译器则强制实施这项约束。const 的用法非常灵活:

  • 可以用在 class 外部修饰 global 或者 namespace 作用域中的常量(见条款 02 );
  • 也可以修饰文件、函数、或区块作用域(block scope)中被声明为 static 的对象;
  • 还可以修饰 class 内部的 static 和 non-static 的成员变量;
  • 对于指针,可以令指针自身,指针所指对象,或者两者都为 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* 的右边,即指针本身为常量,不可改变,反之亦然。这意味着:const char* pchar const * p 同样都是令指针本身为 const

const 和 STL

STL 中的迭代器(iterator)也是基于指针构造出来的,所以迭代器的功能和一个 T* 指针类似。这里注意一下,如果我们声明一个迭代器为 const 的话,本质上相当于声明了一个 T* const,实际上是规定了迭代器指针本身不可改变。而如果我们想令迭代器所指对象不可改变的话,我们需要使用 STL 中的 const_iterator,见以下例子:

std::vector<int> testVec;
...
const std::vector<int>::iterator iter = testVec.begin();    // 迭代器不可改变,迭代器所指对象可改变
iter = testVec.end();                                       // 失败!迭代器本身不可改变
*iter = 10;                                                 // 没问题!
std::vector<int>::const_iterator cIter = testVec.begin();   // 迭代器可改变,迭代器所指对象不可改变
++cIter;                                                    // 没问题!
*cIter = 10;                                                // 失败!迭代器所指对象不可改变

const 和函数

上面介绍的只是 const 的一些基本用法,除此之外我们还需要重点关注 const 作用于函数声明时的不同用法。

const 用于函数返回值

const 用于函数返回值指明返回的变量(对象)不可改变。这句话听上去感觉没什么,但是如果使用得当的话,能够很大程度地避免程序员写错误的代码,节省 debug 时间。首先我们考虑以下例子:

// 一个整数类
class Integer {  
public:
    int m_value;
    Integer(int value):m_value(value) {}
    Integer() {}
    operator bool() const { return m_value != 0; } // 整数可以转为布尔值
};

// 整数类的乘法
Integer operator* (const Integer & lhs, const Integer & rhs) {
    int product = lhs.m_value * rhs.m_value;
    return Integer(product);
}

Integer a(1), b(2), c(4);
// 判断 a * b 是否等于 c
if (a * b = c) {
    std::cout << "a * b = c" << std::endl;
}
else {
    std::cout << "a * b != c" << std::endl;
}

观察以上例子,我们有一个整数类并为其重载了一个 * 操作符来实现两整数对象的乘法。然后我们有三个整数对象 a = 1b = 2c = 4。我们想判断 a * b 是否等于 c,但是运行上面代码会惊讶地发现程序输出:a * b = c !问题出在哪里呢?相信不少人一下子就看出来了在 if 语句中表示相等的 == 写成了 =,于是我们相当于c 赋值给了 a * b 的结果中,而由于我们的整数类可以被转化为布尔值,该表达式则将返回了 true 于是输出了错误的结果。而因为编译器没有报错,所以如果这条指令夹在很多函数当中,我们很难一下子定位到具体发生错误的位置。而 (a * b) = c 这种我们在正常情况下不可能会写出来的语句之所以能成功执行的关键就在于我们没有限定 operator* 的返回结果为 const。假如我们将其声明了 const 如下:

...
const Integer operator* (const Integer & lhs, const Integer & rhs) {
    ...
}
...

当我们编译时,编译器会在 if ( a * b = c) 这行报错: error: passing ‘const Integer’ as ‘this’ argument discards qualifiers [-fpermissive],提示我们赋值操作不能执行,如此大大减少我们的 debug 时间。通过 const 声明返回值,可以避免我们出现上面例子中的错误。

同样如果我们不会在函数当中改变传入参数的话,我们也应该尽量将其声明为 const,不止是只针对传入的是指针或者引用的情况。理由同样也是避免我们犯了类似将 == 写成 = 这种错误。

const 用于成员函数

作用

在实际工作中我们将 const 实施于成员函数的目的主要有两点理由:

  • const 使得 class 的借口更加容易被理解,我们能比较清楚地知道哪个函数可以改动对象内容而哪个函数不可以
  • 基于上一点原因,const 成员函数的存在使得我们可以对 const 对象进行操作。这一点非常重要,因为在函数参数传递中,很大程度上我们都还采用 pass by reference to const 的方式,这一点会在后面的条款中提及。因此,可以通过 const 成员函数来进行 const 对象的操作(不涉及修改)非常关键。

const 在成员函数的用法中有一个很重要的特性,即:两个函数即便就是常量性不同(一个是 const 成员函数,一个是 non-const 成员函数)也是可以被重载的。这个特性在某些情况下非常地有用,比如考虑以下例子(代码见 item04 ):

class TextBlock {
public:
    ...

    // const version of operator []
    const char& operator[](std::size_t position) const { return m_text[position]; }

    // non-const version of operator []
    char& operator[](std::size_t position) { return m_text[position]; }

private:
    std::string m_text;
};

上面的 TextBlock 声明了一个文本块类,并重载了操作符 [],注意这里我们重载了 constnon-const 两个版本的方法,这两个函数可以作用如下:

TextBlock tb("Hello");
std::cout << tb[0] << std::endl;    // tb[0] changeable
tb[0] = 'h';                        // works

const TextBlock ctb("Hello");
std::cout << ctb[0] << std::endl;   // ctb[0] non-changeable
ctb[0] = 'h';                       // 编译失败!error: assignment of read-only location ‘ctb.TextBlock::operator[](0)’

当然这样子的例子可能看上去比较普通,但是其用处可能非常广泛!考虑以下函数:

void print(const TextBlock& block) {

    std::cout << block[0] << std::endl; // 如果没有 const 重载的成员函数,这里会编译失败!

}

上面的函数接受一个 const TextBlock 对象并输出第一个字符,注意这样子的参数传递方式(pass by reference to const)正如之前所说的应用非常普遍,大部分情况下也是我们推荐的做法(当函数不涉及对对象的修改时)。但是如果我们没有重载一个 const 版本的话 print 编译会不通过,因为 non-const 版本的操作符 [] 函数返回的是一个对成员变量的引用因此是可以被修改的。当然某些情况下我们可以在 non-const 成员函数中返回一个 const 的引用或者干脆返回一个变量;但是对于上例中的情况下不可行,因为某些情况下我们可能会需要通过 [] 操作符来修改文本块(类似于 tb[0] = 'h' 这样子的操作)。由此可以看出 const 的成员函数有着非常实用的功能。

注意事项

这里我们要明确一点:const 成员函数意味着什么?这里有两种观点:

  • bitwise constness: 这种观点认为成员函数不应该修改任何成员变量(static 除外),即对于一个对象来说,在函数内不修改它的任何一个 bit。这样判定的好处是编译器在判断是否为 const 只需要寻找成员变量的赋值动作即可。同时这也是 C++ 中对于 const 定义。因此,const 成员函数不该修改对象内任何 non-static 成员变量。
  • logical constness: 当我们考虑一个例子,某个类中包含了一个指针成员变量( T* )指向外部变量(T)。从逻辑上来说,const 成员函数同样不应该修改该指针所指对象,但是符合上述 bitwise constness 标准的函数却不一定符合 logical constness。考虑以下例子 (代码见 item05 ):
class CTextBlock {
public:
    ...

    // 符合 bitwise conestness 原则,但实际上 pText 指向的字符串是可以被改变的!
    char& operator[](std::size_t position) const { return pText[position]; }

private:
    char* pText;
};

这个类中对操作符 [] 的重载符合 bitwise 原则(函数内没有对成员变量 pText 进行修改或输出引用),因此声明为 const 可行。但是很容易可以注意到,通过这个函数我们不可以对 pText 进行修改,但是对它指向的对象却是可以的!而大部分情况下我们声明该变量为 const 不会希望我们可以通过该对象修改了其内部指针所指内容,见以下例子:

const CTextBlock block("Hello");    // pText -> "Hello"
block[0] = 'h';             // works! which it shouldn't

因此,logical constness 的支持者认为:一个 const 成员函数允许修改它处理的对象中的某些 bits,只要在客户端侦测不出来的情况(对于使用者而言,能体现出 constness)即可。例如以下例子 (代码见 item05 ):

class CTextBlock {
public:
    ...

    // 编译失败! 因为对成员变量进行操作
    std::size_t length() const {
        if (!lengthIsValid) {
            textLength = std::strlen(pText);
            lengthIsValid = true;
        }

        return textLength;
    }

private:
    char* pText;
    std::size_t textLength;         // 最近一次计算的文本区块长度
    bool lengthIsValid;             // 目前长度是否有效
};

上述类中的 length() 从使用者的角度来看对于使用 const CTextBlock 对象没有问题(不关注内部实现),符合 logical constness。但是由于其不符合 bitwise constness,肯定是无法编译的。对于这个情况,C++ 给我们提供了一个解决方法:使用关键字 mutable 来释放掉 non-static 成员变量中的 bitwise constness 约束,如下所示:

class CTextBlock {
public:
    ...

    std::size_t length() const {
        if (!lengthIsValid) {
            textLength = std::strlen(pText);    // 即使在 const 成员函数内也能被修改
            lengthIsValid = true;
        }

        return textLength;
    }

private:
    char* pText;
    mutable std::size_t textLength;         // 最近一次计算的文本区块长度
    mutable bool lengthIsValid;             // 目前长度是否有效
};

const 和 non-const 成员函数避免重复

在上述例子中,我们可以通过 mutable 来解决部分由于强制 bitwise constness 的问题,但在某些情况下这个方法并不可取。考虑以下例子:TextBlock 中的 [] 不仅返回一个指向某字符的 reference,同时还执行边界检验(bounds checking),日志访问信息(logged access info) 甚至还有数据完善性检验。并且将所有这些函数同时放入 constnon-const[] 操作符中,会导致出现下面的情况:

class TextBlock {
public:
    ...
    const char& operator[] (std::size_t position) const {
        ... // 边界检验
        ... // 日志数据访问
        ... // 检验数据完整性
        return text[position];
    }

    char& operator[] (std::size_t position) {
        ... // 边界检验
        ... // 日志数据访问
        ... // 检验数据完整性
        return text[position];
    }
};
private:
    std::string text[position];

上面的例子中,产生了大量的代码重复并且会导致后续的代码维护,编译开销的增长。当然我们可以选择将那些重复代码写在多个 private 函数里并在两个版本的 [] 方法中调用他们,但是这样不可避免的还是会有多次重复的调用及返回。因此,针对这种情况,我们应该做的是实现一次 [] 操作符的功能并使用两次,即使用一个其中一个 [] 方法来调用另一个,因此我们需要采取常量性转移 (cast away constness)。

通常来说,转型(casting)不是一个好的方法,在后面的条款中会具体讨论。但是在这种例子下,产生代码重复同样不是一个好做法。在这个例子中,const operator [] 做了所有 non-const 版本所有需要的一切,唯一区别只在于返回类型多了一个 const 资格修饰。这种情况下将返回值的 const 是安全的,因为在调用 non-const 之前首先需要有一个 non-const 的对象。转型的过程如下所示:

class TextBlock {
public:
    ...

    // const 版本
    const char& operator[] (std::size_t position) const {

        // 边界检查、日志数据访问、检验数据完整性等等...

        return text[position];
    }

    // non-const 版本,只调用了 const 版本方法
    char& operator[] (std::size_t position) {
        return const_cast<char&>(                           // 将 op[] 返回值的 const 转移
            static_cast<const TextBlock&>(*this)[position]  // 为*this 加上 const 以调用 const op[]
        );
    }
private:
    std::string text;
};

在上述例子中,我们采取了两次转型而非一次。原因在于我们想让 non-const operator[] 调用 const operator[];但如果我们不先将 (*this)TextBlock& 转为 const TextBlock& 而直接调用 operator[],它将递归调用自身,并且没有程序出口。而由于 C++ 没有直接语法可以明确调用函的数版本,所以我们需要先通过转型将其转为 const, 再对 const operator[] 中的返回值去除 const。当然,由于本例中用的函数是操作符重载,所以语法看上去有点奇怪,但是它确实实现了我们想要的功能。因此我们需要了解 运用 const 成员函数实现出其 non-const 函数 这一方法。

相反,令 const 函数调用其 non-const 版本来避免重复并不是一个好方法。因为 const 函数承诺不对对象进行修改,而 non-const 没有这样的保证。因此如果我们令 const 函数调用 non-const 版本,有一定风险会使对象进行修改。(如果要这么做,我们必须通过 const_cast 来将 (*this)const 性质解除来通过编译器的检查)。

总结

总而言之,const 可以作用于各种情况:变量和迭代器;指针、迭代器以及reference所指的对象;函数参数和返回类型;local 变量;成员函数等等。本条款可以概括为:

  • 将某些东西声明为 const 可帮助编译器侦测出错误用法。const 可被施加于任何作用域的对象、函数参数、函数返回类型、成员函数本体;
  • 编译器强制实施 bitwise constness,但我们编写程序时应该使用”概念上的常量性“(conceptual constness),而不是简单让其通过编译器检查
  • constnon-const 成员函数有着实质等价的实现,令 non-const 版本调用 const 版本避免代码重复。

完整可运行代码地址:Effective-Cpp-Reading-Note

Built with Hugo
Theme Stack designed by Jimmy