前言
这是入门系列的第二篇,主题是「数据怎么表示、变量怎么定义、宏能做与不能做什么」。看起来都是最基础的内容,但恰恰是基础知识里的模糊地带最多:int到底几个字节、char有没有符号、const和constexpr差在哪、#define为什么被现代 C++ 极力劝退。这些问题在初学阶段若按「背结论」的方式处理,后面写出的代码会在跨平台时集中暴露。
必须先说明一点:C++ 标准不规定大多数基本类型的精确字节数,只规定最小范围和相对大小关系。sizeof(int)在常见平台上多为 4,但这是实现定义的结果,不是语言保证。char是否有符号同样是实现定义的。因此本文给出的尺寸一律标注为「常见平台」,遇到需要精确宽度的场合,应当使用<cstdint>里的std::int32_t这类定宽类型。
本文先讲类型与对象的基本概念,再讲变量的初始化方式,然后进入宏的世界:预处理器在编译之前做了什么、宏的典型陷阱、以及为什么现代 C++ 更推荐constexpr、inline函数和模板来替代宏。最后用一个综合示例把类型、变量、宏边界串起来。
一、基本数据类型
C++ 的基本类型分为几大类:布尔、字符、整数、浮点、void,以及由它们派生的指针和数组。下面按类别说明。
| 类型 | 常见大小(常见平台) | 标准保证 | 备注 |
|---|
bool | 1 字节 | 至少能表示true/false | 参与算术运算时提升为int |
char | 1 字节 | sizeof(char) == 1 | 是否有符号由实现决定 |
signed char | 1 字节 | 至少 -127 到 127 | 明确有符号 |
unsigned char | 1 字节 | 至少 0 到 255 | 常用于字节缓冲 |
short | 2 字节 | 至少 16 位 |
int | 4 字节 | 至少 16 位 | 最常见的默认整型 |
long | Windows 4 / Linux 8 | 至少 32 位 | LLP64 与 LP64 的差异点 |
long long | 8 字节 | 至少 64 位 | C++11 起标准 |
float | 4 字节 | 至少 6 位有效数字 |
double | 8 字节 | 至少 10 位有效数字 |
long double | 8/12/16 字节 | 至少与double同精度 | 各平台差异很大 |
sizeof的结果类型是std::size_t,定义在<cstddef>。注意sizeof(char)恒为 1,但「1 字节」不等于「8 位」——标准只保证一个字节至少 8 位,实际平台几乎都是 8 位。
整数类型还有有符号与无符号之分。无符号运算按模 2^N 回绕,这是良定义的;有符号整数溢出则是未定义行为(undefined behavior, UB),不是「回绕成负数」。这一点必须记牢:int溢出后程序可能得到任何结果,包括看起来正常的结果,也可能被优化器利用而产生难以理解的代码。
字符类型要区分三个概念:char、signed char、unsigned char是三种不同的类型。char的符号性由实现决定,所以在把char当小整数用时,应先明确转换到signed char或unsigned char。wchar_t的宽度也是实现定义的:Windows 上通常 2 字节,Linux 上通常 4 字节。C++11 还引入了char16_t与char32_t用于 Unicode,C++20 增加了char8_t。
二、变量的声明、定义与初始化
「声明」告诉编译器某个名字存在及其类型;「定义」则真正分配存储或给出函数体。一个定义同时也是声明,但反之不成立。对变量而言,extern int x;是声明,int x;是定义。
C++11 起推荐使用列表初始化(花括号初始化),它有一个重要的附加效果:禁止窄化转换。
// 适用于 C++17
#include <vector>
#include <string>
int a = 0; // 拷贝初始化
int b(0); // 直接初始化
int c{0}; // 列表初始化(推荐)
int d{}; // 值初始化,d 为 0
double pi = 3.14159;
// int bad{pi}; // 错误:窄化转换,编译期报错
int ok = static_cast<int>(pi); // 显式转换可以
std::vector<int> v{1, 2, 3}; // 三个元素
std::vector<int> w(3); // 三个值为 0 的元素对容器来说,v{1, 2, 3}与v(3)的差别尤其要注意:前者调用了接受std::initializer_list的构造函数,后者调用的是接受size_type的构造函数。这是列表初始化在容器上最容易踩的坑。
变量还有存储期(storage duration)和链接属性(linkage)两个维度。局部自动变量具有自动存储期,离开作用域即销毁;static局部变量具有静态存储期,首次执行到声明处时初始化(C++11 起该初始化是线程安全的);全局变量和命名空间作用域变量也具有静态存储期。链接属性决定名字能否跨翻译单元可见,static在命名空间作用域表示内部链接。
未初始化的局部变量具有不确定的值,读取它是 UB。这一点和某些语言「默认给零」的规则不同。全局变量和静态变量会被零初始化,但不要依赖这个规则来省事,显式初始化更清晰。
三、const、constexpr 与 auto
const表示「通过这个名字不能修改对象」,但它不保证对象本身不可变——别人可能通过别的路径修改。constexpr则表示「可以在编译期求值」,它隐含const,但语义更强。
// 适用于 C++17
const int runtime_value = compute(); // 运行期初始化,只读
constexpr int compile_time_value = 42; // 编译期常量
constexpr int square(int x) { return x * x; }
static_assert(square(4) == 16, "compile-time check");constexpr函数在 C++11 中限制很多(函数体基本只能有一条 return),C++14 放宽了限制,允许循环、局部变量等。C++17 起constexprlambda 也可用。判断一个constexpr函数能否在编译期求值,取决于实参是否为常量表达式以及函数体是否满足要求;不满足时它仍然可以在运行期调用。
auto让编译器从初始化器推导类型。它有几个必须记住的规则:
auto x = expr;会丢弃顶层const和引用;auto& x = expr;保留引用,const auto&保留只读引用;auto&&是转发引用(在泛型语境中),能绑定左值和右值;- 花括号初始化与
auto组合时,auto x{1}在 C++17 起推导为int,而auto x = {1}推导为std::initializer_list<int>。
decltype则保留表达式的完整类型(包括引用和const),decltype(auto)在 C++14 起用于返回值推导,能精确保留引用性。
四、宏:预处理器能做什么
宏由预处理器处理,发生在编译之前。预处理器只做文本替换,不理解类型、作用域、重载。这一点决定了宏的一切优点和缺点。
宏的常见用途有四类:
- 包含守卫:
#ifndef/#define/#endif,或非标准的#pragma once; - 常量:
#define MAX_SIZE 1024; - 函数式宏:
#define SQUARE(x) ((x) * (x)); - 条件编译:
#ifdef _WIN32之类。
预处理还提供了一批预定义宏,例如__FILE__(当前源文件名)、__LINE__(当前行号)、__DATE__、__TIME__、__cplusplus(标准版本号)。这些是排查问题和做条件编译的常用工具。C++20 起还有__VA_OPT__用于可变参数宏的可选展开,属于较新特性。
函数式宏最著名的陷阱是参数被求值多次和缺少括号:
// 适用于 C++17
#include <iostream>
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main() {
int i = 3;
int r1 = SQUARE(i++); // 展开为 ((i++) * (i++)),行为未定义
int r2 = MAX(i++, 10); // i++ 可能执行两次
(void)r1;
(void)r2;
return 0;
}SQUARE(i++)展开后同一个表达式里对i自增两次且没有顺序保证,C++17 之前是 UB,C++17 起对于*的操作数仍然没有顺序规定,依然不能这么写。MAX的问题更隐蔽:即使加了括号,a和b仍可能各求值两次,若实参有副作用就会出错。
宏还无法处理类型:MAX对int和std::string都能展开,但比较语义完全不同;宏也没有作用域,定义会一直影响到#undef或文件结束,容易与别处的同名宏冲突。
五、用现代设施替代宏
绝大多数宏的用途在现代 C++ 里都有更好的替代:
| 宏的用途 | 现代替代 | 说明 |
|---|
| 常量 | constexpr/inline constexpr | 有类型、有作用域、可调试 |
| 小型函数 | constexpr函数或inline函数 | 参数只求值一次,支持重载 |
| 类型无关的比较 | 函数模板 /std::max | 保留类型信息 |
| 条件编译 | 尽量用if constexpr或构建系统 | 平台判断仍需预处理器 |
| 调试输出 | 日志库 /std::source_location(C++20) | 可获取文件名行号且类型安全 |
| 字符串化 | 仍可用#运算符 | 无完全等价替代 |
constexpr函数是替代函数式宏的首选:
// 适用于 C++17
#include <algorithm>
constexpr int square(int x) { return x * x; }
template <typename T>
constexpr const T& max_of(const T& a, const T& b) {
return (a < b) ? b : a;
}max_of的参数是引用,只求值一次;模板保证类型一致;constexpr允许编译期使用。注意返回引用要求两个实参在返回后仍然存活,若传入临时对象就会悬垂,这是它和宏不同的地方,需要使用者注意。
包含守卫可以用#pragma once简化,但它不是标准语法,只是被主流编译器普遍支持。可移植的写法仍是传统的三行守卫。C++20 的模块(modules)从根本上改变了头文件的组织方式,但各编译器支持程度不一,初学阶段不必强求。
需要保留宏的场景主要有:平台条件编译、生成代码、日志/断言中需要__FILE__和__LINE__的地方、以及某些需要字符串化的接口。使用时要遵守两条纪律:宏名全大写并加项目前缀,所有参数和整体都加足括号。
六、综合示例
下面这个小程序把类型宽度、列表初始化、constexpr、static_assert和条件编译放在一起,展示各自的边界。
// 适用于 C++17
#include <cstdint>
#include <iostream>
#include <limits>
#include <string>
constexpr int kBufferSize = 256;
static_assert(kBufferSize > 0, "buffer size must be positive");
struct Sample {
std::int32_t id{};
double value{0.0};
std::string name{};
};
int main() {
Sample s{1, 2.5, "alpha"}; // 聚合初始化
std::cout << "int bytes: " << sizeof(int) << "\n";
std::cout << "long bytes: " << sizeof(long) << "\n";
std::cout << "int max: " << std::numeric_limits<int>::max() << "\n";
unsigned int u = 0;
u = u - 1; // 无符号回绕,结果良定义
std::cout << "wrap: " << u << "\n";
#ifdef _WIN32
std::cout << "platform: windows\n";
#else
std::cout << "platform: other\n";
#endif
return 0;
}逐项说明。std::int32_t来自<cstdint>,在支持的平台上精确为 32 位,比裸int更适合需要固定宽度的场景。Sample s{1, 2.5, "alpha"}是聚合初始化,成员按声明顺序对应;C++20 起还可以用指定初始化器写{.id = 1, .value = 2.5},但需要 C++20 及以上。u = u - 1在unsigned int上是模运算,结果等于该类型的最大值,这是标准保证的;如果u是int,同样写法就是 UB。#ifdef _WIN32是平台宏,属于实现定义的宏,不是 C++ 标准的一部分,只在对应平台上有意义。
常见坑点
- ❌ 认为
int一定是 4 字节、long一定是 8 字节。
✅ 标准只规定最小范围;需要固定宽度用<cstdint>的定宽类型,需要平台相关尺寸用sizeof查询。
- ❌ 让有符号整数溢出,并期待它回绕成负数。
✅ 有符号溢出是 UB;需要回绕语义时使用无符号类型,或先做范围检查。
- ❌ 读取未初始化的局部变量。
✅ 定义时就初始化,或使用{}值初始化;开启编译器警告辅助发现。
- ❌ 在
std::vector上用v{3}期待「三个元素」。
✅v{3}是一个元素 3,v(3)才是三个默认值;注意initializer_list构造的优先级。
- ❌ 写
#define SQUARE(x) x*x并传入带副作用的实参。
✅ 用constexpr函数;若必须用宏,至少写成((x)*(x))并避免多次求值。
- ❌ 用
#define定义常量,导致调试器看不到名字、作用域泄漏。
✅ 用constexpr或inline constexpr变量。
- ❌ 认为
const对象一定在编译期确定、一定不可修改。
✅const只约束当前访问路径;编译期求值用constexpr,真正不可变可配合const接口设计。
- ❌ 把
char当作确定的无符号字节类型。
✅ 字节缓冲用unsigned char或std::byte(C++17),文本用char。
总结
| 主题 | 核心规则 | 易错点 |
|---|
| 基本类型 | 宽度多为实现定义,标准只给最小值 | 假定int/long固定宽度 |
| 有符号性 | char的符号性由实现决定 | 拿char当小整数 |
| 初始化 | 优先列表初始化,禁止窄化 | 容器花括号与圆括号语义不同 |
const/constexpr | 前者只读,后者可编译期求值 | 混用两者语义 |
auto | 默认丢弃引用与顶层const | 忘记写&导致拷贝 |
| 宏 | 纯文本替换,无类型无作用域 | 多次求值、缺括号、命名冲突 |
| 预定义宏 | __FILE__、__LINE__、__cplusplus | 把平台宏当标准 |
数据类型和变量是写一切代码的地基,宏则是这座地基旁边的一把「没有护栏的电锯」。理解类型的实现定义边界、坚持列表初始化、用constexpr和模板替代函数式宏,这三条能避开入门阶段绝大多数隐蔽错误。宏不会消失,但应当被收缩到条件编译和少数必须依赖预处理器的角落。