1. 1. 左值右值
  2. 2. 函数对象
    1. 2.1. 优点
  3. 3. 谓词
  4. 4. 原始字面量
    1. 4.1. 基本语法
    2. 4.2. 关键特性
    3. 4.3. 示例
      1. 4.3.1. 普通字符串 vs 原始字面量
      2. 4.3.2. 多行字符串
        1. 4.3.2.1. 包含括号的情况(使用定界符)
      3. 4.3.3. 正则表达式场景
      4. 4.3.4. 文件路径
    4. 4.4. 注意事项
  5. 5. nullptr
    1. 5.1. 作用
    2. 5.2. nullptr 的类型与特性
    3. 5.3. 使用示例
      1. 5.3.1. 基本用法
      2. 5.3.2. 解决重载歧义
      3. 5.3.3. 与模板配合
    4. 5.4. nullptr 与 NULL 的对比
    5. 5.5. 注意事项
  6. 6. Lambda 表达式
    1. 6.1. 基本语法
      1. 6.1.1. 捕获列表
      2. 6.1.2. Tips
      3. 6.1.3. 示例
    2. 6.2. 泛型Lambda
    3. 6.3. 本质
  7. 7. override与 final
    1. 7.1. override
    2. 7.2. final
    3. 7.3. override + final
  8. 8. 统一初始化与列表初始化
  9. 9. constexpr
    1. 9.1. 基本概念
    2. 9.2. constexpr 变量
      1. 9.2.1. 与 const 的区别
    3. 9.3. constexpr 函数
      1. 9.3.1. 限制
    4. 9.4. 修饰构造函数
    5. 9.5. 修饰模板函数
      1. 9.5.1. 局限性(C++11)
  10. 10. 自动类型推导
    1. 10.1. auto
      1. 10.1.1. 基本用法
      2. 10.1.2. 推导规则
      3. 10.1.3. 例子
      4. 10.1.4. 限制
  11. 11. 右值引用与移动语义
    1. 11.1. 右值引用
      1. 11.1.1. 绑定规则
    2. 11.2. 移动语义
      1. 11.2.1. std::move
    3. 11.3. 完美转发
      1. 11.3.1. 万能引用
      2. 11.3.2. std::forward
  12. 12. 智能指针
    1. 12.1. RAII
      1. 12.1.1. 原理
      2. 12.1.2. 优势
    2. 12.2. std::unique_ptr
      1. 12.2.1. 与函数
    3. 12.3. std::shared_ptr
      1. 12.3.1. 与函数
    4. 12.4. std::weak_ptr
      1. 12.4.1. 循环引用陷阱
      2. 12.4.2. 安全性:lock()
    5. 12.5. 使用手册
    6. 12.6. 工厂函数与所有权
      1. 12.6.1. std::make_unique(C++14)
        1. 12.6.1.1. 作用
        2. 12.6.1.2. 函数签名
        3. 12.6.1.3. 返回值
        4. 12.6.1.4. 所有权变化
        5. 12.6.1.5. 示例
      2. 12.6.2. std::make_shared(C++11)
        1. 12.6.2.1. 作用
        2. 12.6.2.2. 函数签名
        3. 12.6.2.3. 返回值
        4. 12.6.2.4. 所有权变化
        5. 12.6.2.5. 内部优化
        6. 12.6.2.6. 示例
      3. 12.6.3. std::weak_ptr 的创建
        1. 12.6.3.1. 如何获得 weak_ptr?
        2. 12.6.3.2. 所有权变化
        3. 12.6.3.3. 示例
      4. 12.6.4. 总结
    7. 12.7. std::unique_ptr所有权转移例子
      1. 12.7.1. 类型定义
      2. 12.7.2. reset 函数
        1. 12.7.2.1. 解析
      3. 12.7.3. drop 函数
        1. 12.7.3.1. 解析
      4. 12.7.4. forward 函数
        1. 12.7.4.1. 解析
      5. 12.7.5. 核心要点总结
    8. 12.8. std::shared_ptr 引用计数示例解析
      1. 12.8.1. 示例代码
      2. 12.8.2. 分步解析
      3. 12.8.3. 关键操作说明
      4. 12.8.4. 注意事项
      5. 12.8.5. 总结
  13. 13. 可变参数模板

C++现代特性之CPP11

参考资料:

c++11实用特性

《C++ Primer Plus》

deepseek

Gemini

  • 内存管理:移动语义、右值引用、智能指针
  • 并发编程: 见cpp多线程
  • 泛型编程: 模板
  • 语法糖

左值右值

  1. 左值
    • 指向内存中明确位置、具有持久状态(有名字、能取地址)的对象
    • 可以出现在赋值号 = 的左边或右边
    • 示例:变量名、解引用表达式 *p、返回左值引用的函数调用。
  2. 右值
    • 不具有内存实体(或即将被销毁的临时对象)、没有名字、无法取地址的值。
    • 只能出现在赋值号 = 的右边。
    • 字面常量(如 42)、算术表达式(如 a + b)、返回非引用的函数调用。

函数对象

函数对象就是一个表现得像函数一样的对象。在 C++ 中,它是通过在类(class/struct)中重载 operator()(圆括号运算符)来实现的。

由于这种类实例化后的对象可以像函数一样被调用,所以也被称为 仿函数(Functor)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
// 定义一个函数对象类
class Adder {
public:
Adder(int n) : num(n) {} // 构造函数可以存储状态
// 重载圆括号运算符
int operator()(int x) const {
return x + num;
}

private:
int num;
};

int main() {
Adder add5(5); // 创建一个对象,内部“记住”了 5
int result = add5(10); // 像调用函数一样调用对象
std::cout << result; // 输出 15
return 0;
}

优点

相对于普通函数:

A. 带有“状态”(State)

普通函数除非使用全局变量或静态变量,否则无法在多次调用之间保持状态。而函数对象是类,可以拥有成员变量。

  • 例子: 在处理 MoE 专家调度时,你可以创建一个函数对象,在构造时传入当前的 GPU_IDStream_ID。每次调用这个对象执行任务时,它都知道自己在哪个设备上工作。

B. 更好的内联优化(Inlining)

当你把函数对象传递给模板算法(如 std::sort)时,编译器知道确切的类类型,因此可以直接将 operator() 的逻辑内联到调用处。 相比之下,传递“函数指针”往往会导致编译器无法内联,从而产生函数调用的额外开销。


谓词

返回布尔值(True 或 False)的函数或函数对象

在 C++ STL中,谓词通常用于算法(如 std::sort, std::find_if, std::remove_if)中。

  • 一元谓词(Unary Predicate): 接收一个参数,返回 bool
    • 例子: 判断一个数是不是偶数?isEven(x)
  • 二元谓词(Binary Predicate): 接收两个参数,返回 bool
    • 例子: 判断第一个数是否大于第二个数?compare(a, b)

A. 普通函数

1
2
3
4
bool isPositive(int n) {
return n > 0;
}
// 使用:std::find_if(vec.begin(), vec.end(), isPositive);

B. 函数对象(仿函数)

函数对象作为谓词的强大之处在于它可以拥有状态

1
2
3
4
5
6
7
class GreaterThan {
int threshold;
public:
GreaterThan(int t) : threshold(t) {}
bool operator()(int n) const { return n > threshold; }
};
// 使用:std::find_if(vec.begin(), vec.end(), GreaterThan(10));

C. Lambda 表达式(最常用)

这是现代 C++ 中最优雅的写法。

1
// 使用:std::find_if(vec.begin(), vec.end(), [](int n) { return n > 10; });

原始字面量

在 C++11 标准中,引入了原始字面量(Raw String Literal),用于简化字符串中反斜杠 \ 和引号 " 等特殊字符的处理。它让字符串内容可以“原样”呈现,无需转义,特别适合正则表达式、文件路径、多行文本等场景。

基本语法

原始字面量的格式为:

1
R"delimiter(raw_characters)delimiter"
  • R 表示原始字面量前缀。
  • delimiter 是可选的定界符序列(长度不超过 16 个字符的自定义字符序列,不能包含反斜杠、空格和括号)用于唯一标识字符串的结束位置。当字符串内容本身包含 )" 时,避免编译器误认为字面量提前结束。
  • ( raw_characters ) 中是真正的字符串内容,可以包含任何字符(包括换行、引号、反斜杠等),无需转义

如果省略定界符,简写为 R"(...)"

关键特性

  • 不处理转义序列:例如 \n\t\" 等均作为普通字符对待。
  • 允许换行:字符串可以直接跨越多行,换行符会被保留为字符串的一部分。
  • 允许引号:无需转义即可包含 " 字符。
  • 允许反斜杠\ 就是普通反斜杠,不会引起转义。

示例

普通字符串 vs 原始字面量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <string>

int main() {
// 普通字符串:需要转义反斜杠和引号
std::string normal = "C:\\Program Files\\\"MyApp\"";

// 原始字面量:无需转义
std::string raw = R"(C:\Program Files\"MyApp")";

std::cout << "Normal: " << normal << std::endl;
std::cout << "Raw: " << raw << std::endl;
// 两者输出相同: C:\Program Files\"MyApp"
}

多行字符串

1
2
3
4
5
6
7
8
9
std::string multi = R"(第一行
第二行
第三行)";

std::cout << multi;
// 输出:
// 第一行
// 第二行
// 第三行

包含括号的情况(使用定界符)

如果字符串本身包含 )" 序列,就需要自定义定界符来避免歧义。

1
2
3
4
5
// 字符串内容为:Hello )" World
// 不加定界符会提前结束:R"(Hello )" World)" ← 错误,第一个 )" 就结束了字面量

std::string with_delim = R"delim(Hello )" World)delim";
// 正确,定界符 delim 使编译器正确匹配结束符 )delim"

正则表达式场景

正则表达式中经常出现大量反斜杠,原始字面量可以显著提高可读性。

1
2
3
4
5
6
#include <regex>
#include <string>

// 匹配形如 \d{3}-\d{4} 的电话号码
std::regex phone_normal("\\d{3}-\\d{4}"); // 需要双重转义
std::regex phone_raw(R"(\d{3}-\d{4})"); // 更清晰

文件路径

1
2
std::string path = R"(C:\Users\Name\Documents\file.txt)";
// 等价于 "C:\\Users\\Name\\Documents\\file.txt"

注意事项

  • 原始字面量中唯一需要留意的字符是 )" 序列(如果未使用定界符)或 )delimiter"(如果使用了定界符)。只要字符串内容不包含该精确序列,就可以安全使用。
  • 定界符可以是任意可见字符(除了反斜杠、空格和括号),通常使用一个短单词或下划线,如 R"tag(...)tag"
  • 原始字面量仍然是标准 std::string(或字符数组),与普通字符串字面量完全兼容。
  • 原始字面量也支持宽字符、UTF-8/16/32 前缀,例如 u8R"(...)"LR"(...)" 等。

nullptr

nullptr 代表一个空指针字面量(null pointer literal)。它的类型是 std::nullptr_t(定义在 <cstddef> 中),可以隐式转换为任意指针类型或成员指针类型,但不能转换为整数类型

作用

在 C++11 之前,程序员通常用 NULL 或直接使用 0 表示空指针。例如:

1
2
int* p = NULL;   // NULL 通常被定义为 0 或 (void*)0
int* q = 0;

这种做法存在两个主要问题:

  • 类型模糊NULL 本质上是整数 0(或 (void*)0),在函数重载时会导致意外行为。
  • 与整数混用:由于 0 既可以表示整数也可以表示空指针,编译器在重载决议时可能选择错误的版本。

例如,考虑以下重载:

1
2
3
4
void func(int);
void func(char*);

func(NULL); // 调用 func(int) 还是 func(char*)?

在不同的实现中,NULL 可能是 00L,因此调用 func(NULL) 会匹配到 func(int),而不是预期的 func(char*),造成逻辑错误。

nullptr 的类型与特性

  • nullptr 的类型是 std::nullptr_t(可视为一种特殊的“指针类型”)。
  • std::nullptr_t 的实例(如 nullptr)可以隐式转换为任何指针类型(包括成员指针)和 bool 类型(转换为 false)。
  • nullptr 不能隐式转换为整数类型(如 intlong 等)。
  • 所有类型为 std::nullptr_t 的对象(实际上只有 nullptr 是标准定义的)彼此相等,且与任何空指针值相等。

使用示例

基本用法

1
2
3
4
5
6
7
int* p = nullptr;        // 指向 int 的空指针
double* q = nullptr; // 指向 double 的空指针
void (*fp)() = nullptr; // 函数指针空值

if (p == nullptr) { // 与 nullptr 比较
// p 为空
}

解决重载歧义

1
2
3
4
5
6
void func(int) { std::cout << "int version\n"; }
void func(char*) { std::cout << "char* version\n"; }

func(0); // 调用 func(int)
func(NULL); // 可能调用 func(int) (取决于 NULL 的定义)
func(nullptr); // 一定调用 func(char*) —— 因为 nullptr 只能匹配指针

与模板配合

1
2
3
4
5
6
7
8
template<typename T>
void foo(T* ptr) {
if (ptr == nullptr) {
// 安全的空指针检查
}
}

foo(nullptr); // 推导 T 为某个类型,但实际不实例化对象

nullptrNULL 的对比

特性 nullptr NULL
类型 std::nullptr_t 整数(通常为 intlong
可转换为指针 是(隐式) 是(通过整数转换)
可转换为整数
重载时优先匹配指针 否(优先整数)
类型安全

注意事项

  • nullptr 可以赋值给指针,但不能赋值给整型变量(需要显式转换如 int n = reinterpret_cast<int>(nullptr);,但通常不应该这样做)。
  • 在条件判断中,nullptr 可转换为 boolfalse,因此 if (!ptr) 依然有效。
  • nullptr 的引入并不强制废弃 NULL,但现代 C++ 推荐使用 nullptr 以提升代码清晰度和类型安全。
  • 使用 nullptr 时需要包含 <cstddef> 来获取 std::nullptr_t 的定义(不过许多编译器头文件已间接包含,且 nullptr 本身是关键字,不需要头文件即可使用)。

Lambda 表达式

Lambda表达式(也称为匿名函数或闭包)允许在代码中就地定义函数对象。在C98/03时代,若需要传递一个简单的可调用对象,通常需要单独编写一个函数或重载operator()的仿函数(Functor),代码冗长且不直观。Lambda表达式的出现极大地简化了这一过程,尤其在STL算法、异步编程、事件回调等场景中,Lambda已成为现代C编程的标配。

Lambda 表达式其实就是函数对象的**“语法糖”**

基本语法

1
[capture] (params) mutable constexpr noexcept -> retType { body }
部分 说明
[capture] 捕获列表,必填。指定Lambda内可以访问哪些外部变量以及访问方式(值或引用)。
(params) 参数列表,可选。与普通函数的参数列表类似,C++14起可以auto(泛型Lambda)。
mutable 可变修饰,可选。默认情况下,值捕获的变量在Lambda体内是只读的,加上mutable后可修改其副本
constexpr 常量表达式修饰,可选(C++17)。强制编译器在常量表达式中求值Lambda。
noexcept 异常说明,可选。指明Lambda不会抛出异常。
-> retType 返回类型,可选。若省略,编译器根据return语句自动推导(C++14起支持更灵活的自动推导)。
{ body } 函数体,必填。实际执行的代码。

捕获列表

这是 Lambda 与普通函数最大的区别。它决定了 Lambda 内部如何访问外部作用域的变量:

  • []:不捕获任何外部变量。
  • [=]值捕获。按值复制一份外部所有变量到 Lambda 内部(只读,除非加 mutable)。
  • [&]引用捕获。直接引用外部变量,内部修改会影响外部。
  • [x, &y]:特定捕获。x 按值捕获,y 按引用捕获。T

Tips

  • 引用捕获:必须确保被引用变量在Lambda被调用时仍然存活。例如,返回一个捕获了局部变量引用的Lambda,会导致悬垂引用。
  • 值捕获:变量在Lambda定义时被拷贝(而非调用时)。对于只移动类型(如std::unique_ptr),C++14允许通过初始化捕获来移动捕获。

示例

  1. 简单的Lambda表达式:

    []{} —— 空捕获、无参数、无返回类型、空函数体。

    1
    2
    auto greet = []() { std::cout << "Hello Lambda!\n"; };
    greet(); // 输出:Hello Lambda!
  2. 带参数与返回类型

    1
    2
    3
    4
    5
    6
    //参数与返回类型
    auto add = [](int a, int b) -> int { return a + b; };
    std::cout << add(3, 4); // 输出:7

    // 省略返回类型(自动推导)
    auto multiply = [](int a, int b) { return a * b; };
  3. 捕获外部变量

    1
    2
    3
    4
    5
    6
    7
    8
    int x = 10, y = 20;
    // 值捕获x,引用捕获y
    auto func = [x, &y]() {
    // x += 1; // 错误:x是只读的
    y += 1; // 正确:y是引用
    return x + y;
    };
    std::cout << func(); // 使用原始x副本和修改后的y
  4. 使用mutable修改值捕获的副本

    1
    2
    3
    4
    5
    6
    7
    int count = 0;
    auto counter = [count]() mutable {
    return ++count; // 修改的是捕获到的副本
    };
    std::cout << counter(); // 1
    std::cout << counter(); // 2
    std::cout << count; // 0(原变量未变)

泛型Lambda

C++14允许在参数列表中使用auto,使得Lambda可以接受任意类型的参数,相当于定义了模板化的operator()

1
2
3
4
5
auto generic_lambda = [](auto a, auto b) { return a + b; };
std::cout << generic_lambda(1, 2); // 3,int
std::cout << generic_lambda(1.5, 2.5); // 4.0,double
std::cout << generic_lambda(std::string("Hello "), std::string("World"));
// 字符串拼接:"Hello World"

实际上,编译器会为每种参数类型组合生成不同的重载。泛型Lambda大大提升了代码复用性,常用于算法中。

本质

Lambda表达式在编译时会被转换为一个仿函数类(匿名函数对象),其operator()默认是const的(除非使用了mutable)。捕获的变量会作为该类的成员变量。

例如,[x, &y](int z) { return x + y + z; }大致等价于:

1
2
3
4
5
6
7
8
class AnonymousLambda {
private:
int x; // 值捕获的成员
int& y; // 引用捕获的成员
public:
AnonymousLambda(int x, int& y) : x(x), y(y) {}
auto operator()(int z) const { return x + y + z; }
};

因此,值捕获产生拷贝,引用捕获产生引用成员。

override final

override

在派生类中显式声明某个虚函数意在重写基类中的一个同名虚函数。编译器会检查该函数是否真正重写了基类的某个虚函数(签名匹配),如果不匹配则产生编译错误。

在成员函数声明(或定义)的参数列表之后、函数体或 = default/delete 之前加上 override

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Base {
public:
virtual void foo(int x);
virtual void bar() const;
virtual void baz();
};

class Derived : public Base {
public:
void foo(int x) override; // 正确重写 Base::foo
void bar() const override; // 正确重写 Base::bar
void baz() override; // 正确重写 Base::baz
// void foo(double x) override; // 错误!因为 Base 中没有 virtual void foo(double)
// void bar() override; // 错误!缺少 const,签名不匹配
};

使用override可以防止出现,粗心导致子类重写虚函数出错,从而被认为是一个新的函数

1
2
3
4
5
6
7
8
9
10
11
class Animal {
public:
virtual void speak(const std::string& msg) const;
};

class Dog : public Animal {
public:
// 笔误:参数类型写成了 std::string(非引用),且缺少 const
void speak(std::string msg) override;
// 编译错误: 'Dog::speak' does not override a virtual function from 'Animal'
};
  • 只要在派生类中重写虚函数,就加上 override。这相当于一种自文档化,也防止未来因基类改动导致重写失效。
  • 可以将 overridevirtual 同时使用,但 override 本身已经隐含了该函数是虚函数,因此 virtual 可省略(但保留也不影响)。

final

final 有两种使用场景:

  • 作为虚函数的修饰符:禁止派生类进一步重写该虚函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Base {
    public:
    virtual void foo() final; // 此函数不可被任何派生类重写
    };

    class Derived : public Base {
    public:
    // void foo() override; // 错误!Base::foo 是 final 的
    };
  • 作为类的修饰符:禁止该类被继承。

    1
    2
    3
    4
    5
    class FinalClass final {
    // 该类不允许被任何类继承
    };

    // class BadAttempt : public FinalClass { }; // 编译错误!
  • final 可以用来修饰一个“新引入的虚函数”,而不仅仅是修饰一个“重写基类的虚函数”

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Base {
    public:
    virtual void foo() final; // 这是一个全新的虚函数,没有重写任何基类函数
    // 注意:Base 没有基类(或基类中没有 foo)
    };

    class Derived : public Base {
    // void foo() override; // 错误!Base::foo 是 final 的,不能重写
    };
  • final 关键字只能用于虚函数,因此 static 函数不能用 final

    • 因为static函数属于整个类,不存在this指针,但虚函数是动态绑定的需要this指针

override + final

派生类可以在重写基类虚函数的同时,声明该重写版本为 final,从而阻止更深层的派生类再次重写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class GrandBase {
public:
virtual void func();
};

class Parent : public GrandBase {
public:
void func() override final; // 重写了 GrandBase::func,并禁止进一步重写
};

class Child : public Parent {
public:
// void func() override; // 错误!Parent::func 是 final 的
};

统一初始化与列表初始化


constexpr

constexpr(常量表达式)是 C++11 引入的关键字,用于在编译期求值的表达式或函数。它允许程序员显式要求某个变量、函数或构造函数在编译阶段完成计算,从而提升性能并支持编译期元编程。

基本概念

  • 常量表达式:指值在编译阶段就可以确定,且不会改变的表达式。
  • constexpr 变量:必须用常量表达式初始化,且本身是 const 的(但 constexpr 隐含了 const 语义,对指针略有不同)。
  • constexpr 函数:其返回值或参数可以是常量表达式,当传入常量参数时在编译期求值,否则退化为普通函数。
  • constexpr 构造函数:允许创建编译期的用户自定义类型对象。

constexpr 变量

语法:constexpr 类型 变量名 = 常量表达式;

1
2
3
4
5
6
constexpr int max_size = 100;          // 编译期常量
constexpr double pi = 3.1415926;
constexpr int arr[max_size]; // 可用于数组大小

int x = 10;
// constexpr int y = x; // 错误:x 不是常量表达式

const 的区别

  • const 表示“运行期只读”,不保证编译期已知。
  • constexpr 表示“编译期常量”,一定在编译期求值,且隐含 const 属性。
1
2
3
int a = 5;
const int b = a; // 合法,b 是运行期常量(只读)
// constexpr int c = a; // 错误,a 不是常量表达式

constexpr 函数

函数可以声明为 constexpr,此时被称为常量表达式函数,当所有参数都是常量表达式时,函数在编译期求值;否则像普通函数一样在运行期执行。

限制

  • 函数体不能出现常量表达式以外的内容,但是using typedefstatic_assertreturn除外 。
  • 返回值类型必须是常量表达式。
  • 函数体不能有 try 块或 asm 声明。
1
2
3
4
5
6
7
constexpr int square(int x) {
return x * x;
}

constexpr int val = square(5); // 编译期求值 val = 25
int y = 3;
int z = square(y); // 运行期调用,y 不是常量
1
2
3
4
5
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}

constexpr int f5 = factorial(5); // 编译期求值 120

修饰构造函数

用户自定义类型可以通过 constexpr 构造函数创建编译期对象。该类必须满足:

  • 函数体必须为空,采用初始化列表方式为各个成员变量赋值
  • 至少有一个 constexpr 构造函数。
  • 析构函数不能是用户自定义的(C++11 中默认或隐式即可)。
1
2
3
4
5
6
7
8
9
10
11
class Point {
public:
constexpr Point(double x, double y) : x_(x), y_(y) {}
constexpr double x() const { return x_; }
constexpr double y() const { return y_; }
private:
double x_, y_;
};

constexpr Point origin(0.0, 0.0);
constexpr double ox = origin.x(); // 编译期求值

修饰模板函数

constexpr 经常与模板结合,实现编译期计算和元编程。

1
2
3
4
5
6
7
8
9
10
template<int N>
constexpr int fib() {
return fib<N-1>() + fib<N-2>();
}
template<>
constexpr int fib<0>() { return 0; }
template<>
constexpr int fib<1>() { return 1; }

int arr[fib<10>()]; // 数组大小为 55

局限性(C++11)

  • 函数体限制严格:只能有一条 return,不能用循环、局部变量(C++14 放宽)。
  • 不能修改参数:所有参数都是 const 的(按值传递可修改副本,但意义不大)。
  • 构造函数不能有函数体(C++11 中 constexpr 构造函数必须为空,成员通过初始化列表初始化)。
  • 不能有虚函数

自动类型推导

C++11 引入了 autodecltype 两种自动类型推导机制。

auto

auto 让编译器根据变量的初始值来推导其类型。

基本用法

  • 声明变量auto x = 5;xint

  • 声明指针/引用auto 会自动剥离顶层 constvolatile,除非显示声明。

    1
    2
    3
    4
    5
    6
    7
    8
    int a = 10;
    int& ref = a;
    const int ca = 20;

    auto b = ref; // b 是 int(引用被忽略)
    auto c = ca; // c 是 int(顶层 const 被忽略)
    auto& d = ref; // d 是 int&(显式引用保留)
    const auto e = ca; // e 是 const int(显式 const 保留)

推导规则

顶层:表示对象本身是常量,即该对象的值不可修改。

  • 对于非指针/非引用的类型,const 总是顶层。

  • 对于指针,顶层 const 修饰的是指针本身(即指针变量存储的地址不可变)

  • const int ci = 42;       // ci 是顶层 const:ci 本身不可修改
    int* const p = &x;       // p 是顶层 const:p 的指向不可变(但 *p 可修改)
    const int* const cp = &x; // cp 既是顶层 const(指针本身)又是底层 const(指向 const int)
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14



    底层:**表示指针(或引用)所指的对象是常量**,即通过该指针/引用不能修改所指对象的值。

    - 对于**指针**,底层 `const` 出现在 `*` 的左边,例如 `const int*`。

    - 对于**引用**,所有 `const` 引用(`const T&`)都是底层 `const`,因为引用本身不是对象,无法被设为“引用本身不可改”,所以 `const` 修饰的是所指对象。


    - ```cpp
    const int* p = &x; //p是指向常量的指针,因此 p 是底层 const:不能通过 p 修改 x
    int const* p2 = &x; // 同上,底层 const
    const int& r = x; // r 是底层 const:不能通过 r 修改 x
  1. auto丢弃初始化表达式的引用顶层 const/volatile,但保留底层 const/volatile

    • 如果希望保留引用或顶层 const,需要显式使用 auto&const auto
  2. 列表初始化auto 可推导 std::initializer_list

    1
    2
    auto lst = {1, 2, 3}; // lst 类型为 std::initializer_list<int>
    // auto lst2{1, 2, 3}; // C++17 起:错误,直接列表初始化只能单元素
  3. 指针推导:当初始值是一个指针时,autoauto* 都可以正确推断出指针类型,两者的效果通常是等价的。但 auto* 会强制要求初始值必须是一个指针。

    1
    2
    3
    4
    5
    int val = 42;
    auto p1 = &val; // p1 推导为 int*
    auto* p2 = &val; // p2 推导为 int*

    // auto* p3 = val; // 编译错误:val 不是指针,无法匹配 auto*
  4. 数组退化: 当用数组初始化auto变量时,数组会“退化”为指向其首元素的指针。如果使用 auto&,则不会退化,而是推导为数组的引用。

    1
    2
    3
    4
    int arr[] = {1, 2, 3};

    auto arr_ptr = arr; // arr_ptr 的类型是 int* (数组退化为指针)
    auto& arr_ref = arr; // arr_ref 的类型是 int(&)[3] (对长度为3的数组的引用)

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int temp = 110;
auto *a = &temp;//a是int*,auto推测的类型是int
auto b = &temp; //b是int*, auto推测的类型是int*
auto &c = temp; //c是int&, auto推测的类型是int
auto d = temp; //d是int , auto推测的类型是int

int tmp = 250;
const auto a1 = tmp; //显示声明了const a1是const int , auto推测为int
auto a2 = a1; //顶层const 则剥离 因此a2是int , auto推测为int
const auto &a3 = tmp; //a3是const int & , auto推测为int
auto &a4 = a3; // 底层const 保留,因此a4是const int& , auto推测为const int


作者: 苏丙榅
链接: https://subingwen.cn/cpp/autotype/#1-1-%E6%8E%A8%E5%AF%BC%E8%A7%84%E5%88%99
来源: 爱编程的大丙
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

限制

  1. 不能作为函数的参数直接使用。因为只有在函数调用的时候才会给函数参数传递实参,auto要求必须要给修饰的变量赋值,因此矛盾
  2. 不能用于类的非静态成员变量的初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
class Test
{
auto v1 = 0; // error
static auto v2 = 0; // error,类的静态非常量成员不允许在类内部直接初始化
static const auto v3 = 10; // ok
}


作者: 苏丙榅
链接: https://subingwen.cn/cpp/autotype/#1-2-auto%E7%9A%84%E9%99%90%E5%88%B6
来源: 爱编程的大丙
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  1. 无法使用auto推导出模板参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
       template <typename T>
    struct Test{}

    int func()
    {
    Test<double> t;
    Test<auto> t1 = t; // error, 无法推导出模板类型
    return 0;
    }







    ### 常用场景
    1. **遍历stl容器**
    2. **泛型编程,使用模板时,很多时候不知道变量应该定义为什么类型**

    ```cpp
    //不用auto
    #include <iostream>
    #include <string>
    using namespace std;

    class T1
    {
    public:
    static int get()
    {
    return 0;
    }
    };

    class T2
    {
    public:
    static string get()
    {
    return "hello, world";
    }
    };

    template <class A> // 添加了模板参数 B
    void func(void)
    {
    auto val = A::get();
    cout << "val: " << val << endl;
    }

    int main()
    {
    func<T1>();
    func<T2>();
    return 0;
    }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//不用auto
#include <iostream>
#include <string>
using namespace std;

class T1
{
public:
static int get()
{
return 0;
}
};

class T2
{
public:
static string get()
{
return "hello, world";
}
};

template <class A, typename B> // 添加了模板参数 B
void func(void)
{
B val = A::get();
cout << "val: " << val << endl;
}

int main()
{
func<T1, int>(); // 手动指定返回值类型 -> int
func<T2, string>(); // 手动指定返回值类型 -> string
return 0;
}
作者: 苏丙榅
链接: https://subingwen.cn/cpp/autotype/#1-3-auto%E7%9A%84%E5%BA%94%E7%94%A8
来源: 爱编程的大丙
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24


## `decltype`
> decltype(全称:Declared Type),作用是向编译器发出查询:“这个表达式的类型是什么?”,与 auto 必须绑定变量初始化不同,decltype 只是**静态地在编译期分析表达式的类型**,绝不会真正计算(求值)该表达式。在**模板元编程和泛型编程**中,我们经常需要知道某个表达式的结果究竟是什么类型,但又不能或者不想真正去执行这个表达式,这时候 decltype 就成为了不可或缺的工具。
> ` auto `与 `decltype`
>
> 1. 精确推导:与 auto 会丢弃引用和顶层 const 不同,decltype **保留所有类型修饰符**(包括引用和 CV 限定符)。
> 2. 零运行时开销:`auto` 和 `decltype` 同样在编译阶段完成,不会生成任何对应的汇编指令。

### 推导规则
`decltype(expr)` 的推导结果极其依赖于表达式的形式以及它的值类别。C++ 标准将其推导规则严格分为以下几类:
1. **未加括号的标识符或成员访问:**
如果`e` 是一个没有被多余括号包围的**变量名、函数名**,或者是**类成员访问表达式**(如 obj.member 或 ptr->member),那么 `decltype(e)`推导出的就是该实体在代码中声明时的精确类型。

```cpp
const int ci = 0;
int x = 10;
int& ref_x = x;
struct Point { double x; double y; };
Point pt;

decltype(ci) a = 1; // a 的类型是 const int (完美保留 const)
decltype(ref_x) b = x; // b 的类型是 int& (完美保留引用)
decltype(pt.x) c = 0.0; // c 的类型是 double
  1. 其他表达式的值类别:
    如果不符合1的条件(例如它是一个算术表达式、函数调用,或者被括号 () 包围的变量),编译器将根据表达式 e 的值类别来决定类型。假设表达式 e 的基础类型为 T:

    • e是左值:decltype(e) T&
    • e是将亡值: decltype(e)T&&
    • e是纯右值:decltype(e)T
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int i = 42;
    int* p = &i;

    // 函数调用
    int f();
    int& g();
    decltype(f()) x1 = 1; // x1 是 int (纯右值)
    decltype(g()) x2 = i; // x2 是 int& (左值)

    // 算术表达式
    decltype(i + 0) x3 = 5; // x3 是 int (i+0 产生纯右值)

    // 解引用表达式
    decltype(*p) x4 = i; // x4 是 int& (*p 返回左值,可以被赋值)

右值引用与移动语义

在 C11 之前,C 在处理临时对象(比如函数返回的大型容器、字符串拼接产生的中间结果)时,往往会触发昂贵的深拷贝 (Deep Copy)。为了解决这个性能瓶颈,C++11 引入了右值引用,它使得程序能够“窃取”临时对象的内存资源,从而实现零拷贝的移动语义 (Move Semantics)

右值引用

传统 C++ 中的引用(现在称为左值引用)使用 & 符号;C++11 引入的右值引用使用 && 符号。

绑定规则

  1. 右值引用只能绑定到右值上,不能直接绑定到左值。它的核心目的是延长临时对象的生命周期,或者接管临时对象的资源。
1
2
3
4
5
6
int a = 10;          // a 是左值
int& ref_a = a; // 正确:左值引用绑定到左值

// int&& rref_a = a; // 错误:不能将右值引用绑定到左值
int&& rref_1 = 20; // 正确:20 是纯右值
int&& rref_2 = a + 5;// 正确:a+5 产生一个临时纯右值
  1. 右值引用变量本身是左值,上述代码中rref_1 的类型是右值引用,但它是左值
1
2
3
4
5
6
7
8
9
void process(int& x) { /* 处理左值 */ }
void process(int&& x) { /* 处理右值 */ }

void test() {
int&& r = 100;
// 下面这行会调用 process(int& x),而不是 process(int&& x)!
// 因为 r 作为变量传递时,它是一个有名字的左值。
process(r);
}

移动语义

移动语义是右值引用最大的价值所在。通过编写移动构造函数移动赋值运算符,我们可以将资源(如动态分配的内存)从源对象直接转移到目标对象,而不是进行深拷贝。

std::move

如果想把一个左值当作右值来处理(即明确告诉编译器:“我不再需要这个左值了,你可以把它的资源拿走”),可以使用 std::move()

注意:std::move 并在运行时不执行任何实际的移动操作,它仅仅是在编译期执行了类型转换(static_cast<T&&>),将左值强制转换为右值引用(将亡值)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <iostream>
#include <cstring>

class MyString {
private:
char* data;
size_t size;

public:
// 1. 普通构造函数
MyString(const char* str) {
size = std::strlen(str);
data = new char[size + 1];
std::strcpy(data, str);
std::cout << "Constructed\n";
}

// 2. 拷贝构造函数 (深拷贝 - 极其昂贵)
// 绑定到左值
MyString(const MyString& other) {
size = other.size;
data = new char[size + 1];
std::strcpy(data, other.data);
std::cout << "Copy Constructed (Deep Copy)\n";
}

// 3. 移动构造函数 (浅拷贝/资源窃取 - 极速)
// 绑定到右值 (临时对象或 std::move)
// 注意加上 noexcept,向 STL 保证移动过程不会抛出异常
MyString(MyString&& other) noexcept {
// 窃取源对象的指针和大小
data = other.data;
size = other.size;

// 将源对象置于“有效但未指定”的空状态,防止其析构函数释放这块内存
other.data = nullptr;
other.size = 0;

std::cout << "Move Constructed (Zero Copy)\n";
}

~MyString() {
delete[] data;
}
};

int main() {
MyString str1("Hello"); // 调用普通构造

// 场景 A:必须深拷贝,因为 str1 后面可能还要用
MyString str2 = str1; // 调用拷贝构造函数

// 场景 B:我们确定 str1 以后不再使用了,使用 std::move 榨干它的资源
MyString str3 = std::move(str1); // 调用移动构造函数!

// 此时 str1.data 已经被置为 nullptr,它的资源现在属于 str3
return 0;
}

完美转发

当我们在编写模板函数时,常常需要将参数原封不动地传递给内部的另一个函数。所谓“原封不动”,指的是保持参数的左值或右值属性不变,以及保持 const 属性不变。

万能引用

模板中,如果参数类型写为 T&&(且 T 是需要推导的模板参数),那么它就不再是普通的右值引用,而是万能引用

  • 如果传入左值,T&& 会被推导为左值引用(发生引用折叠)。
  • 如果传入右值,T&& 会保持为右值引用。

std::forward

右值引用作为形参进入函数体后,由于有了名字,就变成了左值。为了在继续向下传递时恢复它原本的“左右值”属性,必须使用 std::forward<T>()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void process(int& x)  { std::cout << "Lvalue processed\n"; }
void process(int&& x) { std::cout << "Rvalue processed\n"; }

// 这是一个工厂/包装函数模板
template <typename T>
void wrapper(T&& arg) {
// 错误做法:arg 在此处是左值,将永远调用 process(int&),失去右值属性
// process(arg);

// 正确做法:完美转发。
// 如果外部传入的是左值,forward 传递左值;传入的是右值,forward 传递右值。
process(std::forward<T>(arg));
}

int main() {
int a = 5;
wrapper(a); // 传入左值,输出: Lvalue processed
wrapper(42); // 传入纯右值,输出: Rvalue processed
wrapper(std::move(a)); // 传入将亡值,输出: Rvalue processed
}

C++11 将右值进一步细分为:

  1. 纯右值 (Prvalue):非引用返回的临时变量、运算表达式产生的临时变量、原始字面量。
  2. 将亡值 (Xvalue):与右值引用相关的表达式,比如 std::move(x) 的返回值,或者返回右值引用的函数调用。它标志着某个对象的资源可以被安全地“移动”。

智能指针

在传统 C++(C++98/03)中,内存管理完全由程序员负责。这种“裸指针”模式面临三大痛点:

  1. 内存泄漏:申请了内存但忘记释放。
  2. 悬垂指针:指向的对象已释放,但指针仍在使用。
  3. 二次释放:对同一块内存调用两次 delete 导致崩溃。

C++11 引入了智能指针,其核心思想是 **RAII **:通过一个栈上的对象来管理堆上的资源。当栈对象生命周期结束析构时,自动释放其管理的堆资源。

RAII

RAII(Resource Acquisition Is Initialization,资源获取即初始化):将资源的生命周期与对象的生命周期绑定,在对象构造时获取资源(如动态内存、文件句柄、互斥锁、数据库连接等),在对象析构时自动释放资源。

原理

  • 构造函数:负责获取资源(分配内存、打开文件、加锁等)。
  • 析构函数:负责释放资源(释放内存、关闭文件、解锁等)。
  • 当对象离开作用域时(包括正常结束、抛出异常等),C++ 保证析构函数会被自动调用,从而确保资源被正确释放。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <fstream>

class FileHandle {
public:
FileHandle(const char* filename) : file(fopen(filename, "r")) {
if (!file) throw std::runtime_error("打开文件失败");
}
~FileHandle() {
if (file) fclose(file);
}
// 禁止拷贝构造和拷贝赋值 避免出现多个对象共享同一资源并重复释放的错误。
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
private:
FILE* file;
};

优势

  1. 异常安全:即使发生异常,对象析构依然会被调用,资源不会泄漏。
  2. 防止忘记释放资源:不需要手动写 deletefclose 等代码。
  3. 代码简洁清晰:资源管理逻辑集中在构造/析构函数中。

std::unique_ptr

最常用、性能最高的智能指针,保证同一时间内只有一个指针拥有该对象。

  • 禁止拷贝:不能通过赋值或构造进行拷贝。如果允许会出现两个指针指向同一个资源,发生重复释放
  • 允许移动:可以通过 std::move 转移所有权。因为禁止了拷贝所以只能通过移动来转移所有权
  • 零开销:在运行时,其大小与裸指针完全一致,没有任何性能损失。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <memory>
#include <iostream>

struct Resource {
Resource() { std::cout << "资源已创建\n"; }
~Resource() { std::cout << "资源已释放\n"; }
void use() { std::cout << "正在使用资源\n"; }
};

void test_unique() {
// 1. 创建 unique_ptr (建议使用 C++14 的 std::make_unique)
std::unique_ptr<Resource> ptr1(new Resource());

// 2. ptr2 = ptr1; // 错误!禁止拷贝

// 3. 转移所有权
std::unique_ptr<Resource> ptr2 = std::move(ptr1);
if (!ptr1) std::cout << "ptr1 现在为空\n";

ptr2->use();
} // 函数结束时,ptr2 析构,资源自动释放

与函数

  1. 值传递:std::unique_ptr 是独占所有权的智能指针,它不可拷贝,只能移动。
    1
    2
    3
    4
    5
    6
    void func(std::unique_ptr<int> p) {
    // p 持有对象
    }
    auto ptr = std::make_unique<int>(10);
    func(std::move(ptr)); // 必须使用 std::move,因为不能拷贝
    // 此时 ptr 为空,所有权已转移到函数形参 p
  2. 引用传递:借用,不转移所有权
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void func(std::unique_ptr<int>& p) {   // 非 常量引用
    *p = 20; // 可以修改对象
    }

    void func_const(const std::unique_ptr<int>& p) {
    int val = *p; // 只读访问
    }

    auto ptr = std::make_unique<int>(10);
    func(ptr); // ptr 仍持有对象,没有被移动
  3. 函数返回,返回 std::unique_ptr,即移动所有权
    1
    2
    3
    4
    std::unique_ptr<int> create() {
    return std::make_unique<int>(42);
    }
    std::unique_ptr<int> ptr = create();
    • 函数返回 unique_ptr 时,会通过移动将所有权传递给接收者。
    • C++11 起支持移动语义,返回局部 unique_ptr 是安全的,而且不会有任何拷贝。

std::shared_ptr

std::shared_ptr 允许多个指针同时指向同一个对象。对象内部维护一个**引用计数 **。

对象内部通常包含两个指针:

  • 指向管理对象的指针
  • 指向控制块的指针(包含引用计数、弱引用计数、删除器等)
  1. 引用计数:每增加一个指向该对象的 shared_ptr,计数加 1;每有一个指针失效,计数减 1。
  2. 自动析构:当计数降为 0 时,自动销毁对象并释放内存。
  3. 线程安全:引用计数操作是线程安全的,但对象本身它指向的资源需要程序员额外处理同步。”
1
2
3
4
5
6
7
8
9
10
11
12
void test_shared() {
// 使用 std::make_shared 是更高效且安全的方式
auto ptr1 = std::make_shared<Resource>();
std::cout << "当前计数: " << ptr1.use_count() << "\n"; // 1

{
auto ptr2 = ptr1; // 拷贝:计数加 1
std::cout << "当前计数: " << ptr1.use_count() << "\n"; // 2
} // ptr2 离开作用域,计数减 1

std::cout << "当前计数: " << ptr1.use_count() << "\n"; // 1
} // ptr1 离开作用域,计数归 0,释放资源

与函数

  1. 按值传递:共享所有权
    std::shared_ptr 是共享所有权的智能指针,它支持拷贝
    1
    2
    3
    4
    5
    6
    7
    void func(std::shared_ptr<int> p) {
    // p 和外部 ptr 共享同一个对象,引用计数 +1
    }

    auto ptr = std::make_shared<int>(10);
    func(ptr); // 拷贝,引用计数从1变为2
    // 函数返回后,p 析构,引用计数减1,ptr 仍然有效
    • 实参直接传递即可,会调用拷贝构造,引用计数增加。
    • 函数形参 p 在函数结束时析构,引用计数减1。
    • 这种写法表示:函数希望在内部延长对象的生命周期,或者与调用者共享所有权。
  2. 按引用传递,借用,不增加计数
    1
    2
    3
    4
    5
    6
    void func(const std::shared_ptr<int>& p) {
    int val = *p;
    }

    auto ptr = std::make_shared<int>(10);
    func(ptr); // 引用计数不变
    • 按引用传递不会增加引用计数,因为只是引用了已有的 shared_ptr 对象。
    • 这在性能上比按值传递更高效(避免原子计数操作),但同样,如果你只是需要访问对象本身,传T&而非智能指针引用。
  3. 返回值,拷贝
    1
    2
    3
    4
    std::shared_ptr<int> create() {
    auto p = std::make_shared<int>(42);
    return p; // 拷贝,引用计数+1,然后局部 p 析构,最终引用计数为1
    }
    • 返回 shared_ptr 时,一般会发生拷贝(除非编译器优化为移动),最终结果是返回的指针持有一份所有权,引用计数至少为1。

std::weak_ptr

std::weak_ptr是一种不控制对象生命周期的智能指针,它是 std::shared_ptr 的观察者。

  • 不增加计数:指向对象但不参与所有权。
  • 安全性:在使用前必须调用 lock() 升级为 shared_ptr,如果对象已销毁,lock() 返回空指针。
  • 打破循环引用:这是它最重要的用途。

循环引用陷阱

当两个对象互相持有对方的 shared_ptr 时,引用计数永远不会归零,导致内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct B;
struct A { std::shared_ptr<B> b_ptr; ~A() { std::cout << "A 析构\n"; } };
struct B { std::shared_ptr<A> a_ptr; ~B() { std::cout << "B 析构\n"; } };

void leak_demo() {
// 步骤 1:在堆上创建 A 对象。
// 栈上有一个局部变量 a 指向它。
auto a = std::make_shared<A>();
// 【当前状态】对象 A 计数 = 1

// 步骤 2:在堆上创建 B 对象。
// 栈上有一个局部变量 b 指向它。
auto b = std::make_shared<B>();
// 【当前状态】对象 B 计数 = 1

// 步骤 3:A 对象内部的 b_ptr 也指向了 B 对象。
// 现在有局部变量 b 和 a->b_ptr 两个指针同时指向 B 对象。
a->b_ptr = b;
// 【当前状态】对象 B 计数 = 2

// 步骤 4:B 对象内部的 a_ptr 也指向了 A 对象。
// 现在有局部变量 a 和 b->a_ptr 两个指针同时指向 A 对象。
b->a_ptr = a;
// 【当前状态】对象 A 计数 = 2

} // <--- 关键点:函数结束,右大括号!

在 C++ 中,当离开作用域(即遇到 })时,栈上的局部变量会按照后进先出的顺序被自动销毁。

  1. 销毁局部变量 b:栈上的 b 指针不复存在了。因此,对象 B 的引用计数减 1 (从 2 变成了 1)
  2. 销毁局部变量 a:栈上的 a 指针也不复存在了。因此,对象 A 的引用计数减 1 (从 2 变成了 1)

现在,函数已经彻底执行完毕,栈被清空了,但我们在堆上留下了两个对象:

  • 对象 A 的计数是 1,因为虽然局部变量 a 死了,但对象 B 内部的 b->a_ptr 仍然指着它
  • 对象 B 的计数是 1,因为虽然局部变量 b 死了,但对象 A 内部的 a->b_ptr 仍然指着它

因为引用计数都没有降到 0,所以它们的析构函数(~A()~B())永远不会被触发,这两块堆内存就永远挂在空中,造成了内存泄漏

如果我们将其中一个结构体内部的指针换成 std::weak_ptr,它在上述步骤 3 或 4 中就不会增加引用计数(最高只能是 1),那么在函数结束销毁局部变量时,计数就会顺利降为 0,从而触发链式反应,完美释放内存。

安全性:lock()

weak_ptr 没有重载 ->* 运算符。你在代码里根本无法直接写出 wp->value 这样的代码,编译器会直接报错。它强迫你必须使用 lock()

既然不能直接用,那就必须把 weak_ptr 变成 shared_ptr 才能用。这个过程就叫做升级 (Upgrade)

调用 wp.lock() 时,底层其实在做一个极其严谨且线程安全(原子性)的操作

  1. 检查控制块:它去查看该对象当前的“强引用计数(shared count)”是否大于 0。
  2. 分支 A(对象还活着):如果大于 0,说明对象还在。lock()立刻将强引用计数加 1,并返回一个有效的 shared_ptr
    • 为什么叫安全? 因为一旦拿到了这个临时的 shared_ptr,引用计数就增加了。就算此时其他线程全都不管这个对象了,只要你手里的这个临时 shared_ptr 还没死,对象就绝对不会被销毁。你可以放心大胆地用。
  3. 分支 B(对象已死亡):如果强引用计数等于 0,说明对象已经被释放了。lock() 会返回一个空的(Null)shared_ptr。你的程序可以通过 if 判断安全地跳过操作,避免了崩溃。

使用手册

  1. 优先使用 unique_ptr:如果不需要共享所有权,默认使用它,因为它性能最优。

  2. 始终使用工厂函数:优先用 std::make_unique (C14) 和 std::make_shared (C11)。它们能提高性能(减少内存分配次数)并防止由于异常导致的内存泄漏。

  3. 不要将裸指针直接初始化:尽量写成 auto p = std::make_shared<T>()

  4. 接口传递

    • 只读访问:传递 const T&

    • 转移所有权:传递 std::unique_ptr<T>&& 或值传递。

    • 共享所有权:传递 std::shared_ptr<T>

工厂函数与所有权

C++ 标准库提供了 std::make_uniquestd::make_shared 两个工厂函数,用于安全、高效地创建由智能指针管理的对象。注意:没有 std::make_weak,因为 weak_ptr 本身不拥有对象,它只能从已有的 shared_ptr 构造或赋值得到。


std::make_unique(C++14)

作用

在堆上创建一个 T 类型的对象,并返回一个独占所有权std::unique_ptr<T>

函数签名

1
2
template<class T, class... Args>
std::unique_ptr<T> make_unique(Args&&... args);

返回值

  • 类型:std::unique_ptr<T>
  • 所有权:独占,该 unique_ptr 是对象的唯一所有者。

所有权变化

  • 创建时,返回的 unique_ptr 获得对象的独占所有权。
  • 之后只能通过移动转移所有权;拷贝被禁止。
  • 当该 unique_ptr 被销毁、重置或移动后,对象被释放(delete)。

示例

1
2
auto ptr = std::make_unique<int>(42);   // ptr 独占 int 对象
// ptr 指向一个 int,值为 42

std::make_shared(C++11)

作用

在堆上创建一个 T 类型的对象,并返回一个共享所有权std::shared_ptr<T>,同时分配控制块(保存引用计数等信息)。

函数签名

1
2
template<class T, class... Args>
std::shared_ptr<T> make_shared(Args&&... args);

返回值

  • 类型:std::shared_ptr<T>
  • 所有权:共享,初始时引用计数为 1。

所有权变化

  • 创建时,返回的 shared_ptr 持有对象,引用计数 = 1。
  • 之后可以通过拷贝增加引用计数,通过移动不增加计数(但所有权转移)。
  • 当最后一个 shared_ptr 被销毁或重置时,对象被释放,控制块销毁。

内部优化

make_shared 通常只进行一次内存分配,将对象和控制块放在同一块连续内存中,比 std::shared_ptr<T>(new T) 更高效、更安全(异常安全)。

示例

1
2
auto ptr = std::make_shared<int>(42);   // ptr 持有对象,计数为1
auto ptr2 = ptr; // 拷贝,计数变为2

std::weak_ptr 的创建

标准库没有 make_weakweak_ptr 是“观察者”,不拥有对象,因此没有工厂函数。

如何获得 weak_ptr

从已有的 shared_ptr 构造或赋值:

1
2
std::shared_ptr<int> sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp; // 不增加引用计数

所有权变化

  • weak_ptr 从不拥有对象,不影响强引用计数。
  • 它只能通过 lock() 临时获取一个 shared_ptr(若对象还存活),此时会短暂增加引用计数。

示例

1
2
3
std::shared_ptr<int> sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp;
std::shared_ptr<int> sp2 = wp.lock(); // sp2 有效,引用计数 +1

总结

工厂函数 返回类型 初始所有权 创建方式 注意点
make_unique std::unique_ptr<T> 独占 单次分配 C++14,不可拷贝
make_shared std::shared_ptr<T> 共享(计数1) 单次分配(含控制块) C++11,推荐优于 new 构造
(无 make_weak std::weak_ptr<T> shared_ptr 构造/赋值 不增加引用计数,仅观察

std::unique_ptr所有权转移例子

以下三个函数都以 std::unique_ptr<Resource> 为参数和返回值,演示了 unique_ptr 的独占所有权如何在不同作用域间流动。

类型定义

1
using Unique = std::unique_ptr<Resource>;

reset 函数

1
2
3
4
Unique reset(Unique ptr) {
if (ptr) ptr->record('r');
return std::make_unique<Resource>();
}

解析

  • 参数传递:按值接收 Unique,调用者必须通过 std::move 将所有权传入,形参 ptr 接管对象。
  • 函数体
    • 如果 ptr 非空,则调用 record('r'),在原对象上追加字符 'r'
    • 创建一个新的 Resource 对象,并用 std::make_unique 返回。
  • 所有权流转
    • 旧对象(原本由 ptr 管理)在函数结束、形参 ptr 析构时被销毁(触发 ~Resource,其内部字符串记录到全局 RECORDS)。
    • 新对象通过返回值转移给调用者,所有权传出。
  • 效果:结束旧对象的生命,开始新对象的生命。

drop 函数

1
2
3
4
Unique drop(Unique ptr) {
if (ptr) ptr->record('d');
return nullptr;
}

解析

  • 参数传递:同 reset,按值接收并接管所有权。
  • 函数体
    • 如果 ptr 非空,则追加字符 'd'
    • 返回 nullptr,表示放弃所有权。
  • 所有权流转
    • 形参 ptr 在函数结束时仍持有对象,因此析构时会销毁该对象(记录其字符串)。
    • 返回值是空指针,调用者收到空所有权。
  • 效果:直接销毁对象,并传递空指针。

forward 函数

1
2
3
4
Unique forward(Unique ptr) {
if (ptr) ptr->record('f');
return ptr;
}

解析

  • 参数传递:同前,按值接收并接管所有权。
  • 函数体
    • 如果 ptr 非空,则追加字符 'f'
    • 返回 ptr 本身(通过移动)。
  • 所有权流转
    • return ptr; 触发移动构造,将 ptr 所持有的对象所有权转移给返回值。
    • 形参 ptr 在被移动后变为空指针,函数结束时析构空指针,不会销毁任何对象
  • 效果:只是“转发”所有权,不销毁对象,也不创建新对象。

核心要点总结

函数 参数所有权进入 函数内行为 返回时行为 对象最终命运
reset 接管 记录 'r',创建新对象 旧对象随形参析构销毁,新对象传出 旧对象销毁,新对象存活
drop 接管 记录 'd',返回空指针 形参析构销毁旧对象 对象销毁
forward 接管 记录 'f',返回 ptr 所有权移交给返回值,形参变空 对象原样传递,不销毁

关键机制

  • 按值传递 unique_ptr 意味着所有权从调用者移动到形参。
  • return ptr; 如果返回 unique_ptr,会将所有权从形参移动给返回值。
  • 如果形参在返回前已移动为空,则析构时不会销毁任何对象;否则析构时销毁其管理的对象。

这个模式清晰展示了 std::unique_ptr 的独占所有权和移动语义,是理解智能指针生命周期的典型例子。


std::shared_ptr 引用计数示例解析

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include "../exercise.h"
#include <memory>

int main(int argc, char **argv) {
auto shared = std::make_shared<int>(10);
std::shared_ptr<int> ptrs[]{shared, shared, shared};

std::weak_ptr<int> observer = shared;
ASSERT(observer.use_count() == ?, "");

ptrs[0].reset();
ASSERT(observer.use_count() == ?, "");

ptrs[1] = nullptr;
ASSERT(observer.use_count() == ?, "");

ptrs[2] = std::make_shared<int>(*shared);
ASSERT(observer.use_count() == ?, ""); // 注:标准行为此处应为 1

ptrs[0] = shared;
ptrs[1] = shared;
ptrs[2] = std::move(shared);
ASSERT(observer.use_count() == ?, ""); // 注:标准行为此处应为 3

std::ignore = std::move(ptrs[0]);
ptrs[1] = std::move(ptrs[1]);
ptrs[1] = std::move(ptrs[2]);
ASSERT(observer.use_count() == ?, "");

shared = observer.lock();
ASSERT(observer.use_count() == ?, ""); // 注:标准行为此处应为 3

shared = nullptr;
for (auto &ptr : ptrs) ptr = nullptr;
ASSERT(observer.use_count() == ?, "");

shared = observer.lock();
ASSERT(observer.use_count() == ?, "");

return 0;
}

分步解析

步骤 代码 对引用计数的影响 当前计数
1 auto shared = std::make_shared<int>(10); 创建一个对象,shared 持有所有权 1
2 std::shared_ptr<int> ptrs[]{shared, shared, shared}; 拷贝 shared 三次,每个拷贝增加 1 4
3 std::weak_ptr<int> observer = shared; weak_ptr 不增加强引用计数 4
4 ptrs[0].reset(); ptrs[0] 释放所有权,计数减 1 3
5 ptrs[1] = nullptr; ptrs[1] 赋空指针,释放所有权,计数减 1 2
6 ptrs[2] = std::make_shared<int>(*shared); ① 右侧创建新对象;② ptrs[2] 先释放原对象(计数‑1,此时原对象只有 shared 持有,计数=1);③ ptrs[2] 接管新对象(新对象计数=1)。原对象计数变为 1 原对象:1
7 ptrs[0] = shared; 拷贝 shared,计数 +1 2
8 ptrs[1] = shared; 再次拷贝,计数 +1 3
9 ptrs[2] = std::move(shared); ptrs[2] 释放新对象;shared 移动给 ptrs[2],原对象计数不变(一减一增)。最终持有原对象:ptrs[0], ptrs[1], ptrs[2] 原对象:3
10 std::ignore = std::move(ptrs[0]); std::ignore 的赋值是空操作,ptrs[0] 不受影响 仍为 3
11 ptrs[1] = std::move(ptrs[1]); 自我移动赋值,不改变所有权 3
12 ptrs[1] = std::move(ptrs[2]); ptrs[1] 先释放自己的所有权(计数 ‑1 → 2),然后接管 ptrs[2] 的所有权(计数不变),ptrs[2] 变空。最终持有者:ptrs[0], ptrs[1] 2
13 shared = observer.lock(); observer 指向原对象且对象存活,lock() 成功,返回新 shared_ptr,计数 +1 3
14 shared = nullptr; 释放 shared,计数 ‑1 2
15 for (auto &ptr : ptrs) ptr = nullptr; 释放 ptrs[0]ptrs[1],计数减到 0,对象销毁 0
16 shared = observer.lock(); 对象已销毁,lock() 返回空指针,计数为 0 0

关键操作说明

  • 拷贝构造/赋值:引用计数 +1
  • 移动构造/赋值:所有权转移,引用计数 不变(但目标原先持有的对象会被释放,若指向同一对象则一减一增)。
  • reset() / 赋值为 nullptr:释放所有权,引用计数 -1
  • weak_ptr::lock():若对象存活,返回一个新的 shared_ptr,引用计数 +1;若对象已销毁,返回空指针,计数仍为 0。
  • std::ignore = std::move(...):看似移动,实则因为 std::ignore 的赋值运算符为空操作,不会移动 shared_ptr,所以原指针状态不变。

注意事项

上述表格中第 6 步、第 9 步、第 13 步、第 16 步在标准 C++ 行为下的引用计数与代码中 ASSERT 断言的数字不完全一致
这是因为题目设计时可能考虑了特定编译器对复杂表达式中临时对象析构顺序的未指定行为,或者出于教学目的设置了预期值。
实际运行时应以当前编译器和平台的输出为准,特别是涉及 std::ignore、临时对象、自我移动等边缘情况时,不同编译器可能存在差异。


总结

  • shared_ptr 通过引用计数实现共享所有权,拷贝增加计数,销毁/重置减少计数,移动不影响总计数。
  • weak_ptr 用于观察对象而不增加强引用计数,通过 lock() 可以安全地获取一个 shared_ptr
  • 在使用智能指针作为函数参数或进行复杂表达式操作时,需注意临时对象的析构顺序,避免依赖未指定的行为。

可变参数模板