更多 C++ 文章见《修远之路(C++集萃)》专栏
C++有如下四种常量:
| 关键字 | 核心本质 | 求值与初始化 | 说明 |
|---|---|---|---|
const |
只读 (Read-Only) 接口契约。 | 运行时或编译期求值; 在运行时或静态初始化阶段完成初始化。 | 典型用于限制入参修改、运行时只读配置。 |
constexpr |
编译期常量 (Constant Expression)。 | 强制编译期求值; 在编译期或静态初始化阶段完成初始化。 | 作为数组长度、模板参数、元编程的基石。 |
consteval |
立即函数 (Immediate Function)。 | 严格强制编译期求值; 在编译期完成初始化。 | 仅能修饰函数。典型用于硬件配置校验、编译期字符串解析。 |
constinit |
静态初始化断言 (Static Init Assertion)。 | 编译期求值(初始化表达式);强制在静态初始化阶段完成初始化。 | 约束为静态或线程局部存储。用于彻底解决 SIOF 问题的全局非常量变量。 |
关键字深度解析
const:只读契约与指针/引用语义
- 非绝对物理只读:
const在 C++ 中代表Read-only(只读视图)而非Immutable(不可变内存)。若对象分配在栈或堆上,通过const_cast强转修改其内容,在语法上可行,但如果对象本身处于只读数据段(.rodata),强转修改会导致 Segment Fault (OOB/Access Violation)。 - 物理内存分配:全局
const变量若未被取地址且编译器完成了常量折叠,可能不占用符号表与物理内存。
在系统编程中,涉及多级指针、无符号类型转换或硬件 DMA 缓冲区时,必须精准区分:
arduino
// 生产级内存映射/指针控制示例
using byte = unsigned char;
void process_buffer(const byte* const src, byte* const dest, size_t size) {
// src: 底层 const + 顶层 const -> 指针不能动,指针指向的数据也不能动 (输入源)
// dest: 顶层 const -> 指针不能动,但指向的数据可写 (输出缓冲区)
// 输入校验(防御性设计,不允许静默失败)
if (!src || !dest || size == 0) {
throw std::invalid_argument("Invalid buffer pointers or size.");
}
for (size_t i = 0; i < size; ++i) {
dest[i] = src[i];
}
}
constexpr:编译期计算
-
计算右移 (Shift-Left on Computation):通过将计算移至编译期,不仅实现了零运行时开销 (Zero-overhead),还能将原本需要运行时进行的错误捕获提前到编译期(通过编译期
throw触发编译失败)。 -
C++14/20 的演进契机:
- C++14 解除了纯函数限制,允许在
constexpr函数内部使用局部变量、条件分支、循环。 - C++20 甚至支持了
constexpr std::vector和std::string(可在编译期进行动态内存分配,但内存必须在编译期结束前释放)。
- C++14 解除了纯函数限制,允许在
示例:编译期 CRC32 校验
arduino
#include <string_view>
#include <cstdint>
#include <array>
#include <stdexcept>
class Crc32 {
private:
// C++14 兼容的编译期查找表生成
static constexpr std::array<uint32_t, 256> make_table() noexcept {
std::array<uint32_t, 256> table{};
for (uint32_t i = 0; i < 256; ++i) {
uint32_t ch = i;
for (size_t j = 0; j < 8; ++j) {
ch = (ch & 1) ? (0xEDB88320 ^ (ch >> 1)) : (ch >> 1);
}
table[i] = ch;
}
return table;
}
// C++17 inline constexpr 允许在类内直接定义并初始化静态成员
static inline constexpr auto table = make_table();
public:
// constexpr 函数:既可在编译期计算,也可在运行时计算
static constexpr uint32_t calculate(std::string_view str) noexcept {
uint32_t crc = 0xFFFFFFFF;
for (char c : str) {
crc = table[(crc ^ static_cast<uint8_t>(c)) & 0xFF] ^ (crc >> 8);
}
return ~crc;
}
};
// 生产验证
void daemon_init() {
// 编译期求值:完全消除了运行时的字面量哈希开销
constexpr uint32_t kConfigHash = Crc32::calculate("SYS_CONFIG_V1");
static_assert(kConfigHash == 0x7E303DCE, "Compile-time CRC32 verification failed.");
}
consteval:强不可逆的立即函数
constexpr函数具有双重性(参数为非常量时降级为运行时执行)。如果系统架构要求某项高耗时计算绝对不允许流向运行时(例如:大型密码学 S 盒生成、固件校验和),必须使用consteval。
示例:强制编译期哈希避免运行时解析
arduino
#include <string_view>
#include <stdexcept>
// consteval 确保运行时没有任何字符串处理
consteval uint64_t fnv1a_hash(std::string_view str) noexcept {
uint64_t hash = 0xcbf29ce484222325;
for (char c : str) {
hash ^= static_cast<uint64_t>(c);
hash *= 0x00000100000001B3;
}
return hash;
}
void route_message(uint64_t msg_id) {
// 如果不小心传入运行时变量,编译器会立刻熔断报错
switch (msg_id) {
case fnv1a_hash("LOG_EVENT"): // 100% 编译期常量
break;
case fnv1a_hash("NETWORK_ERR"):
break;
}
}
constinit:终结静态初始化顺序陷阱
-
静态初始化顺序陷阱(Static Initialization Order Fiasco,SIOF ):不同编译单元(
.cpp)中的全局静态变量,其初始化顺序在标准中是未定义的。若A.cpp的全局变量依赖B.cpp的全局变量,极易引发运行时未定义行为或崩溃。 -
底层机理:C++ 静态存储期变量的初始化分为两个阶段:静态初始化(Static Initialization)与动态初始化(Dynamic Initialization)。
- 静态初始化包含零初始化(Zero)与常量初始化(Constant),发生在程序加载的第一时间(Linux 平台下表现为在操作系统加载 ELF 镜像并跳转到
_start入口之后、调用.init_array节槽位之前,直接将编译期字面量写入特定数据段); - 而动态初始化涉及运行时构造函数调用。SIOF 的根源在于不同编译单元间动态初始化的顺序不可控。
constinit强制断言修饰的变量必须在"静态初始化"阶段完成赋值(即要求初始化器必须是编译期常量表达式),彻底阻断了其进入动态初始化阶段的可能,从而根治 SIOF。
- 静态初始化包含零初始化(Zero)与常量初始化(Constant),发生在程序加载的第一时间(Linux 平台下表现为在操作系统加载 ELF 镜像并跳转到
-
注意:
constinit保证的是"初始化安全",它修饰的变量不是常量,后续运行中可以随意修改。它与constexpr的本质区别在于:constexpr隐式包含const属性且要求变量本身不可变constinit仅规范初始化时机,不改变变量的可变性(Mutability)。

示例:可安全访问的全局单例/上下文
arduino
#include <cstdint>
struct SystemMetrics {
uint64_t total_requests;
double alpha_weight;
// 提供 constexpr 构造函数以支持常量初始化
constexpr SystemMetrics(uint64_t req, double weight) noexcept
: total_requests(req), alpha_weight(weight) {}
};
// 使用 constinit 确保该全局上下文在程序加载的 Phase-0 阶段即静态初始化完成
// 它免除了 SIOF 烦恼,且它不是 const,运行时工作线程可以自由对其进行原子或并发修改
inline constinit SystemMetrics g_system_metrics{0, 0.85};
constexpr VS consteval
都为编译期计算的基石
-
基本目标:都旨在将计算任务从"运行时"向"编译期"右移(Shift-Left),从而实现零运行时开销(Zero-overhead),并允许将计算结果用于需要常量表达式的上下文(如模板参数、
static_assert、数组长度)。 -
语法限制:其内部代码都必须符合 C++ 标准对常量表达式函数(constexpr function)的限制。如:
- 不能包含运行时才确定的动态未定义行为
- 不能调用非
constexpr函数 - 不能使用
goto语句(C++14 后放宽了循环和局部变量限制,C++20 后支持了编译期动态内存分配但必须在编译期结束前释放)。
-
隐式内联:修饰函数时,两者都会隐式地将函数标记为
inline。
两者间的差异
| 维度 | constexpr (C++11 引入) | consteval (C++20 引入) |
|---|---|---|
| 核心本质 | 条件性/双重性常量函数 | 立即函数 (Immediate Function) |
| 求值时机 | 编译期或运行时。如果参数是编译期常量,且其结果被用于常量上下文,则在编译期求值;否则会退化为普通的运行时函数。 | 严格强制编译期求值。每一次调用都必须在编译期完成,绝不允许流向运行时。 |
| 修饰对象 | 既可以修饰函数,也可以修饰变量(表示该变量是编译期常量且不可变)。 | 只能修饰函数,不能修饰变量。 |
| 不满足编译期条件的行为 | 传入运行时变量时,正常降级并在运行时执行。 | 传入运行时变量,或者无法在编译期完成求值时,编译器直接熔断报错。 |
面向对象中的常量设计
常量成员函数与 mutable 的权衡
- 逻辑常数性 vs 物理常数性:成员函数声明为
const(如int get_data() const;)向调用者承诺不改变对象的逻辑状态。 - Mutable 设计:在多线程高并发系统软件中,为了实现线程安全的只读访问,经常需要在
const成员函数内部加锁或读写缓存。这时必须引入mutable关键字修饰成员锁或缓存组件,从而在物理内存可变的前提下,捍卫逻辑层面的只读契约。
arduino
#include <shared_mutex>
#include <string>
#include <stdexcept>
class ThreadSafeConfig {
private:
std::string config_path_;
mutable std::shared_mutex rw_mutex_; // 为了在 const 函数中控制并发,必须为 mutable
mutable std::string cached_data_ {};
mutable bool is_dirty_{true};
public:
explicit ThreadSafeConfig(std::string path) : config_path_(std::move(path)) {
if (config_path_.empty()) {
throw std::invalid_argument("Config path cannot be empty.");
}
}
// 虽然是 const 函数,但通过 mutable 配合 shared_mutex 实现了高性能并发读与延迟加载
std::string get_config() const {
// 1. 先施加读锁(共享锁),允许多线程并发读取缓存
{
std::shared_lock<std::shared_mutex> read_lock(rw_mutex_);
if (!is_dirty_) {
return cached_data_;
}
}
// 2. 缓存失效,升级为写锁(排他锁)刷新缓存
std::unique_lock<std::shared_mutex> write_lock(rw_mutex_);
// 双检锁模式,防止并发写锁排队时重复加载
if (is_dirty_) {
// 模拟高昂的 IO 物理读取
cached_data_ = "Loaded_Data_From_" + config_path_;
is_dirty_ = false;
}
return cached_data_;
}
};
传递与返回的现代 C++ 规范
-
入参:优先采用
const T&避免大对象拷贝;对于简单标量(如int,char,float、std::string_view、std::span),直接值传递性能更高,无须加const。 -
出参:
- 绝不要返回局部对象的
const T。这会强制破坏 C++11 的移动语义 (Move Semantics),阻碍编译器进行 RVO (返回值优化)。 - 如果返回类内部大数组成员,且不想发生拷贝,应返回
const T&。
- 绝不要返回局部对象的
统一常量编码规范
现代 C++ 风格指导
- 全面弃用宏常量:全面禁止
#define MATCH_SIZE 1024。宏不受命名空间约束,无类型保护,且在符号调试(GDB/LLDB)中完全隐形。 - 默认
constexpr优先:凡是编译期能确定的配置、大小、字面量变换,一律声明为constexpr。 - 命名空间污染隔离:现代常量应收敛于特定的
namespace内,避免污染全局域。
常量声明示例
arduino
#include <string_view>
#include <cstddef>
#include <cstdint>
namespace sys::config {
// 基础标量常量
inline constexpr std::string_view kEngineVersion = "v2.4.1";
inline constexpr size_t kMaxNetworkPacketSize = 65536;
inline constexpr double kEpsilon = 1e-6;
// 复杂配置结构体编译期初始化
struct DeviceProperty {
uint32_t id;
size_t alignment;
};
inline constexpr DeviceProperty kDefaultDmaProp{0x0A, 4096};
}
高频误区
-
const代表性能优化- 真相:除了作为编译期字面量的
const会被折叠外,运行时的const T&往往因为指针别名问题 (Pointer Aliasing) 限制了编译器的寄存器优化。当编译器在一个作用域内同时看到const T*和T*时,由于无法确信非const指针是否在暗中修改了同一块物理内存(Strict Aliasing),它不得不放弃寄存器缓存优化,在每次读取时都生成一条防御性的内存重载指令(Memory Reload)。
- 真相:除了作为编译期字面量的
-
constexpr函数只能在编译期跑- 真相:
constexpr函数是非常温和的。除非你在要求强制常量上下文(如static_assert、数组定义、constexpr变量声明)中调用它,否则传入运行时参数时,它会自动退化为普通的运行时函数。
- 真相:
-
通过
const_cast修改const对象是安全的- 真相:如果原对象声明时就是
const(处于只读存储区 ),对其进行const_cast并写值属于未定义行为 (Undefined Behaviour),在底层通常会引发段错误。只有当原对象本身是非const的,只是在传递过程中被const&引用包裹时,强转修改在物理上才是安全的(但极度不推荐这种破坏设计契约的行为)。
- 真相:如果原对象声明时就是