07 | const/volatile/mutable:常量/变量究竟是怎么回事?

你好,我是Chrono。

上节课我讲了自动类型推导,提到auto推导出的类型可以附加const、volatile修饰(通常合称为“cv修饰符”)。别看就这么两个关键字,里面的“门道”其实挺多的,用好了可以让你的代码更安全、运行得更快。今天我就来说说它们俩,以及比较少见的另一个关键字mutable。

const与volatile

先来看const吧,你一定对它很熟悉了。正如它的字面含义,表示“常量”。最简单的用法就是,定义程序用到的数字、字符串常量,代替宏定义

const int MAX_LEN       = 1024;
const std::string NAME  = "metroid";

但如果我们从C++程序的生命周期角度来看的话,就会发现,它和宏定义还是有本质区别的:const定义的常量在预处理阶段并不存在,而是直到运行阶段才会出现

所以,准确地说,它实际上是运行时的“变量”,只不过不允许修改,是“只读”的(read only),叫“只读变量”更合适。

既然它是“变量”,那么,使用指针获取地址,再“强制”写入也是可以的。但这种做法破坏了“常量性”,绝对不提倡。这里,我只是给你做一个示范性质的实验,还要用到另外一个关键字volatile。

// 需要加上volatile修饰,运行时才能看到效果
const volatile int MAX_LEN  = 1024;

auto ptr = (int*)(&MAX_LEN);
*ptr = 2048;
cout << MAX_LEN << endl;      // 输出2048

可以看到,这段代码最开始定义的常数是1024,但是输出的却是2048。

你可能注意到了,const后面多出了一个volatile的修饰,它是这段代码的关键。如果没有这个volatile,那么,即使用指针得到了常量的地址,并且尝试进行了各种修改,但输出的仍然会是常数1024。

这是为什么呢?

因为“真正的常数”对于计算机来说有特殊意义,它是绝对不变的,所以编译器就要想各种办法去优化。

const常量虽然不是“真正的常数”,但在大多数情况下,它都可以被认为是常数,在运行期间不会改变。编译器看到const定义,就会采取一些优化手段,比如把所有const常量出现的地方都替换成原始值。

所以,对于没有volatile修饰的const常量来说,虽然你用指针改了常量的值,但这个值在运行阶段根本没有用到,因为它在编译阶段就被优化掉了。

现在就来看看volatile的作用。

它的含义是“不稳定的”“易变的”,在C++里,表示变量的值可能会以“难以察觉”的方式被修改(比如操作系统信号、外界其他的代码),所以要禁止编译器做任何形式的优化,每次使用的时候都必须“老老实实”地去取值。

现在,再去看刚才的那段示例代码,你就应该明白了。MAX_LEN虽然是个“只读变量”,但加上了volatile修饰,就表示它不稳定,可能会悄悄地改变。编译器在生成二进制机器码的时候,不会再去做那些可能有副作用的优化,而是用最“保守”的方式去使用MAX_LEN。

也就是说,编译器不会再把MAX_LEN替换为1024,而是去内存里取值(而它已经通过指针被强制修改了)。所以,这段代码最后输出的是2048,而不是最初的1024。

看到这里,你是不是也被const和volatile这两个关键字的表面意思迷惑了呢?我的建议是,你最好把const理解成read only(虽然是“只读”,但在运行阶段没有什么是不可以改变的,也可以强制写入),把变量标记成const可以让编译器做更好的优化。

而volatile会禁止编译器做优化,所以除非必要,应当少用volatile,这也是你几乎很少在代码里见到它的原因,我也建议你最好不要用(除非你真的知道变量会如何被“悄悄地”改变)。

基本的const用法

作为一个类型修饰符,const的用途非常多,除了我刚才提到的修饰变量外,下面我再带你看看它的常量引用、常量指针等其他用法。而volatile因为比较“危险”,我就不再多说了。

在C++里,除了最基本的值类型,还有引用类型和指针类型,它们加上const就成了常量引用常量指针

int x = 100;

const int& rx = x;
const int* px = &x;

const &被称为万能引用,也就是说,它可以引用任何类型,即不管是值、指针、左引用还是右引用,它都能“照单全收”。

而且,它还会给变量附加上const特性,这样“变量”就成了“常量”,只能读、禁止写。编译器会帮你检查出所有对它的写操作,发出警告,在编译阶段防止有意或者无意的修改。这样一来,const常量用起来就非常安全了。

因此,在设计函数的时候,我建议你尽可能地使用它作为入口参数,一来保证效率,二来保证安全

const用于指针的情况会略微复杂一点。常见的用法是,const放在声明的最左边,表示指向常量的指针。这个其实很好理解,指针指向的是一个“只读变量”,不允许修改:

string name = "uncharted";
const string* ps1 = &name; // 指向常量
*ps1 = "spiderman";        // 错误,不允许修改

另外一种比较“恶心”的用法是,const在“*”的右边,表示指针不能被修改,而指向的变量可以被修改:

string* const ps2 = &name;  // 指向变量,但指针本身不能被修改
*ps2 = "spiderman";        // 正确,允许修改

再进一步,那就是“*”两边都有const,你看看是什么意思呢:

const string* const ps3 = &name;  // 很难看懂

实话实说,我对const在“*”后面的用法“深恶痛绝”,每次看到这种形式,脑子里都会“绕一下”,实在是太难理解了,似乎感觉到了代码作者“深深的恶意”。

还是那句名言:“代码是给人看的,而不是给机器看的。”

所以,我从来不用“* const”的形式,也建议你最好不要用,而且这种形式在实际开发时也确实没有多大作用(除非你想“炫技”)。如果真有必要,也最好换成其他实现方式,让代码好懂一点,将来的代码维护者会感谢你的。

与类相关的const用法

刚才说的const用法都是面向过程的,在面向对象里,const也很有用。

定义const成员变量很简单,但你用过const成员函数吗?像这样:

class DemoClass final
{
private:
    const long  MAX_SIZE = 256;    // const成员变量
    int         m_value;           // 成员变量
public:
    int get_value() const        // const成员函数
    {
        return m_value;
    }
};

注意,这里const的用法有点特别。它被放在了函数的后面,表示这个函数是一个“常量”。(如果在前面,就代表返回值是const int)

“const成员函数”的意思并不是说函数不可修改。实际上,在C++里,函数并不是变量(lambda表达式除外),所以,“只读”对于函数来说没有任何意义。它的真正含义是:函数的执行过程是const的,不会修改对象的状态(即成员变量),也就是说,成员函数是一个“只读操作”

听起来有点平淡无奇吧,但如果你把它和刚才讲的“常量引用”“常量指针”结合起来,就不一样了。

因为“常量引用”“常量指针”关联的对象是只读、不可修改的,那么也就意味着,对它的任何操作也应该是只读、不可修改的,否则就无法保证它的安全性。所以,编译器会检查const对象相关的代码,如果成员函数不是const,就不允许调用。

这其实也是对“常量”语义的一个自然延伸,既然对象是const,那么它所有的相关操作也必然是const。同样,保证了安全之后,编译器确认对象不会变,也可以去做更好的优化。

看到这里,你会不会觉得常量引用、常量指针、常量函数这些概念有些“绕”呢?别担心,我给你总结了一个表格,看了它,以后你写代码的时候就不会晕了。

这方面你还可以借鉴一下标准库,比如vector,它的empty()、size()、capacity()等查看基本属性的操作都是const的,而reserve()、clear()、erase()则是非const的。

关键字mutable

说到这里,就要牵扯出另一个关键字“mutable”了。

mutable与volatile的字面含义有点像,但用法、效果却大相径庭。volatile可以用来修饰任何变量,而mutable却只能修饰类里面的成员变量,表示变量即使是在const对象里,也是可以修改的。

换句话说,就是标记为mutable的成员不会改变对象的状态,也就是不影响对象的常量性,所以允许const成员函数改写mutable成员变量。

你是不是有些奇怪:“这个mutable好像有点‘多此一举’啊,它有什么用呢?”

在我看来,mutable像是C++给const对象打的一个“补丁”,让它部分可变。因为对象与普通的int、double不同,内部会有很多成员变量来表示状态,但因为“封装”特性,外界只能看到一部分状态,判断对象是否const应该由这些外部可观测的状态特征来决定。

比如说,对象内部用到了一个mutex来保证线程安全,或者有一个缓冲区来暂存数据,再或者有一个原子变量做引用计数……这些属于内部的私有实现细节,外面看不到,变与不变不会改变外界看到的常量性。这时,如果const成员函数不允许修改它们,就有点说不过去了。

所以,对于这些有特殊作用的成员变量,你可以给它加上mutable修饰,解除const的限制,让任何成员函数都可以操作它

class DemoClass final
{
private:
    mutable mutex_type  m_mutex;    // mutable成员变量
public:
    void save_data() const          // const成员函数
    {
        // do someting with m_mutex
    }
};

不过要当心,mutable也不要乱用,太多的mutable就丧失了const的好处。在设计类的时候,我们一定要仔细考虑,和volatile一样要少用、慎用。

小结

好了,今天我和你聊了const、volatile、mutable这三个关键字,在这里简单小结一下。

1.const

  • 它是一个类型修饰符,可以给任何对象附加上“只读”属性,保证安全;
  • 它可以修饰引用和指针,“const &”可以引用任何类型,是函数入口参数的最佳类型;
  • 它还可以修饰成员函数,表示函数是“只读”的,const对象只能调用const成员函数。

2.volatile

它表示变量可能会被“不被察觉”地修改,禁止编译器优化,影响性能,应当少用。

3.mutable

它用来修饰成员变量,允许const成员函数修改,mutable变量的变化不影响对象的常量性,但要小心不要误用损坏对象。

你今后再写类的时候,就要认真想一想,哪些操作改变了内部状态,哪些操作没改变内部状态,对于只读的函数,就要加上const修饰。写错了也不用怕,编译器会帮你检查出来。

总之就是一句话:尽可能多用const,让代码更安全。

这在多线程编程时尤其有用,让编译器帮你检查对象的所有操作,把“只读”属性持续传递出去,避免有害的副作用。

课下作业

最后是课下作业时间,给你留两个思考题:

  1. 学完了这节课,你觉得今后应该怎么用const呢?
  2. 给函数的返回值加上const,也就是说返回一个常量对象,有什么好处?

欢迎你在留言区写下你的思考和答案,如果觉得文章对你有所帮助,也欢迎把文章分享给你的朋友,我们下节课见。

精选留言

  • 无止境

    2020-05-21 09:57:05

    c++的指针和引用有啥区别老师?
    作者回复

    简单来说,指针是内存地址,引用是变量别名,指针可以是空,而引用不能为空。

    2020-05-21 10:44:29

  • Bluebuger

    2020-06-17 00:36:43

    volatile 在底层用的多,驱动、裸机开发这类。由于外部硬件设备,有部分处理器设计时候直接映射的内存地址,所以除了软件可以修改,硬件可能修改,所以需要让编译器不去优化这样的变量,必须从源头重新取值。
    作者回复

    对,volatile在底层开发的时候用的比较多,一般做上层应用开发、比如服务器、UI就很少用了。

    2020-06-17 09:23:17

  • 奋斗

    2021-05-25 21:13:52

    《1》volitate: cpu每次读取数据的时候,如果寄存器或者三级缓存中有该值,则直接使用,所以此时如果内存中的值被改变,值不会改变。如果加上volitate每次绕过寄存器和缓存直接从内存读取,此时内存中的值已经改变了。
    《2》mutable: 1、在lambal表达式中,如果捕获按值捕获,但是在函数体中想要修改,可以使用mutable
    2、多线程环境下如果某个成员函数,比如int get_count() const { },返回类中某个成员数量,势必会进行加锁保护变量达到线程安全,此时声明mutex必须是mutable的。
    int get_count() const {
    std::lock_guard<std::mutex> lock(m)
    return count;
    }
    在声明mutable std::mutex m; 需要加 mutable
    作者回复

    总结的非常好,awsome。

    2021-05-26 07:51:41

  • EncodedStar

    2020-05-22 14:44:01

    用好const 记住文章中的“ “const &”可以引用任何类型,是函数入口参数的最佳类型” 是重点
    作者回复

    这个是一个通用准则,但对于int、double这样小而简单的类型就有点“重”,int这样的内建类型可以直接用const T。

    Boost库里有一个call_traits,它可以推导出最佳类型。

    2020-05-22 16:08:22

  • 罗杰

    2020-08-04 13:21:07

    C++ 中volatile 关键字, 我感觉最关键的是要知道, 他根本不构成 同步语义, 多线程编程中要杜绝使用.
    记得之前看过一个资料, volatile 从C++ 标准中出现的原因是 为了解决 "硬件映射到内存上..." 的问题, 也就是说 一般的开发者, 根本不会涉及到这一块.

    java 中 volatile 和 C++ 中的 volatile 还不一样, java 中的volatile 是构成 happen-before的, 是可以使用在多线程编程当中的
    作者回复

    说得很好,一定不要把其他语言的经验简单地套用到C/C++里,特别是volatile这个关键字,差异非常大。

    2020-08-04 14:11:19

  • Stephen

    2020-07-31 19:59:54

    "const 定义的常量在预处理阶段并不存在,而是直到运行阶段才会出现。",老师,那编译阶段它也没有出现吗?
    作者回复

    是的,const常量本质上是变量,那么必然就是在运行阶段分配内存才能出现。

    但现在很多时候编译器会对const常量做优化,允许在编译阶段使用,但必须要说这并不是const常量的本意。

    2020-08-03 09:04:49

  • 木须柄

    2021-10-04 22:08:19

    万能引用 (universal reference) 一般是指在函数模板时传入的 "T&&" 这种形参形式,主要作用是用来同时匹配左值和右值实参的传入,这里我觉得罗老师更多是借用了这个概念,主旨是为了说明 "const &" 使用的广泛性
    作者回复

    在T&&出现之前,const&的确是万能引用,不过自从C++11新增了右值,“万能”的这个头衔就只能“让贤”了,笑。

    2021-10-05 20:41:53

  • IMBFD

    2020-09-08 00:17:21

    前辈在const函数那里为什么不说明其实是const修饰了this呢?这样就很好解释了
    作者回复

    单从成员函数的形式上来看,是看不到this指针的,我怕这么说会让有的同学更糊涂,所以就先简单地说一下,把详细的解释放在了小贴士里。

    2020-09-08 08:56:53

  • 宇天飞

    2020-09-01 08:38:48

    学完了这节课,你觉得今后应该怎么用 const 呢?
    1、修饰常量、成员变量、成员函数
    2、修饰类的时候注意const成员以及可变成员

    给函数的返回值加上 const,也就是说返回一个常量对象,有什么好处?
    使用更方便,防止意外修改
    作者回复

    学习的进展很快啊,不过还是要适当保持节奏,留一点思考的时间,欲速则不达。

    2020-09-01 09:03:03

  • 范闲

    2020-05-21 11:48:27

    1. effecttive里主要的用处就是const替换define,const成员函数,const &入参
    2.返回常量对象就是实际上保持了内部状态的不可变。不受外部影响,实际上也是不希望外部改变对象
    作者回复

    说的挺好的,其实在effective 里对const也花了很多的篇幅,用好const真的能够让代码更安全。

    2020-05-21 12:57:56

  • 陈英桂

    2020-06-04 08:55:19

    1、函数的入参,返回值还有变量的定义根据实际的情况,使用const来保证变量值只可以读,不可以修改。STL的迭代器也有const和非const,使用迭代器如果没有修改操作,尽量使用const版本的迭代器。
    2、函数的返回值总const,表示返回值只可以读
    作者回复


    1.说的的很对。

    2.const返回值可以强制函数的调用者无法修改,让外界用起来更安全。

    2020-06-04 09:31:54

  • 韩泽文

    2020-05-21 21:42:29

    我之前的理解好像与这个有偏差:
    const变量在未被优化时是分配到内存中,该内存页表标记为只读,不可写。 程序执行过程中尝试修改该内存就会页出错。
    同样的,const在编译阶段能够起到 安全作用,凡是同一个编译单元(同一个cc文件尝试修改它就会报错)
    上面提到的编译错误其实可以躲避编译器检查的,一般的定义时标注为const,另一cc文件引用时没有const,并且有修改操作,编译不报错的!
    以上是c语言的理解,不知道有没有问题,
    Cpp会对const变量符号修饰吗?
    作者回复

    我记得const最早是C++使用的,后来被借进了C。

    const常量其实就是变量,没有内存页标记这种机制,只是编译器会做检查,运行时不会有约束。

    2020-05-22 08:53:34

  • 搬铁少年ai

    2022-06-03 10:43:36

    函数返回值如果是return by value 就没必要加const了,除非返回的是一个引用。那可不可以不返回引用就直接返回一个值呢?可能具体情况还要具体看。多数情况应该没必要
    作者回复

    对的,一般来说返回const&是最常见的用法。

    2022-06-05 15:34:14

  • Geek_552f7a

    2024-05-29 22:57:10

    请问课外贴士第4点如何理解啊?无法声明 const this 是指什么,如果可以声明,函数应该写成什么样子呢?
    作者回复

    语法限制,C++不允许传入this指针,也就没有办法为this添加修饰。

    2024-05-30 11:14:28

  • 学习者

    2023-05-17 23:03:31

    打卡
    作者回复

    good

    2023-05-18 15:12:34

  • Loca..

    2022-10-16 11:55:50

    我有一个问题,1.既然const在运行阶段才出现,那么文章后面所说的,对没有用volatile的const常量即使指针修改了值,他还是没用,因为在编译阶段被优化了,一个运行阶段才出现的值,怎么会在编译阶段被优化。所以我的问题是,const是不是在编译阶段就已经出现了呢
    作者回复

    const在理论上只能在运行阶段出现,但现实情况是很多编译器为它做了特别的优化,而且因为const不变,在泛型和模板元编程里也可以在编译期使用。
    但编译阶段的常量正确来说应该用constexpr来声明。

    2022-10-16 18:02:30

  • Kermit

    2022-06-01 14:24:47

    (我对 const 在“*”后面的用法“深恶痛绝”,每次看到这种形式,脑子里都会“绕一下”,实在是太难理解了,似乎感觉到了代码作者“深深的恶意”)
    这句话我遇到一些case,比如 Object* const obj_; 后续一个方法会对Object 对象的属性进行update,这里是不是 使用* const 会更好点,因为毕竟还是要对obj进行数据update。
    作者回复

    个人意见,指针的用法对人的思维是种折磨,所以很多其他语言也都避免指针。

    C++延续了C,指针不能不用,但还是少用,尽量让人能够理解。

    * const用起来没问题,但日后的维护,别人看就不好说了。

    2022-06-01 20:23:28

  • 王兵

    2021-09-03 21:49:26

    公司里的c++代码的类成员函数有很多都是const int之类的基础类型函数返回值,一直不理解为啥要加const。个人理解没有任何意义。之前一直做c开发,对c++不太了解,望老师解惑。
    作者回复

    返回值是const就显式地表示不能被修改,调用者拿到结果后只能做常量应用。

    像这里返回const int,就意味着函数调用后出来的就是一个常量值,不是变量,可以避免一些意外的副作用。

    另外,在c++17/20之后,返回const int的函数还可以用在编译期编程里,而不仅局限在运行时。

    2021-09-04 12:43:58

  • 张飞Dear

    2020-08-06 20:46:14

    1,
    (1)定义函数入口参数,尽量多的用const, 对于一些输入参数 可以直接使用const & 万能引用来做入口。
    (2)在类中定义一些const 函数,让编译器更好的优化。
    (3)多用const 来定义一些常量,少用 #define 来定义常量,让代码更安全。
    2,返回常量对象,只读状态,不让外界进行操作。
    作者回复

    说得很好。

    2020-08-07 08:58:49

  • 怪兽

    2020-07-09 22:55:47

    1. 常量的名称都是大写,但前面加k前缀表。这是什么风格?为什么是k?
    2. constexpr关键字是表示编译阶段的常量,而const表示运行时期的常量,只不过被编译器优化了,是这样理解吗?const和constexpr还有什么区别吗?
    作者回复


    1,加k是有些公司的命名风格,k大概是const的简化吧。

    2.理解基本正确,constexpr还可以用在编译期计算,实现编译期函数、模板元编程。

    2020-07-10 09:04:56