mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
3676 字
10 分钟
CPP重学(六)
2026-10-28

CPP重学(六)#

一、RAII 与智能指针#

RAll思想:构造获取,析构释放#

class File {
FILE *fp_;
public:
File(const char *path) : fp_(fopen(path, "r")) { // 构造 = 获取资源
if (!fp_) throw std::runtime_error("open failed");
}
~File() { if (fp_) fclose(fp_); } // 析构 = 释放资源
// ... 读写接口
};

三种智能指针选型#

  • unique_ptr:默认首选,独占所有权,不可拷贝,可以移动
  • shared_ptr:共享所有权(引用计数),可以拷贝,计数+1.
  • weak_ptr:不拷贝,不拥有,只观察
unique_Ptr:
auto p = std::make_unique<int> 67;
// std::unique_ptr<int> q = p; // 编译错误:不可拷贝
auto q = std::move(p); // 移动转移所有权,p 变空
零开销:与裸指针同尺寸同性能,perf 下无任何额外成本。
语义即文档:unique_ptr 传给函数 = "我借给你,用完还我"。
shared_ptr
auto s1 = std::make_shared<int>(42);
auto s2 = s1; // 引用计数 2
s1.reset(); // 计数 1
// s2 离开作用域时计数归 0,自动 delete
引用计数线程安全,但控制块额外分配一次内存(性能略低于 unique_ptr)。
循环引用会让计数永不归零 → 泄漏,见下文。
weak_ptr:打破循环引用
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> parent; // 用 weak 避免环
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b; // b 被 a 持有
b->parent = a; // weak:不增加 a 的计数
循环引用问题:如果 parent 也用 shared_ptr,a 和 b 互相持有、计数永不归零,内存泄漏。用 weak_ptr 观察但不拥有,打破环。
访问weak指向的对象前要先lock
if (auto sp = parent.lock()) { // lock 成功说明对象还活着
sp->doSomething();
}

常见坑#

坑示例后果
用 new 不用 make_*unique_ptr<T>(new T)额外一次分配/异常不安全
混用裸指针与智能指针p.get() 后 delete双重释放
循环引用shared_ptr 成环泄漏
shared_ptr 存数组make_shared<int[]> 需 C++17 语法析构不完整
把 this 交给 shared_ptr从裸 this 构造双重管理

this陷阱#

当你需要在类的成员函数里把”自己”交给 shared_ptr(比如把 this 注册进回调、定时器),绝不能 shared_ptr<T>(this)——这会凭空造出一个新的所有者,和原有的控制块不是一回事,析构时双重 delete。正确做法是从 std::enable_shared_from_this 继承,用 shared_from_this():

struct Worker : std::enable_shared_from_this<Worker> {
void start() {
// 错误:shared_ptr<Worker>(this) 新建独立控制块 → 双重释放
// register_callback([self = shared_from_this()] { self->on_event(); });
auto self = shared_from_this(); // 正确:复用同一控制块
register_callback([self] { self->on_event(); });
}
void on_event() { /* ... */ }
};

shared_from_this() 要求对象本身已经由一个 shared_ptr 管理,它从那个控制块里取出”第二个强引用”,而不是新建。否则抛 bad_weak_ptr——这是一个能提前暴露用法的保护机制。

另一个高频坑是 lambda 捕获:按值捕获 shared_ptr 会增加引用计数(延长生命周期),按引用捕获 [&] 则要小心回调执行时对象是否已析构。长期存活的异步回调,几乎都该按值捕获 shared_ptr 或 weak_ptr 后 lock(),避免悬空引用。

二、移动语义与右值引用#

拷贝 vs 移动:同一资源,两种代价#

962051011587b857eda1ce0c6dddf9bcbeb8ce88

值类别#

int x = 42; // x 是左值:有名字、可取地址
int y = x; // x 作为右值使用(被读取)
int &&r = 42; // 42 是右值字面量:临时、不可取地址
值类别定义例子能否 &
左值 lvalue有名字、持久变量、*p、a[0]能
纯右值 prvalue临时值42、f() 返回的临时不能
将亡值 xvalue即将销毁的资源持有者std::move(x)能(临时身份)

核心直觉:右值 = 临时对象 = 反正要销毁 → 与其深拷贝,不如偷走它的资源(把指针搬过来,让它变空)。

移动构造与移动赋值#

class Buffer {
char *data_;
size_t size_;
public:
// 拷贝构造:深拷贝(昂贵)
Buffer(const Buffer &o) : data_(new char[o.size_]), size_(o.size_) {
std::copy(o.data_, o.data_ + size_, data_);
}
// 移动构造:偷资源(O(1))
Buffer(Buffer &&o) noexcept : data_(o.data_), size_(o.size_) {
o.data_ = nullptr; // 让源对象失效,析构安全
o.size_ = 0;
}
~Buffer() { delete[] data_; }
};
Buffer a(1024);
Buffer b = a; // 拷贝:分配+复制 1024 字节
Buffer c = std::move(a); // 移动:仅搬指针,O(1)

什么时候移动

场景行为
T c = std::move(a)移动构造
T c = f()(函数返回临时)移动(或拷贝省略,见下)
v.push_back(std::move(obj))移动进容器
把左值传给期望右值的参数需显式 std::move
普通赋值 b = a(左值到左值)拷贝

规则:右值 → 移动,左值 → 拷贝。左值不会自动变成右值,必须显式 std::move——这是 C++ 的安全设计(防止意外移动破坏对象)。

移动构造/赋值声明 noexcept 很关键:vector 扩容时,只有移动是 noexcept 才敢用移动(否则为保证强异常安全退回拷贝)。移动必须 noexcept 是工程铁律。

std::move 与 std::forward#

std::move:只是类型转换,将左值转为没用的右值。

move之后,原来的左值就空了。

template <typename T>
constexpr std::remove_reference_t<T> &&move(T &&t) noexcept {
return static_cast<std::remove_reference_t<T> &&>(t);
}

std::move(x) 不移动任何东西——它只是把 x 转换为右值引用,让重载决议选择移动版本。

两个坑

  1. move 之后源对象处于”有效但未指定”状态,别再用它(虽然能析构、能重新赋值)。
  2. 对 const 对象 move 是无效的——const T 转出来是 const T&&,匹配不到 T(T&&),会退化成拷贝:

std::forward:完美转发,保留原本的左值/右值属性

template <typename T>
void wrapper(T &&arg) { // 万能引用(T&& 在模板里)
inner(std::forward<T>(arg)); // 保持左值/右值属性转发
}
T&& 在模板中不是右值引用而是万能引用:传左值 → T = A&,传右值 → T = A。
std::forward<T>(arg) 按 T 恢复原始值类别:左值转发成左值、右值转发成右值——"完美转发"。

拷贝省略

std::vector<int> make() { return {1, 2, 3}; }
auto v = make(); // C++17 保证:直接构造,零拷贝零移动

C++17 起纯右值直接初始化保证省略拷贝(如 make() 返回的临时直接构造 v)。这是”值语义 + 零开销”的重要支柱——写返回大对象的函数可以放心传值。

机制开销何时生效
拷贝O(n) 深拷贝左值到左值
移动O(1) 偷指针右值目标
拷贝省略0纯右值初始化(C++17 保证)
std::vector<int> make() {
std::vector<int> v(1000000);
return v; // NRVO/C++11 起:直接搬家,不拷百万元素
}
std::vector<int> a = make(); // 接管 make 里的 vector,零拷贝

移动语义让”资源所有权转移”代替”整体复制”。std::move(x) 不移动任何东西,只是把 x 变成右值,触发移动构造/赋值——把指针和长度从源”偷”到目标,源置空。百万元素 vector 从”拷 8MB”变成”拷 3 个字段”,快几个数量级。

陷阱:被 move 后的对象处于”有效但未指定”状态

std::string s = "hello"; std::string t = std::move(s);
// s 现在为空,不能再假设它有 "hello",但能安全析构/赋值

总结:移动语义 = 右值(临时)对象偷走资源而非深拷贝,std::move 只是类型转换、std::forward 保持值类别转发,C++17 拷贝省略进一步把纯右值开销降到 0;写返回大对象的函数就放心传值,交给移动与省略。

三、完美转发与引用折叠#

引用的引用:编译器帮忙折叠#

int x = 1;
int &ref = x;
int & &ref_ref = ref; // "引用的引用"?

C++ 里不允许你直接声明”引用的引用”,但当它经过模板参数推导产生时,编译器会执行引用折叠(reference collapsing),把它变成普通引用。上面这句等价于 int &ref_ref = ref;。

引用折叠的规则只有四条,规律非常好记——只要出现一个左值引用 &,结果就是左值引用;只有两个都是右值引用 &&,结果才是右值引用:

组合折叠结果记忆法
T& &T&有左值就是左值
T& &&T&有左值就是左值
T&& &T&有左值就是左值
T&& &&T&&全是右值才是右值
using LRef = int&;
using RRef = int&&;
using A = LRef&; // int& & → int&
using B = LRef&&; // int& && → int&
using C = RRef&; // int&& & → int&
using D = RRef&&; // int&& && → int&&

一句话记忆:左值引用有”一票否决权”——四种组合里但凡有一个 &,结果都是 &。

万能引用:T&&

&& 出现在两个地方,含义完全不同:

void f(int &&r); // 右值引用:只能绑右值
template <typename T>
void g(T &&arg); // 万能引用:既能绑左值又能绑右值

T&& 只有当 T 是模板推导的类型参数(或 auto&&)时才是万能引用(也叫转发引用)。它的行为:

实参推导出的 T参数类型说明
左值 intint&int& && → int&万能引用变左值引用
右值 42intint&&万能引用变右值引用
const 左值const int&const int&const 保留

这里的关键:万能引用通过引用折叠”同时接收左值和右值”,而且不丢失实参的 const 属性。这正是”完美转发”的地基——信息在传递过程中不失真。

std::forward 的标准实现(简化版)就一句话:

template <typename T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept {
return static_cast<T&&>(t); // 把左值 t 转成 T&&
}

配合万能引用,它做的事是:

template <typename T>
void wrapper(T &&arg) {
inner(std::forward<T>(arg));
}

推演一下两条路径:

  1. 传左值:T = int& → std::forward<int&>(arg) 的返回类型是 int& && → 折叠为 int&。转发成左值,inner 收到左值引用。
  2. 传右值:T = int → std::forward<int>(arg) 返回 int&&。转发成右值,inner 收到右值引用,可以触发移动。

所以 forward 的名字很贴切:把实参原有的值类别(左值/右值)原封不动”转发”给下一层。参数在 wrapper 内部是左值(有名字),经过 forward<T> 恢复了它进函数前的身份。

两者底层都是 static_cast,区别只在”转成什么”:

对比项std::movestd::forward
本质无条件转右值引用按模板参数 T 恢复原值类别
适用场景明确要转移资源泛型代码里转发参数
典型用法v.push_back(std::move(obj))inner(std::forward<T>(arg))
对左值变成右值(危险)保持左值
对右值保持右值保持右值

用错的反面教材:转发函数里用 move 代替 forward,会把左值实参也变成右值传下去——如果 inner 恰好有移动构造重载,左值对象就被悄悄”掏空”了。这是隐蔽 bug 的高发点。

template <typename T>
void bad_wrapper(T &&arg) {
inner(std::move(arg)); // 错!左值也被转成右值
}

完美转发案例

#include <utility>
#include <iostream>
void take(int &x) { std::cout << "lvalue: " << x << "\n"; }
void take(int &&x) { std::cout << "rvalue: " << x << "\n"; }
template <typename T>
void log_and_take(T &&arg) {
std::cout << "log...\n";
take(std::forward<T>(arg)); // 值类别原样转发
}
int main() {
int a = 10;
log_and_take(a); // 输出 lvalue: 10(a 仍是左值)
log_and_take(20); // 输出 rvalue: 20(临时对象保持右值)
}

总结:完美转发 = 万能引用(T&&)+ 引用折叠(有 & 就是 &)+ std::forward(按 T 恢复值类别)三层配合,让泛型包装器把参数的左值/右值身份原封不动传给下一层;move 是无条件右值化,forward 是条件保持,转发场景用后者。

四、模板与泛型编程#

模板 = 把”类型”或”值”变成参数,让同一份代码适用于多种类型,编译期由编译器自动生成具体代码。

编译期展开:一次模板,N 份代码#

4bfb0f5da00c36df294e3279cd1b81e8cf1134a8

函数模板与类模板#

函数模板

template <typename T>
T max_of(T a, T b) {
return a > b ? a : b;
}
int m1 = max_of(3, 7); // 实例化 int 版本
double m2 = max_of(3.14, 2.71); // 实例化 double 版本

编译期生成两份代码(int 和 double 各一份)——每用一次类型就展开一次,这就是”模板 = 编译器代写代码”。

类模板

template <typename T>
class Stack {
std::vector<T> data_;
public:
void push(const T &v) { data_.push_back(v); }
T pop() { auto v = data_.back(); data_.pop_back(); return v; }
};
Stack<int> si; // Stack<int>
Stack<std::string> ss; // Stack<std::string>:互不相同的类型

三个大类#

  • 类型参数(最常见)
template <typename T> // T 是一个类型
void f(T x);
  • 非类型参数(编译期常量)
template <typename T, int N> // N 是一个编译期整数常量
class Array {
T data[N];
};
Array<int, 10> arr; // 固定大小 10 的数组,N 在编译期确定
N 必须是编译期常量(字面量、constexpr、sizeof 结果等),不能是运行时变量。这让数组大小、循环展开等能在编译期优化。
  • 模板模板参数(嵌套)
template <template <typename> class Container, typename T>
class Wrapper {
Container<T> c;
};
Wrapper<std::vector, int> w; // Container = std::vector, T = int
  • 可变参
template <typename... Args> // Args 是一个"类型包",可含 0~N 个类型
void print(Args... args) { // args 是"参数包"
(std::cout << ... << args); // C++17 折叠表达式
}
print(1, "hello", 3.14, 'c'); // Args = {int, const char*, double, char}

模板特化#

全特化:为指定类设计的

template <typename T>
T max_of(T a, T b) { return a > b ? a : b; }
// 全特化:const char* 比较内容而非指针
template <>
const char *max_of<const char *>(const char *a, const char *b) {
return std::strcmp(a, b) > 0 ? a : b;
}

偏特化:只针对部分参数

// 指针版本的 Stack
template <typename T>
class Stack<T *> {
// 专门处理指针:自动 delete 等
};
特化类型语法适用
全特化template <> ... max_of<const char*>函数/类模板
偏特化template <typename T> class Stack<T*>仅类模板
偏特化(参数)template <typename T> class Stack<std::vector<T>>仅类模板

ifconstexpr:编译器分支#

template <typename T>
void process(T v) {
if constexpr (std::is_integral_v<T>) {
// 仅整型时编译此分支
} else {
// 其他类型时编译此分支
}
}

if constexpr 在编译期丢弃未选中分支——不做运行期判断,还能编译”本不该实例化”的代码(如对整型调用字符串专属 API)

SFINAE 与 concepts#

C++20 之前,模板匹配失败的错误信息是几百行晦涩编译错误。SFINAE(替换失败不是错误)是 C++11~17 时代的约束手段,但语法晦涩:

template <typename T>
auto length(const T &c) -> decltype(c.size()) { // 只有有 .size() 才匹配
return c.size();
}

C++20 concepts:把约束变成语言特性

template <typename T>
concept HasSize = requires(T t) { t.size(); };
template <HasSize T> // 约束模板参数
size_t length(const T &c) { return c.size(); }
length(std::vector<int>{1,2,3}); // OK:有 size()
// length(42); // 编译错误:明确提示 int 不满足 HasSize
约束方式时代可读性运行时开销
无约束模板C++98报错难懂0
SFINAEC++11~17晦涩0
conceptsC++20报错清晰0

总结:模板 = 编译期按类型展开的零成本抽象(函数/类模板 + 全特化/偏特化 + 变参折叠 + concepts 约束);与虚函数相比,模板把多态决策从运行期提前到编译期,换来性能、换来可读的错误提示。

五、模板特化与偏特化#

为什么要特化#

template <typename T>
T max_of(T a, T b) {
return a > b ? a : b;
}

大部分类型都能用。但遇到 const char*(C 字符串),这个版本比较的是指针地址而不是字符串内容,行为就错了:

max_of("apple", "banana"); // 比较的是指针,结果随机!

遇到这种类型,我们希望走”按字符串比较”的专用逻辑。这就是特化的价值——通用逻辑覆盖 95%,特殊类型单独处理 5%。

函数模板特化#

显示特化(全特化)

#include <cstring>
// 通用模板
template <typename T>
T max_of(T a, T b) {
return a > b ? a : b;
}
// 对 const char* 的显式特化
template <>
const char* max_of<const char*>(const char* a, const char* b) {
return std::strcmp(a, b) > 0 ? a : b;
}

注意语法要点:

要点说明
template <>空模板参数列表,标志”这是特化”
max_of<const char*>显式指定特化对象
参数类型必须与目标完全匹配(这里是 const char*)

特化vs重载:优先选重载#

C++ 有一个反直觉的事实:函数模板的特化不参与重载决议,它只是”当实例化匹配时优先用的实现”。而重载参与决议。

// 重载:参数类型不同,参与重载决议
const char* max_of(const char* a, const char* b) {
return std::strcmp(a, b) > 0 ? a : b;
}

对函数模板来说,重载通常比特化更好——重载是”新函数”,特化是”原函数的补丁”。工程实践:能用重载解决的问题,不要用特化。

对比项函数模板重载函数模板显式特化
本质独立函数原模板的专用实现
参与重载决议是否
匹配规则更灵活的候选集与原模板绑定
推荐度优先谨慎

特化与偏特化#

全特化:指定所有类型#

// 通用类模板
template <typename T, typename U>
struct Pair {
void describe() const { /* 通用逻辑 */ }
};
// 全特化:两个类型都固定
template <>
struct Pair<int, int> {
void describe() const { /* 针对两个 int 的专用逻辑 */ }
};

偏特化:只指定部分#

// 偏特化:第二个类型固定为 int,第一个保留
template <typename T>
struct Pair<T, int> {
void describe() const { /* 通用 T + int 的专用逻辑 */ }
};
// 偏特化:两个参数都是指针
template <typename T, typename U>
struct Pair<T*, U*> {
void describe() const { /* 针对指针的专用逻辑 */ }
};
特化类型写法示例
全特化template <> struct X<具体类型>struct Pair<int, int>
偏特化template <部分参数> struct X<具体模式>struct Pair<T, int>、struct Pair<T*, U*>
函数模板只能全特化template <> max_of<const char*>

为什么函数不能偏特化? 因为函数可以重载——max_of(const char*, const char*) 本身就是”偏特化效果”,不需要再引入一套偏特化语法。类没有重载机制,只能靠偏特化补上这部分能力。

匹配优先级#

编译器选择模板的顺序是:全特化 > 偏特化(越具体越优先)> 通用模板。

Pair<int, int> a; // 全特化
Pair<double, int> b; // 偏特化 Pair<T, int>
Pair<int*, int*> c; // 偏特化 Pair<T*, U*>
Pair<double, char> d; // 通用模板

171cc5711a2a89e11359da419c9d8c0972b5dc84

std::vector<bool>:特化的经典案例#

std::vector<bool> 是 C++ 里”最出名”的特化:

std::vector<bool> flags(8);
flags[3] = true; // 这里发生了什么?

普通 vector 每个元素是独立对象,operator[] 返回引用。但 vector<bool> 特化后每个 bool 只占 1 bit,8 个 bool 挤在一个字节里——这样就没有”指向单个 bool 的引用”可用了,operator[] 只能返回一个代理对象(proxy)。

这带来两个经典坑:

auto b = flags[3]; // b 是代理对象,不是 bool&
bool x = flags[3]; // 能隐式转换,OK
auto &ref = flags[3]; // 编译错误:不能把代理绑到 bool&
对比项std::vector<bool>普通 std::vector<T>
存储每元素 1 bit(省内存)每元素 sizeof(T)
operator[] 返回代理对象T& 引用
能否取元素地址不能能
性能节省 8 倍内存,但位操作有额外开销直存直取

这个案例的价值在于:特化可以彻底改变一个类模板的行为——节省了 8 倍内存,代价是引号语义和迭代器行为的改变。遇到 auto& ref = v[i] 编译不过,先想想是不是 vector<bool>。

特化注意事项#

  1. 特化必须和原模板在同一个命名空间,且要在使用之前声明。
  2. 特化不能改变成员的签名(不能加成员变量改构造),只能改实现——它的接口必须与原模板兼容。
  3. 部分特化不能用于函数模板(如前述)。
  4. 特化常用于:类型特征(std::is_xxx)、分配器、哈希函数(std::hash<T>)、std::vector<bool>。
namespace std {
template <>
struct hash<MyType> { // 为自定义类型提供哈希
size_t operator()(const MyType &t) const {
return /* 计算哈希 */;
}
};
}

给 std::hash 特化后,MyType 就能直接放进 unordered_map / unordered_set——这是特化在标准库中最常见的用途。

总结:特化是给模板”开小灶”:全特化固定所有类型、偏特化(仅类模板)固定部分类型,编译器按”全特化 > 偏特化 > 通用模板”选择,vector<bool> 的特化则是”省内存换语义”的经典代价案例;函数模板优先用重载而非特化。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

CPP重学(六)
https://fatdog.20060113.xyz/posts/cpp-again6/
作者
神秘大胖狗
发布于
2026-10-28
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00