文章目录
- 引言
- 一、引用的本质:别名,而非地址
-
- [1.1 别名语义](#1.1 别名语义)
- [1.2 引用与指针的内存视图](#1.2 引用与指针的内存视图)
- [1.3 引用必须在定义时初始化](#1.3 引用必须在定义时初始化)
- [二、`const &`:临时对象的生命线](#二、
const &:临时对象的生命线) -
- [2.1 const 引用可以绑定到临时对象](#2.1 const 引用可以绑定到临时对象)
- [2.2 临时对象生命周期延长](#2.2 临时对象生命周期延长)
- 三、引用作为函数参数:零拷贝传递
-
- [3.1 从 C 的指针传参到 C++ 的引用传参](#3.1 从 C 的指针传参到 C++ 的引用传参)
- [3.2 传值 vs 传引用 vs 传 const 引用](#3.2 传值 vs 传引用 vs 传 const 引用)
- 四、返回引用:踩坑天堂
-
- [4.1 可以安全返回引用的情况](#4.1 可以安全返回引用的情况)
- [4.2 绝不能返回的引用](#4.2 绝不能返回的引用)
- 五、左值引用与右值引用的第一印象
-
- [5.1 什么是左值、什么是右值](#5.1 什么是左值、什么是右值)
- [5.2 右值引用的基本用途:移动](#5.2 右值引用的基本用途:移动)
- [5.3 引用折叠(reference collapsing)](#5.3 引用折叠(reference collapsing))
- 六、引用的其他细节
-
- [6.1 引用成员变量](#6.1 引用成员变量)
- [6.2 指针与引用的转换](#6.2 指针与引用的转换)
- [6.3 不要返回函数内 lambda 捕获的引用](#6.3 不要返回函数内 lambda 捕获的引用)
- [七、引用 vs 指针:决策指南](#七、引用 vs 指针:决策指南)
- 总结
本系列为《C++深度修炼:基础、STL源码与多线程实战》第9篇
前置条件:理解 C 语言的指针,了解 C++ 的 const(第8篇)和函数(第4篇)
引言
C 程序员对指针了如指掌:
c
int x = 10;
int *p = &x; // p 存着 x 的地址
*p = 20; // 通过 p 间接修改 x
C++ 引入了引用(reference) ------一个表面上像"自动解引用的指针",但实际上是一个更基础的语言概念:别名。
cpp
int x = 10;
int &r = x; // r 是 x 的别名------不是指针,不是地址,就是同一个东西
r = 20; // 等同于 x = 20
引用不只是"更安全的指针"。它引发了 C++ 中一整套与值类别、临时对象、完美转发相关的设计,这些后话会在泛型编程章节展开。本文先打好基础:引用的语义、与指针的边界、const & 的妙用、以及何时用引用、何时用指针。
一、引用的本质:别名,而非地址
1.1 别名语义
cpp
#include <iostream>
int main() {
int x = 42;
int &r = x; // r 是 x 的引用(别名)
std::cout << "x = " << x << ", r = " << r << '\n'; // 42, 42
std::cout << "&x = " << &x << ", &r = " << &r << '\n'; // 同一个地址!
r = 100; // 修改 r 就是修改 x
std::cout << "x = " << x << '\n'; // 100
}
输出:
text
x = 42, r = 42
&x = 0x7ffc1234, &r = 0x7ffc1234 ← 完全相同的地址
x = 100
取引用变量的地址,得到的和被引用对象的地址是同一个地址。这一点和指针截然不同------指针变量有自己的地址,其中存储的值是目标对象的地址。
1.2 引用与指针的内存视图
cpp
int x = 42;
int *p = &x; // p 是一个独立变量,值为 &x
int &r = x; // r 不是独立变量,它只是 x 的另一个名字
// 内存视角:
// ┌───────┬───┐
// │ x │42 │ ← 地址 0x1000
// ├───────┼───┤
// │ p │0x1000 │ ← 地址 0x1008(p 有自己的地址)
// ├───────┼───┤
// │ r │ (不存在独立存储,r 就是 0x1000) │
// └───────┴───┘
引用在语言层面不占存储空间 (底层实现通常用指针,但这不是你该依赖的细节)。sizeof(r) 返回的是被引用对象的大小,不是指针的大小。
1.3 引用必须在定义时初始化
cpp
int &r; // ❌ 编译错误:引用必须初始化
int &r2 = x; // ✅ 定义时绑定,之后不能"重新绑定"到别的变量
int y = 0;
r2 = y; // 这不是重新绑定 r2------这是把 y 的值赋给 x(通过 r2)!
与指针对比:
cpp
int *p; // ✅ 可以先不初始化(危险,但不报错)
p = &x; // 后续指向 x
p = &y; // 可以改指向 y------指针可以"重新指向"
| 特性 | 指针 | 引用 |
|---|---|---|
| 可以不初始化 | ✅ (危险) | ❌ 必须初始化 |
| 可以重新绑定 | ✅ p = &y |
❌ 绑定后不可改 |
| 可以为空 | ✅ nullptr |
❌ 没有"空引用" |
| 有独立地址 | ✅ &p != &x |
❌ &r == &x |
| 需要解引用 | ✅ *p = 10 |
❌ 直接使用 r = 10 |
| 编译器可能优化掉 | ❌ | ✅ |
二、const &:临时对象的生命线
2.1 const 引用可以绑定到临时对象
这是引用最常用的模式,也是 C 程序员最容易忽略的差异:
cpp
void print(const std::string &s) {
std::cout << s << '\n';
}
int main() {
print("hello"); // "hello" 是 const char[6],不是 std::string
// 但 const std::string& 可以绑定到临时对象!
// 编译器创建一个临时 std::string("hello"),引用绑定到它
}
没有 const &,你只能传 std::string 对象本身:
cpp
void print(std::string &s) { // 非 const 引用------不能绑定临时对象
std::cout << s << '\n';
}
int main() {
// print("hello"); // ❌ 不能把 const char[6] 绑定到 std::string&
std::string s = "hello";
print(s); // ✅ 可以绑定到左值
}
规则 :const T& 可以绑定到临时对象(右值),T& 只能绑定到左值。
2.2 临时对象生命周期延长
cpp
#include <iostream>
class Tracer {
public:
Tracer() { std::cout << "Tracer()\n"; }
~Tracer() { std::cout << "~Tracer()\n"; }
void hello() const { std::cout << "hello\n"; }
};
int main() {
{
Tracer t; // t 在作用域结束时析构
std::cout << "before end of scope\n";
} // t 在这里析构
std::cout << "---\n";
{
const Tracer &ref = Tracer(); // 临时对象!生命周期延长到 ref 的作用域
ref.hello();
std::cout << "before end of scope\n";
} // 临时 Tracer 在这里析构------因为 const & 延长了它的生命
}
输出:
text
Tracer()
before end of scope
~Tracer()
---
Tracer()
hello
before end of scope
~Tracer()
const T& 将临时对象的生命延长到了引用本身的作用域。这个规则确保了你不会在下一行访问已销毁的对象。
三、引用作为函数参数:零拷贝传递
3.1 从 C 的指针传参到 C++ 的引用传参
c
// C 的方式:传指针
void update_temperature(double *temp) {
if (temp) *temp += 5.0; // 必须判空------不然解引用空指针崩掉
}
// 调用侧
update_temperature(&reading); // 需要取地址
cpp
// C++ 的方式:传引用
void update_temperature(double &temp) {
temp += 5.0; // 不需要判空------引用不能为空
}
// 调用侧
update_temperature(reading); // 不需要取地址------和传值一样的写法,但零拷贝
3.2 传值 vs 传引用 vs 传 const 引用
cpp
#include <iostream>
#include <string>
#include <vector>
// 传值:拷贝一份
void process_by_value(std::vector<int> v) {
v.push_back(42); // 修改的是副本
} // 析构副本
// 传引用:不拷贝,可修改
void process_by_ref(std::vector<int> &v) {
v.push_back(42); // 修改的是原对象
}
// 传 const 引用:不拷贝,不可修改
void process_by_cref(const std::vector<int> &v) {
// v.push_back(42); // ❌ const,不可修改
std::cout << v.size() << '\n'; // ✅ 只读访问
}
选择标准:
| 场景 | 传参方式 |
|---|---|
| 小对象(int, double, pointer) | 传值 |
| 大对象,只读访问 | const T& |
| 大对象,需要修改 | T& |
| 需要所有权的转移 | T&&(右值引用,后续章节) |
| 可选参数(可能为空) | 指针(T*)------引用不能表示"没有" |
💡 经验法则 :默认用
const T&传递非基本类型。需要修改时用T&。需要所有权或可选时再考虑其他。
四、返回引用:踩坑天堂
4.1 可以安全返回引用的情况
情况一:返回成员变量的引用
cpp
class Container {
public:
int& at(size_t i) { return data_[i]; } // 非 const 版本
const int& at(size_t i) const { return data_[i]; } // const 版本
private:
std::vector<int> data_{1, 2, 3};
};
情况二:返回静态/全局对象的引用
cpp
const std::string& app_name() {
static const std::string name = "MyApp v2.0";
return name; // 安全:静态对象生命周期 = 整个程序
}
情况三:返回传入的引用参数
cpp
// 流操作符返回引用,支持链式调用
std::ostream& operator<<(std::ostream &os, const Point &p) {
return os << '(' << p.x << ", " << p.y << ')';
}
4.2 绝不能返回的引用
cpp
// ❌ 灾难一:返回局部变量的引用
const std::string& make_greeting(const std::string &name) {
std::string result = "Hello, " + name; // 局部变量
return result; // 悬垂引用!result 在函数返回时就销毁了
}
// ❌ 灾难二:返回临时对象的引用
const int& get_value() {
return 42; // 临时 int 在 return 后销毁------悬垂引用
}
// ❌ 灾难三:返回局部 unique_ptr 的引用
const std::string& bad_factory() {
auto p = std::make_unique<std::string>("hello");
return *p; // p 在函数结束时被销毁------*p 也没了
}
编译器的警告可帮不少忙(-Wall 会警告返回局部变量的引用),但不能依赖警告------逻辑上没有编译器能判断所有情况。
五、左值引用与右值引用的第一印象
C++11 引入了右值引用(rvalue reference) ,用 && 表示。这是移动语义和完美转发的基础------这里先给第一印象,详细内容在模板章节展开。
5.1 什么是左值、什么是右值
简化版定义:
- 左值(lvalue) :有名字、能取地址的表达式。如变量
x、解引用*p - 右值(rvalue) :临时的、没有持久身份的表达式。如字面量
42、表达式结果x + y、函数返回的临时对象
cpp
int x = 10; // x 是左值
int &lr = x; // ✅ 左值引用绑定左值
// int &lr2 = 10; // ❌ 左值引用不能绑定右值
const int &clr = 10; // ✅ const 左值引用可以绑定右值
int &&rr = 10; // ✅ 右值引用绑定右值
int &&rr2 = x + 5; // ✅ 右值引用绑定临时表达式结果
// int &&rr3 = x; // ❌ 右值引用不能直接绑定左值
5.2 右值引用的基本用途:移动
cpp
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<int> v1{1, 2, 3, 4, 5};
std::vector<int> v2 = v1; // 拷贝:v1 保持不变,v2 是副本
std::vector<int> v3 = std::move(v1); // 移动:v1 的数据被"掏空"并转移给 v3
std::cout << "v1.size() = " << v1.size() << '\n'; // 0 ------ v1 被移空了
std::cout << "v3.size() = " << v3.size() << '\n'; // 5 ------ 数据归 v3 了
}
std::move 本质上是一个 cast------它把左值转成右值引用,让编译器可以选择移动构造函数而非拷贝构造函数。移动操作通常很廉价(对 std::vector 只是交换三个指针),避免了深拷贝。
5.3 引用折叠(reference collapsing)
这是模板编程中才会频繁遇到的规则,但了解它有助于理解一些看起来"违反直觉"的行为:
cpp
// 引用的引用在某些语境中会出现,编译器自动折叠:
// T& & → T&
// T& && → T&
// T&& & → T&
// T&& && → T&&
// 规则:只要有一个是左值引用,结果就是左值引用
// 全是右值引用,结果才是右值引用
这个规则是 std::forward(完美转发)能够工作的基础------后续模板章节详细展开。
六、引用的其他细节
6.1 引用成员变量
引用可以作为类的成员:
cpp
class Holder {
public:
Holder(int &ref) : ref_(ref) {}
void set(int v) { ref_ = v; } // 修改引用指向的外部变量
private:
int &ref_; // 引用成员
};
但引用成员有几个问题:
- 必须在构造的初始化列表中初始化(引用不能"后绑定")
- 类不能默认拷贝(编译器不会自动生成拷贝赋值运算符)
- 通常用指针成员更好------除非你明确需要"绑定后不可改"的语义
6.2 指针与引用的转换
cpp
// 引用 → 指针:取地址即可
void by_ref(int &r) {
int *p = &r; // r 是 x 的别名,&r == &x
}
// 指针 → 引用:先判空,再解引用
void by_ptr(int *p) {
if (p) {
int &r = *p; // 安全:已判空
}
}
6.3 不要返回函数内 lambda 捕获的引用
cpp
#include <functional>
// ❌ 危险
std::function<int()> make_counter_bad() {
int count = 0;
return [&count]() { return ++count; }; // count 在函数返回后销毁!
}
// ✅ 安全:按值捕获或使用 shared_ptr
std::function<int()> make_counter_good() {
auto count = std::make_shared<int>(0);
return [count]() { return ++(*count); };
}
七、引用 vs 指针:决策指南
text
你是 C 程序员,遇到下面场景怎么选?
│
需要"不存在的值"(空)?
│ │
是 否
│ │
指针 需要重新绑定?
│ │
是 否
│ │
指针 引用
│ │
对大型对象优化传参?
│ │
是 否
│ │
const T& 传值即可
一句话总结:引用是"不会为空、不会换绑"的指针。当你不想要指针的灵活度时,引用是更好的约束。 反过来,当语义上需要表达"可能没有",就用指针。
总结
引用是 C++ 对 C 指针世界的最重要补丁之一------它保留了间接访问的零开销,去掉了空指针和未初始化指针的危险:
- 引用的本质是别名------和原变量共享同一地址,不独立占用存储(语言层面)
const T&是工程中最常用的传参方式------零拷贝 + 只读保证 + 可绑定临时对象const T&延长临时对象生命周期------让你安全地接收函数返回的临时对象- 返回引用三思------局部变量、临时对象、局部智能指针的引用都会产生悬垂引用
- 右值引用
T&&是移动语义的基础------留下印象即可,后续泛型编程章节会深入 - 默认选择 :大型对象只读传参用
const T&,需要修改用T&,可选参数用指针,小对象传值
第2章的4篇文章(命名空间/输入输出/const/引用)到此结束。这些是 C 程序员进入 C++ 世界必须升级的"基础设施"。下一篇开始进入第3章------动态内存与智能指针 ,从 new/delete 一直讲到 unique_ptr、shared_ptr 和 RAII 的核心理念。
📝 动手练习:
- 写一个函数
swap(int &a, int &b)用引用交换两个整数,再写一个swap(int *a, int *b)用指针。对比调用侧的语法差异- 写一个函数返回
const std::string&,故意返回局部变量,看编译器能给出什么警告(-Wall)- 用
const T&改写一个之前大量传值的函数,用perf统计拷贝次数的减少- 探索:
int &&rr = 10; rr = 20;能编译吗?这意味着什么?(提示:右值引用本身是左值)