前言
引用、inline、nullptr是 C++11 之后最基础也最容易被「大概知道」糊弄过去的三样东西。面试里几乎人人能说「引用是别名、inline 是内联展开、nullptr 比 NULL 安全」,但真正写代码时踩的坑往往来自这些说法的偏差:引用绑定的临时对象什么时候会悬垂、inline到底保不保证展开、nullptr在重载决议里为什么和NULL表现不同。
典型误解有三个。第一,把inline当成「让编译器内联展开」的指令——标准里它的核心作用是允许函数在多个翻译单元中重复定义而不违反单一定义规则(one definition rule, ODR),是否真正展开由优化器决定。第二,以为「引用就是不能为空的指针」——引用还必须绑定到已有对象,且一旦绑定不可更换,这两点比「不能为空」影响更大。第三,以为NULL和nullptr可以互换——NULL在多数实现里就是整数0,遇到f(int)与f(char*)的重载会选错。
本文把三者放在一起讲,是因为它们经常出现在同一个接口里:一个接收const T&的函数、一个写在头文件里的inline工具函数、一个用nullptr表示「无对象」的参数。理解各自的规则,才能写出既正确又能被优化的代码。示例基准为 C++17。
一、引用:别名、绑定与生命周期
1.1 引用不是指针
引用在语义上是已有对象的别名,它没有自己的地址(&r得到的是被引用对象的地址),也不能重新绑定:
// 适用于 C++17
int a = 1;
int b = 2;
int& r = a; // r 绑定到 a
r = b; // 赋值!把 b 的值写进 a,r 仍然绑定 a
int& s; // 错误:引用必须初始化想让r改绑到b是做不到的,只能换一个引用变量。这也是引用不能作为容器元素类型的原因:std::vector<int&>无法通过编译,标准容器要求元素可赋值、可销毁,而引用既不满足可赋值的语义,也没有独立对象。
引用在底层通常用地址实现,但这是实现细节,标准不保证sizeof(int&)等于sizeof(int*)(实际上sizeof(int&)得到的是sizeof(int))。不要把「引用就是指针的语法糖」当成语言规则去推理。
1.2 const 引用与临时对象
const T&可以绑定到临时对象,并且会把临时对象的生命周期延长到引用的生命周期:
// 适用于 C++17
#include <string>
const std::string& name = std::string("temporary"); // 生命周期被延长
int len = static_cast<int>(name.size()); // 安全延长只在这个「直接绑定」的场合成立。绑定到运算结果仍然算直接绑定,是安全的:
// 适用于 C++17
const std::string& ok = std::string("a") + std::string("b"); // 生命周期延长经过函数返回或成员访问就不行了:
// 适用于 C++17 —— 悬垂
const std::string& f() {
return std::string("local"); // 返回局部临时量的引用
}
struct Holder { std::string text; };
const std::string& bad2 = Holder{}.text; // 成员访问,不延长 Holder 的生命周期
const std::string& identity(const std::string& s) { return s; }
const std::string& bad3 = identity(std::string("tmp")); // 临时量在语句结束后销毁bad2的Holder{}是临时对象,.text是它的成员,返回的是成员引用,标准不延长这个临时对象的生命周期,语句一结束就悬垂。bad3更隐蔽:临时量绑定到identity的参数s,函数返回s的引用,但临时量的生命周期只到完整表达式结束,不会跟着返回值延长。
1.3 引用作为参数:什么时候用 const T&
| 参数写法 | 拷贝 | 能否绑定临时量 | 典型用途 |
|---|
T | 一次(可能被省略) | 可以 | 小对象、需要副本 |
const T& | 无 | 可以 | 只读的大对象 |
T& | 无 | 不可以 | 需要修改调用方对象 |
T&& | 无 | 可以(右值) | 移动语义、完美转发 |
对小类型(int、double、指针)按值传递通常更简单,const T&反而可能因为多一层间接而失去优化机会。对大对象(std::string、std::vector)用const T&避免拷贝;需要接管所有权时按值传递再std::move。
1.4 返回引用必须保证对象还活着
返回引用最常见的合法场景是返回成员或静态对象:
// 适用于 C++17
class Buffer {
public:
int& operator[](std::size_t i) { return data_[i]; }
const int& operator[](std::size_t i) const { return data_[i]; }
private:
std::vector<int> data_;
};返回局部变量的引用是未定义行为(undefined behavior, UB),编译器通常只给一个-Wreturn-local-addr之类的警告,运行时可能「看起来正常」——因为栈内存还没被覆盖。这类 bug 极难排查,看到警告必须处理。
二、inline:链接属性优先于优化提示
2.1 标准里的 inline 到底规定什么
inline的核心语义是:允许这个函数在多个翻译单元中出现相同的定义。它把函数的链接属性调整为「外部链接但可多重定义」,链接器会把它们合并成一份。这也是为什么 inline 函数必须定义在头文件里——每个用到它的翻译单元都要看到完整定义。
// 适用于 C++17 —— 放在头文件里
inline int square(int x) {
return x * x;
}如果去掉inline,这个头文件被两个.cpp包含,链接阶段就会报multiple definition(ODR 违规)。反过来,把inline函数只定义在某个.cpp里、在别的翻译单元调用,会得到未定义符号。
2.2 inline 不保证展开
标准从未要求编译器把 inline 函数展开。现代编译器在-O2下会内联很多没写inline的小函数,也会拒绝内联写了inline的递归函数或超大函数。控制是否内联的通常是优化级别、函数体大小、调用次数和编译器的启发式判断,inline关键字在优化决策里的权重很低。
| 说法 | 是否准确 |
|---|
inline让函数在调用处展开 | 不准确,展开由优化器决定 |
inline允许头文件中重复定义 | 准确,这是它的标准语义 |
| 类内定义的成员函数隐式 inline | 准确 |
constexpr函数隐式 inline | 准确 |
写了inline一定更快 | 不准确,可能增大代码体积、降低指令缓存命中率 |
类内直接定义的成员函数(不是声明后再在类外定义)自动是 inline 的,所以头文件里的类通常不需要给成员函数再写inline。
2.3 inline 变量与静态局部变量
C++17 起可以声明 inline 变量,用于在头文件里定义全局常量而不违反 ODR:
// 适用于 C++17
inline constexpr int kMaxRetries = 3;
struct Config {
static inline int timeout_ms = 1000; // C++17 起可用
};C++17 之前,类的静态数据成员要在类外再定义一次,否则被 odr-use 时链接失败。static constexpr成员在 C++17 起隐式 inline,类外定义不再必需。
另一个容易忽略的点:inline 函数里的static局部变量在所有翻译单元中是同一个对象,地址相同。这一点和「头文件里的static函数」正好相反——后者每个翻译单元各有一份,地址不同。
2.4 inline 与宏
宏在预处理阶段做文本替换,没有类型检查,参数会被重复求值:
// 适用于 C++17
#define SQUARE(x) ((x) * (x))
int y = SQUARE(i++); // i 被自增两次,UB 级别的逻辑错误inline 函数有正常的参数求值规则和类型检查,应当优先于宏。宏只在需要字符串化(#x)、拼接记号(##)或条件编译时才有不可替代的用途。
三、nullptr:类型安全的空指针
3.1 NULL 的问题
NULL是宏,典型定义是0或__null(GCC)或nullptr(部分新标准库实现)。在 C++11 之前它只能是个整数常量,于是重载决议会出问题:
// 适用于 C++17
void handle(int value);
void handle(char* buffer);
handle(NULL); // 调用 handle(int)!
handle(nullptr); // 调用 handle(char*)因为NULL是整数常量0,int是精确匹配,而0转char*需要空指针转换,所以重载选中int版本。这类 bug 在「用 0 表示空指针」的老代码里非常常见。
nullptr的类型是std::nullptr_t,它可以隐式转换为任意指针类型和成员指针类型,但不能转换为整数(bool除外):
// 适用于 C++17
int* p1 = nullptr;
double* p2 = nullptr;
void (*fp)() = nullptr;
int n = nullptr; // 错误:不能转成 int
bool b = nullptr; // 合法,b 为 false3.2 nullptr 与模板推导
nullptr在模板里的行为和0完全不同:
// 适用于 C++17
template <typename T>
void take(T* p);
take(nullptr); // T 推导为 std::nullptr_t,参数类型是 std::nullptr_t*
auto p = nullptr; // p 的类型是 std::nullptr_t,不是指针
auto q = static_cast<int*>(nullptr); // q 才是 int*auto p = nullptr;得到的不是「空指针」而是一个std::nullptr_t对象,之后不能给它赋int*。要空指针就显式写int* p = nullptr;。
3.3 使用 nullptr 的边界
delete nullptr;是安全的,标准规定为空操作。delete[] nullptr;同样安全。- 对空指针解引用是 UB,编译器不会替你检查。
- 把
nullptr传给std::string的构造函数是 UB:std::string s(nullptr);会去调用const char*版本并对空指针求长度。要空串就写std::string s;或std::string s{};。 - 打印指针要转成
const void*:std::cout << static_cast<const void*>(p);。直接输出int*也能匹配const void*重载并打印地址,但把类型写明确更不容易出错。
| 写法 | 类型 | 能赋给int* | 参与重载 |
|---|
0 | int | 是(空指针常量转换) | 优先匹配int |
NULL | 实现定义(通常是int或long) | 是 | 通常优先匹配整数重载 |
nullptr | std::nullptr_t | 是 | 匹配指针重载 |
四、三者交汇的完整示例
// 适用于 C++17
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>
inline constexpr std::size_t kSmallLimit = 16;
// inline:定义在头文件里也不会违反 ODR
inline bool is_small(const std::string& s) {
return s.size() <= kSmallLimit;
}
class Registry {
public:
// 引用参数:不拷贝,且承诺不修改
void add(const std::string& name) {
names_.push_back(name);
}
// 返回引用:对象归成员所有,生命周期由 Registry 保证
const std::string& at(std::size_t i) const {
return names_.at(i);
}
// nullptr 表示「没有找到」,返回值语义清晰
const std::string* find(const std::string& name) const {
for (const auto& n : names_) {
if (n == name) return &n;
}
return nullptr;
}
private:
std::vector<std::string> names_;
};
void report(const std::string* name) {
if (name == nullptr) {
std::cout << "not found\n";
return;
}
std::cout << *name << '\n';
}
int main() {
Registry reg;
reg.add("alpha");
reg.add("beta");
const std::string& first = reg.at(0);
std::cout << first << '\n';
report(reg.find("beta"));
report(reg.find("gamma"));
int* empty = nullptr;
delete empty; // 安全,什么也不做
return 0;
}这份示例里,is_small的inline保证多个翻译单元包含它时不会重复定义;add的const std::string&避免了拷贝;at返回引用是因为元素存放在成员容器里、生命周期跟随对象;find返回指针而不是引用,因为「没找到」需要一个可表示的空值——引用做不到这一点,这正是引用与指针最本质的分工。
常见坑点
- ❌
int& r;声明一个「空引用」之后再赋值
✅ 引用必须在定义时绑定:int& r = value;引用没有空状态,未初始化的引用无法通过编译。
- ❌ 函数返回局部变量的引用或指针
✅ 返回值、返回静态对象,或让调用方传入输出参数 局部对象在函数返回时销毁,返回的引用悬垂,使用是 UB。
- ❌
const std::string& s = make_holder().text;
✅ 先按值保存Holder h = make_holder();再取成员引用 成员访问不会延长外层临时对象的生命周期。
- ❌ 在头文件里定义普通函数(不写
inline)
✅ 加inline,或改成类内定义,或放.cpp多个翻译单元各有一份定义,链接期报重复定义。
- ❌ 认为写了
inline就一定会展开,据此做性能优化
✅ 用inline表达「可多重定义」,性能交给优化器和测量 标准不保证展开,内联决策由编译器按优化级别和成本模型做。
- ❌
handle(NULL);期望调用指针版本
✅handle(nullptr);NULL通常是整数0,重载决议优先匹配int参数。
- ❌
auto p = nullptr;之后把p当指针用
✅ 写int* p = nullptr;或auto p = static_cast<int*>(nullptr);auto推导出的是std::nullptr_t,不是任何指针类型。
- ❌
std::string s(nullptr);
✅std::string s;或std::string s{};构造函数会选择const char*版本并对空指针求长度,属于 UB。
总结
| 主题 | 核心规则 | 常见误区 |
|---|
| 引用 | 必须绑定、不可改绑、无空状态 | 当成可为空的指针 |
| const 引用 | 直接绑定临时量可延长生命周期 | 以为成员访问也能延长 |
| inline | 允许跨翻译单元重复定义 | 当成强制内联指令 |
| inline 变量 | C++17 起可在头文件定义 | 仍用类外定义的老写法 |
| nullptr | 类型是std::nullptr_t | 以为等价于NULL |
| 空值语义 | 可能为空就用指针,不可能为空才用引用 | 用引用表达「未找到」 |
把这三样东西串起来看,会发现它们共同指向一个设计原则:用类型表达约束。引用表达「一定有对象」,const T&表达「只读且不拷贝」,inline表达「这个定义可以出现在多个翻译单元」,nullptr表达「空指针且类型明确」。接口签名把这些约束写清楚,比在注释里叮嘱调用方要可靠得多。
新手最容易犯的错不是不知道语法,而是在「差不多能用」的地方停下:引用悬垂时程序往往不崩,inline写错时小项目只有一个翻译单元也看不出问题,NULL的重载错误在只有单个重载时完全无害。等工程变大、优化开启、链接单元变多,这些隐患才会集中爆发。按标准语义理解它们,而不是按「我见过的写法」理解,是避开这些坑的唯一办法。