前言
枚举和指针是 C++ 入门阶段两块看似无关、实则都很容易"会用但用不对"的内容。枚举的坑集中在类型安全:传统枚举的枚举值会泄漏到外层作用域,还能悄悄转换成整数,两个不同枚举之间也能互相比较,编译器不会提醒你。指针的坑集中在生命周期和边界:未初始化、悬垂、越界,三者都是未定义行为(undefined behavior, UB),程序可能崩溃,也可能看起来一切正常。
本文按"先建立模型、再写代码"的顺序讲。枚举部分从传统枚举讲到 C++11 的enum class,说明底层类型和作用域规则;指针部分讲地址、类型、解引用、const修饰位置、指针运算的合法边界,最后给一个把两者结合起来的完整示例。示例以 C++17 为准,涉及 C++11 特性的地方会注明。
阅读时请记住一条底线:本文的代码没有被编译或运行过,所有结论都来自语言标准的语义推导。真实行为请以你自己的编译器和标准文档为准。
一、传统枚举与枚举类
传统枚举(unscoped enumeration)用enum定义:
// 适用于 C++17
enum LegacyColor { kRed, kGreen, kBlue };它有三个特点。第一,枚举值kRed、kGreen、kBlue直接进入外层作用域,相当于在这里声明了三个常量,所以同一作用域内不能再定义同名标识符。第二,枚举值可以隐式转换为整数,int x = kGreen;合法,反过来整数转枚举则需要显式转换。第三,不同枚举类型的值可以互相比较,因为比较前都先转成了整数,这让"把颜色和状态拿来比较"这种错误无法被类型系统拦住。
C++11 引入的枚举类(scoped enumeration)解决了这些问题:
// 适用于 C++17
enum class Color : std::uint8_t { Red, Green, Blue };枚举值必须写成Color::Red,不会污染外层作用域;不会隐式转换为整数;不同枚举类之间不能直接比较。冒号后的std::uint8_t指定底层类型,这在需要固定内存布局、与协议字段对接时很有用。不指定时,枚举类的底层类型默认是int;传统枚举的底层类型由实现决定,只要足以容纳所有枚举值即可。
| 特性 | 传统enum | enum class |
|---|
| 枚举值作用域 | 泄漏到外层 | 限定在枚举名内 |
| 隐式转整数 | 允许 | 禁止 |
| 不同枚举间比较 | 允许(都转整数) | 禁止 |
| 指定底层类型 | C++11 起可指定 | 可指定,默认int |
| 前置声明 | 需指定底层类型 | 可直接前置声明 |
二、枚举的取值、底层类型与位掩码
枚举值默认从 0 开始依次加一,也可以显式赋值。允许重复值,也允许跳过某些值,但要注意:枚举的合法值范围不是"最小到最大"这么简单,标准规定的是一个可以用若干位表示的区间,把任意整数强制转换成枚举类型时,落在区间外的值属于未定义行为。因此不要用static_cast<Color>(99)去试探。
枚举类做位掩码时要自己提供运算符,因为标准不提供按位运算:
// 适用于 C++17
enum class Permission : unsigned {
None = 0u,
Read = 1u << 0,
Write = 1u << 1,
Execute = 1u << 2
};
constexpr Permission operator|(Permission a, Permission b) {
return static_cast<Permission>(
static_cast<unsigned>(a) | static_cast<unsigned>(b));
}
constexpr bool has(Permission set, Permission bit) {
return (static_cast<unsigned>(set) & static_cast<unsigned>(bit)) != 0u;
}constexpr让这些函数在编译期也能求值。注意operator|返回的是Permission,这样has()的第二个参数类型才不会错。位掩码枚举通常把None设为 0,并保证各标志位互不重叠。
switch配合枚举时,建议把每个枚举值都列出来。部分编译器能对遗漏的枚举值给出警告,但这依赖编译选项,不是标准要求。不要用default草草了事,否则将来新增枚举值时编译器帮不上忙。
三、指针:保存地址的变量
指针是一个保存地址的变量,它的类型决定了"解引用时按什么类型解释这块内存"。声明int* p表示p里存的是一个int对象的地址。取地址用&,解引用用*:
// 适用于 C++17
int value = 42;
int* p = &value; // p 指向 value
*p = 43; // 通过 p 修改 value
int copy = *p; // 读取 value&value的类型是int*,*p的类型是int左值。指针本身也是一个对象,它有自己的地址,所以可以有指向指针的指针int**,但这在入门阶段很少需要。
空指针用nullptr表示(C++11)。NULL在多数实现里是整数常量0,在重载解析时可能被当成int,从而选错函数;nullptr的类型是std::nullptr_t,能隐式转换为任意指针类型但不会转成整数。新代码一律用nullptr。
未初始化的指针里是 indeterminate 值,读取它本身就是未定义行为,更不用说解引用。养成声明即初始化的习惯:暂时没有目标就写int* p = nullptr;。
需要区分"指针指向的对象是否可改"和"指针本身是否可改",这由const的位置决定:
| 写法 | 指向的对象 | 指针本身 | 记忆方法 |
|---|
const int* p | 不可改 | 可改 | const 在*左边,约束所指对象 |
int* const p | 可改 | 不可改 | const 在*右边,约束指针 |
const int* const p | 不可改 | 不可改 | 两边都有 |
int const* p | 不可改 | 可改 | 与第一种等价 |
从右往左读声明通常不会错:int* const p读作"p 是一个 const 指针,指向 int"。
四、指针运算与数组边界
数组名在多数表达式里会退化为指向首元素的指针,所以可以用指针遍历数组:
// 适用于 C++17
int arr[5] = {10, 20, 30, 40, 50};
int* begin = arr;
int* end = arr + 5; // 指向末尾后一位,合法但不可解引用
for (int* it = begin; it != end; ++it) {
std::cout << *it << '\n';
}指针算术只在同一数组(或同一个完整对象)内部有定义。arr + 5是合法的"尾后指针"(one-past-the-end),可以用作循环终止条件,但不能解引用。arr + 6或arr - 1的运算本身就是未定义行为,哪怕不 dereference。两个指针相减只有在指向同一数组时才有定义,结果的类型是std::ptrdiff_t。
sizeof(arr)得到整个数组的字节数,sizeof(begin)得到指针本身的字节数,后者由实现决定(64 位平台通常是 8)。把数组传给函数后,形参类型退化为指针,函数内部再也拿不到数组长度,这是初学者最常见的困惑之一。解决办法是额外传长度,或用std::array、std::vector、std::span(C++20)。
完整示例:状态机与权限表
下面把枚举与指针放在一个示例里:用枚举表示状态,用指向函数的指针数组做状态处理表。示例只演示语法组织,不含平台相关代码。
// 适用于 C++17
#include <cstddef>
#include <iostream>
enum class State { Idle, Running, Paused, Stopped };
const char* to_string(State s) {
switch (s) {
case State::Idle: return "Idle";
case State::Running: return "Running";
case State::Paused: return "Paused";
case State::Stopped: return "Stopped";
}
return "Unknown";
}
// 函数指针类型:接收 State,返回 void
using Handler = void (*)(State);
void on_idle(State) { std::cout << "waiting\n"; }
void on_running(State) { std::cout << "working\n"; }
void on_paused(State) { std::cout << "suspended\n"; }
void on_stopped(State) { std::cout << "done\n"; }
int main() {
// 下标与 State 的整数值一一对应
Handler table[] = {on_idle, on_running, on_paused, on_stopped};
State current = State::Running;
const std::size_t index = static_cast<std::size_t>(current);
if (index < sizeof(table) / sizeof(table[0])) {
table[index](current); // 通过函数指针调用
}
std::cout << to_string(current) << '\n';
int* nothing = nullptr;
if (nothing != nullptr) {
std::cout << *nothing; // 只有非空才解引用
}
return 0;
}这里用static_cast<std::size_t>把枚举转成下标,并且在使用前检查了边界。函数指针数组的类型必须完全一致:on_idle的签名是void(State),放进Handler数组才不会出错。sizeof(table) / sizeof(table[0])是 C 风格数组求长度的经典写法,对指针不适用。
常见坑点
- ❌ 把
enum class的值直接赋给int。✅ 必须static_cast<int>(value),这正是枚举类防止误用的设计目的。
- ❌ 用
NULL初始化或比较指针。✅ 用nullptr;NULL可能是整数0,会在重载解析中产生意外。
- ❌ 声明
int* p;后直接*p = 1;。✅ 未初始化指针的值不确定,解引用是 UB;声明时就初始化为nullptr或有效地址。
- ❌ 用
const int* p试图说明"指针不能改"。✅ 那是int* const p;const 在*左侧约束所指对象,右侧约束指针本身。
- ❌ 返回函数内局部变量的地址。✅ 函数返回后局部对象生命周期结束,指针悬垂;返回值、返回静态对象,或由调用方提供缓冲区。
- ❌ 认为
arr + n只要不解引用就没事。✅ 指针算术本身必须落在同一数组的[begin, end]闭区间内,越界运算即 UB。
- ❌ 对
new[]分配的内存用delete释放。✅ 必须delete[];两者不匹配是 UB,且通常伴随堆损坏。
- ❌ 在
switch里用default吞掉所有未列出的枚举值。✅ 逐个列出枚举值,让编译器在新增枚举值时给出警告。
总结
| 主题 | 要点 |
|---|
| 传统枚举 | 枚举值泄漏作用域,可隐式转整数 |
| 枚举类 | 强作用域、强类型,可指定底层类型 |
| 枚举转换 | 用static_cast,不要越界构造非法值 |
| 空指针 | 一律nullptr,解引用前判空 |
| const 位置 | 左约束值,右约束指针 |
| 指针运算 | 仅限同一数组,尾后指针可比较不可解引用 |
| 生命周期 | 悬垂指针解引用是 UB,返回局部地址必错 |
枚举负责把"一组有限的取值"表达成类型,指针负责间接访问对象,两者本身都不复杂。真正需要训练的是边界意识:枚举值是否合法、指针是否有效、下标是否越界。把这三问养成习惯,入门阶段的大部分崩溃都可以提前避免。