1. 常量这件事,为什么值得单独拎出来讲
写C++有些年头的人都有一个共同体会:变量好理解,常量反而容易写歪。原因不复杂——C++给常量提供的写法实在太多了,字面常量、const、constexpr、enum、#define,五种方式摆在面前,新手第一反应往往是"能跑就行",随手挑一个用。结果就是同一个项目里五种写法混着出现,代码风格割裂,更麻烦的是某些写法在特定场景下会埋下难以排查的坑。
这篇笔记要解决的就是这个问题。我会把C++里定义常量的五种主流方式逐一拆开,讲清楚每种写法背后的机制、适用边界、编译期行为差异,以及在实际工程里到底该选哪个。内容覆盖从最基础的字面常量,到const、constexpr、enum、#define的完整对比,适合刚接触C++常量的初学者建立体系认知,也适合有一定经验但没系统梳理过的开发者查漏补缺。
需要先明确一个核心判断标准:常量到底是在编译期确定,还是在运行期确定。这一条几乎决定了所有选型决策。编译期常量能被编译器直接替换、能用于数组长度、能作为模板参数;运行期常量则只是"不可修改的变量",本质还是变量。把这条线抓住,后面五种写法的取舍就顺了。
另外提前说一句,#define虽然是C语言遗留的宏定义方式,在C++里依然能用,但它的行为和其他四种完全不在一个层面上——它是预处理阶段的文本替换,不参与编译器的类型检查。这个差异是很多诡异bug的源头,后面会专门用一节讲。
2. 字面常量:最原始也最容易被忽视的一类
2.1 字面常量到底是什么
字面常量(literal constant)指的是直接写在代码里的固定值,比如42、3.14、'A'、"hello"、true。它们不需要任何关键字修饰,编译器看到就知道这是个常量。很多人觉得字面常量太简单不值得讲,但实际上它的类型推导规则、后缀写法、进制表示,都是容易出错的地方。
举个最常见的例子:
int a = 10; // 10 是 int 类型字面常量 double b = 3.14; // 3.14 是 double 类型字面常量 float c = 3.14f; // 加 f 后缀才是 float long d = 100L; // 加 L 后缀才是 long如果你写float c = 3.14;,编译器会先把这个3.14当成double处理,再隐式转换成float,中间可能损失精度,某些严格编译选项下还会报警告。这就是字面常量后缀的实际意义——显式告诉编译器这个值的类型,避免隐式转换带来的精度问题和警告。
2.2 进制与字符字面常量的细节
字面常量支持多种进制写法,这一点在底层开发、位运算场景里很常用:
| 写法 | 进制 | 示例 | 十进制值 |
|---|---|---|---|
| 无前缀 | 十进制 | 255 | 255 |
0前缀 | 八进制 | 0377 | 255 |
0x前缀 | 十六进制 | 0xFF | 255 |
0b前缀 | 二进制(C++14起) | 0b11111111 | 255 |
八进制这个坑要特别提一下。int x = 010;看起来像十进制的10,实际是八进制的8。我在实际项目里见过有人写端口号、权限位的时候忘了这回事,排查半天才发现是进制问题。只要数字以0开头,就是八进制,这个规则从C语言继承下来,C++没有改。
字符字面常量也有讲究。'A'是char类型,值是65;'\n'是转义字符;'\0'是空字符,值为0。字符串字面常量"hello"的类型其实是const char[6](末尾有个隐藏的\0),不是const char*,这一点在模板推导、sizeof计算时会有体现。sizeof("hello")的结果是6而不是5,就是这个原因。
2.3 字面常量的局限
字面常量最大的问题是没有名字。同一个魔法数字如果在代码里出现十几次,改起来就是灾难。比如某个缓冲区大小写死成1024,散落在十几个文件里,哪天要改成2048,你得全局搜索替换,还得小心别误伤其他恰好也是1024的地方。这就是为什么我们需要给常量起名字——也就是后面要讲的const、constexpr这些方式。
提示:字面常量适合用在只出现一次、语义极其明确的场景,比如
int arr[10]里的10。一旦某个值需要重复使用或语义不够直观,就应该考虑具名常量。
3. const 定义常量:用得最多,但误解也最多
3.1 const 的本质是"只读"
const是C++里定义常量最常用的关键字,但它的准确含义是"只读"(read-only),而不是严格意义上的"编译期常量"。这两个概念经常被混为一谈,导致很多人在需要编译期常量的地方误用const。
const int a = 10; // 编译期就能确定,可以当常量用 int b = 20; const int c = b; // 运行期才确定,只是"不可修改"上面两个const int,行为完全不同。a在编译期就已知,编译器可以直接把它内联替换;c依赖运行期的变量b,它只是承诺"后续不能改c",但值本身是运行时算出来的。如果你拿c去定义数组长度,比如int arr[c];,在标准C++里是非法的(变长数组是C99特性,不是标准C++)。
3.2 const 修饰指针的三种位置
const和指针结合是经典难点,三种写法含义完全不同:
const int* p1; // 指向的内容不可改,指针本身可改 int* const p2 = &x; // 指针本身不可改,指向的内容可改 const int* const p3 = &x; // 两者都不可改记忆方法是从右往左读:p1是"指向const int的指针",p2是"const的指针,指向int",p3是"const的指针,指向const int"。这个规则在面试和实际代码审查里出现频率极高,写错了轻则编译报错,重则逻辑出错。
3.3 const 在函数参数和成员函数中的价值
const用在函数参数上,是接口设计的重要工具:
void printInfo(const std::string& name); // 承诺不修改name传引用避免拷贝,加const避免误改,这是C++里传递大对象的标配写法。如果参数不加const,调用方无法确认函数会不会改动传入的对象,接口的语义就不清晰。
在类成员函数末尾加const,表示这个函数不会修改对象的成员变量:
class Point { public: int getX() const { return x_; } // 常量成员函数 private: int x_; };常量成员函数可以被const对象调用,非const成员函数不行。这个规则在设计不可变对象、编写线程安全代码时非常关键。
3.4 const 常量的初始化时机陷阱
const变量必须在定义时初始化,这是硬性要求。但初始化的值来自哪里,决定了它是编译期常量还是运行期常量。全局const变量如果初始值来自另一个编译单元的变量,还会涉及"静态初始化顺序"问题——不同编译单元的全局变量初始化顺序是不确定的,可能导致某个const变量在初始化时读到了未初始化的值。这类问题排查起来极其痛苦,通常的规避方式是用函数内的局部static const,或者干脆用constexpr。
4. constexpr:真正意义上的编译期常量
4.1 constexpr 解决了什么问题
C++11引入constexpr,核心目的就是明确表达"这个值在编译期就能算出来"。它和const最大的区别在于:const只保证只读,constexpr保证编译期可求值。
constexpr int a = 10; // 编译期常量 constexpr int b = a * 2 + 1; // 编译期就能算出21 int arr[b]; // 合法,b是编译期常量如果用const int b = a * 2 + 1;,虽然大多数编译器也能优化,但标准层面不保证b是编译期常量,用它定义数组长度在严格模式下可能不通过。constexpr则从语言层面给了保证。
4.2 constexpr 函数:编译期计算的利器
constexpr还能修饰函数,表示这个函数在参数是编译期常量时,可以在编译期求值:
constexpr int square(int x) { return x * x; } constexpr int result = square(5); // 编译期算出25 int runtime_val = 7; int r2 = square(runtime_val); // 运行期计算,也合法constexpr函数的妙处在于"双重身份":参数是编译期常量时它编译期算,参数是运行期变量时它当普通函数用。这让同一份代码既能用于编译期优化,又能处理运行期数据。
C++14之后constexpr函数放宽了限制,可以包含循环、条件分支、局部变量等,写起来更接近普通函数。C++17又引入了if constexpr,可以在编译期做条件分支,模板元编程的写法因此大幅简化。
4.3 constexpr 与 const 的选型建议
实际工程里的判断标准很简单:
- 如果这个常量必须在编译期确定(用于数组长度、模板参数、
static_assert、case标签等),用constexpr。 - 如果只是运行期不可修改,用
const即可。 - 如果拿不准,优先用
constexpr——它能覆盖const的所有场景,且约束更强,编译器能帮你发现更多问题。
有一个细节要注意:constexpr变量隐含const,所以constexpr int x = 5;里的x本身就是常量,不能再加const修饰(加了也不报错,但冗余)。
注意:
constexpr要求初始值必须是编译期常量表达式。如果写成constexpr int x = someRuntimeFunc();,编译直接失败。这个"失败"其实是好事,它帮你把"我以为这是编译期常量"的误解当场暴露出来。
5. enum 定义常量:被低估的整型常量方案
5.1 enum 作为常量集合的天然优势
enum(枚举)本质上是给一组整型常量起名字。它最典型的用法是定义一组相关的常量:
enum Color { RED, // 0 GREEN, // 1 BLUE // 2 };不指定值时,枚举成员从0开始依次递增。也可以显式指定:
enum HttpStatus { OK = 200, NOT_FOUND = 404, SERVER_ERROR = 500 };用enum定义常量的好处是语义清晰、类型安全、便于调试。相比散落的#define,枚举成员有作用域、有类型,调试器里能看到名字而不是数字。
5.2 传统 enum 与 enum class 的关键差异
C++11引入的enum class(强类型枚举)解决了传统enum的几个老问题:
| 对比项 | 传统 enum | enum class |
|---|---|---|
| 作用域 | 成员泄漏到外层作用域 | 成员限定在枚举内 |
| 隐式转换 | 可隐式转int | 不可隐式转int |
| 底层类型 | 由编译器决定 | 可显式指定 |
| 前向声明 | 受限 | 支持 |
传统enum的成员会污染外层命名空间,两个不同的枚举如果有同名成员就会冲突。enum class把成员关在枚举内部,用的时候必须写Color::RED,避免了命名冲突。同时它不能隐式转成整数,减少了误用。
enum class Color { RED, GREEN, BLUE }; Color c = Color::RED; // int x = c; // 错误,不能隐式转换 int x = static_cast<int>(c); // 必须显式转换5.3 enum 定义常量的适用边界
enum适合定义一组相关的整型常量,比如状态码、选项标志、类型标识。它不适合定义单个孤立的常量,也不适合定义非整型的常量(比如浮点数、字符串)。如果你只需要一个constexpr double PI = 3.14159;,用enum就没意义了。
另外,enum的底层类型在传统写法里是不确定的,可能是有符号也可能是无符号,跨平台时可能出问题。enum class可以显式指定底层类型,比如enum class Flag : uint8_t { ... };,这在需要精确控制内存布局的场景(如协议解析、硬件寄存器映射)里很重要。
6. define 定义常量:能用,但要清楚代价
6.1 define 的本质是文本替换
#define是预处理指令,在编译之前由预处理器做纯文本替换。它不理解类型、不理解作用域、不参与编译器的语义分析:
#define MAX_SIZE 1024 #define PI 3.14159预处理器会把代码里所有MAX_SIZE直接替换成1024,就像用文本编辑器的查找替换一样。这个机制带来了一系列问题。
6.2 define 的经典陷阱
陷阱一:没有类型检查。#define PI 3.14159没有类型,参与运算时可能触发意外的类型转换。而constexpr double PI = 3.14159;有明确的double类型。
陷阱二:宏展开的括号问题。这是最经典的坑:
#define SQUARE(x) x * x int r = SQUARE(2 + 3); // 展开成 2 + 3 * 2 + 3 = 11,不是25正确写法要给参数和整体都加括号:#define SQUARE(x) ((x) * (x))。但即便如此,带副作用的参数仍会出问题,比如SQUARE(i++)会自增两次。
陷阱三:作用域失控。#define没有作用域概念,一旦定义,从定义点到文件末尾(或#undef)都有效,容易和其他头文件里的同名宏冲突。
陷阱四:调试困难。调试器看到的是替换后的值,看不到宏名,排查问题时缺少线索。
6.3 什么时候还该用 define
尽管问题多,#define在某些场景下仍不可替代:
- 头文件保护:
#ifndef XXX_H这类用法是预处理器的专属功能,没有替代品(虽然#pragma once更简洁,但#define方式兼容性更好)。 - 条件编译:
#ifdef DEBUG这类跨平台、跨配置的编译开关,只能用预处理器。 - 字符串化与拼接:
#和##运算符是宏独有的能力,用于生成代码或日志。
但对于定义常量值这个用途,现代C++里基本没有理由再用#define。constexpr能提供类型安全、作用域控制、调试友好,全面优于宏。我在代码审查里看到用#define定义数值常量的,基本都会建议改成constexpr。
7. 五种方式横向对比与选型决策
7.1 一张表看清差异
| 方式 | 编译期确定 | 类型安全 | 有作用域 | 可调试 | 典型场景 |
|---|---|---|---|---|---|
| 字面常量 | 是 | 是 | 无 | 是 | 一次性使用的固定值 |
| const | 不一定 | 是 | 有 | 是 | 运行期只读变量 |
| constexpr | 是 | 是 | 有 | 是 | 编译期常量、模板参数 |
| enum | 是 | 较强 | 有(enum class) | 是 | 一组相关整型常量 |
| define | 是(文本替换) | 无 | 无 | 差 | 条件编译、头文件保护 |
7.2 选型决策路径
实际写代码时,可以按这个顺序判断:
- 需要条件编译或头文件保护?用
#define,这是它唯一不可替代的领域。 - 是一组相关的整型常量?用
enum class,语义清晰、类型安全。 - 需要编译期确定的值?用
constexpr,用于数组长度、模板参数、static_assert等。 - 只是运行期不可修改?用
const。 - 只用一次且语义明确?直接用字面常量,不必强行具名。
7.3 几个容易踩的坑
坑一:用const当编译期常量。前面提过,const int不保证编译期可求值,用在需要编译期常量的地方可能编译失败。需要编译期常量就用constexpr。
坑二:#define和constexpr混用。同一个项目里两种风格并存,维护时容易混乱。统一用constexpr,只在预处理场景保留#define。
坑三:enum成员重名。传统enum的成员会泄漏到外层作用域,两个枚举有同名成员就冲突。用enum class规避。
坑四:忽略constexpr函数的限制。C++11的constexpr函数只能有一条return语句,C++14才放宽。如果项目还在用C++11标准,写复杂constexpr函数会受限。
8. 我在实际项目里的使用习惯
说点实在的。经过这些年写C++,我现在的习惯基本固定下来了:
数值常量一律用constexpr,不管它是不是真的需要在编译期用。理由很简单——constexpr约束更强,编译器能帮我发现更多问题,而且它天然是const,不会比const差。只有在初始值确实依赖运行期数据时,才退而用const。
一组相关的常量,比如状态码、错误码、配置项枚举,用enum class。它比一堆constexpr int更有组织性,调试时能看到名字,还能防止不同类型之间误赋值。
#define只留给预处理相关的场景:头文件保护、条件编译、平台相关的宏开关。定义数值常量这件事,我已经很多年没用过#define了。
还有一个细节值得分享:constexpr变量在头文件里定义时,每个包含它的编译单元都会有一份副本,但因为它是编译期常量,通常不会造成实际的存储开销(编译器会内联替换)。如果确实需要一份共享的存储,可以用inline constexpr(C++17起)或者放在.cpp里配合extern声明。这个细节在写库的时候要注意,普通应用开发基本不用操心。
最后提一个排查技巧:如果某个常量相关的代码行为诡异,先确认它到底是编译期常量还是运行期常量。用static_assert测试一下,比如static_assert(MAX_SIZE == 1024);,如果编译通过说明是编译期常量,不通过就说明它是运行期的。这个手段比盯着代码猜要快得多。