与const算算账

const爱恨史:

       从学谭浩强那本C语言开始,我就接触到了const,知道它是英文单词“constant”的缩写,意为“不变的,恒定的,常数”。但我当时一股脑地认为常量就是常量,为不可变的量,像自然数1、2、3、4、5等字面值就是所谓的常量;变量就是变量,有标识符名称,可以存数, 可以被改写。但是常量和变量组合在一起成常变量,这是个什么鸟东西?好纠结啊·········虽然课本有提到常变量与变量的区别是:常变量具有变量的基本属性,有类型,占内存单元,只是不允许改变其值,常变量是有名字的不变量,而常量是没有名字的不变量,有名字便于在程序中引用([4]第42页)。但心里还是纠结,一个好好的变量却用来当常量使用,不允许别人改变,那不如直接用常量好了,干嘛还用变量,这不是多此一举吗?这不是限制了变量的才华吗?况且,当时我对“在程序中引用”这个概念不理解,于是我就把const当成是一般性的存在,不予以重视,因为我根本不理解“不能被改变的变量”到底有何用意,也认为自己日后不会多用。
       直到最近遇上编译原理的实验和课程设计时,才让我意识到const变量的可贵性。
       我班的编译原理实验和课程设计都是基于一段“古老”的C++Build 6.0 项目代码上来做的,大家的任务就是改动代码以增加新功能。经阅读代码,自我感觉其编码风格极其恶心,印象最深的就是全文居然在数十处使用了一个字面值常量33。该33说明符号表中共有33个元素,程序能识别的符号类型总共有33类。我们要改动代码让程序能识别更多符号,所以必须改动符号表,而一改动符号表其长度也就变了,所以我们面临着将这数十个涉及到33的地方全部改成新值的大工程。要是放着这个问题不管,在以后每增加一次符号时,都要做同样的大修改,很是浪费时间与精力。这时候,小白我突然深深意识到“在程序中引用”的精髓所在了,于是我声明了一个全局变量,赋值为33,再将所有涉及到33的地方改成该全局变量的标识符名字,以后改动符号表时,可以由改动数十处缩减到只改动该全局变量的值,省下不少功夫。虽说平时自己写代码时都会有“将同样的东西抽取出来作为全局量引用”这个意识,但惟独这次的感受特别地深。
       在感叹之余,我也进一步意识到了新问题所在。本来是一个字面值常量33,这在运行时无论如何都是改变不了的东西,但现在却变成了“全局变量”,意味着该值被改动的因素可以来自很多方面。这时我心里隐隐约约有种不踏实的感觉,因为这个33原本是别人已经写好的,是不用为之操心的字面值,程序能在“该值不随意改变”的事实上正常运行,而现在却成了变量,我开始生怕自己不知什么时候 手贱或者以其他乱七八糟的方式改变了该变量值,而这一举动是在自己不知不觉的情况下进行的,若程序出现了错误也很难查找。这种害怕是在平时自己主动写代码时感受不到的,因为会对自己亲手写的代码非常自信,每个角落都了如指掌,即使出了错也能很快排查。但万万没想到当在改动别人的代码时却会有一番新感受,仿佛到了一个陌生的地方,行事处处需要小心。正当我在纠结如何保证这个具有举足轻重地位的全局变量不会被随意改变之时,const变量仿佛在呼唤着我:“嘿!~用我吧。我能保证你的变量不能被改变,能保证你的代码更安全。”瞬间,我“内牛满面”,原来一直不被我重视的const变量居然在关键时刻能给我如此安全的保证,这就是const变量之精髓的极致体现啊!从此我认同了const的使用,主动找回那些年曾经错过的const也就成了理所当然的事。
       但是,由于坑爹的课程设计使我来不及好好会一会const,面试的时候到了··········

       面试官笑眯眯地(这笑眯眯可不是一般的笑眯眯)问我:“这里有一个类B,请你设计它的成员变量的存取访问函数。”,代码如下:

class A
{
};

class B
{
private:
    A m_a;
};

       我一看,皱了下眉头。自己隐隐约约知道这道看似简单的题存在着许多陷阱,但还不是很肯定是这样,同时脑子里也在隐隐约约的提问着自己:返回值到底要不要加const?函数参数到底要不要加const?函数体到底要不要const?返回值好还是返回指针好·········但是我又害怕自己想得太复杂,生怕面试官的意图不在这方面,而且我平时也不怎么注意参数的const和函数体的const,用得不多自然就怀疑到底需不需要,若需要又是为了什么,这些问题平时都没有考虑到·········犹豫之际我发现过了不少时间,生怕面试官会鄙视我在一道“简单”题上面思考太久,遂执笔补充代码如下:

class B
{
public:
    const A* GetA() { return &m_a; }
    void SetA(A* pa) { m_a = *pa; }
private:
    A m_a;
};

       面试官一看我的答案,眼睛迅速瞪了一下又很快地恢复了原状。我知道,这肯定要出问题了。不出我所料,恭喜我“荣获”面试官大叔笑眯眯的热心的指导(自我感觉糗爆了!~),这里要加const喔,那里要加const喔·········后来我发现在Set函数时还忘了给指针判空,自信顿时飞流直下三千尺·········
       走出网易大厦,我的脸蛋还是滚烫滚烫滴。果然,才大三的我还是 too yaung too simple 了。一道不起眼的题居然暴露了我编码的不严谨性,我不禁扪心自问:这么久以来我的C++白学了吗?虽说,我平常时写代码会很注意标识符的命名习惯,会很严格地对代码缩进,逻辑也很缜密,内存的申请与释放严格对应,调试也很细心,许多bug都逃不过我的眼睛,但是却好像不曾考虑过代码的安全性,如const的使用。虽然一直以来都知道const这个东西的存在,const常出现的地方也见过很多了,但是就一直觉得它很神秘,因为不敢很肯定它们出现在那里是干啥的,目的是为了什么,是不是有无皆可,是不是牛逼们用它来装饰代码使之看起来更加高级的。总感觉const会限制了我代码的灵活性,但专家们一直建议多使用const以提高代码的可维护性和安全性,防止某些代码被误用,但我一直对自己写代码很自信,相信自己绝对不会误用代码,觉得其他人是杞人忧天了,殊不知自己写的代码以后可能会被别人使用和维护,也没意识到“误用”不一定是思维误导而也可能是“手误”。种种原因导致我一直很少使用const,也使我对const的认识只停留在知道的阶段。于是,无知之弊终将暴露。

       为了狠狠地记住这场教训,我想,是时候与const算算账了(仅限C++中的const,且non-const的基本语法不作赘述)!



const变量:

       没错!这里要说的就是const变量,既然是“算账”,那就要面面俱到,从细微处做起。
        const的设计初衷就是用来指定一个“不变的对象”的语义约束,而编译器会强制实施这项约束。它告诉编译器和其他程序员某对象不会被改变。只要是你确定了该对象不会被改变,就该说出来,说出来才好让编译器帮你实施 ([1]的条款3,第17页)。一但你或他人不慎改变了该对象,编译器就会及时提醒要遵守原来的语义,这就是const提高代码安全性的一个体现。在他人阅读自己的代码时,const的出现也可以让别人的思考范围成倍缩小到某一个确定的范围,这就是通常所说的提高了代码的可读性和可维护性。

int Num = 8;
const int num = 8;
······                //省略号代表中间还有很多代码
Num = 10;    //没问题,Num是non-const
num = 10;    //编译器会报错,num是const

       正如上面的代码所示,两个标识符名很相近,区别只是在于开头字母大小写不同。若我要改变的是Num,而恰巧num没有被声明为const,则很容易因一个手误而导致被改变的是num,特别是在今天这样方便的IDE中,因为都带有拼写自动补全的功能。于是,告诉编译器谁该被const是很重要的,特别是在中间的省略号代表很长的代码时。
       就像编译原理课设中那样,const的优势源自于既有变量的可被引用性又有常量的不变性。若在代码多个地方用到同一个字面值常量,这时候将同一个字面值常量抽取出来成为const变量,以提供统一的访问点,这样一但涉及到要修改常量值时就只需要修改那个统一的访问点,而不用煞费心机地去修改每个字面值常量,同时也提高了代码的可读性。

······
for(int i = 0; i<33; i++) { ······ }
for(int i = 0; i<33; i++) { ······ }
for(int i = 0; i<33; i++) { ······ }
······
for(int i = 0; i<33; i++) { ······ }
······

       我们可以改成下面这样:

const int g_SymNum = 33;
······
for(int i = 0; i<g_SymNum ; i++) { ······ }
for(int i = 0; i<g_SymNum ; i++) { ······ }
for(int i = 0; i<g_SymNum ; i++) { ······ }
······
for(int i = 0; i<g_SymNum ; i++) { ······ }
······

       为什么不直接改成变量?因为确定33在运行时是无论如何都不会改变的,所以为了保证常量的本性,g_SymNum应该被声明为const。
       咋看这件事好像用 #define 来做也可以啊!是的,也许大家用 #define 会多过用const,我们完全可以用

#define g_SymNum 33

来实现同样的效果。但是从代码可维护性的角度来说,用 #define 并不是一个好的做法。[1]中的条款2有专门针对此问题讲解,我印象最深的一点就是:当 #define 的符号与该符号的使用分属不同的文件时,若某一时刻因为符号的使用而导致编译报错,那么编译错误只会提到符号被 #define 后的值,而不会提到符号。比如,若因g_SymNum的使用而导致编译错误,那么错误信息中提到的将会是 33 而不是 g_SymNum,假如这个 #define 是在一个很深层的别人写的头文件中的话,那么你将不知道 33 的来历,致使你花费大量的时间和精力来追踪这个错误。为什么错误提示会变得如此不人性化呢?因为 #define 的处理是在预编译阶段由预编译器来实施替换的,到了真正编译的时候,g_SymNum已经被替换成了33,这样被#define的符号将不会进入编译器的眼中,所以编译器也就不能准确报错了。
       于是,[1]的条款2提倡尽量用const来代替#define的使用 事实上,[4]的第42页也有说到自从有了const变量之后,可以不必多用符号常量。但是#define还没到了被抛弃的时候,因为防止头文件的重复包含和编译版本控制还是需要#define的。
       因为const对象在定义后就不能被修改,所以const对象必须被初始化([2]第53页,[3]第49页),注意说的不止是变量


const全局变量:

       const变量可以是局部的,也可以是全局的。const局部变量除了有不能修改的性质外,其他特性与一般局部变量相同,作用域也相同。const全局变量却不然,一般全局变量的作用域默认是整个程序,但const全局变量的作用域默认是“文件”,即一个const全局变量默认只属于一个文件,不能被其他文件引用。要想被其他文件引用,在定义const全局变量时必须将其声明为extern并初始化,然后在其他引用的文件内也要再extern一次 ([2]第54页) 若在定义时不使用extern,而只在使用到的文件内extern,那么该const全局变量也是不能被其他文件使用的,即使名字相同。

//----------------不能被外部文件使用---------------------
// file_1.cpp
const int val_1  = 8;
// file_2.cpp
extern const int val_1;    //会报错,因为相当于定义了一个新对象而没有初始化

//----------------可以被外部文件使用---------------------
// file_1.cpp
extern const int val_2  = 8;
// file_2.cpp
extern const int val_2;


const与指针:

       const与指针有三种关系,分别是:指向const对象的指针const指针指向const对象的const指针
       用得最多的要数指向const对象的指针了,通过这种指针不能随意改变 指向的对象 ,但是没有规定指针指向的对象原本一定要是const的,也可以是non-const的。若该类指针指向的对象原本是non-const的,则该对象可以通过其他途径改变自身值。这里其实可以理解为“该类指针‘自以为是’地指向了const对象而自觉地遵守不改变对象的约定而已”([2]第56页,[3]第111页)。但是注意,指向non-const对象的指针不能指向const对象,因为如果让指向non-const对象的指针指向const对象合法,那样就能通过non-const指针来改变const对象了。如:

int i = 8;
const int ci = 88;
const int* pc = &i;    //没问题,指向const对象的指针指向non-const对象
i = 80;                     //没问题,原本为non-const的对象可以通过其他方式改变
pc = &ci;                   //没问题,指向const对象的指针指向const对象
int* p = &i;               //没问题,指向non-const对象的指针指向non-const对象
p = &ci;                    //编译报错,指向non-const对象的指针不能指向const对象

       指向const对象的指针本身不是一个常量,所以C++语言不强制其进行初始化。不过不对指针进行初始化是个危险的做法,良好的编程风格建议养成对任何东西都初始化的习惯,指针至少也要将其初始化为NULL
       如果你想要的是让一个指针永远指向同一个地方,无论如何也不能改变其指向,那么const指针将是最佳首选。该类指针的const作用对象是指针本身,而不是指针指向的对象( [2]第56页,[3]第111页 )。由于该类指针本身就是const的,所以C++语言要求其必须进行初始化。如:

int* const cp = &i;    //没问题,const指针要进行初始化,但初始化之后其指向不能改变了
int* const cp2;           //编译报错,因为没有给const指针初始化

       同时也要注意,const指针本身的指向不能改变,但并不意味着该类指针指向的对象不能改变。该类指针指向的对象能否改变,完全取决于指针指向对象的类型,与指针自身是否const毫无关系。可以通过指向non-const对象的const指针来改变其指向的对象,但不可以通过 指向const对象的const指针来改变其指向的对象。也要注意,指向non-const对象的const指针不可以指向const对象,道理同指向const对象的指针。如:

int* const cp = &i;                //没问题,指向non-const对象的const指针
*cp = 88;                               //没问题,可以通过指向non-const对象的const指针来改变其指向的对象
int* const cp2 = &ci;            //报错,指向non-const对象的const指针不可以指向const对象
const int* const cpc = &ci;   //没问题,指向const对象的const指针
*cpc = 8;                                //报错,不可以通过指向const对象的const指针来改变其指向的对象
const int* const cpc2 = &i;   //没问题,指向const对象的const指针可以指向non-const对象
*cpc2 = 100;                          //报错,不可以通过指向const对象的const指针来改变其指向的对象

       这么多种指针,怎么快速判断是哪种const啊?这么乱怎么记啊?
       要快速判断指针是哪种const其实很简单,const位于 * 左边的就是指向const对象的指针,const位于 * 右边的就是const指针。所以指向const对象的指针有两种写法:

//以下两种写法完全等价
const int* pc = NULL;
int const *pc = NULL;

       至于怎么记也很简单。我们大可不必将两种const修饰混在一起,分开更好:当看到 * 左边有const时,就可以确定该指针是指向const对象的指针, 当看到 * 右边有const时,就可以确定该指针是一个const指针。然后组合,每部分都使用自己的规则,这样就能准确判断指针的能力了。


const与iterator:

       在使用STL的大多数容器时,我们都可以用其内部的iterator来指向其中的某个元素,然后用“->”或者“(&).”来获取元素内部的内容,这让iterator看起来很像一个指针。但iterator实质上不是指针,它只是一个经过运算符重载的类,作用类似于 T* 指针(这里的T用来指代某个类型,但不确定具体是什么类型)([1]条款3第18页)。
       我在此不对iterator的使用作深究,只记录const与iterator中该注意的地方。例如,我们可以通过如下方式获得一个 int 型 vector 中的起始 iterator:

//假设vec是一个已经定义好的vector对象
std::vector<int>::iterator iter = vec.begin();

如果你认为以下这种方式能获得一个指向const内部元素的指针,那就错了:

const std::vector<int>::iterator iter = vec.begin();

这种方式获得的 iterator 是一个指向不能变的 iterator,也就是 iterator 本身是const的,而非所指之物。虽然 iterator 的作用像个 T*,但 const iterator 绝不是想象中的 const T*,而是 T* const 。我们要记住 iterator 实质上是个类,而指向元素的实际上是其内部的某个叫 _M_current 的成员,只是经过运算符重载 operator->() 后将 _M_current 返回给我们罢了。所以在这里const的作用对象实际上是 iterator 类本身,iterator 本身是 const 的意味着类内的状态不能改变,这样就导致了 _M_current 的指向不能变了,所以得出来的效果相当于 T* const 。
       那我要想得到指向 const 元素的 iterator 该怎么办?很遗憾,C++没有提供直接语法来干这事,但是STL的开发人员们已经为我们定义好了一个用于指向 const 元素的 iterator,那就是 const_iterator,注意下划线。代码如下:

//假设vec是一个已经定义好的vector对象
std::vector<int>::const_iterator iter = vec.begin();

       const_iterator 可不像 iterator 那么简单,其内部是怎么实现使返回的 iterator 指向之物有 const 特性的,我追踪了一个小时的stl源码也没搞懂是啥回事,能力不足,请见谅。在此我们暂时只要记住 const_iterator 就相当于 const T* 吧。看来要好好读读《STL源码剖析》了。
       所以,自然的,我们就知道要得到指向 const 元素的const iterator 的写法了:

const std::vector<int>::const_iterator iter = vec.begin();

这样我们就使 iterator 本身和指向之元素都不能改变了。


const、指针与typedef:

       类似于 const 与 iterator,被 typedef 之后的指针也很容易让人误会,如:

typedef int* pint;
int i = 8;
const pint p = &i;

       上面的 p 的类型很容易被误解成 const int*,即指向const对象的指针,其实正确的理解应为 int* const,应该是指向 non-const 对象的 const 指针,这时候是指针本身不能变([2]第61页,[3]第112页)。与一般写法不同,被 typedef 后的复合类型应该被看成是一个整体,而 const 直接作用于这个整体类型的对象,而不是对象之外的东西。所以如下代码中的p都是同样的:

const pint p = &i;
pint const p = &i;
int* const p = &i;


const与引用:

       指向 const 对象的引用叫做“对常量的引用”,但很多程序员们都把它简称为“const引用”,要记住只是个简称,因为引用不是一个对象,所以没有真正的“const引用”。但因为它一经初始化之后就不能改变了,所以从这层意思上来理解,所有引用又算常量 ([2]第46页,第55页,[3]第52页) 至于引用占不占内存空间,我的实验测试结果是占4个字节,经过阅读网友们的贴子,得知引用的本质“可能”是个const指针,只是C++不提供语法干涉初始化后的引用所在内存。关于引用的本质及其是否占内存的问题,我在这里就不讨论了,估计要说得是篇长长的文章。我在这里只记录当引用遇到const时会发生什么事。
       指向 const 对象的引用也像指针那样,可以指向 const 对象或者 non-const 对象,指针中的“自以为是”思想也可以用到引用这里来。如:

int i = 8;
const int ci = 88;
//指向 const 对象的引用可以指向 const 对象或者 non-const 对象
const int& cr = i;
const int& cr2 = ci;

指向 const 对象的引用神奇的地方是,允许在初始化时是用任意的表达式作为初始值,只要该表达式的结果能转换成引用的类型即可([2]第55页,[3]第52页),这项特权是指向non-const对象的引用没有的。代码如下:

double d = 3.14f;
const int& cr1 = d;        //没问题,double型能转换到const int型
const int& cr2 = d * 2;  //没问题,计算得到的临时double型值可以转换到const int型  
const int& cr3 = 8 * 8;  //没问题,计算得到的临时int型值可以转换到const int型
int& r = cr3 * 2;            //编译错误,r是个指向 non-const 对象的引用
int& r2 = 8 * 8;             //编译错误,r是个指向 non-const 对象的引用

       为什么最后两行编译会报错?要是只看倒数第二行,你可能会认为是受到 cr3 这个指向 const 对象的引用参与运算的影响,但是最后一行也报错就可以否认这个想法了。我们先来看看当一个引用被绑定到另外一种与自身不同类型的对象上时会发生什么事( [2]第55页,[3]第52页 )。就看刚才第一二行代码,由于指向const int型的引用要绑定到一个double型对象,为了确保引用被绑定到正确的类型上,编译器会对这两行代码转变为为:

double d = 3.14f;
const int temp = d;        //编译器将d转换成临时的const int型常量
const int& cr1 = temp;  //cr1绑定的实际上是个临时常量

可见,cr1会被绑定到一个临时的const int对象上,因为通过指向const对象的引用是无法改变指向的对象的,所以编译器很放心地这样做了。但想想,如果 cr1 是指向 non-const 对象的引用,那么就可以通过 cr1 来改变这个临时量了,而程序员想要的实际上是 cr1 指向 d,通过 cr1 来改变 d,但临时量的改变却不会体现在 d 上面,所以C++语言干脆就将这种行为归为非法的了。同理,上述导致编译错误的两行代码也会被编译器处理成类似的结果,即引用指向的实际上是个临时量,若该行为合法,则程序员可以通过该引用来改变临时量,但编译器分辨不出之前的表达式是否只是一个其他类型已存在的对象,所以为了防止诡异行为,编译器将报错。


顶层const与底层const:

       正如指针与 const 的关系中那样,const 可以使指针本身是常量,也可以使指针指向的对象是常量,所以这是两个独立的问题。为了使讨论更方便,表达更清晰,[2]的第57页引入了两个专业术语:顶层 const(top-level const)和底层 const(low-level const)。用顶层 const 表示指针本身是个常量,用底层 const 表示指针所指的对象是个常量
       更一般地,顶层 const 用来表示任意对象本身就是一个常量,这里的对象可以是C++语言中的原子类型,如:int、float、double·····还可以是复合类型,如:struct、union、类、指针·······。引用没有顶层const,因为引用不是个对象,只是其他对象的别名。代码例如:

const int ci = 0;
const float cf = 0.0f;
int* const cpi = nullptr;

但是底层 const 就只与指针和引用有关了。如:

const int* cp = &ci;
int const *cp2 = cp;
const int& cr = ci;
int const& cr2 = cr;

指针可以同时是顶层 const 和底层 const,那就是指向 const 对象的 const 指针。
       引入这两个术语没什么好神秘的,就是为了方便讨论,const之前的性质还是原来的样子。


const对象与non-const对象之间的转换:

       在很多时候,我们需要去掉某个对象带有的 const 性质。在C++中我们可以使用C风格的强制转换: type( expression ) 。如:

int i = 8;
const int* cp = &i;
int* p = const( cp );    //或者 ( const ) cp;

还可以使用C++中命名的强制转换: const_cast<type>( expression ) 。([2]第145页,[3]第159页)如:

int i = 8;
const int* cp = &i;
int* p = const_cast<int*>( cp );

上面两种方法所达到的作用其实是一样的,只不过在C++中建议使用 const_cast,因为这样能使代码的可读性更高
       const_cast 只能改变对象的底层const([2]第145页),所以该转换所作用的对象只能是指针或者引用。增加或删除对象身上的 const 特性这项任务只有 const_cast 才能做得到,任何 尝试使用其他命名转换的做法都将引发编译错误,如static_cast、dynamic_cast、reinterpret_cast 。同样的,也不能用const_cast来改变对象的类型 [2]第145页,[3]第159页 。如:

int* cp2 = static_cast<int*>(cp);            //error,不能用其他命名转换来增删对象的const特性
float* cp3 = const_cast<float*>(cp);     //error,不能const_cast来做与增删const无关的转型

       如果一个对象原本不是const的,那么去掉指向它的指针的底层const特性后,通过该指针对对象进行写操作是不会有问题的。但如果对象原本是const的,则会产生未定义的后果([2]第145页)。实际上会发生什么事呢?测试代码如下:

const int num = 8;
std::cout<<num<<std::endl;
const int* cp = #
int* p = const_cast<int*>(cp);
*p = 10;
std::cout<<num<<' '<<*cp<<' '<<*p<<std::endl;
std::cout<<&num<<' '<<cp<<' '<<p<<std::endl;

我的系统是Windows 7,IDE是CodeBlock,得到的输出是:

8
8 10 10
0x28febc 0x28febc 0x28febc

程序运行过程中没有出错,乖乖地运行完毕了。本以为我的做法能绕过编译器的限制对const变量的num实施暴行,但是发现我没有成功,num的值还是原来的初始值8,但是cp和p指向的值却被改变成10了,很明显这是要告诉我num所在的地址与cp和p所指向的地方是不同的了,但是奇葩的是:最后一行将它们的地址全部输出,结果居然全部一样,我当时就懵了。[2]第54页提到编译器在编译过程中会将所有const变量都替换成对应的值。[1]第16页提到优秀的编译器不会为“整型const对象”设定另外的存储空间,除非你创建一个pointer或reference指向该对象。所以,对于我机器的结果,我的猜想是:编译器会将用到num这个对象值的地方全部替换成num的值,而后发现需要取num的地址时,会产生一个临时对象,其初值为num中的值,然后将该临时对象的地址返回给指向num的指针,通过指针进行写操作时,被修改的是临时对象,而不是真正的num,所以我们看到的num的值依然是8,而在将num的地址输出时,实质输出的地址是临时对象的地址,因为真正的num本来就不占有空间,num在编译完毕之后就消失了,所以我们看到的地址值跟两个指针是一样的。
       不知道其他平台其他IDE的环境下会怎样呢?


const与函数参数:

       函数的参数也可以是顶层const和底层const。但是,传入的参数是否是顶层 const 不会引起函数的重载,如:

//这两个函数是一样的
void fun( int i );
void fun( const int i );

//这两个函数也是一样的
void fun2( int* pi );
void fun2( int* const pi );

因为传参的实质就是值传递,当实参的值传到形参之后,实参与形参就是两个毫无关系的对象了,函数体内对形参的改变不会影响到外部的实参,同样,形参是否是顶层const对实参来说也毫无关系,既然是这样,C++语言干脆不对这两种情况加以区分了。
       然而,当传入的参数是否是底层const就会引发函数的重载了,当然,这种参数只限于指针和引用。如:

//这两个函数可以重载
void fun3( int* pi );
void fun3( const int* pi );

//这两个函数可以重载
void fun4( int& ri );
void fun4( const int& ri );

一种可以通过普通指针或引用形参改变外界对象,一种不可以通过指向const对象的指针或引用来改变外界对象,这两种函数是不同的,所以可以重载。
       non-const对象的地址可以传给具有底层const的指针或引用,但是const对象的地址却不可以传给普通指针或引用([3]第236页),因为这样一但合法,就有可能通过普通指针或引用形参来改变外界的const对象了。如:

void fun5( int* pi );
void fun6( const int* pi );

int i = 8;
const int ci = 88;
fun5( &i );
fun5( &ci );    //error
fun6( &i );      //ok,可以隐式转换
fun6( &ci );    //ok

如果同时存在普通版本与底层const形参版本的函数,若传入non-const对象的地址,编译器则会优先选择普通版本的函数([2]第208页)。如:

int i = 10;
fun3( &i );    //被调用的是void fun3( int* pi );
fun3( const_cast<const int*>( &i ) );    //被调用的是void fun3( const int* pi );

就像上面的最后一行代码那样,可以通过const_cast来强制转换以指定调用哪个版本的函数。但是,当传入的是const对象的地址时,若通过const_cast强制去掉其底层const性质,这样的行为则是未定义的。C++语言建议,在设计函数时,若确定形参所指向的对象无需改变时,尽量将形参声明为底层const


const与函数返回值:

       函数的返回值也可以被附上顶层const和底层const的特性。
       如果返回值是个C++基本的内置类型,如int、float、double等,考虑是否将其声明为const显然毫无必要性,因为返回的临时值要不继续参与计算,要不就赋给其他对象,无论如何其const性质都不会被我们干涉。但如果返回的是一个类对象,特别是由运算符重载后返回的类对象,如果确定其不需要改变,那么就尽量将其声明为顶层const,因为这样可以减少我么写代码时犯小毛病的几率。引用[1]中第18、19页的例子,如:

class Rational { ........ };
const Rational operator* ( const Rational& lhs, const Rational& rhs );

Rational a,b,c;
......
if( a*b = c ) { ...... }    //其实这里是想做“==”比较运算

正如上面代码所示,由于手误,可能会将“==”打成“=”,这时编译器就会帮我们发现错误并报错了,因为 a*b 调用了运算符重载函数 operator* 返回了一个无法被改写常量类对象。但想想如果 operator* 返回的类对象不被声明为const时,那么 if( a*b = c ) 这个判断几乎就永远成真了,这样的小错误调试起来很难会被发现。
       const的返回值更多地被用在类的成员函数上。在设计类成员的读取函数时,由于可能要让外界直接访问到类内部,但又不想类内部的状态被外界改变的话,这时最好就是返回一个指向类内部成员的底层const指针或引用了。如:

class C {
public:
    const int* GetNum() { return &m_i; }
private:
    int m_i;
};

但是,返回const的指针或引用也不是绝对安全的,因为如果对象本来不是const的,我们完全可以使用const_cast来去掉指针或引用的const性质,再利用其改变所指对象。但是这种做法是非常危险的,因为我们违背了函数的本意,私自改变了类的内部状态,可能会导致一些不可预想的行为。


const成员函数:

       const还有一个可以作用的地方就是类的成员函数体,严格来说不是作用于函数体,而是作用于指向调用成员函数的类对象的指针,也就是传说中的this指针。
       this是一个指向调用成员函数的对象本身的常量指针,每当调用成员函数时,this都会被自动初始化为指向调用类,所以类的成员函数直接访问类内的成员变量时是默认通过该this指针来访问的。上节的GetNum函数会被编译器识别为:

const int* C::GetNum() { return &m_i; }    //原型
const int* C::GetNum( C* const this )  { return &this->m_i; }     //编译会b变为为这样

this是个常量指针,是顶层const,但不一定是底层const。普通的成员函数之所以能直接改变类内部的成员变量,是因为this指针没附上底层const性质。C++规定可以在成员函数初始化列表之后函数体花括号之前写上const,这样就能 可以为this指针也附加上底层const性质了([2]第232页,[3]第224页)。这样一来,成员函数就变成了const成员函数,在这种函数里面不能随意更改类内成员的状态,任何尝试对类内成员赋值的操作都将引发编译错误

class C {
public:
    const int* GetNum() const { return &m_i; }    //const成员函数,只允许读操作
private:
    int m_i;
};

       为什么要有const成员函数的存在?([1]第19页)第一,const成员函数能提高代码的可读性,能让程序员只看函数声明就可以知道该函数不改变类内状态,这是很重要的。第二,const成员函数使操纵const类对象成为可能。第一点不难理解,但第二点是怎么回事?难道const类对象就不可以调用普通的成员函数了吗?对的。仔细分析后你会发现就是这样。继续看开始两行代码:

const int* C::GetNum() { return &m_i; }    //原型
const int* C::GetNum( C* const this )  { return &this->m_i; }     //编译器会变为这样

如果现在有一个const类对象调用该函数,那么:

const C cc;
int* p = cc.GetNum();

//编译器会变为:
int* p = C::GetNum( &cc );

但是注意,&cc获得的地址的类型是 const C* 型的,是个底层const,而底层const是不可以通过隐式转换去掉其const性质的,所以函数匹配失败。所以,只有const成员函数才能操纵const类对象。也许,各位会想到通过强制转换来使函数匹配成功,但是cc原本是个const类对象,强制去掉其const性质来对其进行non-const操作将是危险的未定义行为。但是反过来,non-const类对象既可以调用普通成员函数,也可以调用const成员函数,因为non-const对象本来就有着被修改的打算,所以哪个版本的函数对其来说都是安全的。
       如果尝试在const成员函数中以non-const形式返回类内部的non-const对象的地址或引用,将会引发编译错误,因为能调用const成员函数的类对象肯定是以const的形态来调用的,那么程序员就肯定认为该类对象不允许被改变,既然是这样,如果返回的是指向non-const类内部对象的指针或引用,该const类对象就有被改变的可能了,所以编译器会报错以防止这种情况的发生。代码如下:

class C {
public:
    int* GetNum() const { return &m_i; }    //编译报错
private:
    int m_i;
};

但是,const成员函数返回指向non-const的非类内部对象的指针或引用是可以的

       当non-const成员函数与non-const成员函数发生重载,并且两个版本的函数都打算实现相同的功能时,为了减少代码的重复,[1]的条款3建议使用non-const版本调用const版本,而避免用const版本来调用non-const版本。另外还有关于 bitwise constness 和 logical constness 的概念,也可以参考[1]的条款3,在此不作详细解释。


亡羊补牢:

       经过一番与const的算账之后,结合面试官大叔的指导,我给出以下较满意的答案:

class A
{
};

class B
{
public:
    const A& GetA() const { return const_cast<const A&>( m_a ); }
    void SetA( const A& a ) { m_a = a; }
private:
    A m_a;
};

为什么使用引用而不用指针?因为引用比指针更简洁,且无需判空。只有一个版本的Get函数没问题吗?没问题,const和non-const类对象都可以调用,non-const类对象对this指针的初始化编译器会自动做隐式转换。类内成员的封装性有保证吗?有!Get函数是个const成员函数,明确告诉别人函数内不会改变类内成员,且返回的引用是底层const的,只要用户不作死就不会死。能保证外部实参不会受Set函数的影响吗?能!因为SetA要求传入的是一个底层const引用,所以函数内无法对外部对象做出修改。

       与const的算账暂告一段落。若各位发现有更好的答案或者发现文中观点有误,请务必告诉我,谢谢。


最后附上一个从某处发现的关于 const 的搞笑视频:


PS:文中标明的引用是为了告诉读者从哪里可以找到相关资料,并不是直接引用原话。

参考文献:
[1] 《Effective C++(第三版)》Scott Meyers 著; 侯捷 译;
[2] 《C++ Primer(第五版)》Stanley B.Lippman,Josee Lajoie,Barbara E.Moo 著; 王刚,杨巨峰 译;
[3] 《C++ Primer(第四版)》Stanley B.Lippman,Josee Lajoie,Barbara E.Moo 著; 李师贤,蒋爱军,梅晓勇,林瑛  译;
[4] 《C语言程序设计(第四版)》谭浩强 著;
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页