引用、const 与类型推导:从复制到借用 / References, const, and Type Deduction
本文属于 C++ 语言基础模块的“接口与类型系统”部分。
上一篇讲了地址、指针和借用。本篇继续追问:一个函数拿到对象时,究竟是复制一份、借用原对象,还是允许修改原对象?
本文会从可观察的程序行为开始,再进入引用绑定、对象生命周期、
const、auto、decltype和右值引用。
1. 同一个参数位置,三种不同承诺
#include <string>
void receive_by_value(std::string text);
void receive_read_only(const std::string& text);
void receive_mutable(std::string& text);它们都接收一个字符串,但关系不同:
receive_by_value
+-- 函数得到独立的 string 对象
+-- 修改它不影响调用者原对象
receive_read_only
+-- 函数借用调用者对象
+-- 不能通过参数修改对象
receive_mutable
+-- 函数借用调用者对象
+-- 可以修改调用者对象这不是为了少写几个字符。
参数形式表达了:
是否复制
是否允许修改
借用是否可能被保存
资源是否可能被转移2. 第一个实验:观察修改发生在哪里
#include <iostream>
#include <string>
void by_value(std::string text) {
text += " inside";
std::cout << "function: " << text << '\n';
}
void by_reference(std::string& text) {
text += " inside";
std::cout << "function: " << text << '\n';
}
void by_const_reference(const std::string& text) {
std::cout << "function: " << text << '\n';
}
int main() {
std::string name = "Ada";
by_value(name);
std::cout << "after value: " << name << '\n';
by_reference(name);
std::cout << "after reference: " << name << '\n';
by_const_reference(name);
}预期现象:
function: Ada inside
after value: Ada
function: Ada inside
after reference: Ada inside
function: Ada inside第一次改的是副本,第二次改的是调用者对象,第三次只观察。
如果文件名是 parameter-kinds.cpp,先用简单命令:
g++ parameter-kinds.cpp -o parameter-kinds命令含义:
g++
+-- 启动 C++ 编译器驱动
parameter-kinds.cpp
+-- 输入源码
-o parameter-kinds
+-- 指定输出程序名运行:
./parameter-kindsPowerShell 中常见写法:
.\parameter-kinds.exe3. 本文基础词小注释
3.1 别名
别名是给同一个对象增加另一个访问名称:
int value = 10;
int& alias = value;value ----+
+----> 同一个 int 对象
alias ----+3.2 借用
借用表示可以访问对象,但不负责拥有或销毁对象。
引用通常表达非空借用,std::string_view 表达字符范围的只读借用。
3.3 顶层 const
顶层 const 修饰变量对象本身:
int* const pointer = nullptr;这里不能给 pointer 重新赋地址。
3.4 底层 const
底层 const 修饰指针指向的对象:
const int* pointer = nullptr;这里可以改变指针指向哪里,但不能通过它修改目标 int。
3.5 临时对象
没有长期名字、在表达式计算过程中产生的对象叫临时对象:
std::string{"hello"}3.6 类型推导
类型推导是编译器根据初始化表达式确定类型:
auto count = 3;count 仍然是静态类型 int,不是运行时动态类型。
4. 引用:给已有对象另一个名字
int value = 10;
int& reference = value;
reference = 20;reference 不是新建的 int,而是 value 的别名。
名字 value -----+
+----> 同一个对象:20
名字 reference -+实验:引用赋值不是重新绑定
#include <iostream>
int main() {
int first = 1;
int second = 2;
int& reference = first;
reference = second;
std::cout << "first = " << first << '\n';
std::cout << "second = " << second << '\n';
}输出:
first = 2
second = 2reference = second 的意思是把 second 的值写入当前绑定对象 first,不是让引用改绑到 second。
引用一旦绑定,不能像普通指针那样重新指向另一个对象。
5. 引用和指针为什么不能简单画等号
常见说法是“引用就是不能为空的指针”。它可以帮助形成实现直觉,但不是完整语言定义。
| 方面 | 引用 T& | 指针 T* |
|---|---|---|
| 初始化 | 必须绑定对象 | 应该初始化 |
| 无目标状态 | 正常语义中不能表达 | 可用 nullptr |
| 修改指向 | 不能重新绑定 | 可以 |
| 访问语法 | 像对象本身 | 需要 * 或 -> |
| 指针运算 | 不支持 | 数组场景可支持 |
| 所有权 | 通常不表达 | 通常也不表达 |
ABI 可能把引用参数实现为地址传递,但源码层面的语义仍不同:
引用:别名关系
指针:保存指针值的对象6. 绑定规则:哪些对象能被哪种引用接收
先观察:
int mutable_value = 1;
const int fixed_value = 2;
int& a = mutable_value;
const int& b = mutable_value;
const int& c = fixed_value;一个初步表格:
int& const int& int&&
可修改左值 可以 可以 通常不行
const 左值 不行 可以 不行
临时结果 不行 可以 可以非常量左值引用不能直接绑定普通临时量:
// int& bad = 42;因为 int& 表达了可修改借用,而 42 没有适合这种借用的稳定身份。
只读引用可以观察临时量:
const int& view = 42;7. 生命周期:引用活着不等于对象活着
引用变量的生命周期和被引用对象的生命周期是两件事。
错误例子:
const std::string& bad() {
std::string local = "temporary";
return local;
}进入 bad()
|
v
创建 local
|
v
返回 local 的引用
|
v
函数结束,local 销毁
|
v
调用者得到悬空引用实验:不要相信“这次打印正常”
#include <iostream>
#include <string>
const std::string& bad() {
std::string local = "temporary";
return local;
}
int main() {
std::cout << bad() << '\n';
}编译器通常会发出返回局部对象引用的警告。
即使偶尔打印出正确文字,也不代表程序正确,因为对象已经结束生命周期。
7.1 直接绑定临时量
const std::string& name = std::string{"Ada"};这种直接绑定语境通常会把临时对象的生命周期延长到 name 的作用域结束。
但不要把延长理解成“引用可以无限转接”:
const std::string& identity(const std::string& value) {
return value;
}
const std::string& bad = identity(std::string{"Ada"});参数、返回引用和调用者的保存并不会自动建立无限生命周期。
8. const 保护的是访问路径
int value = 1;
const int& read_only = value;不能通过 read_only 修改:
// read_only = 2;但原对象不是 const,所以仍可通过其他路径修改:
value = 3;const 限制通过这条路径能做什么
不保证整个程序没有其他可写路径这就是:
void inspect(const std::string& text);通常表达“不应通过该参数修改”,而不是“程序中任何地方都不会修改”。
9. const 指针的三种写法
int value = 1;
const int* p1 = &value;
int* const p2 = &value;
const int* const p3 = &value;从变量名向外读:
const int* p1
+-- p1 是指针,指向 const int
int* const p2
+-- p2 是 const 指针,指向 int
const int* const p3
+-- p3 是 const 指针,指向 const int行为:
p1 = nullptr; // 合法
// *p1 = 2; // 不合法
// p2 = nullptr; // 不合法
*p2 = 2; // 合法顶层和底层 const
int* const
^
+-- 指针对象本身 const:顶层 const
const int*
^
+-- 目标 int const:底层 const按值复制通常丢掉顶层 const:
const int source = 42;
auto copy = source;copy 是独立的 int,不是只读别名。
10. auto:静态类型的自动书写
auto count = 3;编译期会把它确定为:
int count = 3;运行时不会再改变类型。
10.1 普通 auto 通常按值推导
const int value = 42;
const int& reference = value;
auto a = value;
auto b = reference;a 和 b 都是独立的 int:
value
+-- reference:别名
+-- a:值副本
+-- b:值副本普通 auto 通常去掉引用和顶层 const。
10.2 auto& 继续借用
const int value = 42;
const auto& view = value;view 的类型是 const int&。
这里的 & 决定了“不要复制,继续绑定”。
10.3 Range-for 的区别
std::vector<int> values{1, 2, 3};
for (auto value : values) {
value += 10;
}修改的是副本。
for (auto& value : values) {
value += 10;
}修改容器元素。
for (const auto& value : values) {
std::cout << value << '\n';
}只读借用,避免复制。
11. 类型推导实验:让编译器暴露结果
可以用 decltype 写一个故意不完整的模板:
template <typename T>
struct show_type;
const int value = 42;
show_type<decltype(value)> check;编译器错误信息往往会把 T 展开成推导类型。
这个实验不是为了依赖错误,而是为了熟悉:
源码写法
|
v
编译器推导
|
v
错误信息显示类型也可以在正常代码中使用:
static_assert(std::is_same_v<decltype(value), const int>);这里 static_assert 是编译期断言。如果条件为假,程序在编译阶段失败。
12. decltype:询问名字或表达式的类型
int value = 0;
int& reference = value;
decltype(value) a = 1;
decltype(reference) b = value;结果:
decltype(value) int
decltype(reference) int&对于未加括号的名字,decltype(name) 有特殊规则,接近询问名字的声明类型。
decltype((value)) c = value;这里 (value) 是左值表达式,所以得到 int&。
核心对照:
decltype(x) 看未加括号的名字规则
decltype((x)) 看表达式值类别规则实验:括号改变推导结果
int number = 10;
decltype(number) first = 1;
decltype((number)) second = number;
second = 20;second 是引用,所以 number 变成 20。
13. 值类别:左值、右值到底在描述什么
左值和右值不是变量类型,而是表达式类别。
先记住最有用的直觉:
左值 lvalue
+-- 有稳定身份,通常可以取地址
右值 rvalue
+-- 更像计算结果或可被消费的值int value = 10;
value // 左值
value + 1 // 纯右值
42 // 纯右值值类别影响:
能否绑定某种引用
是否能修改原对象
是否适合移动资源13.1 更完整的分类
表达式
|
+-- glvalue:有对象身份
| |
| +-- lvalue:普通具名对象
| +-- xvalue:有身份但资源可复用
|
+-- prvalue:纯计算值或初始化结果
rvalue = prvalue + xvalue基础阶段不必立刻背完分类名。先能回答:
表达式有没有稳定对象身份?
能不能绑定 T&?
是否适合把资源交给移动操作?14. 右值引用和 std::move
std::string text = "hello";
std::string moved = std::move(text);std::move(text) 本身不移动字符。
它只是把表达式转换成可供移动构造选择的形式。
std::move(text)
+-- 改变表达式值类别
string 移动构造
+-- 可能接管 text 的字符资源移动后 text 仍是有效对象,但具体内容通常是有效而未指定。
不要写成“移动后一定为空”。
14.1 具名右值引用是左值表达式
std::string&& reference = std::string{"hello"};虽然变量类型是右值引用,但表达式 reference 有名字和身份,所以它作为表达式是左值。
再次移动时:
std::move(reference);15. constexpr 和 const 的区别
const int runtime_value = read_value();
constexpr int compile_time_value = 42;前者表示初始化后不能通过该名字修改,但值来自运行时。
后者要求能在编译期求值:
constexpr int square(int value) {
return value * value;
}
constexpr int size = square(4);同一个 constexpr 函数也能运行时调用:
int input = read_value();
int result = square(input);提示:
consteval
+-- 调用必须产生编译期结果
constinit
+-- 要求静态存储期变量进行静态初始化不要把这些词和普通 const 混为一谈。
16. 借用视图和保存视图的危险
class View {
public:
explicit View(std::string_view text) : text_(text) {}
private:
std::string_view text_;
};这个类保存借用关系。
危险调用:
View view{std::string{"temporary"}};temporary string
+-- owner,负责字符生命周期
string_view
+-- borrower,只保存地址和长度
完整表达式结束
|
v
owner 销毁
|
v
borrower 悬空如果类必须保存内容,更直接的设计是取得所有权:
class Owner {
public:
explicit Owner(std::string text)
: text_(std::move(text)) {}
private:
std::string text_;
};17. 参数设计:从对象关系出发
需要独立副本?
-> 按值传递
必须存在,只读借用?
-> const T&
必须存在,可修改?
-> T&
允许没有对象?
-> T* 或更明确的可选观察关系
需要转移资源?
-> 按值、unique_ptr 或资源专用类型17.1 按值传递不一定慢
void consume(std::string text);调用者可以选择:
consume(existing); // 复制或省略复制
consume(std::move(existing)); // 允许转移资源如果函数本来就需要一份独立对象,按值参数可能比“先 const 引用再复制”更清晰。
17.2 小对象不必强行引用
void f(int value);对很小的值类型,按值传递通常简单直接。const int& 可能带来不必要的间接访问和生命周期复杂度。
选择时同时考虑:
对象大小
复制成本
所有权
是否保存
调用者是否需要继续使用原对象18. 调用约定的运行时直觉
函数参数需要按照平台 ABI 传递。对于:
void update(int& value);实现上经常可以把引用理解为传递对象地址:
调用者
|
| 传递 value 的地址
v
update()
|
+-- 通过引用语义访问同一个 int但源码层面仍然不同:
引用不能正常表达空值
引用不能重新绑定
引用不能做指针算术值参数和引用参数的本质区别是对象关系,而不是某条固定汇编指令。
19. 常见误解和实验排查
误解一:引用赋值会重新绑定
int a = 1;
int b = 2;
int& ref = a;
ref = b;结果是 a 变成 2。
误解二:const 对象绝不会改变
只读引用限制自己的访问路径:
int value = 1;
const int& view = value;
value = 2;误解三:普通 auto 保留引用
int value = 1;
int& ref = value;
auto copy = ref;
copy = 2;value 仍是 1,因为 copy 是值副本。
误解四:decltype(x) 和 decltype((x)) 相同
括号把名字放进表达式规则,结果可能变成引用。
误解五:std::move 立即清空对象
它只改变值类别,真正的移动由后续构造或赋值完成。
误解六:右值引用变量本身是右值
具名变量表达式通常是左值。
20. 用编译器检查类型和借用错误
类型错误通常能被编译器发现:
int value = 1;
const int& read_only = value;
// read_only = 2; // 编译错误但生命周期错误可能需要警告、Sanitizer 或人工追踪:
std::string_view bad() {
return std::string{"temporary"};
}检查借用接口时要同时问:
类型是否允许这种绑定?
owner 什么时候销毁?
borrower 是否会被保存?
底层存储是否可能重新分配?21. 最小实验路线
依次完成:
- 比较值传递和引用传递;
- 让
const T&绑定临时字符串; - 返回局部对象引用,观察警告;
- 比较
auto、auto&、const auto&; - 比较
decltype(x)与decltype((x)); - 观察
std::move前后对象状态; - 保存
string_view并测试临时字符串; - 用
-S观察值参数和引用参数。
查看汇编:
g++ -std=c++17 -S parameter-kinds.cpp -o parameter-kinds.s-S
+-- 生成汇编文本,不生成最终可执行文件
-o parameter-kinds.s
+-- 指定汇编输出文件名不必先背指令,先观察:
值参数是否操作副本?
引用参数是否需要找到原对象?22. 自测与答案
引用能不能重新绑定?
不能。引用赋值是在修改当前绑定对象。
const int& 能不能绑定普通 int?
可以。它创建只读访问路径,不会让原对象自动变成真正的 const 对象。
auto x = const_value 通常是什么类型?
独立的非 const 值类型,例如 int。
decltype((value)) 为什么常是引用?
因为带括号的具名变量是左值表达式,decltype 根据值类别得到引用类型。
std::move 做了什么?
把表达式转换为适合移动操作选择的值类别,本身不执行资源转移。
保存 string_view 最重要的检查是什么?
确认字符 owner 的生命周期和存储稳定性覆盖 view 的整个使用期。
23. 本篇总结与下一篇连接
调用者对象
|
+-- 按值 ------------> 函数得到独立副本
|
+-- T& --------------> 函数借用并可修改
|
+-- const T& --------> 函数借用但只读
|
+-- T&& -------------> 函数可接收并可能消费资源类型推导决定这些关系是否保留:
auto
+-- 通常按值推导,可能丢掉引用和顶层 const
auto&
+-- 保留借用关系
const auto&
+-- 保留只读借用关系
decltype
+-- 根据名字或表达式值类别精确询问类型下一篇 枚举、结构体、联合体与别名 会继续回答:
如果一个对象由多个字段组成,
这些字段在内存中如何排列?它会把本文的引用、类型、对象表示和内存布局继续连接起来。