news 2026/9/12 18:19:30

OI-wiki 中的 C++ 引用详解:从左右值引用、悬垂引用到移动语义与性能优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OI-wiki 中的 C++ 引用详解:从左右值引用、悬垂引用到移动语义与性能优化实战

OI-wiki 中的 C++ 引用详解:从左右值引用、悬垂引用到移动语义与性能优化实战

【免费下载链接】OI-wiki:star2: Wiki of OI / ICPC for everyone. (某大型游戏线上攻略,内含炫酷算术魔法)项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki

引用(reference)是 C++ 中既基础又容易被误用的语言特性:它既是既存对象或函数的别名,也是 C++ 对"非空指针"的安全封装。本篇以 OI-wiki C++ 语言篇 中的 引用页面 为主体,系统讲解左值引用与右值引用的声明规则、绑定限制、悬垂引用的三大典型成因,以及结合std::move、复制消除与函数返回值优化(RVO/NRVO)的性能优化手法,并辅以仓库内 值类别、常量与常量引用、C++ 与其他语言的区别 与 顺序容器 等页面的代码佐证。读完本文,你将能在算法竞赛与工程代码中正确使用引用传参、安全规避未定义行为,并写出无冗余拷贝的高效代码。

引用的本质:既存对象的别名

引用可以看成是 C++ 封装的非空指针,可以用来传递它所指向的对象,在声明时必须指向对象。也就是说,引用一旦绑定到某个对象,就始终是该对象的别名,之后对该引用的所有读写操作都等同于直接操作被引用对象本身。

引用不是对象,因此存在以下三条限制:

  • 不存在引用的数组
  • 无法获取引用的指针
  • 不存在引用的引用

!!! note "引用类型不属于对象类型" 如果想让引用能完成一般的复制、赋值等操作,比如作为容器元素,则需要reference_wrapper(见 C++ 标准库参考),它通常内部维护一个非空指针实现。

引用主要分为两种:左值引用(lvalue reference)和右值引用(rvalue reference)。对左值和右值的完整定义,请参考 值类别 页面。

引用与指针的区别

理解引用的最佳对照物是指针。在 C++ 与其他常用语言的区别 中明确指出:C++ 中仍然可以使用 C 风格的指针,但对于变量传递而言,更推荐使用引用来实现类似的功能。原因很直接:

  • 引用指向的对象不能为空,因此可以避免一系列空地址访问问题;
  • 指针由于其灵活性(可重新指向、可为空、可参与指针运算),仍然有其用武之地;
  • C 中的NULL空指针在 C++11 起有类型安全的替代品nullptr
  • 引用和指针之间可以通过*&运算符相互转换(见 运算符)。

左值引用T&

通常我们会接触到的引用为左值引用,即绑定到左值的引用;同时,const限定的左值引用可以绑定右值。以下是来自 C++ 参考手册 的一段示例代码:

#include <iostream> #include <string> int main() { std::string s = "Ex"; std::string& r1 = s; const std::string& r2 = s; r1 += "ample"; // 修改 r1,即修改了 s // r2 += "!"; // 错误:不能通过到 const 的引用修改 std::cout << r2 << '\n'; // 打印 r2,访问了s,输出 "Example" }

这段代码展示了左值引用的两个核心性质:

  1. r1s的别名,通过r1 += "ample"修改的就是s本身,因此最终输出为"Example"
  2. r2const限定的左值引用,它只读地观察s,尝试通过r2修改内容会在编译期报错。这一性质与 常量与常量引用 中"常量引用限制了对指向的值的修改"的描述一致:
int a = 0; int &r1 = a; r1 = 1; const int &r2 = a; // r2 = 2; // 不能通过常量引用修改变量 // int &p3 = b; // 不能用 int& 引用 const int变量 const int &r4 = b;

作为函数参数:消除不必要的拷贝

左值引用最常用的地方是函数参数,用于避免不需要的拷贝:

#include <iostream> #include <string> // 参数中的 s 是引用,在调用函数时不会发生拷贝 char& char_number(std::string& s, std::size_t n) { s += s; // 's' 与 main() 的 'str' // 是同一对象,此处还说明左值也是可以放在等号右侧的 return s.at(n); // string::at() 返回 char 的引用 } int main() { std::string str = "Test"; char_number(str, 1) = 'a'; // 函数返回是左值,可被赋值 std::cout << str << '\n'; // 此处输出 "TastTest" }

这个例子还揭示了引用的另一个用途:函数可以返回引用,并且返回的引用是左值,可以被赋值。char_number(str, 1) = 'a'直接修改了str[1],而s += s则说明str在函数内被拼接了一次,最终输出"TastTest"

右值引用T&&(C++ 11)

右值引用是绑定到右值的引用,用于移动对象,也可以用于延长临时对象生存期。C++11 引入右值引用是移动语义进入语言核心的标志(详见 值类别):

#include <iostream> #include <string> using namespace std; int main() { string s1 = "Test"; // string&& r1 = s1; // 错误:不能绑定到左值,需要 std::move 或者 static_cast const string& r2 = s1 + s1; // 可行:到常量的左值引用延长生存期 // r2 += "Test"; // 错误:不能通过到常量的引用修改 cout << r2 << '\n'; string&& r3 = s1 + s1; // 可行:右值引用延长生存期 r3 += "Test"; cout << r3 << '\n'; const string& r4 = r3; // 右值引用可以转换到 const 限定的左值 cout << r4 << '\n'; string& r5 = r3; // 右值引用可以转换到左值 cout << r5 << '\n'; }

该示例系统展示了右值引用的绑定规则与转换关系:

声明绑定目标说明
string&& r1 = s1;左值s1错误:右值引用不能直接绑定左值,需std::movestatic_cast
const string& r2 = s1 + s1;临时量(右值)可行:到常量的左值引用可以绑定右值并延长其生存期,但不可修改
string&& r3 = s1 + s1;临时量(右值)可行:右值引用绑定右值并延长生存期,且可修改
const string& r4 = r3;右值引用(具名后为左值)右值引用可转换到 const 限定的左值
string& r5 = r3;右值引用(具名后为左值)右值引用可转换到左值

!!! tip "具名右值引用是左值" 注意r3本身是具名的,一经命名,r3在表达式中就是一个左值,因此string& r5 = r3合法,而string&& r1 = s1不合法。

悬垂引用:三大约束场景与未定义行为

当引用指代的对象已经销毁,引用就会变成悬垂引用(dangling reference)。访问悬垂引用是未定义行为(UB),可能会导致程序崩溃。以下是三种常见的悬垂引用场景:

场景一:引用局部变量

#include <iostream> int& foo() { int a = 1; return a; } int main() { int& b = foo(); std::cout << b << std::endl; // 未定义行为 }

afoo的局部变量,函数返回后a即被销毁,b指向一块已失效的栈内存,访问b属于未定义行为。

场景二:解分配导致的悬垂引用

#include <iostream> int main() { int* ptr = new int(10); int& ref = *ptr; delete ptr; std::cout << ref << std::endl; // 未定义行为 }

delete ptr释放了堆内存后,ref仍然"记得"那个地址,但该内存已归还系统,访问即 UB。

场景三:内存重分配导致的悬垂引用

#include <iostream> int main() { std::string str = "hello"; const char& ref = str.front(); str.append("world"); // 可能会重新分配内存,导致 ref 指向的内存被释放 std::cout << ref << std::endl; // 未定义行为 }

str.append("world")可能触发std::string内部的内存重分配,原先由str.front()返回的内部缓冲被释放或迁移,ref指向的字符地址随之失效。类似std::vectorstd::unordered_map等容器的插入操作,均有可能导致内存重新分配,从而让指向其内部元素的引用、迭代器、指针失效。

!!! warning "生命周期自检" 使用引用时,应时刻关注引用指向的对象的生命周期,避免造成悬垂引用。通常静态检查工具良好的代码习惯(如不在函数中返回局部对象的引用/指针、不在容器扩容后继续使用旧内部引用)能让我们避免悬垂引用的问题。

引用相关的优化技巧

引用在 OI/ICPC 与工程代码中最大的价值在于性能优化。仓库的 值类别 页面指出:了解值类别"可以帮助我们发现并避免不必要的复制,从而提高代码的效率和性能"。下面从三个角度展开。

消除非轻量对象入参的拷贝开销

常见的非轻量对象有:

  • 容器vectorarraymap等;
  • string
  • 其他实现了或继承了自定义拷贝构造、移动构造等特殊函数的类型。

对这些对象使用按引用传参(通常配合const)可以完全避免每次调用的整体拷贝。仓库内大量数据结构实现都遵循这一约定,例如 AVL 树实现 中operator==(const Entry &rhs)、Size Balanced Tree 实现 的同类写法,以及 红黑树实现 中lower_bound(const key_t &key)erase(const key_t &key)等接口,全部以const引用接收参数,避免key_t在每次查找/插入时被整体复制。

而对轻量对象使用引用不能带来任何好处——引用类型作为参数的空间占用大小,甚至可能会比类型本身还大(引用本质上是指针,按引用传参与按指针传参的底层开销相同)。这可能会带来性能负担,同时可能会阻止编译器优化。以下属于轻量对象

  • 基本类型intfloat等;
  • 较小的聚合体类型(见 聚合初始化);
  • 标准库容器的迭代器(如 稀疏表实现 中default_func(const T &t1, const T &t2)仅因模板泛型而统一使用 const 引用)。

因此实践准则是:大对象按 const 引用传,小对象按值传

将左值转换为右值:std::move

使用std::move转移对象的所有权。std::move的作用是将左值强制转换为右值,以便触发移动语义,从而避免深拷贝。这通常见于局部变量之间,或参数与局部变量之间:

#include <iostream> #include <string> #include <vector> using namespace std; string world(string str) { return std::move(str) += " world!"; } int main() { // 1 cout << world("hello") << '\n'; vector<string> vec0; // 2 { string&& size = to_string(vec0.size()); size += ", " + to_string(size.size()); vec0.emplace_back(std::move(size)); } cout << vec0.front(); }

代码第 1 处,world("hello")的参数str由临时量直接移动构造,函数体内std::move(str) += " world!"复用已分配缓冲完成拼接;第 2 处则演示了典型的"局部临时串 → 入容器"模式:先用右值引用size延长to_string临时量的生存期并就地加工,再通过std::move(size)将所有权转移给vector,全程无深拷贝。

在 顺序容器 中也有同样的实践:移动构造vector不拷贝元素、常数复杂度:

// 7. 移动v2到新创建的vector v6,不发生拷贝; 常数复杂度; 需要 C++11 vector<int> v6(std::move(v2)); // 或者 v6 = std::move(v2);

对比 值类别 中反复出现的push_back(std::move(str))写法,核心收益是避免str与容器内对象各存一份、内存占用加倍的问题。

!!! warning "不是所有时候都需要std::move" 比如函数返回值场景。在 值类别 的"常见误区"中明确指出:

- 在 `f1` 中返回 `std::move(x)` 是**多余**的,并不会带来性能上的提升,反而会干扰编译器进行 NRVO(具名返回值优化)优化; - 在 `f2` 中返回 `std::move(x)` 是**危险**的,函数返回右值引用指向了已被销毁的局部变量 `s`,出现了悬空引用问题。 ```cpp std::string f1() { std::string s = "123"; // 等价于 return std::string(std::move(s)) return std::move(s); } std::string&& f2() { std::string s = "123"; return std::move(s); } ```

右值引用延长临时量生命期

从语义上,临时量可能会带来额外的复制或移动,尽管多数情况下编译器能通过复制消除(Copy Elision)进行优化,但引用能强制编译器不进行这些多余操作,避免不确定性。如前文右值引用示例中string&& r3 = s1 + s1;直接绑定表达式临时结果,就绕开了中间拷贝。

关于复制消除与返回值优化,值类别 补充了两个关键事实:

  1. C++ 允许编译器执行复制消除,例如触发 RVO(返回值优化)后,即使构造与析构有副作用,也只会看到一次构造;
  2. C++17 要求纯右值非必须不实质化,直接构造到其最终目标的存储中,因此std::string str = urvo();直接构造(强制了 URVO),而std::string str = nrvo();是否直接构造仍依赖于优化(NRVO 非强制)。

结合这两点,"引用延长临时量生命期"与"复制消除"是互补的两条路径:前者由程序员用引用显式接管临时量,后者由编译器在标准允许的范围内自动消除拷贝;理解它们才能真正写出确定性的高性能代码。

参考内容

  1. C++ 语言文档——引用声明
  2. C++ 语言文档——值类别
  3. Does const ref lvalue to non-const func return value specifically reduce copies?

进一步阅读仓库内相关页面:值类别、常量与常量引用、C++ 与其他常用语言的区别、函数、顺序容器。

【免费下载链接】OI-wiki:star2: Wiki of OI / ICPC for everyone. (某大型游戏线上攻略,内含炫酷算术魔法)项目地址: https://gitcode.com/GitHub_Trending/oi/OI-wiki

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 18:17:57

基于Django的办公用品采购系统设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 18:16:10

单链表与循环链表的原理、实现与应用对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 18:12:30

Equator工业设备报警代码深度解析与现场诊断指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华