C++:const 的空间,常量也能占内存?

在C++的世界里,const几乎是所有新手第一个被教导的“好习惯”。
它代表不可变、代表安全、代表“编译器的保护”。
但你若真以为它只是“在编译期替换为字面值”,那你离真正理解C++的内存模型还差十年。
今天,我们要拆解一个经常被忽略、但却影响全局链接、符号表乃至优化行为的隐秘机制——const对象的存储空间与链接属性。
一、const 真的“不占空间”吗?
很多人会说:
“const 是编译期常量,编译器直接在代码里替换值,不需要内存存储。”
这句话,只在一种情况下成立——当且仅当 const 的作用域是局部、且没有被取地址。
void f() {const int x = 10;int a = x + 1;
}
此时,编译器的确会直接将 x 替换为 10。
整个汇编阶段中都不会出现“变量 x”的符号。
这叫“立即数优化”或“常量折叠(Constant Folding)”。
但——只要你对它稍微“多看一眼”,情况就完全不同了。
void f() {const int x = 10;const int* p = &x;
}
一旦你对它取地址,编译器就不能再“只替换字面值”。
因为现在程序中有一个指针 p,它指向某个地址——于是 x 必须真的存在于内存中。
此时,x 将会被编译器分配在栈上或静态区(取决于上下文),成为真正的对象。
换句话说:一旦被取地址,const 就拥有了“存在”的权利。
二、全局 const:静态链接与命名空间隔离
局部的 const 还算温柔,到了全局作用域,情况立刻复杂得多。
// a.cpp
const int n = 5;// b.cpp
extern const int n;
你可能以为这很自然,结果却是——链接错误。
编译器告诉你:“undefined reference to n”。
为什么?
因为C++标准规定:
“在全局作用域下,未显式声明为
extern的 const 对象,默认具有内部链接(internal linkage)。”
这意味着它相当于:
static const int n = 5;
也就是说,每个编译单元都有自己的 n 副本。
b.cpp 试图通过 extern 去找另一个文件的 n,自然找不到。
要解决这个问题,你必须显式指定外部链接:
// a.cpp
extern const int n = 5;// b.cpp
extern const int n;
这才让所有编译单元共享同一个符号。
这就是很多人第一次体会到的:
“const 变量默认是静态的。”
它并非只存在于编译时的影子,而是真正在目标文件中分配了存储空间,并具备符号可见性规则。
三、const 与 ODR(One Definition Rule)
“一个定义规则”是C++链接阶段的灵魂。
它要求每个具有外部链接的符号在整个程序中必须且只能定义一次。
全局 const 默认内部链接,因此即使你在多个文件里写:
const int N = 100;
编译器也不会抱怨。
因为每个 .cpp 各自拥有一份 N 的拷贝。
但一旦你加上 extern:
extern const int N = 100;
那就是全局唯一的符号定义,多个同名定义会直接导致 ODR 违例。
这也是为什么许多库开发者在头文件里写:
inline constexpr int version = 3;
因为 constexpr 是编译期常量,inline 则赋予它一种特殊的ODR例外机制——允许在多个编译单元中定义同名对象,只要内容一致即可。
这条语法的存在,本身就是为了解决“const 全局变量多定义”问题。
四、const 数组与内存驻留:编译器不一定会“省”
假设我们写:
const char msg[] = "Hello World!";
这段字符串也许会存在于只读段 .rodata,
但取决于优化级别,它可能被多次复制。
例如在O0编译下,每个引用此字符串的地方都可能生成独立副本。
而在O2或O3中,编译器会尽量将其合并为一个常量池对象。
这背后涉及到编译器的“常量池折叠”策略与链接器的“.mergeable section”规则。
结论是:
“常量不会浪费空间”这句话在理论上正确,在实现上却有很多例外。
C++标准允许编译器根据优化策略灵活决定是否复用常量内存。
五、当 const 碰上指针:静态常量的二重性
看看这段代码:
const int a = 10;
const int* p = &a;
int* q = (int*)&a;
*q = 20;
printf("%d\n", a);
结果未定义,但许多编译器上却能“打印出20”。
为什么?
因为 const 是编译期的“类型修饰符”,并不代表对象所在内存真的只读。
如果 a 存在于栈上,你通过类型转换仍然可以修改那段内存。
而如果编译器把 a 放进 .rodata 段(只读常量区),那对 q 的写操作会直接导致段错误。
这意味着:
const 的“不可变性”是逻辑层面的,不是物理层面的。
C++通过类型系统防止修改行为,而不是强制硬件保护(除非编译器特意优化)。
六、C++ 与 C 的差异:const 的链接行为
C语言的全局 const 默认是外部链接的。
而C++为了实现更强的封装性,特意改成内部链接。
这意味着以下代码:
// C
const int a = 3; // 外部链接// C++
const int a = 3; // 内部链接
它们编译出来的符号表完全不同。
C++版的 a 通常命名为 .L_a 或 _ZL1a(取决于编译器的mangling规则),
属于静态作用域,无法在外部访问。
这也让C++的头文件更“安全”——你可以在头文件中定义 const 常量,而不会产生多重定义冲突。
例如:
// header.h
const int BUFFER_SIZE = 1024;
被多个文件包含也没问题,因为每个编译单元都有独立的副本。
若你换成:
int BUFFER_SIZE = 1024;
则立刻触发ODR错误。
这正是C++设计者在“强封装”方向的一次精细权衡。
七、汇编层面:const 对象的段布局
假设我们编译以下代码:
const int g1 = 1;
int g2 = 2;
使用 objdump -t a.o 查看符号表:
0000000000000000 r g1
0000000000000004 D g2
可以看到:
- g1在 只读数据段- .rodata(符号类型- r)
- g2在 可写数据段- .data(符号类型- D)
这意味着:
const 对象的物理位置在只读数据段,而不是一般的数据段。
这不仅影响可写性,还影响加载时的内存映射。
.rodata 段往往被映射为只读页,系统级别防止写入。
如果你强制修改它,将触发段错误 (Segmentation Fault)。
八、实践:跨文件 const 冲突的典型 Bug
假设:
// config.h
const int SIZE = 10;
然后被多个 .cpp 文件同时 #include。
此时没问题,因为内部链接。
但如果某个文件写:
extern const int SIZE;
问题就出现了。
链接器在不同的编译单元中找不到统一的 SIZE 符号。
这类 bug 尤其常见于C 和 C++混合编译项目。
C文件认为 SIZE 是外部链接;C++文件认为是内部链接;
最终导致符号冲突或找不到。
解决方式:
// config.h
extern const int SIZE;
// config.cpp
const int SIZE = 10;
让定义与声明分离,确保唯一外部符号。
九、思维延伸:编译器如何“知道”常量可共享
这要谈到“常量池(Constant Pool)”的概念。
编译器在中间表示(IR)阶段,会将字面量、const数组、字符串统一放入一个常量表。
若多个地方出现同值常量,它可能复用。
但是:
- 如果取了地址; 
- 或者加上了 - volatile;
- 或者参与到某些模板实例化中; 
编译器必须认为它“具备身份”,从而为其生成独立存储。
所以在C++世界里,const的内存存在与否,是一个从语义推导到编译优化的层级决策,而不是单一规则。
十、总结:const 的哲学意义
const的“不可变”不是对内存的命令,而是对程序员的约束。
它的空间存在,既是编译器的妥协,也是语言设计的精妙平衡。
- 局部const:不取地址 → 立即数折叠;取地址 → 栈上对象。 
- 全局const:默认内部链接,可存在于 - .rodata段。
- extern const:外部链接,唯一符号。 
- constexpr:编译期常量,可跨单元复用。 
C++的const,从未简单。
它既是语义约束,又是内存存在;既是类型系统的一部分,又是链接系统的规则参与者。
理解它,不只是知道“不能改”,
而是明白——为什么有时候它真的“在那儿”。
