C++学习之移动语义与智能指针详解

前言


移动语义和智能指针是 C++11 之后最重要的两项资源管理改进,它们经常被放在一起讲,因为解决的问题是同一个:避免不必要的拷贝,并让所有权表达得更清楚。移动语义让对象可以"转移"内部资源而不是复制一份;智能指针让"谁负责释放"从注释变成类型系统的一部分。


需要先纠正三个高频误解。第一,std::move不移动任何东西,它只是一个类型转换,把表达式变成右值引用;真正发生移动的是随后调用的移动构造函数或移动赋值运算符。第二,std::unique_ptr不能拷贝,只能移动,这是它表达独占所有权的方式,不是缺陷。第三,std::shared_ptr的控制块是线程安全的,但它指向的对象不是,多线程读写同一对象仍然需要同步。


本文按"值类别与移动、移动语义的边界、unique_ptr、shared_ptr 与 weak_ptr"的顺序展开,代码以 C++17 为基准。


一、值类别与右值引用


C++ 的表达式按值类别(value category)分为三类:




类别含义例子



lvalue(左值)有身份、可取地址,通常不能隐式移动变量名、*p、a[i]

prvalue(纯右值)没有身份,就是计算出来的值42、a + b、返回值的函数调用

xvalue(将亡值)有身份,但资源可以被转移std::move(x)、返回右值引用的函数调用



左值和将亡值合称泛左值(glvalue),纯右值和将亡值合称右值(rvalue)。右值引用T&&可以绑定到右值,从而让编译器选中移动构造函数。


// 适用于 C++17
#include <string>
#include <utility>

void take(std::string&& s) { (void)s; } // 只接受右值
void take(const std::string& s) { (void)s; } // 接受左值和右值

int main() {
std::string a = "hello";
take(a); // 调用 const& 版本
take(std::move(a)); // 调用 && 版本;a 之后处于有效但未指定状态
take("world"); // 临时对象,调用 && 版本
}

std::move(a)的等价写法是static_cast<std::string&&>(a)。它不读取、不修改a,只是在编译期改变表达式的值类别。至于移动是否真的发生,取决于目标类型有没有可用的移动构造函数。


移动构造与移动赋值


一个典型的移动构造函数"窃取"源对象的资源,再把源对象置为可安全析构的状态:


// 适用于 C++17
#include <algorithm>
#include <cstddef>
#include <utility>

class Buffer {
public:
explicit Buffer(std::size_t n)
: data_(new int[n]()), size_(n) {}

~Buffer() { delete[] data_; }

Buffer(const Buffer& other)
: data_(new int[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}

Buffer& operator=(const Buffer& other) {
if (this != &other) {
Buffer tmp(other);
swap(tmp);
}
return *this;
}

Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 源对象不再拥有资源
other.size_ = 0;
}

Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_; // 释放自己原有的资源
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}

void swap(Buffer& other) noexcept {
std::swap(data_, other.data_);
std::swap(size_, other.size_);
}

std::size_t size() const { return size_; }

private:
int* data_ = nullptr;
std::size_t size_ = 0;
};

移动构造只复制两个字段,没有分配和逐元素拷贝,因此对持有大块内存的类型,代价从 O(n) 降到 O(1)。这不是"编译器优化",而是语义上的资源转移。


noexcept在这里不是装饰。std::vector扩容时要在"移动已有元素"和"拷贝已有元素"之间选择;如果移动构造函数没有声明noexcept,而拷贝构造函数可用,标准库通常会选择拷贝,以保证扩容失败时原容器不被破坏(强异常保证)。给移动操作标noexcept,容器才会放心地移动。


被移动对象的状态


标准规定,被移动之后的对象处于"有效但未指定"(valid but unspecified)状态。它仍然可以被销毁、被赋值,但它的值不可依赖。上面的Buffer把指针置空、大小置零,这是良好的实现习惯:让对象处于一个明确可用的空状态,而不是留下悬垂指针。


不要写这样的代码:


// 适用于 C++17
std::string a = "hello";
std::string b = std::move(a);
// 错误:a 的值未指定,不要假设它一定是空串
// std::cout << a.size();

标准库的std::string通常会把被移动对象置空,但这是实现行为,不是可移植的保证。


二、移动语义的边界


const 对象无法被移动


对const对象使用std::move,得到的是const T&&,它无法绑定到T&&类型的移动构造函数,只能匹配拷贝构造函数。因此移动退化为拷贝,且不会有任何警告。


// 适用于 C++17
const std::string a = "hello";
std::string b = std::move(a); // 实际调用拷贝构造

如果希望函数真正接受并移动参数,参数不应是const T&;按值传递再std::move进成员,是常见的做法。


不要对局部返回值写 std::move


// 适用于 C++17
Buffer make_buffer(std::size_t n) {
Buffer b(n);
return b; // NRVO 或隐式移动
}

Buffer make_buffer_bad(std::size_t n) {
Buffer b(n);
return std::move(b); // 阻止 NRVO,强制一次移动
}

C++17 起,返回纯右值时强制省略拷贝(guaranteed copy elision);返回具名局部变量时,编译器通常会做具名返回值优化(NRVO),直接在调用方的存储中构造对象,连移动都不需要。写std::move(b)反而把表达式变成将亡值,抑制了 NRVO,只剩一次移动。两者结果相同,但后者更慢。


移动不等于免费


移动一个std::array会逐元素移动;移动一个std::string在小字符串优化(SSO)生效时也可能复制缓冲区,因为短字符串直接存在对象内部,没有可窃取的堆内存。这些细节由实现决定,标准只规定语义。不要假设"移动一定是常数时间",对具体类型应以标准库实现和测量为准。


三、unique_ptr:独占所有权


std::unique_ptr<T>表达唯一所有权:同一时刻只有一个unique_ptr拥有对象,它不可拷贝、可移动。对象在unique_ptr销毁时被删除,删除器可以自定义。


// 适用于 C++17
#include <cstdio>
#include <memory>
#include <utility>

struct Widget {
int id = 0;
};

void use(Widget& w) { (void)w; }

int main() {
auto w = std::make_unique<Widget>();
w->id = 7;

std::unique_ptr<Widget> owner = std::move(w);
// 此时 w 为空,owner 拥有对象
if (w) {
use(*w); // 不会执行
}
use(*owner);

// 自定义删除器管理 FILE
auto closer = [](std::FILE* f) {
if (f) {
std::fclose(f);
}
};
std::unique_ptr<std::FILE, decltype(closer)> file(
std::fopen("data.txt", "r"), closer);
if (!file) {
return 1;
}
// file 析构时调用 closer,自动关闭文件
return 0;
}

要点:



  • 用std::make_unique<T>(args...)创建(C++14 起),避免裸new,异常安全更好;

  • 转移所有权必须std::move,拷贝构造和拷贝赋值被删除;

  • get()返回裸指针但不转移所有权,release()放弃所有权并返回裸指针(需要自己删除),reset()销毁当前对象并接管新指针;

  • 默认删除器用delete,所以unique_ptr<T>不能管理new[]分配的数组;数组要用std::unique_ptr<T[]>,它会调用delete[];

  • 自定义删除器会让unique_ptr变大(需要存储删除器),默认删除器通常与裸指针同宽,但标准不保证具体大小。


unique_ptr可以作为函数参数表达"接管所有权",作为返回值表达"转移所有权",比文档里的注释可靠得多。


四、shared_ptr 与 weak_ptr


引用计数与控制块


std::shared_ptr<T>通过控制块(control block)共享所有权。控制块里保存强引用计数、弱引用计数、删除器和分配器。每次拷贝shared_ptr增加强计数,销毁减少;强计数归零时对象被删除,弱计数也归零时控制块本身被释放。


// 适用于 C++17
#include <memory>
#include <string>
#include <vector>

struct Room;

struct Session {
std::string user;
std::weak_ptr<Room> room; // 弱引用,不拥有 Room
};

struct Room {
std::string name;
std::vector<std::shared_ptr<Session>> sessions;
};

void join(const std::shared_ptr<Room>& room,
std::shared_ptr<Session> session) {
session->room = room; // weak_ptr 由 shared_ptr 赋值
room->sessions.push_back(std::move(session));
}

std::shared_ptr<Room> room_of(const Session& s) {
return s.room.lock(); // 提升为 shared_ptr,失败返回空
}

int main() {
auto room = std::make_shared<Room>();
room->name = "general";

auto s = std::make_shared<Session>();
s->user = "alice";
join(room, s);

if (auto r = room_of(*s)) {
// r 与 room 指向同一对象,且在此期间保证对象存活
}
return 0;
}

weak_ptr不增加强引用计数,因此不会阻止对象销毁。它没有operator*和operator->,必须调用lock()得到一个shared_ptr才能访问对象。lock()成功说明对象仍然存活,失败返回空的shared_ptr。这个"检查并提升"是原子的,不会出现检查通过后对象又被销毁的窗口。


循环引用


如果两个对象互相持有shared_ptr,强计数永远不会归零,对象永远不会销毁:


// 适用于 C++17
#include <memory>

struct Node {
std::shared_ptr<Node> next;
// std::shared_ptr<Node> prev; // 错误:与 next 形成环
std::weak_ptr<Node> prev; // 正确:一条边用弱引用
};

这是shared_ptr最典型的泄漏来源。设计双向链表、父子关系、观察者列表时,必须明确哪条边是"拥有"、哪条边是"观察"。通常让父持有子的shared_ptr,子持有父的weak_ptr。


不要用同一裸指针构造两个 shared_ptr


// 适用于 C++17
Widget* raw = new Widget();
std::shared_ptr<Widget> a(raw);
std::shared_ptr<Widget> b(raw); // 错误:两个独立控制块
// a 和 b 各自认为自己是唯一所有者,最终 double free

正确做法是用std::make_shared<Widget>(),或者先构造一个shared_ptr,其余全部拷贝它。std::make_shared还会把对象和控制块合并为一次分配,减少分配次数;代价是只要有weak_ptr存在,整块内存(包括对象存储)就要等到弱计数归零才能释放。


shared_ptr的引用计数操作是原子的,多个线程可以安全地拷贝、销毁不同的shared_ptr实例;但通过它们访问同一个对象不是线程安全的。两个线程同时写*sp仍然是数据竞争,需要互斥量或原子操作保护对象本身。


常见坑点



  1. ❌ 认为std::move(x)会移动x;✅ 它只是把表达式转换为右值引用,真正移动发生在移动构造/赋值被调用时。



  1. ❌ 对const对象使用std::move期待移动;✅ 得到const T&&,只能匹配拷贝构造,移动退化为拷贝。



  1. ❌ 移动之后继续读取源对象的值;✅ 被移动对象处于有效但未指定状态,只能销毁或重新赋值。



  1. ❌return std::move(local);以为能加速返回;✅ 它阻止 NRVO,通常更慢,直接return local;即可。



  1. ❌ 把unique_ptr赋给另一个unique_ptr而不std::move;✅ 拷贝被删除,必须显式转移所有权。



  1. ❌ 用std::shared_ptr<T>(new T)和另一个shared_ptr共享同一裸指针;✅ 会产生两个控制块并 double free,统一用make_shared或拷贝已有shared_ptr。



  1. ❌ 用shared_ptr管理互相引用的对象;✅ 强计数永不归零导致泄漏,至少一条边改用weak_ptr。



  1. ❌ 认为shared_ptr线程安全就等于对象访问线程安全;✅ 只有控制块的计数是原子的,对象本身仍需同步。


总结




主题关键结论



std::move只是类型转换,不执行移动

移动构造窃取资源,源对象置为有效但未指定状态

noexcept影响容器扩容时选择移动还是拷贝

返回值直接返回局部变量,不要包std::move

unique_ptr独占、不可拷贝、可移动、零额外管理成本

shared_ptr共享所有权,控制块计数原子,对象访问不原子

weak_ptr观察而不拥有,用lock()安全提升

循环引用用weak_ptr打破,否则内存泄漏



移动语义的核心是所有权转移,智能指针的核心是所有权表达。把两者放在一起理解,就会发现它们服务于同一个目标:让资源的生命周期由类型和值语义决定,而不是由散落各处的new/delete决定。真正的难点不在语法,而在于每次设计接口时回答清楚一个问题:这个对象归谁所有,转移时是复制还是交出。




相关新闻

STM32定时器主从模式:精确控制脉冲个数的步进电机方案

STM32定时器主从模式:精确控制脉冲个数的步进电机方案

简介&#xff1a;这份资源面向STM32嵌入式开发者与运动控制方向的学习者&#xff0c;聚焦F103与F407两款芯片的定时器主从模式&#xff0c;解决步进电机精准脉冲个数输出的实际问题。内容基于HAL/LL库函数实现&#xff0c;涵盖主从定时器协同、TRGO触发、预分频与比较寄存器配置…

2026/10/20 4:40:47 阅读更多 →
离线环境NTP部署实战:从依赖解析到多级时间树搭建

离线环境NTP部署实战:从依赖解析到多级时间树搭建

1. 为什么离线环境里NTP不是“可有可无”&#xff0c;而是系统稳定性的命脉在某高校实验室的边缘计算节点集群里&#xff0c;我接手过一个典型场景&#xff1a;6台物理服务器全部部署在封闭机房&#xff0c;网络策略严格限制——仅允许内网通信&#xff0c;外网出口被完全关闭。…

2026/10/20 4:40:47 阅读更多 →
提示工程架构师视角:大模型在传统场景的工程化落地方法

提示工程架构师视角:大模型在传统场景的工程化落地方法

那段时间&#xff0c;我几乎每星期都要跟人解释一遍&#xff1a;提示工程不是“跟AI聊天”&#xff0c;也不是谁都能随便写几段话让别人去“抄作业”的工作。真正干这一行之后才明白&#xff0c;提示工程的核心是一整套“怎么让大模型稳定输出、不跑偏、可复用”的工程方法。更…

2026/10/20 4:40:46 阅读更多 →

最新新闻

智能支持员实战:从意图识别到多轮诊断的AI排障系统搭建

智能支持员实战:从意图识别到多轮诊断的AI排障系统搭建

做了这么多年技术支持&#xff0c;我对一件事特别有感触&#xff1a;真正占时间的不是那些疑难杂症&#xff0c;而是每天反复出现的“老三样”——服务起不来、配置不生效、报错信息看不懂。团队决定做“智能支持员”这个项目的时候&#xff0c;我的第一反应不是“又要上机器人…

2026/10/20 5:20:04 阅读更多 →
大模型推理性能优化:Prefill与Decode的瓶颈拆解与KV Cache调优

大模型推理性能优化:Prefill与Decode的瓶颈拆解与KV Cache调优

1. 把 Prefill 和 Decode 当成两个物种来看&#xff0c;是这套笔记第一个想讲明白的事聊到高级调优&#xff0c;核心其实就两个字&#xff1a;边界。Prefill 阶段和 Decode 阶段在计算形态、访存特性、延迟目标上完全是两个物种&#xff0c;却挤在同一套显存和同一个调度器里&a…

2026/10/20 5:20:04 阅读更多 →
从20%转化异常到1,833.56分方案:Coomia DIP决策闭环全解析

从20%转化异常到1,833.56分方案:Coomia DIP决策闭环全解析

1. 一次转化异常背后的完整决策链条1.1 从数据异常到决策闭环的起点转化率从预期的基准线掉到20%附近&#xff0c;这个信号在任何业务场景里都足够刺眼。我见过不少团队遇到这种情况的第一反应是“赶紧改页面”“赶紧换素材”&#xff0c;但真正有效的做法是先停下来&#xff0…

2026/10/20 5:20:04 阅读更多 →
AI编程模型迭代加速:从Argon到Carbon,什么是真正的代码生成“手感”?

AI编程模型迭代加速:从Argon到Carbon,什么是真正的代码生成“手感”?

最近圈子里有个消息挺有意思&#xff1a;某头部科技公司还没把内部代号Argon的编程模型推到全员使用&#xff0c;下一代Carbon就已经开始小范围内测了。参与内测的人给出的评价很耐人寻味——他们说Carbon的编程“手感”已经接近内部基准模型Opus 5.5。这话听起来像一句随口的评…

2026/10/20 5:20:04 阅读更多 →
ResidualQuant:面向循环Transformer KV Cache的2-bit残差量化方案

ResidualQuant:面向循环Transformer KV Cache的2-bit残差量化方案

做模型推理优化这几年&#xff0c;我最大的一个体感是&#xff1a;真正卡住长上下文生成的&#xff0c;往往不是算力&#xff0c;而是内存带宽和缓存容量。模型权重可以离线反复压缩&#xff0c;已经卷到 4-bit、甚至更低&#xff1b;可 KV Cache 是运行时动态增长的数据&#…

2026/10/20 5:20:04 阅读更多 →
FunctionCall函数调用全解析:从原理到实战,让大模型真正“动手干活”

FunctionCall函数调用全解析:从原理到实战,让大模型真正“动手干活”

如果你正在做AI对话类产品&#xff0c;不管是客服助手、内容助手还是企业内部的知识问答系统&#xff0c;大概率会遇到这样一个让人头疼的瞬间&#xff1a;用户问“帮我查一下昨天订单的物流状态”&#xff0c;模型回了一句“很抱歉&#xff0c;我无法访问你的订单系统”。不是…

2026/10/20 5:19:03 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

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

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

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

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

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

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/18 12:16:16 阅读更多 →