前言
「常量」在 C++ 里不是一个关键字,而是一组相关概念的集合:const限定只读,constexpr要求编译期可求值,consteval要求必须在编译期求值,constinit要求静态初始化,此外还有enum、字面量、#define宏等老式做法。初学者最常见的误解是「加了const就是编译期常量」,于是写下const int n = read_from_file();之后又拿n去定义数组大小,结果在标准 C++ 里编译不过。
第二个误解是把const当成「运行时不能改」的银弹。const更多是一种接口契约:它告诉调用者和编译器「通过这个途径不会修改对象」,编译器据此做优化和重载选择。但它并不阻止对象在别处被修改,也不保证被const_cast去掉限定后写入是合法的——对一个原本定义为const的对象做这种写入是未定义行为。
本文按「限定符的语义 → 编译期求值 → C++20 的补充 → 类与接口中的用法」的顺序,把这几类常量讲清楚,并给出可以逐行推演的示例。代码基准为 C++17,涉及 C++20 的地方会单独标注。
一、const修饰的到底是什么
const修饰它左边(或最左边)的类型,但指针带来两个层次:指针本身不能改,和指针指向的对象不能改。
// 适用于 C++17
int a = 1;
int b = 2;
const int* p1 = &a; // 指向 const int:不能通过 p1 改 *p1
int* const p2 = &a; // const 指针:p2 不能改指向,但可以改 *p2
const int* const p3 = &a; // 两者都不能改
// *p1 = 3; // 错误:*p1 只读
p1 = &b; // 正确:p1 本身可以改指向
*p2 = 3; // 正确:a 不是 const 对象
// p2 = &b; // 错误:p2 本身只读从右往左读声明是常用的记忆法:const int* const p3读作「p3 是一个 const 指针,指向 const int」。
这里的关键是:p1指向a,但a本身不是 const 对象,所以通过p2修改a完全合法;const只是限制了p1这条访问路径。反过来说,如果对象本身定义为const int a = 1;,那么用const_cast去掉限定再写入就是 UB,哪怕底层内存可写。
1.1 引用与 const
引用本身没有「改指向」这一说,所以const只能修饰被引用的类型:
// 适用于 C++17
int x = 10;
const int& r = x; // 不能通过 r 修改 x
// r = 11; // 错误
x = 11; // 正确:x 本身可以被改const T&有一个重要能力:可以绑定到临时对象,并把临时对象的生命周期延长到引用的生命周期。这就是为什么函数参数常写成const std::string&——既能接受字面量和临时对象,又避免拷贝。
1.2 顶层 const 与底层 const
- 顶层 const(top-level const):对象本身是 const,例如
int* const、const int作为变量声明。 - 底层 const(low-level const):通过指针或引用访问的目标是 const,例如
const int*。
拷贝时顶层 const 会被忽略:可以把const int赋给int,因为拷贝的是值。底层 const 不能忽略:不能把const int*赋给int*,否则就绕过了只读保护。这也是函数重载的一个细节——顶层 const 不参与重载区分,底层 const 参与。
1.3 命名空间作用域的 const 是内部链接
在命名空间作用域写const int limit = 100;,这个变量默认具有内部链接(internal linkage),每个翻译单元各有一份。它和static的效果类似。如果希望多个源文件共享同一个常量,需要显式写extern const int limit = 100;并在头文件里声明;C++17 之后更推荐inline constexpr int limit = 100;,它既有外部链接又允许在头文件中定义。
二、constexpr:编译期可求值
constexpr修饰变量时,要求初始化表达式是常量表达式(constant expression);修饰函数时,表示「在实参是常量表达式的前提下,这个函数可以在编译期求值」。注意后半句:constexpr函数也可以在运行期调用,编译器不强制每次都编译期算。
// 适用于 C++17
constexpr int square(int x) { return x * x; }
constexpr int a = square(5); // 编译期求值
int n = 5;
int b = square(n); // 运行期求值,完全合法constexpr变量隐含const,但反过来不成立:
// 适用于 C++17
int f();
const int c = f(); // 运行期初始化,不是常量表达式
// constexpr int d = f(); // 错误:f() 不是 constexprconstexpr函数从 C++14 起放宽了很多限制:可以有局部变量、循环、多个返回语句、if分支等。C++11 的版本基本只允许一条 return 语句。写现代代码时按 C++14 及以后的规则即可,但要注意目标编译器的最低标准。
2.1constexpr与static_assert
static_assert的第一个参数必须是常量表达式,这正是constexpr最能体现价值的地方:
// 适用于 C++17
constexpr int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) {
result *= i;
}
return result;
}
static_assert(factorial(5) == 120, "5! should be 120");如果factorial不是constexpr,这行static_assert无法通过编译。注意static_assert的条件在编译期求值,运行期的n不能出现在这里。
2.2if constexpr
C++17 的if constexpr让分支在编译期选择,未选中的分支不会被实例化。它常用于模板里按类型分派:
// 适用于 C++17
#include <string>
#include <type_traits>
template <class T>
std::string describe(const T& value) {
if constexpr (std::is_integral_v<T>) {
return "integral: " + std::to_string(value);
} else {
return "other";
}
}if constexpr的条件必须是常量表达式。它和普通if的区别在于:普通if的两个分支都会被编译,即使其中一个在给定类型下不合法;if constexpr只实例化选中的分支,因此可以写「只对某些类型成立」的代码。
2.3 字面类型与常量表达式
不是所有类型都能用在constexpr变量上。字面类型(literal type)要求析构函数可访问且不抛异常(通常是平凡的或constexpr的),并且所有非静态数据成员和基类都是字面类型。std::string在 C++17 里不是字面类型,所以不能写constexpr std::string s = "abc";。C++20 起std::string在部分实现中支持constexpr,但这依赖标准库实现,不能当作 C++17 的通用写法。
三、C++20 的consteval与constinit
3.1consteval:立即函数
consteval修饰的函数叫立即函数(immediate function),每一次调用都必须在编译期产生常量。它比constexpr更严格:constexpr允许运行期调用,consteval不允许。
// 需要 C++20
consteval int square(int x) { return x * x; }
constexpr int a = square(5); // 正确
// int n = 5;
// int b = square(n); // 错误:n 不是常量表达式consteval适合做编译期校验、生成查表数据等场景。由于它要求实参是常量表达式,用在接口上会限制调用方,不要随手把普通函数改成consteval。
3.2constinit:保证静态初始化
constinit解决的是另一个问题:具有静态存储期的变量,如果初始化不是常量初始化,就可能产生静态初始化顺序问题——不同翻译单元的全局对象初始化顺序不确定,一个全局对象可能在另一个还没构造时就被使用。
// 需要 C++20
constinit int global_counter = 0; // 必须是常量初始化
// constinit int bad = std::rand(); // 错误constinit只作用于静态或线程存储期的变量,不隐含const,变量之后仍然可以修改。它和constexpr的区别是:constexpr要求变量本身只读且编译期可求值,constinit只要求初始化发生在编译期。一个变量可以同时是constinit和const,也可以只是constinit。
四、常量在类与接口中的位置
4.1 const 成员函数
在成员函数参数列表后加const,表示该函数不修改对象的可观察状态,this的类型变成指向 const 的指针。const 对象只能调用 const 成员函数。
// 适用于 C++17
#include <cstddef>
#include <vector>
class Buffer {
public:
explicit Buffer(std::size_t n) : data_(n) {}
std::size_t size() const noexcept { return data_.size(); }
int& operator[](std::size_t i) { return data_[i]; }
const int& operator[](std::size_t i) const { return data_[i]; }
private:
std::vector<int> data_;
};两个operator[]构成重载:非 const 对象拿到可写引用,const 对象拿到只读引用。如果只提供非 const 版本,const 对象就无法下标访问;如果只提供 const 版本,非 const 对象也改不了元素。
4.2mutable:const 函数里的可变成员
mutable允许成员在 const 成员函数中被修改,典型用途是缓存、互斥量、引用计数:
// 适用于 C++17
#include <cstddef>
#include <string>
class Query {
public:
explicit Query(std::string sql) : sql_(std::move(sql)) {}
const std::string& text() const {
if (!cached_) {
normalized_ = sql_; // mutable 成员,可以在 const 函数里改
cached_ = true;
}
return normalized_;
}
private:
std::string sql_;
mutable std::string normalized_;
mutable bool cached_ = false;
};mutable不是「绕过 const」的通用手段。它表达的是「这个成员的改变不影响对象的逻辑状态」,比如缓存。把业务数据标成mutable然后到处改,等于放弃了 const 的正确性检查。
4.3 类内静态常量
C++17 之前,类内的static const int如果被 ODR 使用(比如取地址、绑定引用),需要在类外再定义一次。C++17 引入内联变量后,可以直接写:
// 适用于 C++17
class Limits {
public:
inline static constexpr int kMax = 1024;
};inline static constexpr既是常量表达式,又有多份定义合并为一个的保证,可以安全地放在头文件里。C++17 之前常见写法是static const int kMax = 1024;加类外const int Limits::kMax;,现代代码不必再这样写。
完整示例:把常量用在编译期检查上
下面这个例子把constexpr计算、static_assert和类内常量组合起来,演示「错误尽量在编译期暴露」的思路。
// 适用于 C++17
#include <array>
#include <cstddef>
#include <iostream>
constexpr std::size_t fib(std::size_t n) {
std::size_t a = 0, b = 1;
for (std::size_t i = 0; i < n; ++i) {
std::size_t next = a + b;
a = b;
b = next;
}
return a;
}
static_assert(fib(0) == 0);
static_assert(fib(1) == 1);
static_assert(fib(10) == 55);
int main() {
constexpr std::size_t n = 10;
std::array<std::size_t, n> table{};
for (std::size_t i = 0; i < n; ++i) {
table[i] = fib(i);
}
for (std::size_t v : table) {
std::cout << v << ' ';
}
std::cout << '\n';
}fib是constexpr,所以既能出现在static_assert里,也能在运行期循环中调用。std::array的大小n是常量表达式,满足模板实参的要求。如果写成普通const std::size_t n = 10;,在 C++17 里它仍然是常量表达式(因为初始化器是字面量),但换成运行期函数返回值就不行了——这正是「const 不等于 constexpr」的边界。
常见坑点
❌ 认为const int n = f();可以用作数组大小。
✅ 数组大小需要常量表达式,用constexpr int n = f();并确保f是constexpr;否则改用std::vector。
❌ 写const int a;不初始化。
✅ const 对象必须在定义时初始化,写成const int a = 0;。类成员则通过构造函数初始化列表初始化。
❌ 对原本定义为 const 的对象使用const_cast后写入。
✅ 需要修改就不要定义成 const;const_cast只应用于「接口约定为 const、但底层对象本来可写」的少数场景,写 const 对象是 UB。
❌ 在头文件里写const int kLimit = 10;,然后在多个源文件中期望它是同一个对象。
✅ 命名空间作用域的 const 默认内部链接,每个翻译单元一份;要共享就写inline constexpr int kLimit = 10;。
❌ 把const成员函数写成「一定不会改任何成员」,结果无法更新缓存。
✅ 缓存、互斥量等不影响逻辑状态的成员用mutable,其余保持 const 正确性。
❌ 返回const T(按值)试图阻止调用方修改。
✅ 按值返回本来就不该加 const,它只影响重载解析和移动,现代写法直接返回T。
❌ 用#define MAX 1024定义常量。
✅ 用constexpr int kMax = 1024;,有类型、有作用域、可调试,也不会被宏展开误伤。
❌ 认为constexpr函数一定在编译期执行。
✅constexpr只表示「可以」在编译期求值,运行期调用同样合法;要求必须编译期执行用consteval(C++20)。
总结
| 写法 | 是否只读 | 是否编译期 | 典型用途 |
|---|
const int | 是 | 不一定 | 运行期确定的只读值 |
constexpr int | 是 | 是 | 数组大小、模板实参、编译期计算 |
consteval函数 | 不适用 | 必须 | 编译期校验、生成常量 |
constinit变量 | 否 | 初始化在编译期 | 避免静态初始化顺序问题 |
#define | 否 | 文本替换 | 不推荐,改用 constexpr |
enum/enum class | 是 | 是 | 具名整数常量集合 |
常量设计的核心不是「多加 const 就安全」,而是把「哪些值在编译期确定」「哪些接口承诺不修改对象」「哪些状态变化不影响逻辑」这三件事分别表达清楚。const管接口契约,constexpr管编译期求值,consteval管强制编译期,constinit管静态初始化顺序,各司其职。
本文代码均按标准语义人工推演,未在本机编译运行;涉及 C++20 的特性请以目标编译器的支持表为准。