《C++代码设计与重用》——2.8 const关键字的使用

简介:

本节书摘来自异步社区出版社《Imperfect C++中文版》一书中的第2章,第2.8节,作者: 【美】Martin D.Carroll , Margaret A.Ellis,更多章节内容可以访问云栖社区“异步社区”公众号查看。

2.8 const关键字的使用

C++代码设计与重用
2.8 const关键字的使用
在程序库中,const关键字的正确使用是很重要的。使用const的最大障碍就是用户往往未能正确理解const的意义。接下来,我们将讨论如何解释const,如何使用const,和当我们要改变const的时候,const为什么不能够被重新解释。

2.8.1 抽象const对比位元const

我们可以用好几种方式来解释关键字const。先考虑函数sqrt,它用于计算Rational对象的平方根(这里的Rational指2.1节描述有理数的类):

Rational sqrt(const Rational& r);

如果我们采用抽象解释方式,那么上面这个声明语句说明,sqrt函数不会使用r去改变r所引用对象的抽象值。如果我们采用位元解释方式,那么这个声明语句说明,sqrt函数不会使用r去改变组成r所引用对象的任何位元。(练习2.10讨论了其他几种可能的解释方式。)

相对于抽象const,位元const有一个优点和几个缺点。如果在任何地方都使用位元const,那么,对于没有构造函数和析构函数类型的const对象,我们就可以把它安全地储存在只读内存区域(ROM,read-only memory)。而且,对一些应用程序而言,把对象储存在只读内存区域将会是一个很重要的优化方式。然而,位元const也具有一些缺点:它具有比抽象const更低级别的抽象性。实际上,一个C++程序库接口的抽象性级别越低,使用这个程序库就越困难。

而且,使用位元const的程序库接口暴露了程序库的实现细节。任何时候的实现细节都被暴露无遗了,而这往往会带来一些负面效应。例如,假设在我们程序库的最新版本中,我们决定这样来优化sqrt函数:使类r的num(分子)和denom(分母)数据成员转化成最简形式。如果采用的是位元const的方式,这时对sqrt函数实现的改变将要求我们去除sqrt声明语句的const关键字,而这违反了我们原先使用const的本意。更加遗憾的是,这个改变是源代码不兼容的,因此可能会破坏用户的代码。

因此,在程序库接口和程序库实现细节上面,程序库设计者都应该使用抽象const。考虑下面的代码:

class Rational {
     //...
private:
     void reduce() const;
     int num;
     int denom;
};

函数reduce把num和denom转变成最简形式。因为简化有理数的表示并没有改变这个有理数的(抽象)值,所以我们把reduce定义为const函数。

现在考虑下面reduce函数的实现代码:

void Rational::reduce() const {
     int gcd = GCD(num,denom);
     num /= gcd;   //错误,不能改变*this,因为const的限制
     demon /= gcd;  //错误,不能改变*this,因为const的限制

在这里,GCD是一个返回它的两个参数的最大公约数的函数。遗憾的是,试图改变num和denom的语句是非法的,因为C++编译器根本没有办法知道这样的事实:对num和denom都进行化简并没有改变this指针指向对象的值。

我们可以用3种办法来解决这个问题。首先,我们可以把num和denom声明为mutable(可变的):

class Rational {
private:
     mutable int num;
     mutable int denom;
     //...
};

现在,任何试图对const Rational的num和denom成员变量的改变都是合法的(包括试图改变它们中的一个或者两个,从而导致Rational对象值的改变的操作,都是合法的)。另一种允许改变num和denom的技术是:只在我们需要改变的地方,去除const(cast away const):

void Rational::reduce() const {
     //...
     const_cast<int> (num) /= gcd;  //ok
     const_cast<int> (denom) /= gcd;  //ok
}

由于mutable和const_cast是C++相对较新的特性,因此现今有些编译器并不能实现它们;从而,使用它们的代码有时就不具备可移植性。另外,一个具有更好可移植性的技术是使用旧式的强制转型:

void Rational::reduce() const {
     //...
     Rational* let_me_modify = (Rational*) this;
     let_me_modify->num /= gcd;  //ok
     let_me_modify->denom /=gcd;  //ok
}

试图使用旧式的cast转型对const对应的X对象进行转型,只有当被转型的类X具有不少于一个的显示构造函数时,类X的转型后的对象才会有X原来定义的行为1。(大多数重要的类都能满足这个限制条件,即至少有一个显式构造函数)

第三种避免编译器产生错误的方法是:对我们要改变的对象增加一个间接层(indirection):

class Rational {
public:
     Rational() : num(_new_ int), denom(_new_ int) {/* ... */}
     //...
private:
     int* num;
     int* denom;
};

那么,改变num和denom是合法的,即使在Rational的成员函数里面也是如此:

void Rational::reduce() const {
     //...
     *num /= gcd;   //ok, 因为Rational的成员变量num并没有改变,它还是指向
                          //原来的地址。
     *denom /= gcd;  //ok,同上
}

然而,增加一个间接层降低了程序的效率。因此,使用关键字mutable将是解决这个问题最好的办法,除非可移植性是主要的关注因素。

2.8.2 最大限度地使用const

许多C++程序员只把const当成一个不能捕获错误的讨厌东西,因此,并不是所有的程序员都能充分地使用const。然而,作为C++程序库的设计者,就没有这么多是否使用const的自由了。对于C++程序库的接口,应该在它应用的每个地方都使用const关键字—就是说,使用const的每个地方都可以确保程序库在此处不会被修改。

如果未能最大限度地使用const,那么很有可能会给程序库用户带来问题。假设我们想要提供一个库函数,它带有两个参数—1个指向以null结束的字符串指针p和一个指针数组a,a的元素也是以null结束的字符串指针—并且,如果p指向的字符串和a中某个指针元素指向的字符串相等,就返回真值。我们很可能会像下面这样定义这个函数:

//没有最大限度使用const
bool contains(const char** a, const char* p);

对编写下面代码的用户而言,这个接口可以顺利通过编译:

static const char* keyword[] = {
     "array", "of", "four", "strings"
};
bool iskeyword(const char* p) {
     return contains(keyword, p);
}

然而,下面的代码却不能通过编译:

bool iskeyword(const char* const* keyword, const char* p)
{
     return contains(keywords, p);    //错误
}

这个错误来源于我们的失误,因为我们没有在每个应该使用const的地方都使用const,下面是contains正确的接口:

//最大限度地使用const
bool contains(const char* const* a, const char* p);

现在所有的用户代码,不管是充分使用const的代码,还是没有使用const的代码,都可以如用户预期地通过编译(或者是不通过编译,但也是用户预期的)。

对最大限度地使用const的规律存在着一个例外:假设contains函数并没有改变它的参数a和p的值,我们仍然不应该如下声明contains函数:

//不好的想法
bool contains(const char* const* const a,  //增加了一个const
     const char* const p);      //增加了一个const

我们在这里增加的const对用户没有产生任何影响,并且,用户也很少被非引用的(nonreference)参数所影响。而且,如果contains将来的版本由于某种原因需要改变参数a或p的值,那么这些相应的const也应该被删去,这将破坏兼容性。因此,const决不能用于改变非引用(nonreference)参数的值。

2.8.3 对const不安全的解释

有时,程序库设计者希望能对const作不同于抽象解释和位元解释的另一种解释,然而,大多数对const的解释并不是类型安全的。考虑下面的类:

class Noderef {
public:
     int value() const;
     void setvalue(int val) const;
     const Noderef& operator=(const Noderef& n);
     //...
};

Noderef是一个指向底层节点的引用;每一个底层节点都只包含一个int值。函数value返回节点储存的这个int值,而函数setvalue则把val的值赋给底层节点这个值。由上面代码可以看出,value函数和setvalue函数都是const函数,因此这两个函数都不会改变Noderef本身的值(指引用值,类似指针值,而并不是节点值)。

把setvalue成员函数声明为const函数多少会让人有些惊讶,因此类Noderef的设计者可能希望在应用Noderef的时候,可以重新解释const。例如,设计者可能如下重新解释所有const的用法:

储存在底层节点的值并没有改变。

然而,这个对const的重新解释是不安全的。下面是在所提出的重新解释下类Noderef的声明:

//具有对const不安全重新解释的类Noderef
class Noderef {
public:
     int value() const;
     void setvalue(int val);
     const Noderef& operator=(const Noderef& n) const;
     //...
};

在上面的代码中,setvalue已经不是const函数了,但现在赋值运算符变成了const函数!(赋值运算符改变了类Noderef中的值,但不是储存在底层节点的值。)然而,这个接口(setvalue函数)包含了一个类型漏洞,请考虑下面的代码:

void f(const Noderef& n) {
         Noderef m = n;  //m和n现在引用(指向)相同的节点
         m.setvalue(0);  //oops!
    };

函数f在它的声明中,保证f将不会使用n来改变n所引用节点的值,但在我们对const的重新解释下,使用m对函数setvalue的调用却违反了这个保证,并且还可以顺利通过编译,而不产生任何错误或警告。而且,读者可以证明:为了避免这个类型漏洞,最简单的权宜之计莫过于,删去Noderef赋值运算符声明语句中的最后一个const,但这并不能成功,也达不到我们的目的。

因此,我们应该尽量避免对const进行重新解释。

1译注:如上例,是指只有当Rational具有公共构造函数时,转型后的对象Let_me_modify才能调用它的成员变量num和denom。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

相关文章
|
21天前
|
存储 安全 编译器
【C++专栏】C++入门 | auto关键字、范围for、指针空值nullptr
【C++专栏】C++入门 | auto关键字、范围for、指针空值nullptr
27 0
|
2天前
|
安全 编译器 C++
【C++类和对象】const成员函数及流插入提取
【C++类和对象】const成员函数及流插入提取
|
2天前
|
C语言 C++
【C++入门】关键字、命名空间以及输入输出
【C++入门】关键字、命名空间以及输入输出
|
8天前
|
C++
【C++】std::string 转换成非const类型 char* 的三种方法记录
【C++】std::string 转换成非const类型 char* 的三种方法记录
5 0
|
16天前
|
存储 编译器 C++
【C++成长记】C++入门 | 类和对象(中) |拷贝构造函数、赋值运算符重载、const成员函数、 取地址及const取地址操作符重载
【C++成长记】C++入门 | 类和对象(中) |拷贝构造函数、赋值运算符重载、const成员函数、 取地址及const取地址操作符重载
|
22天前
|
编译器 C++
|
22天前
|
C++
|
23天前
|
存储 编译器 Linux
【C++】C++入门第二课(函数重载 | 引用 | 内联函数 | auto关键字 | 指针空值nullptr)
【C++】C++入门第二课(函数重载 | 引用 | 内联函数 | auto关键字 | 指针空值nullptr)
|
23天前
|
编译器 C语言 C++
【C++】C++入门第一课(c++关键字 | 命名空间 | c++输入输出 | 缺省参数)
【C++】C++入门第一课(c++关键字 | 命名空间 | c++输入输出 | 缺省参数)
|
23天前
|
存储 程序员 编译器
C++注释、变量、常量、关键字、标识符、输入输出
C++注释、变量、常量、关键字、标识符、输入输出