能否正确地使用const是判断一个C++程序员基本编程素质的有效手段。但是,能准确地理解该关键字的含义则是更高的要求。简单地说,和其他所有的用于提升C++之生产力的手段一样,它表征的根本含义是使得代码更具表达性,亦即,更能清楚地表明代码的含义。
在C++标准中,const是一个对象修饰符。一般地,初学者很容易按照字面意思,把它理解为常量。确实如此吗?
真正的常量和语义上的常量
一个常见的C和C++初学者在遇到如下的定义之后往往不能清楚地理解它们之间的细微差别:
在C的风格中,一个数组的名字很容易退化成指向数组成员的指针。所以下面的代码都有效:
事实上,所有的以字符指针为形参(parameter)的函数都可以接受字符数组作为参数,这样可以工作的原因依赖于两个约定:
1. 存在数组名字到数组首元素地址的隐式转换
2. C风格的字符是以字符'/0'作为结束符的,而上述定义的变量p1和p2恰好都满足这个条件
这里都是说的他们的相同点。
他们的不同点在于对如下的语句的解释执行,试图使第一个字母大写:
编译这样的代码,我们会立即得到一个编译错误:试图修改一个标识为const的值。我们有两种可能的办法:
1. 去除那个const定义。这往往会导致一个警告,我们先不去管它
2. 使用const_cast来做转换。不是说有两种C++程序员么,我们当然是第一种,我们谨记Scott的教诲,使用const_cast而不是强制的类型转换。这次,我们不会得到任何的警告。
重新编译,然后运行,程序崩溃了。why?
有经验的C++程序员会告诉你,p1的字符串是存储在一个只读的区域的,尽管你可以诱骗编译器,但是在执行的时候,任何试图修改只读区域的尝试都会触发崩溃。正解,但是不够准确
更准确的技术性解释是:p1指向的字串在C++叫做字符串字面值(string literal),它们的是否可区别(上述定义中两个“hello world”是不是指的是同一个)是实现定义的(编译器说了算),而任何试图修改的行为都是未定义的(编译器可以在这里干事情,如使程序崩溃)。
【看了这些解释,可能会更加困惑,but why again?
尽管这不是这节要讨论的问题,但是这里简单给出一个提示。一个变量被声明意味着该变量所占有的内存将会被分配。对于指针来说,那就是说编译器分配这四个字节(32位平台)未作指针内容,并且把该地址的名字记入符号表。由于还需要初始化,则把哪个字串的地址赋值给该指针。但是这个字串从哪里来,该指针并不关心,它并不拥有字串字面值;而对于数组,情况就大大不同,数组的构成依赖于两个元素,数组元素的类型(这里是char)和数组的长度(这个没有指定,因为我们要编译器从初始化列表中自动计算,那是12),于是编译器就分配sizeof(char)*12个字节的内存,并且使用上述字符串字面值初始化之。你可能已经注意到,对于数组变量来说,其拥有这段内存!这就是为什么编译器把修改字符串字面值的行为规定为未定义,因为我们不鼓励任何修改不属于自己的东西!】
这里其实区分了两个不同的东西,真正的常量和概念上的常量(语义常量)。对于真正的常量,它就是常量,任何情况下都不能被修改,比如字符串字面值;而概念上的常量则表达了一种含义:它不期望客户代码去修改它,尽管你完全可以这么做。这仅仅是与以上的限制。这就是为什么C++中const关键字在绝大部分的情况下表达的是“只读”的含义,而不是常量。不要奇怪,不少新的语言支持readonly关键字来表达这个含义,而不是const。
作为一条基本的规则,请记住,const表达的含义是该变量是只读的。
使用常量表达更清晰的接口语义
清楚了const 的真正含义,就可以很好地理解为什么C++社区建议尽可能地使用const,因为使用它可以更有效地表明你期望一个变量应该被如何使用。这对于类成员函数尤其有用。一方面,你可以通过阅读类的定义来准确理解一个调用究竟会不会对对象的状态产生影响,这对准确地理解类的作用非常有用,另一方面,C++编译器通过检查const正确性来保证const语义在所有的代码中被严格遵守。如果一个定义为只读的对象要调用一个不是只读类型的对象方法,那么也就意味着或者你的类型系统定义有问题,或者你的代码误用了const。无论如何,这是一个使用编译器来帮助程序员检查错误的典型例子。强类型系统的好处就在于编译器掌握大量的关于代码的知识,可以积极地帮助程序员检查可能出现的类型错误。对于大型的系统来说,这非常重要。细心的C++程序员常常可以感觉到C++这种专门为工业编程而准备的体贴设计而倍感欣慰。
lazy计算以及mutable关键字
任何规则都有异常,除了这条规则本身。const正确性的概念有效地强化了C++的类型系统,但是与之而来的一个副作用是传统的延迟求值的常规优化技术在这里就不能使用。假设有一个提供了某种内部计算接口的类,而该计算非常消耗时间。很显然,除非必要,这样的计算做好不要做。该计算过程显然是非const的。于此同时,它提供了一个接口来获取计算结果。根据接口语义,获取一个东西显然是一个只读的行为,我们可以把它标识为const。现在的问题是,我们可能使用该接口,也可能不使用,这依赖于用户的输入。而我们又要在获取用户输入之前构造该对象。调用哪个内部计算接口的唯一机会就是在这里了,但是我们却不希望在这里调用哪个消耗时间的操作,万一用户的输入不要求该结果怎么办?简单想一下,我们的日常编程工作中常常可以遭遇类似的问题。
我们的解决方案的传统的延迟求值,那就是说在用户调用的是在去计算,然后返回计算结果,但是这打破了const正确性:一个只读的行为需要调用非只读的行为。C++的解决方案是引入mutable关键字,用它来标识需要在只读的方法中修改的成员变量。它的含义是:尽管该方法是只读的,但是修改mutable的成员变量依然合法。它背后的潜台词是:该成员不是对象外部可见状态的一部分(它是对象状态转换的中间数据,属于对象的内部状态,对用户不可见),所以不受const 的限制。实际应用中,只要一看到mutable,就可以放心地假设该类使用了lazy计算来确保任何情况下的性能最优。
const_cast为什么有用
既然有了mutable关键字,为什么我们还需要const_cast关键字呢?原因是mutable仅仅能用于lazy计算的情况,还有其他多种情况我们需要在对象和const修饰的对象之间转换。这样情况包括:
1. 非成员变量的使用和交互
2. 与C接口的集成
3. 给一个对象增加const修饰符
这是必须使用const_cast修饰符,不建议使用传统的强制类型转换。
你可能已经注意到,我们把const成为一个修饰符,从语义上理解,这不是对象的基本属性的一部分,而是对象外部暴露特性(一种修饰)的一种,它可以是const,也可以是volatile(C++中把它们统称为cv修饰符)。它表征的只是你期望别人如何看待你的对象,而不是说对象就是这个样子。暴露的行为与真实行为不一致,这不正是信息隐藏的特性吗?你隐藏了对象在可以被修改的特性而宣称它们是只读的,这样就可以保证你日后的内部修改不会影响到其他代码对你的接口的依赖和期望。信息隐藏的好处也莫过于此了。