第3项:
尽可能使用
const
const令人赞叹之处就是:你可以通过它来指定一个语义上的约束(一个特定的不能够更改的对象)这一约束由编译器来保证。通过一个const,你可以告诉编译器和其他程序员,你的程序中有一个数值需要保持恒定不变。不管何时,当你需要这样一个数时,你都应该这样做,这样你便可以让编译器来协助你确保这一约束不被破坏。
const
关键字的用途十分广泛。在类的外部,你可以定义全局的或者名字空间域的常量,也可以通过添加
static
关键字来定义文件、函数、或者程序块域的对象。在类的内部,你可以使用它来定义静态的或者非静态的数据成员。对于指针,你可以制定一个指针是否是
const
的,其所指的数据是否是
const
的,或者两者都是
const
,或者两者都不是。
char greeting[] = "Hello";
char *p = greeting; //
非
const
指针,非
const
数据
const char *p = greeting; //
非
const
指针,
const
数据
char * const p = greeting; // const
指针,非
const
数据
const char * const p = greeting; // const
指针,
const
数据
这样的语法看上去反复无常,实际上并不是这样。如果
const
关键字出现在星号的左边,那么指针所指向的就是一个常量;如果
const
出现在星号的右边,那么指针本身就是一个常量;如果
const
同时出现在星号的两边,那么两者就都是常量。
当所指向的为常量时,一些程序员喜欢把
const
放在类型之前;其他一些人则喜欢放在类型后边,但要在星号的前边。这两种做法没有什么本质的区别,所以下边给出的两个函数声明的参数表实际上是相同的:
void f1(const Widget *pw); // f1
传入一个指向
Widget
对象常量的指针
void f2(Widget const *pw); // f2
也一样
由于这两种形式在实际代码中都会遇到,所以你都要适应。
STL
迭代器是依照指针模型创建的,
所以说一个
iterator
更加像一个指向
T*
的指针。把一个
iterator
声明为
const
的更像是声明一个
const
的指针(也就是声明一个指向
T* const
的指针):
iterator
不允许指向不同类型的内容,但是其所指向的内容可以被修改。如果你希望一个迭代器指向某些不能被修改的内容(也就是指向
const T*
的指针),此时你需要一个
const_iterator
:
std::vector<int> vec;
...
const
std::vector<int>::iterator iter = vec.begin();
// iter
就像一个
T* const
*iter = 10; //
正确,可以改变
iter
所指向的内容
++iter; //
出错!
Iter
是一个
const
std::vector<int>::const_iterator cIter = vec.begin();
// cIter
就像一个
const T*
*cIter = 10; //
出错!
*cIter
是一个
const
++cIter; //
正确,可以改变
cIter
const
在函数声明方面还有一些强大的用途。在一个函数声明的内部,
const
可以应用在返回值、单个参数,对于成员函数,可以将其本身声明为
const
的。
让函数返回一个常量通常可以减少意外发生的可能,而且不用放弃考虑安全和效率问题。好比有理数乘法函数(
operator*
)的声明,更多信息请参见第
24
项。
class Rational { ... };
const
Rational operator*(const Rational& lhs, const Rational& rhs);
很多程序员在初次到这样的代码时都不会正眼看一下。为什么
operator*
要返回一个
c
onst
对象呢?这是因为如果不是这样,客户端将会遇到一些不愉快的状况,比如:
Rational a, b, c;
...
(a * b) = c; //
调用
operator=
能返回一个
a*b
!
我不知道为什么一些程序员会企图为两个数
的乘积赋值,但是我确实知道好多程序员的初衷并非如此。他们也许仅仅在录入的时候出了个小差错(他们的本意也许是一个布尔型的表达式):
if (a * b = c) ... //
噢
…
本来是想进行一次比较!
显而易见,如果
a
和
b
是内建数据类型,那么这样的代码就是非法的。避免与内建数据类型不必要的冲突,这是一个优秀的用户自定义类型的设计标准之一(另请参见第
18
项),而允许为两数乘积赋值这让人看上去就很不必要。声明
operator*
函数时如果让其返回一个
const
型数据则可以避免这一冲突,这便是要这样做的原因所在。
const
的参数没有什么特别新鲜的——它们与局部
const
对象的行为基本一致,你在必要的时候要尽可能使用它们。除非你需要更改某个参数或者局部对象,其余的所有情况最好都声明为
const
。这仅仅需要你多打六个字母,但是它可以使你从恼人的错误(比如我们刚才见到的“我本想打‘
==
’但是却打了‘
=
’”)中解放出来。
const
成员函数
对成员函数使用
const
的目的是指明这些成员函数可以被
const
对象调用。这一类成员函数是很重要的,首先,它们使得类的接口更加易于理解。很有必要了解哪些函数可以修改而哪些不可以。其次,这些函数可以与
const
对象协同工作。这对于高效编码是十分重要的一方面,这是由于(将在第
20
项中展开解释)提高
C++
程序性能的一条最基本的途径就是:传递对象的
const
引用。使用这一技
术需要一个前提:这就是首先要有
const
成员函数存在,并且它们用于处理之前生成的
const
对象。
如果若干成员函数之间的区别仅仅为“是否是
const
的”,那么它们也可以被重载。很多人都忽略了这一点,但是这是
C++
重要特征之一。请观察下面的代码,这是一个文字块的类:
class TextBlock {
public:
...
const char& operator[](std::size_t position) const
// operator[]
用于返回相应位置的字符
{ return text[position]; } //
返回一个
const
对象
char& operator[](std::size_t position)
// operator[]
用于返回相应位置的字符
{ return text[position]; } //
返回一个非
const
对象
private:
std::string text;
};
TextBlock
的
operator[]
可以这样使用:
TextBlock tb("Hello");
std::cout << tb[0]; //
调用非
const
的
TextBlock::operator[]
const TextBlock ctb("World");
std::cout << ctb[0]; //
调用
const
的
TextBlock::operator[]
顺便说一下,在真实的程序中,
const
对象在大多数情况下都以“通过指针传递”或“引用一个
const
”的形式出现。
上面的
ctb
的例子纯粹是人为的,而下面的例子在真实状况中常会出现:
void print(const TextBlock& ctb) //
在这个函数中
ctb
是
const
的
{
std::cout << ctb[0]; //
调用
const
的
TextBlock::operator[]
...
}
通过对
operator[]
的重载以及为每个版本提供不同类型的返回值,你便可以以不同的方式处理
const
的或者非
const
的
TextBlock
:
std::cout << tb[0]; //
正确:读入一个非
const
的
TextBlock
tb
[0] = 'x'; //
正确:改写一个非
const
的
TextBlock
std::cout << ctb[0]; //
正确:读入一个
const
的
TextBlock
ctb
[0] = 'x'; //
错误
!
不能改写
const
的
TextBlock
请注意,这一错误只与所调用的
operator[]
的返回值的类型有关,如果仅仅调用
operator[]
本身则不会出现任何问题。错误出现在:企图为一个
const char&
赋值,而
const char&
则是
operator[]
的
const
版本的返回值类型。
同时还要注意的是,非
const
的
operator[]
的返回值类型是一个
char
的引用,而不是
char
本身。如果
operator[]
真的简单的返回一个
char
,那么下面的语句将不能正确编译:
这是因为,企图修改一个返回内建数据类型的函数的返回值根本都是非法的。即使假设这样做合法,而
C++
是
通过传值返回对象的,所修改的仅仅是由
tb.text[0]
复制出的一份副本,而不是
tb.text[0]
本身,你不会得到预期的效果。
让我们暂停一小会儿,来考虑一下这里边的哲学问题。把一个成员函数声明为
const
的有什么涵义呢?这里有两个流行的说法:按位恒定(也可叫做物理恒定)和逻辑恒定。
按位
恒定阵营坚信:当且仅当一个成员函数对于所有对象的数据成员(
static
数据成员除外)都不做出改动时,才需要将这一成员函数声明为
const
的,换句话说,将成员函数声明为
const
的条件是:成员函数不对对象内部做任何的改动。按位恒定的好处之一就是,它使得错误检查便得更轻松:编译器仅需要查找对数据成员的赋值。实际上,按位恒定就是
C++
对于恒定的定义,如果一个
const
的成员函数调用了某个对象,那么即使该对象拥有非静态数据成员,其所有数据成员也都是不可修改的。
不幸的是,大多数不完全是
const
的成员函数也可以通过按位恒定的检验。在特定的情况下,如果一个成员函数频繁的修改一个指针所指的位置,那么我们说它就不是一个
const
的成员函数。但是只要这个指针存在于一个对象中,这个函数就是按位恒定的,这时候编译器不会报错。这样会导致编成的行为不符合常规习惯。比如说,我们手头有一个类似于
TextBlock
的类,其中保存着
char*
类型的数据而不是
string
,因为这段代码有可能要与一些
C
语言的
API
交互,但是
C
语言中没有
string
对象一说。
class CTextBlock {
public:
...
char& operator[](std::size_t position) const
// operator[]
不恰当的
(但是符合按位恒定规则)定义方法
{ return pText[position]; }
private:
char *pText;
};
尽管
operator[]
返回一个对对象内部数据的引用,这个类仍(不恰当地)将其声明为
const
的
成员函数(第
28
项将深入讨论这个问题)。先忽略这个问题,请注意
operator[]
的实现中并没有以任何形式修改
pText
。于是编译器便会欣然接受这样的做法,毕竟,所有的编译器所检查的是“代码是否符合按位恒定规则”。但是请观察,在编译器的纵容下,还会有什么样的事情发生:
const CTextBlock cctb("Hello");//
声明对象常量
char *pc = &cctb[0]; //
调用
const
的
operator[]
//
从而得到一个指向
cctb
中数据的指针
*pc = 'J'; // cctb
现在的值为
"Jello"
当你创建了一个包含具体值的对象常量后,你仅仅通过对其调用
const
的成员函数,就可以改变它的值!这显然是有问题的。
辑恒定应运而生。坚持这一宗旨的人们争论道,一个
const
的成员函数可能对其调用的对象内部做出改动,但是仅仅以客户端无法察觉的方式进行。比如说,你的
CTextBlock
类可能需要保存文字块的长度,以便在需要的时候调用:
class CTextBlock {
public:
...
std::size_t length() const;
private:
char *pText;
std::size_t textLength; //
最后一次计算出的文字块长度
bool lengthIsValid; //
当前长度是否可用
};
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText); //
错误!不能在
const
成员函数中
lengthIsValid = true; //
对
textLength
和
lengthIsValid
赋值
}
return textLength;
}
以上
length
的实现绝不是按位恒定的。这是因为
textLength
和
lengthIsValid
都可以改动。尽管看上去它应该对于
CTextBlock
对象常量可用,但是编译器不答应。编译器始终坚持遵守按位恒定。那么该怎么办呢?
解决方法很简单:利用
C++
中
与
const
相关的灵活性,使用可变的(
mutable
)数据成员。
mutable
可以使非静态数据成员不受按位恒定规则的约束:
class CTextBlock {
public:
...
std::size_t length() const;
private:
char *pText;
mutable std::size_t textLength;//
这些数据成员在任何情况下均可修改
mutable bool lengthIsValid; //
在
const
成员函数中也可以
};
std::size_t CTextBlock::length() const
{
if (!lengthIsValid) {
textLength = std::strlen(pText); //
现在可以修改了
lengthIsValid = true; //
同上
}
return textLength;
}
避免
const
与非
const
成员函数之间的重复
mutable
对于“我不了解按位恒定”的情况不失为一个良好的解决方案,但是它对于所有的
const
难题并不能做到一劳永逸。举例说,
TextBlock
(以及
CTextBlock
)中的
operator[]
不仅仅返回一个对恰当字符的引用,同时还要进行边界检查、记录访问信息,甚至还要进行数据完整性检测。如果将所有这些统统放在
const
或非
const
函数(我们现在会得到过于冗长的隐式内联函数,不过不要惊慌,在第
30
项中这个问题会得到解决)中,看看我们会得到什么样的庞然大物:
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;
};
噢!天哪,这让人头疼:重复代码,以及随之而来的编译时间增长、维护成本增加、代码膨胀、等等……当然,像边界检查这一类代码是可以移走的,它们可以单独放在一个成员函数(当然是私有的)中,然后让这两个版本的
operator[]
来调用它,但是你的代码仍然有重复的函数调用,以及重复的
return
语句。
对于
operator[]
你真正需要的是:一次实现,两次使用。也就是说,你需要一个版本的
operator[]
来调用另一个。这样便可以通过转型来消去函数的恒定性。
通常情况下转型是一个坏主意,后边我将专门用一项来告诉你为什么不要使用转型(第
21
项),但是代码重复也不会让人感到有多轻松。在这种情况下,
const
版的
operator[]
与非
const
版的
operator[]
所做的事情完全相同,不同的仅仅是它的返回值是
const
的。通过转型来消去返回值的恒定性是安全的,这是因为任何人调用这一非
const
的
operator[]
首先必须拥有一个非
const
的对象,否则它就不能调用非
const
函数。所以尽管需要一次转型,在
const