# C++ const与constexpr的使用 ## const `const` 是一种类型限定符,限定变量具有“只读”权限。它可以用于变量、函数参数和函数返回类型。声明为 `const` 的变量的值在初始化后就不能再改变。 ### const与#define比较 - 编译器处理方式不同:`#define` 是预处理阶段,`const` 是编译阶段; - 类型安全检查不同:`#define` 不做类型检查,`const` 有具体的类型,在编译会执行检查。`#define` 只是简单的替换,没有类型,`const` 可以做到防窜改与类型安全; - 存储方式不同:`#define` 不会分配内存,存储在代码段,`const` 会分配内存,存储在常量区; - 作用域不同:`const` 有作用域的概念,函数内部定义的 `const` 变量只能在函数内部使用。而 `#define` 没有作用域的概念(除非使用 `ifdef` 配合),一个函数内定义的宏定义变量可以在另外一个函数中使用。 ### const修饰普通变量 `const` 修饰的变量,本质是变量,但是不能直接修改,有常量的属性,称为常变量。常变量一旦创建后其值就不能再改变,所以必须初始化。 ```cpp const int a = 1; int const b = 1; //等价于a const int a; // err, 必须初始化 ``` 如果利用一个变量去初始化另外一个变量,则它们是不是 `const` 都无关紧要。常变量的常量特征仅仅在执行改变常变量的操作时才会发挥作用。 ```cpp int i = 42; const int ci = i; // ok int j = ci; // ok ``` ### const修饰引用 `const` 修饰的引用,称为常量引用或常引用。不能通过常量引用改变对应的对象的值。 ```cpp const int ci = 10; const int& r1 = ci; // ok r1是常量引用,对应的对象ci也是常量 r1 = 20; // err 不能通过常量引用改变对应的对象的值 int& r2 = ci; // err ci不能改变,当然也就不能通过引用去改变ci // 假设合法,则可以通过r2来改变ci,这是不合法的 ``` 在初始化常量引用时允许用任意表达式作为初始值,只要该表达式的结果能转换成引用的类型即可。 ```cpp int i = 50; const int& r1 = i; // ok 将int常量引用绑定到int变量上 int& r2 = i; // ok 将int引用绑定到int变量上 const int& r3 = 10; // ok 将int常量引用绑定到int右值上 int& r4 = 10; // err 非常量引用不能绑定右值 const int& r5 = 66.6f; // ok 将int常量引用绑定到float右值上 int& r6 = 66.6f; // err 非常量引用不能绑定右值 double pi = 3.14; const int& rpi = pi; // ok 将int常量引用绑定到double变量上 int& rpi = pi; // err 非常量引用的类型要和所绑定的对象的类型严格匹配 ``` 我们要清楚当一个常量引用被绑定到另外一种类型上时到底发生了什么: ```cpp double pi = 3.14; const int& rpi = pi; // ok 将const int&绑定到一个普通double对象上 ``` 为什么 `rpi`能够绑定 `pi`?为了让 `rpi`绑定一个整型对象,编译器把代码处理为: ```cpp const int temp = pi; // 隐式类型转换 double->const int const int& rpi = temp; // 让rpi绑定这个临时量 ``` 所以当一个常量引用被绑定到另外一种类型上时,常量引用绑定的其实是相同类型的临时量。 ### const修饰指针 `const` 放在 * 的右边,修饰的是指针本身,指针是常量指针。 - 指针本身的值不能改变 - 指针指向的值可以改变 ```cpp int m = 5; int n = 10; int* const p = &m; // p是常量指针 p = &n; // err 指针本身的值不能改变 *p = 0; // ok 指针指向的值可以改变 ``` 常量指针(`const pointer`)必须初始化,而且一旦初始化完成,则它的值(也就是存放在指针中的那个地址)就不能再改变了。 `const` 放在 * 的左边,修饰的是指针指向的值,指针是指向常量的指针。 - 指针指向的值不能改变 - 指针本身的值可以改变 ```cpp int m = 5; int n = 10; const int* p = &m; // p是指向常量的指针 // 等价于int const* p = &m; *p = 0; // err 指针指向的值不能改变 p = &n; // ok 指针本身的值可以改变 ``` `const` 放在 * 的左右两边,修饰的是既是指针本身,又是指针指向的值,指针是指向常量的常量指针。 - 指针本身的值不能改变 - 指针指向的值不能改变 ```cpp const int n = 10; const int* const p = &n; // p是指向常量的常量指针 p = &n; // err 指针本身的值不能改变 *p = 0; // err 指针指向的值不能改变 ``` 顶层 `const` 和底层 `const` 如前所述,指针本身是一个对象,它又可以指向另外一个对象。因此,指针本身是不是常量以及指针所指的是不是一个常量就是两个相互独立的问题。用名词顶层 `const(top-level const)` 表示指针本身是个常量,而用名词底层 `const(low-level const)` 表示指针所指的对象是一个常量。顶层 `const` 可以表示任意的对象是常量,这一点对任何数据类型都适用,如算术类型、类、指针等。底层const则与指针和引用等复合类型的基本类型部分有关。比较特殊的是,指针类型既可以是顶层 `const` 也可以是底层 `const`,这一点和其他类型相比区别明显: ```cpp int i = 0; int* const p1 = &i; // 顶层const,不能改变p1的值 const int ci = 42; // 顶层const,不能改变ci的值 const int* p2 = &ci; // 底层const,可以改变p2的值 const int* const p3 = p2; // 靠右的const是顶层const,靠左的是底层const const int& r = ci; // 用于声明引用的const都是底层const ``` 当执行对象的拷贝操作时,常量是顶层 `const` 还是底层 `const` 区别明显。其中,顶层 `const` 不受什么影响: ```cpp i = ci; // ok 拷贝ci的值,ci是一个顶层const,对此操作无影响 p2 = p3; // ok p2和p3指向的对象类型相同,p3顶层const的部分不影响 ``` 执行拷贝操作并不会改变被拷贝对象的值,因此,拷入和拷出的对象是否是常量都没什么影响。 另一方面,底层 `const` 的限制却不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象必须具有相同的底层 `const` 资格,或者两个对象的数据类型必须能够转换。一般来说,非常量可以转换成常量,反之则不行: ```cpp int* p = p3; // err p3包含底层const的定义,而p没有 p2 = p3; // ok p2和p3都是底层const p2 = &i; // ok int*能转换成const int* int &r = ci; // err 普通的int&不能绑定到int常量上 const int &r2 = i; // ok const int&可以绑定到一个普通int上 ``` `p3` 既是顶层`const`也是底层`const`,拷贝`p3`时可以不在乎它是一个顶层`const`,但是必须清楚它指向的对象得是一个常量。因此,不能用`p3`去初始化`p`,因为`p`指向的是一个普通的(非常量)整数。另一方面,`p3`的值可以赋给`p2`,是因为这两个指针都是底层`const`,尽管`p3`同时也是一个常量指针(顶层`const`),仅就这次赋值而言不会有什么影响。 ### const修饰类对象 `const` 修饰类对象表示该对象为常量对象,其中的任何成员都不能被修改。对于对象指针和对象引用也是一样。`const` 修饰的类对象,该对象的任何非 `const` 成员函数都不能被调用,因为任何非 `const` 成员函数会有修改成员变量的企图。 ### const修饰函数形参 函数参数类型前加 `const` 指明该参数为常量,在函数内部不可改变。 ```cpp void func(const int x) { //x不可以在内部进行赋值等操作. } ``` ### const修饰函数返回值 `const` 修饰函数返回值的主要作用是限制返回值的可修改性,具体作用取决于返回值的类型和传递方式: **修饰值类型返回值** 对于内置类型的值传递(如 `int func()`),`const` 修饰没有实际意义,因为返回值会被复制到临时对象中,临时对象本身已是不可修改的右值。但对于自定义类型,返回 `const A` 可能影响临时对象的操作权限(不能调用非 `const` 成员函数和阻止链式调用中的非 `const` 操作)。 **修饰指针类型返回值** 需区分三种 `const` 修饰位置: - ``` const T* ``` :指向常量数据的指针(数据不可改) - ``` T* const ``` :常量指针(指针不可改) - ``` const T* const ``` :双重保护(指针和数据均不可改) ```cpp const int* getConstPointer() { /*...*/ } // 情况1 int* const getPointerConst() { /*...*/ } // 情况2 const int* const getConstPointerConst() { /*...*/ } // 情况3 ``` **修饰引用类型返回值** 对于返回引用的情况,`const` 会禁止将函数调用表达式作为左值使用。例如 `const int& func()` 的返回值不能被赋值(如 `func() = 10` 会报错),但引用的原始变量仍可通过其他方式修改。这种用法常见于操作符重载时避免逻辑错误,如防止 `(a+b)=c` 这类意外赋值。 **注:`const` 成员函数返回成员变量的引用时,必须使用 `const` 修饰以维持常量性。** **与左值的关系** 在 C++ 中,返回值、指针和引用能否作为左值的区别源于它们的底层语义和生命周期管理机制: 1. 返回引用(能作为左值) - 本质:返回的是已有变量的别名(内存地址绑定)。 - 左值特性:直接关联到持久存储位置(如全局/静态变量、通过参数传入的对象)。 - 但如果返回 `const int&` 将不能作为左值。 ```cpp int& func() { static int x = 1; return x; } func() = 10; // 合法,修改的是static变量x 返回值加上const则不合法 ``` 2. 返回值(不能作为左值) - 本质:生成临时对象的副本(纯右值)。 - 限制:临时对象在表达式结束后销毁,无持久内存地址。 - 无论是否 `const` 返回值都不行 ```cpp int func() { return 42; } func() = 10; // 错误:临时int对象不可修改 ``` 3. 返回指针(不能直接作为左值) - 本质:返回的是地址值的副本(右值)。 - 限制:指针本身是临时值,但可通过解引用修改目标。 - 无论是否 `const` 返回值,单纯的 `func()` 都不能当左值。但对于全局变量等持久化地址的指针可以通过解引用即 `*func()` 作为左值。 ```cpp int* func() { static int x = 1; return &x; } *func() = 10; // 合法:修改指针指向的内容 func() = nullptr; // 错误:指针返回值是右值 ``` 关键区别总结: - ‌**引用**‌:持久化别名(左值) - ‌**值/指针**‌:临时副本(右值) ### const修饰成员函数 在成员函数末尾添加 `const`,表示承诺不修改调用该函数的对象实例的状态。对于非静态成员(非静态成员变量和非静态成员函数),该函数不会修改类的成员变量(除非变量被 `mutable` 修饰),但允许读取,也不能调用非`const`成员函数,但能够调用const成员函数。对于静态成员(包括变量和函数)拥有完全权限(可读、可写、可调用)。在const成员函数内部,`this` 指针的类型从 `ClassName* const` 变为 ‌**`const ClassName\* const`** ```cpp class MyClass { void SetValue(int a) const { value = a; } // const成员函数 }; ``` ### const与非const转换 | 操作 (赋值方向) | 语法示例 | 是否允许 | 原因 | | :------------------------- | :----------------------- | :------- | :------------------------------------- | | `const`对象 `->` 普通对象 | `T a = getConstT()` | 允许 | 拷贝。创建独立副本,安全。 | | 普通对象 `-> const` 对象 | `const T a = getT()` | 允许 | 拷贝+自我约束。权限收缩,安全。 | | `const` 指针 `->` 普通指针 | `T* p = getConstTPtr()` | 禁止 | 权限扩张。可能导致修改常量数据,危险。 | | 普通指针 `-> const` 指针 | `const T* p = getTPtr()` | 允许 | 权限收缩。承诺不修改数据,安全。 | | `const` 引用 `->` 普通引用 | `T& r = getConstTRef()` | 禁止 | 权限扩张。危险。 | | 普通引用 `-> const` 引用 | `const T& r = getTRef()` | 允许 | 权限收缩。安全且高效(避免拷贝)。 | 在 C++ 的类型系统中,添加 `const` 限定符(无论是对象本身,还是指针/引用所指向的对象)总是安全的,编译器允许这样做。而移除 `const` 限定符则通常是危险的,需要编译器强制干预(如`const_cast`),并且应极其谨慎使用。 ### const内部链接属性 默认状态下,`const` 对象仅在文件内有效,当以编译时初始化的方式定义一个 `const` 对象时,就如对 `bufSize` 的定义一样: ```cpp const int bufSize = 512; ``` 编译器将在编译过程中把用到该变量的地方都替换成对应的值(**在大多数实际使用场景中(尤其是开启优化后)**)。也就是说,编译器会找到代码中所有用到 `bufsize` 的地方,然后用 `512` 替换。 为了执行上述替换,编译器必须知道变量的初始值。如果程序包含多个文件,则每个用了 `const` 对象的文件都必须得能访问到它的初始值才行。要做到这一点,就必须在每一个用到变量的文件中都有对它的定义。为了支持这一用法,同时避免对同一变量的重复定义,默认情况下,`const` 对象被设定为仅在文件内有效。当多个文件中出现了同名的 `const` 变量时,其实等同于在不同文件中分别定义了独立的变量。 某些时候有这样一种 `const` 变量,它的初始值不是一个常量表达式,但又确实有必要在文件间共享。这种情况下,我们不希望编译器为每个文件分别生成独立的变量。相反,我们想让这类 `const` 对象像其他(非常量)对象一样工作,也就是说,只在一个文件中定义 `const`,而在其他多个文件中声明并使用它。 解决的办法是,对于 `const` 变量不管是声明还是定义都添加 `extern` 关键字,这样只需定义一次就可以了: ``` // file_1.cpp定义并初始化了一个常量,该常量能被其他文件访问 extern const int bufSize = fun(); // file_1.h头文件 extern const int bufsize; // 与file_1.cpp中定义的bufSize是同一个 ``` 如上述程序所示,`file_1.cpp` 定义并初始化了 `bufsize`。因为这条语句包含了初始值,所以它(显然)是一次定义。然而,因为 `bufsize` 是一个常量,必须用 `extern` 加以限定使其被其他文件使用。`file_1.h` 头文件中的声明也由 `extern` 做了限定,其作用是指明 `bufsize` 并非本文件所独有,它的定义将在别处出现。如果想在多个文件之间共享 `const` 对象,必须在变量的定义之前添加 `extern` 关键字。const 本身不直接指定链接性(C++ 默认给内部链接,C默认给外部链接,但可以通过 extern 和static覆盖)。 ## constexpr 常量表达式(const expression)是指值不会改变并且在编译过程就能得到计算结果的表达式。显然,字面值属于常量表达式,用常量表达式初始化的 `const` 对象也是常量表达式。 一个对象(或表达式)是不是常量表达式由它的数据类型和初始值共同决定,例如: ``` const int max_files = 20; // max_files是常量表达式 const int limit = max_files + 1; // limit是常量表达式 int staff_size = 27; // staff_size不是常量表达式 const int sz = get_size(); // sz不是常量表达式 ``` 尽管 `staff_size` 的初始值是个字面值常量,但由于它的数据类型只是一个普通 `int` 而非 `const int`,所以它不属于常量表达式。另一方面,尽管 `sz` 本身是一个常量,但它的具体值直到运行时才能获取到,所以也不是常量表达式。 ### constexpr变量 在一个复杂系统中,很难(几乎肯定不能)分辨一个初始值到底是不是常量表达式。当然可以定义一个 `const` 变量并把它的初始值设为我们认为的某个常量表达式,但在实际使用时,尽管要求如此却常常发现初始值并非常量表达式的情况。可以这么说,在此种情况下,对象的定义和使用根本就是两回事儿。 C++11 新标准规定,允许将变量声明为 `constexpr` 类型以便由编译器来验证变量的值是否是一个常量表达式。声明为 `constexpr` 的变量一定是一个常量,而且必须用常量表达式初始化: ```cpp constexpr int mf = 20; // 20是常量表达式 constexpr int limit = mf + 1; // mf+1是常量表达式 constexpr int sz = size(); // 只有当size是一个constexpr函数时, // 才是一条正确的声明语句 ``` 尽管不能使用普通函数作为 `constexpr` 变量的初始值,但是新标准允许定义一种特殊的 `constexpr` 函数。这种函数应该足够简单以使得编译时就可以计算其结果,这样就能用 `constexpr` 函数去初始化 `constexpr` 变量了。一般来说,如果你认定变量是一个常量表达式,那就把它声明成 `constexpr` 类型。 ### 字面值类型 常量表达式的值需要在编译时就得到计算,因此对声明 `constexpr` 时用到的类型必须有所限制。因为这些类型一般比较简单,值也显而易见、容易得到,就把它们称为字面值类型(literal type)。 算术类型、引用、指针、字面值常量类、枚举都属于字面值类型。 尽管指针和引用都能定义成 `constexpr`,但它们的初始值却受到严格限制。一个 `constexpr` 指针的初始值必须是 `nullptr` 或者 `0`,或者是存储于某个固定地址中的对象。 函数体内定义的变量一般来说并非存放在固定地址中,因此 `constexpr` 指针不能指向这样的变量。相反的,定义于所有函数体之外的对象其地址固定不变,能用来初始化 `constexpr` 指针。允许函数定义一类有效范围超出函数本身的变量,这类变量和定义在函数体之外的变量一样也有固定地址。因此,`constexpr` 引用能绑定到这样的变量上,`constexpr` 指针也能指向这样的变量。 ### constexpr指针 必须明确一点,在 `constexpr` 声明中如果定义了一个指针,限定符 `constexpr` 仅对指针有效,与指针所指的对象无关: ``` const int* p = nullptr; // p是一个指向整型常量的指针 constexpr int* q = nullptr; // q是一个指向整数的常量指针 ``` `p` 和 `q` 的类型相差甚远,`p` 是一个指向常量的指针,而 `q` 是一个常量指针,其中的关键在于 `constexpr` 把它所定义的对象置为了顶层 `const`。 与其他常量指针类似,`constexpr` 指针既可以指向常量也可以指向一个非常量: ``` // i和j都必须定义在函数体之外 int j = 0; constexpr int i = 42; // i的类型是整型常量 constexpr int* np = nullptr; // np是一个指向整数的常量指针,其值为空 constexpr const int* p = &i; // p是常量指针,指向整型常量i constexpr int* p1 = &j; // p1是常量指针,指向整数j ``` ## const与constexpr特性差别 | 特性 | const | constexpr | | :--------- | :----------------------------------- | :----------------------------------------------- | | ‌主要语义‌ | ‌只读性‌ (Read-only) | ‌编译期求值‌ (Compile-time evaluation) | | ‌初始化时机‌ | 可以是‌编译期‌,也可以是‌运行期‌ | ‌必须‌是‌编译期‌ | | ‌变量可变性‌ | 初始化后‌不可修改‌ | 初始化后‌不可修改‌ (隐含 const) | | ‌适用场景‌ | 防止意外修改、保护接口、成员函数修饰 | 数组大小、模板参数、开关 case、性能优化 | | ‌修饰函数‌ | 修饰成员函数表示不修改对象状态 | 修饰函数表示该函数‌可以‌在编译期执行 | | ‌内存分布‌ | 可能在栈、堆或全局区 | 通常直接嵌入指令或存储在只读数据段,无运行时开销 |