Introduction
该文档记录部分(不太准确的)C++知识,以及一些面试题。
大部分知识均不完善,不是手册,仅仅作为笔记。
Tip
如果感兴趣的话,可以提交你自己的知识到仓库中。
C++语言面试题汇总
-
volatile关键字有什么作用? -
const和constexpr关键字有什么作用,有什么区别? -
static关键字有什么作用?和C语言中的有什么区别? -
extern有什么作用? -
面向对象的三大特征是什么?
-
typedef和define有什么区别?
-
指针常量与常量指针各是什么,二者有什么区别?
C++基础知识
C++基础部分包括语言的核心语法与程序结构,理解从“源代码到可执行程序”的基本过程。本部分将介绍变量与常量、数据类型、运算符、控制结构、函数、数组、指针、结构体、类与对象等基础概念。
掌握这些内容后,就能编写出功能完整、逻辑清晰的小型程序,理解程序的输入输出、流程控制和内存中数据的基本表示方式。C++基础知识不仅是学习更复杂特性的起点,也是理解程序设计思想与计算机工作原理的重要桥梁。
本章内容
| 章节 | 说明 |
|---|---|
| 基础概念与环境 | 程序结构、预处理器、命名空间、编译运行、编译与链接、注释与命名规则 |
| 数据类型 | 基本类型、派生类型、字符串、类型转换与类型推导 |
| 变量与常量 | 变量的作用域与存储类型,const、constexpr 与枚举 |
| 控制结构 | 循环、判断与条件运算符 |
| 操作符 | 各类运算符的语义、优先级与结合性 |
| 函数 | 声明与定义、参数传递、默认参数、重载与内联 |
| Lambda 表达式 | 捕获列表、泛型 lambda、mutable 与闭包类型 |
| 类与对象 | 类的组成、访问控制、构造与析构、对象生命周期 |
| 模板 | 函数模板、类模板、特化与参数推导 |
运行环境与基础概念
初识C++应知必会的基础概念。
C++ 程序结构(Program Structure)
引言
C++ 程序的结构决定了它的组织方式、执行流程和可维护性。 从最小的“Hello, World!”程序开始,我们可以认识一个 C++ 程序通常包含的头文件、命名空间、函数定义、语句块等组成部分。
一个最小可运行的 C++ 程序
#include <iostream> // 头文件:引入输入输出库
using namespace std; // 使用标准命名空间
int main() { // 主函数:程序执行入口
cout << "Hello, World!" << endl; // 输出语句
return 0; // 返回值:表示程序是否成功结束
}
程序执行流程说明
| 行号 | 内容 | 说明 |
|---|---|---|
| 1 | #include <iostream> | 预处理指令,引入标准输入输出库 |
| 2 | using namespace std; | 告诉编译器默认使用 std 命名空间 |
| 3 | int main() { | 主函数是程序的入口点(返回类型必须是 int) |
| 4 | cout << "Hello, World!" << endl; | 使用 cout 输出字符串并换行 |
| 5 | return 0; | 返回 0 表示程序正常结束 |
程序的基本组成部分
一个完整的 C++ 程序通常由以下几个部分组成:
-
预处理指令(Preprocessor Directives) 在编译前由预处理器执行,例如:
#include <iostream> #define PI 3.14159 -
命名空间(Namespace) 命名空间用于避免名称冲突:
namespace myspace { int x = 10; } -
主函数
main()所有可执行 C++ 程序都必须有且仅有一个main()函数。 -
函数定义(Functions) 函数是代码逻辑的基本单元,示例:
int add(int a, int b) { return a + b; } -
变量与语句(Variables & Statements) 程序的逻辑通过语句执行,通过变量存储数据。
int sum = add(2, 3); cout << sum << endl;
C++ 程序的执行入口
-
程序从
main()函数开始执行。 -
main()的返回值会传递给操作系统。 -
常见的两种形式:
int main() { // 无参数版本 return 0; } int main(int argc, char* argv[]) { // 带命令行参数的版本 // argc 表示参数数量 // argv 表示参数数组 return 0; }
程序的语句块与作用域
语句块使用花括号 {} 表示,定义一个新的作用域(scope):
int x = 10;
{
int x = 20; // 内层作用域的 x 隐藏外层变量
cout << x; // 输出 20
}
cout << x; // 输出 10
作用域是 C++ 的重要概念,影响变量的可见性与生命周期。
程序结构的逻辑层次(层级模型)
| 层级 | 名称 | 说明 |
|---|---|---|
| 顶层 | 预处理部分 | 定义宏、引入头文件 |
| 全局层 | 命名空间、全局变量、函数声明 | |
| 主函数层 | 程序入口、主流程 | |
| 函数层 | 逻辑实现与局部变量 | |
| 语句层 | 执行单元(表达式、控制语句等) |
这是一种从外到内的逻辑组织结构。良好的层级划分有助于程序的模块化。
程序文件结构(大型项目)
在真实项目中,一个 C++ 程序通常被拆分为多个文件:
project/
├── main.cpp // 程序入口
├── math/
│ ├── add.cpp
│ └── add.h
└── utils/
├── log.cpp
└── log.h
通过 头文件 (.h/.hpp) 声明接口,源文件 (.cpp) 实现功能:
-
add.h
#ifndef ADD_H #define ADD_H int add(int a, int b); #endif -
add.cpp
#include "add.h" int add(int a, int b) { return a + b; } -
main.cpp
#include <iostream> #include "add.h" int main() { std::cout << add(3, 4) << std::endl; return 0; }
大型工程中,通常直接或间接地使用
Cmake或make进行编译和项目管理。
预处理器 (Preprocessor)
C++ 预处理器(Preprocessor)是 C++ 编译过程中的第一阶段。它负责处理源代码中所有以 # 开头的指令,在实际编译(生成目标代码)之前对源代码进行文本替换、文件包含、条件编译等操作。
一、预处理指令 (Directives)
预处理指令以 # 符号开头,并且占据一行。它们不是 C++ 语句,因此不需要以分号 (;) 结尾。
| 指令 | 目的 | 示例 |
|---|---|---|
#include | 文件包含。将指定文件的内容插入到当前位置。 | #include <iostream> |
#define | 宏定义。定义常量宏或函数宏。 | #define PI 3.14159 |
#undef | 取消宏定义。 | #undef PI |
#if / #elif / #else / #endif | 条件编译。根据条件决定是否编译某段代码。 | #if VERSION >= 2 |
#ifdef / #ifndef | 条件编译。检查宏是否已被定义。 | #ifndef DEBUG_MODE |
#error | 停止编译,并输出指定的错误信息。 | #error "Platform not supported" |
#warning | 输出指定的警告信息,继续编译(非标准,但广泛支持)。 | #warning "Legacy code detected" |
#pragma | 编译器特定的指令,用于向编译器发出特殊指令。 | #pragma once, #pragma warning |
二、宏定义 (#define)
宏定义是预处理器最重要的功能之一,它执行的是文本替换。
-
对象式宏 (Object-like Macros) - 定义常量
#define BUFFER_SIZE 1024 // 预处理后:所有 BUFFER_SIZE 都会被替换为 1024 -
函数式宏 (Function-like Macros) - 定义类似函数的替换
使用时注意:必须在参数和整个宏体周围加上括号,以避免运算符优先级问题。
#define SQUARE(x) ( (x) * (x) ) // 错误示例:#define SQUARE(x) x * x // SQUARE(1 + 2) -> 1 + 2 * 1 + 2 -> 5 (错误!) // 正确示例:SQUARE(1 + 2) -> ( (1 + 2) * (1 + 2) ) -> 9 (正确!)最佳实践: 除非必要,在 C++ 中应使用
const或constexpr变量代替对象式宏,使用 内联函数 (inline) 或 模板 代替函数式宏,以获得类型检查和更好的可读性。 -
宏的特殊操作符
-
#(字符串化运算符 / Stringizing Operator):将宏参数转换为字符串字面值。#define MESSAGE(x) #x // std::cout << MESSAGE(Hello World); -> 输出: Hello World -
##(连接运算符 / Token-Pasting Operator):连接两个宏标记(token),形成一个新的标记。#define GLUE(a, b) a ## b // int GLUE(my, Variable) = 10; -> int myVariable = 10;
-
三、文件包含 (#include)
用于将一个文件的内容插入到另一个文件中。
-
尖括号 (
<>):用于包含标准库头文件。编译器会在标准系统路径下查找。#include <iostream> -
双引号 (
""):用于包含用户自定义头文件。编译器首先在当前源文件目录查找,然后才在标准系统路径下查找(具体的查找路径取决于编译器设置)。#include "my_header.h"
四、条件编译 (Conditional Compilation)
条件编译允许根据预处理器定义的条件,决定是否将一段代码发送给编译器。这对于平台移植、调试开关和头文件保护至关重要。
-
头文件保护 (Header Guards) - 经典方式
用于防止头文件被重复包含,导致重定义错误。
#ifndef MY_HEADER_H // IF Not DEFined #define MY_HEADER_H // 头文件内容 #endif // MY_HEADER_H -
基于宏的条件
#define DEBUG_MODE // 开启调试模式 // ... #ifdef DEBUG_MODE // 如果 DEBUG_MODE 宏已定义,则编译以下代码 std::cout << "Debugging output." << std::endl; #endif #ifndef RELEASE_MODE // 如果 RELEASE_MODE 宏未定义,则编译以下代码 // ... #endif -
多分支条件
#define OS_LINUX 1 // 假设在 Linux 平台编译 #if OS_LINUX #include <unistd.h> #elif OS_WINDOWS #include <windows.h> #else #error "Unsupported OS!" #endif#if后面要求表达式求值为非零(真)或零(假)。表达式中未定义的宏会被当作零处理。defined运算符:#if defined(MACRO_NAME)与#ifdef MACRO_NAME效果相似,但defined可以用于更复杂的布尔表达式中,例如#if defined(A) && !defined(B)。
五、编译器特定指令 (#pragma)
#pragma 指令是一种特殊的预处理指令,用于向编译器提供特定于编译器或平台的指示。由于它们是非标准的(不属于 C++ 语言规范),因此其功能和语法可能在不同编译器之间有所不同。
-
#pragma once-
目的: 实现头文件保护,防止头文件被重复包含。
-
优点: 代码更简洁,不会污染全局宏命名空间,并且大多数现代编译器(GCC, Clang, MSVC 等)都支持且执行效率更高(编译器可以直接比较文件名或路径)。
-
用法: 放在头文件的最开始。
// my_header.h #pragma once struct MyData { /* ... */ }; -
对比
#ifndef: -
#ifndef是标准 C++ 的一部分,可移植性最高。 -
#pragma once是事实上的行业标准,使用更方便,但技术上非标准。在新的代码中通常推荐使用它。
-
-
#pragma warning(主要用于 MSVC)-
目的: 临时修改或控制编译器警告的行为。
-
关键语法:
-
#pragma warning( disable : 警告编号 ):禁用特定的警告。 -
#pragma warning( error : 警告编号 ):将特定的警告视为错误。 -
#pragma warning( push ):保存当前的警告状态到堆栈。 -
#pragma warning( pop ):恢复最近一次保存的警告状态。 -
用途示例: 临时禁用第三方库中产生的警告。
#pragma warning( push ) // 保存当前警告状态 #pragma warning( disable : 4100 ) // 禁用 MSVC 警告 C4100 (未使用的形参) void unused_param_func(int x) { // ... code that intentionally doesn't use x } #pragma warning( pop ) // 恢复之前的警告状态
GCC/Clang 对应功能: 它们通常使用
#pragma GCC diagnostic push/pop和#pragma GCC diagnostic ignored "-Wxxx"。 -
-
其它特定
#pragma#pragma pack(n):指定结构体成员的对齐字节数,常用于跨平台或与硬件交互。#pragma comment(...):向目标文件中插入注释信息或链接器指令(如链接特定的库文件)。
命名空间(Namespace)
一、概念与作用
- 定义:命名空间是声明性区域,它提供了一种将代码组织成逻辑组并避免命名冲突的机制。
- 作用:
- 将类型、函数、变量等标识符包含在一个有名称的范围(Scope)内。
- 在大型程序中,尤其当程序包含多个库时,有效解决名称冲突问题(例如,不同库中可能存在同名的函数或变量)。
二、命名空间声明
使用 namespace 关键字来声明一个命名空间。
// 文件: MyCode.h
namespace MyNamespace {
int value;
void FunctionA() {
// ...
}
class MyClass {
// ...
};
}
三、访问命名空间成员
要访问命名空间内的成员,需要使用范围解析运算符 ::。
方式一:完整限定名称(Fully Qualified Name)
在命名空间外部使用完整的名称来限定成员。
// 访问 MyNamespace 中的 value 和 FunctionA
MyNamespace::value = 10;
MyNamespace::FunctionA();
方式二:using 声明(using Declaration)
将命名空间中的特定名称引入当前作用域。
#include "MyCode.h"
// 引入 MyNamespace 中的 FunctionA
using MyNamespace::FunctionA;
void SomeOtherFunction() {
// 此时可以直接使用 FunctionA,而不需要 MyNamespace::
FunctionA();
// 访问 value 仍需限定
MyNamespace::value = 20;
}
方式三:using 指示词(using Directive)
将命名空间中的所有名称引入当前作用域。
#include "MyCode.h"
// 将 MyNamespace 中的所有名称引入
using namespace MyNamespace;
void AnotherFunction() {
// 此时可以直接使用所有成员
value = 30;
FunctionA();
}
最佳实践:
- 避免在头文件(
.h或.hpp)中使用using namespace指示词,因为这会将命名空间中的所有名称带入所有包含该头文件的文件中,可能导致难以调试的名称冲突或隐藏问题。- 在实现文件(
.cpp)的顶部或函数内部,可以使用using namespace指示词以简化代码。- 如果只使用一两个标识符,建议使用完整限定名称或
using声明。
四、命名空间的特性
1. 分散定义(Separated Definition)
命名空间可以在单个文件中的多个块中声明,也可以在多个文件中声明。编译器会将所有部分连接起来。
// 文件 A.h
namespace Utils {
void func1();
}
// 文件 B.h
namespace Utils {
void func2(); // 即使在不同文件,也属于同一个 Utils 命名空间
}
2. 全局命名空间(Global Namespace)
-
未在任何显式命名空间中声明的标识符属于隐式全局命名空间。
-
要显式访问全局命名空间中的标识符,可以使用没有名称的范围解析运算符
:::int globalVar = 100; namespace Local { int globalVar = 200; // 隐藏全局变量 void test() { int val1 = globalVar; // 使用 Local::globalVar (200) int val2 = ::globalVar; // 使用 全局命名空间 的 globalVar (100) } }
3. 嵌套命名空间(Nested Namespaces)
命名空间可以嵌套,C++17 引入了内联嵌套简化语法。
namespace Outer {
namespace Inner {
void innerFunc();
} // 结束 Inner
} // 结束 Outer
// 访问方式:
Outer::Inner::innerFunc();
// C++17 简化写法:
namespace Outer::Inner {
void anotherInnerFunc();
}
4. 匿名或未命名命名空间(Anonymous/Unnamed Namespaces)
声明一个没有名称的命名空间。
namespace {
int MyFunc() { return 0; }
int internalVar;
}
- 作用:未命名命名空间中的变量和函数只在当前编译单元(文件)内可见,它们具有内部链接(internal linkage),等同于在全局作用域中使用
static关键字。 - 用途:避免在其他文件中看到变量声明,替代老式的
static全局/函数定义。
5. 命名空间别名(Namespace Alias)
用于为过长的命名空间名称创建别名,以提高代码可读性和输入效率。
namespace VeryLongNamespaceName {
void func();
}
// 创建别名
namespace VLNN = VeryLongNamespaceName;
// 使用别名访问
VLNN::func();
五、std 命名空间
1. std 命名空间简介
- 标准库:
std是 Standard(标准)的缩写,是 C++ 标准库(Standard Library)中所有实体(Entities)的容器。 - 内容:所有 C++ 标准库提供的类、函数、变量、模板等都被声明在
std命名空间内。- 常见的例子包括:
- 输入/输出:
std::cout,std::cin,std::endl - 字符串:
std::string - 容器:
std::vector,std::map,std::list - 算法:
std::sort,std::find - 其他:
std::unique_ptr,std::exception等。
- 输入/输出:
- 常见的例子包括:
2. 使用 std 成员
由于标准库的所有内容都在 std 命名空间内,因此需要使用前面介绍的三种方式来访问它们。
示例:完整限定名称(推荐)
这是最安全、最清晰的方式,尤其是在头文件和库代码中。
#include <iostream>
void print_message() {
// 每次使用都需要前缀 std::
std::cout << "Hello, C++ World!" << std::endl;
}
示例:using 声明
将特定的标准库成员引入当前作用域。
#include <iostream>
#include <vector>
using std::cout;
using std::vector;
void process_data() {
// 可以直接使用 cout 和 vector
cout << "Processing data..." << '\n';
vector<int> numbers = {1, 2, 3};
// 但 std::endl 仍需限定
cout << "Done." << std::endl;
}
示例:using 指示词
将整个 std 命名空间引入当前作用域。
#include <iostream>
// 将所有 std:: 成员引入
using namespace std;
void dangerous_example() {
// 可以直接使用 cout 和 endl
cout << "This is quick but potentially risky." << endl;
// 风险:如果用户定义了名为 'cout' 的变量,则会发生命名冲突或名称隐藏。
// int cout = 100; // 错误:在某些编译器上可能导致二义性或隐藏 std::cout
}
3. C 库头文件的兼容性
C++ 标准库也包含了 C 语言的标准库函数(如 printf, malloc 等)。
- C++ 风格:使用
c前缀和不带.h后缀的头文件(例如,<cmath>对应 C 语言的<math.h>)。- 在 C++ 风格的头文件中,所有函数和类型都位于
std::命名空间中。
- 在 C++ 风格的头文件中,所有函数和类型都位于
- C 风格(兼容性):使用带
.h后缀的头文件(例如,<math.h>)。- 为了兼容性,在 C 风格的头文件中,函数和类型通常同时位于全局命名空间和
std::命名空间中。
- 为了兼容性,在 C 风格的头文件中,函数和类型通常同时位于全局命名空间和
| 语言 | 头文件 | 函数位置 | 示例 |
|---|---|---|---|
| C | <stdlib.h> | 全局命名空间 | malloc(...) |
| C++ | <cstdlib> | std:: 命名空间 | std::malloc(...) |
| C++ (兼容性) | <stdlib.h> | 全局 + std:: | malloc(...) 或 std::malloc(...) |
C++ 程序的编译与运行(Compilation and Execution)
引言
C++ 是一种 编译型语言(compiled language)。
这意味着在执行程序之前,必须先将源代码 (.cpp) 转换为计算机可以理解的机器指令(可执行文件)。
整个过程通常包括以下几个阶段:
源代码 (.cpp)
↓
预处理(Preprocessing)
↓
编译(Compilation)
↓
汇编(Assembly)
↓
链接(Linking)
↓
可执行文件(Executable)
↓
运行(Execution)
编译过程的四个主要阶段
| 阶段 | 作用 | 示例命令 | 输出文件 |
|---|---|---|---|
| 预处理(Preprocessing) | 展开宏、包含头文件、删除注释 | g++ -E main.cpp -o main.i | main.i |
| 编译(Compilation) | 将预处理后的代码转换为汇编代码 | g++ -S main.i -o main.s | main.s |
| 汇编(Assembly) | 将汇编代码转换为机器指令(目标文件) | g++ -c main.s -o main.o | main.o |
| 链接(Linking) | 将多个目标文件与库链接生成可执行文件 | g++ main.o -o main | main |
可以通过这些命令观察 C++ 源码在每个阶段的输出,理解“编译器到底做了什么”。
完整编译命令示例
最常用的编译命令只需一行:
g++ main.cpp -o main
g++:GNU C++ 编译器(GCC 的 C++ 前端)main.cpp:源文件-o main:指定输出文件名(不写则默认为a.out)
运行程序:
./main
输出:
Hello, World!
多文件编译示例
大型项目通常由多个源文件组成:
project/
├── main.cpp
├── add.cpp
└── add.h
编译方式一:一步完成
g++ main.cpp add.cpp -o main
编译方式二:分步编译
g++ -c main.cpp -o main.o
g++ -c add.cpp -o add.o
g++ main.o add.o -o main
分步编译的好处是:当部分文件修改时,只需重新编译变动的部分,而不是整个项目。
运行机制详解
C++ 程序编译完成后,会生成一个可执行文件(如 main 或 main.exe)。
程序的运行流程如下:
操作系统加载可执行文件
↓
执行程序的入口函数 main()
↓
程序逻辑执行
↓
return 返回值传递给操作系统
返回值 0 通常表示正常退出,非零值表示错误或异常终止。
编译器与构建工具
| 工具 | 说明 | 适用平台 |
|---|---|---|
| g++ | GNU 编译器套件中的 C++ 编译器 | Linux / macOS / Windows(MinGW) |
| clang++ | LLVM 项目的 C++ 编译器,语法检查友好 | 跨平台 |
| MSVC (cl.exe) | 微软的 C++ 编译器 | Windows |
| CMake | 构建系统生成工具,可跨平台生成编译脚本 | 所有平台 |
| Makefile | 编译自动化脚本,用于管理依赖 | Linux / macOS |
使用 IDE 进行编译运行
常见 IDE
| IDE | 编译器 | 特点 |
|---|---|---|
| Visual Studio | MSVC | Windows 下功能最全 |
| CLion | CMake + g++/clang++ | 跨平台支持强 |
| VS Code | 可结合 g++、clang++ | 轻量灵活 |
| Qt Creator | qmake / CMake | 适用于图形界面开发 |
在 IDE 中执行的隐含步骤
当你点击“运行(Run)”时,IDE 实际执行了以下操作:
- 调用编译器生成目标文件;
- 执行链接步骤生成可执行程序;
- 启动该可执行文件并显示输出。
常见编译选项
| 选项 | 含义 | 示例 |
|---|---|---|
-o <file> | 指定输出文件名 | g++ main.cpp -o app |
-Wall | 启用所有警告信息 | g++ -Wall main.cpp |
-g | 生成调试信息 | g++ -g main.cpp |
-O2 | 优化等级 2,提高执行效率 | g++ -O2 main.cpp |
-std=c++17 | 指定 C++ 标准版本 | g++ -std=c++17 main.cpp |
-I <dir> | 添加头文件搜索路径 | g++ -I include main.cpp |
-L <dir> | 添加库文件路径 | g++ -L lib -lmylib main.cpp |
-D <macro> | 定义宏 | g++ -DDEBUG main.cpp |
扩展
-
交叉编译 (Cross Compilation):在一台机器上为另一架构(如 ARM、RISC-V)生成可执行程序。 示例:
riscv64-linux-gnu-g++ main.cpp -o main_rv -
静态链接 vs 动态链接:
- 静态链接 (
.a):在编译时嵌入库文件。 - 动态链接 (
.so/.dll):运行时动态加载库。
- 静态链接 (
编译与链接
C++ 程序从源代码到可执行文件要经过预处理、编译、汇编、链接四个阶段。理解这个过程,才能解释许多看似奇怪的现象:为什么头文件要加 include guard、为什么模板定义要放在头文件里、为什么会出现「未定义引用」错误。
一、编译流程
源文件 (.cpp)
↓ 预处理(Preprocessing)
预处理后的源文件 (.i)
↓ 编译(Compilation)
汇编代码 (.s)
↓ 汇编(Assembly)
目标文件 (.o / .obj)
↓ 链接(Linking)
可执行文件
| 阶段 | 工具 | 主要工作 |
|---|---|---|
| 预处理 | cpp | 展开 #include、替换宏、处理条件编译 |
| 编译 | cc1plus | 语法分析、语义检查、生成汇编代码 |
| 汇编 | as | 把汇编代码翻译成机器码 |
| 链接 | ld | 合并目标文件,解析符号引用,生成可执行文件 |
用 GCC 可以分步执行:
g++ -E main.cpp -o main.i # 只做预处理
g++ -S main.i -o main.s # 编译为汇编
g++ -c main.s -o main.o # 汇编为目标文件
g++ main.o -o main # 链接
日常使用的 g++ main.cpp -o main 会依次完成全部四步。
二、声明与定义
这是 C++ 中最基本也最重要的区分:
| 声明 (Declaration) | 定义 (Definition) | |
|---|---|---|
| 作用 | 告诉编译器「有这么个东西」 | 告诉编译器「它是什么」 |
| 是否分配内存 | 否 | 是(函数则是提供函数体) |
| 能否重复 | 可以多次 | 只能一次(ODR) |
// 声明
extern int global_var; // 变量声明
int add(int a, int b); // 函数声明
// 定义
int global_var = 42; // 变量定义
int add(int a, int b) { // 函数定义
return a + b;
}
类定义、inline 函数、模板定义是例外——它们可以出现在多个翻译单元中。
三、单一定义规则(ODR)
ODR(One Definition Rule) 规定:
- 任何变量、函数、类类型、枚举在整个程序中只能有一个定义
- 同一个翻译单元内不能有重复定义
- 不同翻译单元中的定义必须完全相同(对于类、
inline函数、模板)
违反 ODR 属于未定义行为——编译器可能不报错,但链接时或运行时出现奇怪问题。
常见的 ODR 违规:
// header.h
int global_value = 42; // 错误:这是定义,被多个 .cpp 包含会重复定义
正确做法是加 extern 声明,在一个 .cpp 中定义:
// header.h
extern int global_value; // 声明
// one.cpp
int global_value = 42; // 定义(只此一处)
四、头文件
1. 为什么要用头文件
C++ 采用分离编译模型:每个 .cpp 独立编译成目标文件。如果多个文件都要用同一个函数,就需要一种机制共享声明——这就是头文件。
// math_utils.h —— 声明
#pragma once
int add(int a, int b);
int multiply(int a, int b);
// math_utils.cpp —— 定义
#include "math_utils.h"
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
// main.cpp —— 使用
#include "math_utils.h"
int main() {
return add(1, 2);
}
2. Include Guard
头文件可能被间接包含多次,导致重复定义。两种防护方式:
// 方式一:include guard(标准做法)
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
// 内容
#endif
// 方式二:#pragma once(非标准但广泛支持)
#pragma once
// 内容
#pragma once 更简洁,且能避免宏名冲突;include guard 是标准做法,可移植性更好。两者都可以用,但不要混用。
3. 头文件里应该放什么
| 可以放 | 不应放 |
|---|---|
| 函数声明 | 函数的普通定义(会重复定义) |
| 类定义 | 非 inline 的全局变量定义 |
inline 函数定义 | — |
| 模板定义 | — |
constexpr 变量 | — |
| 宏定义、类型别名 | — |
模板和 inline 函数必须放在头文件,因为编译器在实例化时需要看到完整定义。
五、链接
链接器的工作是解析符号引用:把每个目标文件中「引用了但未定义」的符号,与其它目标文件中「定义了」的符号对应起来。
1. 静态库与动态库
| 静态库 | 动态库 | |
|---|---|---|
| Linux 后缀 | .a | .so |
| Windows 后缀 | .lib | .dll |
| 链接时机 | 编译期,代码被复制进可执行文件 | 运行期,动态加载 |
| 可执行文件大小 | 较大 | 较小 |
| 更新库 | 需重新编译程序 | 替换库文件即可 |
| 部署 | 简单,单文件 | 需附带库文件 |
# 创建静态库
g++ -c lib.cpp -o lib.o
ar rcs libmylib.a lib.o
g++ main.cpp -L. -lmylib -o main
# 创建动态库
g++ -fPIC -c lib.cpp -o lib.o
g++ -shared lib.o -o libmylib.so
g++ main.cpp -L. -lmylib -o main
2. 常见链接错误
「未定义引用」(undefined reference):声明了但没定义。
undefined reference to `add(int, int)'
原因通常是:忘记链接某个源文件或库、函数签名不匹配、模板定义没放在头文件。
「重复定义」(multiple definition):同一个符号在多个目标文件中都有定义。
multiple definition of `global_value'
原因通常是:在头文件中定义了变量或非 inline 函数。
六、inline 与 static 的链接语义
这两个关键字都影响链接行为,但方式不同。
1. inline
inline 最初用于建议编译器内联展开,现代 C++ 中它的主要作用是允许在多个翻译单元中重复定义:
// header.h
inline int square(int x) { return x * x; } // 多个 .cpp 包含也没问题
编译器会保证最终只保留一份。因此 inline 函数可以放在头文件中。
C++17 起还支持 inline 变量:
// header.h
inline int global_counter = 0; // 头文件中定义变量,C++17 起可行
2. static
static 在 C++ 中有多重含义,取决于使用位置:
| 位置 | 含义 |
|---|---|
| 全局变量/函数前 | 内部链接:只在当前翻译单元可见 |
| 局部变量前 | 静态存储期:函数退出后仍存在 |
| 类成员前 | 属于类而非对象 |
内部链接意味着同名符号在不同 .cpp 中互不冲突:
// a.cpp
static int counter = 0; // 只在本文件可见
// b.cpp
static int counter = 0; // 另一个独立的变量,不冲突
Warning
头文件中的
static是陷阱在头文件中定义
static函数或变量,每个包含它的.cpp都会得到独立的一份副本:// header.h static int counter = 0; // 每个 .cpp 各有一份!这会导致内存浪费,且修改一个副本不会影响其它。应该用
inline(C++17)或extern声明。
七、条件编译
预处理指令可以根据条件选择性地编译代码:
// 平台判断
#ifdef _WIN32
#include <windows.h>
#elif defined(__linux__)
#include <unistd.h>
#endif
// 调试开关
#ifdef DEBUG
#define LOG(msg) std::cout << msg << '\n'
#else
#define LOG(msg)
#endif
// 版本判断
#if __cplusplus >= 202002L
// C++20 及以上
#endif
标准库提供了 __cplusplus 宏表示语言版本(C++17 为 201703L,C++20 为 202002L)。
八、注意事项
- 头文件必须加 include guard。否则重复包含会导致重复定义。
- 变量定义不要放在头文件。应使用
extern声明 + 单处定义,或 C++17 的inline变量。 - 模板和
inline函数必须放头文件。编译器实例化时需要看到完整定义。 static在头文件中会复制。每个翻译单元各一份,通常不是想要的效果。c_str()和data()的指针会失效。容器修改后指针可能悬垂。- ODR 违规是未定义行为。编译器可能不报错,问题在链接期或运行期才暴露。
#pragma once非标准。虽然广泛支持,但严格可移植的代码应用 include guard。- 编译单元是独立编译的。修改一个
.cpp只需重新编译它,这是增量编译的基础。
九、相关章节
C++ 注释(Comments)
1. 概述
注释用于解释程序逻辑、记录设计思路、说明接口功能或在调试时暂时屏蔽代码。 C++ 编译器在编译阶段会忽略注释内容,它们不会出现在目标文件中。 良好的注释能显著提升代码的可读性与可维护性。
2. 注释的基本类型
C++ 提供两种常见的注释形式:
2.1 单行注释(Single-line Comment)
以 // 开头,直到行尾结束。
// 输出欢迎信息
std::cout << "Hello, World!" << std::endl;
// 在行末添加说明
int count = 10; // 循环次数
适用于简短说明或临时调试。
2.2 多行注释(Multi-line Comment)
以 /* 开始,以 */ 结束,可以跨越多行。
/*
此部分用于初始化程序资源。
包括:
1. 配置加载
2. 内存分配
3. 日志系统启动
*/
initialize_system();
注意:多行注释 不支持嵌套,即不能在 /* ... */ 内再次使用 /* ... */。
3. 注释编写原则
- 准确:注释必须与代码逻辑一致。
- 必要:解释“为什么这样做”,而非“代码做了什么”。
- 简洁:避免冗长或与代码重复的描述。
- 及时:修改代码时应同步更新注释。
- 一致:保持统一的注释风格与格式。
示例:
// 不推荐:重复代码逻辑
int x = x + 1; // x 加 1
// 推荐:说明设计目的
// 自增以保持计数连续性
x++;
4. 文档化注释(Documentation Comments)
在较大的工程中,可以通过特殊格式的注释自动生成接口文档。 常用工具为 Doxygen,支持从源码中提取函数、类、参数等说明信息。
4.1 格式示例
/**
* @brief 计算两个整数的和
* @param a 第一个整数
* @param b 第二个整数
* @return 两数之和
*/
int add(int a, int b) {
return a + b;
}
或者:
/// 计算矩形面积
/// @param width 宽度
/// @param height 高度
/// @return 面积
double area(double width, double height);
4.2 常用标签
| 标签 | 含义 | 示例 |
|---|---|---|
@brief | 简要说明 | @brief 初始化系统资源 |
@param | 参数说明 | @param path 配置文件路径 |
@return | 返回值说明 | @return 初始化是否成功 |
@note | 备注说明 | @note 必须在主线程调用 |
@warning | 警告信息 | @warning 该函数非线程安全 |
@todo | 待办事项 | @todo 增加异常处理 |
5. 实际使用规范
在团队开发或课程项目中,建议采用以下注释习惯:
- 文件头部:说明文件用途、作者、日期。
- 函数前:使用文档注释描述功能、参数与返回值。
- 代码内部:仅在逻辑复杂处添加必要注释。
- 调试阶段:临时屏蔽代码时使用
//,不保留旧逻辑。
示例:
/**
* @file user_manager.cpp
* @brief 用户管理模块
* @author Ding
* @date 2025-10-13
*/
/// 初始化用户系统
/// @return 初始化是否成功
bool init_user_system() {
// TODO: 支持从配置文件加载默认用户
return true;
}
6. 调试与维护中的注释
-
调试时临时屏蔽代码
// 禁用日志输出 // std::cout << "Debug info" << std::endl; -
避免保留废弃代码
// 不推荐:应使用版本控制系统管理 // old_function();
命名规则 (Naming Conventions)
命名规则是代码风格的重要组成部分,它能显著提高代码的可读性、可维护性和团队协作效率。在 C++ 社区中,存在多种主流命名规范(如 Google Style、LLVM Style)。
核心原则
-
一致性 (Consistency): 在一个项目或代码库中,一旦选定了一种命名风格,就必须始终如一地使用它,避免混用多种风格导致的混乱。
-
描述性 (Descriptive): 名称应清晰地描述其所代表的实体的用途或含义,便于其他开发者理解代码。
-
避免歧义 (Avoid Ambiguity): 避免使用容易引起误解的缩写或模糊单词,例如
tmp、data等,必要时增加上下文信息。
常用命名风格
| 风格名称 | 描述 | 示例 |
|---|---|---|
| PascalCase / 大驼峰 | 每个单词首字母大写,不使用分隔符。通常用于类型名称。 | MyClassName |
| camelCase / 小驼峰 | 第一个单词首字母小写,其余单词首字母大写。通常用于变量和函数。 | calculateSum |
| snake_case / 下划线 | 所有字母小写,单词之间用下划线 _ 分隔。 | current_balance |
| UPPER_CASE / 全大写 | 所有字母大写,单词之间用下划线 _ 分隔。通常用于常量或宏。 | MAX_SIZE |
命名实体规范
| 实体类型 | 推荐风格 | 示例 | 备注 |
|---|---|---|---|
| 类 (Class) | PascalCase | Customer, FileManager | 类名通常是名词。 |
| 结构体 (Struct) | PascalCase | Point2D, StudentInfo | 与类一致,都是用户自定义类型。 |
| 函数/方法 (Function) | camelCase | calculateArea(), getFileSize() | 通常是动词或动宾短语,表示行为。 |
| 局部变量 (Local Variable) | camelCase | age, totalCount, isValid | 保持简洁、描述清晰。 |
| 成员变量 (Member Variable) | camelCase + 后缀或前缀 | m_value 或 value_ | 用于区分成员变量和局部变量,提高可读性。 |
| 常量 (Constants) | UPPER_CASE | MAX_ITEMS, PI_VALUE | 用于 #define 或 const/constexpr 编译期常量。 |
| 枚举 (Enums/Enum Class) | PascalCase | ColorType | 枚举类型使用 PascalCase。 |
| 枚举值 (Enum Values) | PascalCase 或 UPPER_CASE | Red, Green 或 COLOR_RED, COLOR_GREEN | 建议加类型前缀避免冲突。 |
| 命名空间 (Namespace) | snake_case 或 全小写 | my_project, network | 命名空间通常是项目名或模块名。 |
| 宏 (Macros) | UPPER_CASE | #define DEBUG_MODE | 宏是全局预处理符号,必须与变量/常量区分。 |
命名限制与最佳实践
1. C++ 保留标识符
C++ 标准保留了某些名称供编译器和标准库使用,用户代码禁止使用:
1.1 全局范围(Global Scope)保留
- 包含两个连续下划线
__的名称 示例:__myVar,My__Class - 以下划线
_开头,紧跟大写字母 示例:_PrivateMember,_Max_Value
1.2 全局命名空间(Global Namespace)保留
- 以下划线
_开头的名称 示例:_name,_a_local_var在局部作用域内可能安全,但全局范围内使用会违反标准。最佳实践:避免任何下划线开头的自定义名称。
1.3 关键字 (Keywords)
绝对禁止使用 C++ 关键字作为标识符:
| C++ 关键字(部分) | |||
|---|---|---|---|
alignas | decltype | if | return |
auto | do | inline | short |
bool | double | int | sizeof |
break | else | long | struct |
case | enum | namespace | switch |
char | extern | new | template |
class | float | private | this |
const | for | public | try |
continue | goto | register | void |
default | thread_local | virtual | while |
C++11/14/17/20 新增:
constexpr,noexcept,override,concept
2. 命名技巧与长度
-
避免单字母名称:除循环迭代器(
i, j, k)和数学变量(x, y, z)外,应使用描述性名称。差:
int s;好:int studentCount; -
避免匈牙利命名法:现代 C++ 不推荐通过前缀标注类型,如
iAge、szName。类型信息已由编译器和 IDE 提供。 -
布尔值命名:布尔变量或函数应以动词开头,清晰表达状态。 示例:
bool isValid;,bool hasError();,bool isFinished; -
Getter/Setter 命名:遵循约定提高可读性。
- Getter:
getValue()或value()(变量名value_) - Setter:
setValue(int newValue)或set_value(int newValue)
- Getter:
3. 文件命名规范
| 风格 | 头文件 (.h) | 源文件 (.cpp) | 备注 |
|---|---|---|---|
| PascalCase | FileManager.h | FileManager.cpp | 常用于 Qt 等 IDE/框架,与类名一致 |
| snake_case | file_manager.h | file_manager.cc | Google 风格推荐,更便于跨平台和工具链使用 |
头文件保护 (Header Guards):确保文件内容只被编译一次。推荐使用 全大写 + 项目/目录/文件名 的宏。
// network/tcp_socket.h
#ifndef MYPROJECT_NETWORK_TCP_SOCKET_H
#define MYPROJECT_NETWORK_TCP_SOCKET_H
// ... 代码 ...
#endif // MYPROJECT_NETWORK_TCP_SOCKET_H
示例代码
// 1. 类型 (PascalCase)
class EmployeeManager {
private:
// 2. 成员变量 (推荐使用后缀,避免保留标识符)
int employee_count_;
// 错误示范(应避免)
// int __bad_var; // 双下划线
// int _AnotherBadVar; // 下划线开头 + 大写
// int _safe_in_local_scope; // 全局范围不安全
public:
// 3. 函数/方法 (camelCase)
bool isValidEmployee(const std::string& name) {
// 4. 局部变量 (camelCase)
int maxHours = 40;
if (this->employee_count_ < maxHours) { // 使用 this-> 或后缀区分成员
return true;
}
return false;
}
};
// 5. 宏常量 (UPPER_CASE)
#define MAX_BUFFER_SIZE 1024
数据类型
在 C++ 中,数据类型(Data Types) 是程序设计的基础,它定义了变量在内存中的存储方式、可表示的取值范围以及能进行的操作。 理解数据类型不仅是掌握语法的关键,也是进行性能优化、内存管理和类型安全编程的前提。
C++ 的类型系统丰富而严格,既包含直接映射到硬件的基本类型(Fundamental Types),也支持由这些类型构建的派生类型(Derived Types),此外还提供了面向对象与泛型编程所需的抽象类型系统。
本章将系统介绍 C++ 中的数据类型体系结构,内容包括:
-
基本数据类型: 描述整型、浮点型、布尔型、空类型等语言内建类型及其修饰符(
signed、unsigned、long等)。 -
派生数据类型: 介绍数组、指针、引用、函数、结构体、类、枚举等由基本类型组合或扩展而来的类型。
-
字符串类型: 涵盖 C 风格字符串 (
char[]) 与 C++ 字符串类 (std::string、std::wstring) 的使用与区别。 -
类型转换: 探讨隐式与显式类型转换、C 风格转换与 C++ 四种安全转换(
static_cast、const_cast、reinterpret_cast、dynamic_cast)。 -
类型推导: 说明
auto、decltype、模板参数推导等机制如何简化类型声明并保持类型安全。
基本数据类型
C++ 基本数据类型
| 数据类型 | 描述 | 大小(字节) | 范围/取值示例 |
|---|---|---|---|
bool | 布尔类型,表示真或假 | 1 | true 或 false |
char | 字符类型,通常用于存储 ASCII 字符 | 1 | -128 到 127 或 0 到 255(取决于有符号或无符号) |
signed char | 有符号字符类型 | 1 | -128 到 127 |
unsigned char | 无符号字符类型 | 1 | 0 到 255 |
wchar_t | 宽字符类型,用于存储 Unicode 字符 | 2 或 4 | 取决于平台,通常 2 或 4 字节 |
char16_t | 16 位 Unicode 字符类型(C++11) | 2 | 0 到 65,535 |
char32_t | 32 位 Unicode 字符类型(C++11) | 4 | 0 到 4,294,967,295 |
short | 短整型 | 2 | -32,768 到 32,767 |
unsigned short | 无符号短整型 | 2 | 0 到 65,535 |
int | 整型 | 4 | -2,147,483,648 到 2,147,483,647 |
unsigned int | 无符号整型 | 4 | 0 到 4,294,967,295 |
long | 长整型 | 4 或 8 | 取决于平台 |
unsigned long | 无符号长整型 | 4 或 8 | 取决于平台 |
long long | 长长整型(C++11) | 8 | -9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 |
unsigned long long | 无符号长长整型(C++11) | 8 | 0 到 18,446,744,073,709,551,615 |
float | 单精度浮点数 | 4 | 约 ±3.4e±38(6-7 位有效数字) |
double | 双精度浮点数 | 8 | 约 ±1.7e±308(15 位有效数字) |
long double | 扩展精度浮点数 | 8、12 或 16 | 取决于平台 |
C++ 修饰符
| 修饰符 | 描述 | 示例 |
|---|---|---|
signed | 有符号类型(默认) | signed int x = -10; |
unsigned | 无符号类型 | unsigned int y = 10; |
short | 短整型 | short int z = 100; |
long | 长整型 | long int a = 100000; |
static | 静态存储期,或内部链接,或类级别共享 | static int count = 0; |
const | 常量,值不可修改 | const int b = 5; |
constexpr | 编译期常量,值在编译时计算,可用于常量表达式和元编程 | constexpr int size = 10; |
volatile | 变量可能被意外修改,禁止编译器优化 | volatile int c = 10; |
mutable | 类成员可以在 const 对象中修改 | mutable int counter; |
extern | 声明一个在其他源文件中定义的变量或函数,用于跨文件共享全局符号 | extern int global_var; |
register | 建议编译器将变量存放在 CPU 寄存器中以提高访问速度(现代编译器多已自动优化) | register int counter = 0; |
C++11 新增数据类型
| 数据类型 | 描述 | 示例 |
|---|---|---|
auto | 自动类型推断 | auto x = 10; |
decltype | 获取表达式的类型 | decltype(x) y = 20; |
nullptr | 空指针常量 | int* ptr = nullptr; |
std::initializer_list | 初始化列表类型 | std::initializer_list<int> list = {1, 2, 3}; |
std::tuple | 元组类型,可以存储多个不同类型的值 | std::tuple<int, float, char> t(1, 2.0, 'a'); |
说明
宽字符类型 (wchar_t)
wchar_t 是 C++ 中用于存储宽字符的类型,广泛应用于需要处理 Unicode 字符集的程序中。与普通的 char 类型(通常用于存储 ASCII 字符)不同,wchar_t 的设计目的是为了支持更大的字符集,特别是 Unicode。由于 wchar_t 需要存储更多的字符信息,因此其大小取决于平台,通常在 2 或 4 字节之间。在一些平台上,wchar_t 被定义为 2 字节(16 位),在其他平台上则可能是 4 字节(32 位)。使用 wchar_t 可以轻松处理如中文、日文等非拉丁字符。
#include <iostream>
int main() {
wchar_t wide_char = L'我'; // 使用 wchar_t 存储一个 Unicode 字符
std::wcout << wide_char << std::endl; // 输出:我
return 0;
}
char16_t 和 char32_t
char16_t 和 char32_t 是 C++11 引入的专门用于存储 Unicode 字符的类型,分别表示 16 位和 32 位字符类型。char16_t 是为了支持 UTF-16 编码而设计的,而 char32_t 是为了支持 UTF-32 编码。char16_t 用 2 字节来存储一个字符,而 char32_t 用 4 字节存储一个字符。这两种类型能够直接表示 Unicode 字符,而无需进行额外的编码转换。
char16_t 和 char32_t 提供了对更广泛字符集的支持,尤其适合那些需要处理全球化文本的应用程序。char16_t 和 char32_t 作为 Unicode 字符的表示方式,分别与 UTF-16 和 UTF-32 编码兼容,能够表示包括基本多语言平面(BMP)以及更高平面字符在内的所有 Unicode 字符。
#include <iostream>
int main() {
char16_t char16 = u'你'; // 使用 char16_t 存储一个 UTF-16 编码的字符
char32_t char32 = U'你'; // 使用 char32_t 存储一个 UTF-32 编码的字符
std::wcout << "char16_t: " << char16 << std::endl;
std::wcout << "char32_t: " << char32 << std::endl;
return 0;
}
volatile
在现代 CPU 中通常包含多个核心,每个核心都有独立的缓存。多个核心可能同时缓存了同一段主存的数据。一般情况下,缓存和主存的数据是一致的,但在多线程并发场景下,由于缓存写回策略的影响,数据的修改可能无法及时同步到主存,从而导致数据不一致的问题。
volatile 关键字用于告诉编译器:某个变量的值可能随时被外部因素(如其他线程、硬件设备或中断)修改,因此缓存中的值并不可靠。出于这个原因,编译器在访问该变量时不会进行过度优化,而是强制每次都从内存中读取最新的值。
-
可见性
volatile保证变量的值在不同线程或不同硬件环境下始终是“最新可见”的。即使某个核心或寄存器中有缓存数据,访问volatile变量时也必须直接从内存或硬件中获取,而不是使用缓存副本。 -
不可优化 在编译阶段,为了提高执行效率,编译器会进行多种优化。例如:
int flag = 0; while (flag == 0) { // 等待 flag 改变 }若
flag未被声明为volatile,编译器可能认为flag始终等于 0,于是直接将循环优化为while(false),导致死循环。使用
volatile可以禁止类似的优化,包括 消除优化、传播优化 和 合并优化,从而保证变量的读写行为不会被错误简化。 -
顺序性
volatile还会在一定程度上影响指令的顺序。编译器在处理volatile变量时,会保证读写操作不会因乱序优化而颠倒,从而维持必要的执行顺序。
volatile 在嵌入式编程和多线程编程中尤为重要:
- 多线程共享变量:确保不同线程读取到的值保持一致。
- 中断处理:中断可能随时修改某个变量,主程序通过
volatile保证能正确检测到变化。 - 硬件寄存器访问:在嵌入式系统中,硬件寄存器的值可能在后台自动更新,必须通过
volatile确保每次访问都直接读取硬件寄存器的当前值。
尽管 volatile 在保证可见性、防止编译器优化、维持一定顺序性方面很有用,但它并不是并发编程的“万能钥匙”,主要局限性如下:
-
不保证原子性
-
volatile仅保证读写操作不会被优化和缓存,但不能保证复合操作的原子性。 -
例如:
volatile int counter = 0; counter++; // 实际分解为:读取 -> 修改 -> 写回在多线程环境下可能发生竞态条件,导致结果错误。
-
-
不等同于内存屏障(Memory Barrier)
volatile的“顺序性”只作用于编译器层面,防止指令在编译时被重排。- 但在 CPU 的指令执行层面,仍然可能发生硬件乱序执行。若需要在多线程同步中严格保证内存访问顺序,还需要使用更强的同步原语(如 C++ 中的
std::atomic或内存屏障指令)。
-
性能开销
- 每次访问
volatile变量都要从内存中读取最新值,无法使用寄存器缓存,可能造成一定性能损失。
- 每次访问
-
局限于特定场景
-
volatile更适合用于:- 标志位(如中断标志、任务完成标志)。
- 硬件寄存器访问。
-
但在复杂的多线程共享数据同步场景下,仅依赖
volatile是不够的,往往需要互斥锁、原子操作或更高级的同步机制。
-
mutable
mutable 关键字是用来修饰类的成员变量的,意味着即使该对象是常量(const),这些成员变量也可以被修改。通常,mutable 用于那些希望在 const 方法中进行修改的成员变量,比如用于缓存的成员变量。通过 mutable,我们可以在 const 方法中修改这些成员,而不会破坏 const 对象的常量性。
以下示例展示了在 const 方法中修改 mutable 成员变量的情况:
class MyClass {
public:
mutable int cache; // 使用 mutable 修饰的成员变量
MyClass() : cache(0) {}
void updateCache() const {
// 即使 updateCache 是 const 方法,cache 依然可以被修改
cache++;
}
};
int main() {
const MyClass obj;
obj.updateCache(); // 可在 const 对象上调用
std::cout << obj.cache << std::endl; // 输出:1
return 0;
}
decltype
decltype 是 C++11 引入的一个关键字,用于获取表达式的类型,而不需要显式地声明变量的类型。它通常用于模板编程中,或者当我们不确定某个表达式的类型时。decltype 可以非常方便地获取复杂类型,尤其是当类型通过复杂的表达式推导出来时。
例如,以下代码通过 decltype 获取了变量 x 的类型,并且通过 auto 使得代码更加简洁:
int x = 5;
decltype(x) y = 10; // y 的类型是 int,因为 x 是 int
auto z = x + y; // z 的类型由编译器推断,类型为 int
std::initializer_list
std::initializer_list 是 C++11 引入的一个模板类,用于支持初始化列表。它允许在创建对象时通过花括号 {} 来传递多个值。initializer_list 主要用于支持类的构造函数接收不定数量的参数,或者将多个值传递给函数,特别适用于那些需要接收多个初始值的容器类型。
#include <initializer_list>
#include <iostream>
void printList(std::initializer_list<int> list) {
for (auto i : list) {
std::cout << i << " ";
}
std::cout << std::endl;
}
int main() {
printList({1, 2, 3, 4, 5}); // 使用 initializer_list
return 0;
}
std::tuple
std::tuple 是 C++11 引入的一个模板类,允许存储多个不同类型的元素。与数组和 std::vector 不同,tuple 允许每个元素拥有不同的类型。std::tuple 是一个非常强大的工具,可以将多个不同类型的值打包在一起,并在需要时访问这些值。它常用于函数返回多个不同类型的值,或在需要将多种类型的参数组合起来时使用。
#include <tuple>
#include <iostream>
int main() {
std::tuple<int, double, char> t(1, 3.14, 'A');
std::cout << std::get<0>(t) << ", "; // 1
std::cout << std::get<1>(t) << ", "; // 3.14
std::cout << std::get<2>(t) << std::endl; // A
// 修改 tuple 的元素
std::get<0>(t) = 42;
std::cout << std::get<0>(t) << std::endl; // 42
return 0;
}
static
static 关键字在 C++ 中有三种主要用途,取决于它所修饰的对象和作用域,而在C语言中由于不支持类从而只支持修饰局部静态变量和外部静态变量、函数。
1. 局部变量(函数内)- 改变存储期
当 static 用于函数内的局部变量时,它改变了变量的存储期(Storage Duration)。
- 存储期:
static局部变量在程序运行期间只会被初始化一次,且生命周期与整个程序相同(静态存储期),但其作用域仍限定在定义它的函数内部。 - 用途:用于记录函数被调用的次数,或者在多次调用中保持某个状态。
void func() {
static int count = 0; // 只在程序启动时初始化一次
count++;
std::cout << "Count: " << count << std::endl;
}
// 每次调用 func(),count 都会递增,而不是重置为 0
2. 全局变量和函数(文件作用域)- 改变链接性
当 static 用于全局变量或普通函数时,它改变了它们的链接性(Linkage)。
- 链接性:将默认的外部链接(External Linkage,可以在其他源文件访问)改为内部链接(Internal Linkage)。
- 用途:使变量或函数只在其定义的**当前翻译单元(源文件)**中可见和可用,避免与其他源文件中的同名标识符发生冲突。
// file1.cpp
static int global_data = 10; // 只能在 file1.cpp 中访问
static void helper_func() { // 只能在 file1.cpp 中调用
// ...
}
3. 类成员(成员变量和成员函数)- 类级别共享
当 static 用于类内部的成员时,它使成员成为类级别的共享资源,而不是每个对象独有的资源。
- 静态成员变量:
- 该变量为所有类的对象所共享,只存在一个副本。
- 它必须在类外部进行定义和初始化(除非是
const static整数类型)。 - 可以通过类名或对象访问。
- 静态成员函数:
- 它不依赖于任何特定的类对象。
- 它不能直接访问非静态的成员变量或成员函数(因为它没有
this指针)。 - 通常用于访问和操作静态成员变量,或作为工具函数。
class MyClass {
public:
static int object_count; // 静态成员变量声明
MyClass() {
object_count++;
}
static int get_count() { // 静态成员函数
return object_count;
}
};
// 在类外定义和初始化静态成员
int MyClass::object_count = 0;
int main() {
MyClass obj1;
MyClass obj2;
// 使用类名直接访问静态成员
std::cout << MyClass::get_count() << std::endl; // 输出:2
return 0;
}
auto
auto 是 C++11 引入的一个关键字,允许编译器自动推导出变量的类型。auto 使得代码更加简洁,并且减少了显式指定类型的需求,尤其在处理复杂类型时非常有用。auto 通常用于变量声明时,让编译器根据赋值的表达式自动推断类型,这在迭代器和模板编程中尤其常见。
int main() {
auto x = 10; // x 的类型是 int
auto y = 3.14; // y 的类型是 double
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto it = vec.begin(); it != vec.end(); ++it) {
std::cout << *it << " ";
}
std::cout << std::endl;
return 0;
}
const
在 C 语言和 C++ 中,const 都用于限定变量为“只读”。
但是 C++ 对 const 的支持更为强大和灵活,它不仅影响编译器的检查机制,还会在类型系统中起作用。
-
基本特性
-
在 C 中,
const修饰的变量默认是只读存储(readonly),但本质上仍然是变量,而不是常量。const int x = 10; int *p = (int*)&x; // 通过强制转换依然能修改 *p = 20; // UB(未定义行为) -
在 C++ 中,
const更严格,编译器会将其视为类型的一部分。const int x = 10; x = 20; // 编译错误,禁止修改
-
-
修饰位置
const可以修饰不同对象,表达不同含义:-
修饰变量:值不可修改。
-
修饰指针:
const int *p; // 指向常量的指针(*p 不可改,p 可改) int *const p; // 常量指针(p 不可改,*p 可改) const int *const p; // 指向常量的常量指针 -
修饰函数参数:保证函数体内不会修改该参数。
-
修饰成员函数:表示该成员函数不会修改对象的成员变量,本质上是修饰this指针。
-
-
作用域与链接
- 在 C 中,
const全局变量默认是 外部链接,除非显式加static使得在本文件可见。 - 在 C++ 中,
const全局变量默认是 内部链接(只在本翻译单元内可见),若要在多个文件共享,需加extern。
- 在 C 中,
-
局限性
const并不保证编译期求值,它仅仅保证“运行时不能被修改”。- 如果需要编译期常量(如数组大小、模板参数),在 C++11 之前通常使用
#define或enum hack。
constexpr
constexpr 是 C++11 引入的关键字,用于声明“编译期常量表达式”。它不仅意味着值不可变,更重要的是:
编译器必须在编译期对其进行求值(只要表达式满足常量表达式要求)。
-
基本特性
-
constexpr变量一定是常量,并且能在编译期被计算:constexpr int size = 10; int arr[size]; // 合法 -
与
const不同,constexpr要求初始化表达式必须是编译期可计算的常量。
-
-
函数支持
-
constexpr还可以修饰函数:constexpr int square(int x) { return x * x; } int arr[square(5)]; // 在编译期计算为 25 -
这样的函数可以在编译期使用,也可以在运行时调用(若传入非常量参数)。
-
-
类与构造函数
- C++11 起,
constexpr可以修饰构造函数,表示该类的对象可以在编译期生成常量。 - C++14/17 对
constexpr的限制逐步放宽,例如允许有分支、循环,更接近普通函数。
- C++11 起,
-
区别于
constconst:运行期常量(只读),初始化表达式可以是运行时值,可以使用const_cast去除限定。constexpr:编译期常量,初始化表达式必须在编译期可求值。
对比示例:
const int a = std::time(nullptr); // 合法,运行期决定 constexpr int b = std::time(nullptr); // 错误,不能在编译期求值
extern
extern 关键字用于声明而非定义变量或函数。被extern标识的变量或者函数声明其定义在别的文件中,提示编译器遇到此变量和函数时在其他模块寻找其定义。
它出现在多文件项目中,用于在一个文件中访问另一个文件定义的全局变量或函数。
-
作用:
- 告诉编译器“该变量或函数在别处定义”;
- 不会为其分配存储空间(除非在定义处);
- 避免重复定义全局符号。
// file1.cpp
int count = 10; // 定义全局变量
// file2.cpp
#include <iostream>
extern int count; // 声明外部变量(非定义)
int main() {
std::cout << count << std::endl; // 输出 10
return 0;
}
-
在C++中:
C++ 默认情况下,
const全局变量具有内部链接(internal linkage),也就是仅在本文件内可见。 若希望跨文件共享一个常量变量,必须结合extern使用:// file1.cpp extern const int BUFFER_SIZE = 1024; // file2.cpp extern const int BUFFER_SIZE; // 声明外部常量 -
与函数结合使用:
对于函数来说,
extern是默认属性,即所有非static函数都具有外部链接性,因此通常可省略:extern void foo(); // 与 void foo(); 等价
register
register 是早期 C/C++ 时代用于提升变量访问速度的关键字,提示编译器将变量存储在 CPU 寄存器中,而非内存中。
-
特点:
- 变量可能被存放在 CPU 寄存器中,而非内存;
- 不能对
register变量使用取地址操作符&; - 仅能修饰局部变量或函数参数;
- 在现代编译器中通常被自动优化机制取代,因此多用于教学或历史理解。
#include <iostream>
int main() {
register int i; // 建议编译器将 i 放入寄存器中
for (i = 0; i < 5; ++i) {
std::cout << i << " ";
}
std::cout << std::endl;
return 0;
}
-
局限性:
- 编译器不保证一定会将其放入寄存器;
- 不能取地址(即
&i是非法的); - 在现代 C++ 中几乎没有实际性能提升,优化器会自动选择合适的寄存器分配策略。
派生数据类型
C++派生数据类型
在 C++ 中,派生数据类型(Derived Types) 是由基本数据类型或其他派生类型构建而来的类型。 它们扩展了语言的表达能力,使得开发者能够通过组合与抽象创建更复杂的数据结构与行为模型。
派生类型包括数组、指针、引用、函数、结构体、类、联合体以及枚举类型等。
| 数据类型 | 描述 | 示例 |
|---|---|---|
| 数组 | 相同类型元素的集合 | int arr[5] = {1, 2, 3, 4, 5}; |
| 指针 | 存储变量内存地址的类型 | int* ptr = &x; |
| 引用 | 变量的别名 | int& ref = x; |
| 函数 | 函数类型,表示函数的签名 | int func(int a, int b); |
| 结构体 | 用户定义的数据类型,可以包含多个不同类型的成员 | struct Point { int x; int y; }; |
| 类 | 用户定义的数据类型,支持封装、继承和多态 | class MyClass { ... }; |
| 联合体 | 多个成员共享同一块内存 | union Data { int i; float f; }; |
| 枚举 | 用户定义的整数常量集合 | enum Color { RED, GREEN, BLUE }; |
数组(Array)
数组用于存储相同类型元素的固定长度序列,在内存中是连续存放的。
int numbers[5] = {1, 2, 3, 4, 5};
std::cout << numbers[2]; // 输出 3
-
下标从 0 开始,越界访问会导致未定义行为(Undefined Behavior)。
-
在函数参数中,数组会退化为指针:
void printArray(int arr[], int size); // 等价于 void printArray(int* arr, int size); -
若需要安全的动态数组,请使用
std::vector。
指针(Pointer)
指针是存储变量地址的变量,是 C++ 的核心特性之一。
int a = 10;
int* p = &a; // 指针p指向a
std::cout << *p; // 输出10
关键点:
*用于定义或解引用指针;&用于取地址;- 指针类型必须与目标对象类型一致;
nullptr表示空指针(C++11 引入)。
常见错误:
-
解引用空指针或野指针会导致崩溃;
-
动态内存需成对使用:
int* p = new int(5); delete p;
推荐使用智能指针(std::unique_ptr, std::shared_ptr)来避免内存泄漏。
引用(Reference)
引用是另一个变量的别名,必须在定义时初始化。
int value = 10;
int& ref = value;
ref = 20; // value 也变为 20
-
引用不能为空;
-
绑定后不能再更改引用目标;
-
常用于函数参数传递以避免拷贝:
void modify(int& x) { x *= 2; }
C++11 还引入了 右值引用 (T&&),用于支持移动语义和完美转发。
函数类型(Function Type)
函数本身也是一种类型,其类型由返回值类型和参数类型列表组成。
int add(int a, int b) { return a + b; }
函数类型也可通过指针或引用使用:
int (*funcPtr)(int, int) = add;
std::cout << funcPtr(2, 3); // 输出5
C++11 提供了更安全的函数封装:
std::function:可存储任意可调用对象;auto和 Lambda 表达式使函数类型推导更简洁。
结构体(struct)
结构体是用户自定义的复合类型,可包含多个不同类型的成员。
struct Point {
int x;
int y;
};
Point p1 = {10, 20};
std::cout << p1.x << ", " << p1.y;
- 默认成员访问权限是 public;
- 可以包含成员函数;
- 可以与类(
class)结合使用面向对象设计。
类(class)
类是 C++ 的核心概念之一,支持 封装(Encapsulation)、继承(Inheritance) 和 多态(Polymorphism)。
class Rectangle {
private:
int width, height;
public:
Rectangle(int w, int h) : width(w), height(h) {}
int area() const { return width * height; }
};
- 成员默认为 private;
- 支持构造函数、析构函数、运算符重载等;
- 是对象与抽象数据类型的基础。
类与对象是C++语言极为重要的内容。
联合体(union)
联合体(union)允许多个成员共用同一块内存。
union Data {
int i;
float f;
char c;
};
Data d;
d.i = 10;
std::cout << d.i; // 输出10
d.f = 3.14; // i 被覆盖
- 占用的内存大小等于最大成员的大小;
- 只能同时存储一个有效成员;
- 可用于节省内存或实现类型复用。
枚举类型(enum)
枚举用于定义一组具名的整数常量,提高代码可读性与类型安全。
创建枚举类型时,使用关键字 enum。其一般形式为:
enum 枚举名 {
标识符[=整型常数],
标识符[=整型常数],
...
标识符[=整型常数]
} 枚举变量;
例如,下面的代码定义了一个颜色枚举,变量 c 的类型为 color。最后,c 被赋值为 blue:
enum color { red, green, blue } c;
c = blue;
默认情况下,第一个名称的值为 0,第二个名称的值为 1,第三个名称的值为 2,以此类推。然而,您也可以给名称赋予一个特殊的值,只需要添加一个初始值即可。例如,在下面的枚举中,green 的值为 5:
enum color { red, green=5, blue };
在此示例中,blue 的值为 6,因为默认情况下,每个名称都会比它前面一个名称大 1,而 red 的值仍然为 0。
C++11 引入 强类型枚举:
enum class Status { OK, ERROR };
Status s = Status::OK;
强类型枚举不会隐式转换为整数,作用域也更加安全。
C++类型别名
类型别名可通过 typedef 或 using 定义。
| 关键字 | 描述 | 示例 |
|---|---|---|
typedef | 为已有类型定义别名 | typedef int MyInt; |
using | C++11 引入的新语法 | using MyInt = int; |
示例:
typedef unsigned long ulong_t;
using ushort_t = unsigned short;
using 语法更直观,且支持模板别名:
template <typename T>
using Vec = std::vector<T>;
Vec<int> v = {1, 2, 3};
Tip
typedef 和 define 有什么区别?
typedef和#define虽然在表面上都可以用于“简化代码书写”,但它们的本质、用途以及生效阶段存在明显区别。(1) 用法不同:
typedef用于为已有的数据类型定义一个新的类型别名,从而增强程序的可读性和可维护性。例如,可以通过typedef unsigned int uint;为unsigned int定义一个更简洁的别名。 而#define是一种宏定义指令,主要用于定义常量或书写复杂但使用频繁的宏表达式,如#define PI 3.14159或#define MAX(a, b) ((a) > (b) ? (a) : (b))。 前者作用于类型层面,后者仅进行文本替换。(2) 执行时间不同:
typedef属于编译阶段的一部分,编译器在处理类型定义时会进行语法与类型检查,确保类型合法。 而#define是在编译前的预处理阶段执行的,它仅做简单的字符串替换,并不参与类型检查,因此如果使用不当,容易造成潜在的逻辑错误。(3) 作用域不同:
typedef受 C/C++ 作用域规则限制,只在其定义的作用域内有效,例如在函数内定义的类型别名在函数外无法使用。 相对地,#define不受作用域约束,只要在定义之后且未被#undef取消,其宏名在整个文件(甚至被包含的其他文件)中都有效,因此若管理不当,可能造成命名冲突。(4) 对指针的操作不同:
typedef和#define在定义指针类型时的行为存在显著区别。 例如:#define PINT1 int* typedef int* PINT2; PINT1 a, b; // 等价于 int* a, b; => b是int类型 PINT2 c, d; // 等价于 int* c, *d; => c和d都是int*类型这表明:
#define仅仅进行文本替换,而typedef定义的是一种完整的类型。使用typedef可以避免宏替换带来的歧义。(5) 语法要求不同:
typedef是一条语句,因此必须以分号结尾;#define是预处理指令,不属于语句,不能加分号,否则分号也会被替换到目标文本中,引发错误。
C++标准库类型
| 数据类型 | 描述 | 示例 |
|---|---|---|
std::string | 字符串类型 | std::string s = "Hello"; |
std::vector | 动态数组 | std::vector<int> v = {1, 2, 3}; |
std::array | 固定大小数组(C++11 引入) | std::array<int, 3> a = {1, 2, 3}; |
std::pair | 存储两个值的容器 | std::pair<int, float> p(1, 2.0); |
std::map | 键值对容器 | std::map<int, std::string> m; |
std::set | 唯一值集合 | std::set<int> s = {1, 2, 3}; |
C++ 字符串类型
C++ 提供了两种主要的字符串表示方式:C 风格字符串和 C++ 引入的 string 类类型。尽管 C++ 中 string 类型在许多情况下更为常用,但 C 风格字符串仍然是 C++ 中的一个重要组成部分,特别是在需要与旧有的 C 库代码兼容时。
C 风格字符串
C 风格的字符串源自 C 语言,并在 C++ 中继续得到支持。这种字符串表示方式实际上是一个以 null 字符 \0 结尾的字符数组。C 风格字符串的本质是一个一维字符数组,末尾的 null 字符标识了字符串的结束。
C 风格字符串的定义与初始化
您可以通过以下方式定义并初始化一个 C 风格字符串:
char site[6] = {'H', 'E', 'L', 'L', 'O', '\0'};
也可以使用更简便的方式进行初始化:
char site[] = "HELLO";
在这种情况下,C++ 编译器会自动在字符串的末尾添加 null 字符 \0,无需显式地写出。
#include <iostream>
using namespace std;
int main() {
char s[] = "HELLO"; // 字符串自动以 '\0' 结束
cout << s << endl;
return 0;
}
输出:
HELLO
C 风格字符串的常用操作函数
C 风格字符串有许多函数可以进行常见的操作。以下是常用的几个字符串操作函数:
| 函数 | 目的 |
|---|---|
strcpy(s1, s2) | 复制字符串 s2 到字符串 s1。 |
strcat(s1, s2) | 将字符串 s2 连接到字符串 s1 的末尾。 |
strlen(s1) | 返回字符串 s1 的长度(不包括 \0)。 |
strcmp(s1, s2) | 比较两个字符串 s1 和 s2,返回值为 0 时相同,负值表示 s1 < s2,正值表示 s1 > s2。 |
strchr(s1, ch) | 返回指针,指向字符串 s1 中字符 ch 的首次出现位置。 |
strstr(s1, s2) | 返回指针,指向字符串 s1 中子字符串 s2 的首次出现位置。 |
#include <iostream>
#include <cstring>
using namespace std;
int main() {
char str1[13] = "hello";
char str2[13] = "world";
char str3[13];
int len;
// 复制 str1 到 str3
strcpy(str3, str1);
cout << "strcpy(str3, str1): " << str3 << endl;
// 连接 str1 和 str2
strcat(str1, str2);
cout << "strcat(str1, str2): " << str1 << endl;
// 计算连接后的长度
len = strlen(str1);
cout << "strlen(str1): " << len << endl;
return 0;
}
输出:
strcpy(str3, str1): hello
strcat(str1, str2): helloworld
strlen(str1): 10
C++ 中的 string 类
C++ 标准库提供了 string 类,它是 C 风格字符串的现代替代品。string 类提供了丰富的成员函数,支持字符串的动态操作,简化了字符串处理过程,并且避免了 C 风格字符串的许多潜在问题。
string 类基本操作
C++ 中的 string 类提供了方便的字符串处理方法,使得字符串操作变得更加高效和直观。以下是一些常见的操作。
#include <iostream>
#include <string>
using namespace std;
int main() {
string str1 = "hello";
string str2 = "world";
string str3;
int len;
// 复制 str1 到 str3
str3 = str1;
cout << "str3: " << str3 << endl;
// 连接 str1 和 str2
str3 = str1 + str2;
cout << "str1 + str2: " << str3 << endl;
// 计算连接后的总长度
len = str3.size();
cout << "str3.size(): " << len << endl;
return 0;
}
输出:
str3: hello
str1 + str2: helloworld
str3.size(): 10
string 类的常用成员函数
string 类提供了多种函数来执行常见的字符串操作,常见的成员函数包括:
append(str):将str追加到当前字符串末尾。insert(pos, str):在位置pos处插入字符串str。erase(pos, len):从位置pos开始,删除len个字符。substr(pos, len):返回从位置pos开始的长度为len的子字符串。find(str):返回子字符串str在当前字符串中首次出现的位置。replace(pos, len, str):将当前位置pos开始的len个字符替换为字符串str。
#include <iostream>
#include <string>
using namespace std;
int main() {
string str = "Hello, world!";
// 获取子字符串
string sub = str.substr(7, 5); // 从位置 7 开始,获取 5 个字符
cout << "Substring: " << sub << endl;
// 查找字符 'w' 在字符串中的位置
size_t pos = str.find('w');
cout << "'w' found at: " << pos << endl;
// 替换子字符串
str.replace(7, 5, "C++");
cout << "After replace: " << str << endl;
return 0;
}
输出:
Substring: world
'w' found at: 7
After replace: Hello, C++!
类型转换
类型转换是将一个数据类型的值转换为另一种数据类型的值。在 C++ 中,类型转换主要有四种方式:静态转换、动态转换、常量转换和重新解释转换。每种转换方式有其特定的应用场景和注意事项。
静态转换(Static Cast)
静态转换用于将一个数据类型的值强制转换为另一种类型,通常适用于类型之间存在一定的相似性。例如,可以将 int 类型转换为 float 类型。静态转换并不会进行运行时的类型检查,因此在某些情况下,它可能会导致运行时错误,尤其是在转换较为复杂的对象时。
例如:
int i = 10;
float f = static_cast<float>(i); // 将 int 转换为 float
动态转换(Dynamic Cast)
动态转换是 C++ 中用于在继承体系中进行类型转换的方式。它通常用于将基类指针或引用转换为派生类指针或引用,特别是在涉及多态的场景中。与静态转换不同,动态转换会在运行时检查转换是否合法,确保程序的类型安全。如果转换失败,对于指针类型,它会返回 nullptr,而对于引用类型,则会抛出 std::bad_cast 异常。
例如:
#include <iostream>
class Base {
public:
virtual ~Base() = default; // 基类必须有虚函数
};
class Derived : public Base {
public:
void show() {
std::cout << "Derived class method" << std::endl;
}
};
int main() {
Base* ptr_base = new Derived; // 基类指针指向派生类对象
// 将基类指针转换为派生类指针
Derived* ptr_derived = dynamic_cast<Derived*>(ptr_base);
if (ptr_derived) {
ptr_derived->show(); // 成功转换,调用派生类方法
} else {
std::cout << "Dynamic cast failed!" << std::endl;
}
delete ptr_base;
return 0;
}
输出:
Derived class method
在转换引用时,也可以使用 dynamic_cast,不过如果转换失败,它会抛出异常:
#include <iostream>
#include <typeinfo>
class Base {
public:
virtual ~Base() = default;
};
class Derived : public Base {
public:
void show() {
std::cout << "Derived class method" << std::endl;
}
};
int main() {
Derived derived_obj;
Base& ref_base = derived_obj; // 基类引用绑定到派生类对象
try {
Derived& ref_derived = dynamic_cast<Derived&>(ref_base);
ref_derived.show(); // 成功转换,调用派生类方法
} catch (const std::bad_cast& e) {
std::cout << "Dynamic cast failed: " << e.what() << std::endl;
}
return 0;
}
输出:
Derived class method
动态转换提供了更高的安全性,确保在程序运行时可以正确地进行类型检查。尽管如此,它相对于静态转换而言,性能开销较高,因为需要进行类型验证。
常量转换(Const Cast)
常量转换(const_cast)用于去除对象的 const 限定符,从而使得一个 const 对象能够被修改。它并不会改变对象的实际类型,只是去掉了 const 属性。这种转换通常用于需要修改原本被声明为常量的对象时,尽管这种做法应谨慎使用。
例如:
const int i = 10;
int& r = const_cast<int&>(i); // 去除 const 限定符
此代码将 const int 转换为普通的 int,使得对 i 的修改变得可能。但请注意,修改一个原本是常量的对象可能导致未定义的行为。
重新解释转换(Reinterpret Cast)
重新解释转换(reinterpret_cast)允许将一个数据类型的值重新解释为另一种数据类型的值。它通常用于在指针类型之间进行转换,不会进行任何类型检查,因此可能会导致未定义的行为。这种转换应谨慎使用,特别是在不同类型之间进行转换时,必须确保数据在内存中的表示是兼容的。
例如:
int i = 10;
float f = reinterpret_cast<float&>(i); // 将 int 转换为 float
这段代码通过 reinterpret_cast 将 int 类型的数据重新解释为 float 类型。然而,重新解释转换的使用非常危险,因为它不会检查类型是否真正兼容。如果目标类型与原类型不兼容,可能会导致程序崩溃或者数据损坏。因此,reinterpret_cast 应该仅在非常确定数据类型兼容的情况下使用。
自动类型推导
在 C++ 中,自动类型推导(type inference)允许编译器根据初始化表达式推断变量的类型,从而减少显式指定类型的需求。C++11 引入了 auto 关键字,它使得变量声明更加简洁和灵活,同时也有助于避免类型错误。
auto 关键字的基本使用
auto 关键字让编译器根据初始化表达式推导出变量的类型。在声明变量时,使用 auto 代替类型名称,编译器将自动推断类型。
#include <iostream>
int main() {
auto x = 42; // 自动推导类型为 int
auto y = 3.14; // 自动推导类型为 double
std::cout << "x: " << x << ", y: " << y << std::endl;
return 0;
}
在这个例子中,auto 自动推导出 x 的类型为 int,y 的类型为 double,根据其初始化值。无需显式地指定类型。
输出:
x: 42, y: 3.14
auto 与容器
auto 在处理容器时非常有用,尤其是当容器类型复杂且冗长时,使用 auto 可以简化代码并提高可读性。C++11 引入了范围-based for 循环,配合 auto 使用可以轻松地遍历容器。
#include <iostream>
#include <vector>
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
// 使用 auto 遍历容器
for (auto& v : vec) {
std::cout << v << " ";
}
std::cout << std::endl;
return 0;
}
在此示例中,auto 用来自动推导容器元素的类型。由于我们希望修改容器中的元素,所以使用了 auto& 来获取元素的引用。
输出:
1 2 3 4 5
auto 和指针
在使用指针时,auto 也可以自动推导出指针的类型。注意,auto 仅推导出变量本身的类型,而不推导出指针指向的类型。
#include <iostream>
int main() {
int num = 100;
int* ptr = #
// 使用 auto 推导指针类型
auto p = ptr; // p 将是 int* 类型
std::cout << "p points to: " << *p << std::endl;
return 0;
}
在这个例子中,auto 推导出变量 p 的类型为 int*,与原始指针 ptr 类型相同。
输出:
p points to: 100
auto 与函数返回类型
auto 不仅可以用于变量声明,还可以用于函数的返回类型。当返回类型较为复杂或需要根据返回的表达式推导时,auto 可以简化代码。
#include <iostream>
#include <vector>
auto get_sum(const std::vector<int>& vec) {
int sum = 0;
for (auto v : vec) {
sum += v;
}
return sum; // 返回类型由 auto 自动推导
}
int main() {
std::vector<int> vec = {1, 2, 3, 4, 5};
std::cout << "Sum: " << get_sum(vec) << std::endl;
return 0;
}
在这个示例中,auto 用于函数 get_sum 的返回类型,编译器根据 sum 变量的类型推导出返回值的类型。
输出:
Sum: 15
auto 与模板
auto 也可以与模板结合使用,尤其是在模板函数中,允许推导类型从而使得函数更加灵活。使用 auto 可以避免显式指定模板参数,从而简化模板代码。
#include <iostream>
template <typename T>
auto add(T a, T b) {
return a + b; // 返回类型自动推导
}
int main() {
std::cout << add(1, 2) << std::endl; // 输出 3
std::cout << add(1.5, 2.5) << std::endl; // 输出 4.0
return 0;
}
在这个示例中,auto 自动推导 add 函数的返回类型,无论是整型还是浮点型,都能正确处理。
输出:
3
4
注意
-
auto不能用于函数参数类型auto只能用于变量声明和返回类型,不能用于函数的参数类型。例如,以下代码是错误的:auto add(auto a, auto b) { // 错误,auto 不能用于函数参数类型 return a + b; } -
auto与引用、常量结合 当使用auto时,变量的类型是根据初始化表达式的类型来推导的。如果需要引用或常量,应该明确使用auto&或const auto&来推导引用类型或常量类型。const auto& ref = some_variable; // 推导为 const 引用 -
类型推导的限制 编译器根据初始化表达式推导类型,但不能从不完整的类型推导。因此,必须确保变量在声明时能够通过表达式推导出类型。
变量与常量
一、基本概念
在 C++ 中,变量 (Variable) 是内存中的一块可命名的存储空间,用于保存数据值。 常量 (Constant) 则是在程序执行期间不能被修改的值。
变量与常量是程序存储数据和表达逻辑的基础。
二、变量 (Variable)
1. 定义变量
变量在使用前必须定义。定义时需指定类型,并可选地赋初值。
int age = 20; // 定义并初始化一个整型变量
double weight; // 定义一个浮点型变量,但未初始化
char grade = 'A'; // 定义并初始化一个字符型变量
建议始终初始化变量,避免使用未定义的值(否则可能产生未定义行为)。
2. 变量的命名规则
变量的命名要求符合一定的命名规则,具体可参考命名规则,可简要总结为:
-
只能包含字母、数字和下划线
_ -
不能以数字开头
-
区分大小写 (
score与Score不同) -
不能使用 C++ 关键字 (
int,return等) -
应体现变量含义,例如:
int studentCount; // 好 int sc; // 不推荐
3. 变量的作用域与生命周期
(1)作用域 (Scope)
变量的可见范围取决于其定义位置:
| 类型 | 定义位置 | 可见范围 |
|---|---|---|
| 局部变量 | 函数或代码块内 | 仅在当前函数或块中可见 |
| 全局变量 | 所有函数外部 | 在整个文件中可见 |
int globalVar = 10; // 全局变量
void func() {
int localVar = 5; // 局部变量
}
(2)生命周期 (Lifetime)
- 局部变量:进入作用域时创建,离开作用域时销毁。
- 全局变量/静态变量:程序启动时创建,程序结束时销毁。
void counter() {
static int count = 0; // 静态局部变量,只初始化一次
count++;
std::cout << count << std::endl;
}
4. 变量的存储类型说明符
| 关键字 | 含义 |
|---|---|
auto | 自动类型推断(C++11 引入) |
register | 建议编译器将变量存储在寄存器中(已过时) |
static | 使局部变量在函数多次调用间保持值不变 |
extern | 引用外部定义的变量 |
mutable | 允许在 const 对象中修改该成员(仅类成员有效) |
extern int count; // 声明外部变量
auto x = 3.14; // 类型自动推断为 double
还有更多的变量修饰符,详见基本数据类型。
三、常量 (Constant)
1. 字面值常量 (Literal Constant)
直接出现在代码中的固定值:
123 // 整数常量
3.14 // 浮点常量
'a' // 字符常量
"Hello" // 字符串常量
true, false // 布尔常量
有时候会以宏定义的方式存在,例如
#define pi 3.14,在预处理阶段完成替换处理,本质上还是字面值常量。使用宏替换的方式,被替换的常量在编译出来的二进制中会重复存在,因此使用
constexpr会提高空间利用率。
2. const 常量
const 修饰的变量一旦初始化,其值不能被修改,因此const常量本质上是不可变化的变量:
const double PI = 3.14159;
PI = 3.14; // 错误:不能修改常量
常量指针与指向常量的指针
| 形式 | 含义 |
|---|---|
const int* p // 常量指针 | 指针指向的值不能改 |
int* const p // 指针常量 | 指针本身不能改 |
const int* const p | 值与指针均不可改 |
Tip
指针常量和常量指针各是什么,有什么区别?
指针常量是指定义了一个指针,这个指针的值只能在定义时进行初始化,其他时候不能改变。
常量指针是指定义了一个指针,这个指针指向了一个只读的对象,不能通过常量指针来修改这个对象的值。
指针常量强调的是指针本身的不可改变性,而常量指针强调的是指针对其所指对象的不可改变性。
无论是指针常量还是常量指针,其最大的用途就是作为函数的形式参数,保证实参在被调用函数中的不可改变特性。
3. constexpr 常量表达式(C++11 起)
constexpr 表示在编译期即可确定值的常量。它比 const 更严格:
constexpr int max_size = 100;
constexpr int square(int x) { return x * x; }
int arr[square(4)]; // 编译期确定数组大小
const表示值不可改,而constexpr表示在编译时确定值。
4. 枚举常量 (Enumeration)
枚举用于定义一组命名的整型常量:
enum Color { RED, GREEN, BLUE };
Color c = GREEN;
自定义起始值:
enum ErrorCode { OK = 0, NotFound = 404, ServerError = 500 };
四、变量与常量的对比
| 特性 | 变量 | 常量 |
|---|---|---|
| 值是否可修改 | 可修改 | 不可修改 |
| 是否必须初始化 | 否(但推荐) | 是 |
| 存储位置 | 栈 / 堆 / 静态区 | 通常在只读区 |
| 示例 | int x = 5; | const int x = 5; |
五、示例代码
#include <iostream>
using namespace std;
constexpr double PI = 3.14159;
int main() {
int radius = 5;
const double area = PI * radius * radius;
cout << "半径:" << radius << endl;
cout << "圆面积:" << area << endl;
// area = 100; // 常量不可修改
return 0;
}
输出:
半径:5
圆面积:78.5397
控制结构(Control Structures)
在 C++ 中,控制结构用于控制程序语句的执行顺序。C++ 的控制流主要分为三种类型:
- 顺序结构:从上到下依次执行语句,是程序的默认执行方式。
- 选择结构:根据条件判断,执行不同的分支语句。
- 循环结构:重复执行一段代码,直到某个条件满足为止。
在理解控制结构时,最关键的是掌握表达式求值时机、作用域规则与循环/分支退出机制。
一、循环结构(Loop Structure)
循环结构的本质是在某个条件满足时重复执行代码块。
C++ 提供了三种主要循环语法:for、while 和 do...while。
虽然三者都能实现循环逻辑,但它们在执行顺序与条件判断时机上存在显著差异。
1. for 循环
for (初始化表达式; 条件表达式; 迭代表达式) {
// 循环体
}
在执行时,for 循环遵循如下过程:
- 初始化表达式在循环开始时执行一次;
- 计算条件表达式,若为
true,则进入循环体; - 执行循环体中的语句;
- 执行迭代表达式(通常为自增或自减操作);
- 再次判断条件表达式;
- 若条件仍为真,继续循环;否则退出循环。
#include <iostream>
using namespace std;
int main() {
for (int i = 0; i < 5; ++i) {
cout << "当前 i = " << i << endl;
}
}
程序执行顺序如下:
初始化:i = 0
判断条件:i < 5 → true
执行循环体:输出 i
迭代表达式:i++
再判断...
-
局部作用域:在
for循环中声明的变量(如int i)仅在循环内部可见,循环结束后变量被销毁。 -
可省略项:
for (;;) { // 无限循环 }如果条件表达式被省略,默认为
true。 -
循环控制建议:
- 在循环内部修改循环变量容易出错;
- 条件表达式应当保证能在有限次迭代后终止,否则会造成死循环。
C++11 引入了基于范围的 for 循环(range-based for loop):
#include <vector>
#include <iostream>
using namespace std;
int main() {
vector<int> nums = {1, 2, 3, 4, 5};
for (int x : nums) {
cout << x << " ";
}
}
等价于传统形式:
for (auto it = nums.begin(); it != nums.end(); ++it)
cout << *it << " ";
这种写法更简洁、安全,也更符合 STL 容器的使用习惯。
2. while 循环
while (条件表达式) {
// 循环体
}
while 循环先判断条件,再执行循环体。执行流程如下:
- 判断条件;
- 若条件为
true,执行循环体; - 循环体执行完毕后,再次判断条件;
- 若条件为
false,循环终止。
int i = 0;
while (i < 5) {
cout << "i = " << i << endl;
++i;
}
与 for 相比,while 更适用于循环次数不确定但终止条件明确的情况,例如文件读取或网络接收:
string line;
while (getline(cin, line)) {
cout << line << endl;
}
这种结构常见于 IO 流循环,因为输入次数事先未知。
一个经典的初学者错误:
int i = 0;
while (i < 10);
{
cout << i << endl;
++i;
}
这里的分号 ; 结束了 while 语句,导致循环体为空。
实际运行时这段代码会陷入死循环,因为条件判断永远为真而循环体无法修改变量。
3. do...while 循环
do {
// 循环体
} while (条件表达式);
与 while 不同,do...while 先执行循环体一次,再判断条件。
因此,无论条件是否为真,循环体至少会被执行一次。
int n;
do {
cout << "请输入一个正数:";
cin >> n;
} while (n <= 0);
程序会反复要求输入,直到输入值大于零。 这种结构常用于需要先执行一次再决定是否继续的交互逻辑。
4. 循环控制语句
-
break—— 提前结束循环break用于立即终止当前循环或switch语句,不会再执行后续的循环体和条件判断。for (int i = 0; i < 10; ++i) { if (i == 5) break; cout << i << " "; } -
continue—— 跳过当前迭代continue会跳过本次循环剩余部分,直接进入下一次条件判断。for (int i = 0; i < 10; ++i) { if (i % 2 == 0) continue; // 跳过偶数 cout << i << " "; } -
goto—— 无条件跳转(极少使用)int i = 0; loop_start: if (i < 5) { cout << i << endl; ++i; goto loop_start; }虽然语法合法,但这种写法不符合结构化编程思想,现代 C++ 中几乎不使用。
5. 嵌套循环与性能考量
循环可以嵌套,例如输出九九乘法表:
for (int i = 1; i <= 9; ++i) {
for (int j = 1; j <= i; ++j) {
cout << j << "x" << i << "=" << i * j << "\t";
}
cout << endl;
}
嵌套循环会带来O(n²) 级别的时间复杂度,因此在数据量大时应当考虑优化:
- 减少内层循环的迭代次数;
- 将不必要的计算提前至循环外;
- 使用
break或条件提前退出。
二、判断结构(Selection Structure)
判断结构的目的是让程序在不同条件下执行不同语句。
C++ 提供了两种主要形式:if 与 switch,另有三目运算符用于简单条件判断。
1. if 条件语句
if (条件表达式) {
// 条件成立时执行
}
else {
// 条件不成立时执行
}
在执行时,编译器首先计算条件表达式的布尔值:
- 若为
true,执行if块; - 否则执行
else块(如果存在)。
除此以外,if还支持多分支判断:
if (score >= 90)
cout << "优秀";
else if (score >= 80)
cout << "良好";
else if (score >= 60)
cout << "及格";
else
cout << "不及格";
编译器从上至下依次判断条件表达式。 当某个条件为真时,后续分支将被跳过。
- 短路求值(short-circuit evaluation)
逻辑运算符 && 和 || 在判断中具有短路行为:
if (x != 0 && 10 / x > 1) // 安全
若 x == 0,第一个条件为 false,第二个表达式不会被求值,从而避免除零错误。
短路机制是 C++ 逻辑判断中非常重要的性能与安全特性。
- 悬挂 else(dangling else)问题
if (a > 0)
if (b > 0)
cout << "A";
else
cout << "B";
此时 else 默认匹配最近的未匹配 if,即第二个 if。
建议始终使用大括号 {} 明确逻辑边界。
2. switch 语句
switch (表达式) {
case 常量1:
// 语句
break;
case 常量2:
// 语句
break;
default:
// 默认语句
break;
}
switch 表达式的值会与每个 case 标签进行匹配,若相等,则从该处开始执行,直到遇到 break 或 switch 结束。
int day = 3;
switch (day) {
case 1: cout << "Monday"; break;
case 2: cout << "Tuesday"; break;
case 3: cout << "Wednesday"; break;
default: cout << "Invalid";
}
-
语义分析与注意事项
-
switch的判断值必须是整数类型或枚举类型; -
case标签必须是常量表达式; -
若
break省略,将导致贯穿 (fall through),继续执行后续分支; -
default分支可选,但建议始终保留。 -
现代 C++ 的枚举与
switch
enum class Color { Red, Green, Blue };
Color c = Color::Green;
switch (c) {
case Color::Red: cout << "Red"; break;
case Color::Green: cout << "Green"; break;
case Color::Blue: cout << "Blue"; break;
}
使用 enum class 可避免命名冲突和隐式转换,提高类型安全性。
3. 条件运算符(三目运算符)
C++ 提供了简洁的条件表达式:
表达式1 ? 表达式2 : 表达式3;
求值逻辑:
- 若表达式1为真,则整个表达式结果为表达式2;
- 否则结果为表达式3。
示例:
int a = 10, b = 20;
int max = (a > b) ? a : b;
该语句的效果等价于:
if (a > b) max = a;
else max = b;
三目运算符常用于简单赋值,但不适合复杂逻辑,因为可读性较差。
操作符(Operators)
操作符(Operator)是 C++ 表达式的核心组成部分,用于对操作数(Operand)进行各种运算、比较或逻辑处理。 在编译阶段,操作符会被转化为相应的汇编指令或函数调用(例如运算符重载时)。
C++ 拥有丰富的操作符体系,并且允许程序员通过运算符重载(operator overloading)定义自定义类型的操作方式,从而实现与内置类型一致的自然语法。
一、操作符的分类
| 类别 | 示例 | 用途 |
|---|---|---|
| 算术操作符 | +, -, *, /, % | 数值计算 |
| 自增/自减操作符 | ++, -- | 累加或递减 |
| 关系操作符 | ==, !=, <, >, <=, >= | 比较 |
| 逻辑操作符 | &&, ||, ! | 逻辑判断 |
| 位操作符 | &, |, ^, ~, <<, >> | 位级运算 |
| 赋值操作符 | =, +=, -=, *=, /=, %= 等 | 赋值与复合赋值 |
| 条件运算符 | ?: | 三目条件判断 |
| 逗号运算符 | , | 顺序求值 |
| 成员操作符 | ., ->, .*, ->* | 成员访问 |
| 指针与地址操作符 | *, & | 指针解引用与取地址 |
| 类型转换操作符 | (type), static_cast, dynamic_cast 等 | 类型转换 |
| 其他特殊操作符 | sizeof, typeid, new, delete | 特殊用途 |
二、算术操作符(Arithmetic Operators)
1. 基本算术运算
| 操作符 | 含义 | 示例 |
|---|---|---|
+ | 加法 | a + b |
- | 减法 | a - b |
* | 乘法 | a * b |
/ | 除法 | a / b |
% | 取余(模运算) | a % b |
示例:
int a = 10, b = 3;
cout << a + b << endl; // 13
cout << a - b << endl; // 7
cout << a * b << endl; // 30
cout << a / b << endl; // 3
cout << a % b << endl; // 1
2. 注意事项
-
整数除法:两个整数相除结果仍为整数,小数部分会被截断。
-
浮点除法:若任一操作数为浮点型,则结果为浮点数。
-
取余运算
%:仅适用于整数类型。 -
负数取余:结果的符号与被除数相同,例如:
cout << (-7) % 4; // 输出 -3
三、自增与自减操作符(++ 与 --)
1. 区分前置与后置
| 形式 | 名称 | 求值顺序 | 示例 |
|---|---|---|---|
++i | 前置自增 | 先自增再返回值 | int a = 1; cout << ++a; // 输出 2 |
i++ | 后置自增 | 先返回值再自增 | int a = 1; cout << a++; // 输出 1 |
同理适用于 --。
2. 编译层面的区别
前置自增:
int& operator++(int& i) { i += 1; return i; }
后置自增:
int operator++(int& i, int) { int old = i; ++i; return old; }
因此后置版本通常会创建临时对象,性能略低。
四、关系操作符(Relational Operators)
用于比较两个值的大小或相等性,结果为 bool 类型。
int a = 5, b = 10;
cout << (a < b) << endl; // 1
cout << (a == b) << endl; // 0
| 操作符 | 含义 | 示例 |
|---|---|---|
== | 等于 | a == b |
!= | 不等于 | a != b |
< | 小于 | a < b |
> | 大于 | a > b |
<= | 小于等于 | a <= b |
>= | 大于等于 | a >= b |
五、逻辑操作符(Logical Operators)
逻辑操作符用于布尔表达式组合,返回 true 或 false。
| 操作符 | 含义 | 示例 | 特性 |
|---|---|---|---|
&& | 逻辑与 | (x > 0 && y > 0) | 短路:若左侧为假,右侧不求值 |
|| | 逻辑或 | (x < 0 || y < 0) | 短路:若左侧为真,右侧不求值 |
! | 逻辑非 | !flag | 取反 |
短路求值(short-circuit evaluation)是逻辑运算的重要特性,它不仅提升性能,也避免非法运算(例如除零)。
if (ptr != nullptr && *ptr > 10)
cout << "安全访问";
六、位操作符(Bitwise Operators)
位操作符直接作用于整数的二进制表示。
| 操作符 | 含义 | 示例 | ||
|---|---|---|---|---|
& | 按位与 | a & b | ||
| ` | ` | 按位或 | `a | b` |
^ | 按位异或 | a ^ b | ||
~ | 按位取反 | ~a | ||
<< | 左移 | a << n (相当于乘以 2ⁿ) | ||
>> | 右移 | a >> n (相当于除以 2ⁿ) |
示例:
unsigned int a = 5; // 00000101
unsigned int b = 3; // 00000011
cout << (a & b); // 00000001 -> 1
cout << (a | b); // 00000111 -> 7
cout << (a ^ b); // 00000110 -> 6
cout << (~a); // 11111010 -> 取反
注意:
- 位移运算的右操作数必须为非负;
- 有符号右移的高位填充由实现定义;
- 通常使用无符号类型进行位操作以避免歧义。
七、赋值与复合赋值操作符(Assignment Operators)
1. 基本赋值
int x = 10;
2. 复合赋值
| 操作符 | 等价形式 | ||
|---|---|---|---|
+= | a = a + b | ||
-= | a = a - b | ||
*= | a = a * b | ||
/= | a = a / b | ||
%= | a = a % b | ||
&= | a = a & b | ||
| ` | =` | `a = a | b` |
^= | a = a ^ b | ||
<<= | a = a << b | ||
>>= | a = a >> b |
复合赋值的优点是只计算一次左值,因此在复杂左值表达式中效率更高:
arr[getIndex()] += 10; // getIndex() 只调用一次
八、条件运算符(Ternary Operator)
三目运算符语法:
条件 ? 表达式1 : 表达式2;
示例:
int max = (a > b) ? a : b;
等价于:
if (a > b)
max = a;
else
max = b;
注意:
- 运算符返回一个值,因此可嵌入表达式;
- 但应避免多层嵌套,影响可读性。
九、逗号运算符(Comma Operator)
逗号运算符从左到右依次求值,整个表达式的结果是最后一个表达式的值:
int a = (b = 3, b + 2); // b=3执行后,再计算b+2,a=5
在 for 循环中常见:
for (int i = 0, j = 10; i < j; ++i, --j)
cout << i << " " << j << endl;
十、成员与指针操作符(Member & Pointer Operators)
| 操作符 | 含义 | 示例 |
|---|---|---|
. | 访问对象成员 | obj.member |
-> | 访问指针所指对象的成员 | ptr->member |
.* | 访问对象的成员指针 | (obj.*ptrMember) |
->* | 通过指针访问成员指针 | (ptr->*ptrMember) |
示例:
struct Test { int x; void show() { cout << x; } };
Test t{42};
Test* p = &t;
cout << t.x; // 使用 .
cout << p->x; // 使用 ->
十一、指针相关操作符
| 操作符 | 作用 | 示例 |
|---|---|---|
& | 取地址 | int* p = &x; |
* | 解引用 | cout << *p; |
这两个符号在声明与使用中含义不同:
int* p; // 声明指针类型
*p = 10; // 解引用赋值
十二、类型转换操作符(Cast Operators)
1. C 风格强制类型转换
int a = 10;
double b = (double)a;
2. C++ 风格强制类型转换
| 操作符 | 用途 |
|---|---|
static_cast<T>(expr) | 编译期已知的安全类型转换 |
dynamic_cast<T>(expr) | 用于多态类型的安全向下转换(运行期检查) |
const_cast<T>(expr) | 去除或添加 const 属性 |
reinterpret_cast<T>(expr) | 强制重解释类型(高风险) |
示例:
Base* b = new Derived();
Derived* d = dynamic_cast<Derived*>(b); // 安全类型检查
十三、其他特殊操作符
1. sizeof
返回对象或类型的字节大小,结果类型为 size_t:
cout << sizeof(int); // 通常为4
对数组时返回整个数组的大小,而不是指针大小:
int arr[10];
cout << sizeof(arr); // 40 (假设int为4字节)
2. typeid
用于在运行时获取类型信息,常与 RTTI(运行时类型识别)配合使用。
#include <typeinfo>
int a = 5;
cout << typeid(a).name(); // 输出类型名
3. new 与 delete
动态内存分配与释放:
int* p = new int(10);
delete p;
int* arr = new int[5];
delete[] arr;
new 会自动调用构造函数,而 delete 调用析构函数。
十四、运算符优先级与结合性
| 优先级(高→低) | 操作符 | 结合方向 |
|---|---|---|
| 1 | :: | 左到右 |
| 2 | () [] -> . ++ -- | 左到右 |
| 3 | ! ~ ++ -- + - (type) * & sizeof new delete | 右到左 |
| 4 | * / % | 左到右 |
| 5 | + - | 左到右 |
| 6 | << >> | 左到右 |
| 7 | < <= > >= | 左到右 |
| 8 | == != | 左到右 |
| 9 | & | 左到右 |
| 10 | ^ | 左到右 |
| 11 | | | 左到右 |
| 12 | && | 左到右 |
| 13 | || | 左到右 |
| 14 | ?: | 右到左 |
| 15 | =, +=, -=, *=, /=, %= 等 | 右到左 |
| 16 | , | 左到右 |
建议使用括号明确运算顺序,尤其在混合表达式中。
十五、运算符重载(Operator Overloading)
C++ 允许为自定义类型定义运算符行为,让类的对象也能像内置类型一样使用 +、==、<< 等符号:
class Vector {
public:
int x, y;
Vector(int x, int y): x(x), y(y) {}
Vector operator+(const Vector& v) const {
return Vector(x + v.x, y + v.y);
}
};
使用:
Vector a(1, 2), b(3, 4);
Vector c = a + b; // 等价于 a.operator+(b)
注意:
- 不能重载
.、::、?:、sizeof; - 运算符重载不会改变操作符优先级;
- 应保证语义一致性(例如重载
==时应与!=保持逻辑对称)。
运算符重载的完整规则——成员函数与友元函数的选择、各类运算符的重载方式、返回值设计以及常见陷阱——属于面向对象进阶内容,详见 运算符重载。
函数 (Functions)
函数是 C++ 程序的基本组成部分,它们用于将程序分解为可管理、可重复使用的代码块。
一、函数的基本概念
1. 定义
函数是一段执行特定任务的命名代码块。
2. 优点
- 模块化 (Modularity): 使程序结构清晰,易于理解和维护。
- 代码重用 (Code Reusability): 一次编写,多次调用,避免重复劳动。
- 简化调试 (Easier Debugging): 易于隔离和测试代码。
二、函数的构成要素
一个函数通常由以下几个部分组成:
1. 函数原型 (Function Prototype) / 函数声明 (Declaration)
告诉编译器函数的名称、返回类型和参数列表,但没有函数体。
格式:
返回类型 函数名(参数类型 参数名, ...);
示例:
int add(int a, int b); // 函数原型
- 注意: 函数原型通常放在
main函数之前或头文件 (.h文件) 中。
2. 函数定义 (Function Definition)
包含函数头和函数体(实际执行的代码)。
格式:
返回类型 函数名(参数类型 参数名, ...)
{
// 函数体:执行任务的代码
// ...
return 表达式; // 如果返回类型不是 void
}
示例:
int add(int a, int b) // 函数头
{
// 函数体
return a + b;
}
3. 函数调用 (Function Call)
在程序中执行函数。
格式:
函数名(实参1, 实参2, ...);
示例:
int sum = add(5, 3); // 调用 add 函数,5 和 3 是实参
三、函数的关键要素
1. 返回类型 (Return Type)
函数执行完毕后返回给调用者的值的类型。
- 可以是任何有效的数据类型(如
int,double,bool, 自定义类型等)。 void: 如果函数不返回任何值,则使用void。
2. 函数名 (Function Name)
用于唯一标识函数。遵循 C++ 命名规则。
3. 参数 (Parameters) / 形参 (Formal Parameters)
定义在函数头中,用于接收调用者传递的数据。
- 格式:
类型 名称(例如int x)
4. 实参 (Arguments)
调用函数时传递给形参的实际值。
5. return 语句
用于:
- 返回值: 将一个值返回给调用者。
- 结束执行: 立即终止函数的执行。
- 注意: 对于返回类型非
void的函数,必须有return语句返回一个与返回类型兼容的值。
四、参数传递 (Argument Passing)
函数调用时,将实参的值复制或引用到形参中,主要有三种方式:
1. 值传递 (Pass by Value) (最常用)
-
机制: 将实参的值复制给形参。
-
效果: 函数内部对形参的任何修改,不会 影响到外部的实参。
-
声明:
void func(int x); // x 是一个副本
2. 地址传递 (Pass by Pointer) (C 风格,C++ 中不推荐)
-
机制: 传递实参的内存地址(指针)。
-
效果: 函数可以通过指针修改实参的值。
-
声明:
void func(int *ptr); // ptr 是一个指向 int 的指针
3. 引用传递 (Pass by Reference) (C++ 推荐)
-
机制: 形参成为实参的别名(引用)。
-
效果: 函数内部对形参的任何修改,会 直接影响到外部的实参。
-
声明:
void func(int &ref); // ref 是实参的引用- 优点: 既能修改外部变量,又避免了指针的复杂性和值传递的开销。
五、函数的进阶特性
1. 默认参数 (Default Arguments)
允许在函数声明时给参数设定一个默认值。调用时若不提供该实参,则使用默认值。
- 规则: 默认参数必须从右向左依次设置。一旦一个参数有了默认值,它右边的所有参数都必须有默认值。
示例:
void print_info(int a, int b = 10, int c = 20); // b 和 c 是默认参数
// 调用方式
print_info(1); // a=1, b=10, c=20
print_info(1, 5); // a=1, b=5, c=20
print_info(1, 5, 8); // a=1, b=5, c=8
2. 函数重载 (Function Overloading)
在同一作用域内,允许存在多个同名函数,但它们的 参数列表 必须不同(数量、类型或顺序)。
- 作用: 使程序能够使用一个统一的名称来执行相似但操作不同数据类型的任务。
示例:
int operate(int a, int b)
{ return a + b; }
double operate(double a, double b)
{ return a * b; }
int operate(int a) // 与前两个函数参数数量不同
{ return a * a; }
3. 内联函数 (Inline Functions)
使用 inline 关键字修饰的函数。
- 目的: 建议编译器在函数被调用的地方直接将函数体的代码展开,而不是执行常规的函数调用机制。
- 优点: 消除函数调用开销,提高小函数的执行效率。
- 缺点: 可能导致代码膨胀(占用更多内存)。
- 适用场景: 函数体非常简单(只有一两行代码)的情况。
示例:
inline int square(int x) { return x * x; }
- 注意:
inline只是对编译器的建议,编译器可以忽略它。
示例代码
#include <iostream>
// 函数原型 (声明)
int add(int a, int b);
void swap_by_ref(int &x, int &y);
// 带有默认参数的函数原型
void greet(std::string name, std::string greeting = "Hello");
// main 函数 (程序入口)
int main() {
int num1 = 10, num2 = 5;
// 1. 函数调用
int sum = add(num1, num2); // num1, num2 是实参
std::cout << "Sum: " << sum << std::endl; // 输出: 15
// 2. 引用传递示例
int x = 100, y = 200;
std::cout << "Before swap: x=" << x << ", y=" << y << std::endl;
swap_by_ref(x, y); // 交换 x 和 y 的值
std::cout << "After swap: x=" << x << ", y=" << y << std::endl; // 输出: x=200, y=100
// 3. 默认参数示例
greet("Alice"); // 使用默认问候语: Hello Alice
greet("Bob", "Good Morning"); // 使用自定义问候语: Good Morning Bob
return 0;
}
// 函数定义
int add(int a, int b) // a, b 是形参 (值传递)
{
return a + b;
}
// 引用传递实现值交换
void swap_by_ref(int &x, int &y) // x, y 是引用
{
int temp = x;
x = y;
y = temp;
// x 和 y 的改变会影响到 main 函数中的实参
}
// 默认参数实现
void greet(std::string name, std::string greeting)
{
std::cout << greeting << " " << name << std::endl;
}
Lambda 表达式
Lambda 表达式(C++11 引入)用于在调用点就地定义一个匿名函数对象。它让「把函数作为参数传递」变得极为简洁,是标准库算法、回调、异步任务中最常用的语法之一。
Lambda 的本质是编译器生成的一个闭包类型(closure type)——一个重载了 operator() 的匿名类。因此它和手写的函数对象在行为上完全等价,只是写法更紧凑。
一、基本语法
[capture](parameters) -> return_type { body }
| 部分 | 说明 |
|---|---|
[capture] | 捕获列表,决定如何访问外部变量(不可省略) |
(parameters) | 参数列表,无参数时可省略 |
-> return_type | 返回类型,可省略(编译器自动推导) |
{ body } | 函数体 |
最简单的形式:
auto add = [](int a, int b) { return a + b; };
std::cout << add(3, 4); // 7
返回类型通常可以省略。但如果函数体中有多个 return 语句且类型不一致,就必须显式指定:
auto f = [](int x) -> double {
if (x > 0) return x * 1.0;
return 0.0;
};
二、捕获列表
捕获列表决定 lambda 内部能访问哪些外部变量,以及访问方式。
1. 按值捕获与按引用捕获
int a = 10, b = 20;
auto byValue = [a] { return a; }; // 拷贝 a
auto byRef = [&b] { return b; }; // 引用 b
a = 100;
b = 200;
std::cout << byValue(); // 10(捕获时的副本)
std::cout << byRef(); // 200(引用的是原变量)
| 捕获方式 | 写法 | 特点 |
|---|---|---|
| 按值 | [x] | 拷贝一份,lambda 内默认只读 |
| 按引用 | [&x] | 引用原变量,lambda 内可修改 |
| 全部按值 | [=] | 隐式按值捕获所有用到的变量 |
| 全部按引用 | [&] | 隐式按引用捕获所有用到的变量 |
| 混合 | [=, &x] | 默认按值,x 按引用 |
| 混合 | [&, x] | 默认按引用,x 按值 |
2. mutable
按值捕获的变量在 lambda 内默认是 const 的,要修改副本必须加 mutable:
int count = 0;
auto counter = [count]() mutable {
return ++count; // 修改的是副本
};
std::cout << counter(); // 1
std::cout << counter(); // 2
std::cout << count; // 0(原变量未变)
Warning
按引用捕获的生命周期陷阱
lambda 不会延长被引用对象的生命周期。如果 lambda 的生命周期超过了被引用变量,就会产生悬垂引用:
auto makeLambda() { int local = 42; return [&local] { return local; }; // 错误:local 已销毁 } auto f = makeLambda(); f(); // 未定义行为需要返回 lambda 时,应改为按值捕获。
3. 初始化捕获(C++14)
初始化捕获(init-capture)允许在捕获时执行表达式,并给捕获的变量起新名字:
// 移动捕获:把 move-only 对象移入 lambda
auto ptr = std::make_unique<int>(42);
auto f = [p = std::move(ptr)] { return *p; }; // ptr 被移入 lambda
// 捕获时计算
int x = 10;
auto g = [y = x * 2] { return y; }; // y = 20
这是唯一能把 move-only 类型(如 unique_ptr)放进 lambda 的方式。
4. 捕获 this
在成员函数中,[this] 捕获的是指针,[*this](C++17)捕获的是对象副本:
class Widget
{
int value_ = 42;
public:
auto byPointer()
{
return [this] { return value_; }; // 引用当前对象
}
auto byCopy()
{
return [*this] { return value_; }; // 拷贝整个对象(C++17)
}
};
[this] 的 lambda 在对象销毁后调用会悬垂;[*this] 则安全。
三、泛型 Lambda
C++14 起,参数可以用 auto,此时 lambda 的 operator() 变成一个模板:
auto print = [](auto x) { std::cout << x << '\n'; };
print(42); // int
print("hello"); // const char*
print(3.14); // double
C++20 起可以显式写模板参数列表,从而使用 Concepts 约束:
auto add = []<typename T>(T a, T b) { return a + b; };
// 带约束
auto integralAdd = []<std::integral T>(T a, T b) { return a + b; };
显式模板参数还能解决一个常见问题——对 vector 的 size() 求值:
// C++20 前:需要额外处理
auto before = [](auto&& v) { return v.size(); };
// C++20:可以正确推导
auto after = []<typename T>(std::vector<T> const& v) { return v.size(); };
四、其它特性
1. constexpr Lambda(C++17)
如果 lambda 满足 constexpr 函数的要求,operator() 自动是 constexpr:
constexpr auto square = [](int x) { return x * x; };
static_assert(square(5) == 25);
2. 无捕获 Lambda 转换为函数指针
无捕获的 lambda 可以隐式转换为函数指针,因此能传给 C 风格 API:
void c_function(int (*callback)(int));
c_function([](int x) { return x * 2; }); // OK:无捕获
有捕获的 lambda 不能这样转换,因为需要携带状态。
3. 立即调用
lambda 定义后可以立刻调用,常用于初始化复杂常量:
const int value = [] {
// 复杂的计算过程
return 42;
}();
4. 递归 Lambda
lambda 无法直接调用自己(因为名字还没定义完)。常见做法是把自己作为参数传入:
auto factorial = [](auto self, int n) -> int {
return n <= 1 ? 1 : n * self(self, n - 1);
};
std::cout << factorial(factorial, 5); // 120
C++23 起可以用 this auto self 显式对象参数,写法更自然:
auto factorial = [](this auto self, int n) -> int {
return n <= 1 ? 1 : n * self(n - 1);
};
五、与函数对象的关系
lambda 在编译期被展开成一个匿名类:
int threshold = 10;
auto pred = [threshold](int x) { return x > threshold; };
// 大致等价于:
class __Lambda
{
int threshold_;
public:
__Lambda(int t) : threshold_(t) {}
bool operator()(int x) const { return x > threshold_; }
};
这解释了 lambda 的几个特性:
- 每个 lambda 都有独一无二的类型,因此不能用
decltype之外的方式声明,通常用auto接收。 - 两个写法相同的 lambda 也是不同类型。
- 需要存储 lambda 时,如果类型必须统一,可以用
std::function(有类型擦除开销)。
六、常见用法
配合标准库算法是最典型的场景:
std::vector<int> v{5, 2, 8, 1, 9};
// 排序:按绝对值降序
std::sort(v.begin(), v.end(), [](int a, int b) {
return std::abs(a) > std::abs(b);
});
// 查找:第一个大于 5 的元素
auto it = std::find_if(v.begin(), v.end(), [](int x) { return x > 5; });
// 统计:偶数个数
int evens = std::count_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; });
// 变换:每个元素平方
std::transform(v.begin(), v.end(), v.begin(), [](int x) { return x * x; });
// 移除:删除所有小于 3 的元素
v.erase(std::remove_if(v.begin(), v.end(), [](int x) { return x < 3; }), v.end());
七、注意事项
- 按引用捕获有生命周期风险。lambda 不会延长被引用对象的生命周期,返回 lambda 时应按值捕获。
[=]捕获this是陷阱。在成员函数中,[=]捕获的是this指针而非对象副本,对象销毁后调用会悬垂。C++20 起这种隐式捕获已被弃用,应显式写[this]或[*this]。mutable只影响副本。加了mutable后修改的是 lambda 内部的副本,原变量不变。- 每个 lambda 类型唯一。不要试图写出 lambda 的类型,用
auto或std::function。 std::function有开销。它涉及类型擦除和可能的堆分配,性能敏感处应直接用auto或模板参数。- 捕获列表不能捕获类成员。
[x]中的x必须是变量,类成员需要用[x = x]或[this]。 - 无捕获 lambda 才能转函数指针。有捕获的 lambda 携带状态,无法转换。
八、相关章节
- 函数:函数对象与函数指针
- 模板:泛型 lambda 与模板的关系
- Utility:
std::function与std::bind - Algorithms:算法中 lambda 的典型用法
- 多线程与并发:lambda 作为线程函数
类与对象
C++ 是一种支持面向对象编程(Object-Oriented Programming, OOP)的语言。 在面向对象的设计思想中,类(Class)是 C++ 的核心特性之一,常被称为用户自定义的数据类型(user-defined type)。
类用于定义对象的结构与行为,是一种将数据与操作这些数据的函数封装在一起的抽象描述。 在类中,用于存储数据的部分称为成员变量(Member Variables),而用于操作这些数据的函数称为成员函数(Member Functions)。
类本质上是一种模板(Template)或蓝图(Blueprint),通过它可以创建出多个具有相同属性和行为的具体实例,这些实例被称为对象(Objects)。每个对象都拥有属于自己的成员变量副本,并可以通过成员函数来执行特定的操作。
一、类的结构
在 C++ 中,类(Class)是一种由用户定义的数据类型(User-defined Data Type), 它将数据(成员变量)与操作数据的函数(成员函数)有机地结合在一起, 从而实现封装(Encapsulation)与抽象(Abstraction)。
一个类的基本结构如下:
class ClassName {
private:
// 私有成员(数据与函数)
protected:
// 受保护成员
public:
// 公有成员
};
1. 类的基本组成
类由以下几个主要部分构成:
| 组成部分 | 说明 |
|---|---|
| 类名(Class Name) | 类的标识符,用于定义和引用该类。 |
| 成员变量(Member Variables) | 用于存储对象状态的数据。 |
| 成员函数(Member Functions) | 用于操作数据或定义对象行为的函数。 |
| 访问控制符(Access Specifiers) | 控制外部对类成员的访问权限:private、protected、public。 |
| 构造函数与析构函数(Constructor & Destructor) | 对象创建与销毁时自动调用的特殊成员函数。 |
| 静态成员(Static Members) | 属于类本身而非某个对象的成员。 |
| 友元(Friend) | 特殊访问权限,允许外部函数或类访问私有成员。 |
2. 访问控制符(Access Specifiers)
访问控制符用于限定类成员的可见性和访问范围:
| 控制符 | 访问范围 | 典型用途 |
|---|---|---|
private | 仅类内可访问 | 封装内部实现细节 |
protected | 类内和子类可访问 | 继承时保留部分访问权限 |
public | 任何地方都可访问 | 提供对外接口 |
示例:
class Example {
private:
int secret; // 私有成员
protected:
int semi_secret; // 受保护成员
public:
int visible; // 公有成员
};
默认情况下,
class的成员默认是 private, 而struct的成员默认是 public。
3. 成员变量(Member Variables)
成员变量用于存储对象的状态。 每个对象都会拥有独立的一份成员变量副本。
class Student {
private:
std::string name;
int age;
};
成员变量可以是基本类型、对象、指针、引用、数组、容器等。
也可以为成员变量提供默认初始值(C++11 起支持):
class Point {
int x = 0;
int y = 0;
};
4. 成员函数(Member Functions)
成员函数用于定义对象的行为,通常用于访问和修改成员变量。
class Student {
private:
std::string name;
int age;
public:
void setInfo(const std::string& n, int a) {
name = n;
age = a;
}
void display() {
std::cout << "Name: " << name << ", Age: " << age << std::endl;
}
};
也可以在类外定义成员函数:
void Student::display() {
std::cout << "Name: " << name << ", Age: " << age << std::endl;
}
5. 构造函数(Constructor)
构造函数在对象创建时自动执行,用于初始化成员变量。 它与类同名,无返回值。
class Student {
private:
std::string name;
int age;
public:
// 构造函数
Student(const std::string& n, int a) : name(n), age(a) {
std::cout << "Constructor called." << std::endl;
}
};
构造函数的种类包括:
| 类型 | 说明 | 示例 |
|---|---|---|
| 默认构造函数 | 无参版本 | Student() |
| 有参构造函数 | 初始化时传参 | Student("张三", 20) |
| 拷贝构造函数 | 用已有对象创建新对象 | Student(const Student& s) |
构造函数支持多态,可以使用多个参数不同的构造函数,在使用时会自动匹配。
6. 析构函数(Destructor)
析构函数在对象销毁时自动调用,用于资源释放或清理。
class Student {
public:
~Student() {
std::cout << "Destructor called." << std::endl;
}
};
析构函数名以
~开头,无参、无返回值,每个类最多有一个析构函数。
7. this 指针
this 是一个隐含指针,指向调用成员函数的当前对象。
它可用于区分成员变量与同名参数:
class Student {
private:
std::string name;
public:
Student(const std::string& name) {
this->name = name; // 区分成员变量与参数
}
};
8. 静态成员(Static Members)
静态成员属于类本身,而非具体对象。 所有对象共享同一份静态数据。
静态成员分为两类:
- 静态成员变量(Static Member Variables)
- 静态成员函数(Static Member Functions)
(1)静态成员变量
静态成员变量在所有对象之间共享同一份存储空间。 它不依赖于任何对象存在,无论创建多少个对象,这个变量都只有一份。
因此,静态成员变量常用于表示类级别的公共信息,例如计数器、配置、全局状态等。
class Counter {
public:
static int count;
Counter() { count++; }
};
int Counter::count = 0;
(2)静态函数变量
静态成员函数同样属于类本身,而不是对象。 它的主要特征是:
- 不依赖任何对象实例
- 无法访问非静态成员变量或函数(因为没有具体对象可供操作)
- 通常用于类级别的操作,如访问静态数据或执行与对象无关的逻辑。
- 没有
this指针,因为它不属于任何对象
class Counter {
private:
static int count;
public:
Counter() { count++; }
// 静态成员函数
static void showCount() {
std::cout << "Current count: " << count << std::endl;
}
};
// 类外定义静态变量
int Counter::count = 0;
int main() {
Counter a, b;
Counter::showCount(); // 通过类名访问
a.showCount(); // 也可通过对象访问
}
9. 常成员(const Members)
- 常成员函数:在函数后加
const,表示不修改成员变量。 - 常对象:对象一旦定义,其状态不可更改。
class Point {
private:
int x, y;
public:
void display() const {
std::cout << "(" << x << ", " << y << ")" << std::endl;
}
};
10. 友元(Friend)
友元函数或友元类可以访问类的私有成员。 这在操作符重载、类间紧密协作时非常有用。
#include <iostream>
class Box {
private:
int width;
public:
void set_width(int val){
width = val;
}
friend void printWidth(Box b);
};
void printWidth(Box b) {
std::cout << "Width: " << b.width << std::endl;
}
int main(){
Box a;
a.set_width(20);
printWidth(a);
}
友元会破坏封装性,应谨慎使用。
友元函数不是成员函数,不能用 对象.函数() 方式调用。它只是可以访问类私有成员的普通函数,调用方式与普通函数相同。
11. 类的组合与嵌套
一个类可以将另一个类作为成员,这种关系称为组合(Composition)。
class Address {
public:
std::string city;
};
class Person {
public:
std::string name;
Address addr; // 组合
};
当对象销毁时,其组合成员也会自动销毁。
二、对象
在 C++ 中,对象(Object)是类的实例(Instance)。 当我们定义了一个类后,这个类本身只是一个抽象的模板或蓝图, 而对象才是真正占用内存、可操作的具体实体。
类定义了“事物的共性”,对象体现了“事物的个性”。
1. 对象的创建与定义
定义一个类对象与定义普通变量非常相似:
class Student {
public:
std::string name;
int age;
};
int main() {
Student s1; // 创建对象 s1
s1.name = "张三";
s1.age = 20;
Student s2; // 再创建一个对象 s2
s2.name = "李四";
s2.age = 21;
}
每个对象都有独立的成员变量副本,互不影响。
例如:
s1.age = 20;
s2.age = 21;
// 修改 s1 的 age 不会影响 s2
2. 对象的初始化
当类中定义了构造函数时,对象可以在定义时直接初始化:
class Point {
private:
int x, y;
public:
Point(int a, int b) { // 构造函数
x = a;
y = b;
}
void show() {
std::cout << "(" << x << ", " << y << ")" << std::endl;
}
};
int main() {
Point p1(1, 2); // 调用构造函数
Point p2 = Point(3, 4); // 另一种写法
p1.show();
p2.show();
}
注意:对象创建时,构造函数会被自动调用;对象销毁时,析构函数会被自动调用。
3. 对象的作用域与生命周期
对象的生命周期与其定义的位置有关。
| 定义位置 | 生命周期说明 |
|---|---|
| 局部对象(栈上) | 作用域结束时自动销毁 |
| 全局对象 | 程序结束时销毁 |
| 静态对象 | 程序结束时销毁 |
| 动态对象(堆上) | 需要手动释放(使用 new / delete) |
示例:
class Example {
public:
Example() { std::cout << "Constructed\n"; }
~Example() { std::cout << "Destructed\n"; }
};
int main() {
Example local; // 局部对象
static Example global; // 静态对象
Example* ptr = new Example(); // 动态对象
delete ptr; // 手动释放
}
输出顺序体现了不同对象的生命周期管理。
4. 对象数组
可以定义一个包含多个对象的数组:
class Point {
public:
int x, y;
Point(int a = 0, int b = 0) : x(a), y(b) {}
};
int main() {
Point arr[3] = { {1,2}, {3,4}, {5,6} };
for (auto& p : arr)
std::cout << "(" << p.x << ", " << p.y << ")\n";
}
若类中没有默认构造函数,则必须在定义对象数组时显式提供初始化参数。
5.对象的指针与引用
(1)对象指针
和基本类型类似,可以使用指针指向对象。
Student s1;
Student* ptr = &s1;
ptr->name = "王五";
ptr->age = 22;
使用
->运算符访问对象的成员。
也可以使用 new 创建动态对象:
Student* stu = new Student;
stu->name = "赵六";
stu->age = 18;
delete stu; // 释放内存
(2)对象引用
引用可以直接操作已有对象:
Student s1;
Student& ref = s1;
ref.name = "张三";
引用不会创建新对象,只是为已有对象取别名。
6. 对象的拷贝与赋值
当我们用一个对象初始化另一个对象时,会自动调用拷贝构造函数。
class Box {
public:
int width;
Box(int w) : width(w) {}
Box(const Box& b) { // 拷贝构造函数
width = b.width;
std::cout << "Copy constructor called\n";
}
};
int main() {
Box b1(10);
Box b2 = b1; // 调用拷贝构造函数
}
赋值操作调用的是赋值运算符(operator=),而不是拷贝构造。
7. const 对象
可以将对象声明为常量,使其内容不可被修改:
class Point {
public:
int x, y;
void show() const {
std::cout << x << ", " << y << std::endl;
}
};
int main() {
const Point p = {1, 2};
p.show();
// p.x = 5; // 错误:常对象不可修改成员
}
常对象只能调用常成员函数(即声明为
void func() const的函数)。
8. 对象之间的比较与赋值
对象可以相互赋值:
Student s1, s2;
s1.name = "A";
s2 = s1; // 成员变量逐个拷贝
但若希望对象间的比较(==、< 等)有意义,需要重载运算符(进阶内容,后续讲述)。
9. 对象与内存模型
每个对象在内存中都有独立的成员变量副本:
Student s1 ──► name="张三", age=20
Student s2 ──► name="李四", age=21
而静态成员在所有对象间共享。
三、完整示例
#include <iostream>
#include <string>
using namespace std;
class Student {
public:
string name;
int age;
Student(string n = "未知", int a = 0) : name(n), age(a) {
cout << "Constructed: " << name << endl;
}
~Student() {
cout << "Destructed: " << name << endl;
}
void show() const {
cout << "Name: " << name << ", Age: " << age << endl;
}
};
int main() {
Student s1("张三", 20);
Student s2("李四", 21);
s1.show();
s2.show();
Student* p = new Student("王五", 22);
p->show();
delete p; // 手动释放动态对象
}
输出:
Constructed: 张三
Constructed: 李四
Constructed: 王五
Name: 张三, Age: 20
Name: 李四, Age: 21
Name: 王五, Age: 22
Destructed: 王五
Destructed: 李四
Destructed: 张三
四、面向对象的三大特征
面向对象编程(OOP)的核心思想可以概括为三大特征:封装(Encapsulation)、继承(Inheritance)和多态(Polymorphism)。这三大特征是 C++ 类与对象设计的基础,也是理解和应用面向对象程序设计的关键。
继承与多态的内容属于C++面向对象进阶内容。
1. 封装(Encapsulation)
封装是面向对象最基本的特征之一,也是类和对象概念的核心。
封装的核心思想是将对象的状态(数据)和行为(函数)组合到一个整体——类中,并通过访问控制机制对外部可见性进行限制和保护,对受保护的私有数据仅可以使用成员函数进行操作。
作用:
- 对内部数据进行保护,只允许可信的方法或对象访问;
- 隐藏实现细节,使类的使用者无需了解内部工作原理;
- 提供统一接口,提高模块化和可维护性。
示例:
class Student {
private:
std::string name; // 私有成员,外部无法直接访问
int age;
public:
void setInfo(const std::string& n, int a) { // 提供接口修改数据
name = n;
age = a;
}
void display() const { // 提供接口访问数据
std::cout << "Name: " << name << ", Age: " << age << std::endl;
}
};
在上例中,name 和 age 仅能通过 setInfo 和 display 访问和修改,这就是封装的典型应用。
封装就是把客观事物封装成抽象类,并控制外部访问权限,保护数据安全并提高代码可维护性。
2. 继承(Inheritance)
继承允许新建的类复用已有类的属性和行为,并在此基础上扩展。被继承的类称为基类(Base Class)或父类,从基类继承的类称为派生类(Derived Class)或子类。
按继承方式不同,基类成员在派生类中的访问级别会发生变化:
| 继承方式 | 基类 public 成员 | 基类 protected 成员 | 基类 private 成员 |
|---|---|---|---|
public 继承 | public | protected | 不可访问 |
protected 继承 | protected | protected | 不可访问 |
private 继承 | private | private | 不可访问 |
class Person {
protected:
std::string name;
public:
void setName(const std::string& n) { name = n; }
};
class Student : public Person { // Student 继承 Person
private:
int grade;
public:
void setGrade(int g) { grade = g; }
void showInfo() const {
std::cout << "Name: " << name << ", Grade: " << grade << std::endl;
}
};
Student 复用了 Person 的 name,同时扩展了 grade。继承的主要作用是代码复用和构建类层次结构,它也是实现运行时多态的前提。
继承的完整规则——构造与析构顺序、多重继承、菱形继承与虚继承、
using声明改变访问级别等——见 继承。
需要说明的是,继承并非复用的唯一手段。当类之间是「has-a」(拥有)关系而非「is-a」(是)关系时,应当使用组合:在类中包含另一个类的对象作为成员。设计原则是优先使用组合,而非继承。
3. 多态(Polymorphism)
多态指同一个操作作用于不同对象时表现出不同的行为。C++ 中的多态分为两类:
| 类型 | 实现方式 | 决定时机 |
|---|---|---|
| 编译时多态(静态多态) | 函数重载、运算符重载、模板 | 编译期 |
| 运行时多态(动态多态) | 虚函数 | 运行期 |
运行时多态通过基类指针或引用调用虚函数实现:
class Animal {
public:
virtual void speak() const { std::cout << "Animal sound" << std::endl; }
};
class Dog : public Animal {
public:
void speak() const override { std::cout << "Woof!" << std::endl; }
};
int main() {
Animal* a = new Dog();
a->speak(); // 输出 "Woof!",根据实际对象类型调用
delete a;
}
关键在于 speak 被声明为 virtual:通过基类指针调用时,实际执行的是派生类的版本。多态使统一接口处理不同对象成为可能,提高了程序的灵活性和可扩展性。
虚函数的工作机制(虚函数表、动态绑定)、虚析构函数的必要性、纯虚函数与抽象类、
override与final等,见 虚函数与多态。
五、小结
封装、继承、多态构成了面向对象设计的基础:
关于运算符重载、友元、拷贝控制、模板高级应用等面向对象的进阶主题,见 面向对象进阶。
模板 (Template)
模板是C++中实现泛型编程的基础工具,泛型编程即以一种独立于任何特定类型的方式编写代码。模板是创建泛型类或函数的蓝图或公式。库容器,比如迭代器和算法,都是泛型编程的例子,它们都使用了模板的概念。它允许我们编写与类型无关的代码(如类或函数),从而提高代码的重用性。编译器在编译时会根据用户提供的类型参数,从模板生成具体的类型或函数,这个过程称为模板实例化(Template Instantiation)。
一、模板的基本类型
C++主要有两种模板:
1. 函数模板 (Function Template)
用于创建可处理不同数据类型的函数。
语法:
template <typename T>
T minimum(const T& lhs, const T& rhs) {
return lhs < rhs ? lhs : rhs;
}
// 或使用 class 关键字,效果等同于 typename
template <class T>
T maximum(const T& lhs, const T& rhs) {
return lhs > rhs ? lhs : rhs;
}
使用:
int a = 10, b = 20;
int min_val = minimum(a, b); // 编译器推导 T 为 int
double x = 3.14, y = 1.618;
double max_val = maximum<double>(x, y); // 显式指定 T 为 double
2. 类模板 (Class Template)
用于创建可处理不同数据类型的类,常用于实现容器(如 std::vector、std::pair)。
语法:
template <typename T1, typename T2>
class Pair {
public:
T1 first;
T2 second;
Pair(T1 f, T2 s) : first(f), second(s) {}
// 成员函数也可以使用模板参数
void print() const;
};
template <typename T1, typename T2>
void Pair<T1, T2>::print() const {
// ... 实现细节
}
使用:
Pair<int, double> p(10, 3.14); // 实例化 Pair<int, double> 类
二、模板参数 (Template Parameters)
模板参数可以是以下几种类型:
| 参数类型 | 描述 | 示例 |
|---|---|---|
| 类型参数 | 作为类型的占位符。使用 typename 或 class 关键字声明。 | template <typename T> |
| 非类型参数 | 模板参数可以是整型、枚举、指针、引用、std::nullptr_t 等编译期常量。 | template <typename T, size_t N> (如 std::array) |
| 模板的模板参数 | 参数本身是一个模板(较高级)。 | template <typename T, template<typename> class Container> |
默认模板参数:
模板参数可以拥有默认值(从C++11开始,函数模板也可以拥有默认模板参数)。
template <typename T, size_t N = 10> // N 的默认值为 10
struct Array { T arr[N]; };
Array<int> a; // N 默认为 10
Array<double, 5> b; // N 显式指定为 5
三、模板特化 (Template Specialization)
模板特化允许为特定的类型参数提供一个不同的实现,以优化性能或处理通用模板无法处理的情况。
1. 完全特化 (Full Specialization)
为模板的所有参数提供具体的类型或值。
语法:
// 原始函数模板
template <typename T>
T process(T value) { /* 通用实现 */ }
// int 类型的完全特化
template <> // 尖括号内为空
int process<int>(int value) {
// 针对 int 类型的优化或特殊实现
return value * 2;
}
2. 部分特化 (Partial Specialization)
只对模板的部分参数进行特化,或对参数的形式进行限制(例如,特化为指针类型)。注意:只有类模板可以进行部分特化,函数模板不能。
语法 (类模板):
// 原始类模板
template <typename T1, typename T2>
class MyPair { /* 通用实现 */ };
// 部分特化:当第二个参数为 int 时
template <typename T1>
class MyPair<T1, int> { /* T2 固定为 int 的实现 */ };
// 部分特化:当两个参数都是指针时
template <typename T1, typename T2>
class MyPair<T1*, T2*> { /* 两个参数都是指针的实现 */ };
四、关键字 typename
typename 关键字有两个主要用途:
-
在模板参数列表中,指示一个模板参数是一个类型参数(等同于
class)。template <typename T> // 或 template <class T> -
在模板定义内部,用于显式告诉编译器一个依赖于模板参数的嵌套名称是一个类型。 这是因为在编译时,编译器无法确定依赖名称是否是一个类型或静态成员。
template <typename T> void func(T value) { // 假设 T::InnerType 是 T 内部定义的类型 typename T::InnerType *ptr; // 必须加 typename }
五、模板参数推导 (Argument Deduction)
1. 函数模板参数推导
调用函数模板时,编译器通常可以根据传递的实参类型自动推导出模板参数的类型。
template <typename T> void print(T val) { /* ... */ }
print(42); // 编译器推导 T 为 int
print("abc"); // 编译器推导 T 为 const char*
2. 类模板参数推导 (CTAD, C++17)
从 C++17 开始,类模板的实例化也可以进行类型推导,无需显式指定模板参数。
// 假设 Pair 是一个类模板
// C++17 之前:
Pair<int, double> p1(1, 2.3);
// C++17 之后:
Pair p2(1, 2.3); // 编译器推导为 Pair<int, double>
六、后续内容
本章介绍的是模板的基础语法:函数模板与类模板的定义、模板参数、特化以及类型推导。
模板的进阶应用——SFINAE、可变参数模板、模板元编程、Concepts(C++20)、模板与继承的结合,以及如何用模板替代虚函数获得更高性能——见 类模板与函数模板高级应用。
C++进阶知识
C++进阶部分将深入语言内部机制与底层实现,理解编译器如何处理对象模型、内存布局、虚函数表、模板实例化以及异常机制等。
本部分不再局限于语法层面的使用,而是关注性能、抽象与控制力。内容包括高级内存管理、面向对象、多线程与并发、智能指针等内容。
只有理解语言设计背后的思想,真正做到“以C++思考”,才能具备面向底层系统优化与大型工程开发的能力。
本章内容
| 章节 | 说明 |
|---|---|
| 内存管理 | 内存布局、动态分配、智能指针、RAII、自定义分配器与内存模型 |
| 面向对象(进阶) | 运算符重载、继承、虚函数、友元、模板高级应用、拷贝控制 |
| 异常处理 | 异常语法、栈展开、异常安全保证、noexcept 与标准异常体系 |
| 多线程与并发 | 线程、互斥量与锁、条件变量、原子操作、异步任务与 C++20 同步原语 |
| 现代 C++ 演进 | C++11 至 C++26 各版本特性时间线与速查 |
C++ 内存管理
内存管理是 C++ 编程中的核心概念之一,它直接影响程序的性能、可靠性和稳定性。C++ 提供了灵活且高效的内存管理机制,这也使得开发者能够更精细地控制程序的内存使用。C++ 中的内存管理分为两大类:静态内存管理和动态内存管理。
在 C++ 中,静态内存管理主要涉及由编译器管理的栈内存,通常用于存储局部变量和函数调用的相关信息。与此不同,动态内存管理则涉及到程序运行时动态分配的内存区域,这通常需要程序员显式地分配和释放内存。
C++ 提供了一些工具来帮助开发者更高效地管理内存,避免内存泄漏、悬空指针和野指针等常见问题。在传统的内存管理方式中,开发者需要通过 new 和 delete 来手动管理动态内存的分配和释放。而为了提高开发效率和安全性,C++11 引入了智能指针,如 std::unique_ptr、std::shared_ptr 和 std::weak_ptr,它们自动管理内存的生命周期,减少了人为错误的可能性。
此外,C++ 还提供了内存池和内存对齐等更底层的内存管理技术,使得开发者可以根据需要进一步优化程序的内存使用。
本章内容
| 章节 | 说明 |
|---|---|
| 内存布局与模型 | 栈、堆、全局区、常量区与代码段的划分,对象在内存中的排布 |
| 动态内存分配 | new / delete 的机制、operator new 与 placement new、数组与对齐 |
| RAII 与资源管理 | 用对象生命周期管理资源的核心思想,以及它的各种应用形态 |
| 智能指针 | unique_ptr、shared_ptr、weak_ptr 的用法与取舍 |
| 自定义内存管理 | 分配器、内存池、pmr 与对象池 |
| 内存调试与诊断 | 常见内存错误的成因与检测工具 |
| C++ 内存模型(并发) | 原子操作、内存序与 happens-before 关系 |
几条经验
在展开细节之前,先把结论摆在这里。后面各章基本都是在解释这几条为什么成立:
- 能用栈就别用堆。栈上分配几乎零成本,而且天然异常安全。只有当对象需要活过当前作用域、或者体积过大时,才考虑堆。
- 不要手写
delete。用 RAII 把资源交给对象管理,让析构函数去释放。手动delete的代码在异常路径上几乎必然出错。 - 所有权要明确。一个资源归谁管、什么时候释放,应该在类型上就能看出来。
unique_ptr表示独占,shared_ptr表示共享,裸指针只表示「我不负责释放」。 - 先想清楚生命周期,再想性能。绝大多数内存 bug 都源于生命周期没理顺,而不是分配次数太多。
内存布局与模型
C++ 程序的内存管理建立在计算机的存储模型(Memory Model)之上。理解程序在内存中的布局,是掌握变量生命周期、作用域、动态分配与释放机制的基础。 一个典型的 C++ 程序在运行时会被划分为多个区域,每个区域负责存储不同类型的数据,并具有各自的生命周期与访问特性。
一、程序的五个内存区域
一个进程的地址空间大致可以分成五块:
高地址
┌──────────────────┐
│ 栈 Stack │ ← 向下增长
├──────────────────┤
│ ↓ │
│ │
│ ↑ │
├──────────────────┤
│ 堆 Heap │ ← 向上增长
├──────────────────┤
│ BSS 段(未初始化) │
├──────────────────┤
│ Data 段(已初始化)│
├──────────────────┤
│ 常量区 .rodata │
├──────────────────┤
│ 代码段 .text │
└──────────────────┘
低地址
| 区域 | 存放内容 | 生命周期 | 大小 |
|---|---|---|---|
| 栈 | 局部变量、函数参数、返回地址 | 随作用域结束 | 编译期确定,通常 1~8 MB |
| 堆 | new / malloc 分配的对象 | 手动控制 | 受限于可用内存 |
| BSS 段 | 未初始化的全局/静态变量 | 整个程序运行期 | 编译期确定 |
| Data 段 | 已初始化的全局/静态变量 | 整个程序运行期 | 编译期确定 |
| 常量区 | 字符串字面量、const 全局量 | 整个程序运行期 | 编译期确定 |
| 代码段 | 机器指令 | 整个程序运行期 | 编译期确定 |
看一段代码,把变量对号入座:
int global_init = 42; // Data 段
int global_uninit; // BSS 段
const char* msg = "hello"; // 指针在 Data 段,"hello" 在常量区
void func()
{
int local = 1; // 栈
static int static_local = 2; // Data 段(只初始化一次)
const char* p = "world"; // 指针在栈,"world" 在常量区
int* heap = new int(3); // 指针在栈,对象在堆
delete heap;
}
常量区的字符串是只读的。
"hello"[0] = 'H'是未定义行为,在多数平台上会直接段错误。
二、栈
栈由编译器自动管理,分配和释放只是移动栈指针,因此极快——通常就是一条指令。
栈帧
每次函数调用都会在栈上压入一个栈帧(stack frame),包含:
高地址
┌──────────────────┐
│ 调用者的局部变量 │
├──────────────────┤
│ 函数参数 │
├──────────────────┤
│ 返回地址 │
├──────────────────┤
│ 保存的寄存器 │
├──────────────────┤
│ 被调用者的局部变量 │
└──────────────────┘
低地址
函数返回时整个栈帧被弹出,其中的局部对象按逆序析构。
栈溢出
栈空间有限(Linux 默认 8 MB),以下情况会导致栈溢出:
void recurse(int n)
{
int buffer[10000]; // 每次调用占用 40 KB 栈空间
recurse(n + 1); // 无限递归,很快耗尽栈
}
int main()
{
int huge[10'000'000]; // 约 40 MB,直接溢出
}
大数组应该放到堆上,或者用 std::vector:
std::vector<int> huge(10'000'000); // 数据在堆上,安全
栈上的对象天然异常安全
栈对象的析构由编译器插入,无论函数是正常返回还是异常退出,析构都会执行。这是 RAII 能成立的前提。
三、堆
堆是程序运行时可以自由申请的内存区域。它的大小只受限于系统可用内存,代价是:
- 分配慢:需要在空闲链表中找合适的块,可能触发系统调用
- 需要手动管理:必须显式释放,否则泄漏
- 容易碎片化:反复分配释放不同大小的块,会留下难以利用的空洞
int* p = new int(42); // 分配
// ... 使用
delete p; // 释放,忘记这步就泄漏了
堆的分配器实现相当复杂(glibc 的 malloc 有几千行),但基本思路是维护若干空闲链表,按大小分类管理。
四、对象的内存布局
1. 成员排布与填充
编译器会按成员声明顺序排布,但为了对齐,可能插入填充字节:
struct A
{
char c; // 1 字节
// 3 字节填充
int i; // 4 字节
char d; // 1 字节
// 3 字节填充(尾部对齐到 4 的倍数)
};
// sizeof(A) == 12,而不是 6
调整成员顺序可以减小体积:
struct B
{
int i; // 4
char c; // 1
char d; // 1
// 2 字节填充
};
// sizeof(B) == 8
2. 对齐规则
每个类型都有一个对齐要求(alignof(T)),通常是其大小的约数。规则是:
- 成员的偏移量必须是其对齐值的整数倍
- 结构体的总大小必须是最大成员对齐值的整数倍
std::cout << alignof(int) << '\n'; // 4
std::cout << alignof(double) << '\n'; // 8
std::cout << alignof(char) << '\n'; // 1
C++11 起可以用 alignas 强制指定对齐:
struct alignas(16) Vec4
{
float x, y, z, w; // 16 字节,正好对齐到 SIMD 寄存器宽度
};
3. 空类与继承
空类的大小是 1 而不是 0——因为每个对象必须有唯一地址:
struct Empty {};
std::cout << sizeof(Empty); // 1
但空基类优化(EBO)可以让空基类不占空间:
struct Empty {};
struct WithMember : Empty
{
int x;
};
std::cout << sizeof(WithMember); // 4,Empty 被优化掉了
4. 虚函数与虚表指针
含有虚函数的类,对象里会多一个虚表指针(vptr),指向该类的虚函数表:
struct Base
{
virtual void f();
int x;
};
std::cout << sizeof(Base); // 16(64 位系统:8 字节 vptr + 4 字节 int + 4 填充)
多个虚函数不会增加对象大小——它们共享同一张虚表。
5. 查看实际布局
offsetof 可以查看成员偏移:
#include <cstddef>
struct A { char c; int i; };
std::cout << offsetof(A, c) << '\n'; // 0
std::cout << offsetof(A, i) << '\n'; // 4
五、字节序
多字节整数在内存中的排列顺序有两种:
| 类型 | 低字节位置 | 典型平台 |
|---|---|---|
| 小端(little-endian) | 低地址 | x86、ARM(默认) |
| 大端(big-endian) | 高地址 | 部分嵌入式、网络字节序 |
int x = 0x12345678;
// 小端内存布局(从低地址到高地址):
// 78 56 34 12
C++20 提供了 std::endian 检测:
#include <bit>
if constexpr (std::endian::native == std::endian::little)
std::cout << "小端\n";
字节序只在跨平台传输数据时才需要关心。同一平台内的内存操作不受影响。
六、常见误区
「栈比堆快,所以尽量用栈」 —— 分配本身确实是栈快,但如果对象很大,栈的拷贝成本可能更高。而且栈空间有限,几十 MB 的数组只能放堆上。
「sizeof 就是成员大小之和」 —— 忽略了填充。结构体大小取决于成员顺序和对齐要求。
「指针大小等于所指向对象的大小」 —— 指针大小固定(64 位系统上是 8 字节),与指向什么无关。
「new 一定返回连续内存」 —— 单次 new T[n] 是连续的,但多次 new 之间没有任何位置保证。
「局部变量的地址可以返回」 —— 函数返回后栈帧被回收,返回局部变量的指针或引用是悬垂引用。
七、相关章节
- 动态内存分配:
new/delete的细节 - RAII 与资源管理:利用栈对象的析构自动释放资源
- 内存调试与诊断:越界、泄漏等问题的排查
- C++ 内存模型(并发):多线程下的内存可见性
动态内存分配
栈上的对象由编译器管理,出了作用域就自动销毁。但有些对象必须活得更久——比如跨函数传递的数据、大小在运行时才能确定的数组。这类对象需要放在堆上,由程序员(或封装好的工具)负责申请和释放。
这一章讲 new / delete 到底做了什么,以及它们有哪些容易踩的地方。
一、new 和 delete 做了什么
很多人以为 new 就是「分配内存」,其实它做两件事:
- 调用
operator new分配原始内存 - 在这块内存上构造对象(调用构造函数)
delete 同样做两件事,顺序相反:
- 调用对象的析构函数
- 调用
operator delete释放内存
MyClass* p = new MyClass(42);
// 等价于:
// void* mem = operator new(sizeof(MyClass));
// MyClass* p = new (mem) MyClass(42); // placement new
delete p;
// 等价于:
// p->~MyClass();
// operator delete(p);
理解这个分解很重要,后面几个话题都建立在它之上。
二、operator new 与 operator delete
operator new 是可以重载的全局函数,默认实现就是调用 malloc:
// 全局 operator new 的签名
void* operator new(std::size_t size);
void* operator new[](std::size_t size);
void operator delete(void* ptr) noexcept;
void operator delete[](void* ptr) noexcept;
重载它们可以接管整个程序的堆分配:
void* operator new(std::size_t size)
{
std::cout << "分配 " << size << " 字节\n";
if (void* p = std::malloc(size))
return p;
throw std::bad_alloc();
}
void operator delete(void* ptr) noexcept
{
std::free(ptr);
}
也可以只为某个类重载:
class Pooled
{
public:
static void* operator new(std::size_t size);
static void operator delete(void* ptr) noexcept;
};
这常用于对象池、内存追踪等场景。
new失败时默认抛std::bad_alloc。如果希望像malloc那样返回nullptr,可以用new (std::nothrow) T。
三、placement new
placement new 允许在已分配的内存上构造对象,不分配新内存:
#include <new>
// 1. 先分配原始内存
void* buffer = std::malloc(sizeof(MyClass));
// 2. 在已有内存上构造对象
MyClass* p = new (buffer) MyClass(42);
// 3. 手动析构(不能 delete!)
p->~MyClass();
// 4. 释放原始内存
std::free(buffer);
这里绝对不能调用 delete p——因为内存不是 operator new 分配的,delete 会试图释放一块不属于它的内存。必须手动调用析构函数,再自己释放缓冲区。
标准库容器正是这样实现的:std::vector 先分配一块原始内存,再用 placement new 逐个构造元素。
四、new[] 与 delete[]
数组版本的 new 会在内存头部额外记录元素个数,delete[] 需要这个信息来逐个调用析构函数:
MyClass* arr = new MyClass[10];
delete[] arr; // 正确:析构 10 个对象
// delete arr; // 错误:只析构 1 个,且释放方式不匹配
必须配对使用。用 new[] 分配却用 delete 释放(或反之)是未定义行为。
对于没有析构函数的类型(如 int),编译器通常不会额外记录长度,但混用仍然是未定义行为,不要依赖这一点。
现代 C++ 中,几乎不需要手写 new[]——用 std::vector 或 std::make_unique<T[]> 更安全:
auto arr = std::make_unique<int[]>(10); // 自动管理
五、内存对齐
有些类型要求内存地址必须是某个值的整数倍。比如 SIMD 类型通常要求 16 或 32 字节对齐。
C++17 起,new 会自动处理过度对齐类型:
struct alignas(64) CacheLine
{
char data[64];
};
CacheLine* p = new CacheLine; // C++17 保证 64 字节对齐
C++17 之前需要手动指定:
void* mem = aligned_alloc(64, sizeof(CacheLine));
如果需要手动管理对齐内存:
#include <memory>
// 分配 64 字节对齐、能放下 10 个 CacheLine 的内存
void* mem = std::aligned_alloc(64, sizeof(CacheLine) * 10);
std::free(mem);
六、常见错误
1. 忘记释放
void leak()
{
int* p = new int(42);
// 忘记 delete p
}
短命程序里泄漏可能无所谓,但长期运行的服务会慢慢吃光内存。
2. 重复释放
int* p = new int(42);
delete p;
delete p; // 未定义行为,通常直接崩溃
释放后应该把指针置空,或者干脆用智能指针:
delete p;
p = nullptr; // 至少让第二次 delete 变成安全的空操作
3. 释放后继续使用
int* p = new int(42);
delete p;
std::cout << *p; // 悬垂指针,未定义行为
这块内存可能已被重新分配给别的对象,读到的值无法预料。
4. 释放非堆内存
int local = 42;
int* p = &local;
delete p; // 灾难:试图释放栈内存
5. 混用 malloc/free 与 new/delete
int* p = (int*)std::malloc(sizeof(int));
delete p; // 错误:malloc 配 free,new 配 delete
int* q = new int(42);
std::free(q); // 错误
两套机制的内存管理方式不同,混用是未定义行为。
七、异常安全
new 可能抛出 std::bad_alloc,这让手动管理内存更容易出错:
void risky()
{
MyClass* a = new MyClass();
MyClass* b = new MyClass(); // 如果这里抛异常,a 就泄漏了
// ... 使用
delete b;
delete a;
}
如果第二个 new 失败,a 指向的内存永远不会被释放。正确做法是用 RAII:
void safe()
{
auto a = std::make_unique<MyClass>();
auto b = std::make_unique<MyClass>(); // 抛异常时 a 会自动释放
}
这是「不要手写 delete」这条建议的核心理由——异常路径上的清理太容易遗漏了。
八、什么时候该用堆
堆分配有成本,不是所有对象都该放堆上:
| 场景 | 建议 |
|---|---|
| 局部临时对象 | 栈 |
| 大小在编译期确定的小对象 | 栈 |
| 需要活过作用域的对象 | 堆(用智能指针) |
| 大小运行时才能确定 | 堆(用 std::vector) |
| 体积很大(几 MB 以上) | 堆 |
| 多态对象的动态创建 | 堆(用智能指针) |
一个经验法则:如果对象的生命周期不需要超出当前作用域,就别放堆上。
九、相关章节
- 内存布局与模型:栈与堆的区别
- RAII 与资源管理:用对象管理资源,避免手写 delete
- 智能指针:
unique_ptr与shared_ptr - 自定义内存管理:内存池与分配器
- 内存调试与诊断:泄漏与越界的排查
Smart Pointer
智能指针是 RAII 在内存管理上的标准实现。它把「指向对象的指针」和「负责释放」这两件事绑在一起,让堆对象的生命周期由对象自己管理,而不是靠程序员记得写 delete。
C++11 引入了三种智能指针,各有明确的使用场景:
| 类型 | 所有权 | 拷贝 | 适用场景 |
|---|---|---|---|
std::unique_ptr | 独占 | 不可拷贝,可移动 | 默认选择,单一所有者 |
std::shared_ptr | 共享 | 可拷贝,引用计数 | 多个对象共享同一资源 |
std::weak_ptr | 不拥有 | 可拷贝 | 打破 shared_ptr 的循环引用 |
此外还有 std::auto_ptr,它是 C++98 的产物,因为拷贝时隐式转移所有权这一设计缺陷,在 C++11 被弃用、C++17 被移除。现在只作为历史知识了解即可,见 auto_ptr。
怎么选
大部分情况下答案很简单:
- 优先用
unique_ptr。它没有额外开销(大小和裸指针一样),语义清晰。需要转移所有权时用std::move。 - 确实需要共享时才用
shared_ptr。它要维护控制块和引用计数,有原子操作开销。 - 出现循环引用时用
weak_ptr打破。它不增加引用计数,只观察对象是否还活着。
一个常见的误区是「shared_ptr 更安全所以都用它」。实际上共享所有权会让生命周期变得难以推理——你不知道对象什么时候被销毁。能用独占就用独占。
本章内容
- unique_ptr:独占所有权、自定义删除器、工厂函数
- shared_ptr:引用计数、控制块、线程安全、
enable_shared_from_this - weak_ptr:打破循环引用、缓存场景、
lock()用法 - auto_ptr:被移除的旧智能指针,以及它为什么被移除
std::auto_ptr
std::auto_ptr 是 C++98 中引入的智能指针类型,它提供了一种自动管理动态内存的方式。其主要功能是通过自动销毁对象来避免内存泄漏。然而,由于其所有权转移的语义问题(例如,拷贝操作时所有权隐式转移),std::auto_ptr 在 C++11 中被 弃用,并且在 C++17 中被 完全移除。因此,建议开发者使用 std::unique_ptr 来替代 std::auto_ptr。
1. 定义
std::auto_ptr 的模板定义如下:
template<class T>
class auto_ptr;
它是一个模板类,接受一个类型 T,并提供对该类型的自动内存管理。
std::auto_ptr 还有一个专用的模板版本:
template<>
class auto_ptr<void>;
这个版本用于不处理任何具体类型的 void 指针,但它通常不用于常规编程中,因为它并没有实际的意义。
2. 基本功能
std::auto_ptr 自动管理其指向的动态内存资源。其基本特点是:
- 在
auto_ptr被销毁时,自动释放其指向的对象。 - 所有权转移:当
auto_ptr被拷贝时,源auto_ptr的所有权被转移到目标auto_ptr,这意味着源指针不再持有资源。这一行为可能会导致意外的内存管理问题。
3. 重要特点
-
所有权转移:
std::auto_ptr的最具争议的特性就是它的 所有权转移。在拷贝构造函数或拷贝赋值运算符中,auto_ptr会转移所有权(即源指针将失去对对象的所有权,而目标指针会成为新的所有者)。这种行为与 C++ 中其他类型的智能指针(如std::unique_ptr)不同,容易导致不易察觉的 bug,尤其是在多次使用相同的对象时。
-
析构时自动释放资源:
- 在
auto_ptr被销毁时,它会自动释放它持有的内存。其析构函数会调用所管理对象的析构函数来释放资源,因此,开发者不需要显式地调用delete。
- 在
-
不支持复制语义:
- 由于所有权转移,
std::auto_ptr不支持复制操作(即不支持拷贝构造和拷贝赋值),它只支持移动语义。
- 由于所有权转移,
4. 使用示例
#include <iostream>
#include <memory>
class MyClass {
public:
MyClass() { std::cout << "MyClass constructor\n"; }
~MyClass() { std::cout << "MyClass destructor\n"; }
};
int main() {
// 创建一个 auto_ptr 指向 MyClass
std::auto_ptr<MyClass> ptr1(new MyClass());
// 通过拷贝构造将 ptr1 的所有权转移到 ptr2
std::auto_ptr<MyClass> ptr2 = ptr1; // ptr1 的所有权转移给 ptr2
// ptr1 现在为空,不能再使用它
if (!ptr1) {
std::cout << "ptr1 is now null.\n";
}
// ptr2 仍然拥有 MyClass 的资源
// 在程序结束时,ptr2 将自动释放 MyClass 对象
return 0;
}
5. 为什么 std::auto_ptr 被弃用
-
所有权转移的问题:
std::auto_ptr的一个关键问题是它的拷贝构造函数和拷贝赋值运算符会隐式地转移所有权,这会导致意外的行为和 bug。开发者可能会错误地认为一个对象仍然拥有资源,而实际上的资源已经被转移给另一个对象,导致悬空指针或多次释放同一资源。 -
现代 C++ 的替代方案: 在 C++11 中,
std::unique_ptr被引入作为std::auto_ptr的替代品。与std::auto_ptr不同,std::unique_ptr不允许拷贝操作,只允许移动操作,避免了隐式所有权转移的风险,从而更符合现代 C++ 的设计理念。 -
移动语义:
std::unique_ptr支持移动语义,它允许资源的所有权在不同对象间转移,而不会引起混淆或潜在错误。
6. 替代品
-
std::unique_ptr:std::unique_ptr是std::auto_ptr的现代替代品,它通过明确的所有权控制来避免潜在的错误。std::unique_ptr不允许拷贝,只支持通过std::move转移所有权,因此避免了所有权转移时产生的问题。 -
std::shared_ptr: 对于共享所有权的场景,std::shared_ptr可以提供引用计数功能,使得多个指针可以共同拥有资源,而在最后一个指针被销毁时释放资源。
std::unique_ptr
std::unique_ptr 是 C++11 引入的智能指针,表示对资源的独占所有权。它应该是你管理堆对象时的默认选择——没有额外开销,语义清晰,不会出现「不知道谁负责释放」的问题。
一、基本用法
#include <memory>
// 推荐:用 make_unique 创建(C++14)
auto p = std::make_unique<MyClass>(42);
// 也可以用裸指针构造(C++11)
std::unique_ptr<MyClass> p2(new MyClass(42));
p->doSomething(); // 像普通指针一样使用
(*p).doSomething();
unique_ptr 的大小和裸指针一样(64 位系统上 8 字节),因为它不需要存引用计数之类的额外信息——所有权是唯一的,销毁时机在编译期就确定了。
优先用
std::make_unique而不是new。除了更简洁,它还能避免一个微妙的异常安全问题:// 危险:如果 MyClass 构造抛异常,或者 g() 抛异常, // 都可能让 new 出来的内存泄漏 f(std::unique_ptr<MyClass>(new MyClass()), g()); // 安全 f(std::make_unique<MyClass>(), g());
二、所有权转移
unique_ptr 不能拷贝,只能移动:
auto p1 = std::make_unique<MyClass>();
// auto p2 = p1; // 编译错误:不能拷贝
auto p2 = std::move(p1); // 正确:转移所有权
// 此时 p1 变为 nullptr
if (!p1)
std::cout << "p1 已失去所有权\n";
这个限制是有意设计的。如果允许拷贝,两个 unique_ptr 会指向同一对象,销毁时就会重复释放。用 std::move 显式转移,代码里所有权流转一目了然。
作为函数参数与返回值
// 接管所有权:调用者不能再使用
void takeOwnership(std::unique_ptr<MyClass> p);
// 只借用:不涉及所有权转移
void useObject(const MyClass& obj);
void useObject(MyClass* obj);
// 返回所有权:把资源交给调用者
std::unique_ptr<MyClass> create()
{
return std::make_unique<MyClass>(); // 移动语义,无拷贝开销
}
函数返回局部 unique_ptr 时不需要写 std::move,编译器会自动应用移动(NRVO 或隐式移动)。
三、常用接口
| 接口 | 作用 |
|---|---|
get() | 返回裸指针,不转移所有权 |
release() | 放弃所有权并返回裸指针,需要手动 delete |
reset(p) | 释放当前对象,接管新指针 p(默认 nullptr) |
swap(other) | 交换两个 unique_ptr |
operator bool | 是否持有对象 |
operator* / operator-> | 访问对象 |
auto p = std::make_unique<int>(42);
int* raw = p.get(); // 只读,p 仍然拥有
p.reset(); // 释放对象,p 变为空
p.reset(new int(100)); // 释放旧的,接管新的
if (p) // 判空
std::cout << *p << '\n';
release() 要小心使用——它把所有权交还给裸指针,之后必须自己 delete:
auto p = std::make_unique<int>(42);
int* raw = p.release(); // p 变空,raw 需要手动管理
delete raw; // 别忘了
它主要用于与 C 风格接口交接所有权。
四、数组支持
unique_ptr 有数组特化版本 unique_ptr<T[]>,它使用 delete[] 而非 delete:
auto arr = std::make_unique<int[]>(10); // 10 个 int
arr[0] = 1; // 支持 operator[]
arr[9] = 10;
// 自动 delete[],无需手动管理
不过大多数情况下 std::vector 是更好的选择——它知道自己的大小,支持迭代器和算法。
五、自定义删除器
unique_ptr 的第二个模板参数是删除器,可以自定义释放方式:
template<class T, class Deleter = std::default_delete<T>>
class unique_ptr;
这让 unique_ptr 能管理任何「需要释放」的资源,不限于 new 出来的内存:
// 管理文件
auto fileDeleter = [](FILE* f) { if (f) fclose(f); };
std::unique_ptr<FILE, decltype(fileDeleter)> file(fopen("a.txt", "r"), fileDeleter);
// 管理 malloc 的内存
std::unique_ptr<int, decltype(&std::free)> buf(
(int*)std::malloc(sizeof(int) * 10), &std::free);
// 管理 socket
auto socketDeleter = [](int* fd) { if (*fd >= 0) close(*fd); delete fd; };
std::unique_ptr<int, decltype(socketDeleter)> sock(new int(fd), socketDeleter);
注意:使用自定义删除器后,unique_ptr 的大小可能变大(需要存储删除器对象)。无状态的删除器(如函数指针、无捕获 lambda)通常不增加大小,因为编译器可以做空基类优化。
六、作为类成员
unique_ptr 常用于实现 Pimpl 惯用法(Pointer to Implementation),把实现细节隐藏起来:
// widget.h
class Widget
{
public:
Widget();
~Widget(); // 必须在 .cpp 中定义
Widget(Widget&&) noexcept; // 同上
Widget& operator=(Widget&&) noexcept;
void doSomething();
private:
struct Impl;
std::unique_ptr<Impl> impl_; // 只暴露前置声明
};
// widget.cpp
struct Widget::Impl
{
std::string data;
// 复杂的实现细节
};
Widget::Widget() : impl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // Impl 在这里才是完整类型
这样做的好处:
- 头文件不需要包含实现依赖,加快编译
- 修改实现不会导致使用方重新编译
- 保持 ABI 稳定
注意析构函数必须在 .cpp 中定义——因为在头文件里 Impl 还是不完整类型,unique_ptr 的析构需要完整类型。
七、作为工厂函数返回值
工厂函数返回 unique_ptr 是标准做法,把所有权明确交给调用者:
class Shape
{
public:
virtual ~Shape() = default;
virtual void draw() const = 0;
};
std::unique_ptr<Shape> createShape(const std::string& type)
{
if (type == "circle")
return std::make_unique<Circle>();
if (type == "square")
return std::make_unique<Square>();
return nullptr;
}
// 使用
auto shape = createShape("circle");
if (shape)
shape->draw();
这里基类的析构函数必须是 virtual,否则通过基类指针删除派生类对象是未定义行为。
八、常见误区
「unique_ptr 不能放进容器」 —— 可以,但容器需要支持移动。std::vector<std::unique_ptr<T>> 是合法的,只是不能用需要拷贝的操作(如 std::copy)。
「返回 unique_ptr 一定要写 std::move」 —— 返回局部变量时不需要,编译器会隐式移动。写 std::move 反而可能阻止 NRVO 优化。
「get() 返回的指针可以随便用」 —— get() 只是借用,unique_ptr 销毁后该指针立刻悬垂。不要保存它。
「release() 之后 unique_ptr 还会释放」 —— 不会。release() 就是放弃所有权,之后必须自己管理。
「自定义删除器不影响大小」 —— 有状态的删除器会增加 unique_ptr 的大小。无状态删除器通常不增加。
「unique_ptr 可以拷贝到 shared_ptr」 —— 可以,用移动:std::shared_ptr<T> sp = std::move(up);。这是把独占所有权升级为共享所有权的唯一方式。
九、相关章节
- 智能指针:三种智能指针的对比与选择
- shared_ptr:需要共享所有权时
- RAII 与资源管理:
unique_ptr背后的设计思想 - 拷贝控制:移动语义与 Rule of Five
std::shared_ptr
std::shared_ptr 表示共享所有权:多个 shared_ptr 可以指向同一个对象,引用计数降到 0 时对象才被销毁。
它解决的是「这个对象到底该由谁释放」的问题——当多个模块都需要访问同一个对象,又无法确定谁活得最久时,共享所有权是自然的答案。代价是额外的控制块和原子操作开销。
一、基本用法
#include <memory>
// 推荐:用 make_shared 创建
auto p1 = std::make_shared<MyClass>(42);
// 也可以从裸指针构造
std::shared_ptr<MyClass> p2(new MyClass(42));
// 拷贝:引用计数增加
auto p3 = p1;
std::cout << p1.use_count(); // 3
p1.reset(); // 计数减到 2
p3 = nullptr; // 计数减到 1
// p2 销毁时计数归零,对象被释放
shared_ptr 的大小通常是裸指针的两倍(16 字节)——一个指向对象,一个指向控制块。
二、引用计数与控制块
shared_ptr 内部维护一个控制块,记录:
- 强引用计数(
shared_ptr的数量) - 弱引用计数(
weak_ptr的数量) - 删除器
- 分配器
对象在强引用计数归零时销毁,控制块在强弱引用计数都归零时才释放。
shared_ptr p1 ──┐
├──→ 控制块 ──→ 对象
shared_ptr p2 ──┘ ├─ 强引用: 2
├─ 弱引用: 0
└─ 删除器
make_shared 与 new 的差别
auto p1 = std::make_shared<MyClass>(); // 一次分配
std::shared_ptr<MyClass> p2(new MyClass()); // 两次分配
make_shared 把对象和控制块放在同一块内存里,只分配一次。这更快,缓存局部性也更好。
但它有个副作用:对象的内存要等控制块也释放后才能回收。如果存在 weak_ptr 长期观察,对象已经析构但内存不会归还。
另外,make_shared 无法指定自定义删除器。需要自定义删除器时只能用 new 构造。
三、线程安全
这是最容易误解的一点。shared_ptr 的线程安全性要分两个层面看:
| 操作 | 是否线程安全 |
|---|---|
多个线程读取同一个 shared_ptr 对象 | ✅ 安全 |
多个线程操作不同的 shared_ptr 对象(即使指向同一资源) | ✅ 安全 |
多个线程操作同一个 shared_ptr 对象 | ❌ 不安全,需要加锁 |
通过 shared_ptr 访问对象内容 | ❌ 不安全,需要自行同步 |
引用计数本身是原子的,所以「拷贝/销毁不同的 shared_ptr 实例」是安全的。但如果多个线程同时读写同一个 shared_ptr 变量(比如一个线程 reset(),另一个线程拷贝),就会产生数据竞争。
std::shared_ptr<Data> g_data;
// 线程 A
g_data = std::make_shared<Data>(); // 写
// 线程 B
auto local = g_data; // 读 —— 与 A 竞争!
正确做法是加锁,或者用 std::atomic<std::shared_ptr<T>>(C++20):
std::atomic<std::shared_ptr<Data>> g_data;
g_data.store(std::make_shared<Data>()); // 原子写
auto local = g_data.load(); // 原子读
注意:
std::atomic<std::shared_ptr<T>>在 C++20 才被正式支持。在此之前只能用std::atomic_load/std::atomic_store这些自由函数。
至于对象内容的访问,shared_ptr 完全不提供保护。多个线程同时修改同一个对象,仍然需要 mutex 等同步手段。
四、循环引用
shared_ptr 最经典的坑是循环引用:
struct Node
{
std::shared_ptr<Node> next;
~Node() { std::cout << "销毁\n"; }
};
void leak()
{
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b; // b 的计数 = 2
b->next = a; // a 的计数 = 2
}
// 函数结束,a 和 b 各自减 1,计数都是 1
// 对象永远不会销毁,析构函数不会调用
两个对象互相持有对方的 shared_ptr,计数永远降不到 0。
解决办法是把其中一条边改成 weak_ptr:
struct Node
{
std::weak_ptr<Node> next; // 不增加计数
~Node() { std::cout << "销毁\n"; }
};
现在 b->next 不影响 a 的计数,函数结束时两个对象都能正常销毁。
判断原则:如果两个对象是「父子」或「主从」关系,让子/从指向父/主用 weak_ptr。如果确实需要双向强引用,说明设计可能有问题。
五、enable_shared_from_this
有时类内部需要返回指向自己的 shared_ptr:
class Widget
{
public:
std::shared_ptr<Widget> getSelf()
{
return std::shared_ptr<Widget>(this); // 错误!
}
};
这样写会创建第二个控制块,导致同一个对象被释放两次。
正确做法是继承 enable_shared_from_this:
class Widget : public std::enable_shared_from_this<Widget>
{
public:
std::shared_ptr<Widget> getSelf()
{
return shared_from_this(); // 复用已有的控制块
}
};
使用时必须确保对象已经由 shared_ptr 管理:
auto p = std::make_shared<Widget>();
auto q = p->getSelf(); // 正确,q 和 p 共享控制块
Widget w;
auto bad = w.getSelf(); // 错误:w 不是 shared_ptr 管理的,抛 bad_weak_ptr
这个模式在异步回调中很常见——回调需要持有 this 的 shared_ptr 以保证对象存活。
六、常用接口
| 接口 | 作用 |
|---|---|
get() | 返回裸指针 |
use_count() | 返回强引用计数(调试用,多线程下不可靠) |
unique() | 是否独占(C++20 弃用) |
reset() | 释放当前对象 |
swap(other) | 交换 |
operator bool | 是否持有对象 |
owner_before(other) | 比较控制块顺序(用于有序容器) |
auto p = std::make_shared<int>(42);
std::cout << p.use_count() << '\n'; // 1
auto q = p;
std::cout << p.use_count() << '\n'; // 2
p.reset();
std::cout << q.use_count() << '\n'; // 1
use_count() 只应用于调试。多线程环境下它的返回值随时可能失效,不要用它做逻辑判断。
七、类型转换
shared_ptr 之间的转换需要专用函数,不能用 static_cast:
std::shared_ptr<Base> base = std::make_shared<Derived>();
// 向下转换
auto derived = std::dynamic_pointer_cast<Derived>(base); // 失败返回 nullptr
if (derived)
derived->derivedMethod();
// 静态转换
auto d2 = std::static_pointer_cast<Derived>(base);
// const 转换
auto c = std::const_pointer_cast<Derived>(base);
这些函数会共享控制块,引用计数正确维护。直接用 static_cast 得到裸指针再构造新的 shared_ptr 会导致双重释放。
八、什么时候不该用
shared_ptr 很方便,但共享所有权会让生命周期推理变难:
- 对象什么时候销毁? —— 计数归零时,但归零的时机可能很晚,甚至超出你的预期。
- 谁在持有? —— 需要看所有拷贝点,代码量一大就很难追踪。
因此:
| 场景 | 建议 |
|---|---|
| 单一所有者 | unique_ptr |
| 明确的父子关系 | 父持有 unique_ptr,子持有裸指针或引用 |
| 需要跨模块共享且生命周期不定 | shared_ptr |
| 缓存、观察者 | weak_ptr |
| 函数参数只是借用 | const T& 或 T* |
不要把 shared_ptr 当默认选择。它应该用在确实需要共享所有权的地方,而不是「懒得想清楚生命周期」时。
九、常见误区
「shared_ptr 是线程安全的」 —— 只有引用计数是原子的。同一个 shared_ptr 对象的并发读写、以及对象内容的并发访问都不安全。
「use_count() 可以用来判断是否独占」 —— 多线程下不可靠,标准明确说它只用于调试。
「make_shared 总是更好」 —— 它无法指定自定义删除器,且在有 weak_ptr 长期存在时会延迟内存回收。
「用 get() 得到的指针可以构造新的 shared_ptr」 —— 会创建独立控制块,导致双重释放。要用 enable_shared_from_this 或 shared_ptr 的别名构造函数。
「shared_ptr 可以替代 unique_ptr」 —— 技术上可以,但会带来不必要的开销和难以推理的生命周期。能用独占就用独占。
「两个 shared_ptr 指向同一对象就共享控制块」 —— 只有通过拷贝或 make_shared 才共享。分别用同一个裸指针构造两次,会得到两个独立控制块。
十、相关章节
- 智能指针:三种智能指针的对比与选择
- unique_ptr:独占所有权,默认选择
- weak_ptr:打破循环引用
- RAII 与资源管理:共享所有权的设计思想
- 多线程与并发:
shared_ptr在并发中的正确用法
std::weak_ptr
std::weak_ptr 是对 shared_ptr 管理对象的弱引用。它能看到对象,但不参与所有权——不增加强引用计数,因此不会阻止对象被销毁。
它主要解决两个问题:打破 shared_ptr 的循环引用,以及在缓存等场景中安全地观察对象是否还活着。
一、基本用法
weak_ptr 不能直接构造,必须从 shared_ptr 得到:
auto sp = std::make_shared<MyClass>();
std::weak_ptr<MyClass> wp = sp; // 从 shared_ptr 构造
std::cout << sp.use_count() << '\n'; // 1,weak_ptr 不影响计数
访问对象必须先转成 shared_ptr:
if (auto locked = wp.lock()) // 尝试提升
{
locked->doSomething(); // 对象还活着
}
else
{
// 对象已被销毁
}
lock() 是唯一的访问途径,这个设计是刻意的——它把「检查是否存在」和「使用对象」合成一个原子操作,避免了先检查后使用之间的竞态。
二、解决循环引用
这是 weak_ptr 最经典的用途。看一个双向链表或树结构:
struct Node
{
std::shared_ptr<Node> parent; // 指向父节点
std::vector<std::shared_ptr<Node>> children;
~Node() { std::cout << "销毁节点\n"; }
};
void leak()
{
auto parent = std::make_shared<Node>();
auto child = std::make_shared<Node>();
parent->children.push_back(child); // child 计数 = 2
child->parent = parent; // parent 计数 = 2
}
// 函数结束,两个计数都停在 1,对象永不销毁
父节点持有子节点,子节点又持有父节点,形成环。解决办法是把「反向」的那条边改成弱引用:
struct Node
{
std::weak_ptr<Node> parent; // 改成 weak_ptr
std::vector<std::shared_ptr<Node>> children;
~Node() { std::cout << "销毁节点\n"; }
};
现在 child->parent 不影响 parent 的计数,函数结束时两个对象都能正常销毁。
怎么判断哪条边该用 weak_ptr?
- 树结构:父持有子用
shared_ptr,子指回父用weak_ptr - 观察者模式:被观察者持有观察者用
shared_ptr,观察者持有被观察者用weak_ptr - 一般原则:让「拥有」的方向用强引用,「引用」的方向用弱引用
如果两个对象确实需要互相强引用,通常说明设计本身有问题——考虑能否提取出第三个对象来持有它们。
三、常用接口
| 接口 | 作用 |
|---|---|
lock() | 返回 shared_ptr,对象已销毁则返回空 |
expired() | 对象是否已销毁 |
use_count() | 强引用计数(调试用) |
reset() | 放弃观察 |
swap(other) | 交换 |
owner_before(other) | 比较控制块顺序 |
auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp;
std::cout << wp.expired() << '\n'; // false
std::cout << wp.use_count() << '\n'; // 1
sp.reset();
std::cout << wp.expired() << '\n'; // true
std::cout << wp.lock() << '\n'; // 空指针
expired()和use_count()在多线程下都不可靠——返回值可能立刻失效。要访问对象,永远用lock()再判断。
四、缓存场景
weak_ptr 的另一个常见用途是缓存:缓存不应该决定对象的生命周期,只应该「顺便记住」还活着的对象。
class ImageCache
{
public:
std::shared_ptr<Image> get(const std::string& path)
{
auto it = cache_.find(path);
if (it != cache_.end())
{
if (auto img = it->second.lock()) // 缓存还有效
return img;
cache_.erase(it); // 已失效,清掉
}
auto img = loadImage(path); // 重新加载
cache_[path] = img; // 存弱引用
return img;
}
private:
std::unordered_map<std::string, std::weak_ptr<Image>> cache_;
};
如果缓存用 shared_ptr,那么只要缓存还在,图片就永远不会被释放——缓存变成了内存泄漏。用 weak_ptr 则不会:没人用图片时它自然销毁,缓存项随之失效。
这个模式也适用于对象池、连接池等场景。
五、观察者模式
观察者需要知道被观察者是否还活着,但不应该影响它的生命周期:
class Subject;
class Observer
{
public:
virtual void onNotify() = 0;
};
class Subject
{
public:
void subscribe(std::shared_ptr<Observer> obs)
{
observers_.push_back(obs);
}
void notify()
{
// 清理已销毁的观察者,同时通知存活的
std::erase_if(observers_, [](auto& wp) {
if (auto obs = wp.lock()) {
obs->onNotify();
return false;
}
return true; // 已失效,移除
});
}
private:
std::vector<std::weak_ptr<Observer>> observers_;
};
如果这里用 shared_ptr,被观察者会一直持有观察者,导致观察者无法销毁。
六、与 shared_ptr 的关系
理解两者关系的关键是控制块:
shared_ptr ──┐
├──→ 控制块 ──→ 对象
weak_ptr ────┘ ├─ 强引用: 1
├─ 弱引用: 1
└─ 删除器
- 强引用归零 → 对象被销毁(析构函数调用)
- 强引用和弱引用都归零 → 控制块被释放
所以只要还有 weak_ptr 在观察,控制块就不会释放。这也是 make_shared 的一个副作用:对象和控制块在同一块内存里,控制块不释放,对象的内存也就无法归还给系统。
auto sp = std::make_shared<BigObject>();
std::weak_ptr<BigObject> wp = sp;
sp.reset(); // BigObject 析构,但内存还没还给系统
wp.reset(); // 现在控制块也释放了,内存才真正回收
对象大小较大、且存在长期 weak_ptr 时,用 shared_ptr<T>(new T) 可能比 make_shared 更合适。
七、常见误区
「weak_ptr 可以直接解引用」 —— 不行,没有 operator* 和 operator->。必须先 lock()。
「expired() 返回 false 就可以安全使用」 —— 不行。检查和使用之间对象可能被销毁,这是典型的竞态。要用 lock() 一次完成。
「weak_ptr 会增加引用计数」 —— 不增加强引用计数,只增加弱引用计数。对象该销毁时照样销毁。
「use_count() 可以用来判断对象是否存活」 —— 多线程下不可靠。判断存活用 lock() 或 expired(),且前者更安全。
「循环引用只能靠 weak_ptr 解决」 —— 也可以重新设计对象关系,比如用非拥有指针(裸指针、引用)替代。weak_ptr 只是最方便的方案。
「weak_ptr 可以独立存在」 —— 它必须从 shared_ptr 或另一个 weak_ptr 构造。凭空创建的 weak_ptr 是空的。
八、相关章节
- 智能指针:三种智能指针的对比与选择
- shared_ptr:引用计数与控制块
- unique_ptr:独占所有权
- RAII 与资源管理:所有权设计的一般原则
std::shared_ptr<MyClass> ptr = wp.lock(); // 转换为 shared_ptr
if (ptr) {
// 使用 ptr 来访问 MyClass 对象
}
通过这种方式,我们可以在必要时访问由 std::weak_ptr 管理的对象,但前提是该对象仍然存在。
自定义内存管理
前面几章讲的都是「怎么正确使用内存」。这一章讲另一件事:怎么让内存分配更快、更可控。
标准库的 new 和 malloc 是通用分配器,要应付各种大小、各种模式,因此实现复杂、开销不小。如果你的程序有明确的分配模式(比如大量相同大小的小对象、频繁创建销毁),自己管理内存可以带来数量级的提升。
不过要先说清楚:绝大多数程序不需要自定义分配器。先用 profiler 确认分配真的是瓶颈,再考虑优化。
一、通用分配器的问题
malloc 的典型开销:
- 查找空闲块:维护多个大小类别的空闲链表,需要遍历
- 加锁:多线程下要保护堆结构,即使用了 thread-local cache 也有开销
- 元数据:每块内存前面有头部,记录大小等信息
- 碎片化:反复分配释放不同大小的块,会留下难以利用的空洞
- 缓存不友好:对象分散在堆各处,遍历时 cache miss 频繁
一个具体例子:分配 100 万个 32 字节的小对象,malloc 实际可能占用 48 字节/个(含头部和对齐),而且每次分配都是一次函数调用加链表操作。
二、分配器接口
标准库容器允许指定分配器,接口定义在 <memory>:
template<class T>
class MyAllocator
{
public:
using value_type = T;
MyAllocator() = default;
template<class U>
MyAllocator(const MyAllocator<U>&) noexcept {}
T* allocate(std::size_t n);
void deallocate(T* p, std::size_t n);
// C++11 起,相等比较决定分配器能否互换
bool operator==(const MyAllocator&) const noexcept { return true; }
bool operator!=(const MyAllocator&) const noexcept { return false; }
};
一个最小实现:
template<class T>
T* MyAllocator<T>::allocate(std::size_t n)
{
if (n > std::numeric_limits<std::size_t>::max() / sizeof(T))
throw std::bad_alloc();
if (auto p = static_cast<T*>(std::malloc(n * sizeof(T))))
return p;
throw std::bad_alloc();
}
template<class T>
void MyAllocator<T>::deallocate(T* p, std::size_t) noexcept
{
std::free(p);
}
用法:
std::vector<int, MyAllocator<int>> v;
注意:分配器的 allocate 只分配原始内存,不构造对象。构造由容器通过 allocator_traits::construct(内部是 placement new)完成。
三、内存池
内存池的思路很简单:一次申请一大块,之后从里面切分。
1. 固定大小内存池
适合「大量相同大小的对象」场景:
class FixedPool
{
public:
explicit FixedPool(std::size_t blockSize, std::size_t blockCount)
: blockSize_(std::max(blockSize, sizeof(void*)))
{
// 一次分配所有内存
storage_ = std::malloc(blockSize_ * blockCount);
// 把空闲块串成链表
for (std::size_t i = 0; i < blockCount; ++i)
{
void* block = static_cast<char*>(storage_) + i * blockSize_;
*static_cast<void**>(block) = freeList_; // 用块本身存 next 指针
freeList_ = block;
}
}
~FixedPool() { std::free(storage_); }
void* allocate()
{
if (!freeList_)
throw std::bad_alloc();
void* block = freeList_;
freeList_ = *static_cast<void**>(freeList_); // 取出下一个
return block;
}
void deallocate(void* p) noexcept
{
*static_cast<void**>(p) = freeList_; // 头插回空闲链表
freeList_ = p;
}
private:
void* storage_ = nullptr;
void* freeList_ = nullptr;
std::size_t blockSize_;
};
分配和释放都只是链表操作,是常数时间,而且没有锁、没有元数据开销。
这个技巧叫 free list,用一个空闲块的前几个字节存下一个空闲块的地址——反正空闲块里的数据已经没用了。
2. 内存池的代价
- 不能释放单块给系统:整个池要一起释放
- 块大小固定:需要不同大小就得建多个池
- 可能浪费:如果块大小不匹配,会有内部碎片
所以内存池适合生命周期一致、大小固定的对象,不适合通用场景。
四、对象池
内存池管理原始内存,对象池则管理已构造的对象,常用于需要频繁创建销毁的场景:
template<class T>
class ObjectPool
{
public:
template<class... Args>
T* create(Args&&... args)
{
void* mem = pool_.allocate();
return new (mem) T(std::forward<Args>(args)...); // placement new
}
void destroy(T* obj)
{
obj->~T(); // 显式析构
pool_.deallocate(obj);
}
private:
FixedPool pool_{sizeof(T), 1024};
};
这在游戏开发中很常见——子弹、粒子这类对象每秒创建销毁上千次,用对象池可以避免反复的堆分配。
五、pmr:多态内存资源
C++17 引入了 <memory_resource>,提供了一套运行时多态的分配器机制。它解决了传统分配器的一个痛点:分配器类型是容器类型的一部分,改分配器就要改类型。
#include <memory_resource>
// 传统方式:分配器写进类型
std::vector<int, MyAllocator<int>> v1;
// pmr 方式:分配器是构造参数
std::pmr::vector<int> v2{&myResource};
std::pmr::vector<T> 就是 std::vector<T, std::pmr::polymorphic_allocator<T>> 的别名。
内置的内存资源
| 类型 | 说明 |
|---|---|
std::pmr::new_delete_resource() | 默认,转发到 new / delete |
std::pmr::null_memory_resource() | 任何分配都抛异常,用于测试 |
std::pmr::monotonic_buffer_resource | 单调递增,只增不减,极快 |
std::pmr::unsynchronized_pool_resource | 池化分配,单线程 |
std::pmr::synchronized_pool_resource | 池化分配,线程安全 |
monotonic_buffer_resource
这是最实用的一个。它在一块预分配的内存上单调递增地分配,不支持单独释放,直到整个资源销毁:
// 用栈上的缓冲区,避免任何堆分配
std::array<std::byte, 4096> buffer;
std::pmr::monotonic_buffer_resource pool{buffer.data(), buffer.size()};
std::pmr::vector<int> v{&pool};
v.push_back(1); // 从 buffer 分配,不碰堆
典型用法是「处理一批数据,用完整体丢弃」:
void processRequest(const Request& req)
{
std::pmr::monotonic_buffer_resource pool;
// 这一堆临时对象都从 pool 分配
std::pmr::vector<Item> items{&pool};
std::pmr::string name{&pool};
std::pmr::map<int, Detail> details{&pool};
// ... 处理
} // pool 析构,所有内存一次性释放
这样避免了多次 new / delete,也避免了内存碎片。
六、对齐分配
有时需要特定对齐的内存,比如 SIMD 类型要求 16 或 32 字节对齐。
C++17 提供了对齐版本的分配:
void* p = operator new(size, std::align_val_t{64});
operator delete(p, std::align_val_t{64});
C 风格接口:
// C11 / C++17
void* p = std::aligned_alloc(64, size);
std::free(p);
或者用 C++11 的 std::align 手动调整:
void* ptr = buffer;
std::size_t space = sizeof(buffer);
void* aligned = std::align(64, size, ptr, space);
七、什么时候值得自定义分配
| 场景 | 是否值得 |
|---|---|
| 通用业务代码 | ❌ 用默认分配器 |
| 大量同大小小对象(> 10 万) | ✅ 固定大小池 |
| 频繁创建销毁的短期对象 | ✅ 对象池 |
| 批处理、请求级临时数据 | ✅ monotonic_buffer_resource |
| 实时系统、不能有分配抖动 | ✅ 预分配池 |
| 嵌入式、内存受限 | ✅ 定制分配器 |
先测量再优化。用 profiler 看分配热点,确认 malloc 真的占了可观的时间,再考虑动手。
一个常见的误判是「我的程序分配很频繁,所以需要内存池」。但如果每次分配只花 50 纳秒,100 万次也才 50 毫秒——可能根本不是瓶颈。
八、常见误区
「自定义分配器一定更快」 —— 通用分配器经过几十年优化,对一般场景已经很好。自定义分配器的优势来自针对特定模式的优化,模式不匹配反而更慢。
「内存池可以随便释放单块」 —— 大多数池不支持单独归还给系统,只能整池释放。需要单独释放就得用更复杂的结构(如 slab 分配器)。
「allocate 会构造对象」 —— 不会。它只给原始内存,构造由容器负责。
「pmr 比普通分配器慢」 —— 多一层虚函数调用,但省去了分配器类型污染。在批处理场景下,monotonic_buffer_resource 通常比默认分配快得多。
「内存池不会有碎片」 —— 固定大小池不会有外部碎片,但可能有内部碎片(块大小不匹配)。而且池本身如果长期运行、反复分配释放,空闲链表也可能变得分散。
九、相关章节
- 动态内存分配:
new/delete与operator new - 内存布局与模型:对齐与对象布局
- 内存调试与诊断:分配器的调试支持
- RAII 与资源管理:分配器也需要 RAII 封装
RAII 与资源管理
RAII 是 Resource Acquisition Is Initialization 的缩写,直译是「资源获取即初始化」。这个名字起得不太好——它听起来像是在讲初始化,实际上讲的是析构。
它的核心思想只有一句话:把资源的生命周期绑定到对象的生命周期上。资源在构造函数里获取,在析构函数里释放。由于栈对象的析构是编译器自动插入的,无论函数是正常返回、提前 return 还是抛异常,资源都会被正确释放。
这是 C++ 区别于大多数语言的核心特性,也是整个标准库的设计基石。
一、为什么需要 RAII
先看一段没有 RAII 的代码:
void process()
{
FILE* f = fopen("data.txt", "r");
if (!f) return;
char* buffer = (char*)malloc(1024);
if (!buffer) {
fclose(f); // 每个错误分支都要手动清理
return;
}
if (parse(f, buffer) < 0) {
free(buffer); // 又一处
fclose(f);
return;
}
free(buffer);
fclose(f);
}
问题很明显:
- 每个提前返回的分支都要重复写清理代码,漏一个就泄漏
- 如果
parse抛出异常,两处清理都不会执行 - 清理顺序必须和获取顺序相反,容易写错
用 RAII 改写:
void process()
{
std::ifstream file("data.txt"); // 构造时打开
if (!file) return; // 自动关闭
std::vector<char> buffer(1024); // 构造时分配
parse(file, buffer); // 抛异常也没关系
} // 离开作用域,buffer 和 file 自动释放
清理代码消失了,因为编译器会在每个退出路径上插入析构调用。
二、RAII 的完整形态
一个 RAII 类通常包含四部分:
class FileHandle
{
public:
// 1. 构造函数获取资源
explicit FileHandle(const char* path)
: file_(fopen(path, "r"))
{
if (!file_)
throw std::runtime_error("打开文件失败");
}
// 2. 析构函数释放资源
~FileHandle()
{
if (file_)
fclose(file_);
}
// 3. 禁止拷贝(否则会双重释放)
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
// 4. 允许移动(转移所有权)
FileHandle(FileHandle&& other) noexcept
: file_(other.file_)
{
other.file_ = nullptr;
}
FileHandle& operator=(FileHandle&& other) noexcept
{
if (this != &other) {
if (file_) fclose(file_);
file_ = other.file_;
other.file_ = nullptr;
}
return *this;
}
FILE* get() const { return file_; }
private:
FILE* file_;
};
这四步是 RAII 类的标准模板:获取、释放、禁拷贝、允许移动。
三、RAII 的常见形态
RAII 不只用于内存,任何「获取后必须释放」的资源都适用。
1. 锁
std::mutex mtx;
void safe_increment()
{
std::lock_guard<std::mutex> lock(mtx); // 构造时加锁
++shared_counter;
} // 析构时自动解锁,异常也安全
手写 lock() / unlock() 的话,中间任何异常都会导致死锁。
2. 文件与流
{
std::ofstream out("log.txt");
out << "写点东西\n";
} // 自动关闭并 flush
3. 数据库连接、网络连接
class Connection
{
public:
Connection(const std::string& url) : conn_(connect(url)) {}
~Connection() { disconnect(conn_); }
// ...
};
4. 临时状态恢复
RAII 也可以用来「保证状态被还原」:
class ScopedFlag
{
public:
ScopedFlag(bool& flag) : flag_(flag), old_(flag) { flag_ = true; }
~ScopedFlag() { flag_ = old_; }
private:
bool& flag_;
bool old_;
};
void process()
{
ScopedFlag guard(is_processing); // 进入时设为 true
// ... 无论怎么退出,都会恢复原值
}
5. 性能计时
class Timer
{
public:
Timer(const char* name)
: name_(name), start_(std::chrono::steady_clock::now()) {}
~Timer()
{
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start_);
std::cout << name_ << " 耗时 " << ms.count() << " ms\n";
}
private:
const char* name_;
std::chrono::steady_clock::time_point start_;
};
void work()
{
Timer t("work"); // 离开作用域自动打印耗时
// ...
}
四、RAII 与异常安全
RAII 之所以重要,是因为它让异常安全变得可行。
考虑这个函数:
void bad()
{
Resource* r1 = acquire();
Resource* r2 = acquire(); // 抛异常 → r1 泄漏
use(r1, r2);
release(r2);
release(r1);
}
改成 RAII:
void good()
{
auto r1 = acquire(); // 返回 RAII 对象
auto r2 = acquire(); // 抛异常 → r1 自动释放
use(r1, r2);
}
不需要写 try / catch,资源也不会泄漏。这就是为什么标准库的异常安全保证能够成立——它建立在 RAII 之上。
析构函数不能抛异常
栈展开过程中,如果某个析构函数又抛出异常,而此时已有异常在传播,程序会直接调用 std::terminate。
~Bad()
{
throw std::runtime_error("oops"); // 危险
}
C++11 起析构函数默认是 noexcept 的,在其中抛异常会导致终止。如果析构中确实可能失败(比如 fclose 返回值),应该吞掉异常或记录下来,而不是往外抛。
五、RAII 与所有权
RAII 的核心是「谁负责释放」。这引出了所有权的概念:
| 所有权形式 | 含义 | 典型类型 |
|---|---|---|
| 独占 | 只有一个对象负责释放 | std::unique_ptr、std::ifstream |
| 共享 | 多个对象共同负责,最后一个释放 | std::shared_ptr |
| 不拥有 | 只使用,不负责释放 | 裸指针、引用、std::string_view |
设计接口时应该明确表达所有权:
// 明确:函数接管所有权
void take(std::unique_ptr<Widget> w);
// 明确:函数只借用,不负责释放
void use(const Widget& w);
void use(Widget* w); // 也常见,但不如引用清晰
// 明确:函数共享所有权
void share(std::shared_ptr<Widget> w);
裸指针作为参数本身没有错,但要清楚它表示「不拥有」——调用者负责保证对象在调用期间存活。
六、Rule of Three / Five / Zero
RAII 类需要正确处理拷贝和移动,这引出了几个经验法则。
Rule of Three(C++98)
如果类需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,那么通常三个都需要。
原因:需要自定义析构,说明持有需要手动释放的资源;那么默认的拷贝行为(浅拷贝指针)会导致双重释放,必须一并处理。
Rule of Five(C++11)
加上移动构造函数和移动赋值运算符,变成五个:
class Buffer
{
public:
Buffer(const Buffer&); // 拷贝构造
Buffer& operator=(const Buffer&); // 拷贝赋值
Buffer(Buffer&&) noexcept; // 移动构造
Buffer& operator=(Buffer&&) noexcept; // 移动赋值
~Buffer(); // 析构
};
如果定义了拷贝操作但没定义移动操作,编译器不会自动生成移动版本——此时「移动」会退化为拷贝,损失性能。
Rule of Zero
最好的做法是一个都不写。
用现成的 RAII 类型作为成员,编译器生成的默认操作就已经正确了:
class Widget
{
std::string name_; // 自己管理内存
std::vector<int> data_; // 自己管理内存
std::unique_ptr<Impl> impl_; // 独占所有权
std::shared_ptr<Config> config_; // 共享所有权
};
// 不需要写析构、拷贝、移动——编译器生成的都对
这是现代 C++ 推荐的风格:让成员类型去管理资源,自己只管组合。
七、常见误区
「RAII 就是智能指针」 —— 智能指针只是 RAII 的一种应用。锁、文件、连接、临时状态恢复都是 RAII。
「RAII 会拖慢程序」 —— 析构调用是编译期确定的,和手写 delete 没有性能差异。反而因为减少了错误分支,代码往往更快。
「析构函数里可以随便做事」 —— 析构函数不能抛异常,也不应该做可能失败的操作。复杂的清理逻辑应该提供显式的 close() 方法,析构只做兜底。
「移动后源对象不能再用」 —— 移动后的对象处于「有效但未指定」状态,可以重新赋值或析构,但不能假设它的内容。
「拷贝构造和移动构造可以只写一个」 —— 定义了拷贝构造后,移动构造不会自动生成,此时移动会退化为拷贝。反之亦然。
八、相关章节
- 动态内存分配:为什么不该手写
delete - 智能指针:RAII 在内存管理上的标准实现
- 异常处理:栈展开与异常安全保证
- 拷贝控制:Rule of Three / Five / Zero 的细节
- 多线程与并发:
lock_guard等 RAII 锁
内存调试与诊断
内存错误的特点是症状和原因往往离得很远。程序可能在一个完全无关的地方崩溃,或者运行几小时后才慢慢泄漏光内存。这一章讲几类常见错误的成因,以及怎么把它们揪出来。
一、常见内存错误
1. 内存泄漏
分配了内存却忘记释放:
void leak()
{
int* p = new int(42);
// 忘记 delete
}
后果是进程内存持续增长。短命程序可能无所谓,但服务端程序会慢慢吃光内存。
隐蔽的泄漏往往来自异常路径:
void subtleLeak()
{
auto* a = new Resource();
auto* b = new Resource(); // 如果这里抛异常,a 泄漏了
// ...
delete b;
delete a;
}
还有一种是逻辑泄漏——内存还被引用着,但已经没用了:
std::vector<Connection> connections;
void cleanup()
{
connections.clear(); // 如果 Connection 持有裸指针,指针指向的资源泄漏了
}
2. 悬垂指针
指针指向的对象已经销毁:
int* dangling()
{
int local = 42;
return &local; // 栈帧已回收
}
int* p = dangling();
std::cout << *p; // 未定义行为
堆上的版本更隐蔽:
int* p = new int(42);
int* q = p;
delete p;
std::cout << *q; // q 悬垂
释放后内存可能被重新分配给别的对象,读到的值无法预料。这类 bug 在测试环境常常「看起来正常」,上线后才爆发。
3. 缓冲区越界
读写超出分配范围:
int* arr = new int[10];
arr[10] = 1; // 越界写,破坏了相邻内存
char buf[8];
strcpy(buf, "这是一个很长的字符串"); // 栈溢出
越界写会破坏相邻对象的元数据或内容,症状可能在很久之后才出现。
std::vector 的 operator[] 不做检查,at() 会检查并抛异常:
std::vector<int> v(10);
v[10] = 1; // 未定义行为
v.at(10) = 1; // 抛 std::out_of_range
4. 重复释放
int* p = new int(42);
delete p;
delete p; // 未定义行为
如果两个指针指向同一块内存,各自都以为该由自己释放:
void bad(std::unique_ptr<int> a, int* raw)
{
// raw 是 a.get(),函数结束时 a 释放了内存
}
// 调用者又 delete raw → 重复释放
5. 释放方式不匹配
int* p1 = new int[10];
delete p1; // 应该用 delete[]
int* p2 = (int*)malloc(sizeof(int));
delete p2; // 应该用 free
int* p3 = new int(42);
free(p3); // 应该用 delete
6. 未初始化内存
int* p = new int; // 未初始化
std::cout << *p; // 读到垃圾值
int arr[10];
std::cout << arr[0]; // 同上
用 new int() 或 new int{} 可以值初始化:
int* p = new int(); // 初始化为 0
int* q = new int{}; // 同上
二、检测工具
1. AddressSanitizer(ASan)
最推荐的入门工具,GCC 和 Clang 都内置:
g++ -fsanitize=address -g main.cpp -o main
./main
它能检测:
- 堆缓冲区越界
- 栈缓冲区越界
- 释放后使用(use-after-free)
- 重复释放
- 内存泄漏(配合
-fsanitize=leak)
输出示例:
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018
WRITE of size 4 at 0x602000000018 thread T0
#0 0x400abc in main main.cpp:5
ASan 会给出精确的出错位置和调用栈,代价是程序变慢 2~3 倍、内存占用增加。适合在测试环境常开。
2. UndefinedBehaviorSanitizer(UBSan)
检测未定义行为:
g++ -fsanitize=undefined -g main.cpp -o main
能捕获整数溢出、空指针解引用、越界移位等。可以和 ASan 一起用:
g++ -fsanitize=address,undefined -g main.cpp -o main
3. Valgrind
老牌工具,不需要重新编译:
valgrind --leak-check=full ./main
输出:
==12345== HEAP SUMMARY:
==12345== in use at exit: 4 bytes in 1 blocks
==12345== total heap usage: 1 allocs, 0 frees, 4 bytes allocated
==12345==
==12345== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C2A2BB: operator new(unsigned long) (vg_replace_malloc.c:334)
==12345== by 0x400ABC: main (main.cpp:4)
优点是能检测「可能泄漏」(still reachable)等 ASan 不报的情况,缺点是慢 10~50 倍。
4. 工具对比
| 工具 | 速度 | 检测能力 | 需要重编译 |
|---|---|---|---|
| ASan | 慢 2~3 倍 | 越界、UAF、重复释放、泄漏 | 是 |
| UBSan | 慢约 20% | 未定义行为 | 是 |
| Valgrind | 慢 10~50 倍 | 全面,含未初始化读取 | 否 |
| TSan | 慢 5~10 倍 | 数据竞争 | 是 |
建议:日常开发用 ASan + UBSan,CI 里跑一遍 Valgrind 兜底,多线程代码用 TSan。
5. ThreadSanitizer(TSan)
专门检测数据竞争:
g++ -fsanitize=thread -g main.cpp -o main -pthread
输出会指出竞争的两个位置:
WARNING: ThreadSanitizer: data race
Write of size 4 at 0x7b0400000000 by thread T1:
#0 increment() main.cpp:8
Previous read of size 4 at 0x7b0400000000 by main thread:
#0 main main.cpp:15
注意 TSan 和 ASan 不能同时使用,需要分开跑。
三、静态分析
编译器的警告是第一道防线,务必打开:
g++ -Wall -Wextra -Wpedantic -Wshadow -Wconversion main.cpp
Clang 的静态分析器能发现更深的问题:
clang++ --analyze main.cpp
它能识别出「分配后未释放」「空指针解引用」等模式,不需要运行程序。
四、自己写分配器钩子
重载全局 operator new / operator delete 可以记录所有分配:
#include <cstdlib>
#include <iostream>
#include <map>
#include <mutex>
namespace {
std::mutex g_mutex;
std::map<void*, std::size_t> g_allocations;
}
void* operator new(std::size_t size)
{
void* p = std::malloc(size);
if (!p) throw std::bad_alloc();
std::lock_guard<std::mutex> lock(g_mutex);
g_allocations[p] = size;
return p;
}
void operator delete(void* p) noexcept
{
if (!p) return;
std::lock_guard<std::mutex> lock(g_mutex);
g_allocations.erase(p);
std::free(p);
}
// 程序退出时打印未释放的分配
struct Reporter
{
~Reporter()
{
if (!g_allocations.empty())
{
std::cerr << "泄漏 " << g_allocations.size() << " 处分配:\n";
for (const auto& [p, size] : g_allocations)
std::cerr << " " << p << " (" << size << " 字节)\n";
}
}
};
Reporter g_reporter;
这个简易追踪器能告诉你有没有泄漏、泄漏了多少处,但拿不到调用栈。生产级的实现(如 ASan)会记录栈回溯。
五、预防措施
工具能帮你抓 bug,但更好的做法是让 bug 写不出来。
1. 用智能指针
// 容易出错
Widget* w = new Widget();
// ...
delete w;
// 不可能忘记释放
auto w = std::make_unique<Widget>();
2. 用容器代替裸数组
// 容易越界
int* arr = new int[n];
arr[i] = 1;
delete[] arr;
// 有边界检查(at)、自动管理
std::vector<int> arr(n);
arr.at(i) = 1;
3. 用 string 代替 char*
// 容易溢出
char buf[100];
strcpy(buf, input.c_str());
// 自动扩容
std::string buf = input;
4. 遵循 Rule of Zero
让成员类型管理资源,自己不写析构和拷贝:
class Widget
{
std::string name_;
std::vector<int> data_;
std::unique_ptr<Impl> impl_;
// 不需要写析构、拷贝、移动
};
5. 编译期检查
// 数组大小用常量表达式
constexpr std::size_t N = 10;
std::array<int, N> arr;
// 用 span 传递数组,避免退化成指针丢失大小
void process(std::span<int> data);
六、排查思路
遇到内存问题时,按这个顺序排查:
- 打开所有警告,先看编译器报什么
- 跑 ASan,多数越界和 UAF 会立即暴露
- 跑 Valgrind,看有没有泄漏和未初始化读取
- 如果是并发问题,跑 TSan
- 如果还找不到,用
operator new钩子记录分配,缩小范围 - 最后手段:二分注释代码,定位问题区间
一个实用技巧:崩溃位置往往不是出错位置。如果 ASan 报告「堆缓冲区溢出」,重点看那块内存是谁分配的、谁写的,而不是崩溃的那一行。
七、常见误区
「测试通过就没内存问题」 —— 内存错误高度依赖具体的内存布局和执行时序,测试环境跑通不代表生产环境安全。
「用 delete 后置空就没问题了」 —— 置空只防止重复释放,防止不了其他指针的悬垂。
「ASan 太慢不能常开」 —— 在 CI 和本地测试环境开启完全可行,代价远小于线上排查一个内存 bug。
「Valgrind 能替代 ASan」 —— 两者互补。Valgrind 不需要重编译、能检测未初始化读取;ASan 更快、栈信息更准。
「内存泄漏只影响内存」 —— 泄漏的往往不只是内存。文件描述符、socket、锁、数据库连接泄漏同样致命。
「智能指针能解决所有问题」 —— 智能指针解决的是「忘记释放」,解决不了循环引用、逻辑泄漏、越界访问。
八、相关章节
- 动态内存分配:
new/delete的常见错误 - RAII 与资源管理:从设计上避免泄漏
- 智能指针:自动内存管理
- 自定义内存管理:分配器中的调试钩子
- C++ 内存模型(并发):数据竞争与 TSan
C++ 内存模型(并发)
单线程程序里,「代码按顺序执行」是理所当然的。但在多线程环境下,编译器会重排指令、CPU 会乱序执行、缓存会让不同核心看到不同的值——你以为的顺序,在硬件层面完全不存在。
C++11 引入的内存模型(memory model)就是用来约束这些行为的:它规定了多线程程序在什么条件下能观察到什么值。理解它是写出正确并发代码的前提。
这一章偏理论,但每个概念都会落到实际用法上。
一、数据竞争
数据竞争(data race)的定义很明确:
两个线程同时访问同一内存位置,其中至少一个是写操作,且没有同步措施。
数据竞争的后果是未定义行为——不是「读到旧值」这么简单,而是编译器可以做任何假设,程序行为完全不可预测。
int counter = 0; // 普通 int
void increment()
{
for (int i = 0; i < 100000; ++i)
++counter; // 数据竞争
}
int main()
{
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter << '\n'; // 大概率不是 200000
}
++counter 实际是「读-改-写」三步,两个线程可能同时读到相同的值,导致丢失更新。
消除数据竞争只有两条路:加锁或用原子操作。
二、happens-before 关系
内存模型的核心概念是 happens-before。如果操作 A happens-before 操作 B,那么 A 的所有内存写入对 B 都可见。
happens-before 由几种关系组合而成:
1. 同线程内的顺序
同一线程中,前面的语句 happens-before 后面的语句:
int x = 1; // A
int y = x + 1; // B:A happens-before B
2. 锁的同步
一个线程解锁,另一个线程加同一个锁,则解锁 happens-before 加锁:
std::mutex mtx;
int data = 0;
// 线程 A
{
std::lock_guard<std::mutex> lock(mtx);
data = 42; // 写
} // 解锁
// 线程 B
{
std::lock_guard<std::mutex> lock(mtx);
std::cout << data; // 保证读到 42
} // 加锁
这是互斥量能保证可见性的原因——它不只是「互斥」,还建立了同步关系。
3. 原子操作的同步
原子操作可以建立跨线程的 happens-before:
std::atomic<bool> ready{false};
int data = 0;
// 线程 A
data = 42;
ready.store(true, std::memory_order_release); // 释放
// 线程 B
while (!ready.load(std::memory_order_acquire)) { } // 获取
std::cout << data; // 保证读到 42
release 和 acquire 配对,建立起 A 到 B 的同步关系。
4. 传递性
happens-before 具有传递性:如果 A → B 且 B → C,那么 A → C。
三、内存序
原子操作默认使用 std::memory_order_seq_cst(顺序一致),这是最严格也最慢的。C++ 提供了六种内存序:
| 内存序 | 用途 | 保证 |
|---|---|---|
memory_order_relaxed | 只需要原子性 | 无同步,只保证操作不可分割 |
memory_order_consume | 数据依赖(很少用) | 已不推荐 |
memory_order_acquire | 读操作 | 之后的读写不能重排到它之前 |
memory_order_release | 写操作 | 之前的读写不能重排到它之后 |
memory_order_acq_rel | 读-改-写操作 | 兼具 acquire 和 release |
memory_order_seq_cst | 默认,最安全 | 全局统一顺序 |
1. relaxed:只要原子性
std::atomic<int> counter{0};
void increment()
{
for (int i = 0; i < 100000; ++i)
counter.fetch_add(1, std::memory_order_relaxed);
}
// 结果是确定的 200000,但不保证与其他变量的顺序关系
relaxed 只保证「计数不会丢失」,不建立任何同步。适合纯粹的计数器。
2. acquire / release:建立同步
这是最常用的一对,用于「生产者写完数据,通知消费者」:
std::atomic<bool> ready{false};
int payload = 0;
// 生产者
void produce()
{
payload = 42; // 先写数据
ready.store(true, std::memory_order_release); // 再发布
}
// 消费者
void consume()
{
while (!ready.load(std::memory_order_acquire)) { } // 等待发布
std::cout << payload; // 保证看到 42
}
关键点:release 之前的写入,对执行 acquire 的线程可见。如果这里用 relaxed,消费者可能看到 ready == true 但 payload 还是 0。
3. seq_cst:全局一致顺序
std::atomic<bool> x{false}, y{false};
std::atomic<int> z{0};
// 线程 A
x.store(true); // 默认 seq_cst
// 线程 B
y.store(true);
// 线程 C
while (!x.load()) { }
if (y.load())
++z;
seq_cst 保证所有线程看到同一个全局操作顺序。这让推理变简单,但代价是需要内存屏障,性能比 acquire/release 差。
建议:默认用 seq_cst,确认是瓶颈后再降级到 acquire/release。
四、内存屏障
有时需要在没有原子操作的情况下插入同步点:
std::atomic_thread_fence(std::memory_order_release);
屏障本身不操作数据,只是限制重排。典型用法:
// 生产者
payload = 42;
std::atomic_thread_fence(std::memory_order_release);
flag.store(true, std::memory_order_relaxed); // 用 relaxed 存
效果和直接在 store 上用 release 类似,但把同步点独立出来了。实际代码中很少需要,直接用 acquire/release 更清晰。
五、volatile 不是同步手段
这是最经典的误解。volatile 在 C++ 中的含义是:
- 告诉编译器「这个变量可能被外部修改」,不要优化掉对它的访问
- 不提供任何原子性
- 不提供任何线程间同步
- 不建立 happens-before 关系
volatile int counter = 0;
void increment()
{
++counter; // 仍然是数据竞争!
}
volatile 的正确用途是访问内存映射的硬件寄存器,或者配合 setjmp/longjmp。多线程同步必须用 std::atomic 或互斥量。
六、实际应用
1. 双重检查锁定
单例模式中常见的写法,但很容易写错:
class Singleton
{
public:
static Singleton& instance()
{
if (!instance_) // 第一次检查(无锁)
{
std::lock_guard<std::mutex> lock(mtx_);
if (!instance_) // 第二次检查
instance_ = std::make_unique<Singleton>();
}
return *instance_;
}
private:
static std::unique_ptr<Singleton> instance_;
static std::mutex mtx_;
};
这里的 instance_ 是普通指针,第一次检查是数据竞争。正确做法是用原子指针:
static std::atomic<Singleton*> instance_;
static Singleton& instance()
{
Singleton* p = instance_.load(std::memory_order_acquire);
if (!p)
{
std::lock_guard<std::mutex> lock(mtx_);
p = instance_.load(std::memory_order_relaxed);
if (!p)
{
p = new Singleton();
instance_.store(p, std::memory_order_release);
}
}
return *p;
}
不过更简单的做法是用 C++11 的局部静态变量——标准保证它是线程安全的:
static Singleton& instance()
{
static Singleton inst; // 线程安全的初始化
return inst;
}
2. 无锁队列
用原子操作实现的生产者-消费者队列,核心是 CAS 循环:
template<class T>
class LockFreeStack
{
struct Node
{
T data;
Node* next;
};
std::atomic<Node*> head_{nullptr};
public:
void push(T value)
{
Node* newNode = new Node{std::move(value), nullptr};
newNode->next = head_.load(std::memory_order_relaxed);
// CAS 循环:如果 head_ 还是 newNode->next,就换成 newNode
while (!head_.compare_exchange_weak(newNode->next, newNode,
std::memory_order_release,
std::memory_order_relaxed))
{ }
}
};
无锁编程极其困难,上面这个栈还有 ABA 问题。除非有明确的性能需求,否则用互斥量。
3. 原子计数器
最简单的场景:
std::atomic<std::size_t> requestCount{0};
void handleRequest()
{
requestCount.fetch_add(1, std::memory_order_relaxed); // 只需原子性
// ...
}
这里 relaxed 就够了——我们只关心计数正确,不关心它和其他变量的顺序。
七、常见误区
「volatile 可以用于多线程同步」 —— 不能。它不提供原子性,也不建立 happens-before。这是 Java 程序员最常带入 C++ 的错误。
「原子操作就是无锁」 —— 不一定。std::atomic<T> 对某些类型可能内部用锁实现,可以用 is_lock_free() 检查。
「relaxed 是没用的」 —— 它保证了原子性,适合纯计数器场景,性能最好。
「seq_cst 太慢,应该都用 acquire/release」 —— 在 x86 上两者性能差异很小,在 ARM 上才明显。先保证正确性,再考虑优化。
「atomic 变量的所有操作都是原子的」 —— 单个操作是原子的,但复合操作不是:
std::atomic<int> x{0};
if (x > 0) // 原子读
x--; // 原子写
// 但「检查再减」整体不是原子的,中间可能被插入
「多个原子变量之间有一致性」 —— 没有。两个独立的原子变量之间不保证任何顺序关系,除非用 seq_cst 或显式同步。
八、相关章节
面向对象进阶
在这一阶段,需要解决的问题不再是“能不能用类”,而是“如何让类体系设计得更合理、更高效、更安全”。
这一部分的核心是深入理解对象间的关系与行为机制,理解编译器在背后为我们做了什么,从而能在面对复杂系统时,自信地控制对象生命周期、类型转换和继承结构。
-
运算符重载: 对象能否像内置类型一样使用
+、==、[]等运算符?怎样定义它们才能符合直觉又不出错?特别是赋值运算符、比较运算符的语义要与类设计保持一致。 -
继承与派生结构: 为什么要继承?什么时候该用继承、什么时候该用组合?多继承会引发哪些问题(比如二义性和菱形继承)?虚继承又是如何解决的?
-
虚函数与多态: 运行时多态是如何实现的?虚表(vtable)是怎么工作的?析构函数为什么要定义为虚函数?什么场景下多态反而会带来性能或逻辑问题?
-
友元与类关系: 当类之间需要共享私有数据时,友元机制如何使用?为什么要谨慎使用友元?在大型项目中,友元往往是“设计不合理”的信号还是必要的桥梁?
-
模板高级应用: 模板不只是泛型编程,它还能实现多态、类型推导、SFINAE 等复杂行为。要理解模板与继承、多态的结合——如何用模板替代虚函数以获得更高性能。
-
类型转换与转换运算符: 从基本类型到类类型的转换(构造函数隐式转换),从类类型到基本类型(转换运算符)之间隐藏着哪些风险?如何避免意外转换或歧义?
-
拷贝控制: 拷贝构造、赋值运算符、移动构造、移动赋值和析构函数的完整语义——RAII 的核心。理解“拷贝与移动”意味着理解对象的完整生命周期。
-
C++新特性在面向对象中的影响: 现代C++(C++11及之后)的特性,如智能指针、右值引用、
override、final、=default、=delete等,改变了我们对类设计的传统思维。要掌握如何用这些新特性强化封装性和安全性。
这一章的目标,不是单纯记住语法,而是要能回答:
“当我写下一个类、继承一个父类、重载一个函数时,编译器究竟在背后做了什么?”
本章内容
| 章节 | 说明 |
|---|---|
| 运算符重载 | 成员函数与友元函数的选择、各类运算符的重载方式、返回值设计 |
| 继承 | 继承方式、构造析构顺序、多重继承与菱形问题、虚继承 |
| 虚函数与多态 | 虚表机制、动态绑定、虚析构、纯虚函数与抽象类 |
| 友元与类关系 | 友元函数与友元类、组合/聚合/依赖关系的选择 |
| 类模板与函数模板高级应用 | 可变参数模板、SFINAE、Concepts、CRTP 与模板元编程 |
| 类型转换与转换运算符 | 转换构造函数、operator type()、explicit 与二义性 |
| 拷贝控制 | 五个特殊成员函数、深浅拷贝、Rule of Three/Five/Zero |
| C++ 新特性 | 值类别、移动语义、constexpr、noexcept 与类设计 |
运算符重载(Operator Overloading)
一、概述
C++ 是一种支持面向对象编程的语言,在其类型系统中,除了内置类型(如 int、double)外,用户还可以自定义类(Class)来表示复杂数据结构。
然而,类的对象默认并不能像内置类型那样使用 +、==、<< 等运算符。为了让自定义类型具备直观、自然的操作方式,C++ 允许程序员重定义(overload)运算符的行为,使得运算符能作用于类的对象。
这种机制称为运算符重载(Operator Overloading)。它是 C++ 多态性的重要体现之一,使自定义类型可以拥有与内置类型一致的使用体验。
二、运算符重载的定义形式
运算符重载的本质是一个函数定义,它的函数名以 operator 开头,后接要重载的运算符符号。
基本形式如下:
返回类型 operator运算符(参数列表);
运算符重载函数既可以定义为类的成员函数,也可以定义为友元函数(friend function)。
对于成员函数形式,函数的第一个操作数是当前对象本身(通过隐式的 this 指针传递);
对于友元函数形式,则需要显式地接收两个参数。
三、可重载与不可重载的运算符
并非所有运算符都可以被重载。C++ 出于语言安全性和可维护性的考虑,对部分运算符做出了限制。
| 可重载的运算符 | 不可重载的运算符 |
|---|---|
+ - * / % | . (成员访问) |
== != < > <= >= | :: (作用域解析) |
+= -= *= /= | sizeof |
[] () -> | ?: (条件运算符) |
<< >> (常用于输入输出) | 类型转换运算符(如 typeid、dynamic_cast) |
此外,重载不会改变运算符的优先级和结合性,也不能引入新的运算符符号。
四、成员函数与友元函数重载的区别
-
成员函数形式 适用于当左操作数是类对象时。例如:
Complex a, b, c; c = a + b; // 调用 a.operator+(b)在这种情况下,函数原型为:
Complex operator+(const Complex& rhs) const; -
友元函数形式 当运算符左边不是该类的对象,或者需要访问私有成员时,使用友元函数更合适。
friend Complex operator+(const Complex& lhs, const Complex& rhs); -
主要区别
| 项目 | 成员函数 | 友元函数 |
|---|---|---|
| 左操作数 | 必须是类对象 | 可以不是类对象 |
| 调用形式 | a.operator+(b) | operator+(a, b) |
| 访问权限 | 可直接访问成员 | 若声明为 friend,可访问私有成员 |
| 适用场景 | 一般用于类内操作 | 用于输入输出、双操作数等场合 |
这两种形式在使用时完全一致,可以直接使用a+b调用重载的符号。
五、算术运算符的重载
算术运算符(如 +、-、*、/)是最常见的重载形式之一。
下面以复数类 Complex 为例,说明如何重载加法运算符 +。
#include <iostream>
class Complex {
private:
double real, imag;
public:
Complex(double r=0, double i=0) : real(r), imag(i) {}
Complex operator+(const Complex& rhs) const {
return Complex(real + rhs.real, imag + rhs.imag);
}
void display() const {
std::cout << real << " + " << imag << "i" << std::endl;
}
};
int main() {
Complex a(2.0, 3.0), b(1.5, 2.0);
Complex c = a + b; // 调用 a.operator+(b)
c.display(); // 输出 3.5 + 5i
}
该示例中,运算符函数返回一个新的对象,不改变原操作数的内容。
六、比较运算符的重载
对象之间的比较并不会自动发生。若要实现“对象是否相等”的判断,需要重载比较运算符。
示例:
class Point {
private:
int x, y;
public:
Point(int a, int b) : x(a), y(b) {}
bool operator==(const Point& rhs) const {
return x == rhs.x && y == rhs.y;
}
bool operator!=(const Point& rhs) const {
return !(*this == rhs);
}
};
int main() {
Point p1(1, 2), p2(1, 2), p3(2, 3);
if (p1 == p2) std::cout << "p1 and p2 are equal" << std::endl;
if (p1 != p3) std::cout << "p1 and p3 are not equal" << std::endl;
}
比较运算符通常返回布尔值,并应保证逻辑自洽性(如 == 和 != 应互为补充)。
七、赋值运算符的重载
C++ 默认会生成一个“浅拷贝”的赋值运算符,这在涉及动态内存管理的类中往往会引发问题。
因此,通常需要手动重载 operator=,以实现深拷贝(deep copy)。
#include <cstring>
class String {
private:
char* data;
public:
String(const char* s = "") {
data = new char[strlen(s) + 1];
strcpy(data, s);
}
String& operator=(const String& rhs) {
if (this != &rhs) { // 防止自赋值
delete[] data;
data = new char[strlen(rhs.data) + 1];
strcpy(data, rhs.data);
}
return *this; // 返回自身引用
}
~String() { delete[] data; }
};
重载赋值运算符的注意事项:
- 检查自赋值(防止对象赋给自己)。
- 正确释放旧内存并重新分配。
- 返回
*this的引用以支持链式赋值。
八、输入输出运算符的重载
流插入运算符 << 和流提取运算符 >> 无法作为成员函数重载,因为左操作数通常是标准流对象(如 std::cout 或 std::cin)。
因此,通常将它们重载为友元函数。
#include <iostream>
using namespace std;
class Complex {
private:
double real, imag;
public:
Complex(double r=0, double i=0) : real(r), imag(i) {}
friend ostream& operator<<(ostream& os, const Complex& c) {
os << c.real << " + " << c.imag << "i";
return os;
}
friend istream& operator>>(istream& is, Complex& c) {
is >> c.real >> c.imag;
return is;
}
};
int main() {
Complex c1;
cin >> c1;
cout << c1 << endl;
}
这种方式使得自定义类对象可以与标准输入输出流无缝交互。
九、自增与自减运算符的重载
C++ 区分前置与后置两种形式:
- 前置
++a:函数无形参; - 后置
a++:函数带一个虚拟的int形参,用于区分。
class Counter {
private:
int value;
public:
Counter(int v = 0) : value(v) {}
Counter& operator++() { // 前置 ++
++value;
return *this;
}
Counter operator++(int) { // 后置 ++
Counter temp = *this;
++value;
return temp;
}
int get() const { return value; }
};
继承
继承(Inheritance)是面向对象编程的三大特征之一(封装、继承、多态)之一,它使得我们可以在已有类的基础上创建新的类,从而实现代码复用与层次结构的抽象建模。
C++ 的继承机制极其灵活,既可以实现简单的单继承(Single Inheritance),也可以进行复杂的多继承(Multiple Inheritance),甚至支持通过**虚继承(Virtual Inheritance)**来解决多重继承带来的“菱形问题”。
一、继承的基本概念
继承是从一个已有的类(称为基类 / 父类 Base Class)派生出一个新的类(称为派生类 / 子类 Derived Class)。 派生类自动拥有基类的成员(数据与函数),并可以在此基础上新增成员或重写行为。
语法格式如下:
class 派生类名 : 继承方式 基类名 {
// 派生类成员
};
二、基类构造与派生类构造顺序
派生类对象中包含了基类子对象,因此在创建派生类实例时,必须先调用基类构造函数来完成基类部分的初始化。
调用顺序如下:
- 按声明顺序调用所有基类构造函数(从上到下)。
- 再调用派生类自身构造函数。
销毁顺序则相反: 先调用派生类析构函数,再调用基类析构函数。
示例:
class Base {
public:
Base() { std::cout << "Base constructed\n"; }
~Base() { std::cout << "Base destroyed\n"; }
};
class Derived : public Base {
public:
Derived() { std::cout << "Derived constructed\n"; }
~Derived() { std::cout << "Derived destroyed\n"; }
};
int main() {
Derived d;
}
输出结果为:
Base constructed
Derived constructed
Derived destroyed
Base destroyed
若基类构造函数需要参数,必须在派生类的构造函数初始化列表中显式调用:
class Base {
public:
Base(int x) { std::cout << "Base(" << x << ")\n"; }
};
class Derived : public Base {
public:
Derived(int x) : Base(x) {
std::cout << "Derived(" << x << ")\n";
}
};
构造函数调用顺序是从“上至下”,析构顺序是从“下至上”。 这种严格的顺序保证了对象的完整性和资源的正确释放。
三、访问控制与继承权限
访问控制是继承体系中非常重要的机制,它决定了哪些成员可以在派生类中被访问。
继承方式主要有三种:
| 继承方式 | 基类 public 成员在派生类中的访问属性 | 基类 protected 成员在派生类中的访问属性 | 特点说明 |
|---|---|---|---|
public 继承 | public → public | protected → protected | 常用方式,保持原有访问级别 |
protected 继承 | public → protected | protected → protected | 用于希望限制外部访问但允许派生使用的情况 |
private 继承 | public → private | protected → private | 派生类对外隐藏基类接口 |
-
public 成员
- 在
public继承中保持public,外部依然可以访问。 - 在
protected/private继承中则变为不可外部访问。
- 在
-
protected 成员
- 在
public/protected继承中可被派生类访问。 - 在
private继承中仅在本类可访问。
- 在
-
private 成员
- 永远不能被派生类直接访问。
class Base {
public:
int a;
protected:
int b;
private:
int c;
};
class Derived : public Base {
public:
void show() {
a = 1; // 可访问(public继承下保持public)
b = 2; // 可访问(protected继承下保持protected)
// c = 3; // 不可访问(private成员永远不能被继承访问)
}
};
在继承中,private 成员虽然被继承,但不可直接访问,只能通过基类的 public 或 protected 接口间接访问。
四、单继承与多继承
1. 单继承
最常见的继承方式,一个派生类只有一个直接基类:
class Animal {
public:
void eat() { std::cout << "Eating\n"; }
};
class Dog : public Animal {
public:
void bark() { std::cout << "Barking\n"; }
};
Dog 继承了 Animal 的所有非私有成员,因此:
Dog d;
d.eat(); // 继承自 Animal
d.bark(); // 自身成员
2. 多继承
C++ 支持一个类继承自多个基类,从而组合多种功能:
class A {
public:
void funcA() { std::cout << "A\n"; }
};
class B {
public:
void funcB() { std::cout << "B\n"; }
};
class C : public A, public B {
public:
void funcC() { std::cout << "C\n"; }
};
C 同时拥有 A 和 B 的成员:
C obj;
obj.funcA();
obj.funcB();
obj.funcC();
但多继承容易引入命名冲突:
class A { public: void show() { std::cout << "A\n"; } };
class B { public: void show() { std::cout << "B\n"; } };
class C : public A, public B {};
C c;
c.show(); // 二义性错误:不知该调用 A::show 还是 B::show
需使用作用域限定符解决:
c.A::show();
五、多重继承的菱形问题
多继承中最著名的问题是“菱形继承问题(Diamond Problem)”。
示例:
class A {
public:
int value = 1;
};
class B : public A {};
class C : public A {};
class D : public B, public C {};
继承关系如下:
A
/ \
B C
\ /
D
此时,D 同时继承了两份 A,因此 value 出现二义性:
D d;
d.value = 10; // 二义性:B::A::value 与 C::A::value 冲突
六、虚继承(Virtual Inheritance)
为解决菱形问题,C++ 引入了 虚继承(Virtual Inheritance)。
通过在继承声明前加上关键字 virtual,可以让共同的基类只保留一份共享副本。
修改上例:
class A {
public:
int value = 1;
};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};
此时:
D d;
d.value = 10; // 不再二义性,A仅保留一份
在对象布局上,编译器会通过额外的“虚基表指针(vbptr)”来实现共享基类的唯一性。 因此,虚继承的对象模型更复杂,但解决了最棘手的多继承冲突问题。
Tip
虚基表指针(vbptr)如何实现共享基类的唯一性?
在普通多继承中,如果一个基类被多个派生类重复继承(如“菱形继承”结构),最底层派生类对象中会包含多份相同的基类成员,造成 数据冗余 和 二义性。
为了解决这一问题,C++ 提供了 虚继承(virtual inheritance)。编译器通过在对象中引入一套特殊的指针机制——虚基表指针(
vbptr) 和 虚基表(vbtable),来确保所有派> 生路径最终共享同一个虚基类实例。
- 当一个类以
virtual方式继承基类时,编译器会在该类的对象布局中添加一个隐藏成员:vbptr。vbptr指向一张由编译器生成的 虚基表(vbtable)。vbtable中记录了从当前对象地址到虚基类子对象地址的偏移量。- 当程序访问虚基类的成员时,编译器通过
vbptr查表计算出正确的虚基类位置,确保所有继承路径共享同一个虚基类实例。class A { int x; }; class B : virtual public A {}; class C : virtual public A {}; class D : public B, public C {};在
D的对象布局中:D ├── B 子对象(含 vbptr → 指向 B 的 vbtable) ├── C 子对象(含 vbptr → 指向 C 的 vbtable) └── A 虚基类子对象(唯一一份)访问
A::x时:
- 若通过
B或C访问,编译器都会通过vbptr找到唯一的A子对象;- 从而避免了重复继承带来的“二义性”与“多份拷贝”问题。
虚继承常用于框架或接口设计中,尤其当多个中间层类共享同一顶层基类时。
虚函数与多态
多态(Polymorphism)是面向对象的核心特征之一,它让我们可以通过统一的接口来操作不同类型的对象。C++ 的多态是通过 虚函数(virtual function)机制 实现的,它使得函数调用在运行时(而非编译时)才确定具体执行哪个版本——这被称为 动态绑定(Dynamic Binding)。
一、虚函数表机制(vtable)
理解虚函数,必须先理解它背后的实现机制:虚函数表(Virtual Table)。
当一个类中包含虚函数时,编译器会为该类自动生成一张“虚函数表”,记录所有虚函数的地址。 每个含虚函数的对象中,会隐式包含一个指向这张表的指针(称为 vptr)。
- 生成虚函数表(
vtable): 为该类自动生成一张只读表,其中记录了所有虚函数的地址。 - 植入虚指针(
vptr): 每个含虚函数的对象中,会隐式包含一个指向这张表的指针(vptr)。在主流编译器中,vptr通常是对象内存布局中的第一个成员。
换句话说,每个对象在内存中都携带一张“函数地址目录”,在调用虚函数时,程序会通过这张表找到该对象对应的函数实现。
一个典型的过程如下:
- 为该类生成一张虚函数表(vtable)。
- 每个对象内部会有一个指针(vptr),在构造函数中初始化,指向所属类的 vtable。
- 当通过基类指针或引用调用虚函数时,程序会查找 vtable 来决定实际执行哪个函数。
例如:
class Base {
public:
virtual void show() { std::cout << "Base\n"; }
};
class Derived : public Base {
public:
void show() override { std::cout << "Derived\n"; } // C++11 推荐使用 override
};
Base* p = new Derived();
p->show(); // 输出:Derived
编译器生成的伪逻辑相当于:
(*(p->vptr[0]))(p); // 从 vtable[0] 取出函数指针并调用
这就是 动态绑定 的本质。
二、纯虚函数与抽象类
有时,基类只定义接口而不提供具体实现,这时就需要 纯虚函数(pure virtual function)。
纯虚函数的定义方式是在声明末尾加上 = 0:
class Shape {
public:
// 纯虚函数,只提供接口,没有实现体
virtual void draw() = 0;
};
当类中含有至少一个纯虚函数时,该类就成为 抽象类(Abstract Class)。
抽象类不能被实例化,只能被继承。
它的作用是定义一个统一的接口,让派生类去实现具体行为。
例如:
class Shape {
public:
virtual void draw() = 0;
};
class Circle : public Shape {
public:
void draw() override { std::cout << "Drawing Circle\n"; } // 必须实现 draw()
};
// Shape s; // 错误:不能实例化抽象类
Circle c; // 正确:Circle 实现了所有纯虚函数
Shape 不能直接创建对象,但 Circle 可以,因为它实现了 draw()。
这种方式让我们能通过“基类接口”统一管理不同对象,实现真正意义上的多态。
三、动态绑定 vs 静态绑定
静态绑定(Static Binding) 是在编译阶段就决定函数调用的目标,依据是变量的声明类型。 动态绑定(Dynamic Binding) 则在运行时根据对象的实际类型决定调用哪个函数。
| 绑定类型 | 发生时机 | 调用依据 | 示例 |
|---|---|---|---|
| 静态绑定 | 编译期 | 变量声明类型 | 非虚函数调用、重载函数 |
| 动态绑定 | 运行期 | 对象实际类型 | 虚函数调用 |
示例:
class Base {
public:
void func() { std::cout << "Base::func\n"; }
virtual void vfunc() { std::cout << "Base::vfunc\n"; }
};
class Derived : public Base {
public:
void func() { std::cout << "Derived::func\n"; }
void vfunc() override { std::cout << "Derived::vfunc\n"; }
};
Base* p = new Derived();
p->func(); // 静态绑定 -> 调用 Base::func (基类指针只能看到基类声明的非虚函数)
p->vfunc(); // 动态绑定 -> 调用 Derived::vfunc (通过 vtable 确定实际类型)
Tip
强烈建议在派生类中重写虚函数时使用
override关键字。override显式告知编译器该函数意图是重写基类的虚函数。如果签名(函数名、参数、const 性)与基类不匹配,编译器将报错,从而避免了重写失败导致的行为错误。
四、析构函数的虚函数问题
一个常见的陷阱:基类的析构函数必须声明为虚函数。
否则当通过基类指针 delete 派生类对象时,如果析构函数不是虚函数,将发生静态绑定,只会调用基类的析构函数。
会导致派生类中特有的资源(如动态分配的内存、文件句柄等)将不会被释放,导致内存泄漏或资源泄漏。
class Base {
public:
// 必须是虚函数,确保通过基类指针删除时能触发动态绑定
virtual ~Base() { std::cout << "Base destroyed\n"; }
};
class Derived : public Base {
private:
int* data;
public:
Derived() : data(new int[10]) {}
~Derived() override {
delete[] data; // 派生类特有的资源释放
std::cout << "Derived destroyed\n";
}
};
Base* p = new Derived();
delete p; // 1. 动态绑定调用 Derived::~Derived() 2. 调用 Base::~Base()
// 保证了所有资源的正确释放
五、虚函数的开销
虚函数机制提供了强大的灵活性(多态),但它不是零代价的。使用虚函数会带来一定的性能和内存开销:
- 空间开销 (对象层面): 每个含有虚函数的对象,都会增加一个
vptr指针的存储空间(在 64 位系统上通常是 8 字节)。 - 空间开销 (类层面): 每个含有虚函数的类都需要生成一张
vtable来存储函数指针。 - 时间开销: 虚函数的调用比普通函数(静态绑定)多了一步间接寻址(查找
vptr\(\to\) 访问vtable\(\to\) 调用函数)。虽然开销微小,但在高度性能敏感的循环中,可能会产生可见的影响。
因此,只有当确实需要多态特性时,才应该将函数声明为虚函数。
友元与类关系
封装是面向对象的基础——把数据藏起来,只暴露必要的接口。但有时两个类确实需要共享内部状态,比如容器和它的迭代器、对象和它的工厂函数。
friend 就是为这种情况准备的:它允许指定的函数或类访问私有成员。但它的代价是打破封装,所以用之前要想清楚是不是真的必要。
这一章还讨论类之间的几种关系——组合、聚合、依赖——它们决定了对象之间该怎么互相持有。
一、友元函数
友元函数不是成员函数,但能访问类的私有成员:
class Point
{
private:
double x_, y_;
public:
Point(double x, double y) : x_(x), y_(y) {}
// 声明友元
friend double distance(const Point& a, const Point& b);
};
// 定义(不需要 friend 关键字)
double distance(const Point& a, const Point& b)
{
double dx = a.x_ - b.x_; // 可以访问私有成员
double dy = a.y_ - b.y_;
return std::sqrt(dx * dx + dy * dy);
}
什么时候需要友元函数
运算符重载是最常见的场景。当运算符的左操作数不是类对象时,必须用非成员函数:
class Complex
{
double re_, im_;
public:
Complex(double re, double im) : re_(re), im_(im) {}
// 需要访问私有成员,所以声明为友元
friend std::ostream& operator<<(std::ostream& os, const Complex& c);
friend Complex operator+(double lhs, const Complex& rhs);
};
std::ostream& operator<<(std::ostream& os, const Complex& c)
{
return os << c.re_ << "+" << c.im_ << "i";
}
// 支持 2.0 + complex,而不仅仅是 complex + 2.0
Complex operator+(double lhs, const Complex& rhs)
{
return Complex(lhs + rhs.re_, rhs.im_);
}
operator<< 的左操作数是 ostream,double + Complex 的左操作数是 double——都不是 Complex 对象,所以不能定义为成员函数。
友元的几个特性
- 友元关系不可传递:A 是 B 的友元,B 是 C 的友元,不代表 A 是 C 的友元
- 友元关系不可继承:基类的友元不能访问派生类的私有成员
- 友元声明不受访问控制影响:可以放在
private、protected或public区域,效果相同 - 友元不是成员:不受
this指针影响,调用方式与普通函数一致
二、友元类
整个类都可以成为友元:
class Matrix
{
private:
double* data_;
int rows_, cols_;
friend class MatrixIterator; // 迭代器需要访问内部数据
friend class MatrixFactory; // 工厂需要直接构造
};
友元类中的所有成员函数都能访问对方的私有成员。
一个典型场景是迭代器:
class Container
{
int* data_;
std::size_t size_;
public:
class Iterator
{
int* ptr_;
public:
int& operator*() { return *ptr_; }
Iterator& operator++() { ++ptr_; return *this; }
// ...
};
Iterator begin() { return Iterator{data_}; }
Iterator end() { return Iterator{data_ + size_}; }
};
这里迭代器用嵌套类实现,作为成员类天然可以访问外部类的私有成员,不需要 friend。
友元成员函数
也可以只让某个类的特定成员函数成为友元:
class A;
class B
{
public:
void accessA(A& a);
};
class A
{
int secret_ = 42;
// 只让 B::accessA 成为友元
friend void B::accessA(A& a);
};
这种写法需要前置声明,且顺序敏感,实际中较少使用。
三、友元的代价
友元打破封装,这是它的本质。用之前先问几个问题:
能不能通过公开接口实现? 如果加个 getX() 就够了,就不需要友元。
是不是设计有问题? 如果两个类需要频繁互相访问私有成员,说明它们的职责可能没分清楚,也许应该合并成一个类,或者提取出共同的部分。
能不能缩小范围? 与其把整个类设为友元,不如只开放一两个成员函数。
不过友元也不是洪水猛兽。标准库中就有大量友元——operator<<、比较运算符、swap 函数通常都是友元。关键判断标准是:这个外部函数在概念上是不是这个类的一部分。
operator<< 显然是 Complex 的一部分(它就是用来打印 Complex 的),所以作为友元是合理的。反过来,如果某个业务函数需要访问类的私有数据,那就值得怀疑了。
四、类之间的关系
两个类之间可能有几种关系,选择合适的表达方式很重要。
1. 组合(Composition)—— 「是一个部分」
组合表示强拥有关系:整体销毁时部分也随之销毁。
class Engine
{
public:
void start();
};
class Car
{
Engine engine_; // 直接持有对象
public:
void start() { engine_.start(); }
};
特点:
- 成员是值,不是指针
- 生命周期完全绑定:
Car销毁,Engine也销毁 - 通常用
unique_ptr表示需要延迟构造或多态的组合
class Car
{
std::unique_ptr<Engine> engine_; // 独占所有权
public:
Car() : engine_(std::make_unique<V8Engine>()) {}
};
2. 聚合(Aggregation)—— 「有一个」
聚合表示弱拥有关系:部分可以独立于整体存在。
class Department;
class University
{
std::vector<Department*> departments_; // 不拥有
public:
void addDepartment(Department* d) { departments_.push_back(d); }
};
特点:
- 成员是指针或引用,不负责生命周期
- 被持有的对象可以在外部创建和销毁
- 需要调用者保证对象存活
如果生命周期不确定,可以用 shared_ptr;如果只是观察,用 weak_ptr。
3. 依赖(Dependency)—— 「用一下」
依赖是临时关系,通常通过函数参数体现:
class ReportGenerator
{
public:
void generate(const Database& db, std::ostream& out);
};
ReportGenerator 不持有 Database,只是在调用时使用它。
4. 继承(Inheritance)—— 「是一个」
继承表示 is-a 关系:
class Shape { public: virtual void draw() = 0; };
class Circle : public Shape { public: void draw() override; };
继承的细节见 继承。
关系对照
| 关系 | 语义 | 实现方式 | 生命周期 |
|---|---|---|---|
| 组合 | is-a-part-of | 值成员 / unique_ptr | 绑定 |
| 聚合 | has-a | 裸指针 / 引用 / shared_ptr | 独立 |
| 依赖 | uses-a | 函数参数 | 临时 |
| 继承 | is-a | 公有继承 | 绑定 |
五、设计建议
优先用组合而非继承。继承会带来强耦合,基类的改动会影响所有派生类。组合更灵活,也更容易测试。
明确表达所有权。用值成员表示「我拥有」,用裸指针表示「我不拥有」,用 unique_ptr 表示「独占但延迟构造」。不要让读者去猜。
避免双向强引用。A 持有 B,B 又持有 A,会导致循环引用和生命周期混乱。如果确实需要双向访问,让一方用弱引用。
友元是最后手段。先考虑公开接口、成员函数、嵌套类,都不行再用友元。
六、常见误区
「友元破坏了封装,所以绝对不能用」 —— 友元是语言提供的工具,用在对的地方(如 operator<<)是合理的。关键是判断这个函数在概念上是否属于这个类。
「友元关系可以继承」 —— 不能。派生类不会继承基类的友元关系。
「友元关系可以传递」 —— 不能。A 是 B 的友元,B 是 C 的友元,A 不能访问 C 的私有成员。
「友元声明的位置影响访问权限」 —— 不影响。放在 private 还是 public 区域都一样。
「组合就是用一个指针成员」 —— 用指针不一定表示组合。裸指针通常表示不拥有(聚合),unique_ptr 才表示强拥有。
「两个类互相是友元就没问题了」 —— 互相友元通常意味着设计有问题,两个类的职责可能没分清。
七、相关章节
类模板与函数模板高级应用
基础语法在 模板 里已经讲过:怎么写函数模板、类模板,怎么做特化。这一章讲的是模板真正发挥威力的部分——编译期计算、约束与泛型设计。
这些技术让 C++ 的泛型能力远超「把类型当参数」的层面。
一、可变参数模板
C++11 引入的参数包让模板能接受任意数量的类型或值:
template<class... Args>
void print(Args... args)
{
(std::cout << ... << args) << '\n'; // C++17 折叠表达式
}
print(1, "hello", 3.14, 'c');
Args... 是类型参数包,args... 是函数参数包。展开它们有几种方式。
1. 折叠表达式(C++17)
最简洁的展开方式:
template<class... Args>
auto sum(Args... args)
{
return (args + ...); // 一元右折叠:a + (b + (c + ...))
// return (... + args); // 一元左折叠:((a + b) + c) + ...
// return (0 + ... + args); // 二元折叠,带初始值
}
四种形式:
| 形式 | 展开结果 |
|---|---|
(... op pack) | 左折叠,((a op b) op c) |
(pack op ...) | 右折叠,(a op (b op c)) |
(init op ... op pack) | 带初值的左折叠 |
(pack op ... op init) | 带初值的右折叠 |
2. 递归展开(C++11)
C++17 之前只能用递归:
// 递归终止
void print() {}
template<class T, class... Rest>
void print(const T& first, const Rest&... rest)
{
std::cout << first << ' ';
print(rest...); // 递归展开
}
3. 逗号表达式展开
利用初始化列表保证求值顺序:
template<class... Args>
void print(Args... args)
{
int dummy[] = { (std::cout << args << ' ', 0)... };
(void)dummy;
}
4. sizeof… 获取数量
template<class... Args>
constexpr std::size_t count(Args...)
{
return sizeof...(Args);
}
count(1, 2, 3); // 3
二、SFINAE
SFINAE(Substitution Failure Is Not An Error,替换失败并非错误)是 C++ 模板的核心机制之一。
它的含义是:当编译器用具体类型替换模板参数时,如果产生的代码无效,不会立即报错,而是把这个候选从重载集中移除,继续尝试其他候选。
// 只有当 T 有 size() 成员时,这个重载才有效
template<class T>
auto getSize(const T& container) -> decltype(container.size())
{
return container.size();
}
// 备选:用于没有 size() 的类型
template<class T>
std::size_t getSize(...)
{
return 0;
}
std::vector<int> v{1, 2, 3};
getSize(v); // 调用第一个
getSize(42); // 第一个替换失败(int 没有 size()),调用第二个
enable_if
std::enable_if 是最经典的 SFINAE 工具,用于按条件启用重载:
#include <type_traits>
// 只对整数类型启用
template<class T>
std::enable_if_t<std::is_integral_v<T>, T>
half(T value)
{
return value / 2;
}
// 只对浮点类型启用
template<class T>
std::enable_if_t<std::is_floating_point_v<T>, T>
half(T value)
{
return value / 2.0;
}
half(10); // 调用整数版本
half(3.14); // 调用浮点版本
enable_if_t<cond, T> 在 cond 为真时是 T,为假时替换失败,从而移除该候选。
void_t 检测成员
std::void_t 可以把任意类型映射为 void,常用于检测成员是否存在:
template<class T, class = void>
struct has_serialize : std::false_type {};
template<class T>
struct has_serialize<T, std::void_t<decltype(std::declval<T>().serialize())>>
: std::true_type {};
static_assert(has_serialize<MyClass>::value);
如果 T().serialize() 无效,特化版本替换失败,回退到主模板(false_type)。
三、Concepts(C++20)
SFINAE 能工作,但错误信息极其难读。C++20 的 Concepts 提供了更清晰的替代方案。
1. 定义与使用
#include <concepts>
// 定义概念
template<class T>
concept Numeric = std::is_arithmetic_v<T>;
// 约束模板参数
template<Numeric T>
T square(T x) { return x * x; }
// 也可以用在 requires 子句中
template<class T>
requires Numeric<T>
T cube(T x) { return x * x * x; }
违反约束时,编译器会直接告诉你「T 不满足 Numeric」,而不是一堆模板实例化错误。
2. requires 表达式
用于描述类型需要支持哪些操作:
template<class T>
concept Container = requires(T c, const T& cc)
{
{ c.begin() } -> std::same_as<typename T::iterator>;
{ c.end() } -> std::same_as<typename T::iterator>;
{ cc.size() } -> std::convertible_to<std::size_t>;
typename T::value_type;
};
template<Container C>
void process(const C& c) { /* ... */ }
requires 块里可以写四类要求:
| 形式 | 含义 |
|---|---|
expr; | 表达式必须合法 |
{ expr } -> Concept; | 表达式合法且返回值满足概念 |
typename T::type; | 类型必须存在 |
requires cond; | 嵌套的进一步约束 |
3. 标准库概念
标准库提供了一批常用概念:
| 概念 | 含义 |
|---|---|
std::integral | 整数类型 |
std::floating_point | 浮点类型 |
std::same_as<T, U> | 类型相同 |
std::convertible_to<From, To> | 可转换 |
std::derived_from<D, B> | 继承关系 |
std::invocable<F, Args...> | 可调用 |
std::copyable / std::movable | 可拷贝 / 可移动 |
std::ranges::range | 是范围 |
4. 简写形式
概念可以直接用在参数位置:
void process(std::integral auto x); // 等价于 template<std::integral T>
void handle(std::ranges::range auto&& r);
四、模板与继承的结合
1. CRTP:静态多态
CRTP(Curiously Recurring Template Pattern,奇异递归模板模式)让基类知道派生类的类型:
template<class Derived>
class Shape
{
public:
void draw() const
{
// 转换为派生类,调用其实现
static_cast<const Derived*>(this)->drawImpl();
}
};
class Circle : public Shape<Circle>
{
public:
void drawImpl() const { std::cout << "画圆\n"; }
};
class Square : public Shape<Square>
{
public:
void drawImpl() const { std::cout << "画方\n"; }
};
template<class T>
void render(const Shape<T>& s) { s.draw(); }
好处:draw() 在编译期绑定,可以内联,没有虚表开销。
代价:不能通过基类指针统一存储不同派生类对象——Shape<Circle> 和 Shape<Square> 是完全不同的类型。
CRTP 的典型应用:
- 静态多态(替代虚函数)
- 给类批量添加操作符(如
operator!=由operator==推导) - 计数器(统计每个派生类实例化次数)
2. 策略模式
用模板参数注入行为:
template<class ComparePolicy>
class Sorter
{
public:
template<class Container>
void sort(Container& c)
{
ComparePolicy cmp;
std::sort(c.begin(), c.end(), cmp);
}
};
struct Ascending
{
template<class T>
bool operator()(const T& a, const T& b) const { return a < b; }
};
struct Descending
{
template<class T>
bool operator()(const T& a, const T& b) const { return a > b; }
};
Sorter<Ascending> asc;
Sorter<Descending> desc;
比运行时多态更快(可内联),但同样牺牲了运行期切换的能力。
3. 混入(Mixin)
用可变参数模板叠加多个功能:
template<class... Mixins>
class Combined : public Mixins...
{
public:
Combined(const Mixins&... mixins) : Mixins(mixins)... {}
};
struct Loggable
{
void log() const { std::cout << "logging\n"; }
};
struct Serializable
{
void serialize() const { std::cout << "serializing\n"; }
};
Combined<Loggable, Serializable> obj{Loggable{}, Serializable{}};
obj.log();
obj.serialize();
五、模板元编程
模板在编译期就能做计算。经典的例子是编译期阶乘:
template<unsigned N>
struct Factorial
{
static constexpr unsigned value = N * Factorial<N - 1>::value;
};
template<>
struct Factorial<0>
{
static constexpr unsigned value = 1;
};
static_assert(Factorial<5>::value == 120);
C++11 之后,constexpr 函数更易读,多数场景不再需要这种写法:
constexpr unsigned factorial(unsigned n)
{
return n <= 1 ? 1 : n * factorial(n - 1);
}
static_assert(factorial(5) == 120);
类型萃取
<type_traits> 提供了大量编译期类型操作:
template<class T>
void process(T value)
{
if constexpr (std::is_pointer_v<T>)
{
// T 是指针
if (value) use(*value);
}
else
{
use(value);
}
}
if constexpr(C++17)让编译期分支写起来像普通 if,比 SFINAE 清晰得多。
类型列表操作
template<class... Ts>
struct TypeList {};
// 获取第一个类型
template<class List>
struct Front;
template<class Head, class... Tail>
struct Front<TypeList<Head, Tail...>>
{
using type = Head;
};
using First = Front<TypeList<int, double, char>>::type; // int
这类技术是很多库(如 Boost.MPL、range-v3)的基础,日常开发中很少需要手写。
六、编译期成本
模板的代价是编译时间。几个注意点:
减少实例化次数。同一个模板用 10 种类型实例化,就生成 10 份代码。如果这些类型的行为相同,考虑用类型擦除(如 std::function)合并。
避免头文件膨胀。模板定义必须在头文件里,大量模板会让每个包含它的 .cpp 都变慢。可以用显式实例化减少重复:
// 在 .cpp 中显式实例化
template class MyTemplate<int>;
template class MyTemplate<double>;
注意错误信息长度。深层嵌套的模板错误可能几百行。Concepts 能显著改善这一点。
外部模板(C++11)可以抑制重复实例化:
extern template class MyTemplate<int>; // 告诉编译器别在这里实例化
七、常见误区
「模板只能做泛型容器」 —— 模板能做编译期计算、约束检查、代码生成,远不止容器。
「SFINAE 已经过时了」 —— 在 C++20 之前它是唯一的手段,大量现有代码仍在用。理解它有助于读懂老代码和标准库实现。
「Concepts 只是更好看的 enable_if」 —— 除了语法糖,Concepts 还支持子概念、约束的合取与析取、以及重载决议中的偏序规则。
「CRTP 比虚函数总是更快」 —— 少了虚表间接调用,但代码膨胀更严重。如果类型种类很多,指令缓存反而可能成为瓶颈。
「模板元编程应该尽量用」 —— 编译期计算有编译期成本。如果运行期开销可以接受,普通代码更易维护。
「if constexpr 和普通 if 一样」 —— 不同。if constexpr 在编译期选择分支,未选中的分支不会被实例化,因此可以包含对当前类型无效的代码。
八、相关章节
类型转换与转换运算符
C++ 允许在类型之间自动转换,这带来了便利,也埋下了隐患。一个构造函数可能让编译器在你没意识到的情况下做转换;一个转换运算符可能让对象在意外的地方变成别的类型。
这一章讲清两件事:怎么定义类型转换,以及怎么防止它们失控。
一、转换构造函数
只接受单个参数的构造函数,会同时充当隐式转换的入口:
class Complex
{
public:
Complex(double re, double im = 0) : re_(re), im_(im) {}
private:
double re_, im_;
};
void print(const Complex& c);
print(3.14); // 隐式转换:3.14 → Complex(3.14, 0)
3.14 是 double,但函数要 Complex,编译器发现 Complex 有个能接受 double 的构造函数,就自动调用了它。
这看起来方便,但会带来意外:
Complex a(1, 2);
Complex b = a + 3.0; // 3.0 被隐式转成 Complex(3.0)
// 如果没定义 operator+,编译器会尝试各种转换组合
explicit:禁止隐式转换
C++11 起,用 explicit 可以禁止这种自动转换:
class Complex
{
public:
explicit Complex(double re, double im = 0) : re_(re), im_(im) {}
};
print(3.14); // 编译错误
print(Complex(3.14)); // 显式构造,OK
Complex c = 3.14; // 编译错误
Complex c{3.14}; // OK:直接初始化
经验法则:除非转换在语义上完全自然(比如 std::string 接受 const char*),否则构造函数都应该加 explicit。
C++17 起,explicit 还能用于条件性显式,配合类型转换运算符使用。
二、转换运算符
反过来,让对象能转换成别的类型:
class Fraction
{
public:
Fraction(int num, int den) : num_(num), den_(den) {}
// 转换运算符:Fraction → double
operator double() const
{
return static_cast<double>(num_) / den_;
}
private:
int num_, den_;
};
Fraction f(1, 2);
double d = f; // 隐式调用 operator double()
std::cout << f + 0.5; // 1.0
转换运算符的语法是 operator 目标类型():没有返回类型(返回类型就是目标类型),没有参数,通常加 const。
explicit 转换运算符
同样可以加 explicit 防止隐式转换:
class SafeBool
{
public:
explicit operator bool() const { return valid_; }
private:
bool valid_;
};
SafeBool sb;
if (sb) { } // OK:条件语句中允许显式转换
bool b = sb; // 编译错误
bool b2 = static_cast<bool>(sb); // OK
operator bool 是转换运算符中最常见的一个,用于让对象能用在条件判断中。
三、二义性问题
多个转换路径会导致编译器无法选择:
class A
{
public:
A(int);
operator int() const;
};
A a(1);
int x = a + 1; // 歧义:是把 a 转成 int,还是把 1 转成 A?
编译器会报「ambiguous」错误。解决办法是减少隐式转换的入口——该加 explicit 就加。
另一个常见问题是有多个可行的转换序列:
class B
{
public:
B(double);
B(long);
};
B b = 42; // 歧义:int → double 还是 int → long?
四、转换中的隐式步骤
一次隐式转换最多包含一个「用户定义转换」。但标准转换可以叠加:
class Widget
{
public:
Widget(int);
};
void use(const Widget& w);
short s = 42;
use(s); // short → int(标准转换)→ Widget(用户定义转换)
如果路径需要两个用户定义转换,编译器会拒绝:
class A { public: A(int); };
class B { public: B(const A&); };
void use(const B& b);
use(42); // 错误:需要 int → A → B,两次用户定义转换
五、实际应用
1. 智能指针的 operator bool
auto p = std::make_unique<Widget>();
if (p) // 调用 operator bool
p->doSomething();
bool b = p; // 编译错误:explicit 阻止了隐式转换
标准库的智能指针都用 explicit operator bool,这样既能用在条件判断中,又不会意外转换成整数。
2. string_view 的转换
class MyString
{
public:
operator std::string_view() const // 隐式转换到视图
{
return std::string_view(data_, size_);
}
private:
const char* data_;
std::size_t size_;
};
这样 MyString 能直接传给接受 string_view 的函数,无需拷贝。
3. 数值类型的包装
class Meters
{
public:
explicit Meters(double value) : value_(value) {}
double value() const { return value_; }
private:
double value_;
};
class Seconds
{
public:
explicit Seconds(double value) : value_(value) {}
double value() const { return value_; }
private:
double value_;
};
// 用了 explicit,编译器不会允许这个:
// Meters m(10);
// Seconds s = m; // 编译错误,防止单位混用
这是强类型的常见做法——用 explicit 防止不同单位的数值互相转换。
六、安全建议
构造函数默认加 explicit。除非转换在语义上完全自然,否则不要开放隐式转换。
转换运算符也加 explicit。特别是 operator bool,几乎所有情况下都该是 explicit。
避免定义多个转换路径。如果类既能从 int 构造,又能转成 int,很容易产生歧义。
用命名函数代替转换运算符。toString() 比 operator std::string() 更明确,也更容易搜索。
// 不推荐:隐式转换,调用点看不出发生了什么
std::string s = widget;
// 推荐:明确
std::string s = widget.toString();
注意 operator bool 的特殊性。它是唯一允许在「上下文转换」中使用的转换运算符:
if (obj) { } // OK
while (obj) { } // OK
bool b = obj; // 仅当非 explicit 时 OK
int i = obj; // 错误:不会转成 int
七、常见误区
「单参数构造函数不会引起问题」 —— 它是最常见的隐式转换来源,应该默认加 explicit。
「转换运算符比命名函数方便」 —— 方便的另一面是难以追踪。代码里突然出现类型转换,往往需要翻头文件才能搞清发生了什么。
「explicit 会阻止所有转换」 —— 只阻止隐式转换。显式构造、static_cast、直接初始化都不受影响。
「operator bool 不加 explicit 也没事」 —— 会导致对象能隐式转成 int,进而参与算术运算,产生难以发现的 bug。
「转换运算符可以重载多个」 —— 可以,但多个转换目标很容易造成歧义。
「隐式转换没有性能开销」 —— 转换构造函数会创建临时对象,可能有拷贝开销。explicit 不一定更快,但至少让开销可见。
八、相关章节
拷贝控制
一个类如果持有资源(堆内存、文件句柄、socket),就必须自己决定:拷贝它时会发生什么?销毁它时该做什么?
编译器会为每个类自动生成一组特殊成员函数,但默认行为是「逐成员拷贝」——对指针来说就是拷贝地址,而不是拷贝指向的内容。这往往不是我们想要的。
拷贝控制就是关于这组函数的规则:什么时候该自己写,怎么写才不出错。
一、五个特殊成员函数
| 函数 | 签名 | 何时调用 |
|---|---|---|
| 拷贝构造 | T(const T&) | 用同类型对象初始化新对象 |
| 移动构造 | T(T&&) | 用右值初始化新对象 |
| 拷贝赋值 | T& operator=(const T&) | 已有对象被赋值 |
| 移动赋值 | T& operator=(T&&) | 已有对象被右值赋值 |
| 析构 | ~T() | 对象生命周期结束 |
class Widget
{
public:
Widget(const Widget& other); // 拷贝构造
Widget(Widget&& other) noexcept; // 移动构造
Widget& operator=(const Widget& other); // 拷贝赋值
Widget& operator=(Widget&& other) noexcept; // 移动赋值
~Widget(); // 析构
};
编译器生成的默认版本是这样的:
- 拷贝构造/赋值:逐个成员拷贝
- 移动构造/赋值:逐个成员移动
- 析构:逐个成员析构
对只含 int、std::string 这类成员的类,默认版本完全正确。问题出在持有裸资源的类上。
二、默认行为为什么出错
看一个自己管理内存的类:
class Buffer
{
public:
explicit Buffer(std::size_t n) : data_(new int[n]), size_(n) {}
~Buffer() { delete[] data_; }
private:
int* data_;
std::size_t size_;
};
这个类用了默认的拷贝构造和赋值,会出大问题:
Buffer a(10);
Buffer b = a; // 默认拷贝构造:b.data_ 和 a.data_ 指向同一块内存
// 函数结束,b 先析构,delete[] b.data_
// 然后 a 析构,delete[] a.data_ —— 重复释放!
即使侥幸没崩溃,a 和 b 共享同一块内存也是灾难:修改 b 会影响 a,而这显然不是「拷贝」该有的语义。
这叫浅拷贝问题——拷贝了指针本身,而不是指针指向的内容。
三、拷贝构造与拷贝赋值
1. 拷贝构造
Buffer(const Buffer& other)
: data_(new int[other.size_]) // 分配新内存
, size_(other.size_)
{
std::copy(other.data_, other.data_ + size_, data_); // 拷贝内容
}
要点:
- 参数必须是引用。如果按值传参,会无限递归(拷贝实参时又要调用拷贝构造)
- 通常加
const,因为不应该修改源对象 - 用初始化列表初始化成员,而不是在函数体里赋值
2. 拷贝赋值
Buffer& operator=(const Buffer& other)
{
if (this != &other) // 1. 自赋值检查
{
int* newData = new int[other.size_]; // 2. 先分配新内存
std::copy(other.data_, other.data_ + other.size_, newData);
delete[] data_; // 3. 再释放旧内存
data_ = newData;
size_ = other.size_;
}
return *this; // 4. 返回 *this 以支持链式赋值
}
四个关键点:
自赋值检查。a = a 是合法的,如果不检查,会先 delete[] data_ 再拷贝 other.data_——但 other 就是自己,数据已经被删了。
先分配再释放。如果先 delete[] 再 new,而 new 抛异常,对象就处于「data_ 悬垂」的无效状态。反过来则异常安全——分配失败时对象原封不动。
返回 *this 的引用。这样才能支持 a = b = c 这样的链式赋值。返回值而非引用会导致额外的拷贝(对某些类型还可能编译失败)。
不能返回 const 引用。否则 (a = b) = c 无法编译,与内置类型行为不一致。
3. 更简洁的写法:copy-and-swap
Buffer& operator=(Buffer other) // 注意:按值传参
{
swap(*this, other);
return *this;
}
friend void swap(Buffer& a, Buffer& b) noexcept
{
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
这个写法自动处理了自赋值(因为 other 是副本),而且天然异常安全。缺点是可能多一次拷贝,但现代编译器通常能优化掉。
四、移动构造与移动赋值
拷贝的代价是「分配 + 复制全部内容」。但如果源对象马上就要销毁,我们完全可以直接接管它的资源:
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;
}
两个要点:
必须置空源对象。否则源对象析构时会 delete[] 一块已经不属于它的内存。
应该标记 noexcept。这直接影响 std::vector 的行为——如果移动构造可能抛异常,vector 扩容时会退化为拷贝,性能差很多。
class Good { Good(Good&&) noexcept; }; // vector 会移动
class Bad { Bad(Bad&&); }; // vector 会选择拷贝
移动后的对象状态
被移动的对象处于「有效但未指定」状态:
- 可以安全析构 ✅
- 可以重新赋值 ✅
- 不应该假设它的内容 ❌
Buffer a(10);
Buffer b = std::move(a);
// a.data_ 是 nullptr,a.size_ 是 0
// 但这是实现细节,不要依赖
a = Buffer(5); // 合法:重新赋值
五、Rule of Three / Five / Zero
Rule of Three(C++98)
如果需要自定义析构函数、拷贝构造、拷贝赋值中的任何一个,那么通常三个都需要。
原因:需要自定义析构,说明持有需要手动释放的资源;那么默认的浅拷贝会导致重复释放,必须一并处理。
Rule of Five(C++11)
加上移动构造和移动赋值,变成五个。
如果定义了拷贝操作但没定义移动操作,编译器不会生成移动版本——此时「移动」会静默退化为拷贝:
class OnlyCopy
{
public:
OnlyCopy(const OnlyCopy&); // 自定义拷贝构造
OnlyCopy& operator=(const OnlyCopy&);
// 没有移动构造 → std::move 会调用拷贝构造
};
OnlyCopy a;
OnlyCopy b = std::move(a); // 实际调用的是拷贝构造,不是移动
Rule of Zero
最好的做法是一个都不写。
用现成的 RAII 类型作为成员,编译器生成的默认操作就已经正确:
class Widget
{
std::string name_; // 自己管理内存
std::vector<int> data_; // 自己管理内存
std::unique_ptr<Impl> impl_; // 独占所有权
std::shared_ptr<Config> config_; // 共享所有权
};
// 不需要写任何特殊成员函数
这是现代 C++ 推荐的风格:让成员类型去管理资源,自己只管组合。
判断标准很简单:如果你的类里有裸指针、裸文件句柄、裸 socket,才需要写这五个函数。否则遵循 Rule of Zero。
六、什么时候编译器不生成
编译器生成默认版本是有条件的:
| 情况 | 影响 |
|---|---|
| 定义了拷贝构造 | 不生成移动构造(但拷贝构造仍默认生成) |
| 定义了移动构造 | 拷贝构造被删除 |
| 定义了析构 | 不生成移动操作(C++11 起仍生成拷贝,但已弃用) |
| 成员不可拷贝 | 拷贝操作被删除 |
| 成员不可移动 | 移动操作被删除 |
一个容易踩的坑:只写了析构函数,移动操作就没了。
class Widget
{
public:
~Widget() { /* 清理 */ }
// 移动构造不会被生成!
};
此时 std::vector<Widget> 扩容会退化为拷贝。修复方法是显式声明:
class Widget
{
public:
~Widget() { /* 清理 */ }
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
};
七、= default 与 = delete
C++11 允许显式控制这些函数:
class Widget
{
public:
Widget() = default; // 使用默认实现
Widget(const Widget&) = default;
Widget& operator=(const Widget&) = default;
~Widget() = default;
};
= delete 则用于禁止某个操作:
class NonCopyable
{
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete; // 禁止拷贝
NonCopyable& operator=(const NonCopyable&) = delete;
};
NonCopyable a;
// NonCopyable b = a; // 编译错误
这比 C++98 时代「声明为 private 且不实现」的做法清晰得多——错误在编译期就暴露,而不是链接期。
八、常见误区
「拷贝构造的参数可以按值传递」 —— 会导致无限递归。必须是引用。
「赋值运算符不需要自赋值检查」 —— a = a 合法且常见(比如通过引用间接赋值),不检查会先释放自己的资源再读取已释放的数据。
「移动构造不需要置空源对象」 —— 会导致源对象析构时重复释放。
「移动构造不标 noexcept 也没关系」 —— vector 会检测这个标记,没标就退化为拷贝,性能损失可能很大。
「Rule of Three 只是建议,可以不遵守」 —— 违反它会导致重复释放、内存泄漏等未定义行为,不是风格问题。
「写了析构函数,编译器还会生成移动操作」 —— 不会。这是 C++11 的一个常见陷阱。
「= default 和什么都不写是一样的」 —— 不完全一样。= default 是显式声明,会影响其他特殊成员函数的生成规则。
九、相关章节
- RAII 与资源管理:拷贝控制背后的设计思想
- C++ 新特性:移动语义与右值引用的细节
- 运算符重载:赋值运算符的重载
- 虚函数与多态:为什么基类析构函数要声明为 virtual
C++新特性
C++11 是语言历史上最大的一次修订。它引入的移动语义、右值引用、constexpr、noexcept 等特性,彻底改变了 C++ 的类设计方式——从「如何正确管理资源」转向「如何让编译器自动帮你管理资源」。
本节聚焦这些特性在面向对象设计中的应用。关于拷贝构造、赋值运算符、析构函数的完整语义,见 拷贝控制。
一、值类别:左值与右值
理解移动语义的前提是理解值类别(value category)。
| 类别 | 含义 | 例子 |
|---|---|---|
| 左值 (lvalue) | 有名字、有确定地址、可取址 | 变量名、*p、arr[0] |
| 纯右值 (prvalue) | 临时对象、字面量,无名字 | 42、a + b、func() 的返回值 |
| 将亡值 (xvalue) | 即将被销毁的对象,可被移动 | std::move(x) 的结果 |
左值和将亡值合称泛左值(glvalue),纯右值和将亡值合称右值(rvalue)。
关键区别:左值持久,右值短暂。右值通常是即将销毁的临时对象,因此可以安全地「窃取」它的资源。
int a = 10; // a 是左值
int&& r = 10; // r 是右值引用,绑定到临时值
int&& r2 = std::move(a); // 把左值 a 转成右值引用
二、右值引用
右值引用用 T&& 表示,它只能绑定到右值:
int x = 1;
int& lref = x; // 左值引用,绑定左值
int&& rref = 42; // 右值引用,绑定右值
// int&& bad = x; // 错误:右值引用不能绑定左值
右值引用的意义在于:让函数能够区分「参数是临时对象」还是「参数是持久对象」,从而对临时对象采取更高效的操作。
Tip
引用折叠与完美转发
在模板中,
T&&的含义取决于T的推导结果,这被称为转发引用(forwarding reference):
- 传入左值 →
T推导为T&,经引用折叠后T&&变成T&- 传入右值 →
T推导为T,T&&保持为T&&引用折叠规则:
& + & = &,& + && = &,&& + && = &&。配合
std::forward<T>(x)即可把参数原样转发,保持其值类别不变。
三、移动语义
1. 移动构造函数与移动赋值
移动构造函数接受右值引用,直接接管源对象的资源,而不是复制一份:
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(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;
}
private:
int* data_;
std::size_t size_;
};
移动的本质是「指针交换」:把源对象的内部指针直接赋给目标对象,再把源对象的指针置空。这样既避免了深拷贝的开销,又保证了源对象析构时不会重复释放。
2. 源对象的状态
被移动后的对象处于有效但未指定(valid but unspecified)的状态:
- 可以安全地析构
- 可以重新赋值
- 不应该假设它的值是什么(比如假设它一定为空)
3. 何时触发移动
移动在以下情况自动发生:
| 场景 | 说明 |
|---|---|
| 用临时对象初始化 | Buffer b = makeBuffer(); |
用 std::move 显式转换 | Buffer b2 = std::move(b1); |
| 容器扩容时搬移元素 | vector 重新分配内存 |
| 返回局部对象 | NRVO 未生效时 |
std::vector<std::string> v;
std::string s = "hello";
v.push_back(s); // 拷贝:s 是左值
v.push_back(std::move(s)); // 移动:s 被移空
v.push_back("world"); // 移动:临时对象
四、std::move 与 std::forward
1. std::move
std::move 不移动任何东西,它只是一个类型转换,把左值转成右值引用:
// 大致实现
template <typename T>
constexpr std::remove_reference_t<T>&& move(T&& x) noexcept
{
return static_cast<std::remove_reference_t<T>&&>(x);
}
它相当于告诉编译器:「这个对象我不再需要了,可以当作临时对象处理」。
std::string a = "hello";
std::string b = std::move(a); // 触发移动构造
// a 现在是空字符串(有效但未指定)
Warning
std::move之后不要再使用源对象
std::move只是转换,真正的移动发生在构造/赋值时。但一旦移动完成,源对象的内容就不可依赖了。常见错误:
std::string s = "hello"; use(std::move(s)); // s 被移空 log(s); // 错误:s 的内容已不可依赖
2. std::forward
std::forward 用于完美转发:在模板中把参数原样传递给下一层函数,保持其值类别不变。
template <typename T>
void wrapper(T&& x)
{
// 如果传入左值,x 以左值传递;传入右值,以右值传递
process(std::forward<T>(x));
}
如果写成 process(x),右值参数会被当作左值,退化为拷贝;如果写成 process(std::move(x)),左值参数会被错误地移动。
3. 对比
std::move | std::forward | |
|---|---|---|
| 作用 | 无条件转为右值 | 有条件保持原值类别 |
| 使用场景 | 明确不再需要该对象 | 模板中的参数转发 |
| 参数 | 普通对象 | 转发引用 T&& |
五、constexpr
constexpr 表示「可在编译期求值」。它比 const 更强:const 只保证运行期不可修改,constexpr 则要求编译期就能算出结果。
const int a = 10; // 运行期常量
constexpr int b = 10; // 编译期常量
constexpr int square(int x) { return x * x; }
constexpr int c = square(5); // 编译期算出 25
int arr[square(3)]; // 合法:数组大小是编译期常量
在类设计中的应用
class Circle
{
public:
constexpr Circle(double r) : radius_(r) {}
constexpr double area() const
{
return 3.141592653589793 * radius_ * radius_;
}
private:
double radius_;
};
constexpr Circle c(2.0);
constexpr double a = c.area(); // 编译期计算
要点:
constexpr函数在 C++11 中只能有一条return语句,C++14 起放宽为允许循环和分支constexpr隐含inline,定义需放在头文件中constexpr构造函数使对象可以成为编译期常量- C++20 起还有
consteval(强制编译期求值)和constinit(强制编译期初始化)
六、noexcept 与类设计
noexcept 不只是文档说明,它会直接影响容器的行为。
std::vector 扩容时需要把旧元素搬到新内存。如果元素的移动构造函数可能抛异常,一旦搬运途中抛异常,旧内存已被破坏、新内存尚未完成,无法回滚——强异常安全保证就会被破坏。因此:
- 移动构造函数标记
noexcept→vector移动元素 - 移动构造函数未标记
noexcept→vector退化为拷贝元素
class Good
{
public:
Good(Good&&) noexcept = default; // vector 会移动
};
class Bad
{
public:
Bad(Bad&&) { /* 未标记 noexcept */ } // vector 会选择拷贝
};
实践建议:
- 移动构造函数和移动赋值运算符应尽可能标记
noexcept - 析构函数默认就是
noexcept(C++11 起),不要在其中抛异常 swap通常也应标记noexcept,许多算法依赖这一点
七、其它现代特性
1. override 与 final
class Base
{
public:
virtual void f();
virtual void g();
};
class Derived : public Base
{
public:
void f() override; // 明确表示覆盖基类虚函数,签名不匹配会报错
void g() final; // 禁止派生类继续覆盖
};
override 能在编译期发现「本想覆盖却写错签名」的问题,应始终使用。
2. = default 与 = delete
class NonCopyable
{
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete; // 禁止拷贝
NonCopyable& operator=(const NonCopyable&) = delete;
};
= default 让编译器生成默认实现,= delete 明确禁用某个函数。
3. 委托构造与继承构造
class Widget
{
public:
Widget() : Widget(0, 0) {} // 委托构造:调用另一个构造函数
Widget(int w, int h) : w_(w), h_(h) {}
private:
int w_, h_;
};
class Derived : public Base
{
public:
using Base::Base; // 继承基类的构造函数
};
4. 类内成员初始化
class Config
{
int timeout_ = 30; // 类内默认值
std::string name_ = "default";
};
这样多个构造函数不必重复写相同的初始化。
5. 结构化绑定(C++17)
std::map<std::string, int> m{{"a", 1}};
for (const auto& [key, value] : m)
std::cout << key << ": " << value << '\n';
6. 三路比较运算符 <=>(C++20)
struct Point
{
int x, y;
auto operator<=>(const Point&) const = default; // 自动生成全部比较运算符
};
一行代码即可获得 ==、!=、<、<=、>、>=。
八、注意事项
std::move不是移动,只是转换。它不会改变对象,真正的移动发生在构造或赋值时。- 移动后不要再使用源对象。它的状态是「有效但未指定」,只能析构或重新赋值。
- 移动构造函数要置空源对象的指针。否则源对象析构时会重复释放资源。
noexcept影响容器性能。未标记noexcept的移动构造会让vector退化为拷贝。- 不要对
const对象用std::move。const T&&无法绑定到移动构造的T&&,结果仍是拷贝。 - 返回局部对象不需要
std::move。编译器会做 NRVO(返回值优化),写std::move反而可能阻止优化。 constexpr函数要放在头文件。它隐含inline,否则会违反 ODR。
九、相关章节
- 拷贝控制:拷贝构造、赋值运算符与 Rule of Three/Five/Zero
- 异常处理:
noexcept说明符与异常安全保证 - 智能指针:移动语义在资源管理中的应用
- RAII与资源管理:资源所有权的转移
异常处理
异常(Exception) 是 C++ 报告错误的主要机制。当函数无法完成它承诺的任务时,可以抛出一个异常对象,控制权会沿着调用栈向上传递,直到遇到能处理它的 catch 块。
相比返回错误码,异常的优势在于:错误无法被忽略,且不需要在每一层函数中都写检查代码。代价是运行时有一定开销,且必须小心处理资源释放问题。
一、基本语法
异常处理由三个关键字组成:
| 关键字 | 作用 |
|---|---|
throw | 抛出一个异常对象 |
try | 标记一段可能抛出异常的代码 |
catch | 捕获并处理特定类型的异常 |
#include <iostream>
#include <stdexcept>
double divide(double a, double b)
{
if (b == 0)
throw std::invalid_argument("除数不能为 0");
return a / b;
}
int main()
{
try
{
std::cout << divide(10, 2) << '\n'; // 5
std::cout << divide(10, 0) << '\n'; // 抛出异常
}
catch (const std::invalid_argument& e)
{
std::cerr << "错误: " << e.what() << '\n';
}
return 0;
}
throw 之后的代码不会执行,控制权立即转移。
二、栈展开(Stack Unwinding)
异常抛出后,程序会沿着调用栈逐层退出,直到找到匹配的 catch。这个过程中,每一层作用域内的局部对象都会被析构,这一机制称为栈展开。
void inner()
{
std::string s = "局部对象";
throw std::runtime_error("出错了");
// s 会在这里被析构
}
void outer()
{
std::vector<int> v{1, 2, 3};
inner();
// v 也会被析构
}
栈展开是 RAII(Resource Acquisition Is Initialization)能够工作的基础:只要资源由对象持有,异常发生时析构函数就会自动释放它,不需要手写 catch 来清理。
void safe()
{
std::ifstream file("data.txt"); // 构造时打开文件
// ... 即使这里抛出异常
} // file 的析构函数仍会被调用,文件自动关闭
Warning
析构函数中不要抛出异常
如果栈展开过程中某个析构函数又抛出异常,而此时已经有一个异常正在传播,程序会直接调用
std::terminate终止。C++11 起,析构函数默认是
noexcept的,在其中抛出异常会直接导致终止。
三、捕获异常
1. 按引用捕获
标准库抛出的都是匿名对象,应该按引用捕获:
try
{
throw std::runtime_error("错误");
}
catch (const std::exception& e) // 推荐:按 const 引用
{
std::cerr << e.what() << '\n';
}
如果按值捕获,会发生对象切片(object slicing)——派生类异常被截断成基类,丢失派生部分的信息:
catch (std::exception e) // 错误:切片,e 只是基类部分
{
// e.what() 可能丢失派生类的信息
}
2. 捕获顺序
catch 块按从上到下的顺序匹配,第一个匹配的类型生效。因此派生类必须写在基类之前:
try
{
// ...
}
catch (const std::out_of_range& e) // 派生类在前
{
// ...
}
catch (const std::exception& e) // 基类在后
{
// ...
}
顺序写反的话,基类的 catch 会先捕获所有派生类异常,后面的块永远不会执行。
3. 捕获所有异常
catch (...) 可以捕获任何类型的异常:
try
{
// ...
}
catch (const std::exception& e)
{
// 处理标准异常
}
catch (...) // 兜底
{
// 处理其它未知异常
throw; // 通常应该重新抛出
}
4. 重新抛出
在 catch 块内使用不带操作数的 throw; 可以把当前异常继续向上传播,同时保留原始类型:
try
{
// ...
}
catch (const std::exception& e)
{
log(e.what()); // 记录日志
throw; // 原样重新抛出,而不是 throw e;(后者会切片)
}
四、异常安全保证
标准库对每个操作都规定了异常安全等级。这四个等级是逐层包含的关系:
| 等级 | 含义 | 典型例子 |
|---|---|---|
| Nothrow(不抛异常) | 函数承诺不抛出异常 | 析构函数、swap、移动构造函数 |
| Strong(强保证) | 抛出异常时,状态回滚到调用前的样子 | std::vector::push_back |
| Basic(基本保证) | 抛出异常时,程序仍处于有效状态,不泄漏资源 | 大多数标准库操作 |
| No guarantee(无保证) | 可能泄漏资源或破坏不变量 | — |
此外还有 exception-neutral(异常中立):函数自身不处理异常,但保证异常原样传播给调用者,且不破坏自身状态。模板库通常提供这种保证。
理解这几个等级有助于判断:调用某个操作失败后,对象还能不能继续使用。
std::vector<int> v{1, 2, 3};
try
{
v.push_back(4); // 强保证:失败时 v 仍为 {1,2,3}
}
catch (const std::bad_alloc&)
{
// v 未被修改
}
五、noexcept
C++11 引入 noexcept 说明符,用于声明函数是否会抛出异常。
1. 说明符
void f() noexcept; // 承诺不抛异常
void g() noexcept(true); // 同上
void h() noexcept(false); // 可能抛异常(默认行为)
如果声明为 noexcept 的函数真的抛出了异常,程序会直接调用 std::terminate:
void bad() noexcept
{
throw std::runtime_error("糟糕"); // 编译通过,但运行时终止程序
}
2. noexcept 运算符
noexcept(expr) 是运算符,在编译期判断表达式是否会抛出异常,返回 bool:
std::vector<int> v;
std::cout << std::boolalpha << noexcept(v.push_back(1)); // false(可能抛 bad_alloc)
它常用于编写条件性的 noexcept 模板:
template <typename T>
void wrapper(T&& x) noexcept(noexcept(process(std::forward<T>(x))))
{
process(std::forward<T>(x));
}
3. 为什么重要
noexcept 不只是文档说明,它会影响容器的行为。以 std::vector 扩容为例:
- 如果元素的移动构造函数是
noexcept,vector会选择移动元素 - 否则,为了保持强异常安全保证,
vector只能选择拷贝元素
struct Good
{
Good(Good&&) noexcept = default; // vector 会移动
};
struct Bad
{
Bad(Bad&&) { } // 未标记 noexcept,vector 会选择拷贝
};
因此,自定义类型的移动构造函数应当尽可能标记为 noexcept,否则会带来不必要的性能损失。
六、标准异常体系
标准库的所有异常都直接或间接继承自 std::exception,定义在 <exception> 中。它提供了虚函数 what() 返回错误描述:
namespace std
{
class exception
{
public:
virtual const char* what() const noexcept;
virtual ~exception();
};
}
主要派生类分两大类:
| 基类 | 含义 | 主要派生类 |
|---|---|---|
std::logic_error | 程序逻辑错误,理论上可在运行前发现 | invalid_argument、domain_error、length_error、out_of_range |
std::runtime_error | 运行时才能发现的错误 | range_error、overflow_error、underflow_error |
此外还有一些独立的异常类型:
| 异常 | 头文件 | 触发场景 |
|---|---|---|
std::bad_alloc | <new> | new 分配内存失败 |
std::bad_cast | <typeinfo> | dynamic_cast 引用转换失败 |
std::bad_typeid | <typeinfo> | 对空指针解引用后取 typeid |
std::bad_weak_ptr | <memory> | 用空的 weak_ptr 构造 shared_ptr |
std::bad_function_call | <functional> | 调用空的 std::function |
std::bad_optional_access | <optional> | 访问空的 optional(C++17) |
std::bad_variant_access | <variant> | 访问 variant 中非当前类型(C++17) |
std::bad_any_cast | <any> | any_cast 类型不匹配(C++17) |
std::system_error | <system_error> | 操作系统或底层库错误(C++11) |
七、自定义异常
自定义异常通常继承自 std::runtime_error 或 std::logic_error,这样可以直接复用 what():
#include <stdexcept>
#include <string>
class NetworkError : public std::runtime_error
{
public:
explicit NetworkError(const std::string& msg, int code)
: std::runtime_error(msg), code_(code) {}
int code() const noexcept { return code_; }
private:
int code_;
};
使用时按引用捕获基类即可统一处理,需要时再 dynamic_cast 取回具体类型。
八、注意事项
- 按值抛出,按引用捕获。抛出时构造的是异常对象的副本,捕获时必须用引用以避免切片。
- 不要在析构函数中抛异常。栈展开期间的二次异常会导致
std::terminate。 catch (...)不要吞掉异常。至少要记录日志,否则问题会被掩盖。throw;与throw e;不同。前者原样重抛,后者会拷贝并切片。- 异常不是控制流。不要用异常实现正常的跳转逻辑,它的开销远高于普通的条件判断。
- 构造函数的失败必须用异常报告。构造函数没有返回值,无法用错误码表示失败。
noexcept是承诺而非检查。编译器不会验证函数体是否真的不抛异常,违背承诺只在运行时报错。- 性能考量。异常在不抛出时几乎没有开销(零成本异常模型),但抛出时的栈展开代价较高。因此热路径上的正常情况不应依赖异常。
九、相关章节
多线程与并发
C++11 起,标准库提供了完整的并发支持:线程、互斥量、条件变量、原子操作和异步任务。这些设施定义在 <thread>、<mutex>、<condition_variable>、<atomic>、<future> 等头文件中,让并发程序不再依赖平台特定的 API。
但并发编程的困难不在于 API,而在于正确性。数据竞争、死锁、虚假唤醒、内存序错误这些问题往往在测试中难以复现,却会在生产环境造成严重后果。更麻烦的是,很多错误不会立即崩溃,而是表现为「偶尔算错一个数」——这种 bug 的排查成本极高。
Warning
并发问题的本质
只要多个线程同时访问同一块内存,且其中至少一个是写操作,且没有同步措施,就构成数据竞争(data race)——这是未定义行为,编译器可以做任何假设,结果完全不可预测。
加锁或使用原子操作是消除数据竞争的唯一途径。
Note
加锁了也不一定安全
数据竞争只是并发问题的一类。还有一类更隐蔽的问题叫 TOCTOU(检查时刻到使用时刻):即使每一处访问都加了锁,只要「检查」和「使用」被拆到两个临界区,中间的状态变化仍会导致错误。详见互斥量与锁。
本章内容
| 章节 | 说明 |
|---|---|
| 线程基础 | std::thread 与 std::jthread、线程局部存储、启动与结束 |
| 互斥量与锁 | 五种互斥量类型、RAII 包装器、死锁避免、TOCTOU、call_once |
| 条件变量 | 等待与通知、虚假唤醒、生产者消费者模式 |
| 原子操作 | std::atomic、CAS 循环、内存序、无锁编程的限度 |
| 异步任务 | 回调 / Future / 协程三种风格、async、promise、packaged_task |
| 并发模式与陷阱 | 线程池、回调与事件驱动、常见模式与错误汇总 |
怎么选同步手段
面对一个并发问题,先想清楚「线程之间需要交换什么」,再选工具:
| 需求 | 工具 | 说明 |
|---|---|---|
| 保护一小段临界区 | std::mutex + lock_guard | 最常用,优先考虑 |
| 保护多个资源,需要一起加锁 | std::scoped_lock | 自动避免死锁 |
| 读多写少 | std::shared_mutex + shared_lock | 允许多个读者并发 |
| 等待某个条件成立 | std::condition_variable | 配合互斥量使用 |
| 单个变量的原子读写 | std::atomic | 比加锁轻量 |
| 需要「稍后取结果」 | std::future | 配合 async / promise |
| 等待一批任务完成 | std::latch / std::barrier | C++20 |
| 限制并发数量 | std::counting_semaphore | C++20 |
| 请求取消长时间任务 | std::stop_token | C++20,配合 jthread |
默认从互斥量开始。原子操作和无锁结构看起来更快,但正确性极难保证,除非有明确的性能需求,否则不要轻易尝试。
几条经验
- 优先用
std::jthread。它会在析构时自动 join,不会因为忘记join而让程序终止,还内置了取消机制。 - 不要手动
lock()/unlock()。始终用 RAII 包装器,异常路径才安全。 - 共享数据越少越好。最有效的并发优化是减少共享——用消息传递代替共享内存,或者让每个线程持有自己的数据。
- 先保证正确,再考虑性能。
seq_cst最慢但最安全,确认瓶颈后再降级内存序。 - 并发 bug 靠工具找。用 ThreadSanitizer(
-fsanitize=thread)而非肉眼审查。
与内存模型的分工
本章讲怎么用这些并发设施,C++ 内存模型 讲为什么它们能工作——happens-before 关系、内存序的语义、编译器与 CPU 的重排规则。
如果只是想写出正确的并发代码,本章足够;如果需要理解底层机制或做无锁编程,建议两章对照阅读。
线程基础
线程是操作系统调度的基本单位。一个进程可以包含多个线程,它们共享地址空间,各自拥有独立的栈和寄存器状态。
共享地址空间是线程轻量的原因,也是并发 bug 的根源——所有全局变量、堆对象都是共享的。
一、std::thread
std::thread 在构造时立即启动线程,不需要单独调用 start():
#include <iostream>
#include <thread>
void hello(int id)
{
std::cout << "线程 " << id << " 运行中\n";
}
int main()
{
std::thread t1(hello, 1); // 启动线程,参数按值传递
std::thread t2([] { hello(2); }); // 用 lambda
t1.join(); // 等待 t1 结束
t2.join(); // 等待 t2 结束
}
参数默认是拷贝到线程内部的。需要传引用时必须用 std::ref:
void modify(std::vector<int>& v) { v.push_back(1); }
std::vector<int> data;
std::thread t(modify, std::ref(data)); // 不加 ref 会编译失败
t.join();
不加 std::ref 会编译失败其实是好事——它提醒你引用传递有生命周期风险。如果线程比被引用对象活得久,就会访问已销毁的对象。
1. 必须 join 或 detach
std::thread 析构时如果仍处于 joinable 状态,程序会直接调用 std::terminate:
{
std::thread t(hello, 1);
} // 错误:t 析构时未 join/detach,程序终止
这个设计是刻意的:标准委员会认为「线程还在跑但句柄没了」是严重错误,与其静默泄漏不如直接崩掉。
std::thread t(hello, 1);
t.join(); // 等待结束
// 或
t.detach(); // 放手不管,线程在后台运行
detach() 要慎用——分离后的线程无法再被等待或控制,程序退出时它可能还在访问已销毁的全局对象。
2. 常用接口
| 接口 | 含义 |
|---|---|
join() | 阻塞等待线程结束 |
detach() | 让线程独立运行,不再与 thread 对象关联 |
joinable() | 是否关联着一个线程 |
get_id() | 返回线程 ID |
hardware_concurrency() | 硬件支持的并发线程数(静态函数) |
hardware_concurrency() 常用于决定线程池大小:
unsigned n = std::thread::hardware_concurrency();
if (n == 0) n = 4; // 无法确定时的兜底值
它返回的只是「建议值」,可能受 CPU 亲和性、容器配额等因素影响。
3. 异常与线程
线程函数中抛出的异常不会传播到创建它的线程。如果异常逃出线程函数,会直接调用 std::terminate:
std::thread t([] {
throw std::runtime_error("出错了");
// 异常逃出 → std::terminate
});
t.join();
需要把异常传回主线程,得用 std::promise 或 std::exception_ptr:
std::promise<void> p;
std::thread t([&p] {
try {
doWork();
p.set_value();
} catch (...) {
p.set_exception(std::current_exception());
}
});
try {
p.get_future().get(); // 异常在这里重新抛出
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
t.join();
二、std::jthread(C++20)
std::jthread 解决了 thread 的两个痛点:析构时自动 join,以及内置协作式取消。
#include <thread>
int main()
{
std::jthread t([](std::stop_token st) {
while (!st.stop_requested())
{
// 执行任务
}
});
} // 离开作用域时自动 request_stop() 并 join
1. 自动 join
jthread 析构时会先请求停止,再 join。因此不会出现「忘记 join 导致 terminate」的问题:
{
std::jthread t(doWork);
} // 自动停止并等待
2. 协作式取消
jthread 内部持有一个 std::stop_source,构造时把对应的 std::stop_token 作为第一个参数传给线程函数:
void worker(std::stop_token st, int id)
{
while (!st.stop_requested())
{
// 干活
}
std::cout << "线程 " << id << " 收到停止请求\n";
}
std::jthread t(worker, 1);
// 主线程可以主动请求停止
t.request_stop();
| 成员函数 | 作用 |
|---|---|
request_stop() | 请求停止,返回是否成功 |
get_stop_token() | 获取停止令牌 |
get_stop_source() | 获取停止源 |
注意「协作式」的含义:request_stop() 只是设置一个标志,线程必须主动检查 stop_requested() 才能响应。如果线程卡在阻塞调用里,停止请求不会生效。
3. stop_callback
可以在停止请求发出时执行回调:
std::stop_token st = t.get_stop_token();
std::stop_callback cb(st, [] {
std::cout << "收到停止请求\n";
});
这在需要唤醒阻塞操作时很有用——比如让 condition_variable 停止等待。C++20 的 condition_variable_any 提供了支持 stop_token 的 wait 重载:
std::condition_variable_any cv;
std::mutex mtx;
void worker(std::stop_token st)
{
std::unique_lock lock(mtx);
// 收到停止请求时自动唤醒
cv.wait(lock, st, [] { return /* 条件 */; });
}
三、当前线程操作
std::this_thread 命名空间提供了一组操作当前线程的函数:
#include <thread>
#include <chrono>
std::this_thread::get_id(); // 当前线程 ID
std::this_thread::sleep_for(std::chrono::seconds(1)); // 睡眠 1 秒
std::this_thread::sleep_until(tp); // 睡眠到指定时间点
std::this_thread::yield(); // 让出 CPU
1. sleep_for 与 sleep_until
// 相对时间
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// 绝对时间
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(1);
std::this_thread::sleep_until(deadline);
在循环中做定时任务时,sleep_until 比 sleep_for 更准——它不会累积每次循环的执行时间误差:
// 不好:误差会累积
while (running) {
doWork();
std::this_thread::sleep_for(100ms);
}
// 好:固定周期
auto next = std::chrono::steady_clock::now();
while (running) {
doWork();
next += 100ms;
std::this_thread::sleep_until(next);
}
2. yield
yield() 建议调度器切换到其它线程,但不保证真的切换:
while (!flag.load()) {
std::this_thread::yield(); // 自旋等待时让出 CPU
}
在自旋锁或忙等待中加 yield() 可以降低 CPU 占用,但不如用条件变量或原子等待高效。
四、线程局部存储
thread_local 声明的变量每个线程各有一份独立副本:
thread_local int counter = 0; // 每个线程独立
void increment()
{
++counter; // 不构成数据竞争
}
这是避免共享状态的利器。典型用途:
随机数引擎(引擎不是线程安全的):
void worker()
{
thread_local std::mt19937 gen(std::random_device{}());
// 每个线程独立的引擎
}
线程专属的缓存:
thread_local std::vector<char> buffer; // 复用缓冲区,避免反复分配
错误状态(类似 errno):
thread_local int last_error = 0;
几点说明:
thread_local变量的初始化在首次使用时发生,每个线程各初始化一次- 线程结束时销毁,析构顺序与构造顺序相反
- 有性能开销:访问
thread_local需要查 TLS 表,比普通全局变量慢 thread_local与static可以组合,表示「每个线程的静态变量」
五、线程数量与开销
线程不是免费的:
| 开销项 | 说明 |
|---|---|
| 栈空间 | 每个线程默认 1~8 MB(Linux 通常 8 MB) |
| 创建成本 | 涉及系统调用,通常几十微秒 |
| 上下文切换 | 每次切换几微秒,频繁切换会显著拖慢程序 |
| 缓存失效 | 切换后 CPU 缓存可能失效 |
因此:
- 不要为每个任务创建一个线程。任务数量大时用线程池。
- 线程数通常设在 CPU 核心数附近。计算密集型任务,线程数超过核心数没有收益。
- I/O 密集型任务可以多设一些。线程阻塞在 I/O 上时不占 CPU。
unsigned n = std::thread::hardware_concurrency();
std::vector<std::jthread> pool;
for (unsigned i = 0; i < n; ++i)
pool.emplace_back(worker, i);
六、常见误区
「std::thread 会自动 join」 —— 不会。析构时如果还 joinable,程序直接终止。用 jthread 可以避免。
「detach() 之后就安全了」 —— 分离的线程可能访问已销毁的对象。除非线程完全自包含,否则不要 detach。
「线程函数抛异常会传播到主线程」 —— 不会,会直接 terminate。需要手动传递。
「sleep_for(0) 等于 yield()」 —— 不完全等价。sleep_for(0) 可能触发系统调用,yield() 只是建议调度。
「hardware_concurrency() 返回的就是最佳线程数」 —— 它只是硬件支持的并发数,实际最优值取决于任务类型(计算密集还是 I/O 密集)。
「thread_local 没有开销」 —— 有。访问 TLS 变量比普通变量慢,热路径上要谨慎。
「线程越多越快」 —— 超过核心数后,上下文切换和缓存失效会拖慢程序。
七、相关章节
互斥量与锁
互斥量(mutex)是最基本的同步工具:它保证同一时刻只有一个线程能进入临界区。
「临界区」指的是访问共享数据的代码段。只要所有访问共享数据的地方都套上同一把锁,数据竞争就消除了。
一、互斥量类型
| 类型 | 头文件 | 说明 |
|---|---|---|
std::mutex | <mutex> | 基本互斥量,最常用 |
std::recursive_mutex | <mutex> | 同一线程可重复加锁 |
std::timed_mutex | <mutex> | 支持超时加锁 |
std::recursive_timed_mutex | <mutex> | 可重入 + 超时 |
std::shared_mutex | <shared_mutex> | 读写锁(C++17) |
std::shared_timed_mutex | <shared_mutex> | 读写锁 + 超时(C++14) |
1. 基本用法
#include <mutex>
std::mutex mtx;
int counter = 0;
void increment()
{
mtx.lock();
++counter;
mtx.unlock();
}
但不要这样写。如果 ++counter 抛异常,unlock() 永远不会执行,其它线程会永久阻塞。应该用 RAII 包装器:
void increment()
{
std::lock_guard<std::mutex> lock(mtx);
++counter;
} // 自动解锁,异常也安全
2. recursive_mutex
同一线程可以重复加锁:
std::recursive_mutex rmtx;
void outer()
{
std::lock_guard<std::recursive_mutex> lock(rmtx);
inner(); // 会再次加锁,普通 mutex 会死锁
}
void inner()
{
std::lock_guard<std::recursive_mutex> lock(rmtx);
// ...
}
但通常说明设计有问题。需要递归加锁,往往意味着「哪些函数持锁」这个契约不清晰。更好的做法是把加锁的边界划清楚,让内部函数假设调用者已持锁。
recursive_mutex 还有性能开销——它要记录持有者和递归深度。
3. shared_mutex(读写锁)
允许多个读者并发,但写者独占:
#include <shared_mutex>
std::shared_mutex rwmtx;
std::map<int, std::string> cache;
// 读操作:多个线程可并发
std::string read(int key)
{
std::shared_lock lock(rwmtx); // 共享锁
auto it = cache.find(key);
return it != cache.end() ? it->second : "";
}
// 写操作:独占
void write(int key, const std::string& value)
{
std::unique_lock lock(rwmtx); // 独占锁
cache[key] = value;
}
适合读多写少的场景。如果读写频率相当,普通 mutex 往往更快——shared_mutex 的内部实现更复杂。
二、RAII 锁包装器
标准库提供了四种包装器,各有用途:
| 包装器 | 版本 | 特点 |
|---|---|---|
std::lock_guard | C++11 | 最简单,构造加锁、析构解锁,不可移动 |
std::unique_lock | C++11 | 可移动、可延迟加锁、可手动解锁 |
std::scoped_lock | C++17 | 可同时锁定多个互斥量,避免死锁 |
std::shared_lock | C++14 | 配合 shared_mutex 的共享锁 |
1. lock_guard
最轻量,适合绝大多数场景:
{
std::lock_guard<std::mutex> lock(mtx);
// 临界区
} // 自动解锁
C++17 起可以省略模板参数(CTAD):
std::lock_guard lock(mtx);
2. unique_lock
更灵活,但开销略大(需要记录是否持有锁):
std::unique_lock<std::mutex> lock(mtx); // 立即加锁
std::unique_lock<std::mutex> lock2(mtx, std::defer_lock); // 暂不加锁
lock2.lock(); // 手动加锁
lock2.unlock(); // 手动解锁
if (lock2.owns_lock()) { /* ... */ }
三个标签:
| 标签 | 含义 |
|---|---|
std::defer_lock | 不立即加锁 |
std::try_to_lock | 尝试加锁,失败不阻塞 |
std::adopt_lock | 假设已加锁,接管所有权 |
unique_lock 是条件变量必需的——condition_variable::wait 需要在等待期间解锁、被唤醒后重新加锁。
3. scoped_lock
同时锁定多个互斥量,内部使用死锁避免算法:
std::mutex m1, m2;
void transfer(Account& from, Account& to, int amount)
{
std::scoped_lock lock(from.mtx, to.mtx); // 同时锁定,不会死锁
from.balance -= amount;
to.balance += amount;
}
如果手动按不同顺序加锁,两个线程可能互相等待:
// 线程 A:lock(m1) → lock(m2)
// 线程 B:lock(m2) → lock(m1)
// 可能死锁
scoped_lock 内部用 std::lock 算法,它会尝试加锁所有互斥量,失败则全部释放重试,从而避免循环等待。
4. 手动使用 std::lock
需要更细粒度控制时,可以用 std::lock 配合 adopt_lock:
std::unique_lock<std::mutex> lock1(m1, std::defer_lock);
std::unique_lock<std::mutex> lock2(m2, std::defer_lock);
std::lock(lock1, lock2); // 同时加锁,避免死锁
三、call_once
std::call_once 保证函数在多线程环境下只执行一次:
std::once_flag flag;
void init()
{
std::call_once(flag, [] {
// 只会执行一次,即使多个线程同时调用 init()
});
}
典型用途是延迟初始化:
class Config
{
public:
static Config& instance()
{
std::call_once(initFlag_, [] {
instance_.reset(new Config());
});
return *instance_;
}
private:
static std::once_flag initFlag_;
static std::unique_ptr<Config> instance_;
};
不过 C++11 起,局部静态变量的初始化本身就是线程安全的,所以单例更简单的写法是:
Config& instance()
{
static Config inst; // 线程安全的初始化
return inst;
}
四、死锁
死锁是多个线程互相等待对方释放资源。四个必要条件(Coffman 条件):
- 互斥:资源同一时刻只能被一个线程持有
- 持有并等待:线程持有资源的同时等待其它资源
- 不可抢占:资源只能被持有者主动释放
- 循环等待:存在线程与资源的环形等待链
1. 常见死锁场景
加锁顺序不一致:
// 线程 A
lock(m1); lock(m2);
// 线程 B
lock(m2); lock(m1); // 可能死锁
忘记解锁(异常路径):
mtx.lock();
doSomething(); // 抛异常 → 永远不解锁
mtx.unlock();
自死锁(同一线程重复加锁):
std::mutex mtx;
mtx.lock();
mtx.lock(); // 死锁
2. 避免方法
| 方法 | 说明 |
|---|---|
| 统一加锁顺序 | 所有线程按同一顺序加锁 |
用 scoped_lock | 自动避免循环等待 |
| 用 RAII 包装器 | 异常路径也能解锁 |
| 缩小临界区 | 减少持锁时间,降低冲突概率 |
| 避免嵌套锁 | 尽量不在持锁时调用可能加锁的函数 |
| 用超时锁 | try_lock_for 失败后放弃并重试 |
五、性能考量
1. 锁的代价
一次未争用的加锁解锁大约几十纳秒,涉及:
- 原子操作修改锁状态
- 内存屏障(保证临界区内的写入对后续线程可见)
- 可能的系统调用(争用时)
争用时代价急剧上升——线程会被挂起,涉及上下文切换(几微秒)。
2. 减少争用
缩小临界区:
// 不好:整个函数持锁
void process()
{
std::lock_guard lock(mtx);
auto data = fetchFromNetwork(); // 耗时操作,不该持锁
shared = data;
}
// 好:只在必要时持锁
void process()
{
auto data = fetchFromNetwork(); // 锁外执行
std::lock_guard lock(mtx);
shared = data;
}
分片(sharding):把一个大锁拆成多个小锁:
class ShardedCounter
{
static constexpr int N = 16;
std::array<std::mutex, N> mtxs_;
std::array<int, N> counts_{};
public:
void increment(int key)
{
int idx = key % N; // 按 key 分散到不同锁
std::lock_guard lock(mtxs_[idx]);
++counts_[idx];
}
int total() const
{
int sum = 0;
for (int c : counts_) sum += c;
return sum;
}
};
用原子操作替代锁:简单计数器用 std::atomic 就够了。
读写锁:读多写少时用 shared_mutex。
3. 自旋锁
互斥量在争用时会让线程睡眠。如果临界区极短,睡眠/唤醒的开销可能比自旋等待还大。这时可以用自旋锁:
class SpinLock
{
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
void lock()
{
while (flag_.test_and_set(std::memory_order_acquire))
{
// 自旋等待
}
}
void unlock()
{
flag_.clear(std::memory_order_release);
}
};
但自旋锁通常不是好选择:
- 单核系统上自旋毫无意义(持有者无法运行)
- 临界区稍长就会浪费大量 CPU
- 需要配合
yield()或退避策略
除非你确切知道临界区只有几条指令,否则用 std::mutex。
六、TOCTOU:加锁了也不一定安全
前面反复强调「访问共享数据要加锁」。但有一个更隐蔽的问题:检查和使用之间隔了时间,这段空隙里状态可能被改变。
这就是 TOCTOU(Time-Of-Check To Time-Of-Use,检查时刻到使用时刻)。它的危险之处在于——即使每一处访问都加了锁,逻辑上仍然可能出错。
1. 问题出在哪
TOCTOU 的经典形态是「先检查,再操作」:
if (fileExists(path)) // 检查
{
auto f = openFile(path); // 使用 —— 中间可能已被删除!
}
检查和使用是两次独立的操作,中间的时间窗口里,其它线程(或进程)可以改变状态。
关键在于:加锁只能保证「单个操作」的原子性,不能保证「检查 + 使用」这一对操作的原子性。
2. 加了锁也可能有 TOCTOU
看这个例子——每一行都规规矩矩地加了锁:
// 危险:锁的粒度不对,检查和使用之间被拆开了
bool tryConsume()
{
{
std::lock_guard lock(mtx_);
if (queue_.empty()) // 检查
return false;
} // ← 锁在这里释放了
{
std::lock_guard lock(mtx_);
auto item = queue_.front(); // 使用 —— 队列可能已空!
queue_.pop();
return true;
}
}
两次加锁各自都是原子的,但中间那段没持锁的时间里,别的线程可能把队列清空了。queue_.front() 于是访问了空队列——未定义行为。
正确的做法是把检查和操作放进同一个临界区:
bool tryConsume()
{
std::lock_guard lock(mtx_);
if (queue_.empty()) // 检查
return false;
auto item = queue_.front(); // 使用 —— 同一把锁,中间无法插入
queue_.pop();
return true;
}
3. 更隐蔽的变体:返回引用
这种写法看起来没问题,其实也是 TOCTOU:
// 危险:返回的是指针/引用,锁在返回时就释放了
const std::string* find(int key)
{
std::lock_guard lock(mtx_);
auto it = map_.find(key);
if (it == map_.end())
return nullptr;
return &it->second; // 锁一释放,别的线程就能 erase 掉这个元素
}
// 调用方
const std::string* p = find(42);
if (p) // 检查
std::cout << *p; // 使用 —— 悬空指针!
锁保护了 find 本身,但保护不了返回之后的使用。解决办法是返回值(拷贝)而不是引用:
std::optional<std::string> find(int key)
{
std::lock_guard lock(mtx_);
auto it = map_.find(key);
if (it == map_.end())
return std::nullopt;
return it->second; // 拷贝一份,脱离锁的保护范围也安全
}
4. 文件系统的 TOCTOU(安全漏洞)
TOCTOU 最初是从安全领域被广泛讨论的。Unix 下的经典例子:
// setuid 程序中的漏洞
if (access("file", W_OK) != 0) // 检查:当前用户能否写
exit(1);
fd = open("file", O_WRONLY); // 使用 —— 中间可能被换成符号链接
write(fd, buffer, sizeof(buffer));
攻击者可以在 access 和 open 之间把 file 替换成指向 /etc/passwd 的符号链接,于是程序以特权身份写入了不该写的文件。
这类漏洞造成过真实事故:
- 早期 BSD 的
mktemp()临时文件竞争 - 早期 OpenSSH 的 Unix domain socket 竞争
- 2019 年 Docker 的 TOCTOU 漏洞允许访问宿主机文件系统
- 2025 年 AWS US-EAST-1 因 DNS 管理系统的竞争条件导致大规模故障
这类问题的根本困难:access 和 open 是两个独立的系统调用,中间无法加锁。2004 年有研究证明,在 Unix 上不存在可移植的、确定性的方法来消除这种 TOCTOU。
5. 怎么防
| 策略 | 说明 |
|---|---|
| 扩大临界区 | 让「检查」和「使用」在同一把锁内完成 |
| 返回拷贝而非引用 | 不让共享数据的指针逃出锁的保护范围 |
| EAFP 而非 LBYL | 直接操作,用错误处理兜底,而不是先检查 |
| 用原子的复合操作 | 比如 compare_exchange、fetch_add |
| 用一次性句柄 | 文件场景用 open 返回的 fd,而不是路径 |
EAFP(Easier to Ask for Forgiveness than Permission)值得展开。与其「先检查再操作」,不如「直接操作,失败就处理」:
// LBYL:检查和操作之间有窗口
if (fileExists(path))
openFile(path);
// EAFP:只有一次操作,没有窗口
if (auto f = tryOpenFile(path); f)
use(*f);
「检查」本身就是一次操作,只要它和使用分离,就存在窗口。能合并成一次操作就合并。
6. 和其它并发问题的关系
TOCTOU 和前面讲的死锁、数据竞争是不同维度的问题:
| 问题 | 本质 |
|---|---|
| 数据竞争 | 同一时刻的并发访问未同步 |
| 死锁 | 互相等待资源 |
| TOCTOU | 两个时刻之间的状态变化 |
数据竞争是「同时」,TOCTOU 是「先后」。加锁解决的是前者,而后者需要保证检查与使用之间不被打断——这往往意味着重新设计接口,而不只是加一把锁。
七、常见误区
「std::mutex 可以拷贝」 —— 不能。互斥量不可拷贝也不可移动。
「加锁后忘记解锁只影响性能」 —— 会导致其它线程永久阻塞,是死锁。
「recursive_mutex 能解决所有嵌套加锁问题」 —— 它掩盖了设计问题。更好的做法是明确加锁边界。
「shared_mutex 总是比 mutex 快」 —— 只在读多写少时才有优势,且实现更复杂。读写均衡时 mutex 更快。
「锁的粒度越细越好」 —— 每把锁都有开销(内存、加锁时间)。过度分片反而可能变慢。
「scoped_lock 比 lock_guard 慢」 —— 单个互斥量时两者性能相当,scoped_lock 只是更通用。
「lock_guard 可以手动解锁」 —— 不能。需要手动控制时用 unique_lock。
「加了锁就绝对安全」 —— 不一定。检查和操作被拆到两个临界区,仍然存在 TOCTOU。
「返回引用比返回值高效,所以更好」 —— 在并发环境下,返回引用会把共享数据暴露到锁的保护范围之外。
八、相关章节
- 线程基础:线程的创建与管理
- 条件变量:等待条件成立
- 原子操作:更轻量的同步方式
- 并发模式与陷阱:死锁排查与工程实践
- RAII 与资源管理:锁包装器的设计思想
条件变量
条件变量解决的是「线程需要等待某个条件成立」的问题。
没有它的话,等待只能靠轮询:
// 轮询:浪费 CPU,且响应有延迟
while (!ready) {
std::this_thread::sleep_for(1ms);
}
条件变量让线程真正睡眠,条件成立时被唤醒——既不浪费 CPU,响应也及时。
一、基本用法
条件变量必须配合互斥量使用。三者缺一不可:互斥量保护条件变量、条件变量负责睡眠、谓词判断条件。
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;
// 生产者
void producer()
{
{
std::lock_guard<std::mutex> lock(mtx);
q.push(42);
} // 先解锁
cv.notify_one(); // 再通知
}
// 消费者
void consumer()
{
std::unique_lock<std::mutex> lock(mtx);
// 等待队列非空
cv.wait(lock, [] { return !q.empty(); });
int value = q.front();
q.pop();
}
1. wait 做了什么
cv.wait(lock, pred) 等价于:
while (!pred()) // 1. 检查条件
{
cv.wait(lock); // 2. 原子地解锁并睡眠
} // 3. 被唤醒后重新加锁,回到 1
第 2 步的「原子地解锁并睡眠」是关键——如果解锁和睡眠不是原子的,通知可能在这两步之间发出,导致线程永远睡下去(丢失唤醒)。
2. 为什么必须用谓词版本
条件变量可能发生虚假唤醒(spurious wakeup)——即使没有 notify,wait 也可能返回。这不是 bug,而是实现允许的行为(源于底层系统调用的特性)。
// 错误:可能被虚假唤醒,此时条件并不成立
cv.wait(lock);
if (q.empty()) { /* 逻辑错误 */ }
// 正确:带谓词的版本内部是循环
cv.wait(lock, [] { return !q.empty(); });
永远使用带谓词的版本。手写 while 循环也可以,但谓词版本更简洁,也不容易写错。
3. notify_one 与 notify_all
| 方法 | 行为 |
|---|---|
notify_one() | 唤醒一个等待的线程 |
notify_all() | 唤醒所有等待的线程 |
什么时候用 notify_one:多个线程竞争同一份资源,唤醒一个就够(比如队列里加了一个元素)。
什么时候用 notify_all:
- 条件变化影响所有等待者(比如「关闭」标志置位)
- 不确定哪个线程能推进(唤醒的那个可能条件不满足,又睡回去)
std::atomic<bool> shutdown{false};
void shutdownAll()
{
{
std::lock_guard lock(mtx);
shutdown = true;
}
cv.notify_all(); // 所有等待者都要退出
}
二、生产者消费者
这是条件变量的经典场景。一个完整的、有界队列实现:
#include <condition_variable>
#include <mutex>
#include <queue>
template<class T>
class BoundedQueue
{
public:
explicit BoundedQueue(std::size_t capacity) : capacity_(capacity) {}
void push(T value)
{
std::unique_lock<std::mutex> lock(mtx_);
// 队列满则等待,直到有空间
notFull_.wait(lock, [this] { return q_.size() < capacity_ || closed_; });
if (closed_) return;
q_.push(std::move(value));
lock.unlock(); // 先解锁再通知,减少等待者被立即阻塞的概率
notEmpty_.notify_one();
}
bool pop(T& value)
{
std::unique_lock<std::mutex> lock(mtx_);
// 队列空则等待,直到有元素或已关闭
notEmpty_.wait(lock, [this] { return !q_.empty() || closed_; });
if (q_.empty()) return false; // 已关闭且为空
value = std::move(q_.front());
q_.pop();
lock.unlock();
notFull_.notify_one();
return true;
}
void close()
{
{
std::lock_guard<std::mutex> lock(mtx_);
closed_ = true;
}
notEmpty_.notify_all();
notFull_.notify_all();
}
private:
std::mutex mtx_;
std::condition_variable notEmpty_;
std::condition_variable notFull_;
std::queue<T> q_;
std::size_t capacity_;
bool closed_ = false;
};
几个设计要点:
两个条件变量。等待「非空」和等待「非满」是两个不同的条件,用同一个条件变量会导致 notify_all 唤醒一堆无关线程。
closed_ 标志。让等待者能优雅退出,否则 close() 之后消费者会永远等待。
先解锁再通知。被唤醒的线程会立即尝试加锁,如果通知时还持有锁,它会先阻塞一次。解锁后通知能减少这次无谓的阻塞。
三、condition_variable_any
std::condition_variable 只能配合 std::unique_lock<std::mutex>。需要配合其它锁类型时用 condition_variable_any:
#include <condition_variable>
#include <shared_mutex>
std::shared_mutex rwmtx;
std::condition_variable_any cv;
void reader()
{
std::shared_lock lock(rwmtx);
cv.wait(lock, [] { return /* 条件 */; });
}
代价:condition_variable_any 的实现更复杂(需要类型擦除),性能不如 condition_variable。能用前者就用前者。
配合 stop_token(C++20)
condition_variable_any 支持 stop_token,可以在收到停止请求时自动唤醒:
void worker(std::stop_token st)
{
std::unique_lock lock(mtx);
// 停止请求发出时自动唤醒
cv.wait(lock, st, [] { return /* 条件 */; });
}
这解决了「线程卡在 wait 里,request_stop() 无法生效」的问题。
四、常见陷阱
1. 丢失唤醒
通知发生在等待之前:
// 线程 A(消费者)
// 还没开始 wait
// 线程 B(生产者)
{
std::lock_guard lock(mtx);
q.push(42);
}
cv.notify_one(); // 此时没有等待者,通知丢失
// 线程 A
cv.wait(lock); // 永远等待
解决办法:用谓词版本。wait(lock, pred) 会先检查谓词,即使通知已经丢失,条件已成立就不会等待。
cv.wait(lock, [] { return !q.empty(); }); // 安全
2. 持有锁时通知
// 不好
{
std::lock_guard lock(mtx);
q.push(42);
cv.notify_one(); // 持有锁时通知
}
被唤醒的线程会立即尝试加锁,但锁还被持有,于是它又阻塞一次。虽然正确,但多了一次上下文切换。
// 好
{
std::lock_guard lock(mtx);
q.push(42);
} // 先解锁
cv.notify_one(); // 再通知
3. 用同一个条件变量等待不同条件
// 不好:两个不同的条件共用一个 cv
cv.wait(lock, [] { return !q.empty(); }); // 消费者
cv.wait(lock, [] { return q.size() < 10; }); // 生产者
notify_all() 会唤醒所有线程,其中大部分会发现条件不满足又睡回去。应该用两个独立的条件变量。
4. 忘记在循环外修改共享状态
// 错误:条件永远不成立
cv.wait(lock, [] { return ready; });
// ready 从来没有被设为 true
谓词检查的是共享状态,修改共享状态时必须在持锁状态下进行,否则可能产生数据竞争。
五、性能考量
条件变量的开销主要来自:
- 唤醒延迟:从
notify到被唤醒线程真正运行,通常几微秒 - 上下文切换:涉及内核调度
- 惊群效应:
notify_all唤醒多个线程,但只有一个能推进
优化建议:
优先用 notify_one。只有确实需要唤醒所有线程时才用 notify_all。
减少通知频率。批量处理时,不要每加一个元素就通知一次:
// 不好:每个元素通知一次
for (auto& item : items) {
{
std::lock_guard lock(mtx);
q.push(item);
}
cv.notify_one();
}
// 好:批量加入后通知一次
{
std::lock_guard lock(mtx);
for (auto& item : items)
q.push(item);
}
cv.notify_all();
考虑无锁队列。如果条件变量的开销成为瓶颈,可以用无锁队列配合原子等待(C++20 的 atomic::wait / notify_one)。
六、常见误区
「cv.wait(lock) 不带谓词也可以」 —— 虚假唤醒会导致逻辑错误。永远用谓词版本。
「notify_one 会唤醒特定的线程」 —— 不会。它唤醒任意一个等待者,无法指定。
「通知必须在持锁时发出」 —— 不需要,而且通常应该先解锁再通知。
「条件变量的谓词会在持锁状态下检查」 —— 是的,wait 返回时锁是持有的,谓词检查也在持锁状态下进行。
「condition_variable_any 和 condition_variable 一样快」 —— 前者有类型擦除开销,能用后者就用后者。
「等待超时可以用 sleep 模拟」 —— sleep 无法被通知打断,响应延迟大。用 wait_for / wait_until。
七、相关章节
原子操作
原子操作是比互斥量更轻量的同步手段。它保证单个操作不可分割——要么完全执行,要么完全没执行,不存在中间状态。
对于简单的共享变量(计数器、标志位、指针),原子操作比加锁快得多,因为它不需要内核参与。
一、std::atomic
#include <atomic>
std::atomic<int> counter{0};
void increment()
{
++counter; // 原子操作,无需加锁
}
1. 支持的类型
| 类型 | 说明 |
|---|---|
std::atomic<bool> | 布尔标志 |
std::atomic<int> 等整型 | 计数器 |
std::atomic<T*> | 指针 |
std::atomic<float> / double | C++20 起支持 |
std::atomic<T> | 自定义类型,要求可平凡复制 |
对自定义类型,std::atomic<T> 可能内部使用锁(因为无法用单条指令完成),可以用 is_lock_free() 检查:
struct Big { char data[64]; };
std::atomic<Big> a;
if (!a.is_lock_free())
std::cout << "内部使用了锁,性能不如预期\n";
2. 常用接口
std::atomic<int> value{0};
// 读
int v = value.load();
// 写
value.store(42);
// 读-改-写
int old = value.fetch_add(1); // 返回旧值,然后加 1
int old2 = value.fetch_sub(1);
int old3 = value.fetch_and(0xFF);
int old4 = value.fetch_or(0x01);
int old5 = value.fetch_xor(0x01);
// 交换
int prev = value.exchange(100); // 设为 100,返回旧值
// 隐式转换与赋值
int x = value; // 等价于 load()
value = 42; // 等价于 store(42)
++value; // 等价于 fetch_add(1) + 1
3. atomic_flag
std::atomic_flag 是唯一保证无锁的原子类型,常用于实现自旋锁:
class SpinLock
{
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
public:
void lock()
{
while (flag_.test_and_set(std::memory_order_acquire))
{
// 自旋
}
}
void unlock()
{
flag_.clear(std::memory_order_release);
}
};
C++20 起 atomic_flag 还支持 test()(只读)和 wait() / notify_one()。
二、比较并交换(CAS)
CAS 是无锁编程的基础操作:如果当前值等于期望值,就替换为新值。
std::atomic<int> value{0};
int expected = 0;
bool success = value.compare_exchange_strong(expected, 42);
// 如果 value == 0,则设为 42,返回 true
// 否则把 value 的当前值写入 expected,返回 false
注意 expected 是引用传递——失败时会被更新为当前值,这是为了支持重试循环。
1. weak 与 strong
| 版本 | 特点 |
|---|---|
compare_exchange_weak | 可能虚假失败(值相等也返回 false),但更快 |
compare_exchange_strong | 不会虚假失败,但可能更慢 |
weak 版本在循环中使用,因为它虚假失败后重试的代价很小:
std::atomic<int> value{0};
void increment()
{
int expected = value.load();
// 失败时 expected 会被更新,直接重试即可
while (!value.compare_exchange_weak(expected, expected + 1))
{
// expected 已被更新为当前值,循环继续
}
}
strong 版本适合单次尝试(比如「只在值未变时才更新」)。
2. CAS 循环的通用模式
template<class T>
void atomicUpdate(std::atomic<T>& atom, std::function<T(T)> transform)
{
T expected = atom.load();
while (!atom.compare_exchange_weak(expected, transform(expected)))
{
// expected 已更新,重试
}
}
3. ABA 问题
CAS 只比较值,无法察觉「值被改回原样」的情况:
线程 A:读取 value = 100
线程 B:value = 200
线程 B:value = 100
线程 A:CAS(100 → 300) 成功 ← 但它基于的是过期的假设
在指针场景下这可能导致严重后果(指针指向的对象已被释放又分配了新的)。
解决办法是加版本号:
struct VersionedPtr
{
Node* ptr;
uint64_t version;
};
std::atomic<VersionedPtr> head;
// 每次修改都递增 version,ABA 就无法通过比较
或者用 C++20 的 std::atomic<std::shared_ptr<T>>(内部处理了这个问题)。
三、内存序
原子操作默认使用 std::memory_order_seq_cst(顺序一致),这是最严格也最慢的选项。
1. 六种内存序
| 内存序 | 可用于 | 保证 |
|---|---|---|
memory_order_relaxed | 读/写 | 只保证原子性,无同步 |
memory_order_consume | 读 | 数据依赖顺序(已不推荐) |
memory_order_acquire | 读 | 之后的读写不能重排到它之前 |
memory_order_release | 写 | 之前的读写不能重排到它之后 |
memory_order_acq_rel | 读-改-写 | 兼具 acquire 和 release |
memory_order_seq_cst | 全部 | 全局统一顺序(默认) |
2. relaxed:只要原子性
std::atomic<int> counter{0};
void increment()
{
counter.fetch_add(1, std::memory_order_relaxed);
}
relaxed 保证计数不会丢失,但不建立任何同步关系。适合纯粹的统计计数。
3. acquire / release:建立同步
最常用的一对,用于「写完数据后发布」:
std::atomic<bool> ready{false};
int payload = 0;
// 生产者
void produce()
{
payload = 42; // 先写数据
ready.store(true, std::memory_order_release); // 再发布
}
// 消费者
void consume()
{
while (!ready.load(std::memory_order_acquire)) { } // 等待发布
std::cout << payload; // 保证看到 42
}
关键:release 之前的所有写入,对执行 acquire 的线程可见。如果这里用 relaxed,消费者可能看到 ready == true 但 payload 还是 0。
4. seq_cst:全局一致顺序
std::atomic<bool> x{false}, y{false};
std::atomic<int> z{0};
// 线程 A
x.store(true); // 默认 seq_cst
// 线程 B
y.store(true);
// 线程 C
while (!x.load()) { }
if (y.load())
++z;
seq_cst 保证所有线程看到同一个全局操作顺序,让推理变简单。代价是需要内存屏障,在 ARM 等弱内存模型平台上性能损失明显。
建议:默认用 seq_cst。确认它是瓶颈后再降级到 acquire / release。
四、C++20 的新工具
1. atomic 的等待与通知
C++20 给 std::atomic 加了 wait() / notify_one() / notify_all(),可以替代简单的条件变量:
std::atomic<int> value{0};
// 等待线程
void waiter()
{
int expected = 0;
value.wait(expected); // 阻塞直到 value != 0
std::cout << "值变为 " << value.load() << '\n';
}
// 通知线程
void notifier()
{
value.store(42);
value.notify_one();
}
它比条件变量更轻量——不需要互斥量。适合「等待某个标志变化」的场景。
2. atomic_ref
std::atomic_ref 让非原子对象支持原子操作:
int data = 0;
void increment()
{
std::atomic_ref<int> ref(data); // 对 data 的原子引用
ref.fetch_add(1);
}
这在与已有数据结构交互时很有用——你无法改变数据的类型,但需要原子访问。
注意:使用 atomic_ref 期间,所有对 data 的访问都必须通过 atomic_ref,否则仍有数据竞争。
3. 浮点原子操作
C++20 起 std::atomic<float> 和 std::atomic<double> 支持 fetch_add / fetch_sub:
std::atomic<double> sum{0.0};
sum.fetch_add(3.14);
不过很多平台上这不是无锁的(浮点 CAS 不常见)。
五、无锁编程的限度
「无锁」听起来很美,但实际使用要谨慎。
1. 无锁不等于更快
- 无锁算法通常需要 CAS 循环,争用时可能反复重试
- 内存屏障的开销可能比锁还大
- 代码复杂度高,正确性难验证
基准测试表明:在中等争用下,std::mutex 往往比手写无锁结构更快。
2. 正确性极难保证
无锁数据结构需要考虑:
- ABA 问题
- 内存回收(节点何时能安全释放)
- 内存序(每个操作该用什么序)
- 虚假失败(weak CAS)
一个看似简单的无锁栈,正确实现需要几百行代码和严格的证明。
3. 什么时候值得用
| 场景 | 建议 |
|---|---|
| 简单计数器、标志位 | 用 std::atomic |
| 一般共享数据保护 | 用 std::mutex |
| 极短临界区、高争用 | 考虑自旋锁或原子操作 |
| 需要无锁队列/栈 | 优先用成熟库(如 folly、boost::lockfree) |
| 实时系统、不能阻塞 | 无锁是必要的,但要充分测试 |
六、常见误区
「std::atomic 一定无锁」 —— 对自定义类型可能内部用锁。用 is_lock_free() 检查。
「原子操作可以保护多个变量」 —— 不能。每个原子变量独立,多变量一致性仍需互斥量。
「复合操作是原子的」 —— 不是:
std::atomic<int> x{0};
if (x > 0) // 原子读
x--; // 原子写
// 但「检查再减」整体不是原子的
「volatile 可以替代 std::atomic」 —— 不能。volatile 不提供原子性,也不建立同步关系。
「relaxed 没有用」 —— 它保证了原子性,适合纯计数器场景,性能最好。
「CAS 失败就是出错了」 —— CAS 失败是正常情况(说明有其它线程修改了值),循环重试即可。
「无锁一定比加锁快」 —— 中等争用下往往更慢。先测量再优化。
七、相关章节
异步任务
前面几节讲的都是「多个线程同时干活」。这一节换个角度:把一件事交给别处去做,自己继续往下走,需要结果时再取回来。
这就是异步。它和「多线程」不是一回事——异步是关于任务的组织方式,多线程只是实现它的手段之一。一个异步任务可能跑在另一个线程上,也可能跑在线程池里,甚至可能根本不并发(延迟执行)。
一、三种异步风格
「异步」不是一个具体的 API,而是一类代码组织方式。按「怎么拿到结果」来分,主流有三种风格:
| 风格 | 怎么拿结果 | 典型代表 |
|---|---|---|
| 回调式 | 传一个函数进去,完成时被调用 | readAsync(cb)、epoll、Node.js |
| Future/Promise 式 | 拿到一个「未来会有值」的凭证,get() 取回 | std::future、std::async |
| 协程式 | 用 co_await 挂起,看起来像同步代码 | C++20 协程、std::generator |
这三种风格解决的是同一件事,但控制流的写法完全不同。下面先讲标准库提供的 Future/Promise 风格(这是 C++ 里最常用的),再讲回调,最后讲协程。
1. std::async:最省事的写法
#include <future>
int compute(int x)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
return x * x;
}
int main()
{
std::future<int> fut = std::async(std::launch::async, compute, 10);
// 主线程继续做别的事
std::cout << "doing other work\n";
int result = fut.get(); // 需要结果时取回,会阻塞直到完成
std::cout << result << '\n'; // 100
}
std::async 返回一个 std::future<T>,get() 会阻塞直到结果就绪。如果任务抛了异常,get() 会重新抛出。
启动策略:
| 策略 | 行为 |
|---|---|
std::launch::async | 立即在新线程上启动 |
std::launch::deferred | 延迟到 get() / wait() 时才在当前线程执行 |
| 默认(两者都不指定) | 由实现选择 |
默认策略是个陷阱。如果实现选择了 deferred,任务根本不会异步执行——get() 时才同步跑一遍。这会悄悄破坏你的并发假设:
// 危险:可能根本没并发
auto f1 = std::async(task1);
auto f2 = std::async(task2);
f1.get();
f2.get();
如果两个任务都是 deferred,它们会串行执行。要并发就显式写 std::launch::async。
2. std::promise / std::future:手动传递结果
std::promise 是「结果的写端」,std::future 是「结果的读端」。它们通过一个共享状态连接。
#include <future>
#include <thread>
void worker(std::promise<int> prom)
{
try
{
int result = doSomething();
prom.set_value(result); // 设置结果
}
catch (...)
{
prom.set_exception(std::current_exception()); // 传递异常
}
}
int main()
{
std::promise<int> prom;
std::future<int> fut = prom.get_future();
std::thread t(worker, std::move(prom));
int result = fut.get(); // 等待并取回
t.join();
}
promise 必须被移动(不可拷贝),因为结果只能被设置一次。
适合的场景:任务的结果不是由「函数返回值」自然产生的,比如回调式 API 里在回调中设置结果:
std::future<int> asyncRead(Connection& conn)
{
auto prom = std::make_shared<std::promise<int>>();
auto fut = prom->get_future();
conn.readAsync([prom](int value) {
prom->set_value(value); // 回调里设置结果
});
return fut;
}
3. std::packaged_task:把任务包装成可调用对象
std::packaged_task 把「一个可调用对象 + 一个 future」绑在一起,任务执行时自动把返回值(或异常)送进 future。
#include <future>
std::packaged_task<int(int)> task([](int x) {
return x * x;
});
std::future<int> fut = task.get_future();
// 可以在任意线程、任意时机执行
std::thread t(std::move(task), 10);
t.join();
std::cout << fut.get(); // 100
它的价值在于任务可以被存储、传递、排队——这正是线程池需要的:
class ThreadPool
{
std::queue<std::packaged_task<void()>> tasks_;
// ...
public:
template<class F>
auto submit(F&& f) -> std::future<decltype(f())>
{
using ReturnType = decltype(f());
std::packaged_task<ReturnType()> task(std::forward<F>(f));
std::future<ReturnType> fut = task.get_future();
{
std::lock_guard lock(mtx_);
tasks_.emplace(std::move(task));
}
cv_.notify_one();
return fut; // 调用者拿到 future,稍后取结果
}
};
4. 三种 Future 工具对比
std::async、std::promise、std::packaged_task 都属于 Future/Promise 风格,区别在于「结果是怎么产生的」:
| 方式 | 抽象层次 | 结果从哪来 | 适用场景 |
|---|---|---|---|
std::async | 最高 | 函数返回值,库自动处理 | 一次性任务,不关心线程管理 |
std::promise | 最低 | 手动 set_value | 结果来自回调、需要手动控制时机 |
std::packaged_task | 中等 | 执行时自动填入 | 任务需要排队、存储、传递 |
三者最终都产生 std::future,取结果的方式一致。选哪个取决于结果是怎么产生的。
注意区分两个维度:这里是「Future/Promise 风格内部的三种工具」,而本节开头讲的是「回调 / Future / 协程三种风格」。前者是同一风格下的不同工具,后者是根本不同的代码组织方式。
二、future 的细节
1. shared_future
std::future 的 get() 只能调用一次,且会移动结果。多个线程需要读同一结果时用 std::shared_future:
std::promise<int> prom;
std::shared_future<int> sfut = prom.get_future().share();
auto reader = [sfut] {
std::cout << sfut.get() << '\n'; // 可以多次调用
};
std::thread t1(reader), t2(reader);
prom.set_value(42);
t1.join();
t2.join();
2. 超时等待
std::future<int> fut = std::async(std::launch::async, slowTask);
// 等待最多 100ms
if (fut.wait_for(std::chrono::milliseconds(100)) == std::future_status::ready)
{
std::cout << fut.get() << '\n';
}
else
{
std::cout << "还没完成\n";
}
三种状态:
| 状态 | 含义 |
|---|---|
std::future_status::ready | 结果就绪 |
std::future_status::timeout | 超时 |
std::future_status::deferred | 任务是延迟执行的 |
3. future 的局限
标准库的 future 有几个明显的短板:
没有 then()。无法链式组合:
// 标准库做不到这样
fut.then([](int x) { return x * 2; })
.then([](int x) { std::cout << x; });
C++ 标准委员会曾推动 std::future 的扩展(.then()、when_all、when_any),但最终没有进入标准。需要这些功能得用第三方库(如 folly::Future、boost::future)。
析构可能阻塞。std::async 返回的 future,如果从未调用 get() 或 wait(),析构时会同步等待任务完成:
{
auto fut = std::async(std::launch::async, longTask);
// 忘记 get()
} // 这里会阻塞,直到 longTask 完成
这常常出乎意料。要么确保调用 get(),要么用线程池自己管理。
无法取消。future 没有取消机制。任务一旦启动就必须跑完。
三、回调函数
回调是另一种组织异步的方式:不返回 future,而是传入一个函数,任务完成时调用它。
1. 基本形式
void readAsync(const std::string& path, std::function<void(std::string)> callback)
{
std::thread([path, callback] {
std::string content = readFile(path);
callback(content); // 完成后调用
}).detach();
}
// 使用
readAsync("data.txt", [](std::string content) {
std::cout << "读到 " << content.size() << " 字节\n";
});
回调比 future 更灵活——可以多次调用(流式数据)、可以在任意时机调用。但代价是控制流被打散。
2. 生命周期问题
回调最大的坑是捕获对象的生命周期。如果回调持有 this,而对象在回调触发前被销毁了:
class Downloader
{
public:
void start()
{
// 危险:this 可能在回调前失效
asyncFetch([this](Data d) {
this->onData(d); // 悬空指针
});
}
void onData(Data d) { /* ... */ }
};
// 使用
{
Downloader d;
d.start();
} // d 析构,但异步任务还在跑
解决办法一:enable_shared_from_this
class Downloader : public std::enable_shared_from_this<Downloader>
{
public:
void start()
{
auto self = shared_from_this(); // 延长生命周期
asyncFetch([self](Data d) {
self->onData(d); // 只要回调存在,对象就活着
});
}
};
解决办法二:弱引用 + 检查
class Downloader : public std::enable_shared_from_this<Downloader>
{
public:
void start()
{
std::weak_ptr<Downloader> weak = shared_from_this();
asyncFetch([weak](Data d) {
if (auto self = weak.lock()) // 对象还活着吗?
self->onData(d);
});
}
};
弱引用不会延长生命周期,适合「对象没了就放弃回调」的语义。
解决办法三:取消机制
让异步操作支持取消,对象析构时先取消:
class Downloader
{
std::stop_source stopSrc_;
public:
~Downloader() { stopSrc_.request_stop(); }
void start()
{
auto token = stopSrc_.get_token();
asyncFetch([this, token](Data d) {
if (token.stop_requested()) return;
onData(d);
});
}
};
3. 回调地狱
回调嵌套多了会变成这样:
login(user, [](Result r1) {
fetchProfile(r1.id, [](Profile p) {
loadSettings(p.settingsId, [](Settings s) {
applyTheme(s.theme, [](bool ok) {
// 越来越深……
});
});
});
});
这就是「回调地狱」。协程的动机之一就是解决它。
4. 回调与 future 的桥接
两种风格可以互相转换。前面见过回调 → future(用 promise)。反过来,future → 回调:
template<class T, class F>
void then(std::future<T> fut, F callback)
{
std::thread([fut = std::move(fut), callback]() mutable {
callback(fut.get());
}).detach();
}
// 使用
then(std::async(std::launch::async, compute, 10), [](int x) {
std::cout << x << '\n';
});
5. 异步回调里的 TOCTOU
异步代码天然存在「检查」和「使用」之间的时间差,这正是 TOCTOU(检查时刻到使用时刻)的温床。
看这个例子——检查时对象还活着,回调触发时已经没了:
class Connection
{
public:
void send(const std::string& data)
{
if (!connected_) // 检查:连接还在吗
return;
// ... 中间可能有 await / 回调 / 线程切换 ...
socket_.write(data); // 使用:连接可能已经断开!
}
};
在异步代码里,这个窗口可能非常长——一次网络往返、一次磁盘 I/O,都是毫秒级甚至秒级。相比之下,多线程里的 TOCTOU 窗口通常只有几条指令。
回调场景的典型形态:
// 危险:回调触发时,检查的结论可能已经过期
void onData(Data d)
{
if (buffer_.size() < MAX) // 检查
{
process(d); // 使用 —— 但 buffer_ 可能已被其它回调填满
buffer_.push_back(d);
}
}
解决办法和同步代码一样:把检查和使用合并成一次原子操作。
void onData(Data d)
{
std::lock_guard lock(mtx_);
if (buffer_.size() >= MAX) // 检查和使用在同一临界区
return;
buffer_.push_back(d);
process(d);
}
或者用「一次操作 + 错误处理」的 EAFP 风格:
void onData(Data d)
{
if (!tryPush(d)) // 一次操作,失败就放弃
return;
process(d);
}
异步代码里还要特别注意:co_await、回调、future.get() 都是潜在的「检查点」。任何跨越这些点的「先判断、后操作」都需要重新审视。详见互斥量与锁中的 TOCTOU 一节。
四、协程
协程(coroutine)是 C++20 引入的语言特性。它让函数可以暂停和恢复,而不阻塞线程。
1. 三个关键字
#include <coroutine>
// co_await:等待一个异步操作完成
Task fetchAsync()
{
auto data = co_await fetch("url"); // 暂停,让出线程
co_return process(data); // 返回结果
}
// co_yield:产生一个值并暂停
Generator<int> range(int n)
{
for (int i = 0; i < n; ++i)
co_yield i; // 每次产生一个值
}
// co_return:结束协程并返回
只要函数体里出现这三个关键字之一,它就是协程。
2. 为什么协程能解决回调地狱
用协程重写前面的嵌套回调:
Task applyThemeForUser(User user)
{
Result r1 = co_await login(user);
Profile p = co_await fetchProfile(r1.id);
Settings s = co_await loadSettings(p.settingsId);
bool ok = co_await applyTheme(s.theme);
}
看起来是同步代码,实际是异步执行。每个 co_await 会暂停当前协程、把控制权还给调用者,等操作完成后再恢复。没有嵌套,没有回调地狱。
3. 标准库只给了机制
这是最容易误解的地方:C++20 的协程是纯粹的编译器机制,标准库几乎没有提供可用的协程类型。
编译器负责把协程函数转换成状态机,但「Task 是什么」「Generator 怎么实现」「co_await 一个网络请求意味着什么」——这些都要你自己定义,或者用库。
标准库唯一提供的协程类型是 C++23 的 std::generator:
#include <generator>
std::generator<int> fibonacci()
{
int a = 0, b = 1;
while (true)
{
co_yield a;
std::tie(a, b) = std::pair{b, a + b};
}
}
int main()
{
for (int x : fibonacci())
{
if (x > 100) break;
std::cout << x << ' ';
}
}
4. 自己实现需要什么
一个最小的协程类型需要定义 promise_type:
struct Task
{
struct promise_type
{
Task get_return_object() { return {}; }
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
};
promise_type 是编译器与协程交互的接口——它决定协程何时挂起、结果如何传递、异常如何处理。
真正可用的协程库(如 cppcoro、libunifex)需要实现调度、内存分配、co_await 的 awaiter 协议等,工作量很大。
5. 协程与线程的关系
协程不是线程。协程在哪个线程上恢复,取决于调度器。同一个线程可以跑成千上万个协程,因为挂起时不占用栈。
这让协程非常适合高并发 I/O 密集场景:等待网络响应时挂起,线程去跑别的协程,而不是阻塞在 read() 上。
但协程不解决 CPU 密集问题——计算任务仍然需要多线程。
五、选择哪种方式
先选风格,再选工具:
| 风格 | 适合什么情况 | 不适合什么情况 |
|---|---|---|
| 回调 | 事件驱动、流式数据、需要多次通知 | 复杂的顺序流程(会变成回调地狱) |
| Future | 一次性任务、需要取一次结果 | 需要链式组合、需要取消 |
| 协程 | 复杂的异步流程、大量并发 I/O | 简单场景(引入的复杂度不划算) |
具体到工具:
| 需求 | 推荐 |
|---|---|
| 一次性并行计算 | std::async + launch::async |
| 结果来自回调式 API | std::promise |
| 任务需要排队 | std::packaged_task + 线程池 |
| 简单的完成通知 | 回调函数 |
| 复杂的异步流程 | 协程(配合库) |
需要 .then() 链式组合 | 第三方 future 库 |
六、常见误区
「std::async 一定异步执行」 —— 默认策略可能是 deferred,任务会延迟到 get() 时同步执行。要并发就显式指定 launch::async。
「std::async 返回的 future 析构不会阻塞」 —— 会。如果没调用 get(),析构时会等待任务完成。
「future::get() 可以调用多次」 —— 只能一次。需要多次读用 shared_future。
「回调比 future 好」 —— 各有场景。回调灵活但容易写出回调地狱和生命周期 bug;future 简单但缺少组合能力。
「协程是并发的」 —— 协程是并发模型,不是并行。它在单线程上也能跑,靠挂起切换而非线程切换。
「C++20 有协程就能直接写异步代码了」 —— 标准库只提供了语言机制,可用的协程类型要自己写或用库。
「co_await 会阻塞线程」 —— 不会。它挂起协程、让出线程,这正是协程的价值。
「异步代码里检查过就没问题了」 —— 检查和使用之间隔着 co_await、回调或 I/O,状态可能已经改变,这就是 TOCTOU。
七、相关章节
并发模式与陷阱
前面几节讲的是工具:线程、锁、条件变量、原子操作、异步任务。这一节讲怎么把它们组装成能用的东西。
一、线程池
1. 为什么需要线程池
每来一个任务就 std::thread 创建一个,看起来简单,实际代价很大:
- 创建开销:一个线程的创建涉及内核对象、栈分配(默认 1~8 MB),大约几十微秒
- 销毁开销:线程退出也要清理
- 数量失控:任务多了线程就多,可能耗尽内存或让调度器崩溃
线程池的思路是:预先创建固定数量的线程,任务来了放进队列,空闲线程去取。
任务提交 → [任务队列] → 工作线程 1
→ 工作线程 2
→ 工作线程 3
2. 一个可用的实现
#include <condition_variable>
#include <functional>
#include <future>
#include <mutex>
#include <queue>
#include <thread>
#include <vector>
class ThreadPool
{
public:
explicit ThreadPool(std::size_t n = std::thread::hardware_concurrency())
{
for (std::size_t i = 0; i < n; ++i)
{
workers_.emplace_back([this] { workerLoop(); });
}
}
~ThreadPool()
{
{
std::lock_guard lock(mtx_);
stop_ = true;
}
cv_.notify_all();
for (auto& t : workers_)
t.join();
}
// 提交任务,返回 future
template<class F, class... Args>
auto submit(F&& f, Args&&... args)
-> std::future<std::invoke_result_t<F, Args...>>
{
using ReturnType = std::invoke_result_t<F, Args...>;
// 用 shared_ptr 包装,因为 packaged_task 不可拷贝
auto task = std::make_shared<std::packaged_task<ReturnType()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
std::future<ReturnType> fut = task->get_future();
{
std::lock_guard lock(mtx_);
if (stop_)
throw std::runtime_error("线程池已停止");
tasks_.emplace([task] { (*task)(); });
}
cv_.notify_one();
return fut;
}
private:
void workerLoop()
{
while (true)
{
std::function<void()> task;
{
std::unique_lock lock(mtx_);
cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
if (stop_ && tasks_.empty())
return; // 停止且无剩余任务
task = std::move(tasks_.front());
tasks_.pop();
}
task(); // 在锁外执行
}
}
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex mtx_;
std::condition_variable cv_;
bool stop_ = false;
};
使用:
ThreadPool pool(4);
std::vector<std::future<int>> results;
for (int i = 0; i < 10; ++i)
{
results.push_back(pool.submit([i] {
return i * i;
}));
}
for (auto& f : results)
std::cout << f.get() << '\n';
3. 几个设计要点
任务在锁外执行。task() 必须在解锁后调用,否则工作线程持锁执行任务,其它线程无法取任务,线程池退化成了单线程。
packaged_task 不可拷贝。用 shared_ptr 包装才能放进 std::function(它要求可拷贝)。
析构要等所有任务完成。stop_ 置位后 notify_all,工作线程取完剩余任务才退出。如果直接 detach,析构后任务还在跑,可能访问已销毁的对象。
线程数怎么定:
| 任务类型 | 建议线程数 |
|---|---|
| CPU 密集 | hardware_concurrency() |
| I/O 密集 | 更多(2~4 倍) |
| 混合 | 分成两个池,分别调优 |
4. 有界队列与背压
上面的实现队列无界——任务提交太快会耗尽内存。生产环境需要背压:
template<class T>
class BoundedQueue
{
public:
explicit BoundedQueue(std::size_t cap) : cap_(cap) {}
void push(T item)
{
std::unique_lock lock(mtx_);
notFull_.wait(lock, [this] { return q_.size() < cap_; });
q_.push(std::move(item));
notEmpty_.notify_one();
}
T pop()
{
std::unique_lock lock(mtx_);
notEmpty_.wait(lock, [this] { return !q_.empty(); });
T item = std::move(q_.front());
q_.pop();
notFull_.notify_one();
return item;
}
private:
std::mutex mtx_;
std::condition_variable notEmpty_, notFull_;
std::queue<T> q_;
std::size_t cap_;
};
背压的意思是:队列满了就让提交者等待,而不是无限堆积。这是保护系统不被打垮的关键机制。
二、回调与事件驱动
1. 回调注册模式
事件驱动系统里,组件不直接调用彼此,而是注册回调:
class EventLoop
{
public:
using Callback = std::function<void()>;
void onRead(int fd, Callback cb)
{
readHandlers_[fd] = std::move(cb);
}
void onWrite(int fd, Callback cb)
{
writeHandlers_[fd] = std::move(cb);
}
void run()
{
while (!stop_)
{
auto events = poll(); // 等待事件
for (auto& ev : events)
{
auto it = (ev.type == Event::Read)
? readHandlers_.find(ev.fd)
: writeHandlers_.find(ev.fd);
if (it != readHandlers_.end() && it->second)
it->second(); // 调用回调
}
}
}
private:
std::unordered_map<int, Callback> readHandlers_;
std::unordered_map<int, Callback> writeHandlers_;
bool stop_ = false;
};
这就是 epoll / kqueue / IOCP 这类 I/O 多路复用库的抽象方式。一个线程处理成千上万个连接,靠的是「事件来了才处理」。
2. 回调的线程安全
回调可能在任意线程被调用,这带来两个问题:
回调内部访问共享数据要加锁:
void onData(Data d)
{
std::lock_guard lock(mtx_);
buffer_.push_back(d);
}
回调注册与调用可能并发。如果 run() 在遍历 handler 时另一个线程修改了 map,会崩溃。常见做法是:
- 用「注册到事件循环线程」的队列,避免跨线程修改
- 或者用读写锁保护
class EventLoop
{
std::shared_mutex mtx_;
void onRead(int fd, Callback cb)
{
std::unique_lock lock(mtx_);
readHandlers_[fd] = std::move(cb);
}
void dispatch(int fd)
{
std::shared_lock lock(mtx_); // 读锁,多个分发可并发
auto it = readHandlers_.find(fd);
if (it != readHandlers_.end())
it->second();
}
};
3. 事件驱动的优势与代价
优势:
- 单线程处理大量连接,无线程切换开销
- 内存占用小(每个连接不需要一个线程栈)
- 没有锁竞争(如果都在一个线程里)
代价:
- 一个回调阻塞,所有连接都卡住
- 无法利用多核(除非跑多个事件循环)
- 控制流被事件切碎,调试困难
三、其它常见模式
1. 读写锁保护缓存
class Cache
{
std::shared_mutex mtx_;
std::unordered_map<int, std::string> data_;
public:
std::optional<std::string> get(int key)
{
std::shared_lock lock(mtx_); // 读并发
auto it = data_.find(key);
if (it == data_.end()) return std::nullopt;
return it->second;
}
void put(int key, std::string value)
{
std::unique_lock lock(mtx_); // 写独占
data_[key] = std::move(value);
}
};
2. 双重检查锁定(DCLP)
延迟初始化的经典写法:
class Singleton
{
public:
static Singleton* instance()
{
Singleton* tmp = instance_.load(std::memory_order_acquire);
if (!tmp) // 第一次检查,避免每次都加锁
{
std::lock_guard lock(mtx_);
tmp = instance_.load(std::memory_order_relaxed);
if (!tmp) // 第二次检查,防止重复初始化
{
tmp = new Singleton();
instance_.store(tmp, std::memory_order_release);
}
}
return tmp;
}
private:
static std::atomic<Singleton*> instance_;
static std::mutex mtx_;
};
但通常不需要这么写。C++11 起局部静态变量是线程安全的:
Singleton& instance()
{
static Singleton inst;
return inst;
}
一行搞定,编译器保证只初始化一次。
3. Future 模式
把「计算」和「取结果」解耦:
std::future<Image> loadAsync(const std::string& path)
{
return std::async(std::launch::async, [path] {
return decodeImage(readFile(path));
});
}
// 调用者可以先去干别的
auto fut = loadAsync("photo.jpg");
doOtherWork();
Image img = fut.get();
四、常见陷阱汇总
1. 数据竞争
| 问题 | 表现 |
|---|---|
| 未同步的共享读写 | 结果不确定、偶发崩溃 |
用 volatile 代替 atomic | 编译器优化导致读写丢失 |
| 复合操作非原子 | if (x) x-- 整体不加锁 |
排查工具:ThreadSanitizer(-fsanitize=thread)。
2. 死锁
| 问题 | 表现 |
|---|---|
| 加锁顺序不一致 | 两个线程互相等待 |
| 异常路径未解锁 | 未用 RAII |
| 持锁调用外部代码 | 外部代码又加锁 |
排查工具:GDB 查看各线程栈、pstack、死锁检测器。
3. TOCTOU
| 问题 | 表现 |
|---|---|
| 检查和使用拆在两个临界区 | 中间状态被改变,逻辑错误 |
| 返回共享数据的引用 | 指针逃出锁的保护范围 |
| 异步回调里「先判断后操作」 | 回调触发时判断已过期 |
特点:加锁了也可能存在。加锁保证单个操作的原子性,不保证「检查 + 使用」这一对操作的原子性。
应对:扩大临界区、返回拷贝而非引用、改用 EAFP 风格。详见互斥量与锁。
4. 生命周期
| 问题 | 表现 |
|---|---|
| 线程访问已销毁对象 | 悬空指针、随机崩溃 |
回调捕获 this | 对象先于回调销毁 |
detach 后不管 | 程序退出时线程仍在跑 |
原则:线程和回调的生命周期必须被明确管理,不要 detach 了事。
5. 性能
| 问题 | 表现 |
|---|---|
| 锁粒度过大 | 线程大量串行等待 |
| 伪共享 | 不同线程写同一缓存行的不同变量 |
| 线程过多 | 上下文切换开销超过计算收益 |
| 频繁创建线程 | 创建/销毁开销 |
伪共享值得单独说:两个线程分别写相邻的两个 int,如果它们落在同一个缓存行(通常 64 字节),CPU 会反复使缓存行失效:
// 不好:两个计数器可能在同一缓存行
struct Counters
{
std::atomic<int> a;
std::atomic<int> b;
};
// 好:用填充隔开
struct Counters
{
alignas(64) std::atomic<int> a;
alignas(64) std::atomic<int> b;
};
C++17 还提供了 std::hardware_destructive_interference_size 表示缓存行大小。
6. 调试困难
并发 bug 的特点:偶发、不可复现、换个机器就消失。
应对方法:
- ThreadSanitizer:检测数据竞争,编译时加
-fsanitize=thread - 压力测试:加大并发度和迭代次数
- 确定性调度:用工具(如 rr)记录重放
- 代码审查:重点看共享数据的访问路径
五、几条经验
能用简单方案就别用复杂的。std::mutex 保护一切,往往比精心设计的无锁结构更可靠。
先测量再优化。并发性能问题常常不在你猜的地方。
共享可变状态越少越好。如果数据可以只被一个线程访问,就根本不需要同步。
优先用消息传递而非共享内存。线程间通过队列传数据,比共享数据结构加锁更容易推理。
给线程和回调明确的归属。谁创建、谁销毁、什么时候能安全访问,这些必须清楚。
六、常见误区
「线程池线程越多越快」 —— 超过核心数后,上下文切换开销会抵消收益。
「detach 可以省去 join 的麻烦」 —— 它把生命周期问题变成了崩溃。除非线程真的独立于所有对象,否则别 detach。
「双重检查锁定比局部静态变量好」 —— C++11 起局部静态变量就是线程安全的,且更简洁。
「无锁队列一定比加锁队列快」 —— 中等争用下往往更慢,且正确性极难保证。
「伪共享只是理论问题」 —— 在高频写入场景下可以造成数倍性能差距。
「并发 bug 可以靠加日志调试」 —— 日志会改变时序,bug 可能因此消失。用专门的检测工具。
「加了锁就不会有并发问题了」 —— 锁只解决数据竞争。检查和操作分离导致的 TOCTOU、以及生命周期问题,加锁都挡不住。
七、相关章节
- 线程基础:线程的创建、管理与生命周期
- 互斥量与锁:锁的选择与死锁避免
- 条件变量:任务队列的等待机制
- 原子操作:无锁与伪共享
- 异步任务:future、回调与协程
- 内存模型:happens-before 与可见性
现代 C++ 演进
C++ 从 2011 年起进入三年一版的快速演进节奏。理解各版本引入了什么,有助于判断手头的代码库能用哪些特性,也有助于理解为什么某些写法会成为「现代 C++」的推荐风格。
本节按版本梳理主要特性。具体用法见各专题章节,本节只做脉络梳理与速查。
一、版本时间线
| 标准 | 俗称 | 发布时间 | 核心主题 |
|---|---|---|---|
| C++98 | C++98 | 1998 | 第一个 ISO 标准,STL 正式纳入 |
| C++03 | C++03 | 2003 | 修订版,主要是缺陷修复 |
| C++11 | C++0x | 2011 | 现代 C++ 起点:移动语义、lambda、并发、智能指针 |
| C++14 | C++1y | 2014 | 对 C++11 的完善与放宽 |
| C++17 | C++1z | 2017 | 结构化绑定、if constexpr、optional/variant、并行算法 |
| C++20 | C++2a | 2020 | 第二次大修订:Concepts、Ranges、协程、模块、三路比较 |
| C++23 | C++2b | 2023 | expected、print、mdspan、Ranges 扩展 |
| C++26 | C++2c | 2026 | 反射、契约、并行 Ranges、std::execution |
二、C++11:现代 C++ 的起点
C++11 是语言历史上最大的一次修订,它改变了 C++ 的编程范式。
语言特性
| 特性 | 说明 |
|---|---|
auto | 类型自动推导 |
范围 for | for (auto& x : container) |
右值引用 && | 移动语义的基础 |
| 移动构造/赋值 | 避免不必要的深拷贝 |
| Lambda 表达式 | 就地定义匿名函数对象 |
nullptr | 类型安全的空指针 |
enum class | 强类型枚举,不隐式转换 |
override / final | 显式标注虚函数覆盖 |
= default / = delete | 显式控制特殊成员函数 |
| 变参模板 | template<typename... Args> |
constexpr | 编译期求值 |
noexcept | 声明不抛异常 |
static_assert | 编译期断言 |
| 委托构造 / 继承构造 | 构造函数复用 |
初始化列表 {} | 统一的初始化语法 |
using 别名 | using IntVec = std::vector<int>; |
标准库特性
| 特性 | 说明 |
|---|---|
| 智能指针 | unique_ptr、shared_ptr、weak_ptr |
std::thread | 线程支持 |
std::mutex | 互斥量 |
std::atomic | 原子操作 |
std::chrono | 时间库 |
std::random | 随机数库 |
std::unordered_* | 哈希容器 |
std::array | 固定大小数组 |
std::tuple | 异质容器 |
std::function | 通用函数包装器 |
// C++11 的典型现代写法
auto v = std::vector<int>{1, 2, 3, 4, 5};
auto sum = 0;
for (const auto& x : v)
sum += x;
auto even_count = std::count_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; });
auto ptr = std::make_unique<int>(42);
三、C++14:完善与放宽
C++14 是一个小版本,主要是对 C++11 的补全。
| 特性 | 说明 |
|---|---|
| 泛型 lambda | [](auto x) { return x; } |
| 初始化捕获 | [x = std::move(y)] {} |
constexpr 放宽 | 允许循环、分支、多语句 |
| 函数返回类型推导 | auto f() { return 42; } |
| 变量模板 | template<typename T> constexpr T pi = ...; |
std::make_unique | C++11 遗漏的补全 |
// 泛型 lambda
auto print = [](const auto& x) { std::cout << x << '\n'; };
// 初始化捕获(移动捕获)
auto ptr = std::make_unique<int>(42);
auto f = [p = std::move(ptr)] { return *p; };
四、C++17:实用主义
C++17 引入了大量实用特性,是日常开发中最常用的一版。
语言特性
| 特性 | 说明 |
|---|---|
| 结构化绑定 | auto [a, b] = pair; |
if 初始化语句 | if (auto it = m.find(k); it != m.end()) |
if constexpr | 编译期分支,替代 SFINAE |
| 折叠表达式 | (args + ...) |
| 内联变量 | 头文件中定义变量 |
[[nodiscard]] | 警告忽略返回值 |
[[maybe_unused]] | 抑制未使用警告 |
| 类模板参数推导 | std::pair p(1, 2); |
*this 捕获 | [*this] {} |
constexpr lambda | lambda 可用于编译期 |
| 嵌套命名空间 | namespace a::b::c {} |
标准库特性
| 特性 | 说明 |
|---|---|
std::optional | 可选值 |
std::variant | 类型安全联合体 |
std::any | 任意类型容器 |
std::string_view | 字符串视图 |
std::filesystem | 文件系统操作 |
std::byte | 字节类型 |
| 并行算法 | std::execution::par |
std::scoped_lock | 多互斥量锁定 |
std::apply | 展开 tuple 调用函数 |
// 结构化绑定 + if 初始化
if (auto [it, inserted] = map.try_emplace(key, value); inserted)
std::cout << "插入成功\n";
// if constexpr
template <typename T>
auto stringify(T value)
{
if constexpr (std::is_arithmetic_v<T>)
return std::to_string(value);
else
return std::string(value);
}
// optional
std::optional<int> find_value(const std::string& key);
if (auto v = find_value("key"))
std::cout << *v << '\n';
五、C++20:第二次大修订
C++20 引入了四大特性(Concepts、Ranges、协程、模块),是继 C++11 之后最重要的版本。
语言特性
| 特性 | 说明 |
|---|---|
| Concepts | 对模板参数的显式约束 |
| Ranges | 可组合的范围算法与视图 |
| 协程 | co_await / co_yield / co_return |
| 模块 | import 取代 #include |
三路比较 <=> | 一行定义全部比较运算符 |
| 指定初始化 | Point{.x = 1, .y = 2} |
consteval | 强制编译期求值 |
constinit | 强制编译期初始化 |
using enum | 引入枚举成员到作用域 |
| lambda 模板参数 | []<typename T>(T x) {} |
[[likely]] / [[unlikely]] | 分支预测提示 |
标准库特性
| 特性 | 说明 |
|---|---|
std::span | 连续序列的非拥有视图 |
std::format | 格式化输出 |
std::jthread | 自动 join 的线程 |
std::latch / barrier | 线程同步原语 |
std::semaphore | 信号量 |
std::bit_cast | 类型安全的位转换 |
std::midpoint | 中点计算(避免溢出) |
std::numbers | 数学常量 |
contains | 关联容器的成员函数 |
std::source_location | 源码位置信息 |
// Concepts
template <std::integral T>
T add(T a, T b) { return a + b; }
// Ranges
auto result = data
| std::views::filter([](int x) { return x > 0; })
| std::views::transform([](int x) { return x * 2; })
| std::views::take(5);
// 三路比较
struct Point {
int x, y;
auto operator<=>(const Point&) const = default;
};
// span
void process(std::span<const int> data);
// format
std::string s = std::format("x = {}, y = {}", 1, 2);
六、C++23:稳步推进
| 特性 | 说明 |
|---|---|
std::expected | 错误值或结果值 |
std::print | 直接打印,取代 cout |
std::mdspan | 多维数组视图 |
std::flat_map / flat_set | 基于有序 vector 的容器 |
| Ranges 扩展 | zip、enumerate、chunk、slide、stride |
std::ranges::to | 视图转容器 |
std::stacktrace | 调用栈信息 |
if consteval | 区分编译期与运行期 |
多维下标 [] | arr[i, j] 语法 |
std::byteswap | 字节序转换 |
import std; | 标准库模块 |
// expected
std::expected<int, std::string> parse(const std::string& s)
{
try {
return std::stoi(s);
} catch (...) {
return std::unexpected("解析失败");
}
}
// print
std::print("Hello, {}!\n", "World");
// Ranges 扩展
for (auto [i, x] : std::views::enumerate(data))
std::cout << i << ": " << x << '\n';
七、C++26:即将到来
C++26 已于 2026 年发布,主要特性包括:
| 特性 | 说明 |
|---|---|
| 反射(静态) | 编译期检查与生成代码 |
| 契约 | pre / post / contract_assert |
| 并行 Ranges | ranges:: 算法接受执行策略 |
std::execution | 发送者/接收者异步模型 |
std::hive | 新容器类型 |
std::inplace_vector | 固定容量、栈上存储的动态数组 |
std::linalg | 线性代数算法 |
std::simd | 数据并行类型 |
std::rcu / 危险指针 | 安全内存回收 |
=) 占位符 | 结构化绑定的未使用标记 |
八、如何选择特性版本
| 场景 | 建议 |
|---|---|
| 新项目、工具链可控 | 直接用 C++20 或 C++23 |
| 需要广泛兼容 | C++17 是当前最稳妥的选择 |
| 嵌入式、老编译器 | 可能只能用到 C++11/14 |
| 学习 | 从 C++11 基础特性入手,逐步掌握 C++17/20 |
判断编译器支持情况:
#include <version> // C++20
#if __cpp_lib_optional >= 201603L
// 支持 std::optional
#endif
#if __cpp_concepts >= 201907L
// 支持 Concepts
#endif
也可以用 __cplusplus 宏判断语言版本:
#if __cplusplus >= 202002L
// C++20 或更高
#endif
九、注意事项
- 不要为了用而用。新特性应服务于可读性和正确性,而非炫技。
- 注意编译器支持。不同编译器对同一特性的支持进度不同,尤其是 C++20/23 的部分特性。
- 模块尚未普及。虽然 C++20 就引入了模块,但工具链支持仍在完善中,实际项目大多仍用头文件。
- 协程需要库支持。标准库只提供了语言机制,实际使用需要第三方库(如 cppcoro)或自行封装。
- ABI 兼容性。升级标准可能影响二进制兼容,尤其是涉及标准库类型的接口。
constexpr能力持续增强。C++14 允许循环,C++17 允许 lambda,C++20 允许try(C++26 起允许抛异常),编译器支持度需确认。
十、相关章节
- C++ 新特性:移动语义、右值引用、
constexpr、noexcept的详细用法 - Ranges:Ranges 视图与管道语法
- Iterators:C++20 迭代器概念
- Algorithms:C++20 Ranges 算法与 C++26 并行范围算法
- 异常处理:
noexcept与异常安全
STL
C++ 标准模板库(Standard Template Library,STL)是一套功能强大的 C++ 模板类和函数的集合,基于泛型编程,因此适合各种数据结构,它提供了一系列通用的、可复用的算法和数据结构。将STL结合到程序中可以更方便地编写出更简洁且优质的代码。
| 组件 | 描述 |
|---|---|
| 容器 (Containers) | 容器是 STL 中最基本的组件之一,提供了各种数据结构,包括向量(vector)、链表(list)、队列(queue)、栈(stack)、集合(set)、映射(map)等。这些容器具有不同的特性和用途,可以根据实际需求选择合适的容器。 |
| 算法 (Algorithms) | STL 提供了大量的算法,用于对容器中的元素进行各种操作,包括排序、搜索、复制、移动、变换等。这些算法在使用时不需要关心容器的具体类型,只需要指定要操作的范围即可。 |
| 迭代器 (Iterators) | 迭代器用于遍历容器中的元素,允许以统一的方式访问容器中的元素,而不用关心容器的内部实现细节。STL 提供了多种类型的迭代器,包括随机访问迭代器、双向迭代器、前向迭代器和输入输出迭代器等。 |
| 函数对象 (Function Objects) | 函数对象是可以像函数一样调用的对象,可以用于算法中的各种操作。STL 提供了多种函数对象,包括一元函数对象、二元函数对象、谓词等,可以满足不同的需求。 |
| 适配器 (Adapters) | 适配器用于将一种容器或迭代器适配成另一种容器或迭代器,以满足特定的需求。STL 提供了多种适配器,包括栈适配器(stack adapter)、队列适配器(queue adapter)和优先队列适配器(priority queue adapter)等。 |
本章内容
| 章节 | 说明 |
|---|---|
| Utility | pair、tuple、optional、variant、any 等工具类型 |
| Containers | 序列容器、关联容器、无序容器与容器适配器 |
| Iterators | 迭代器分类、适配器、失效规则与 C++20 概念 |
| Ranges | C++20 视图、管道语法与惰性求值 |
| Algorithms | 非修改序列操作、修改序列操作、排序、数值与未初始化内存操作 |
Utility
在 C++ 中,有一些实用的工具类和函数,这些工具类和函数在编写高效、可读性强的代码时非常有用且大多是C++新特性
| 工具名称 | 说明 | 所属头文件 | C++版本引入 |
|---|---|---|---|
std::pair | 存储两个相关值的通用模板类。 | <utility> | C++98 |
std::make_pair | 辅助函数,用于创建 std::pair。 | <utility> | C++98 |
std::move | 将对象显式转换为右值引用,用于触发移动语义。 | <utility> | C++11 |
std::forward | 完美转发函数,用于保持值类别(左值/右值)。 | <utility> | C++11 |
std::swap | 通用交换函数,支持用户自定义类型的交换。 | <utility> | C++98 |
std::tuple | 可变长异质容器,存储多个不同类型的值。 | <tuple> | C++11 |
std::make_tuple | 辅助函数,用于创建 std::tuple。 | <tuple> | C++11 |
std::tie | 将多个左值绑定为 tuple,用于结构化绑定或比较。 | <tuple> | C++11 |
std::ignore | 与 std::tie 搭配,用于忽略某个绑定的值。 | <tuple> | C++11 |
std::optional | 表示一个可选值,可能含值也可能为空。 | <optional> | C++17 |
std::variant | 类型安全的联合体,可以在多个候选类型中存储其中之一。 | <variant> | C++17 |
std::any | 类型擦除的容器,可以存放任意类型的值,并在运行时安全提取。 | <any> | C++17 |
std::function | 通用函数包装器,可存储可调用对象(函数指针、lambda、仿函数等)。 | <functional> | C++11 |
std::bind | 绑定函数参数,生成新的可调用对象。 | <functional> | C++11 |
std::ref | 将对象包装为引用,以便在需要值语义时保持引用语义。 | <functional> | C++11 |
std::cref | 常量引用版本的 std::ref。 | <functional> | C++11 |
std::pair
std::pair 是一个结构体模板,其可于一个单元内存储两个相异对象。是 std::tuple 的拥有两个元素的特殊情况。一般来说,pair 可以封装任意类型的对象,可以生成各种不同的 std::pair<T1, T2> 对象,可以是数组对象或者包含 std::pair<T1,T2> 的 vector 容器。pair 还可以封装两个序列容器或两个序列容器的指针。
1. 引入
被包括在<utility>头文件中。
#include <utility>
2. 存储方式
std::pair 存储两个对象,分别通过 first 和 second 成员访问。std::pair 自动将初始化参数的值赋给 first 和 second。
3. 方法
(1)构造方法
-
默认构造函数:创建一个未初始化的
std::pair对象。std::pair<int, std::string> myPair; -
值构造函数:通过传入两个对象初始化
std::pair。std::pair<int, std::string> myPair(1, "example"); -
工厂函数
make_pair:生成std::pair对象。auto anotherPair = std::make_pair(2, "test");
(2)成员访问
- first:访问或修改
std::pair的第一个元素。 - second:访问或修改
std::pair的第二个元素。
(3)比较操作符
- 相等 (
==) 和不相等 (!=):比较两个std::pair对象是否相等。 - 小于 (
<) 和大于 (>)和小于等于 (<=) 和大于等于 (>=):根据first和second的字典顺序比较两个std::pair对象。
(4)交换
-
swap:交换两个
std::pair对象的值。myPair.swap(anotherPair);
std::move
将一个对象的所有权从一个地方(特指引用或是指针)转移到另一个地方,相当于rust语言的所有权转让,对于std::string或是更复杂的数据结构使用std::move转移所有权可以比直接复制有着更高的效率。
其模板定义如下:
template< class T >
typename std::remove_reference<T>::type&& move( T&& t ) noexcept; // (since C++11 and until C++14)
template< class T >
constexpr std::remove_reference_t<T>&& move( T&& t ) noexcept; // (since C++14)
1. 引入
被包括在<utility>头文件中。
#include <utility>
2. std::move的工作原理
std::move并不真正“移动”对象的数据,它的作用是将一个左值引用转化为右值引用,从而启用移动语义。在C++中,右值引用和左值引用的主要区别在于右值引用允许资源的所有权转移,而左值引用则保持资源的所有权。
通过使用std::move,编译器能够区分何时使用拷贝构造(复制数据)和移动构造(转移所有权),从而优化程序性能,特别是在涉及大量数据拷贝的场景中。
(1)右值引用与左值引用
- 左值:表示可以被引用且具有持久地址的对象(例如,变量)。
- 右值:表示临时对象或可以被销毁的对象(例如,常量、字面值、函数返回值)。
std::move的作用是将左值转换为右值引用,允许资源的移动。
(2)为什么需要std::move?
C++11引入了移动语义(Move Semantics),目标是提高性能。复制一个大对象时,可能涉及到大量的内存分配和数据拷贝,而如果能将资源的所有权直接转移给另一个对象,就可以避免这些开销。std::move通过将左值转换为右值引用,促使移动构造函数或移动赋值运算符被调用。
例如:
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1); // 这里v2会接管v1的所有权,v1会处于一个"空"状态
在这个例子中,std::move将v1转换为右值引用,从而触发了移动构造函数。结果是,v2接管了v1的数据,而v1进入了一个有效但不确定的状态。
3. 使用std::move的场景
(1)移动构造与移动赋值
在使用std::move时,通常会看到它与移动构造函数和移动赋值运算符一起使用。
- 移动构造函数:当一个对象通过右值引用初始化另一个对象时,调用移动构造函数,通常用于资源的转移。
- 移动赋值运算符:当一个对象被右值引用赋值给另一个已存在的对象时,调用移动赋值运算符。
例如:
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1); // 使用移动构造函数
对于赋值操作:
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2;
v2 = std::move(v1); // 使用移动赋值运算符
不管是哪一种操作,v1都会被剥夺所有权。
(2)自定义类型与std::move
如果你有自定义类型,并希望通过移动语义来优化性能,那么你需要为这个类型定义移动构造函数和移动赋值运算符。这些函数通常需要显式地将资源的所有权从一个对象转移到另一个对象。
例如:
class MyClass {
public:
MyClass(MyClass&& other) noexcept {
// 移动构造函数
this->data = std::move(other.data); // 移动数据
}
MyClass& operator=(MyClass&& other) noexcept {
// 移动赋值运算符
if (this != &other) {
this->data = std::move(other.data); // 移动数据
}
return *this;
}
private:
std::vector<int> data;
};
4. std::move的常见误用
(1)移动之后使用原对象
在将对象通过std::move转移所有权之后,原对象的状态是未定义的。因此,不应再使用原对象,例如:
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1);
// v1现在处于未定义状态,不能再安全使用
std::cout << v1.size(); // 错误
正确的做法是,只在转移所有权后“放弃”原对象的使用。
(2)不必要的使用std::move
std::move的作用是告诉编译器“这不是一个左值,你可以安全地移动它”,但是如果你已经明确知道对象不会被复制(例如返回一个对象的右值引用),就不必使用std::move。
例如:
std::vector<int> createVector() {
std::vector<int> v = {1, 2, 3};
return v; // 编译器会自动进行返回值优化(RVO),不需要手动使用std::move
}
这里的返回值优化(RVO)允许编译器在返回v时进行移动,而不需要显式调用std::move。
std::optional
std::optional 是 C++17 引入的一个工具类,用来表示一个 可选值。它可以包含某个类型的值,也可以为空(即没有值)。这在函数返回值和状态表达上尤其有用,可以避免使用特殊标志值(如 -1 或 nullptr)来表示“无效”或“不存在”的情况。它本质上是对 “可空值” 概念的抽象:
- 有值 (engaged):存储一个
T类型对象,保证其生命周期绑定到optional对象上。 - 无值 (disengaged):表示空状态,此时不存储任何对象。
相比于传统的返回 nullptr 或 std::pair<T,bool> 表示失败,std::optional 更加语义化、类型安全,尤其适合构造成本较高的对象。
其模板定义如下:
template< class T >
class optional;
1. 引入
被包括在 <optional> 头文件中。
#include <optional>
T 必须满足 Destructible(可析构)。
不允许是引用类型、数组类型、函数类型或 void。
从 C++26 起,optional<T> 还支持作为 range view。
2. std::optional 的工作原理
optional<T>内部通过一个 布尔标志 + 可能存在的值存储区 来实现:- 当 engaged 时,
optional内部构造并管理一个T对象。 - 当 disengaged 时,标志为 false,存储区未构造对象。
- 当 engaged 时,
这使得 optional 更接近于 “可能为空的对象”,而不是指针。
即使提供了 operator* 和 operator->,它依旧是值语义而非指针语义。
(1) 为什么需要 std::optional?
传统做法:
- C 风格函数通常用返回值来表示错误,用输出参数来返回数据。
- 某些场景需要用
nullptr或-1表示“无效”,但这并不类型安全。
std::optional 提供了一个清晰、安全的方式:返回“要么有值,要么没有值”。
(2) 基本使用
std::optional<int> maybeInt; // 默认构造:无值
std::optional<int> hasInt = 42; // 构造:有值
std::optional<int> emptyInt = std::nullopt; // 显式无值
if (hasInt) {
std::cout << "Value: " << *hasInt << "\n"; // 解引用访问
}
3. 使用 std::optional 的场景
(1) 函数返回值
最常见的场景是函数可能“有值”或“无值”。
std::optional<int> findValue(const std::vector<int>& vec, int target) {
for (auto v : vec) {
if (v == target) return v;
}
return std::nullopt;
}
auto result = findValue({1, 2, 3}, 2);
if (result) {
std::cout << "Found: " << *result << "\n";
}
(2) 延迟初始化
std::optional 可以用来延迟对象构造,仅在需要时才赋值。
std::optional<std::string> cache;
if (!cache) {
cache = "Hello"; // 仅在需要时才构造
}
(3) 配合结构化绑定
std::optional<std::pair<int, int>> divide(int a, int b) {
if (b == 0) return std::nullopt;
return std::make_pair(a / b, a % b);
}
if (auto res = divide(10, 3)) {
auto [q, r] = *res;
std::cout << "Quotient: " << q << " Remainder: " << r << "\n";
}
4. 常用操作
(0) 构造与析构
// 默认构造:无值
std::optional<int> a;
// 从值构造
std::optional<int> b = 42;
std::optional<std::string> c{"hello"};
// 拷贝构造 / 赋值
std::optional<int> d = b;
// 移动构造 / 赋值
std::optional<std::string> e = std::move(c);
// 使用 nullopt 构造空对象
std::optional<int> f = std::nullopt;
// 析构:生命周期结束时自动析构其中的值(如果有值)
{
std::optional<std::string> g{"RAII"};
} // g 的析构会自动调用 std::string 的析构函数
(1) value() 与 value_or()
std::optional<int> x = 10;
std::cout << x.value() << "\n"; // 获取值,若为空抛出 std::bad_optional_access
std::cout << x.value_or(0) << "\n"; // 若为空返回默认值
(2) emplace() 与 reset()
std::optional<std::string> name;
name.emplace("Alice"); // 原地构造值
std::cout << *name << "\n";
name.reset(); // 清空,变为无值
(3) 布尔上下文与 has_value()
std::optional<int> maybe;
if (maybe) { // 等价于 maybe.has_value()
std::cout << "有值\n";
} else {
std::cout << "无值\n";
}
(4) 访问操作符 operator* 与 operator->
std::optional<std::string> msg = "Hello";
std::cout << (*msg).size() << "\n"; // 解引用访问
std::cout << msg->size() << "\n"; // 箭头访问
(5) swap()
std::optional<int> a = 1, b = 2;
a.swap(b); // 交换内容
std::cout << *a << " " << *b << "\n"; // 输出 2 1
(6) 工厂函数 std::make_optional 与 std::nullopt
auto a = std::make_optional<int>(42); // 创建有值 optional
std::optional<int> b = std::nullopt; // 创建空 optional
(7) 常见 value_or 模式
std::optional<std::string> maybe_name;
std::string name = maybe_name.value_or("Default"); // 若无值,使用默认值
(8) C++23 单子操作(Monadic operations)
std::optional<int> num = 5;
// and_then: 仅在有值时继续计算
auto squared = num.and_then([](int x) { return std::optional<int>(x * x); });
// transform: 值存在时映射为新值
auto str = num.transform([](int x) { return std::to_string(x); });
// or_else: 若无值则提供替代
auto val = num.or_else([] { return std::optional<int>(100); });
5. std::optional 的常见误用
(1) 滥用在“必然有值”的场景
如果某个值在逻辑上 必然存在,使用 optional 反而多此一举,降低可读性。
(2) 忘记检查是否有值
错误示例:
std::optional<int> x;
std::cout << *x; // 未检查直接解引用,未定义行为
正确示例:
if (x) std::cout << *x;
(3) 不当替代指针或容器
std::optional 适合表示“零或一”的情况,但如果可能有多个值,应该用 std::vector 或其他容器,而不是 optional<std::vector<T>> 滥用。
6. 与 Rust 的类比
- Rust 的
Option<T>与 C++ 的std::optional<T>类似,都是“要么有值(Some),要么无值(None)”。 - 不同点:Rust 强制模式匹配,保证空值情况不会被遗漏,而 C++ 中
optional需要程序员手动检查。
std::any
std::any 是 C++17 引入的一个工具类,用来表示 类型安全的任意类型容器。它可以存储任何 可拷贝构造 的类型,并且在运行时动态管理其类型和生命周期。std::any 常用于需要存储不同类型对象,但在编译时无法确定类型的场景。
- 有值 (engaged):存储一个特定类型的对象。
- 无值 (disengaged):表示空状态,没有存储任何对象。
相比于 void* 或不安全的类型转换,std::any 提供了类型安全的访问和异常检测机制。
其模板定义如下:
class any;
1. 引入
被包括在 <any> 头文件中。
#include <any>
存储的类型必须满足 CopyConstructible(可拷贝构造)。
2. std::any 的工作原理
- 内部通过 指针 + 类型信息(typeid) 或小对象优化实现:
- 当对象有值时,存储实际类型对象。
- 当对象为空时,存储状态标记为空。
- 通过
any_cast提供类型安全访问,如果类型不匹配会抛出std::bad_any_cast异常。
(1) 为什么需要 std::any?
- 当需要存储 不同类型的对象,但类型在编译时未知时。
- 用于实现 动态类型容器、事件系统或通用缓存。
- 避免使用不安全的
void*或union。
(2) 基本使用
std::any a = 1; // 存储 int
a = 3.14; // 改为存储 double
a = std::string("hello"); // 改为存储 std::string
if (a.has_value()) {
std::cout << std::any_cast<std::string>(a) << "\n";
}
3. 使用 std::any 的场景
(1) 动态类型存储
std::vector<std::any> values;
values.push_back(10);
values.push_back(3.14);
values.push_back(std::string("text"));
for (auto& v : values) {
if (v.type() == typeid(int))
std::cout << std::any_cast<int>(v) << "\n";
}
(2) 类型安全访问
通过 any_cast 访问内部对象,类型不匹配会抛异常:
std::any a = 42;
try {
double d = std::any_cast<double>(a); // 类型错误,会抛出 std::bad_any_cast
} catch (const std::bad_any_cast& e) {
std::cout << e.what() << "\n";
}
(3) 指针访问
any_cast 也可返回指针,类型错误时返回 nullptr:
std::any a = 3;
if (int* p = std::any_cast<int>(&a)) {
std::cout << *p << "\n"; // 输出 3
}
4. 常用操作
(0) 构造与析构
std::any a; // 默认构造:无值
std::any b = 42; // 从值构造
std::any c = std::string("hi"); // 从其他类型构造
// 拷贝构造 / 拷贝赋值
std::any d = b;
d = c;
// 移动构造 / 移动赋值
std::any e = std::move(c);
e = std::move(b);
// 析构:生命周期结束时自动析构存储的对象
{
std::any f = std::string("RAII");
} // f 的析构自动调用 std::string 析构函数
(1) has_value() 与 type()
std::any a = 42;
if (a.has_value()) {
std::cout << "类型: " << a.type().name() << "\n";
}
(2) emplace() 与 reset()
std::any a;
a.emplace<std::string>("Hello"); // 原地构造新值
std::cout << std::any_cast<std::string>(a) << "\n";
a.reset(); // 清空
(3) 赋值操作 operator=
std::any a, b;
a = 10; // 赋值 int
b = std::string("hi"); // 赋值 string
a = b; // 拷贝 b 的值到 a
(4) swap()
std::any a = 1, b = 2;
a.swap(b);
std::cout << std::any_cast<int>(a) << " " << std::any_cast<int>(b) << "\n"; // 输出 2 1
(5) 工厂函数 std::make_any
auto a = std::make_any<int>(42); // 创建 any 并存储 int
auto b = std::make_any<std::string>("hi"); // 创建 any 并存储 string
(6) 非成员函数 any_cast
std::any a = 123;
int* p = std::any_cast<int>(&a); // 指针访问
int v = std::any_cast<int>(a); // 值访问,类型错误抛异常
5. std::any 的常见误用
(1) 滥用
- 不应将
std::any用作所有类型的通用容器,它不能替代容器或指针管理。 - 仅在 存储单个动态类型对象 且类型不确定时使用。
(2) 忘记类型检查
std::any a = 42;
std::cout << std::any_cast<double>(a); // 未检查类型,抛异常
std::variant
std::variant 是 C++17 引入的 类型安全联合(type-safe union)。在任一时刻,variant<...> 要么保存其候选类型列表中的某一类型的对象(active alternative),要么在异常情况等导致的特殊情形下处于无值状态(valueless_by_exception())。
头文件:
#include <variant>
1. 模板定义
template< class... Types >
class variant;
- 模板参数为
Types...:每个T必须满足 Destructible(能被析构)。 - 不允许持有引用类型、数组类型或
void。 - 可以重复出现相同类型(例如
variant<int,int>合法),也可以出现不同 cv 限定的同一基础类型(如int与const int)。 - 注意:如果你用同一个具体类型多次,基于类型的访问(
std::get<T>/get_if<T>)会变得歧义 / 编译失败(只能用索引或明确in_place_type/in_place_index)。 - 默认构造:默认构造会构造第一个候选类型的默认值,如果第一个候选类型不可默认构造,则
variant本身也不可默认构造。可以把std::monostate放在首位以保证可默认构造。
2. 存储与对象布局
variant内部存储了 discriminator(索引)与一个能容纳最大候选类型的缓冲区;当variant持有某个类型T时,一个T对象会嵌套(placement-new)在该缓冲区内。- 因此
variant的大小≈(max sizeof(alternatives))+ 对齐 + discriminator 大小。 - 在异常情况下(构造/赋值期间)有可能变为 valueless_by_exception(见下文)。
3. 主要成员函数 / 重载
下面列出常用操作、签名(伪签名风格)与行为说明与例子。
构造与析构
-
variant()- 默认构造:构造第一个候选类型的默认值(若可行)。
- 否则
variant不可默认构造。
-
variant(const variant&)/variant(variant&&)- 拷贝 / 移动构造。条件:候选类型支持相应操作;若某些候选类型不可拷贝/移动,相应操作会被删除。
- 如果在移动过程中抛出异常,可能导致
valueless_by_exception(取决于具体实现与异常传播)。
-
converting constructors(从某个值构造)
- 如果传入
U可明确/唯一地构造某个候选类型,variant会构造该候选。若存在二义性(能构造多个候选),编译失败。
- 如果传入
-
in-place 构造(直接在 variant 内就地构造)
variant(in_place_type<T>, Args&&...); variant(in_place_index<I>, Args&&...);in_place_type_t/in_place_index_t用于在 variant 内直接构造目标 alternative,避免先创建临时再赋值。
-
~variant()- 默认析构:会调用当前活动 alternative 的析构函数(如果有值)。
赋值(operator=)
-
variant& operator=(const variant&); -
variant& operator=(variant&&);- 这两个做拷贝/移动赋值。赋值行为在不同情况下(同类型 index / 不同 index)会调用相应 alternative 的赋值/析构+构造。
- 赋值过程中若抛异常,可能使
variant进入valueless_by_exception。
-
template<class T> variant& operator=(T&&);- converting assignment:当
T可以唯一构造某个候选类型时,执行相应赋值/替换。
- converting assignment:当
-
variant& operator=(std::monostate)等(视候选类型而定)。
观察器(Observers)
-
std::size_t index() const noexcept;- 返回当前活动的候选类型的零基索引(0..N-1)。
- 如果处于
valueless_by_exception,返回variant_npos(常为std::size_t(-1))。
-
bool valueless_by_exception() const noexcept;- 如果
variant处于无值状态(例如在变更 active alternative 时异常导致)返回true。
- 如果
修改(Modifiers)
-
template<class T, class... Args> T& emplace(Args&&...);emplace<T>(args...):在variant中就地构造类型T(T 必须是某个 alternative);会销毁旧的 active 值(若有),然后就地构造新值。- 异常安全:如果构造抛出,
variant可能进入valueless_by_exception(取决于实现与被替换对象的销毁时机)。
-
template<size_t I, class... Args> variant& emplace(in_place_index_t<I>, Args&&...);- 使用索引 I 就地构造。
-
void swap(variant& other) noexcept( /* depends */ );- 交换两个 variant 的状态与内容。noexcept 与具体候选类型的 swap/移动操作相关。
访问(get / get_if)
-
std::get<T>(variant&)/std::get<I>(variant&)get<T>(按类型访问)要求T在候选类型中唯一,否则编译错误。get<I>(按索引访问)直接访问索引为I的候选类型。- 若
variant当前不保存请求的 alternative,std::get会 抛出std::bad_variant_access(运行时异常)。
-
std::get_if<T>(&variant)/std::get_if<I>(&variant)get_if返回指向当前值的指针(非nullptr表示匹配),失败时返回nullptr。不会抛异常,通常是更安全的访问方式。- 有
const/ 非const重载:const T* get_if<const T>(&const variant)等。
示例:
std::variant<int,std::string> v = "hi";
if (auto p = std::get_if<std::string>(&v)) {
std::cout << *p << "\n";
}
try {
std::cout << std::get<int>(v); // 抛出 std::bad_variant_access
} catch (const std::bad_variant_access& e) { ... }
访问辅助:std::holds_alternative<T>(v)
- 返回
true当且仅当v当前持有类型T(同get_if<T>非空)。T必须唯一出现在 alternatives 中。
访问索引常量
-
constexpr std::size_t variant_npos = /* often size_t(-1) */;- 表示无效索引(用于
index()返回值在valueless_by_exception()时)。
- 表示无效索引(用于
4. 访问与遍历:std::visit(Visitor 模式)
非成员 std::visit(自 C++17 起)
签名(概念):
template <class Visitor, class... Variants>
decltype(auto) visit(Visitor&& vis, Variants&&... vars);
std::visit会将 visitor(可调用对象)以当前 variant(或多个 variants)的活动值作为参数调用。- 当传入多个
variant时,visitor 会被调用,参数顺序与variant顺序对应。 - 如果任一
variant为valueless_by_exception(),std::visit通常会抛出std::bad_variant_access。 - 返回值类型由 visitor 决定(可以返回
void或其他类型)。 - 常用技巧:用
overloaded(多个 lambda 继承合并)来实现多分支处理:
// helper
template<class... Fs> struct overloaded : Fs... { using Fs::operator()...; };
template<class... Fs> overloaded(Fs...) -> overloaded<Fs...>;
// 使用
std::variant<int,std::string> v = 42;
std::visit(overloaded {
[](int i){ std::cout<<"int "<<i<<"\n"; },
[](const std::string& s){ std::cout<<"str "<<s<<"\n"; }
}, v);
成员 visit(C++26 提案:member visit)
- C++26 引入(或将引入)
v.visit(visitor)的成员形式作为便捷写法(请注意你使用的编译器/标准支持情况)。非成员std::visit在 C++17 就有。
5. 比较运算与哈希
-
operator==等(C++17 起)与operator<=>(C++20)有定义:通常两个variant先比较是否都valueless_by_exception(),再比较index(),在 index 相同时比较包含的值(按对应类型的比较运算)。==:若两者index()相同且 contained values 相等 => true;若两个都 valueless => true;否则 false。</>:若index()不同,通常以index()的大小决定排序;若相同,则调用 contained type 的<。- 详细边界(valueless 等)以标准详细定义为准,但通常结果符合“按 index 首先排序,然后按值比较”的直觉。
-
std::hash<std::variant<...>>在标准库有特化(要求所有候选类型可哈希)。
6. 辅助类型与特性(type traits / helper classes)
-
std::monostate(C++17)- 一个空占位类型,常用于将
variant设置为默认可构造:std::variant<std::monostate, T1, T2>。
- 一个空占位类型,常用于将
-
std::bad_variant_access(C++17)- 当用
std::get<T>/std::get<I>访问但variant未持有该 alternative 时抛出。
- 当用
-
std::variant_size<Variant>/std::variant_size_v<Variant>(C++17)- 编译期获取候选类型数量(常量表达式)。
- 例:
std::variant_size_v<std::variant<int,double>> == 2。
-
std::variant_alternative<I, Variant>::type/std::variant_alternative_t<I, Variant>(C++17)- 编译期获取索引
I对应的类型(类型别名)。 - 例:
std::variant_alternative_t<0,std::variant<int,double>>等于int。
- 编译期获取索引
-
variant_npos:表示无值索引(如index()在 valueless 时返回此值)。
7. 异常安全与 valueless_by_exception
-
在某些变更 active alternative 的操作中(例如赋值、就地构造时),如果构造/移动/复制新的 alternative 的构造函数抛出异常,而旧对象已被销毁,
variant可能无法恢复到原先状态,从而进入valueless_by_exception()。 -
一旦处于
valueless_by_exception():index()返回variant_npos;std::get抛出std::bad_variant_access;std::get_if返回nullptr;- 一些操作(比如
std::visit)会抛出bad_variant_access(取决于实现)。
-
预防策略:当替换可能抛异常的类型时,优先使用
emplace并在必要时进行异常处理;确保候选类型的构造/移动操作尽可能noexcept,可以降低进入无值状态的风险。
8. 常用例子
基本使用与 get/get_if/holds_alternative
std::variant<int,std::string> v = "hello";
if (std::holds_alternative<std::string>(v)) {
std::cout << std::get<std::string>(v) << "\n";
}
if (auto p = std::get_if<int>(&v)) {
std::cout << "int: " << *p << "\n";
} else {
std::cout << "not int\n";
}
emplace / in_place
std::variant<std::monostate, std::string, std::vector<int>> v;
v.emplace<std::string>("abc"); // 就地构造 std::string
v.emplace<std::vector<int>>(3, 42); // 就地构造 vector(3,42)
v.emplace<in_place_index_t<1>>("xyz"); // 使用索引就地构造(index=1 => std::string)
visit 与 overloaded 工具
auto handle = overloaded {
[](int i){ std::cout<<"int "<<i<<"\n"; },
[](const std::string& s){ std::cout<<"str "<<s<<"\n"; }
};
std::variant<int,std::string> v = 10;
std::visit(handle, v);
使用 monostate 使可默认构造
std::variant<std::monostate, std::string> v; // 默认构造后 v 持有 monostate
9. 实用建议 / 常见误用
- 不要把
variant作为替代所有情况:类型过多会导致代码复杂和 visitor 分支膨胀。若候选类型集合非常大或松散,考虑设计别的抽象(多态/策略等)。 - 当候选类型有重复的具体类型时,避免
get<T>:因为会编译错误;使用get<index>或in_place_type显式选择。 - 注意异常安全:替换 active alternative(赋值、emplace)如果构造抛异常,可能进入
valueless_by_exception;为关键路径确保候选类型的移动/复制构造尽可能noexcept。 - 避免把对
variant的访问当作频繁反射:大量类型判断/切换会影响可读性和性能(虽然variant本质上是常数时间的判定与访问,但分支与 visitor 的实现复杂度需考虑)。 - std::variant 类似于 Rust 的 enum,都能表示“一种类型中的多种可能”。 不同点在于Rust 的 enum 语法更简洁,且模式匹配是强制的;C++ 的 std::variant 需要 std::visit 或 get 来显式处理。
10. 标准/特性备注
std::variant自 C++17 引入(特性宏:__cpp_lib_variant等)。- 标准后续对
variant做过修订(例如std::visit扩展、constexpr 能力增强等)。例如有成员形式visit(C++26 提议/扩展),以及使variant更多操作支持constexpr(不同标准版本的支持程度由编译器/标准库实现决定)。 - 使用时注意你的编译器和标准库版本对
variant的各项特性的支持情况(尤其是constexpr、成员visit等较新特性)。
11. 快速 API 参考
- 头文件:
<variant> - 构造:
variant(),variant(in_place_type_t<T>, ...),variant(in_place_index_t<I>, ...), converting constructors - 赋值:
operator=(variant),operator=(T&&)(converting) - 访问:
std::get<T>(v),std::get<I>(v),std::get_if<T>(&v),std::get_if<I>(&v) - 情况检测:
v.index(),v.valueless_by_exception(),std::holds_alternative<T>(v) - 就地构造:
v.emplace<T>(args...),v.emplace<in_place_index_t<I>>(args...) - 访问模式:
std::visit(visitor, v1, v2, ...),C++26 可能支持成员v.visit(visitor) - 辅助类型:
std::monostate,std::bad_variant_access,std::variant_size,std::variant_alternative_t,std::hash<std::variant<...>>
Containers
摘要
C++ 标准模板库(STL)容器是用于存储数据的对象集合,它们提供了不同的存储方式、内存管理机制和访问模式。理解不同容器的底层结构、时间复杂度和内存特性,是高效进行C++编程的关键。
STL 容器主要分为三大类:序列容器、关联容器和无序容器。此外,容器适配器提供受限接口以模拟特定的数据结构(如栈和队列)。C++23 新增的扁平容器则代表了对内存局部性和性能优化的新探索。
容器总览表
| 容器类别 | 容器名称 | 描述 | 存储特性 | 核心操作复杂度 |
|---|---|---|---|---|
| 序列容器 (Sequence) | std::array (C++11) | 固定大小的静态数组。 | 栈上,连续内存 | 随机访问 \(O(1)\) |
| std::vector | 动态数组。 | 堆上,连续内存 | 随机访问 \(O(1)\),末尾增删平均 \(O(1)\) | |
| std::deque | 双端队列。 | 分段连续内存 | 随机访问 \(O(1)\),头尾增删 \(O(1)\) | |
| std::list | 双向链表。 | 堆上,非连续内存 | 任意位置增删 \(O(1)\) | |
| std::forward_list (C++11) | 单向链表。 | 堆上,非连续内存 | 头部增删 \(O(1)\) | |
| 关联容器 (Associative) | std::set/multiset | 存储键,基于红黑树。 | 红黑树结构,有序 | 查找/增删 \(O(\log n)\) |
| std::map/multimap | 存储键值对,基于红黑树。 | 红黑树结构,有序 | 查找/增删 \(O(\log n)\) | |
| 无序容器 (Unordered) | std::unordered_set/multiset | 存储键,基于哈希表。 | 哈希表结构,无序 | 查找/增删 平均 \(O(1)\) |
| std::unordered_map/multimap | 存储键值对,基于哈希表。 | 哈希表结构,无序 | 查找/增删 平均 \(O(1)\) | |
| 容器适配器 (Adaptors) | std::stack | LIFO(后进先出)。 | 默认底层 \(\text{std::deque}\) | \(O(1)\) |
| std::queue | FIFO(先进先出)。 | 默认底层 \(\text{std::deque}\) | \(O(1)\) | |
| std::priority_queue | 优先级队列(最大堆)。 | 默认底层 \(\text{std::vector}\) | 插入/删除 \(O(\log n)\) |
1. 序列容器 (Sequence Containers)
序列容器以线性方式排列元素,元素的位置由程序员控制,通常用于构建列表、数组等基础数据结构。
| 特性 | std::array | std::vector | std::deque | std::forward_list | std::list |
|---|---|---|---|---|---|
| 内存结构 | 连续内存 (栈/全局) | 连续内存 (堆) | 分段连续内存 | 非连续 (单向链表) | 非连续 (双向链表) |
| 随机访问 | \(O(1)\) (最快) | \(O(1)\) (快) | \(O(1)\) (快) | 不支持 | 不支持 |
| 头部增删 | 不支持 | \(O(n)\) | \(O(1)\) (快) | \(O(1)\) (最快) | \(O(1)\) (快) |
| 尾部增删 | 不支持 | 平均 \(O(1)\) (最快) | \(O(1)\) (快) | \(O(n)\) | \(O(1)\) (快) |
| 迭代器稳定性 | 稳定 | 插入可能失效,删除指向被删元素的失效。 | 插入/删除头尾稳定,中间失效。 | 增删不会使其他迭代器失效。 | 增删不会使其他迭代器失效。 |
| 优势场景 | 编译期确定大小,极高性能。 | 默认首选,需随机访问,主要在末尾操作。 | 需头尾快速操作和随机访问的场景。 | 极度频繁的插入/删除,内存占用要求低。 | 频繁在任意位置插入/删除,需双向遍历。 |
2. 有序关联容器 (Ordered Associative Containers)
关联容器基于键 (Key) 进行有序存储,通常使用红黑树实现。它们自动保持元素/键的排序,适用于需要排序和快速查找的场景。
| 特性 | std::set | std::multiset | std::map | std::multimap |
|---|---|---|---|---|
| 底层结构 | 红黑树 | 红黑树 | 红黑树 | 红黑树 |
| 操作复杂度 | 查找、插入、删除均为 \(O(\log n)\) | 查找、插入、删除均为 \(O(\log n)\) | 查找、插入、删除均为 \(O(\log n)\) | 查找、插入、删除均为 \(O(\log n)\) |
| 存储内容 | 仅存储唯一键 | 存储可重复键 | 存储唯一键值对 | 存储可重复键值对 |
| 元素顺序 | 始终保持排序 (按键) | 始终保持排序 (按键) | 始终保持排序 (按键) | 始终保持排序 (按键) |
| 迭代器稳定性 | 插入或删除不会使指向其他元素的迭代器失效。 | 插入或删除不会使指向其他元素的迭代器失效。 | 插入或删除不会使指向其他元素的迭代器失效。 | 插入或删除不会使指向其他元素的迭代器失效。 |
3. 无序容器 (Unordered Containers)
无序容器 (C++11) 基于 哈希表 实现。它们不保证元素顺序,但能提供极快的平均性能,适用于不关心元素顺序、追求极致查找速度的场景。
| 特性 | std::unordered_set | std::unordered_multiset | std::unordered_map | std::unordered_multimap |
|---|---|---|---|---|
| 底层结构 | 哈希表 (桶和链表/树) | 哈希表 | 哈希表 | 哈希表 |
| 操作复杂度 | 平均 \(O(1)\),最坏 \(O(n)\) | 平均 \(O(1)\),最坏 \(O(n)\) | 平均 \(O(1)\),最坏 \(O(n)\) | 平均 \(O(1)\),最坏 \(O(n)\) |
| 存储内容 | 仅存储唯一键 | 存储可重复键 | 存储唯一键值对 | 存储可重复键值对 |
| 元素顺序 | 无序 (取决于哈希值) | 无序 | 无序 | 无序 |
| 迭代器稳定性 | 不稳定。 \(\text{rehash}\) (重新散列) 时所有迭代器和引用都会失效。 | 不稳定。 \(\text{rehash}\) 时所有迭代器和引用都会失效。 | 不稳定。 \(\text{rehash}\) 时所有迭代器和引用都会失效。 | 不稳定。 \(\text{rehash}\) 时所有迭代器和引用都会失效。 |
4. C++23 扁平容器 (Flat Containers)
C++23 引入的扁平容器旨在优化内存局部性。它们使用有序的 \(\text{std::vector}\) 作为底层存储,将键或键值对连续存储,从而利用现代CPU的缓存机制。
| 容器名称 | 对应关联容器 | 底层结构 | 查找性能 | 插入/删除性能 | 优势/劣势 |
|---|---|---|---|---|---|
| std::flat_set / multiset | \(\text{std::set/multiset}\) | 有序 \(\text{std::vector}\) | \(O(\log n)\) (二分查找,比红黑树更快) | \(O(n)\) (需要移动元素) | 优势:极低的内存占用和极佳的遍历性能。劣势:高昂的插入/删除成本。 |
| std::flat_map / multimap | \(\text{std::map/multimap}\) | 一个或两个有序 \(\text{std::vector}\) | \(O(\log n)\) (二分查找,比红黑树更快) | \(O(n)\) (需要移动元素) | 适用于元素数量相对稳定、需要高查找速度和高效遍历的场景。 |
5. 容器适配器 (Container Adaptors)
容器适配器不是独立的容器,而是提供受限接口的类模板。它们使用底层容器来模拟特定的数据结构行为。
| 容器名称 | 接口模型 | 核心操作 | 默认底层容器 | 可选底层容器 |
|---|---|---|---|---|
| std::stack | LIFO (Last-In, First-Out) | \(\text{push()}\), \(\text{pop()}\), \(\text{top()}\) | \(\text{std::deque}\) | \(\text{std::vector}\), \(\text{std::list}\) |
| std::queue | FIFO (First-In, First-Out) | \(\text{push()}\), \(\text{pop()}\), \(\text{front()}\)/\(\text{back()}\) | \(\text{std::deque}\) | \(\text{std::list}\) |
| std::priority_queue | 优先级排序 (最大堆) | \(\text{push()}\), \(\text{pop()}\), \(\text{top()}\) | \(\text{std::vector}\) | \(\text{std::deque}\) |
6. 容器关键概念
迭代器 (Iterators)
迭代器是 STL 的核心,它提供了一种统一访问容器元素的方式,类似于指针。
- 随机访问迭代器:支持 \(O(1)\) 时间内的任意跳转(如 \(\text{std::vector}\), \(\text{std::deque}\), \(\text{std::array}\))。
- 双向迭代器:支持向前和向后移动(如 \(\text{std::list}\), \(\text{std::set}\), \(\text{std::map}\))。
- 前向迭代器:仅支持向前移动(如 \(\text{std::forward_list}\))。
内存分配
- 连续内存:如 \(\text{std::vector}\) 和 \(\text{std::array}\)。优点是内存局部性好,CPU缓存利用率高;缺点是插入/删除中间元素成本高(需要移动后续元素)。
- 非连续内存:如 \(\text{std::list}\) 和红黑树/哈希表容器。优点是插入/删除效率高;缺点是内存碎片化,CPU缓存效率低。
异常安全 (Exception Safety)
- 强保证:如果操作失败(抛出异常),容器保持不变。
- 基本保证:如果操作失败,容器处于可用状态,但可能不是原来的状态。
- \(\text{std::vector}\) 的 \(\text{push_back}\) 在需要重新分配内存时,如果复制构造函数抛出异常,可能导致强保证失效。
std::vector
std::vector 是一个封装了动态大小数组的顺序容器(Sequence Container)。与其它容器一样,它能够存放各种类型的对象,可以简单地认为它是一个能够存放任意类型的动态数组。
std::vector 支持快速随机访问,并且是 C++ 中最常用、最应该优先考虑的容器。在大多数场景下,如果你不确定该用哪个容器,std::vector 通常就是默认选择。
1. 引入
#include <vector>
2. 存储方式
为了支持随机访问,std::vector 将元素连续存储——每个元素紧挨着前一个元素存储。容器中元素是连续存储的,且容器的大小是可变的。
在容器中增加元素时,std::vector 会根据存储元素的大小,在内存上申请一块空间用于存储数据。空间的大小通常会大于所存储元素的实际大小,并且预留出一部分空间,以便再次增加数据时无需重新开辟空间。
当容器再次增加新的元素时,首先判断预留的空间是否够用:够用就直接在预留空间中存储;不够用则要在内存中开辟一整块新的更大的空间,把原来的数据拷贝(或移动)过去,再在新内存中加入新元素,这样才能保证存储空间始终连续。新开辟的空间同样会预留一部分,以便后续继续增加数据。当 std::vector 增长时,容量通常会按一定比例增长(常见的实现是 1.5 倍或 2 倍),这有助于减少频繁的重新分配,提升性能。
需要注意的是,扩容意味着元素被搬到了新的内存地址,扩容之前保存的迭代器、指针和引用都会失效,继续使用属于未定义行为。如果提前知道元素数量,可以用 reserve() 预留空间,避免中途扩容。
std::vector<int> vec = {1, 2, 3};
int* p = &vec[0];
vec.push_back(4); // 可能触发扩容,p 可能已经失效
// *p; // 危险:未定义行为
上面说的“元素连续存储、可以取地址“,对 std::vector<bool> 并不成立。标准库对 bool 做了特化,把它实现成了一个按位存储的位容器,每个元素只占 1 个二进制位,8 个元素合起来才占 1 个字节,因此 std::vector<bool> 在省内存的同时也失去了普通 vector 的很多性质。需要说明的是,标准只要求实现“尽可能省空间“,具体怎么优化由实现决定,按位存储是最常见的做法。这一点在后面几节会陆续提到。
3. 方法
(1) 构造方法
-
vector(): 创建一个空 vector,创建时也可以使用初始化列表进行初始化std::vector<int> vec; std::vector<int> vec = {1, 2, 3, 4, 5}; -
vector(size_type count): 创建一个包含count个元素的 vector,元素为值初始化(内置类型为 0)std::vector<int> vec(10); -
vector(size_type count, const T& value): 创建一个包含count个元素的 vector,且每个元素的值均为valuestd::vector<int> vec(10, 5); -
vector(const vector&): 复制构造函数std::vector<int> vec1 = {1, 2, 3, 4, 5}; std::vector<int> vec2(vec1); // vec2 是 vec1 的一个复制版本 -
vector(begin, end): 复制 [begin, end) 区间内另一个容器(或数组)的元素到 vector 中std::array<int, 5> arr = {1, 2, 3, 4, 5}; // 创建一个 std::array 或 std::vector std::vector<int> vec(arr.begin(), arr.begin() + 3); // 复制前3个元素
(2) 大小函数
-
size_type size() const: 返回 vector 中存放元素的实际数量(实际存储元素的个数) -
size_type capacity() const: 返回 vector 在内存中开辟空间的容量(最多能放多少个元素而不需要重新扩容) -
size_type max_size() const: 返回最大可允许的 vector 元素数量值 -
bool empty() const: 返回数组是否为空 -
void shrink_to_fit(): 请求释放未使用的容量,把 capacity 降到接近 size。这是一个非强制性请求,是否生效由实现决定;如果发生了重新分配,所有迭代器、指针和引用都会失效 -
void resize(size_type count): 修改 vector 的大小(size)。如果新大小比当前大小大,则用值初始化填充新增元素(可能触发扩容);如果新大小比当前大小小,则删除多余的元素,但容量不会随之减小 -
void reserve(size_type new_cap): 把 vector 的 capacity 提升到不小于new_cap,不改变 size。reserve()只能增大容量,不能用来缩小容量
这几个函数里,size()、capacity() 和 reserve() 是配合使用的重点。size() 是当前真正存了多少个元素,capacity() 是不扩容的前提下最多能放多少个,两者通常不相等,中间差的那部分就是预留空间。知道要装多少元素时,先 reserve() 一次性把容量要到够,可以避免边插入边扩容带来的反复拷贝。不过 reserve() 也不是越多越好,如果在每次 push_back() 之前都调用一次,容量就会线性增长而不是指数增长,反而会带来更多的重新分配。
对 std::vector<bool> 来说,这几个函数同样可用,但要注意 capacity() 的单位是位而不是字节,返回值表示的是能容纳多少个布尔值,而不是分配了多少内存。
(3) 增加函数
-
push_back(const T& value): 将元素value添加到 vector 的末尾。如果 vector 已满,push_back会自动扩展容量。std::vector<int> vec = {1, 2, 3}; vec.push_back(4); // 向 vec 中添加元素 4,vec 变为 {1, 2, 3, 4} -
reference emplace_back(Args&&... args): 在 vector 的末尾就地构造一个元素,使用提供的参数直接构造该元素,而不是首先创建元素再添加。这样可以减少不必要的拷贝或移动操作。C++17 起返回新插入元素的引用std::vector<std::pair<int, int>> vec; vec.emplace_back(1, 2); // 在末尾构造一个 pair<int, int>,值为 {1, 2} -
iterator insert(const_iterator pos, const T& value): 在指定位置pos插入一个元素。元素会被插入到pos之前,pos之后的元素会被向后移动,返回指向新插入元素的迭代器std::vector<int> vec = {1, 2, 4, 5}; vec.insert(vec.begin() + 2, 3); // 在位置2插入3,vec 变为 {1, 2, 3, 4, 5} -
iterator insert(const_iterator pos, size_type count, const T& value): 在指定位置pos插入count个元素,所有的元素值为value,返回指向第一个新插入元素的迭代器(count == 0时返回pos)std::vector<int> vec = {1, 2, 4, 5}; vec.insert(vec.begin() + 2, 2, 3); // 在位置2插入两个 3,vec 变为 {1, 2, 3, 3, 4, 5} -
iterator insert(const_iterator pos, InputIterator first, InputIterator last): 将[first, last)区间的元素插入到pos位置。first和last不能是*this自己的迭代器,否则行为未定义std::vector<int> vec1 = {1, 2, 3}; std::vector<int> vec2 = {4, 5}; vec1.insert(vec1.begin() + 2, vec2.begin(), vec2.end()); // 在位置 2 插入 vec2 中的元素,vec1 变为 {1, 2, 4, 5, 3} -
iterator emplace(const_iterator pos, Args&&... args): 在指定位置pos就地构造一个元素,使用提供的参数直接构造该元素,返回指向新插入元素的迭代器std::vector<std::pair<int, int>> vec; vec.emplace(vec.begin(), 1, 2); // 在位置0处构造一个 pair<int, int>,值为 {1, 2}
除了末尾的 push_back 和 emplace_back,其余插入操作都需要移动插入点之后的元素,代价是 O(n),所以能往末尾加就不要往中间插。
std::vector<bool> 的这些函数用法基本一样,但 operator[]、front()、back() 返回的都不是 bool&,而是一个代理对象(std::vector<bool>::reference),它内部记录了“哪一位“的信息,可以隐式转换成 bool,也可以赋值。用起来像引用,但它不是引用,所以 &vec[0] 是无法编译的:
std::vector<bool> vec = {true, false, true};
// bool* p = &vec[0]; // 错误:无法对 vector<bool> 的元素取地址
vec[0] = false; // 可以,通过代理对象修改对应的位
bool value = vec[0]; // 可以,隐式转换为 bool
这一点在写模板代码时尤其容易出问题。下面这段代码对 std::vector<int> 没问题,但对 std::vector<bool> 直接编译不过,因为 vec[i] 返回的是右值代理对象,没法绑定到 auto&:
template <typename T>
void doubleAll(std::vector<T>& vec) {
for (auto& x : vec) { // vector<bool> 时无法绑定到 auto&
x = x + x;
}
}
另外,auto a = vec[0]; 得到的也是代理对象而不是 bool。代理对象内部记录的是“哪一位“,如果它指向的 std::vector<bool> 被销毁或者重新分配了内存,再用它就是悬垂引用,属于未定义行为。想要真正的值,就显式写成 bool b = vec[0];。
(4) 删除函数
-
void pop_back(): 删除 vector 中的最后一个元素。该函数不会改变容器的容量,只是移除最后一个元素并缩小容器的大小。指向最后一个元素的迭代器和引用会失效,end()迭代器也会失效;容器为空时调用属于未定义行为std::vector<int> vec = {1, 2, 3, 4}; vec.pop_back(); // 删除最后一个元素,vec 变为 {1, 2, 3} -
iterator erase(const_iterator pos): 删除指定位置pos处的元素。删除后,后面的元素会向前移动,返回指向被删元素之后位置的迭代器(若删的是最后一个元素,则返回end())std::vector<int> vec = {1, 2, 3, 4}; vec.erase(vec.begin() + 2); // 删除索引为2的元素,vec 变为 {1, 2, 4} -
iterator erase(const_iterator first, const_iterator last): 删除[first, last)区间内的所有元素,返回指向最后一个被删元素之后位置的迭代器std::vector<int> vec = {1, 2, 3, 4, 5}; vec.erase(vec.begin() + 1, vec.begin() + 4); // 删除索引从 1 到 3 的元素,vec 变为 {1, 5} -
void clear(): 删除 vector 中的所有元素,容器变为空。所有指向元素的迭代器、指针和引用(包括end())都会失效,但容量保持不变std::vector<int> vec = {1, 2, 3, 4}; vec.clear(); // 删除所有元素,vec 变为空 { }
删除操作里需要特别留意的是 erase。它删除的是 [first, last) 区间,删除位置及之后的迭代器、指针和引用都会失效(C++11 之前甚至连 erase 位置本身的迭代器也要求失效),因此循环中删除元素时不能简单地 ++it,而要接收 erase 返回的新迭代器:
std::vector<int> vec = {1, 2, 3, 4, 5, 6};
// 错误写法:it 在 erase 后失效,++it 是未定义行为
// for (auto it = vec.begin(); it != vec.end(); ++it) {
// if (*it % 2 == 0) vec.erase(it);
// }
// 正确写法:用 erase 的返回值继续遍历
for (auto it = vec.begin(); it != vec.end(); ) {
if (*it % 2 == 0) {
it = vec.erase(it); // erase 返回下一个有效迭代器
} else {
++it;
}
}
// vec 变为 {1, 3, 5}
std::vector<bool> 的删除操作接口和普通 vector 一样,erase 同样返回后续迭代器,用法也相同。不过 std::vector<bool> 的迭代器是封装了位操作的类,并不是指针,所以不能把 erase 的返回值当作指针使用。
(5) 遍历函数
-
reference at(size_type pos): 返回pos位置元素的引用。与operator[]类似,但会检查边界,如果pos >= size()会抛出std::out_of_range异常std::vector<int> vec = {1, 2, 3, 4, 5}; int& element = vec.at(2); // 返回位置 2 处元素的引用,即值为 3 element = 10; // 修改元素为 10 std::cout << vec[2] << std::endl; // 输出: 10 -
reference front(): 返回 vector 的第一个元素的引用。如果 vector 为空,调用该方法会导致未定义行为。std::vector<int> vec = {1, 2, 3, 4, 5}; int& firstElement = vec.front(); // 返回第一个元素的引用,即值为 1 firstElement = 20; // 修改第一个元素为 20 std::cout << vec.front() << std::endl; // 输出: 20 -
reference back(): 返回 vector 的最后一个元素的引用。如果 vector 为空,调用该方法会导致未定义行为。std::vector<int> vec = {1, 2, 3, 4, 5}; int& lastElement = vec.back(); // 返回最后一个元素的引用,即值为 5 lastElement = 50; // 修改最后一个元素为 50 std::cout << vec.back() << std::endl; // 输出: 50 -
iterator begin(): 返回指向 vector 第一个元素的迭代器。这个迭代器指向 vector 的首元素。std::vector<int> vec = {1, 2, 3, 4, 5}; std::vector<int>::iterator it = vec.begin(); // 返回指向第一个元素的迭代器 std::cout << *it << std::endl; // 输出: 1 -
iterator end(): 返回指向 vector 最后一个元素之后位置的迭代器。这个迭代器指向 vector 的尾元素的下一个位置。std::vector<int> vec = {1, 2, 3, 4, 5}; std::vector<int>::iterator it = vec.end(); // 返回指向最后一个元素之后位置的迭代器 --it; // 移动到最后一个元素 std::cout << *it << std::endl; // 输出: 5 -
reverse_iterator rbegin(): 返回指向 vector 最后一个元素的反向迭代器。该迭代器可以用来从后往前遍历元素。std::vector<int> vec = {1, 2, 3, 4, 5}; std::vector<int>::reverse_iterator rit = vec.rbegin(); // 返回指向最后一个元素的反向迭代器 std::cout << *rit << std::endl; // 输出: 5 -
reverse_iterator rend(): 返回指向 vector 第一个元素之前位置的反向迭代器。该迭代器指向 vector 的第一个元素之前的位置。std::vector<int> vec = {1, 2, 3, 4, 5}; std::vector<int>::reverse_iterator rit = vec.rend(); // 返回指向第一个元素之前位置的反向迭代器 ++rit; // 移动到第一个元素 std::cout << *rit << std::endl; // 输出: 1 -
使用基于范围的
for循环(C++11 及以上): 可以直接遍历std::vector中的每个元素,语法简洁。std::vector<int> vec = {1, 2, 3, 4, 5}; for (const int& num : vec) { std::cout << num << " "; // 输出: 1 2 3 4 5 } -
使用传统的
for循环(基于索引): 使用索引来遍历std::vector,适合在需要访问元素索引的情况下使用。std::vector<int> vec = {1, 2, 3, 4, 5}; for (size_t i = 0; i < vec.size(); ++i) { std::cout << vec[i] << " "; // 输出: 1 2 3 4 5 }
这里要再提一次 std::vector<bool>:它的迭代器不是指针,而是封装了位操作的类,因此不能当指针用,&*it 这类写法是非法的。前面提到的基于范围的 for 循环在它身上也要小心,for (auto& x : vec) 绑定不上代理对象,只能写成 for (bool x : vec) 或 for (auto&& x : vec) 这样的形式。
(6) 其他函数
-
void swap(vector& other): 交换当前 vector 和另一个 vector 的内容(包括 capacity)。它不会对单个元素做拷贝、移动或交换,所以指向元素的迭代器和引用仍然有效,只是跟随元素“搬”到了对方容器里;end()迭代器会失效。如果需要“删除”当前 vector 中的元素,可以通过交换将其与一个空的 vector 交换,从而达到清空的效果std::vector<int> vec = {1, 2, 3, 4}; std::vector<int> emptyVec; vec.swap(emptyVec); // vec 变为空,emptyVec 变为 {1, 2, 3, 4} -
void assign(size_type count, const T& value): 将count个value元素赋值给当前 vector,替换原有内容,所有迭代器、指针和引用都会失效std::vector<int> vec; vec.assign(5, 10); // 将 vec 赋值为 {10, 10, 10, 10, 10} -
void assign(InputIterator first, InputIterator last): 使用区间[first, last)的元素来填充当前 vector,替换原有内容。first和last不能是*this自己的迭代器,否则行为未定义std::vector<int> vec1 = {1, 2, 3}; std::vector<int> vec2; vec2.assign(vec1.begin(), vec1.end()); // vec2 变为 {1, 2, 3} -
T* data(): 返回指向底层数组中首元素的指针,便于与 C 风格接口交互。即使容器为空,[data(), data() + size())也始终是一个合法区间,只是此时指针不可解引用std::vector<int> vec = {1, 2, 3}; int* p = vec.data(); // 等价于 &vec[0] std::cout << p[0] << std::endl; // 输出: 1
data() 是普通 vector 与 C 接口打交道的主要方式,但 std::vector<bool> 没有 data() 成员,也没有 bool* 迭代器,所以 std::memcpy(buffer, vec.data(), ...) 这类写法以及任何要求 bool* 的 C 函数在它身上都用不了。另外,emplace() 和 emplace_back() 这两个函数在 C++11 刚引入时也没有提供给 std::vector<bool>,后来通过 LWG 2187 补上了,所以在较老的编译器上这两个函数可能不可用。std::vector<bool> 还提供了一个特有的成员函数 flip(),用于翻转容器中所有的布尔值(相当于按位取反),普通 std::vector 没有这个函数。
std::vector<bool> vec;
vec.push_back(true);
vec.push_back(false);
vec.emplace_back(true);
vec[0] = false; // 通过代理对象修改
vec.flip(); // 翻转所有位
std::cout << vec.size() << std::endl; // 输出: 3
另外要注意,多个线程同时写同一个字节内的不同位(也就是不同的 std::vector<bool> 元素)会产生数据竞争,而 std::vector<char> 里相邻元素是独立的字节,不会有这个问题。还有一点,按位存储省的是内存,代价是每次读写都要做掩码和移位,访问单个元素比普通 bool 数组更贵,编译器也很难做向量化优化,如果代码是随机访问密集型的,std::vector<bool> 反而可能更慢。
总结一下,std::vector<bool> 适合的场景是只需要一个省内存的位集合,并且不需要取元素地址、不需要和 C 接口打交道。如果需要的是真正的 bool 数组语义,应该换成 std::vector<char>(每个元素占 1 字节,可以取地址),或者长度固定时用 std::bitset(按位存储,接口更丰富)。
std::vector<char> vec(10, 0); // 每个元素占 1 字节,可以取地址
std::bitset<100> bits; // 长度固定,按位存储
std::array
std::array 是一个封装了固定大小数组的容器。它将 C 风格数组的性能和内存布局与标准容器的优点结合起来,例如能够知道自己的大小、支持赋值和随机访问迭代器等。std::array 的大小是模板参数的一部分,在编译时固定,因此它不支持动态大小的调整(如插入或删除元素)。
1. 引入
#include <array>
2. 存储方式
std::array 是一个固定大小的顺序容器,其元素是存储在连续内存中的,在栈中存储。它的内存布局与 C 风格数组 T[N] 完全相同,并且不包含任何额外的开销(例如,不需要存储大小)。
- 固定大小: 数组的大小
N是一个模板参数,在编译时确定,不能在运行时改变。 - 连续存储: 元素在内存中是连续存放的,这使得
std::array支持高效的随机访问(通过索引 \(O(1)\) 时间复杂度)和迭代器操作。 - 无数据成员开销: 数组本身不存储除了元素之外的任何数据(例如,不需要存储大小),因此它的空间效率与 C 风格数组相同。
由于大小固定,std::array 不支持诸如 push_back、pop_back 或 insert、erase 等会改变容器大小的操作。
3. 方法
std::array 的许多操作都是隐式定义的,因为它是一个聚合类型(Aggregate Type),遵循 C 风格数组的初始化和复制规则。
(1) 构造方法
std::array 是一个聚合类型,因此它使用聚合初始化(Aggregate Initialization)规则:
-
默认构造:
std::array<int, 5> arr; // 包含 5 个 int 元素,默认初始化(对于基本类型,值可能是不确定的) -
列表初始化/聚合初始化:
std::array<int, 3> arr1 = {1, 2, 3}; // 包含 3 个元素,值为 1, 2, 3 std::array<int, 5> arr2 = {1, 2}; // 包含 5 个元素,前两个为 1, 2,其余为 0 (零初始化) -
复制/移动构造:
std::array<int, 3> arr3 = {1, 2, 3}; std::array<int, 3> arr4(arr3); // 复制构造
(2) 大小函数
-
size_t size() const: 返回std::array中元素的个数(即模板参数 \(N\))。std::array<int, 4> arr; std::cout << arr.size(); // 输出 4 -
bool empty() const: 检查std::array是否为空。由于 \(N\) 在编译时固定,对于 \(N > 0\) 的数组总是返回false。std::array<int, 0> arr0; std::cout << arr0.empty(); // 输出 true std::array<int, 5> arr5; std::cout << arr5.empty(); // 输出 false -
size_t max_size() const: 返回容器可能包含的最大元素数,与size()相同。
(3) 元素访问
-
reference at(size_type pos): 访问指定位置pos的元素,带边界检查。如果pos超出范围,则抛出std::out_of_range异常。std::array<int, 3> arr = {1, 2, 3}; std::cout << arr.at(1); // 输出 2 // arr.at(5); // 运行时抛出异常 -
reference operator[](size_type pos): 访问指定位置pos的元素,不带边界检查。std::array<int, 3> arr = {1, 2, 3}; std::cout << arr[1]; // 输出 2 // arr[5]; // 未定义行为 -
reference front(): 访问第一个元素。std::array<int, 3> arr = {1, 2, 3}; std::cout << arr.front(); // 输出 1 -
reference back(): 访问最后一个元素。std::array<int, 3> arr = {1, 2, 3}; std::cout << arr.back(); // 输出 3 -
T* data(): 返回指向存储元素的首元素的底层 C 风格数组的指针。std::array<int, 3> arr = {1, 2, 3}; int* p = arr.data(); std::cout << p[0]; // 输出 1 -
std::get<I>(array): 使用元组式接口访问第 \(I\) 个元素,编译时进行边界检查。std::array<int, 3> arr = {1, 2, 3}; std::cout << std::get<1>(arr); // 输出 2
(4) 迭代器函数
std::array 支持随机访问迭代器。
iterator begin()/const_iterator cbegin(): 返回指向第一个元素的迭代器。iterator end()/const_iterator cend(): 返回指向最后一个元素之后位置的迭代器。reverse_iterator rbegin()/const_reverse_iterator crbegin(): 返回指向最后一个元素的反向迭代器。reverse_iterator rend()/const_reverse_iterator crend(): 返回指向第一个元素之前位置的反向迭代器。
(5) 修改函数
std::array 不支持改变大小的操作(如 push_back, pop_back, insert, erase)。
-
void fill(const T& value): 将所有元素的值设置为value。std::array<int, 5> arr; arr.fill(10); // arr 变为 {10, 10, 10, 10, 10} -
void swap(array& other): 交换当前std::array和另一个大小、类型相同的std::array的内容。std::array<int, 3> arr1 = {1, 2, 3}; std::array<int, 3> arr2 = {4, 5, 6}; arr1.swap(arr2); // arr1 变为 {4, 5, 6},arr2 变为 {1, 2, 3}
(6) 遍历函数
由于 std::array 提供了随机访问迭代器,因此可以使用多种方式遍历。
-
使用基于范围的
for循环(C++11 及以上):std::array<int, 3> arr = {1, 2, 3}; for (const int& num : arr) { std::cout << num << " "; // 输出: 1 2 3 } -
使用传统的
for循环(基于迭代器):std::array<int, 3> arr = {1, 2, 3}; for (auto it = arr.begin(); it != arr.end(); ++it) { std::cout << *it << " "; // 输出: 1 2 3 } -
使用传统的
for循环(基于索引):std::array<int, 3> arr = {1, 2, 3}; for (size_t i = 0; i < arr.size(); ++i) { std::cout << arr[i] << " "; // 输出: 1 2 3 }
std::list
List 是一个支持在容器的任意位置快速插入和删除元素的一个顺序容器,支持存储各种类型的对象,链表在存储时不需要重新分配内存或是初始化时预留大小,对象存储在内存中的不连续位置,因此随机访问能力较差。
1. 引入
#include <list>
2. 存储方式
std::list 是基于双向链表实现的容器。与 std::vector 的连续内存不同,std::list 的元素存储在不连续的内存块中。每个元素由一个节点(节点中包含元素和指向前后元素的指针)组成,这些节点通过指针连接成一个链表。由于每个节点都是独立分配的,std::list 不需要连续的内存空间来存储元素。
在 std::list 中插入或删除元素时,由于元素是通过指针连接的,因此插入和删除操作通常是非常高效的。插入、删除操作不会影响链表中其他元素的存储位置。
然而,由于每个元素都需要额外的内存来存储指向前后元素的指针,所以相较于 std::vector,std::list 在存储上较为低效,且不支持快速随机访问。访问元素时,必须从链表的起始或终止节点开始,逐步沿着指针遍历到目标元素。
3. 方法
(1) 构造方法
-
list(): 创建一个空的std::list,默认构造函数。std::list<int> lst; -
list(size_t nSize): 创建一个包含nSize个默认构造元素的std::list。std::list<int> lst(10); // 创建一个包含10个默认构造的元素的列表 -
list(size_t nSize, const T& value): 创建一个包含nSize个元素,并且所有元素的值为value的std::list。std::list<int> lst(10, 5); // 创建一个包含10个元素,值都为5的列表 -
list(const list&): 复制构造函数,创建一个与另一个std::list完全相同的副本。std::list<int> lst1 = {1, 2, 3, 4, 5}; std::list<int> lst2(lst1); // lst2 是 lst1 的一个复制版本 -
list(begin, end): 使用另一个容器或迭代器区间中的元素初始化std::list。std::vector<int> vec = {1, 2, 3, 4, 5}; std::list<int> lst(vec.begin(), vec.end()); // 复制 vector 的元素到 list
(2) 大小函数
-
size_t size() const: 返回std::list中元素的个数。std::list<int> lst = {1, 2, 3, 4}; std::cout << lst.size(); // 输出 4 -
bool empty() const: 检查std::list是否为空,若为空返回true,否则返回false。std::list<int> lst; if (lst.empty()) { std::cout << "List is empty." << std::endl; } -
void resize(size_t size): 调整std::list的大小。若新大小大于当前大小,std::list会插入默认值的元素;若小于当前大小,则会删除多余的元素。std::list<int> lst = {1, 2, 3}; lst.resize(5, 10); // 增加两个元素,元素值为 10
(3) 增加函数
-
push_back(const T& value): 将元素value添加到std::list的末尾。std::list<int> lst = {1, 2, 3}; lst.push_back(4); // 向 lst 中添加元素 4,lst 变为 {1, 2, 3, 4} -
push_front(const T& value): 将元素value添加到std::list的开头。std::list<int> lst = {2, 3, 4}; lst.push_front(1); // 向 lst 中添加元素 1,lst 变为 {1, 2, 3, 4} -
emplace_back(Args&&... args): 在std::list的末尾就地构造一个元素,使用提供的参数直接构造该元素。std::list<std::pair<int, int>> lst; lst.emplace_back(1, 2); // 在末尾构造一个 pair<int, int>,值为 {1, 2} -
emplace_front(Args&&... args): 在std::list的前端就地构造一个元素。std::list<std::pair<int, int>> lst; lst.emplace_front(1, 2); // 在前端构造一个 pair<int, int>,值为 {1, 2} -
insert(iterator pos, const T& value): 在指定位置pos插入元素value,插入操作会将pos后面的元素向后移动。std::list<int> lst = {1, 2, 4, 5}; lst.insert(std::next(lst.begin(), 2), 3); // 在位置2插入3,lst 变为 {1, 2, 3, 4, 5} -
insert(iterator pos, size_t count, const T& value): 在指定位置pos插入count个元素,所有的元素值为value。std::list<int> lst = {1, 2, 4, 5}; lst.insert(std::next(lst.begin(), 2), 2, 3); // 在位置2插入两个3,lst 变为 {1, 2, 3, 3, 4, 5} -
insert(iterator pos, InputIterator first, InputIterator last): 将[first, last)区间的元素插入到指定位置pos。std::list<int> lst1 = {1, 2, 3}; std::list<int> lst2 = {4, 5}; lst1.insert(std::next(lst1.begin(), 2), lst2.begin(), lst2.end()); // 在位置2插入 lst2 中的元素,lst1 变为 {1, 2, 4, 5, 3}
(4) 删除函数
-
pop_back(): 删除std::list中的最后一个元素。std::list<int> lst = {1, 2, 3, 4}; lst.pop_back(); // 删除最后一个元素,lst 变为 {1, 2, 3} -
pop_front(): 删除std::list中的第一个元素。std::list<int> lst = {1, 2, 3, 4}; lst.pop_front(); // 删除第一个元素,lst 变为 {2, 3, 4} -
erase(iterator pos): 删除指定位置pos处的元素,删除操作后,pos后面的元素会向前移动。std::list<int> lst = {1, 2, 3, 4}; lst.erase(std::next(lst.begin(), 2)); // 删除索引为2的元素,lst 变为 {1, 2, 4} -
erase(iterator first, iterator last): 删除区间[first, last)内的所有元素。std::list<int> lst = {1, 2, 3, 4, 5}; lst.erase(std::next(lst.begin(), 1), std::next(lst.begin(), 4)); // 删除从索引1到3的元素,lst 变为 {1, 5} -
clear(): 删除std::list中的所有元素,使容器变为空。std::list<int> lst = {1, 2, 3, 4}; lst.clear(); // 删除所有元素,lst 变为空 {} -
void remove(const T& val):remove函数用于删除链表中所有等于指定值val的元素。它会遍历整个链表,删除所有匹配的元素。std::list<int> lst = {1, 2, 3, 4, 3, 5}; lst.remove(3); // 删除所有值为3的元素,lst 变为 {1, 2, 4, 5} -
void remove_if (bool (*pred)(const T&));: pred: 一个谓词(通常是一个函数或函数对象),用于判断元素是否满足删除条件。pred 返回 true 表示删除该元素,返回 false 表示保留该元素。用于删除满足特定条件的所有元素。与 remove 不同的是,remove_if 允许你使用自定义的条件来判断哪些元素需要被删除。remove_if 会遍历整个容器,将所有满足谓词 pred 条件的元素移到容器的末尾,并返回一个指向容器中第一个不满足条件的元素的迭代器。然后可以使用 erase 来删除这些元素。
需要注意的是,remove_if 并不会实际删除元素,它只是将符合条件的元素移动到容器的末尾,删除操作需要通过 erase 来完成。
假设我们有一个 std::list,并希望删除所有大于 3 的元素:
#include <iostream> #include <list> #include <algorithm> // 包含 std::remove_if bool greater_than_three(int n) { return n > 3; // 条件:删除大于3的元素 } int main() { std::list<int> lst = {1, 2, 3, 4, 5, 6}; // 使用 remove_if 删除大于 3 的元素 lst.remove_if(greater_than_three); // 输出结果 for (int n : lst) { std::cout << n << " "; // 输出: 1 2 3 } return 0; }也可以使用 lambda 表达式作为谓词:
#include <iostream> #include <list> #include <algorithm> int main() { std::list<int> lst = {1, 2, 3, 4, 5, 6}; // 使用 lambda 表达式删除大于 3 的元素 lst.remove_if([](int n) { return n > 3; }); // 输出结果 for (int n : lst) { std::cout << n << " "; // 输出: 1 2 3 } return 0; }
(5) 遍历函数
-
iterator begin(): 返回指向std::list第一个元素的迭代器。std::list<int> lst = {1, 2, 3}; std::list<int>::iterator it = lst.begin(); // 返回指向第一个元素的迭代器 std::cout << *it << std::endl; // 输出: 1 -
iterator end(): 返回指向std::list最后一个元素之后位置的迭代器。std::list<int> lst = {1, 2, 3}; std::list<int>::iterator it = lst.end(); // 返回指向最后一个元素之后位置的迭代器 --it; // 移动到最后一个元素 std::cout << *it << std::endl; // 输出: 3 -
reverse_iterator rbegin(): 返回指向std::list最后一个元素的反向迭代器。std::list<int> lst = {1, 2, 3}; std::list<int>::reverse_iterator rit = lst.rbegin(); // 返回指向最后一个元素的反向迭代器 std::cout << *rit << std::endl; // 输出: 3 -
reverse_iterator rend(): 返回指向std::list第一个元素之前位置的反向迭代器。std::list<int> lst = {1, 2, 3}; std::list<int>::reverse_iterator rit = lst.rend(); // 返回指向第一个元素之前位置的反向迭代器 ++rit; // 移动到第一个元素 std::cout << *rit << std::endl; // 输出: 1 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::list中的每个元素。std::list<int> lst = {1, 2, 3}; for (const int& num : lst) { std::cout << num << " "; // 输出: 1 2 3 } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::list。std::list<int> lst = {1, 2, 3}; for (auto it = lst.begin(); it != lst.end(); ++it) { std::cout << *it << " "; // 输出: 1 2 3 }
(6) 其他函数
-
swap(list& other): 交换当前std::list和另一个std::list的内容。std::list<int> lst1 = {1, 2, 3}; std::list<int> lst2 = {4, 5, 6}; lst1.swap(lst2); // lst1 变为 {4, 5, 6},lst2 变为 {1, 2, 3} -
assign(size_t count, const T& value): 将count个value元素赋值给当前std::list,替换原有内容。std::list<int> lst; lst.assign(5, 10); // 将 lst 赋值为 {10, 10, 10, 10, 10} -
assign(InputIterator first, InputIterator last): 使用区间[first, last)的元素来填充当前std::list,替换原有内容。std::list<int> lst1 = {1, 2, 3}; std::list<int> lst2; lst2.assign(lst1.begin(), lst1.end()); // lst2 变为 {1, 2, 3} -
void sort():sort函数用于对链表中的元素进行排序。默认情况下,它会按照元素的升序排列。如果需要按降序排序,可以提供自定义比较函数。std::list<int> lst = {5, 3, 4, 1, 2}; lst.sort(); // 按升序排序,lst 变为 {1, 2, 3, 4, 5}如果要进行降序排序,可以使用自定义的比较函数:
lst.sort(std::greater<int>()); // 按降序排序,lst 变为 {5, 4, 3, 2, 1} -
void merge(list& other):merge函数用于将另一个已排序的链表other合并到当前链表中。合并后的链表仍然是有序的。注意,merge函数要求两个链表必须是排序过的,否则结果无法保证是有序的。std::list<int> lst1 = {1, 3, 5}; std::list<int> lst2 = {2, 4, 6}; lst1.merge(lst2); // lst1 合并 lst2 后,变为 {1, 2, 3, 4, 5, 6} -
void reverse():reverse函数用于将链表中的元素反转。它会改变链表中元素的顺序。std::list<int> lst = {1, 2, 3, 4, 5}; lst.reverse(); // 反转链表,lst 变为 {5, 4, 3, 2, 1}
std::forward_list
std::forward_list 是一个支持在容器的任意位置快速插入和删除元素的顺序容器。它是一个单向链表(singly-linked list)实现,因此它只支持向前遍历。相比于 std::list,它不存储指向前一个节点的指针,从而提供更节省空间的存储方式,但代价是失去了双向迭代的能力以及快速获取 size() 的能力。
1. 引入
#include <forward_list>
2. 存储方式
std::forward_list 是基于单向链表实现的容器。它的元素存储在内存中的不连续位置,每个元素由一个节点(包含元素和指向下一个元素的指针)组成。
- 单向连接: 每个节点只知道其后继节点,因此只能从头到尾单向遍历。
- 高效的插入和删除: 与
std::list类似,在任意位置插入和删除元素的时间复杂度为 \(O(1)\),但操作通常需要一个指向目标位置之前元素的迭代器。 - 空间优化: 由于每个元素只存储一个指针(指向下一个元素),它比双向链表
std::list更节省内存。 - 不支持随机访问和
size(): 由于是单向链表,它不支持 \(O(1)\) 复杂度的随机访问(例如operator[]),并且为了保持 \(O(1)\) 插入/删除的效率,它通常不提供size()成员函数(除非是 C++20 引入的std::ssize全局函数)。
3. 方法
由于 std::forward_list 的单向特性,其插入和删除操作与 std::list 有显著区别:它没有 pop_back()、push_back() 或 back(),并且 insert 和 erase 操作是在指定位置的后方进行。
(1) 构造方法
-
forward_list(): 创建一个空的std::forward_list,默认构造函数。std::forward_list<int> lst; -
forward_list(size_t nSize): 创建一个包含nSize个默认构造元素的std::forward_list。std::forward_list<int> lst(10); // 创建一个包含10个默认构造的元素的列表 -
forward_list(size_t nSize, const T& value): 创建一个包含nSize个元素,并且所有元素的值为value的std::forward_list。std::forward_list<int> lst(10, 5); // 创建一个包含10个元素,值都为5的列表 -
forward_list(const forward_list&): 复制构造函数,创建一个与另一个std::forward_list完全相同的副本。std::forward_list<int> lst1 = {1, 2, 3}; std::forward_list<int> lst2(lst1); // lst2 是 lst1 的一个复制版本 -
forward_list(begin, end): 使用另一个容器或迭代器区间中的元素初始化std::forward_list。std::vector<int> vec = {1, 2, 3}; std::forward_list<int> lst(vec.begin(), vec.end()); // 复制 vector 的元素到 forward_list
(2) 大小函数
-
bool empty() const: 检查std::forward_list是否为空。若为空返回true,否则返回false。std::forward_list<int> lst; if (lst.empty()) { std::cout << "List is empty." << std::endl; } -
size_t max_size() const: 返回容器可能包含的最大元素数。
(3) 增加函数
std::forward_list 的所有插入操作都围绕前部或指定位置之后进行。
-
push_front(const T& value): 将元素value添加到std::forward_list的开头。std::forward_list<int> lst = {2, 3, 4}; lst.push_front(1); // lst 变为 {1, 2, 3, 4} -
emplace_front(Args&&... args): 在std::forward_list的前端就地构造一个元素。std::forward_list<std::pair<int, int>> lst; lst.emplace_front(1, 2); // 在前端构造一个 pair<int, int>,值为 {1, 2} -
insert_after(const_iterator pos, const T& value): 在指定位置pos的后面插入元素value。std::forward_list<int> lst = {1, 2, 4, 5}; auto it = std::next(lst.begin()); // 指向元素 2 lst.insert_after(it, 3); // 在 2 后面插入 3,lst 变为 {1, 2, 3, 4, 5} -
insert_after(const_iterator pos, size_t count, const T& value): 在指定位置pos的后面插入count个元素,所有元素值为value。std::forward_list<int> lst = {1, 2, 4, 5}; auto it = std::next(lst.begin()); lst.insert_after(it, 2, 3); // 在 2 后面插入两个 3,lst 变为 {1, 2, 3, 3, 4, 5} -
insert_after(const_iterator pos, InputIterator first, InputIterator last): 将[first, last)区间的元素插入到指定位置pos的后面。
(4) 删除函数
std::forward_list 的所有删除操作都围绕前部或指定位置之后进行。
-
pop_front(): 删除std::forward_list中的第一个元素。std::forward_list<int> lst = {1, 2, 3, 4}; lst.pop_front(); // 删除第一个元素,lst 变为 {2, 3, 4} -
erase_after(const_iterator pos): 删除指定位置pos后面的元素。std::forward_list<int> lst = {1, 2, 3, 4}; auto it = lst.begin(); // 指向元素 1 lst.erase_after(it); // 删除 1 后面的元素 2,lst 变为 {1, 3, 4} -
erase_after(const_iterator first, const_iterator last): 删除区间(first, last)内的所有元素。std::forward_list<int> lst = {1, 2, 3, 4, 5}; auto it1 = lst.begin(); // 指向 1 auto it2 = std::next(it1, 3); // 指向 4 lst.erase_after(it1, it2); // 删除 1 后面直到 4 之前的所有元素 {2, 3},lst 变为 {1, 4, 5} -
clear(): 删除std::forward_list中的所有元素,使容器变为空。 -
void remove(const T& val): 删除链表中所有等于指定值val的元素。 -
void remove_if (bool (*pred)(const T&)): 删除满足谓词pred条件的所有元素。std::forward_list<int> lst = {1, 2, 3, 4, 5}; lst.remove_if([](int n) { return n % 2 == 0; }); // 删除偶数,lst 变为 {1, 3, 5}
(5) 遍历函数
std::forward_list 只支持前向迭代器。它提供了一个特殊的迭代器 before_begin() 用于方便地在链表头部之前进行插入/删除操作。
-
const_iterator before_begin(): 返回指向链表第一个元素之前位置的特殊迭代器。用于配合insert_after或erase_after在链表头部操作。std::forward_list<int> lst = {1, 2, 3}; // 在链表头部插入元素 0 lst.insert_after(lst.before_begin(), 0); // lst 变为 {0, 1, 2, 3} -
iterator begin(): 返回指向std::forward_list第一个元素的迭代器。 -
iterator end(): 返回指向std::forward_list最后一个元素之后位置的迭代器。 -
使用基于范围的
for循环(C++11 及以上):std::forward_list<int> lst = {1, 2, 3}; for (const int& num : lst) { std::cout << num << " "; // 输出: 1 2 3 }
(6) 其他函数
-
swap(forward_list& other): 交换当前std::forward_list和另一个std::forward_list的内容。 -
assign(size_t count, const T& value): 将count个value元素赋值给当前std::forward_list,替换原有内容。 -
assign(InputIterator first, InputIterator last): 使用区间[first, last)的元素来填充当前std::forward_list,替换原有内容。 -
void sort(): 对链表中的元素进行排序。默认升序。std::forward_list<int> lst = {5, 3, 4, 1, 2}; lst.sort(); // 按升序排序,lst 变为 {1, 2, 3, 4, 5} -
void merge(forward_list& other): 将另一个已排序的链表other合并到当前链表中。要求两个链表都已排序。 -
void reverse(): 将链表中的元素反转。std::forward_list<int> lst = {1, 2, 3}; lst.reverse(); // 反转链表,lst 变为 {3, 2, 1} -
void unique(): 删除链表中所有连续重复的元素。std::forward_list<int> lst = {1, 2, 2, 3, 3, 3, 4}; lst.unique(); // lst 变为 {1, 2, 3, 4} -
void splice_after(const_iterator pos, forward_list& other): 将other中的所有元素移动到当前列表*this中,放在pos之后。other变为空。std::forward_list<int> lst1 = {1, 4}; std::forward_list<int> lst2 = {2, 3}; lst1.splice_after(lst1.begin(), lst2); // lst1 变为 {1, 2, 3, 4},lst2 变为空 {}
std::deque
std::deque(双端队列)是一个支持在两端快速插入和删除元素的顺序容器。与 std::vector 不同,std::deque 在内存中采用分段连续的存储方式,即它的元素分布在多个不连续的内存块中,这些内存块通过指针数组进行管理。由于这一内存布局,std::deque 可以在头尾两端高效地进行插入和删除操作,并且支持随机访问(可以使用operator[])。尽管如此,它的随机访问性能较 std::vector 差一些,因为每个内存块并不连续。
std::deque 不需要像 std::vector 一样每次扩展时重新分配整个内存空间,而是通过扩展指向内存块的指针数组来增加容量。这使得它在动态变化的场景中,尤其是在需要频繁在两端插入和删除元素时,表现得尤为高效。
1. 引入
#include <deque>
2. 存储方式
std::deque 是双端队列,支持在两端进行高效的插入和删除。为了实现这一点,std::deque 使用的是一种分段连续的内存结构。它的内存布局由多个固定大小的内存块(即“段”)组成,每个块内的元素是连续存储的,但这些块在内存中并不连续。通过维护一个指向这些块的指针数组,std::deque 可以实现高效的两端插入和删除操作。
当 std::deque 容量不足时,它会动态扩展新的内存块,并在原有的内存块之间插入新的块。这样,由于每个块都是独立分配的,std::deque 既能保证高效的插入和删除操作,又能提供支持随机访问的能力(虽然随机访问性能不如 std::vector)。
与 std::vector 不同的是,std::deque 不需要一次性重新分配整个内存空间,它只是扩展指向内存块的指针数组。这样,std::deque 可以高效地在头尾进行扩展,并且仍然保持较好的访问性能。
3. 方法
(1) 构造方法
-
deque(): 创建一个空的std::deque。std::deque<int> deq; -
deque(size_t nSize): 创建一个包含nSize个默认构造元素的std::deque。std::deque<int> deq(10); // 创建一个包含10个默认构造元素的 deque -
deque(size_t nSize, const T& value): 创建一个包含nSize个元素,且每个元素的值为value的std::deque。std::deque<int> deq(10, 5); // 创建一个包含10个元素,值都为5的 deque -
deque(const deque&): 复制构造函数,创建一个与另一个std::deque完全相同的副本。std::deque<int> deq1 = {1, 2, 3, 4, 5}; std::deque<int> deq2(deq1); // deq2 是 deq1 的一个复制版本 -
deque(begin, end): 使用另一个容器或迭代器区间中的元素初始化std::deque。std::vector<int> vec = {1, 2, 3, 4, 5}; std::deque<int> deq(vec.begin(), vec.end()); // 复制 vector 的元素到 deque
(2) 大小函数
-
size_t size() const: 返回std::deque中元素的个数。std::deque<int> deq = {1, 2, 3, 4}; std::cout << deq.size(); // 输出 4 -
bool empty() const: 检查std::deque是否为空,若为空返回true,否则返回false。std::deque<int> deq; if (deq.empty()) { std::cout << "Deque is empty." << std::endl; } -
void resize(size_t size): 调整std::deque的大小。若新大小大于当前大小,std::deque会插入默认值的元素;若小于当前大小,则会删除多余的元素。std::deque<int> deq = {1, 2, 3}; deq.resize(5, 10); // 增加两个元素,元素值为 10 -
void shrink_to_fit(): 调整队列预留内存刚好适应当前的大小,节省内存 -
size_t max_size() const: 返回最大可允许的vector元素数量值
(3) 增加函数
-
push_back(const T& value): 将元素value添加到std::deque的末尾。std::deque<int> deq = {1, 2, 3}; deq.push_back(4); // 向 deq 中添加元素 4,deq 变为 {1, 2, 3, 4} -
push_front(const T& value): 将元素value添加到std::deque的开头。std::deque<int> deq = {2, 3, 4}; deq.push_front(1); // 向 deq 中添加元素 1,deq 变为 {1, 2, 3, 4} -
emplace_back(Args&&... args): 在std::deque的末尾就地构造一个元素,使用提供的参数直接构造该元素。std::deque<std::pair<int, int>> deq; deq.emplace_back(1, 2); // 在末尾构造一个 pair<int, int>,值为 {1, 2} -
emplace_front(Args&&... args): 在std::deque的前端就地构造一个元素。std::deque<std::pair<int, int>> deq; deq.emplace_front(1, 2); // 在前端构造一个 pair<int, int>,值为 {1, 2} -
insert(iterator pos, const T& value): 在指定位置pos插入元素value,插入操作会将pos后面的元素向后移动。std::deque<int> deq = {1, 2, 4, 5}; deq.insert(deq.begin() + 2, 3); // 在位置2插入3,deq 变为 {1, 2, 3, 4, 5} -
insert(iterator pos, size_t count, const T& value): 在指定位置pos插入count个元素,所有的元素值为value。std::deque<int> deq = {1, 2, 4, 5}; deq.insert(deq.begin() + 2, 2, 3); // 在位置2插入两个3,deq 变为 {1, 2, 3, 3, 4, 5} -
insert(iterator pos, InputIterator first, InputIterator last): 将[first, last)区间的元素插入到指定位置pos。std::deque<int> deq1 = {1, 2, 3}; std::deque<int> deq2 = {4, 5}; deq1.insert(deq1.begin() + 2, deq2.begin(), deq2.end()); // 在位置2插入 deq2 中的元素,deq1 变为 {1, 2, 4, 5, 3}
(4) 删除函数
-
pop_back(): 删除std::deque中的最后一个元素。std::deque<int> deq = {1, 2, 3, 4}; deq.pop_back(); // 删除最后一个元素,deq 变为 {1, 2, 3} -
pop_front(): 删除std::deque中的第一个元素。std::deque<int> deq = {1, 2, 3, 4}; deq.pop_front(); // 删除第一个元素,deq 变为 {2, 3, 4} -
erase(iterator pos): 删除指定位置pos处的元素,删除操作后,pos后面的元素会向前移动。std::deque<int> deq = {1, 2, 3, 4}; deq.erase(deq.begin() + 2); // 删除索引为2的元素,deq 变为 {1, 2, 4} -
erase(iterator first, iterator last): 删除区间[first, last)内的所有元素。std::deque<int> deq = {1, 2, 3, 4, 5}; deq.erase(deq.begin() + 1, deq.begin() + 4); // 删除从索引1到3的元素,deq 变为 {1, 5} -
clear(): 删除std::deque中的所有元素,使容器变为空。std::deque<int> deq = {1, 2, 3, 4}; deq.clear(); // 删除所有元素,deq 变为空 {}
(5) 遍历函数
-
reference at(int pos): 同Vector, 返回pos位置元素的引用。与operator[]类似,但会检查边界,如果访问无效的位置会抛出std::out_of_range异常。std::vector<int> vec = {1, 2, 3, 4, 5}; int& element = vec.at(2); // 返回位置 2 处元素的引用,即值为 3 element = 10; // 修改元素为 10 std::cout << vec[2] << std::endl; // 输出: 10 -
iterator begin(): 返回指向std::deque第一个元素的迭代器。std::deque<int> deq = {1, 2, 3}; std::deque<int>::iterator it = deq.begin(); // 返回指向第一个元素的迭代器 std::cout << *it << std::endl; // 输出: 1 -
iterator end(): 返回指向std::deque最后一个元素之后位置的迭代器。std::deque<int> deq = {1, 2, 3}; std::deque<int>::iterator it = deq.end(); // 返回指向最后一个元素之后位置的迭代器 --it; // 移动到最后一个元素 std::cout << *it << std::endl; // 输出: 3 -
reverse_iterator rbegin(): 返回指向std::deque最后一个元素的反向迭代器。std::deque<int> deq = {1, 2, 3}; std::deque<int>::reverse_iterator rit = deq.rbegin(); // 返回指向最后一个元素的反向迭代器 std::cout << *rit << std::endl; // 输出: 3 -
reverse_iterator rend(): 返回指向std::deque第一个元素之前位置的反向迭代器。std::deque<int> deq = {1, 2, 3}; std::deque<int>::reverse_iterator rit = deq.rend(); // 返回指向第一个元素之前位置的反向迭代器 ++rit; // 移动到第一个元素 std::cout << *rit << std::endl; // 输出: 1 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::deque中的每个元素。std::deque<int> deq = {1, 2, 3}; for (const int& num : deq) { std::cout << num << " "; // 输出: 1 2 3 } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::deque。std::deque<int> deq = {1, 2, 3}; for (auto it = deq.begin(); it != deq.end(); ++it) { std::cout << *it << " "; // 输出: 1 2 3 }
(6) 其他函数
-
swap(deque& other): 交换当前std::deque和另一个std::deque的内容。std::deque<int> deq1 = {1, 2, 3}; std::deque<int> deq2 = {4, 5, 6}; deq1.swap(deq2); // deq1 变为 {4, 5, 6},deq2 变为 {1, 2, 3} -
assign(size_t count, const T& value): 将count个value元素赋值给当前std::deque,替换原有内容。std::deque<int> deq; deq.assign(5, 10); // 将 deq 赋值为 {10, 10, 10, 10, 10} -
assign(InputIterator first, InputIterator last): 使用区间[first, last)的元素来填充当前std::deque,替换原有内容。std::deque<int> deq1 = {1, 2, 3}; std::deque<int> deq2; deq2.assign(deq1.begin(), deq1.end()); // deq2 变为 {1, 2, 3} -
std::allocator<T> get_allocator() const: 返回容器所使用的分配器(allocator)。分配器负责容器内存的管理,包括内存的分配、释放等操作。在默认情况下,C++ 标准库容器使用 std::allocator 作为默认的分配器。通过调用 get_allocator(),你可以获取容器使用的分配器,并利用该分配器进行自定义内存操作,或者检查容器如何管理内存。#include <iostream> #include <vector> int main() { std::vector<int> vec; // 获取分配器 std::allocator<int> alloc = vec.get_allocator(); // 使用分配器进行内存分配 int* p = alloc.allocate(5); // 分配5个int元素的内存 // 显示分配的内存地址 std::cout << "Allocated memory at: " << p << std::endl; // 记得释放分配的内存 alloc.deallocate(p, 5); // 释放之前分配的5个int内存 return 0; }
std::set
std::set 是一个排序的关联容器,它存储唯一的元素,并根据元素的顺序进行自动排序。与 std::list 不同,std::set 提供了对元素的高效查找、插入和删除操作,但不支持直接的索引访问。其底层通常是基于平衡二叉树实现(红黑树),因此在查找、插入和删除操作上的时间复杂度通常为 O(log n)。
1. 引入
#include <set>
2. 存储方式
std::set 是基于平衡二叉树(通常是红黑树)实现的容器。与 std::list 和 std::vector 不同,std::set 具有自动排序的特性,元素会按升序(默认情况下)进行排列。每个元素都是唯一的,无法存储重复的元素。
在 std::set 中,元素会以节点的形式存储,每个节点包含一个元素以及指向父节点、左子节点和右子节点的指针。通过这种方式,std::set 保证了所有元素的排序特性,并支持高效的查找、插入和删除操作。
- 插入:由于元素在插入时会自动排序并且不允许重复,插入一个新元素的时间复杂度为 O(log n),这也保证了元素始终保持有序。
- 查找:在
std::set中查找元素的时间复杂度为 O(log n),这是因为它依赖于平衡二叉树的结构。 - 删除:删除操作也有 O(log n) 的时间复杂度,因为删除节点时需要保持树的平衡。
与 std::vector 和 std::list 不同,std::set 的元素是按照某种顺序排列的,因此它支持基于顺序的迭代。由于不允许重复元素,std::set 会自动忽略重复的插入请求。
尽管 std::set 提供了很高效的插入、删除和查找操作,但它并不支持快速的随机访问。要访问集合中的某个元素,必须按顺序遍历元素,无法通过索引直接访问。
3. 方法
(1)构造方法
-
set(): 创建一个空的std::set,默认构造函数。std::set<int> s; std::set<int> s = {1, 2, 3, 4, 5}; std::set<int> s{1, 2, 3, 4, 5}; -
set(const set&): 复制构造函数,创建一个与另一个std::set完全相同的副本。std::set<int> s1 = {1, 2, 3, 4, 5}; std::set<int> s2(s1); // s2 是 s1 的一个复制版本 std::set<int> s3(std::move(s3)); // 移动构造(s3 变为有效但未指定状态,移除所有权) -
set(begin, end): 使用另一个容器或迭代器区间中的元素初始化std::set。std::vector<int> vec = {1, 2, 3, 4, 5}; std::set<int> s(vec.begin(), vec.end()); // 复制 vector 的元素到 set -
std::set<T&, std::greater<T&>>: 按照降序创建set
(2)大小函数
-
size_t size() const: 返回std::set中元素的个数。std::set<int> s = {1, 2, 3, 4}; std::cout << s.size(); // 输出 4 -
bool empty() const: 检查std::set是否为空,若为空返回true,否则返回false。std::set<int> s; if (s.empty()) { std::cout << "Set is empty." << std::endl; } -
size_t max_size() const: 返回最大可允许的set元素数量值
(3)增加函数
-
std::pair<iterator, bool> insert(const T& value): 插入元素到set中,返回一个pair,其first是指向插入元素所在位置的迭代器,其second是插入是否成功(存在重复元素几插入失败)s.insert({1,2,3}); // initializer_list s.insert(s.begin(), 42); // 带 hint 的插入 s.insert(vec.begin(), vec.end()); // 区间插入 -
std::pair<iterator, bool> emplace(_Args&&... __args): 就地构建并插入元素 -
iterator emplace_hint(const_iterator __pos, _Args&&... __args): 就地构建并插入元素,且指定插入位置,但是如果指定位置错误会发生重排序,最坏情况下可能会降低插入的效率
(4)删除元素
-
void clear(): 删除std::set中的所有元素,使容器变为空。std::set<int> s = {1, 2, 3, 4}; s.clear(); // 删除所有元素,s 变为空 {} -
void erase(iterator pos): 删除指定位置pos处的元素std::set<int> s = {1, 2, 3, 4}; s.erase(std::next(s.begin(), 2)); // 删除索引为2的元素,lst 变为 {1, 2, 4} -
size_t erase(const key_type& key): 删除指定键,返回删除个数。 -
erase(first, last): 删除迭代器区间。
(5)查找函数
-
iterator find(const T& value): 查找某元素在set中的位置,返回一个指向该元素的迭代器,如果找不到会返回end() -
std::pair<const_iterator, const_iterator> equal_range(const key_type& __x) const: 返回一对迭代器,表示所有具有等于k的键的元素的范围。由于set包含唯一元素,因此下界将是元素本身,上限将指向键k之后的下一个元素。如果没有与键K匹配的元素,则根据容器的内部比较对象(key_comp),返回的范围的长度为0,两个迭代器均指向大于k的第一个元素。如果键超过了set容器中的最大元素,它将返回一个指向set容器中最后一个元素的迭代器。std::set<int> s = {1, 2, 3, 4}; std::pair p = s.equal_range(2); std::cout<<*p.first<<std::endl; // 输出2 std::cout<<*p.second<<std::endl;// 输出3 -
size_t count(const T& value): 返回是否存在(0 或 1)。 -
iterator lower_bound(const T& value): 返回第一个不小于 value 的迭代器。 -
iterator upper_bound(const T& value): 返回第一个大于 value 的迭代器。 -
bool contains(const T& value) const(C++20): 检查是否包含元素。
(6)遍历函数
-
iterator begin(): 返回指向std::set第一个元素的迭代器。std::set<int> s = {1, 2, 3}; std::set<int>::iterator it = s.begin(); // 返回指向第一个元素的迭代器 std::cout << *it << std::endl; // 输出: 1 -
iterator end(): 返回指向std::set最后一个元素之后位置的迭代器。std::set<int> s = {1, 2, 3}; std::set<int>::iterator it = s.end(); // 返回指向最后一个元素之后位置的迭代器 --it; // 移动到最后一个元素 std::cout << *it << std::endl; // 输出: 3 -
reverse_iterator rbegin(): 返回指向std::set最后一个元素的反向迭代器。std::set<int> s = {1, 2, 3}; std::set<int>::reverse_iterator rit = s.rbegin(); // 返回指向最后一个元素的反向迭代器 std::cout << *rit << std::endl; // 输出: 3 -
reverse_iterator rend(): 返回指向std::set第一个元素之前位置的反向迭代器。std::set<int> s = {1, 2, 3}; std::set<int>::reverse_iterator rit = s.rend(); // 返回指向第一个元素之前位置的反向迭代器 ++rit; // 移动到第一个元素 std::cout << *rit << std::endl; // 输出: 1 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::set中的每个元素。std::set<int> s = {1, 2, 3}; for (const int& num : s) { std::cout << num << " "; // 输出: 1 2 3 } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::set。std::set<int> s = {1, 2, 3}; for (auto it = s.begin(); it != s.end(); ++it) { std::cout << *it << " "; // 输出: 1 2 3 }
(7)其他函数
-
swap(set& other): 交换当前std::set和另一个std::set的内容。std::set<int> s1 = {1, 2, 3}; std::set<int> s2 = {4, 5, 6}; s1.swap(s2); // s1 变为 {4, 5, 6},s2 变为 {1, 2, 3} -
void merge(set& other):merge会将另一个 set 中不与当前容器重复的元素“移动”过来(O(log n) 插入),保持有序性。即使other是乱序的(但它本身仍然是一个 set,天然有序),结果依旧有序。std::set<int> s1 = {1, 3, 5}; std::set<int> s2 = {2, 4, 6}; s1.merge(s2); // s1 合并 s2 后,变为 {1, 2, 3, 4, 5, 6} -
std::allocator<T> get_allocator() const: 返回容器所使用的分配器(allocator)。分配器负责容器内存的管理,包括内存的分配、释放等操作。在默认情况下,C++ 标准库容器使用 std::allocator 作为默认的分配器。通过调用 get_allocator(),你可以获取容器使用的分配器,并利用该分配器进行自定义内存操作,或者检查容器如何管理内存。#include <iostream> #include <set> int main() { std::set<int> s; // 获取分配器 std::allocator<int> alloc = s.get_allocator(); // 使用分配器进行内存分配 int* p = alloc.allocate(5); // 分配5个int元素的内存 // 显示分配的内存地址 std::cout << "Allocated memory at: " << p << std::endl; // 记得释放分配的内存 alloc.deallocate(p, 5); // 释放之前分配的5个int内存 return 0; } -
比较运算符:支持 ==, !=, <, <=, >, >=,C++20 起支持 <=>,但移除了!=, <, <=, >, >=。
例如
<(小于比较)比较两个 std::set 的顺序,即按照元素的字典顺序(升序排列)来进行比较。如果第一个集合的元素在第一个不相等的地方小于第二个集合,则返回 true。
std::multiset
std::multiset 是与 std::set 类似的一个容器,但与 std::set 不同,std::multiset 允许存储重复的元素。在 std::multiset 中,元素会按顺序排列,并且可以包含多个相同的元素。std::multiset 也提供了高效的查找、插入和删除操作,底层通常使用红黑树结构实现,时间复杂度通常为 O(log n)。
1. 引入
#include <set>
2. 存储方式
std::multiset 是基于平衡二叉树(通常是红黑树)实现的容器。与 std::set 相似,std::multiset 也具有自动排序的特性,元素会按升序(默认情况下)进行排列。但是,不同于 std::set,std::multiset 允许存储重复的元素。
在 std::multiset 中,元素存储在节点中,每个节点包含一个元素及其数量(即该元素出现的次数)。这种方式保证了所有元素的排序特性,同时允许插入多个相同的元素。
- 插入:插入一个新元素的时间复杂度为 O(log n),并且插入重复元素时,
std::multiset会将元素的数量增加,而不是替换现有的元素。 - 查找:在
std::multiset中查找元素的时间复杂度为 O(log n),因为它依赖于平衡二叉树的结构。 - 删除:删除操作也有 O(log n) 的时间复杂度,因为删除节点时需要保持树的平衡。
与 std::set 不同,std::multiset 允许插入重复元素,因此 std::multiset 可以包含多个相同的元素。std::multiset也不支持快速的随机访问。
3. 方法
(1) 构造方法
-
multiset(): 创建一个空的std::multiset,默认构造函数。std::multiset<int> ms; std::multiset<int> ms = {1, 2, 3, 4, 5}; -
multiset(const multiset&): 复制构造函数,创建一个与另一个std::multiset完全相同的副本。std::multiset<int> ms1 = {1, 2, 3, 4, 5}; std::multiset<int> ms2(ms1); // ms2 是 ms1 的一个复制版本 -
multiset(begin, end): 使用另一个容器或迭代器区间中的元素初始化std::multiset。std::vector<int> vec = {1, 2, 3, 4, 5}; std::multiset<int> ms(vec.begin(), vec.end()); // 复制 vector 的元素到 multiset -
std::multiset<T&, std::greater<T&>>: 按照降序创建multiset。
(2) 大小函数
-
size_t size() const: 返回std::multiset中元素的个数。std::multiset<int> ms = {1, 2, 3, 4}; std::cout << ms.size(); // 输出 4 -
bool empty() const: 检查std::multiset是否为空,若为空返回true,否则返回false。std::multiset<int> ms; if (ms.empty()) { std::cout << "Multiset is empty." << std::endl; } -
size_t max_size() const: 返回最大可允许的multiset元素数量。
(3) 增加函数
-
std::pair<iterator, bool> insert(const T& value): 插入元素到multiset中,返回一个pair,其first是指向插入元素所在位置的迭代器,second是插入是否成功(是否为重复元素)。ms.insert({1, 2, 3}); // initializer_list ms.insert(ms.begin(), 42); // 带 hint 的插入 ms.insert(vec.begin(), vec.end()); // 区间插入 -
std::pair<iterator, bool> emplace(_Args&&... __args): 就地构建并插入元素。 -
iterator emplace_hint(const_iterator __pos, _Args&&... __args): 就地构建并插入元素,且指定插入位置。
(4) 删除元素
-
void clear(): 删除std::multiset中的所有元素,使容器变为空。std::multiset<int> ms = {1, 2, 3, 4}; ms.clear(); // 删除所有元素,ms 变为空 {} -
void erase(iterator pos): 删除指定位置pos处的元素。std::multiset<int> ms = {1, 2, 3, 4}; ms.erase(std::next(ms.begin(), 2)); // 删除索引为2的元素,ms 变为 {1, 2, 4} -
size_t erase(const key_type& key): 删除指定键,返回删除个数。 -
erase(first, last): 删除迭代器区间。
(5) 查找函数
-
iterator find(const T& value): 查找某元素在multiset中的位置,返回一个指向该元素的迭代器,如果找不到会返回end()。 -
std::pair<const_iterator, const_iterator> equal_range(const key_type& __x) const: 返回一对迭代器,表示所有具有等于k的键的元素的范围。由于multiset允许重复元素,因此该范围可能包含多个相同的元素。如果没有与键K匹配的元素,则根据容器的内部比较对象(key_comp),返回的范围的长度为0,两个迭代器均指向大于k的第一个元素。如果键超过了multiset容器中的最大元素,它将返回一个指向multiset容器中最后一个元素的迭代器。std::multiset<int> ms = {1, 2, 3, 3, 4}; std::pair p = ms.equal_range(3); std::cout << *p.first << std::endl; // 输出 3 std::cout << *p.second << std::endl;// 输出 4 -
size_t count(const T& value): 返回元素出现的次数。 -
iterator lower_bound(const T& value): 返回第一个不小于value的迭代器。 -
iterator upper_bound(const T& value): 返回第一个大于value的迭代器。 -
bool contains(const T& value) const(C++20):检查是否包含元素。
(6) 遍历函数
-
iterator begin(): 返回指向std::multiset第一个元素的迭代器。std::multiset<int> ms = {1, 2, 3}; std::multiset<int>::iterator it = ms.begin(); // 返回指向第一个元素的迭代器 std::cout << *it << std::endl; // 输出: 1 -
iterator end(): 返回指向std::multiset最后一个元素之后位置的迭代器。 -
reverse_iterator rbegin(): 返回指向std::multiset最后一个元素的反向迭代器。 -
reverse_iterator rend(): 返回指向std::multiset第一个元素之前位置的反向迭代器。 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::multiset中的每个元素。std::multiset<int> ms = {1, 2, 3}; for (const int& num : ms) { std::cout << num << " "; // 输出: 1 2 3 } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::multiset。std::multiset<int> ms = {1, 2, 3}; for (auto it = ms.begin(); it != ms.end(); ++it) { std::cout << *it << " "; // 输出: 1 2 3 }
(7) 其他函数
-
swap(multiset& other): 交换当前std::multiset和另一个std::multiset的内容。 -
void merge(multiset& other): 将另一个multiset中的元素合并到当前multiset,如果元素重复,则保留所有元素。std::multiset<int> ms1 = {1, 3, 5}; std::multiset<int> ms2 = {2, 4, 6}; ms1.merge(ms2); // ms1 合并 ms2 后,变为 {1, 2, 3, 4, 5, 6} -
std::allocator<T> get_allocator() const: 返回容器所使用的分配器(allocator)。
std::map
std::map 是 C++ 标准库中的一个关联容器,它用于存储一组键值对(key-value),并根据键进行自动排序。每个键在 std::map 中是唯一的,因此不允许存储重复的键。std::map 底层通常使用红黑树结构实现,时间复杂度通常为 O(log n) 用于插入、查找和删除操作。
1. 引入
#include <map>
2. 存储方式
std::map 是基于平衡二叉树(通常是红黑树)实现的容器。与 std::set 类似,std::map 也具有自动排序的特性,元素会根据键(key)进行升序排序(默认情况下)。与 std::set 的区别是,std::map 存储的是键值对,每个键对应一个值,且键是唯一的。
在 std::map 中,元素存储为节点,每个节点包含一个键值对和指向父节点、左子节点和右子节点的指针。通过这种方式,std::map 保证了所有键的排序特性,并支持高效的查找、插入和删除操作。
- 插入:插入一个新元素的时间复杂度为 O(log n),并且如果插入的键已经存在,新的值会覆盖原来的值。
- 查找:在
std::map中查找元素的时间复杂度为 O(log n),这是因为它依赖于平衡二叉树的结构。 - 删除:删除操作也有 O(log n) 的时间复杂度,因为删除节点时需要保持树的平衡。
std::map 不允许重复的键,因此每个键只能对应一个值。如果尝试插入一个已存在的键,插入操作会被忽略。
3. 方法
(1) 构造方法
-
map(): 创建一个空的std::map,默认构造函数。// std::map<key_type, value_type> myMap; std::map<int, std::string> m; std::map<int, std::string> m = {{1, "one"}, {2, "two"}, {3, "three"}}; -
map(const map&): 复制构造函数,创建一个与另一个std::map完全相同的副本。std::map<int, std::string> m1 = {{1, "one"}, {2, "two"}}; std::map<int, std::string> m2(m1); // m2 是 m1 的一个复制版本 -
map(begin, end): 使用另一个容器或迭代器区间中的元素初始化std::map。std::vector<std::pair<int, std::string>> vec = {{1, "one"}, {2, "two"}}; std::map<int, std::string> m(vec.begin(), vec.end()); // 复制 vector 的元素到 map -
std::map<Key, T, Compare>: 使用自定义的比较器来进行键的排序。
(2) 大小函数
-
size_t size() const: 返回std::map中元素的个数。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; std::cout << m.size(); // 输出 2 -
bool empty() const: 检查std::map是否为空,若为空返回true,否则返回false。std::map<int, std::string> m; if (m.empty()) { std::cout << "Map is empty." << std::endl; } -
size_t max_size() const: 返回最大可允许的map元素数量。
(3) 增加函数
-
std::pair<iterator, bool> insert(const value_type& value): 插入一个元素到std::map中,返回一个pair,其中first是指向插入元素所在位置的迭代器,second表示插入是否成功(如果键已存在,则插入失败)。std::map<int, std::string> m; m.insert({1, "one"}); m.insert({2, "two"}); -
std::pair<iterator, bool> emplace(_Args&&... args): 就地构建并插入元素。 -
iterator emplace_hint(const_iterator hint, _Args&&... args): 就地构建并插入元素,且指定插入位置。 -
std::pair<iterator, bool> try_emplace(const key_type& key, Args&&... args): 是 C++17 引入的一种插入方式。它尝试插入一个元素,如果给定的键已经存在,则不会插入新元素,并且不会更改已有元素的值。它还允许通过参数传递构造函数的参数,避免不必要的拷贝或移动。std::map<int, std::string> m; auto result = m.try_emplace(1, "one"); if (result.second) { std::cout << "Element inserted: " << result.first->second << std::endl; } else { std::cout << "Element with key " << result.first->first << " already exists." << std::endl; } auto result2 = m.try_emplace(1, "uno"); // 不会插入新元素,因为键1已存在 std::cout << "Element: " << result2.first->second << std::endl; // 输出: "one" -
std::pair<iterator, bool> insert_or_assign(const key_type& key, const mapped_type& obj)和std::pair<iterator, bool> insert_or_assign(const key_type& key, mapped_type&& obj): 是 C++17 引入的另一种插入方式,它的作用是:如果键不存在,就插入该键值对;如果键已经存在,则更新其对应的值。 -
operator[]插入方式(mapped_type& operator[](const key_type& key)或mapped_type& operator[](key_type&& key);): 最常见的插入方式。它会检查 std::map 中是否存在给定的键。如果键存在,它返回该键对应的值;如果键不存在,它会插入该键并且初始化对应的值为 mapped_type 的默认值(对于非内置类型,是通过默认构造函数进行初始化)。同样的,这样的方式还可以用来获取键对应的值。std::map<int, std::string> m; m[1] = "one"; // 插入新元素 std::cout << m[1] << std::endl; // 输出: one m[1] = "uno"; // 更新已有的元素 std::cout << m[1] << std::endl; // 输出: uno m[2]; // 键2不存在,会插入键2,并将对应的值初始化为默认值 "" (空字符串) std::cout << m[2] << std::endl; // 输出: (空字符串)
(4) 删除元素
-
void clear(): 删除std::map中的所有元素,使容器变为空。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; m.clear(); // 删除所有元素,m 变为空 {} -
void erase(iterator pos): 删除指定位置pos处的元素。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; m.erase(m.begin()); // 删除第一个元素 -
size_t erase(const key_type& key): 删除指定键,返回删除个数。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; m.erase(1); // 删除键为1的元素 -
erase(first, last): 删除迭代器区间。
(5) 查找函数
-
iterator find(const key_type& key): 查找某个键在map中的位置,返回一个指向该元素的迭代器,如果找不到会返回end()。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; auto it = m.find(1); // 返回指向键1的迭代器 std::cout << it->second; // 输出 "one" -
std::pair<const_iterator, const_iterator> equal_range(const key_type& key) const: 返回一对迭代器,表示所有具有等于键key的元素的范围。在std::map中,由于键是唯一的,因此这个范围包含一个元素。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; auto range = m.equal_range(1); std::cout << range.first->second << std::endl; // 输出 "one" -
size_t count(const key_type& key): 返回容器中是否包含指定键,map中每个键最多出现一次,因此返回值要么是0,要么是1。 -
iterator lower_bound(const key_type& key): 返回第一个不小于key的迭代器。 -
iterator upper_bound(const key_type& key): 返回第一个大于key的迭代器。 -
bool contains(const key_type& key) const(C++20):检查是否包含该键。
(6) 遍历函数
-
iterator begin(): 返回指向std::map第一个元素的迭代器。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; auto it = m.begin(); // 返回指向第一个元素的迭代器 std::cout << it->first << ": " << it->second << std::endl; // 输出 1: one -
iterator end(): 返回指向std::map最后一个元素之后位置的迭代器。 -
reverse_iterator rbegin(): 返回指向std::map最后一个元素的反向迭代器。 -
reverse_iterator rend(): 返回指向std::map第一个元素之前位置的反向迭代器。 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::map中的每个元素。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; for (const auto& pair : m) { std::cout << pair.first << ": " << pair.second << " "; // 输出: 1: one 2: two } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::map。std::map<int, std::string> m = {{1, "one"}, {2, "two"}}; for (auto it = m.begin(); it != m.end(); ++it) { std::cout << it->first << ": " << it->second << " "; // 输出: 1: one 2: two }
(7) 其他函数
-
swap(map& other): 交换当前std::map和另一个std::map的内容。 -
std::allocator<T> get_allocator() const: 返回容器所使用的分配器(allocator)。 -
std::map<Key, T, Compare> merge(map& other): 将另一个map容器合并过来,保持有序性。
std::multimap
std::multimap 是一个与 std::map 类似的关联容器,但与 std::map 不同,std::multimap 允许存储重复的键。在 std::multimap 中,键是可以重复的,而每个键都可以关联多个不同的值。与 std::map 相同,std::multimap 也会自动按照键的顺序进行排序。std::multimap 的底层实现通常使用红黑树,时间复杂度通常为 O(log n) 用于插入、查找和删除操作。
1. 引入
#include <map>
2. 存储方式
std::multimap 与 std::map 类似,都是基于平衡二叉树(通常是红黑树)实现的容器。与 std::map 不同,std::multimap 允许同一个键对应多个值,因此在插入重复的键时,std::multimap 会将新的键值对插入到现有键的下方,而不是替换原有的键值对。
std::multimap 中的每个元素都是一个键值对(key-value),并且元素根据键自动排序。每个键在 std::multimap 中可以重复多次,不同的键值对可以共享同一个键。
- 插入:插入一个新元素的时间复杂度为 O(log n),并且允许插入多个具有相同键的元素。
- 查找:在
std::multimap中查找元素的时间复杂度为 O(log n),因为它依赖于平衡二叉树的结构。 - 删除:删除操作也有 O(log n) 的时间复杂度,因为删除节点时需要保持树的平衡。
std::multimap 适用于需要存储多个值并能够按键排序的场景。
3. 方法
(1) 构造方法
-
multimap(): 创建一个空的std::multimap,默认构造函数。std::multimap<int, std::string> mm; std::multimap<int, std::string> mm = {{1, "one"}, {2, "two"}, {3, "three"}}; -
multimap(const multimap&): 复制构造函数,创建一个与另一个std::multimap完全相同的副本。std::multimap<int, std::string> mm1 = {{1, "one"}, {2, "two"}}; std::multimap<int, std::string> mm2(mm1); // mm2 是 mm1 的一个复制版本 -
multimap(begin, end): 使用另一个容器或迭代器区间中的元素初始化std::multimap。std::vector<std::pair<int, std::string>> vec = {{1, "one"}, {2, "two"}}; std::multimap<int, std::string> mm(vec.begin(), vec.end()); // 复制 vector 的元素到 multimap -
std::multimap<Key, T, Compare>: 使用自定义的比较器来进行键的排序。
(2) 大小函数
-
size_t size() const: 返回std::multimap中元素的个数。std::multimap<int, std::string> mm = {{1, "one"}, {2, "two"}}; std::cout << mm.size(); // 输出 2 -
bool empty() const: 检查std::multimap是否为空,若为空返回true,否则返回false。std::multimap<int, std::string> mm; if (mm.empty()) { std::cout << "Multimap is empty." << std::endl; } -
size_t max_size() const: 返回最大可允许的multimap元素数量。
(3) 增加函数
-
std::pair<iterator, bool> insert(const value_type& value): 插入一个元素到std::multimap中,返回一个pair,其中first是指向插入元素所在位置的迭代器,second表示插入是否成功(即该键是否已经存在)。std::multimap<int, std::string> mm; mm.insert({1, "one"}); mm.insert({2, "two"}); mm.insert({1, "uno"}); // 允许插入重复键 -
std::pair<iterator, bool> emplace(_Args&&... args): 就地构建并插入元素。 -
iterator emplace_hint(const_iterator hint, _Args&&... args): 就地构建并插入元素,且指定插入位置。 -
std::pair<iterator, bool> try_emplace(const key_type& key, Args&&... args): 是 C++17 引入的一种插入方式。它尝试插入一个元素,如果给定的键已经存在,则不会插入新元素,并且不会更改已有元素的值。它还允许通过参数传递构造函数的参数,避免不必要的拷贝或移动。std::multimap<int, std::string> m; auto result = m.try_emplace(1, "one"); if (result.second) { std::cout << "Element inserted: " << result.first->second << std::endl; } else { std::cout << "Element with key " << result.first->first << " already exists." << std::endl; } auto result2 = m.try_emplace(1, "uno"); // 不会插入新元素,因为键1已存在 std::cout << "Element: " << result2.first->second << std::endl; // 输出: "one" -
std::pair<iterator, bool> insert_or_assign(const key_type& key, const mapped_type& obj)和std::pair<iterator, bool> insert_or_assign(const key_type& key, mapped_type&& obj): 是 C++17 引入的另一种插入方式,它的作用是:如果键不存在,就插入该键值对;如果键已经存在,则更新其对应的值。 -
operator[]插入方式(mapped_type& operator[](const key_type& key)或mapped_type& operator[](key_type&& key);): 最常见的插入方式。它会检查 std::multimap 中是否存在给定的键。如果键存在,它返回该键对应的值;如果键不存在,它会插入该键并且初始化对应的值为 mapped_type 的默认值(对于非内置类型,是通过默认构造函数进行初始化)。同样的,这样的方式还可以用来获取键对应的值。std::multimap<int, std::string> m; m[1] = "one"; // 插入新元素 std::cout << m[1] << std::endl; // 输出: one m[1] = "uno"; // 更新已有的元素 std::cout << m[1] << std::endl; // 输出: uno m[2]; // 键2不存在,会插入键2,并将对应的值初始化为默认值 "" (空字符串) std::cout << m[2] << std::endl; // 输出: (空字符串)
(4) 删除元素
-
void clear(): 删除std::multimap中的所有元素,使容器变为空。std::multimap<int, std::string> mm = {{1, "one"}, {2, "two"}}; mm.clear(); // 删除所有元素,mm 变为空 {} -
void erase(iterator pos): 删除指定位置pos处的元素。std::multimap<int, std::string> mm = {{1, "one"}, {2, "two"}}; mm.erase(mm.begin()); // 删除第一个元素 -
size_t erase(const key_type& key): 删除指定键,返回删除的元素个数。如果键有重复元素,则会删除所有具有该键的元素。std::multimap<int, std::string> mm = {{1, "one"}, {1, "uno"}, {2, "two"}}; mm.erase(1); // 删除键为 1 的所有元素 -
erase(first, last): 删除迭代器区间。
(5) 查找函数
-
iterator find(const key_type& key): 查找某个键在multimap中的位置,返回一个指向该元素的迭代器,如果找不到会返回end()。std::multimap<int, std::string> mm = {{1, "one"}, {2, "two"}}; auto it = mm.find(1); // 返回指向键1的迭代器 std::cout << it->second; // 输出 "one" -
std::pair<const_iterator, const_iterator> equal_range(const key_type& key) const: 返回一对迭代器,表示所有具有等于键key的元素的范围。在std::multimap中,由于键是可以重复的,因此这个范围可能包含多个相同的元素。std::multimap<int, std::string> mm = {{1, "one"}, {1, "uno"}, {2, "two"}}; auto range = mm.equal_range(1); std::cout << range.first->second << " " << range.second->second << std::endl; -
size_t count(const key_type& key): 返回容器中某个键的元素个数(在std::multimap中可以有多个相同键)。std::multimap<int, std::string> mm = {{1, "one"}, {1, "uno"}, {2, "two"}}; std::cout << mm.count(1); // 输出 2,因为键1出现了两次 -
iterator lower_bound(const key_type& key): 返回第一个不小于key的迭代器。 -
iterator upper_bound(const key_type& key): 返回第一个大于key的迭代器。 -
bool contains(const key_type& key) const(C++20):检查是否包含该键。
(6) 遍历函数
-
iterator begin(): 返回指向std::multimap第一个元素的迭代器。std::multimap<int, std::string> mm = {{1, "one"}, {2, "two"}}; auto it = mm.begin(); // 返回指向第一个元素的迭代器 std::cout << it->first << ": " << it->second << std::endl; // 输出 1: one -
iterator end(): 返回指向std::multimap最后一个元素之后位置的迭代器。 -
reverse_iterator rbegin(): 返回指向std::multimap最后一个元素的反向迭代器。 -
reverse_iterator rend(): 返回指向std::multimap第一个元素之前位置的反向迭代器。 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::multimap中的每个元素。std::multimap<int, std::string> mm = {{1, "one"}, {2, "two"}}; for (const auto& pair : mm) { std::cout << pair.first << ": " << pair.second << " "; // 输出: 1: one 2: two } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::multimap。std::multimap<int, std::string> mm = {{1, "one"}, {2, "two"}}; for (auto it = mm.begin(); it != mm.end(); ++it) { std::cout << it->first << ": " << it->second << " "; // 输出: 1: one 2: two }
(7) 其他函数
-
swap(multimap& other): 交换当前std::multimap和另一个std::multimap的内容。 -
std::allocator<T> get_allocator() const: 返回容器所使用的分配器(allocator)。 -
std::multimap<Key, T, Compare> merge(multimap& other): 将另一个multimap容器合并过来,保持有序性。
std::unordered_set
std::unordered_set 是一个无序的关联容器,它与 std::set 的主要区别在于元素的存储顺序。std::set 按照元素的升序排列,而 std::unordered_set 不保持任何顺序,它依赖于哈希表实现,因此元素的存储位置是由哈希函数决定的。std::unordered_set 提供了高效的查找、插入和删除操作,底层通常使用哈希表,查找、插入和删除操作的平均时间复杂度为 O(1),但最坏情况下为 O(n)。
unordered_map是一个模板类,其定义如下:
std::unordered_set<Key, Hash = std::hash<Key>, Pred = std::equal_to<Key>, Alloc = std::allocator<Key>>
- Key 是存储在
unordered_set中的元素类型。 - Hash 是一个函数或函数对象,用于生成元素的哈希值,默认为
std::hash<Key>。 - Pred 是一个二元谓词,用于比较两个元素是否相等,默认为
std::equal_to<Key>。 - Alloc 是分配器类型,用于管理内存分配,默认为
std::allocator<Key>。
1. 引入
#include <unordered_set>
2. 存储方式
std::unordered_set 使用哈希表(hash table)来存储元素。每个元素的存储位置由哈希函数计算出来,因此它不保证元素的顺序。哈希表允许非常高效的查找、插入和删除操作,通常时间复杂度为 O(1),但在哈希冲突的情况下,最坏情况的时间复杂度可能退化为 O(n)。
- 插入:插入一个元素时,哈希函数会计算该元素的哈希值,并将元素存储在哈希表中。
- 查找:查找操作的时间复杂度为 O(1),但是哈希冲突可能导致时间复杂度退化为 O(n)。
- 删除:删除操作的时间复杂度通常为 O(1),但最坏情况下可能退化为 O(n)。
std::unordered_set 与 std::set 最大的区别是,它使用哈希表来存储元素,因此没有顺序性,无法进行按顺序的遍历。
3. 方法
(1)构造方法
-
unordered_set(): 创建一个空的std::unordered_set,默认构造函数。std::unordered_set<int> us; std::unordered_set<int> us = {1, 2, 3, 4, 5}; -
unordered_set(const unordered_set&): 复制构造函数,创建一个与另一个std::unordered_set完全相同的副本。std::unordered_set<int> us1 = {1, 2, 3, 4, 5}; std::unordered_set<int> us2(us1); // us2 是 us1 的一个复制版本 -
unordered_set(begin, end): 使用另一个容器或迭代器区间中的元素初始化std::unordered_set。std::vector<int> vec = {1, 2, 3, 4, 5}; std::unordered_set<int> us(vec.begin(), vec.end()); // 复制 vector 的元素到 unordered_set
(2) 大小函数
-
size_t size() const: 返回std::unordered_set中元素的个数。std::unordered_set<int> us = {1, 2, 3, 4}; std::cout << us.size(); // 输出 4 -
bool empty() const: 检查std::unordered_set是否为空,若为空返回true,否则返回false。std::unordered_set<int> us; if (us.empty()) { std::cout << "Unordered Set is empty." << std::endl; } -
size_t max_size() const: 返回最大可允许的unordered_set元素数量。 -
void reserve(size_t n): 用于预先分配哈希表的桶数量。这可以有效减少动态扩容的开销,特别是在你知道将要插入大量元素时。
(3) 增加函数
-
std::pair<iterator, bool> insert(const T& value): 插入元素到unordered_set中,返回一个pair,其中first是指向插入元素所在位置的迭代器,second是插入是否成功(如果存在重复元素,则插入失败)。us.insert({1, 2, 3}); // initializer_list us.insert(us.begin(), 42); // 带 hint 的插入 us.insert(vec.begin(), vec.end()); // 区间插入 -
std::pair<iterator, bool> emplace(_Args&&... args): 就地构建并插入元素。 -
iterator emplace_hint(const_iterator hint, _Args&&... args): 就地构建并插入元素,且指定插入位置,但是如果指定位置错误会发生重排序,最坏情况下可能会降低插入的效率。在哈希索引的前提下是无序的,因此该插入操作等效于insert。
(4) 删除元素
-
void clear(): 删除std::unordered_set中的所有元素,使容器变为空。std::unordered_set<int> us = {1, 2, 3, 4}; us.clear(); // 删除所有元素,us 变为空 {} -
void erase(iterator pos): 删除指定位置pos处的元素。std::unordered_set<int> us = {1, 2, 3, 4}; us.erase(std::next(us.begin(), 2)); // 删除索引为2的元素,us 变为 {1, 2, 4} -
size_t erase(const key_type& key): 删除指定键,返回删除个数。 -
erase(first, last): 删除迭代器区间。
(5) 查找函数
-
iterator find(const T& value): 查找某元素在unordered_set中的位置,返回一个指向该元素的迭代器,如果找不到会返回end()。std::unordered_set<int> us = {1, 2, 3, 4}; auto it = us.find(1); // 返回指向键1的迭代器 if (it != us.end()) { std::cout << *it << std::endl; // 输出: 1 } -
std::pair<const_iterator, const_iterator> equal_range(const key_type& __x) const: 返回一对迭代器,表示所有具有等于k的键的元素的范围。由于set包含唯一元素,因此下界将是元素本身,上限将指向键k之后的下一个元素。如果没有与键K匹配的元素,则根据容器的内部比较对象(key_comp),返回的范围的长度为0,两个迭代器均指向大于k的第一个元素。如果键超过了set容器中的最大元素,它将返回一个指向set容器中最后一个元素的迭代器。 -
size_t count(const T& value): 返回元素是否存在(0 或 1)。 -
bool contains(const T& value) const(C++20):检查是否包含该元素。
(6) 遍历函数
-
iterator begin(): 返回指向std::unordered_set第一个元素的迭代器。 -
iterator end(): 返回指向std::unordered_set最后一个元素之后位置的迭代器。 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::unordered_set中的每个元素。std::unordered_set<int> us = {1, 2, 3}; for (const int& num : us) { std::cout << num << " "; // 输出: 1 2 3 } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::unordered_set。std::unordered_set<int> us = {1, 2, 3}; for (auto it = us.begin(); it != us.end(); ++it) { std::cout << *it << " "; // 输出: 1 2 3 }
(7)哈希相关函数
-
size_type max_bucket_count() const noexcept: max_bucket_count() 返回 std::unordered_set 中可用的最大哈希桶数量。哈希表是由一组桶(bucket)构成的,每个桶存储一定数量的元素。当元素的数量增加时,std::unordered_set 可能会自动扩展桶的数量,以减少哈希冲突。max_bucket_count() 允许你查看哈希表最多能支持多少个桶。 -
float max_load_factor() const noexcept: max_load_factor() 返回或设置哈希表的最大负载因子。负载因子是哈希表中元素的数量与桶数量之比。它决定了哈希表何时扩展——当负载因子超过最大值时,std::unordered_set 会增加桶的数量,减少哈希冲突,从而提高性能。- 负载因子是指:负载因子 = 元素个数 / 桶的数量
- 最大负载因子是哈希表在扩展之前允许的最大负载因子。负载因子越高,意味着桶的数量相对较少,这可能导致更多的哈希冲突,降低查找效率。
-
void max_load_factor(float __z): 设置最大负载因子。 -
size_t bucket_count() const: 返回当前 unordered_set 的桶数量。哈希表的桶数量与容器中元素的数量以及负载因子相关,通常哈希表会在元素数量增加时自动扩展桶的数量。 -
size_t bucket_size(size_t n) const: 返回指定桶的元素数量。哈希表中的每个桶可能包含多个元素,特别是当哈希冲突发生时,多个元素可能会存储在同一个桶中。 -
size_t bucket(const key_type& key) const: 返回给定键所在的桶的索引。这个函数对于理解元素是如何分布在哈希表中的非常有用。 -
float load_factor() const: 返回当前哈希表的负载因子,即元素的数量与桶数量的比值。负载因子越高,哈希冲突的可能性越大,因此负载因子对于性能非常重要。 -
void rehash(size_t n): 用来调整哈希表中的桶数量。它的作用是根据新的元素数量来调整桶的数量,避免哈希冲突过多。rehash() 会导致重新分配桶并重新哈希元素。n 是希望容器能够容纳的元素数量。
(8) 其他函数
-
swap(unordered_set& other): 交换当前std::unordered_set和另一个std::unordered_set的内容。 -
std::allocator<T> get_allocator() const: 返回容器所使用的分配器(allocator)。 -
void merge(unordered_set& other):merge会将另一个 unordered_set 中不与当前容器重复的元素“移动”过来。std::unordered_set<int> s1 = {1, 3, 5}; std::unordered_set<int> s2 = {2, 4, 6}; s1.merge(s2); // s1 合并 s2 后,变为 {1, 2, 3, 4, 5, 6} -
key_equal_type key_eq() const: 返回一个比较器,用于判断两个键是否相等std::unordered_set<Person, PersonHash> people; Person p1 = {"Alice", 30}; Person p2 = {"Bob", 25}; people.insert(p1); people.insert(p2); // 使用 key_eq 获取比较函数 auto eq = people.key_eq(); // 使用 key_eq 函数比较两个键 std::cout << "Are p1 and p2 equal? " << std::boolalpha << eq(p1, p2) << std::endl;
std::unordered_multiset
std::unordered_multiset 是一个类似于 std::unordered_set 的容器,唯一的区别在于它允许元素重复。它同样使用哈希表实现,不保证元素的顺序,能够提供高效的查找、插入和删除操作。
为了支持重复键,unordered_multiset通过将每个元素存储为一个“桶”中的链表或其他结构。每个“桶”会存储多个相同的元素,而 unordered_set 只允许每个桶中最多存储一个元素。
unordered_multiset 的模板定义如下:
template<
class Key,
class Hash = std::hash<Key>,
class KeyEqual = std::equal_to<Key>,
class Allocator = std::allocator<Key>
> class unordered_multiset; // since C++11
namespace pmr {
template<
class Key,
class Hash = std::hash<Key>,
class Pred = std::equal_to<Key>
> using unordered_multiset = std::unordered_multiset<Key, Hash, Pred,
std::pmr::polymorphic_allocator<Key>>;
} // since C++17
- Key:存储在
unordered_multiset中的元素类型。 - Hash:用于计算元素哈希值的函数或函数对象,默认为
std::hash<Key>。 - Pred:用于比较两个元素是否相等的二元谓词,默认为
std::equal_to<Key>。 - Alloc:分配器类型,用于内存分配,默认为
std::allocator<Key>。
1. 引入
#include <unordered_set>
2. 存储方式
std::unordered_multiset 使用哈希表(hash table)来存储元素。每个元素的存储位置由哈希函数计算出来,多个相同的元素会被存储在同一个桶中。
- 插入:插入一个元素时,哈希函数计算该元素的哈希值,将元素存储在相应的桶中,允许同一元素的多个副本被插入。
- 查找:查找操作的时间复杂度通常为 O(1),但最坏情况下会退化为 O(n),具体取决于哈希表的负载因子和哈希冲突。
- 删除:删除操作通常是 O(1),但最坏情况下会退化为 O(n)。
与 unordered_set 不同,unordered_multiset 允许在同一个哈希表的桶中存储多个相同的元素。
3. 方法
(1) 构造方法
-
unordered_multiset():创建一个空的std::unordered_multiset,使用默认构造函数。std::unordered_multiset<int> us; std::unordered_multiset<int> us = {1, 2, 3, 4, 5}; -
unordered_multiset(const unordered_multiset&):复制构造函数,创建一个与另一个std::unordered_multiset完全相同的副本。std::unordered_multiset<int> us1 = {1, 2, 3, 4, 5}; std::unordered_multiset<int> us2(us1); // us2 是 us1 的一个复制版本 -
unordered_multiset(begin, end):使用另一个容器或迭代器区间中的元素初始化std::unordered_multiset。std::vector<int> vec = {1, 2, 3, 4, 5}; std::unordered_multiset<int> us(vec.begin(), vec.end()); // 复制 vector 的元素到 unordered_multiset
(2) 大小函数
-
size_t size() const:返回std::unordered_multiset中元素的个数。std::unordered_multiset<int> us = {1, 2, 3, 4}; std::cout << us.size(); // 输出 4 -
bool empty() const:检查std::unordered_multiset是否为空,若为空返回true,否则返回false。std::unordered_multiset<int> us; if (us.empty()) { std::cout << "Unordered Multiset is empty." << std::endl; } -
size_t max_size() const:返回最大可允许的unordered_multiset元素数量。 -
void reserve(size_t n):用于预先分配哈希表的桶数量。这可以有效减少动态扩容的开销,特别是在你知道将要插入大量元素时。
(3) 增加函数
-
std::pair<iterator, bool> insert(const T& value):插入元素到unordered_multiset中,返回一个pair,其中first是指向插入元素所在位置的迭代器,second是插入是否成功(如果存在重复元素,则插入成功并增加该元素的数量)。us.insert({1, 2, 3}); // 插入多个元素 us.insert(us.begin(), 42); // 带 hint 的插入 us.insert(vec.begin(), vec.end()); // 区间插入 -
std::pair<iterator, bool> emplace(_Args&&... args):就地构建并插入元素。 -
iterator emplace_hint(const_iterator hint, _Args&&... args):就地构建并插入元素,并指定插入位置。与insert一样,最坏情况下会发生重排序。在哈希索引的前提下是无序的,因此该插入操作等效于insert。
(4) 删除元素
-
void clear():删除std::unordered_multiset中的所有元素,使容器变为空。std::unordered_multiset<int> us = {1, 2, 3, 4}; us.clear(); // 删除所有元素,us 变为空 {} -
void erase(iterator pos):删除指定位置pos处的元素。std::unordered_multiset<int> us = {1, 2, 3, 4}; us.erase(std::next(us.begin(), 2)); // 删除索引为2的元素,us 变为 {1, 2, 4} -
size_t erase(const key_type& key):删除指定键的元素并返回删除的数量,注意一个键可能有多个副本,因此返回的数量可能大于 1。std::unordered_multiset<int> us = {1, 2, 2, 3, 4}; us.erase(2); // 删除所有值为 2 的元素,us 变为 {1, 3, 4} -
erase(first, last):删除指定范围的元素。
(5) 查找函数
-
iterator find(const T& value):查找某元素在unordered_multiset中的位置,返回一个指向该元素的迭代器。如果找不到会返回end()。std::unordered_multiset<int> us = {1, 2, 3, 4}; auto it = us.find(1); // 返回指向键1的迭代器 if (it != us.end()) { std::cout << *it << std::endl; // 输出: 1 } -
std::pair<const_iterator, const_iterator> equal_range(const key_type& __x) const:返回一个迭代器对,表示所有具有等于x的键的元素的范围。由于unordered_multiset允许重复元素,这个范围包含所有相同的键。 -
size_t count(const T& value):返回指定元素在unordered_multiset中出现的次数。对于unordered_multiset,返回的次数可能大于 1,因为允许重复元素。 -
bool contains(const T& value) const(C++20):检查是否包含该元素。
(6) 遍历函数
-
iterator begin():返回指向std::unordered_multiset第一个元素的迭代器。 -
iterator end():返回指向std::unordered_multiset最后一个元素之后位置的迭代器。 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::unordered_multiset中的每个元素。std::unordered_multiset<int> us = {1, 2, 2, 3}; for (const int& num : us) { std::cout << num << " "; // 输出: 1 2 2 3 } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::unordered_multiset。std::unordered_multiset<int> us = {1, 2, 2, 3}; for (auto it = us.begin(); it != us.end(); ++it) { std::cout << *it << " "; // 输出: 1 2 2 3 }
(7) 哈希相关函数
-
size_type max_bucket_count() const noexcept:返回unordered_multiset中可用的最大哈希桶数量。 -
float max_load_factor() const noexcept:返回或设置哈希表的最大负载因子。 -
void max_load_factor(float __z):设置最大负载因子。 -
size_t bucket_count() const:返回当前unordered_multiset的桶数量。 -
size_t bucket_size(size_t n) const:返回指定桶的元素数量。 -
size_t bucket(const key_type& key) const:返回给定键所在的桶的索引。 -
float load_factor() const:返回当前哈希表的负载因子。 -
void rehash(size_t n):用来调整哈希表中的桶数量。
(8) 其他函数
-
swap(unordered_multiset& other):交换当前unordered_multiset和另一个unordered_multiset的内容。 -
std::allocator<T> get_allocator() const:返回容器所使用的分配器(allocator)。 -
void merge(unordered_multiset& other):合并另一个unordered_multiset中不重复的元素。 -
key_equal_type key_eq() const:返回一个比较器,用于判断两个键是否相等。
std::unordered_map
std::unordered_map 是 C++ 标准库中的一个无序关联容器,用于存储键值对(key-value)。与 std::map 不同的是,unordered_map 内部使用 哈希表 来组织元素,因此元素的顺序不固定,完全由哈希函数决定。
它能在平均 O(1) 时间内完成查找、插入和删除操作(最坏情况下退化为 O(n))。
unordered_map 的定义如下:
template<
class Key,
class T,
class Hash = std::hash<Key>,
class KeyEqual = std::equal_to<Key>,
class Allocator = std::allocator<std::pair<const Key, T>>
> class unordered_map; // since C++11
namespace pmr {
template<
class Key,
class T,
class Hash = std::hash<Key>,
class KeyEqual = std::equal_to<Key>
> using unordered_map =
std::unordered_map<Key, T, Hash, KeyEqual,
std::pmr::polymorphic_allocator<std::pair<const Key, T>>>;
} // since C++17
- Key:键的类型(必须支持哈希和比较相等操作)。
- T:映射值的类型。
- Hash:哈希函数对象,默认为
std::hash<Key>。 - Pred:相等比较函数对象,默认为
std::equal_to<Key>。 - Alloc:分配器类型,默认为
std::allocator<std::pair<const Key, T>>。
1. 引入
#include <unordered_map>
2. 存储方式
std::map使用 红黑树,有序,查找/插入/删除是 O(log n)。std::unordered_map使用 哈希表,无序,查找/插入/删除平均是 O(1),但在哈希冲突严重时,最坏情况可能退化为 O(n)。
每个键值对通过哈希函数映射到某个 桶(bucket) 中,冲突时使用链表或开链方式解决。 键必须唯一,如果插入相同的键,新值会覆盖旧值。
3. 方法
(1) 构造方法
-
unordered_map(): 默认构造函数std::unordered_map<int, std::string> m; std::unordered_map<int, std::string> m = {{1, "one"}, {2, "two"}}; -
unordered_map(const unordered_map&): 复制构造函数std::unordered_map<int, std::string> m1 = {{1, "one"}, {2, "two"}}; std::unordered_map<int, std::string> m2(m1); -
unordered_map(begin, end): 使用另一个容器或迭代器的区间中的元素初始化std::vector<std::pair<int, std::string>> vec = {{1, "one"}, {2, "two"}}; std::unordered_map<int, std::string> m(vec.begin(), vec.end()); -
std::unordered_map<key, T, Compare>: 使用自定义的比较器来进行键的排序struct MyHash { size_t operator()(int x) const { return x % 10; } }; std::unordered_map<int, std::string, MyHash> m;
(2) 大小函数
size_t size() const: 返回元素个数bool empty() const: 是否为空size_t max_size() const: 返回最大可允许元素数量void reserve(size_type __n): 预分配桶的数量,减少扩容开销
(3) 增加函数
-
std::pair<iterator, bool> insert(const value_type& __x): 插入一个键值对,返回保存指向添加键值对位置的迭代器和是否添加成功的标志的pairm.insert({1, "one"}); m.insert(std::make_pair(2, "two")); -
std::pair<iterator, bool> emplace(Key&& key, T&& obj): 如果键 key 不存在,就直接插入一个新元素。m.emplace(3, "three"); -
std::pair<iterator, bool> try_emplace(const Key& key, Args&&... args): 只有在 key 不存在时,才插入新元素(避免额外的拷贝)。m.try_emplace(1, "one"); -
std::pair<iterator, bool> insert_or_assign(const Key& key, T&& obj)m.insert_or_assign(1, "uno"); -
operator[](const Key& key): 如果 key 存在,返回对应的值;如果 key 不存在,插入一个新的键值对,值为默认构造的类型 T。m[4] = "four"; // 插入 m[1] = "uno"; // 更新 -
at(const Key& key):如果 key 已存在,更新对应的值;如果 key 不存在,插入一个新的键值对。std::cout << m.at(1);
(4) 删除元素
void clear():清空所有元素iterator erase(iterator pos):删除迭代器位置的元素iterator erase(const key_type& key):删除指定键,返回删除个数iterator erase(first, last):删除范围
(5) 查找函数
-
iterator find(const key_type& key): 查找某个键在unordered_map中的位置,返回一个指向该元素的迭代器,如果找不到会返回end()。std::unordered_map<int, std::string> m = {{1, "one"}, {2, "two"}}; auto it = m.find(1); // 返回指向键1的迭代器 std::cout << it->second; // 输出 "one" -
std::pair<const_iterator, const_iterator> equal_range(const key_type& key) const: 返回一对迭代器,表示所有具有等于键key的元素的范围。在std::unordered_map中,由于键是唯一的,因此这个范围包含一个元素。std::unordered_map<int, std::string> m = {{1, "one"}, {2, "two"}}; auto range = m.equal_range(1); std::cout << range.first->second << std::endl; // 输出 "one" -
size_t count(const key_type& key): 返回容器中是否包含指定键,unordered_map中每个键最多出现一次,因此返回值要么是0,要么是1。 -
iterator lower_bound(const key_type& key): 返回第一个不小于key的迭代器。 -
iterator upper_bound(const key_type& key): 返回第一个大于key的迭代器。 -
bool contains(const key_type& key) const(C++20):检查是否包含该键。
(6) 遍历函数
-
iterator begin(): 返回指向std::unordered_map第一个元素的迭代器。std::unordered_map<int, std::string> m = {{1, "one"}, {2, "two"}}; auto it = m.begin(); // 返回指向第一个元素的迭代器 std::cout << it->first << ": " << it->second << std::endl; // 输出 1: one -
iterator end(): 返回指向std::unordered_map最后一个元素之后位置的迭代器。 -
reverse_iterator rbegin(): 返回指向std::unordered_map最后一个元素的反向迭代器。 -
reverse_iterator rend(): 返回指向std::unordered_map第一个元素之前位置的反向迭代器。 -
使用基于范围的
for循环(C++11 及以上): 直接遍历std::unordered_map中的每个元素。std::unordered_map<int, std::string> m = {{1, "one"}, {2, "two"}}; for (const auto& pair : m) { std::cout << pair.first << ": " << pair.second << " "; // 输出: 1: one 2: two } -
使用传统的
for循环(基于迭代器): 使用迭代器来遍历std::unordered_map。std::unordered_map<int, std::string> m = {{1, "one"}, {2, "two"}}; for (auto it = m.begin(); it != m.end(); ++it) { std::cout << it->first << ": " << it->second << " "; // 输出: 1: one 2: two }
(7) 哈希相关函数
-
size_type max_bucket_count() const noexcept: max_bucket_count() 返回 std::unordered_set 中可用的最大哈希桶数量。哈希表是由一组桶(bucket)构成的,每个桶存储一定数量的元素。当元素的数量增加时,std::unordered_set 可能会自动扩展桶的数量,以减少哈希冲突。max_bucket_count() 允许你查看哈希表最多能支持多少个桶。 -
float max_load_factor() const noexcept: max_load_factor() 返回或设置哈希表的最大负载因子。负载因子是哈希表中元素的数量与桶数量之比。它决定了哈希表何时扩展——当负载因子超过最大值时,std::unordered_set 会增加桶的数量,减少哈希冲突,从而提高性能。- 负载因子是指:负载因子 = 元素个数 / 桶的数量
- 最大负载因子是哈希表在扩展之前允许的最大负载因子。负载因子越高,意味着桶的数量相对较少,这可能导致更多的哈希冲突,降低查找效率。
-
void max_load_factor(float __z): 设置最大负载因子。 -
size_t bucket_count() const: 返回当前 unordered_set 的桶数量。哈希表的桶数量与容器中元素的数量以及负载因子相关,通常哈希表会在元素数量增加时自动扩展桶的数量。 -
size_t bucket_size(size_t n) const: 返回指定桶的元素数量。哈希表中的每个桶可能包含多个元素,特别是当哈希冲突发生时,多个元素可能会存储在同一个桶中。 -
size_t bucket(const key_type& key) const: 返回给定键所在的桶的索引。这个函数对于理解元素是如何分布在哈希表中的非常有用。 -
float load_factor() const: 返回当前哈希表的负载因子,即元素的数量与桶数量的比值。负载因子越高,哈希冲突的可能性越大,因此负载因子对于性能非常重要。 -
void rehash(size_t n): 用来调整哈希表中的桶数量。它的作用是根据新的元素数量来调整桶的数量,避免哈希冲突过多。rehash() 会导致重新分配桶并重新哈希元素。n 是希望容器能够容纳的元素数量。
(8) 其他函数
-
swap(unordered_map& other): 交换当前std::unordered_map和另一个std::unordered_map的内容。 -
std::allocator<T> get_allocator() const: 返回容器所使用的分配器(allocator)。 -
void merge(unordered_map& other):merge会将另一个 unordered_map 中不与当前容器重复的元素“移动”过来。std::unordered_map<int> s1 = {1, 3, 5}; std::unordered_map<int> s2 = {2, 4, 6}; s1.merge(s2); // s1 合并 s2 后,变为 {1, 2, 3, 4, 5, 6} -
key_equal_type key_eq() const: 返回一个比较器,用于判断两个键是否相等std::unordered_map<Person, PersonHash> people; Person p1 = {"Alice", 30}; Person p2 = {"Bob", 25}; people.insert(p1); people.insert(p2); // 使用 key_eq 获取比较函数 auto eq = people.key_eq(); // 使用 key_eq 函数比较两个键 std::cout << "Are p1 and p2 equal? " << std::boolalpha << eq(p1, p2) << std::endl; -
hasher hash_function() const:返回当前使用的哈希函数对象
std::unordered_multimap
std::unordered_multimap 是 C++ 标准库中的一个无序关联容器,存储一组键值对(key-value)。与 std::unordered_map 类似,std::unordered_multimap 也使用 哈希表 来组织元素,但不同的是,它允许 键重复。
由于其底层实现为哈希表,元素顺序并不固定,完全由哈希函数决定。平均查找、插入、删除的时间复杂度为 O(1),最坏情况下会退化为 O(n)。
std::unordered_multimap 的定义如下:
template<
class Key,
class T,
class Hash = std::hash<Key>,
class KeyEqual = std::equal_to<Key>,
class Allocator = std::allocator<std::pair<const Key, T>>
> class unordered_multimap; // since C++11
namespace pmr {
template<
class Key,
class T,
class Hash = std::hash<Key>,
class Pred = std::equal_to<Key>
> using unordered_multimap =
std::unordered_multimap<Key, T, Hash, Pred,
std::pmr::polymorphic_allocator<std::pair<const Key, T>>>;
} // since C++17
- Key:键的类型(必须支持哈希和比较相等操作)。
- T:映射值的类型。
- Hash:哈希函数对象,默认为
std::hash<Key>。 - Pred:相等比较函数对象,默认为
std::equal_to<Key>。 - Alloc:分配器类型,默认为
std::allocator<std::pair<const Key, T>>。
1. 引入
#include <unordered_map>
2. 存储方式
std::unordered_multimap底层使用 哈希表 来存储元素,每个元素(键值对)会根据其 键 被哈希到一个桶中。- 键的唯一性:与
std::unordered_map不同,std::unordered_multimap允许 多个相同的键 存在,每个键对应多个值。相同键的元素会存储在同一个桶中。 - 插入与查找:所有操作(查找、插入、删除)平均时间复杂度为 O(1),但在哈希冲突严重时,最坏情况可能退化为 O(n)。
3. 方法
(1) 构造方法
-
unordered_multimap(): 默认构造函数std::unordered_multimap<int, std::string> m; std::unordered_multimap<int, std::string> m = {{1, "one"}, {2, "two"}}; -
unordered_multimap(const unordered_multimap&): 复制构造函数std::unordered_multimap<int, std::string> m1 = {{1, "one"}, {2, "two"}}; std::unordered_multimap<int, std::string> m2(m1); -
unordered_multimap(begin, end): 使用另一个容器或迭代器的区间中的元素初始化std::vector<std::pair<int, std::string>> vec = {{1, "one"}, {2, "two"}}; std::unordered_multimap<int, std::string> m(vec.begin(), vec.end()); -
std::unordered_multimap<key, T, Hash>: 使用自定义的哈希函数struct MyHash { size_t operator()(int x) const { return x % 10; } }; std::unordered_multimap<int, std::string, MyHash> m;
(2) 大小函数
size_t size() const: 返回元素个数bool empty() const: 是否为空size_t max_size() const: 返回最大可允许元素数量void reserve(size_type __n): 预分配桶的数量,减少扩容开销
(3) 插入函数
-
std::pair<iterator, bool> insert(const value_type& __x): 插入一个键值对,返回保存指向添加键值对位置的迭代器和是否添加成功的标志的pairm.insert({1, "one"}); m.insert(std::make_pair(2, "two")); -
std::pair<iterator, bool> emplace(Key&& key, T&& obj): 如果键key不存在,就直接插入一个新元素。m.emplace(3, "three"); -
std::pair<iterator, bool> try_emplace(const Key& key, Args&&... args): 只有在key不存在时,才插入新元素(避免额外的拷贝)。m.try_emplace(1, "one"); -
std::pair<iterator, bool> insert_or_assign(const Key& key, T&& obj): 如果key存在,更新对应的值;如果key不存在,插入一个新的键值对。m.insert_or_assign(1, "uno"); -
operator[](const Key& key): 如果key存在,返回对应的值;如果key不存在,插入一个新的键值对,值为默认构造的类型 T。m[4] = "four"; // 插入 m[1] = "uno"; // 更新 -
at(const Key& key): 如果key已存在,返回对应的值;如果key不存在,抛出std::out_of_range。std::cout << m.at(1); // 输出值
(4) 删除元素
-
void clear(): 清空所有元素 -
iterator erase(iterator pos): 删除迭代器位置的元素 -
size_t erase(const key_type& key): 删除指定键的所有元素,返回删除的数量m.erase(1); // 删除键为1的所有元素 -
iterator erase(first, last): 删除指定迭代器区间的元素
(5) 查找函数
-
iterator find(const key_type& key): 查找指定键的第一个匹配元素,返回一个指向该元素的迭代器,如果找不到返回end()。auto it = m.find(2); if (it != m.end()) { std::cout << it->second << std::endl; } -
std::pair<const_iterator, const_iterator> equal_range(const key_type& key) const: 返回一对迭代器,表示所有具有等于键key的元素的范围。auto range = m.equal_range(2); for (auto it = range.first; it != range.second; ++it) { std::cout << it->second << std::endl; } -
size_t count(const key_type& key): 返回容器中指定键的元素数量(对于unordered_multimap,返回值可能大于 1)。size_t n = m.count(2); // 返回键2的数量 -
bool contains(const key_type& key) const(C++20):检查是否包含指定键。if (m.contains(2)) { std::cout << "Key 2 exists!" << std::endl; }
(6) 遍历函数
-
iterator begin(): 返回指向容器第一个元素的迭代器auto it = m.begin(); std::cout << it->first << " -> " << it->second << std::endl; -
iterator end(): 返回指向容器最后一个元素之后位置的迭代器 -
reverse_iterator rbegin(): 返回指向容器最后一个元素的反向迭代器 -
reverse_iterator rend(): 返回指向容器第一个元素之前位置的反向迭代器 -
使用基于范围的
for循环:for (const auto& [key, value] : m) { std::cout << key << " -> " << value << std::endl; } -
使用传统的迭代器遍历:
for (auto it = m.begin(); it != m.end(); ++it) { std::cout << it->first << " -> " << it->second << std::endl; }
(7) 哈希相关函数
size_t bucket_count() const: 返回桶的数量size_t bucket_size(size_t n) const: 返回指定桶的元素数量size_t bucket(const key_type& key) const: 返回给定键所落入的桶的索引float load_factor() const: 返回当前负载因子(元素数 / 桶数)void rehash(size_t n): 强制调整桶的数量,避免哈希冲突void reserve(size_t n): 确保容器至少可以容纳n个元素
(8) 其他函数
swap(unordered_multimap& other): 交换当前unordered_multimap和另一个容器的内容std::allocator<T> get_allocator() const: 返回容器的分配器void merge(unordered_multimap& other): 将另一个unordered_multimap中不重复的元素“移动”过来(C++17)hasher hash_function() const: 返回当前使用的哈希函数key_equal_type key_eq() const: 返回用于判断键是否相等的谓词
std::stack
std::stack (栈) 是一个容器适配器 (Container Adapter),它不是一个独立的容器,而是对现有顺序容器(如 std::deque、std::list 或 std::vector)的封装,限制了对其元素的访问,使其遵循 LIFO (Last-In, First-Out) 的数据结构规则。
想象一下堆叠起来的盘子,你只能在顶部放一个新盘子,也只能从顶部取走盘子。在 std::stack 中,这个“顶部”对应于底层容器的尾部。
1. 引入
#include <stack>
2. 存储方式
std::stack 通过封装一个底层容器 (Underlying Container) 来存储数据。它只暴露了符合栈(LIFO)操作的方法,例如:
- 推入 (Push): 插入元素到栈顶(对应底层容器的
push_back())。 - 弹出 (Pop): 移除栈顶元素(对应底层容器的
pop_back())。 - 查看 (Top): 访问栈顶元素(对应底层容器的
back())。
默认底层容器:如果创建 std::stack 时不指定底层容器,它默认使用 std::deque<T>。
可选底层容器:任何满足 LIFO 访问要求的顺序容器都可以作为底层容器,包括:
std::deque<T>(默认):通常是栈操作的最佳选择,因为它在两端操作(push_front/pop_front和push_back/pop_back)都很快。std::vector<T>:如果内存是连续的且不需要在底部(前端)操作,这也是一个高效的选择。std::list<T>:如果需要存储不支持连续存储或拷贝构造/赋值操作的复杂对象,可以使用它。
声明带指定底层容器的栈:
// 使用 vector 作为底层容器
std::stack<int, std::vector<int>> stack_vec;
// 使用 list 作为底层容器
std::stack<int, std::list<int>> stack_list;
3. 方法
由于 std::stack 是一个适配器,它不提供任何迭代器(如 begin()/end()),也不支持随机访问(如 operator[]),只能通过 LIFO 接口进行操作。
(1) 构造方法
-
stack(): 创建一个空的std::stack。std::stack<int> s; // 使用默认的 std::deque 作为底层容器 -
stack(const Container& cont): 使用一个已存在的底层容器副本进行初始化。std::deque<int> d = {1, 2, 3}; std::stack<int> s(d); // s 变为 {1, 2, 3} (1 是底,3 是顶) -
stack(const stack&): 复制构造函数。
(2) 大小函数
-
size_t size() const: 返回std::stack中元素的个数。std::stack<int> s = {1, 2, 3}; std::cout << s.size(); // 输出 3 -
bool empty() const: 检查std::stack是否为空。若为空返回true。std::stack<int> s; if (s.empty()) { std::cout << "Stack is empty." << std::endl; }
(3) 元素访问函数
std::stack 只允许访问顶部元素。
-
reference top(): 返回栈顶元素(即最近push进去的元素)的引用。调用前必须确保栈不为空。std::stack<int> s; s.push(10); s.push(20); std::cout << s.top(); // 输出 20
(4) 增加函数 (推入)
-
void push(const T& value): 将元素value复制或移动到栈的顶部。std::stack<int> s; s.push(10); // 栈变为 {10} s.push(20); // 栈变为 {10, 20} (20 在顶) -
template<class... Args> void emplace(Args&&... args): 在栈的顶部就地构造一个元素,无需拷贝或移动。这是推荐的推入方式,尤其是对于复杂对象。std::stack<std::pair<int, int>> s; s.emplace(1, 2); // 直接在栈顶构造 pair {1, 2}
(5) 删除函数 (弹出)
-
void pop(): 移除栈顶的元素。此函数不返回被移除的元素。调用前必须确保栈不为空。std::stack<int> s; s.push(10); s.push(20); // 栈为 {10, 20} s.pop(); // 移除 20,栈变为 {10} // 注意:要获取并移除元素,需要先调用 top(),再调用 pop()
(6) 其他函数
-
void swap(stack& other): 交换当前std::stack和另一个std::stack的内容。底层容器的内容也会随之交换。std::stack<int> s1; s1.push(1); std::stack<int> s2; s2.push(2); s1.swap(s2); // s1 变为 {2},s2 变为 {1}
std::queue
std::queue (队列) 和 std::stack 类似,也是 C++ 标准库中的一个容器适配器 (Container Adapter)。它将底层容器的功能限制在一个特定的访问模式上,以实现 FIFO (First-In, First-Out,先进先出) 的数据结构。
想象一下排队等候的队伍:第一个进入队伍的人也是第一个离开队伍的人。在 std::queue 中,元素从尾部(Back)进入,从头部(Front)离开。
1. 引入
#include <queue>
2. 存储方式与底层容器
std::queue 通过封装一个底层容器 (Underlying Container) 来存储数据。它只暴露了符合队列(FIFO)操作的方法:
- 入队 (Push): 插入元素到队列的尾部(对应底层容器的
push_back())。 - 出队 (Pop): 移除队列的头部元素(对应底层容器的
pop_front())。 - 查看 (Front/Back): 访问头部/尾部元素。
默认底层容器:如果创建 std::queue 时不指定,它默认使用 std::deque<T>。
可选底层容器:底层容器必须同时支持在头部(前端)移除元素(pop_front())和在尾部(后端)添加元素(push_back())。
满足这些要求的标准容器有:
std::deque<T>(默认):通常是最佳选择,因为它在两端操作(push_back和pop_front)都非常高效且 \(O(1)\)$。std::list<T>:如果元素类型不可复制,或者需要频繁地插入/删除,这也是一个合适的选择,操作复杂度也是 \(O(1)\)$。
注意:std::vector<T> 不能作为 std::queue 的底层容器,因为它不支持高效地从头部移除元素(pop_front),std::vector 的 erase(begin()) 操作是 \(O(N)\) 的。
声明带指定底层容器的队列:
// 使用 list 作为底层容器
std::queue<int, std::list<int>> queue_list;
// 使用默认的 deque 作为底层容器
std::queue<int> queue_deque;
3. 常用方法 (操作)
std::queue 同样不提供迭代器,因此不能像 std::vector 或 std::list 那样遍历所有元素。
(1) 构造方法
-
queue(): 创建一个空的std::queue。std::queue<int> q; -
queue(const Container& cont): 使用一个已存在的底层容器副本进行初始化。std::deque<int> d = {10, 20, 30}; // 10 是 front, 30 是 back std::queue<int> q(d); // q 变为 {10, 20, 30}
(2) 大小函数
size_t size() const: 返回std::queue中元素的个数。bool empty() const: 检查std::queue是否为空。若为空返回true。
(3) 元素访问函数
std::queue 允许访问队列的头部和尾部元素。
-
reference front(): 返回队列头部元素(即最先进入队列的元素)的引用。调用前必须确保队列不为空。 -
reference back(): 返回队列尾部元素(即最近进入队列的元素)的引用。调用前必须确保队列不为空。std::queue<int> q; q.push(10); // q: {10} q.push(20); // q: {10, 20} std::cout << q.front(); // 输出 10 std::cout << q.back(); // 输出 20
(4) 增加函数 (入队)
-
void push(const T& value): 将元素value复制或移动到队列的尾部。 -
template<class... Args> void emplace(Args&&... args): 在队列的尾部就地构造一个元素。这是首选的入队方式。std::queue<std::string> q; q.push("Apple"); // 尾部插入 q.emplace("Banana"); // 尾部就地构造 -
template<container-compatible-range R> void push_range(R&& rg)(C++23): 将一个范围(range)内的所有元素插入到队列的尾部。
(5) 删除函数 (出队)
-
void pop(): 移除队列头部的元素。此函数不返回被移除的元素。调用前必须确保队列不为空。std::queue<int> q; q.push(10); q.push(20); // q: {10, 20} // 要取出元素 10,需要两步操作: int oldest = q.front(); // 访问头部 (10) q.pop(); // 移除头部 (10),q 变为 {20}
(6) 交换
void swap(queue& other): 交换两个std::queue的内容。
4. 示例 (FIFO 原则)
通过一个简单的例子来演示 FIFO 的工作原理:
#include <iostream>
#include <queue>
int main() {
std::queue<int> my_queue;
// 入队 (Enroll)
my_queue.push(100); // 100 先进
my_queue.push(200);
my_queue.push(300); // 300 后进
std::cout << "队列头部 (Front/First-in): " << my_queue.front() << std::endl; // 输出 100
std::cout << "队列尾部 (Back/Last-in): " << my_queue.back() << std::endl; // 输出 300
std::cout << "--------------------" << std::endl;
// 出队 (Serve)
while (!my_queue.empty()) {
std::cout << "服务并移除: " << my_queue.front() << std::endl;
my_queue.pop();
}
// 元素的移除顺序为 100, 200, 300,严格遵循先进先出。
return 0;
}
输出:
队列头部 (Front/First-in): 100
队列尾部 (Back/Last-in): 300
--------------------
服务并移除: 100
服务并移除: 200
服务并移除: 300
std::priority_queue
std::priority_queue 是一个 容器适配器 (Container Adapter),它基于堆 (heap) 实现,能够在对数时间内插入元素,并且始终允许在常数时间内访问到“优先级最高”的元素。
默认情况下,它是一个 最大堆 (Max Heap),即 top() 返回容器中的最大元素。通过自定义比较器,可以变成最小堆或实现任意的优先级规则。
典型应用场景:调度系统、Dijkstra 最短路、Huffman 编码、任务队列等。
1. 引入
#include <queue>
2. 存储方式
std::priority_queue 内部使用一个 底层容器 (Underlying Container) 和一个 比较器 (Compare) 来实现堆的功能。
模板定义
template<
class T,
class Container = std::vector<T>,
class Compare = std::less<typename Container::value_type>
> class priority_queue;
- T:存储的元素类型。
- Container:底层容器,默认为
std::vector<T>。必须支持随机访问迭代器和push_back()/pop_back()。 - Compare:比较器,默认为
std::less<T>(大顶堆)。若改为std::greater<T>,则变为小顶堆。
默认实现
- 大顶堆:
Compare = std::less<T> - 小顶堆:
Compare = std::greater<T>
示例:指定底层容器和比较器
// 默认:大顶堆
std::priority_queue<int> pq1;
// 小顶堆
std::priority_queue<int, std::vector<int>, std::greater<int>> pq2;
// 使用 deque 作为底层容器
std::priority_queue<int, std::deque<int>> pq3;
3. 方法
std::priority_queue 同样不提供迭代器(不能遍历),只能通过专门的接口进行操作。
(1) 构造方法
-
priority_queue():构造一个空的优先队列。std::priority_queue<int> pq; // 空的大顶堆 -
priority_queue(const Compare& comp, const Container& cont):用已有容器构造。std::vector<int> vec = {1, 5, 3}; std::priority_queue<int> pq(std::less<int>(), vec); -
复制构造 / 移动构造。
(2) 大小函数
-
size_t size() const:返回元素个数。std::priority_queue<int> pq; pq.push(10); pq.push(20); std::cout << pq.size(); // 输出 2 -
bool empty() const:检查是否为空。if (pq.empty()) std::cout << "Empty\n";
(3) 元素访问函数
-
const_reference top() const:返回堆顶元素(优先级最高的元素),复杂度 O(1)。 调用前必须确保不为空。pq.push(10); pq.push(30); pq.push(20); std::cout << pq.top(); // 输出 30(大顶堆)
(4) 增加函数 (插入)
-
void push(const T& value):插入元素到队列,自动保持堆序。复杂度 O(log n)。pq.push(15); pq.push(40); -
template<class... Args> void emplace(Args&&... args):原地构造元素并插入。std::priority_queue<std::pair<int,int>> pq; pq.emplace(1, 2); // 构造 pair(1,2)
(5) 删除函数 (移除)
-
void pop():移除堆顶元素,复杂度 O(log n)。调用前必须非空。pq.push(10); pq.push(20); pq.pop(); // 移除 20(大顶堆)
(6) 其他函数
-
void swap(priority_queue& other):交换两个队列的内容。std::priority_queue<int> pq1, pq2; pq1.push(1); pq2.push(2); pq1.swap(pq2);
4. 示例
大顶堆
#include <iostream>
#include <queue>
using namespace std;
int main() {
priority_queue<int> pq;
pq.push(10);
pq.push(5);
pq.push(20);
cout << pq.top() << endl; // 20
pq.pop();
cout << pq.top() << endl; // 10
}
小顶堆
priority_queue<int, vector<int>, greater<int>> min_heap;
min_heap.push(10);
min_heap.push(5);
min_heap.push(20);
cout << min_heap.top() << endl; // 5
自定义比较器(例如:比较 pair 的第二个元素)
struct Compare {
bool operator()(const pair<int,int>& a, const pair<int,int>& b) {
return a.second > b.second; // 小顶堆,按第二个元素比较
}
};
priority_queue<pair<int,int>, vector<pair<int,int>>, Compare> pq;
pq.push({1, 10});
pq.push({2, 5});
cout << pq.top().first << endl; // 输出 2,因为 (2,5) 的 second 更小
Iterators
迭代器(Iterator) 是 STL 的核心组件之一。它是对指针的泛化,提供了一套统一的接口来遍历不同的数据结构——无论底层是连续数组、链表还是红黑树,算法都通过迭代器访问元素,无需关心容器的内部实现。
正因如此,STL 的算法才能独立于容器存在:std::sort 不关心你传的是 vector 还是 deque,它只要求迭代器满足随机访问的要求。
一、迭代器与指针的关系
迭代器的语义是指针语义的推广。指针本身就是一个合法的迭代器:
int arr[] = {1, 2, 3, 4, 5};
// 用指针作为迭代器
std::sort(arr, arr + 5);
auto it = std::find(arr, arr + 5, 3);
标准库保证:任何接受迭代器的函数模板,也能接受普通指针。
迭代器的基本操作包括:
| 操作 | 含义 |
|---|---|
*it | 解引用,访问所指元素 |
++it / it++ | 移动到下一个元素 |
--it / it-- | 移动到上一个元素(部分迭代器支持) |
it1 == it2 | 判断是否指向同一位置 |
it + n / it - n | 移动 n 个位置(部分迭代器支持) |
it1 - it2 | 计算距离(部分迭代器支持) |
二、迭代器分类
C++17 起共有六类迭代器,按能力从弱到强排列。每一类都包含前一类的能力:
| 分类 | 读写能力 | 可递增 | 可递减 | 随机访问 | 连续存储 | 典型例子 |
|---|---|---|---|---|---|---|
| 输入迭代器 (Input) | 只读,单遍 | ✓ | istream_iterator | |||
| 输出迭代器 (Output) | 只写,单遍 | ✓ | ostream_iterator | |||
| 前向迭代器 (Forward) | 读写,多遍 | ✓ | forward_list | |||
| 双向迭代器 (Bidirectional) | 读写 | ✓ | ✓ | list、set、map | ||
| 随机访问迭代器 (Random Access) | 读写 | ✓ | ✓ | ✓ | deque | |
| 连续迭代器 (Contiguous) | 读写 | ✓ | ✓ | ✓ | ✓ | vector、array、string |
几点说明:
- 输入与输出迭代器是单遍的(single-pass):只能遍历一次,
it1 == it2不保证有意义。它们主要用于流操作。 - 前向迭代器支持多遍遍历,可以保存副本后分别遍历。
- 连续迭代器是 C++17 正式加入的分类(此前
vector、string、array的迭代器在实际使用中被当作独立类别)。它保证元素在内存中连续,因此&*it得到的地址可以像数组指针一样使用。
Tip
为什么分类重要?
每个算法都要求特定类别的迭代器。例如
std::sort要求随机访问迭代器,因此不能用于std::list(list只提供双向迭代器)。这也是std::list自带成员函数sort的原因。选错容器时,编译器报错通常就是「迭代器类别不满足要求」。
三、迭代器与容器对照
| 容器 | 迭代器类别 | 是否支持 --it | 是否支持 it + n |
|---|---|---|---|
vector | 连续 | ✓ | ✓ |
array | 连续 | ✓ | ✓ |
string | 连续 | ✓ | ✓ |
deque | 随机访问 | ✓ | ✓ |
list | 双向 | ✓ | |
forward_list | 前向 | ||
set / map | 双向 | ✓ | |
unordered_set / unordered_map | 前向 |
四、迭代器适配器
适配器在已有迭代器基础上改变行为,定义在 <iterator> 中。
1. 插入迭代器
插入迭代器把「赋值」变成「插入」,常用于向空容器写入:
| 适配器 | 创建函数 | 插入位置 | 适用容器 |
|---|---|---|---|
back_insert_iterator | back_inserter(c) | 尾部 | vector、deque、list、string |
front_insert_iterator | front_inserter(c) | 头部 | deque、list、forward_list |
insert_iterator | inserter(c, it) | 指定位置之前 | 所有容器 |
std::vector<int> src{1, 2, 3};
std::vector<int> dst;
// 不用预先分配空间
std::copy(src.begin(), src.end(), std::back_inserter(dst));
// dst = {1,2,3}
std::list<int> lst;
std::copy(src.begin(), src.end(), std::front_inserter(lst));
// lst = {3,2,1}(每次插入头部,顺序反转)
std::vector<int> v{1, 5};
std::copy(src.begin(), src.end(), std::inserter(v, v.begin() + 1));
// v = {1,1,2,3,5}
2. 反向迭代器
reverse_iterator 把递增变成递减,用于从后往前遍历:
std::vector<int> v{1, 2, 3, 4, 5};
for (auto it = v.rbegin(); it != v.rend(); ++it)
std::cout << *it << ' '; // 输出: 5 4 3 2 1
rbegin() 对应最后一个元素,rend() 对应第一个元素之前的位置。注意 *rbegin() 是最后一个元素,而解引用 rend() 是未定义行为。
3. 移动迭代器
move_iterator 解引用时返回右值引用,使算法执行移动而非拷贝:
std::vector<std::string> src{"hello", "world"};
std::vector<std::string> dst;
std::copy(std::make_move_iterator(src.begin()),
std::make_move_iterator(src.end()),
std::back_inserter(dst));
// dst 获得元素,src 中的字符串被移空
4. 流迭代器
流迭代器把输入输出流接入算法:
#include <iterator>
// 从 cin 读取整数,直到输入失败
std::vector<int> v;
std::copy(std::istream_iterator<int>(std::cin),
std::istream_iterator<int>(),
std::back_inserter(v));
// 输出到 cout,用空格分隔
std::copy(v.begin(), v.end(),
std::ostream_iterator<int>(std::cout, " "));
五、迭代器操作函数
<iterator> 提供了几个通用的迭代器操作,它们会根据迭代器类别自动选择高效实现:
| 函数 | 作用 | 复杂度 |
|---|---|---|
std::advance(it, n) | 将 it 前进 n 步(n 可为负) | 随机访问为 \(O(1)\),否则 \(O(n)\) |
std::next(it, n) | 返回前进 n 步后的迭代器(默认 n=1) | 同上 |
std::prev(it, n) | 返回后退 n 步后的迭代器(默认 n=1) | 双向以上 |
std::distance(first, last) | 返回两个迭代器之间的距离 | 随机访问为 \(O(1)\),否则 \(O(n)\) |
std::list<int> lst{1, 2, 3, 4, 5};
auto it = lst.begin();
std::advance(it, 3); // it 指向 4
auto it2 = std::next(lst.begin(), 2); // it2 指向 3
auto n = std::distance(lst.begin(), it); // 3
Tip
优先用
std::next而非it + n
it + n只对随机访问迭代器有效,而std::next对所有前向以上迭代器都可用。在模板代码中应使用std::next。
六、迭代器失效
迭代器失效是指容器修改后,原有的迭代器不再指向有效元素。这是使用 STL 时最容易出错的地方,规则因容器而异:
| 容器 | 插入 | 删除 |
|---|---|---|
vector | 扩容时全部失效;否则插入点之后的失效 | 删除点及之后全部失效 |
deque | 头尾插入仅迭代器失效;中间插入全部失效 | 头尾删除仅被删元素失效;中间删除全部失效 |
list / forward_list | 不失效 | 仅被删元素失效 |
set / map | 不失效 | 仅被删元素失效 |
unordered_* | rehash 时全部失效 | 仅被删元素失效 |
详细规则见各容器章节,例如 Vector · 迭代器失效。
七、C++20 的迭代器概念
C++20 用 Concepts 重新定义了迭代器体系,与 C++17 的「具名要求」相比,约束更精确、报错更清晰:
| C++17 分类 | C++20 概念 |
|---|---|
InputIterator | std::input_iterator |
OutputIterator | std::output_iterator |
ForwardIterator | std::forward_iterator |
BidirectionalIterator | std::bidirectional_iterator |
RandomAccessIterator | std::random_access_iterator |
ContiguousIterator | std::contiguous_iterator |
此外还有更基础的概念:indirectly_readable、indirectly_writable、weakly_incrementable、sentinel_for、sized_sentinel_for 等。
C++20 还引入了哨兵(sentinel)的概念:范围的起点和终点可以是不同类型,只要它们可以比较。这使 [begin, sentinel) 形式的范围更灵活。
关联类型也改用了新的别名模板:
std::iter_value_t<It> // 迭代器的值类型
std::iter_reference_t<It> // 解引用返回的引用类型
std::iter_difference_t<It> // 距离类型
八、注意事项
- 不要把
end()缓存起来跨修改使用。容器修改后end()可能失效,应在循环条件中实时获取。 - 删除元素时用
erase的返回值。erase返回下一个有效迭代器,直接++it是未定义行为。 - 反向迭代器的
base()有偏移。rit.base()指向的是rit所指元素的下一个位置。 std::distance对非随机访问迭代器是 \(O(n)\)。在大链表上频繁调用会成为性能瓶颈。auto&&与代理迭代器。vector<bool>的迭代器返回代理对象而非bool&,for (auto& x : vec)无法编译,详见 Vector。- 警惕悬垂迭代器。容器销毁或元素被删除后,指向它的迭代器立即失效。
九、相关章节
- Containers:各容器的迭代器类别与失效规则
- Algorithms:算法对迭代器的要求
- Vector:迭代器失效的典型案例
- List:双向迭代器与成员函数
sort
Ranges
C++20 引入的 Ranges 库(<ranges>)是对算法与迭代器库的扩展,它让操作序列的代码更可组合、更不易出错。
它的核心思想是:把「对序列的变换」抽象成一个个视图(view),视图之间可以用管道符 | 串联成流水线,且整个过程是惰性求值的——只有在真正遍历时才执行计算。
关于 Ranges 版本的算法(ranges::sort 等),见 Algorithms;关于迭代器分类与概念,见 Iterators。
一、为什么需要 Ranges
传统写法需要显式传递 begin() / end(),且中间结果往往要落到临时容器:
std::vector<int> v{1, 2, 3, 4, 5, 6};
// 传统写法:取出偶数并平方
std::vector<int> temp;
std::copy_if(v.begin(), v.end(), std::back_inserter(temp),
[](int x) { return x % 2 == 0; });
std::vector<int> result;
std::transform(temp.begin(), temp.end(), std::back_inserter(result),
[](int x) { return x * x; });
Ranges 写法把两步连成一条流水线,且不产生中间容器:
auto result = v | std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
for (int x : result)
std::cout << x << ' '; // 4 16 36
二、视图与惰性求值
视图(view) 是一个轻量的、不拥有数据的范围。它只记录「如何从底层序列计算元素」,而不实际存储结果。
std::vector<int> v{1, 2, 3, 4, 5};
auto view = v | std::views::transform([](int x) {
std::cout << "计算 " << x << '\n';
return x * x;
});
std::cout << "视图已创建\n";
// 此时没有任何输出——变换尚未执行
for (int x : view)
; // 此时才逐个计算
输出顺序说明了一切:
视图已创建
计算 1
计算 2
...
惰性求值带来两个好处:
- 不产生中间容器,节省内存与拷贝开销
- 可以表示无限序列,如
views::iota(0)配合views::take(10)
代价是:每次遍历都会重新计算,如果同一视图被多次遍历,计算会重复执行。
三、管道语法
| 运算符把左操作数(范围)传给右操作数(视图适配器):
// 以下两种写法等价
auto a = v | std::views::filter(pred);
auto b = std::views::filter(v, pred);
管道形式更符合阅读顺序,多个适配器串联时尤其清晰:
auto result = data
| std::views::filter([](int x) { return x > 0; })
| std::views::transform([](int x) { return x * 2; })
| std::views::take(5);
四、常用视图适配器
1. 筛选与变换
| 适配器 | 作用 | 版本 |
|---|---|---|
views::filter(pred) | 只保留满足谓词的元素 | C++20 |
views::transform(f) | 对每个元素应用函数 | C++20 |
views::take(n) | 取前 \( n \) 个元素 | C++20 |
views::take_while(pred) | 取开头满足谓词的连续元素 | C++20 |
views::drop(n) | 跳过前 \( n \) 个元素 | C++20 |
views::drop_while(pred) | 跳过开头满足谓词的连续元素 | C++20 |
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8};
// 取大于 3 的前 3 个
auto r = v | std::views::filter([](int x) { return x > 3; })
| std::views::take(3); // 4 5 6
2. 结构变换
| 适配器 | 作用 | 版本 |
|---|---|---|
views::reverse | 反向遍历 | C++20 |
views::join | 展平嵌套范围 | C++20 |
views::split(delim) | 按分隔符切分 | C++20 |
views::keys | 取 pair 的 first | C++20 |
views::values | 取 pair 的 second | C++20 |
views::elements<N> | 取 tuple-like 的第 N 个元素 | C++20 |
std::map<std::string, int> m{{"a", 1}, {"b", 2}};
for (const auto& key : m | std::views::keys)
std::cout << key << ' '; // a b
3. 生成器
| 适配器 | 作用 | 版本 |
|---|---|---|
views::iota(start) | 从 start 开始的无限递增序列 | C++20 |
views::iota(start, end) | 从 start 到 end 的序列 | C++20 |
views::single(x) | 只含一个元素 | C++20 |
views::empty<T> | 空序列 | C++20 |
views::repeat(x, n) | 重复 \( n \) 次 | C++23 |
// 0 到 9
for (int i : std::views::iota(0, 10))
std::cout << i << ' ';
// 无限序列配合 take
for (int i : std::views::iota(0) | std::views::take(5))
std::cout << i << ' '; // 0 1 2 3 4
4. 组合与切分(C++23)
| 适配器 | 作用 |
|---|---|
views::zip(a, b) | 把多个范围按位置配对 |
views::enumerate | 给每个元素附上索引 |
views::chunk(n) | 每 \( n \) 个元素分为一组 |
views::slide(n) | 滑动窗口 |
views::stride(n) | 每隔 \( n \) 个元素取一个 |
views::adjacent<N> | 相邻 \( N \) 个元素组成 tuple |
views::cartesian_product | 笛卡尔积 |
std::vector<std::string> names{"Alice", "Bob"};
std::vector<int> ages{30, 25};
for (auto [name, age] : std::views::zip(names, ages))
std::cout << name << ": " << age << '\n';
// 带索引遍历(C++23)
for (auto [i, x] : std::views::enumerate(names))
std::cout << i << ": " << x << '\n';
Tip
views::enumerate是 C++23 最实用的适配器之一在此之前,带索引遍历需要手动维护计数器,或使用
views::iota配合views::zip:// C++20 的替代写法 for (auto [i, x] : std::views::zip(std::views::iota(0), names)) std::cout << i << ": " << x << '\n';
五、转换为容器
视图本身不拥有数据,需要具体容器时用 std::ranges::to(C++23):
#include <ranges>
#include <vector>
std::vector<int> v{1, 2, 3, 4, 5, 6};
// 视图转 vector
auto result = v | std::views::filter([](int x) { return x % 2 == 0; })
| std::ranges::to<std::vector>();
// 也可以指定容器类型
auto set = v | std::views::transform([](int x) { return x % 3; })
| std::ranges::to<std::set>();
C++23 之前需要手动构造:
std::vector<int> result;
auto view = v | std::views::filter([](int x) { return x % 2 == 0; });
std::ranges::copy(view, std::back_inserter(result));
六、范围概念
Ranges 用 Concepts 描述范围的能力,与迭代器概念一一对应:
| 概念 | 含义 |
|---|---|
ranges::range | 有 begin 和 end |
ranges::input_range | 迭代器满足 input_iterator |
ranges::forward_range | 迭代器满足 forward_iterator |
ranges::bidirectional_range | 迭代器满足 bidirectional_iterator |
ranges::random_access_range | 迭代器满足 random_access_iterator |
ranges::contiguous_range | 迭代器满足 contiguous_iterator |
ranges::sized_range | 能在常数时间内得到大小 |
ranges::view | 满足视图要求(拷贝/移动/析构为常数时间) |
此外还有几个重要概念:
ranges::borrowed_range:从该范围取得的迭代器可以安全地脱离范围使用ranges::common_range:begin()和end()类型相同ranges::viewable_range:可以安全转换为视图的范围
七、注意事项
- 视图不拥有数据。如果底层容器被销毁或修改,视图会悬垂。不要返回指向局部容器的视图。
- 惰性求值会重复计算。同一视图遍历两次,变换会执行两次。需要缓存时先转成容器。
- 注意迭代器类别。
views::reverse要求双向范围,views::filter的结果不是连续范围,因此不能再传给要求连续迭代器的代码。 views::filter的结果不能直接传给std::sort。因为filter_view不提供随机访问迭代器,需要先转成容器。std::views是std::ranges::views的别名。两者等价,std::views::filter更常用。- C++23 的适配器需要编译器支持。
views::zip、views::enumerate等在 GCC 13+、Clang 17+ 才可用。 views::join与views::split性能有差异。lazy_split比split更省内存,但某些操作受限。
八、相关章节
- Algorithms:Ranges 版本的算法
- Iterators:迭代器分类与 C++20 概念
- Containers:容器与范围的关系
- Lambda 表达式:视图中大量使用 lambda
Algorithms
C++ 标准库中的算法 (Algorithms) 是一组强大的函数模板,用于对容器或其他范围([first, last))内的元素进行搜索、排序、计数、修改等操作。自 C++20 以来,Ranges (约束算法) 的引入极大地简化了算法的使用,并增强了其通用性。C++17 则引入了执行策略,允许算法进行并行或乱序执行以提升性能。
版本速览
算法库的演进大致可以按下面的时间线理解,后文各表格中的「C++ 版本」列标注的就是各个算法首次引入(或 ranges:: 版本首次引入)的标准版本。
| 标准 | 关键变化 |
|---|---|
| C++11 | all_of / any_of / none_of、copy_n、is_partitioned、is_sorted、minmax、iota 等一批算法;shuffle 取代 random_shuffle(后者在 C++17 被移除) |
| C++14 | 非修改序列操作增强:mismatch、equal、is_permutation 增加双范围重载 |
| C++17 | 执行策略(<execution>)与并行算法;for_each_n、sample、clamp;数值算法 reduce / scan / transform_reduce 等 |
| C++20 | Ranges 约束算法(std::ranges);shift_left / shift_right;lexicographical_compare_three_way;unsequenced_policy(unseq) |
| C++23 | ranges::contains / contains_subrange、ranges::find_last*、ranges::starts_with / ends_with、折叠算法 ranges::fold_*、ranges::iota、ranges::shift_left / shift_right;返回类型 in_value_result / out_value_result |
| C++26 | 并行范围算法(ranges:: 算法开始接受执行策略);ranges::generate_random;<algorithm> 的 freestanding 支持;constexpr 稳定排序 |
C++20 约束算法 (Constrained Algorithms / Ranges)
Ranges 是 C++20 引入的一项革命性特性,它极大地简化了 STL 算法的使用,并增强了它们的组合性。
核心概念与优势
| 特性 | 优势和示例 |
|---|---|
| 单一 Range 参数 | 您不再需要手动传递 begin() 和 end() 迭代器。只需将整个容器(或 Range 视图)作为一个参数传递给算法即可。 |
| Projections (投影) | 简化复杂对象操作。 算法可以直接操作容器内元素的某个成员变量或计算结果,而无需使用复杂的 Lambda 表达式。例如,对一个 vector<Person> 按照 Person::age 成员进行排序:ranges::sort(people, {}, &Person::age)。 |
| 返回类型增强 | 提供所有有用的信息。 大多数 std::ranges:: 算法不再只返回一个迭代器,而是返回一个包含所有相关结果的结构体(如 ranges::in_out_result)。例如,ranges::copy 返回的 copy_result 中同时包含输入范围的末尾迭代器和输出范围的末尾迭代器,方便后续操作。 |
| 更强的约束 | 它们是“约束算法”,这意味着它们使用 C++20 的 Concepts 确保传入的迭代器和 Range 满足算法所需的最低要求,从而提供更清晰的编译错误。 |
| 算法函数对象 | std::ranges 中的算法是算法函数对象(AFO,非正式地称为 niebloid),而非普通函数模板。因此不能显式指定模板实参,也不参与 ADL。 |
示例:
std::vector<int> v{7, 1, 4, 0, -1};
// 经典算法:需要显式传入begin()和end()
std::sort(v.begin(), v.end());
// C++20 Ranges 算法:更简洁
std::ranges::sort(v);
C++17/C++20 执行策略 (Execution Policies)
执行策略 是 C++17 引入的功能,它允许程序员通过向算法传递一个策略对象,来指示算法如何执行——是串行、并行还是乱序执行,从而在多核 CPU 上实现性能优化。
核心策略类型
| 策略类型 | 全局对象 | C++ 版本 | 描述 |
|---|---|---|---|
sequenced_policy | seq | C++17 | 序列化执行:算法按顺序执行,不会使用并行或乱序操作。这是所有算法的默认行为。 |
parallel_policy | par | C++17 | 并行执行:算法可以在不同的线程中并发执行。它保证了并发性,但不保证指令的顺序。 |
parallel_unsequenced_policy | par_unseq | C++17 | 并行且乱序执行:结合了并行和乱序执行,允许并发执行,同时允许编译器进行矢量化 (vectorization) 优化。 |
unsequenced_policy | unseq | C++20 | 乱序执行:不保证并行,但允许编译器对单个线程中的操作进行乱序处理(矢量化),以提高性能。 |
头文件: 所有执行策略都定义在 <execution> 头文件中。策略类型位于 std::execution 命名空间(sequenced_policy、parallel_policy、parallel_unsequenced_policy、unsequenced_policy),而全局策略对象 seq、par、par_unseq、unseq 位于 std 命名空间(即 std::execution::par 也可用,两者是同一对象)。
示例:
#include <algorithm>
#include <execution>
#include <vector>
std::vector<int> data = ...;
// 使用并行策略对数据进行排序,以利用多核CPU
std::sort(std::execution::par, data.begin(), data.end());
// C++20 Ranges 版本也支持策略(如果算法有重载)
std::ranges::sort(std::execution::par, data);
并行算法的技术限制
在使用执行策略时,有一个关键的安全限制需要注意:
除另有说明外,只要元素类型 ( T ) 同时满足 Trivial Copy Construction (std::is_trivially_copy_constructible_v<T> == true) 和 Trivial Destruction (std::is_trivially_destructible_v<T> == true),库就可以对元素进行任意复制,以便在不同的并行线程之间分发数据。
这意味着: 如果你的自定义对象具有非平凡(复杂)的构造函数、析构函数或资源管理,使用并行策略时需要额外小心,以确保你的操作是线程安全的。对于基本类型(如 int, float)和简单的 C 结构体,这通常不是问题。
另外,使用 par_unseq 和 unseq 时,函数调用之间是无序(unsequenced)的,可以交错执行,因此不允许分配/释放内存、获取互斥锁、使用非无锁的 std::atomic,或执行其它“不利于向量化”的操作。
C++26 变更: 从 C++26 开始,
std::ranges下的算法也支持执行策略(并行范围算法)。例如std::ranges::sort(std::execution::par, v)、std::ranges::copy(std::execution::par, src, dst)都已可用。在 C++26 之前,ranges::算法并不接受执行策略参数。
一、非修改序列操作 (Non-modifying sequence operations)
主要头文件:
<algorithm>:包含绝大多数经典的非修改序列操作。<ranges>:包含所有ranges::版本的算法。
批处理操作 (Batch operations)
| 批处理操作 (Batch operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| for_each / ranges::for_each | <algorithm> / <ranges> | 对范围内的元素应用一个一元函数对象 | |
| for_each_n / ranges::for_each_n | (C++17) / (C++20) | <algorithm> / <ranges> | 对序列的前 \( N \) 个元素应用一个函数对象 |
搜索操作 (Search operations)
| 搜索操作 (Search operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| all_of / ranges::all_of | (C++11) / (C++20) | <algorithm> / <ranges> | 检查一个谓词对范围内的所有元素是否为真 |
| any_of / ranges::any_of | (C++11) / (C++20) | <algorithm> / <ranges> | 检查一个谓词对范围内的任一元素是否为真 |
| none_of / ranges::none_of | (C++11) / (C++20) | <algorithm> / <ranges> | 检查一个谓词对范围内的所有元素是否为假 |
| ranges::contains | (C++23) | <ranges> | 检查范围是否包含给定元素 |
| ranges::contains_subrange | (C++23) | <ranges> | 检查范围是否包含给定子范围 |
| find / ranges::find | <algorithm> / <ranges> | 查找满足特定条件的第一个元素 | |
| find_if / ranges::find_if | (C++11) / (C++20) | <algorithm> / <ranges> | 查找满足谓词的第一个元素 |
| find_if_not / ranges::find_if_not | (C++11) / (C++20) | <algorithm> / <ranges> | 查找不满足谓词的第一个元素 |
| ranges::find_last | (C++23) | <ranges> | 查找满足特定条件的最后一个元素 |
| ranges::find_last_if | (C++23) | <ranges> | 查找满足谓词的最后一个元素 |
| ranges::find_last_if_not | (C++23) | <ranges> | 查找不满足谓词的最后一个元素 |
| find_end / ranges::find_end | <algorithm> / <ranges> | 查找某一范围内的最后一个序列 | |
| find_first_of / ranges::find_first_of | <algorithm> / <ranges> | 搜索一组元素中的任意一个 | |
| adjacent_find / ranges::adjacent_find | <algorithm> / <ranges> | 查找第一个两个相邻且相等的项(或满足给定谓词) | |
| count / ranges::count | <algorithm> / <ranges> | 返回满足特定条件的元素的数量 | |
| count_if / ranges::count_if | <algorithm> / <ranges> | 返回满足谓词的元素的数量 | |
| mismatch / ranges::mismatch | (C++14) | <algorithm> / <ranges> | 查找两个范围第一次不同的位置 |
| equal / ranges::equal | (C++14) | <algorithm> / <ranges> | 确定两组元素是否相同 |
| search / ranges::search | <algorithm> / <ranges> | 搜索一个元素范围的第一次出现 | |
| search_n / ranges::search_n | <algorithm> / <ranges> | 搜索一个元素在范围中连续出现 ( N ) 次的第一次出现 | |
| ranges::starts_with | (C++23) | <ranges> | 检查一个范围是否以另一个范围开始 |
| ranges::ends_with | (C++23) | <ranges> | 检查一个范围是否以另一个范围结束 |
折叠操作 (Fold operations)
| 折叠操作 (Fold operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| ranges::fold_left | (C++23) | <ranges> | 对一系列元素进行左折叠 |
| ranges::fold_left_first | (C++23) | <ranges> | 使用第一个元素作为初始值对一系列元素进行左折叠 |
| ranges::fold_right | (C++23) | <ranges> | 对一系列元素进行右折叠 |
| ranges::fold_right_last | (C++23) | <ranges> | 使用最后一个元素作为初始值对一系列元素进行右折叠 |
| ranges::fold_left_with_iter | (C++23) | <ranges> | 对一系列元素进行左折叠,并返回 in_value_result(迭代器与值) |
| ranges::fold_left_first_with_iter | (C++23) | <ranges> | 使用第一个元素作为初始值对一系列元素进行左折叠,并返回 in_value_result(迭代器与值) |
注意: 折叠算法没有非
ranges::版本,它们只在<ranges>中提供。
Ranges 返回类型 (Return types)
std::ranges 算法不再只返回一个迭代器,而是返回下面这些结构体(class template),它们提供了算法执行过程中计算出的全部有用信息。这也是 Ranges 算法与经典算法在接口上的一个重要区别。
| 返回类型 | C++ 版本 | 含义 |
|---|---|---|
| ranges::in\_fun\_result | (C++20) | 一个迭代器 + 一个函数对象 |
| ranges::in\_in\_result | (C++20) | 两个迭代器(如 mismatch) |
| ranges::in\_out\_result | (C++20) | 输入迭代器 + 输出迭代器(如 copy) |
| ranges::in\_in\_out\_result | (C++20) | 两个输入迭代器 + 一个输出迭代器(如 merge) |
| ranges::in\_out\_out\_result | (C++20) | 输入迭代器 + 两个输出迭代器(如 partition\\_copy) |
| ranges::min\_max\_result | (C++20) | 最小值 + 最大值(如 minmax\\_element) |
| ranges::in\_found\_result | (C++20) | 迭代器 + 布尔标志(如 next\\_permutation) |
| ranges::in\_value\_result | (C++23) | 迭代器 + 一个值(如 fold\\_left\\_with\\_iter) |
| ranges::out\_value\_result | (C++23) | 输出迭代器 + 一个值 |
二、修改序列操作 (Modifying sequence operations)
主要头文件:
<algorithm>:包含绝大多数经典的修改序列操作。<ranges>:包含所有ranges::版本的算法。<utility>:包含std::swap和std::iter_swap。
复制操作 (Copy operations)
| 复制操作 (Copy operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| copy / ranges::copy | (C++11) | <algorithm> / <ranges> | 将一个元素范围复制到一个新位置 |
| copy_if / ranges::copy_if | (C++11) | <algorithm> / <ranges> | 有条件地复制一个元素范围到新位置 |
| copy_n / ranges::copy_n | (C++11) | <algorithm> / <ranges> | 将指定数量的元素复制到一个新位置 |
| copy_backward / ranges::copy_backward | <algorithm> / <ranges> | 以向后顺序复制一个元素范围 | |
| move / ranges::move | (C++11) | <algorithm> / <ranges> | 将一个元素范围移动到一个新位置 |
| move_backward / ranges::move_backward | (C++11) | <algorithm> / <ranges> | 以向后顺序移动一个元素范围 |
交换操作 (Swap operations)
| 交换操作 (Swap operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| swap | (C++11) | <utility> | 交换两个对象的值(C++11 前在 <algorithm>) |
| swap_ranges / ranges::swap_ranges | <algorithm> / <ranges> | 交换两个元素范围 | |
| iter_swap | <utility> | 交换两个迭代器所指向的元素 |
变换操作 (Transformation operations)
| 变换操作 (Transformation operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| transform / ranges::transform | <algorithm> / <ranges> | 对一个元素范围应用一个函数,并将结果存储在目标范围中 | |
| replace / ranges::replace | <algorithm> / <ranges> | 将所有满足特定条件的值替换为另一个值 | |
| replace_if / ranges::replace_if | <algorithm> / <ranges> | 将所有满足谓词的值替换为另一个值 | |
| replace_copy / ranges::replace_copy | <algorithm> / <ranges> | 复制一个范围,并将满足特定条件的元素替换为另一个值 | |
| replace_copy_if / ranges::replace_copy_if | <algorithm> / <ranges> | 复制一个范围,并将满足谓词的元素替换为另一个值 |
生成操作 (Generation operations)
| 生成操作 (Generation operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| fill / ranges::fill | <algorithm> / <ranges> | 将给定值赋值给范围内的每个元素 | |
| fill_n / ranges::fill_n | <algorithm> / <ranges> | 将给定值赋值给范围内的 ( N ) 个元素 | |
| generate / ranges::generate | <algorithm> / <ranges> | 将连续函数调用的结果赋值给范围内的每个元素 | |
| generate_n / ranges::generate_n | <algorithm> / <ranges> | 将连续 ( N ) 次函数调用的结果赋值给范围内的 ( N ) 个元素 |
移除操作 (Removing operations)
这里的“移除”通常是逻辑移除,通过将未被移除的元素移动到范围的前部来实现,并返回新的逻辑尾部。
| 移除操作 (Removing operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| remove / ranges::remove | <algorithm> / <ranges> | 移除满足特定条件的元素(逻辑移除) | |
| remove_if / ranges::remove_if | <algorithm> / <ranges> | 移除满足谓词的元素(逻辑移除) | |
| remove_copy / ranges::remove_copy | <algorithm> / <ranges> | 复制一个范围,省略满足特定条件的元素 | |
| remove_copy_if / ranges::remove_copy_if | <algorithm> / <ranges> | 复制一个范围,省略满足谓词的元素 | |
| unique / ranges::unique | <algorithm> / <ranges> | 移除范围内的连续重复元素(逻辑移除) | |
| unique_copy / ranges::unique_copy | <algorithm> / <ranges> | 创建一个不含连续重复元素的范围副本 |
顺序改变操作 (Order-changing operations)
| 顺序改变操作 (Order-changing operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| reverse / ranges::reverse | <algorithm> / <ranges> | 反转范围内的元素顺序 | |
| reverse_copy / ranges::reverse_copy | <algorithm> / <ranges> | 创建一个反转后的范围副本 | |
| rotate / ranges::rotate | <algorithm> / <ranges> | 旋转范围内的元素顺序 | |
| rotate_copy / ranges::rotate_copy | <algorithm> / <ranges> | 复制并旋转一个元素范围 | |
| shift_left / ranges::shift_left | (C++20) / (C++23) | <algorithm> / <ranges> | 左移范围内的元素 |
| shift_right / ranges::shift_right | (C++20) / (C++23) | <algorithm> / <ranges> | 右移范围内的元素 |
| random_shuffle / shuffle / ranges::shuffle | (C++11) / (C++11) | <algorithm> / <ranges> | 随机重新排序范围内的元素(random_shuffle 已在 C++17 移除) |
采样操作 (Sampling operations)
| 采样操作 (Sampling operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| sample / ranges::sample | (C++17) / (C++20) | <algorithm> / <ranges> | 从序列中选择 \( N \) 个随机元素 |
三、排序及相关操作 (Sorting and related operations)
主要头文件:
<algorithm>:包含绝大多数经典的排序及相关操作。<ranges>:包含所有ranges::版本的算法。
分区操作 (Partitioning operations)
| 分区操作 (Partitioning operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| is_partitioned / ranges::is_partitioned | (C++11) | <algorithm> / <ranges> | 确定范围是否被给定谓词分区 |
| partition / ranges::partition | <algorithm> / <ranges> | 将一个元素范围划分为两组 | |
| partition_copy / ranges::partition_copy | (C++11) | <algorithm> / <ranges> | 复制一个范围,将元素划分为两组 |
| stable_partition / ranges::stable_partition | <algorithm> / <ranges> | 将元素划分为两组,同时保持它们的相对顺序 | |
| partition_point / ranges::partition_point | (C++11) | <algorithm> / <ranges> | 定位一个已分区范围的分区点 |
排序操作 (Sorting operations)
| 排序操作 (Sorting operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| sort / ranges::sort | <algorithm> / <ranges> | 将一个范围排序为升序 | |
| stable_sort / ranges::stable_sort | <algorithm> / <ranges> | 排序一个元素范围,同时保持相等元素间的相对顺序 | |
| partial_sort / ranges::partial_sort | <algorithm> / <ranges> | 排序一个范围的前 ( N ) 个元素 | |
| partial_sort_copy / ranges::partial_sort_copy | <algorithm> / <ranges> | 复制并部分排序一个元素范围 | |
| is_sorted / ranges::is_sorted | (C++11) | <algorithm> / <ranges> | 检查一个范围是否已排序为升序 |
| is_sorted_until / ranges::is_sorted_until | (C++11) | <algorithm> / <ranges> | 查找最大的已排序子范围 |
| nth_element / ranges::nth_element | <algorithm> / <ranges> | 部分排序给定范围,确保它被给定元素分区 |
二分搜索操作 (Binary search operations)
这些操作要求输入范围必须是已排序的。
| 二分搜索操作 (Binary search operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| lower_bound / ranges::lower_bound | <algorithm> / <ranges> | 返回第一个不小于给定值的元素的迭代器 | |
| upper_bound / ranges::upper_bound | <algorithm> / <ranges> | 返回第一个大于给定值的元素的迭代器 | |
| equal_range / ranges::equal_range | <algorithm> / <ranges> | 返回匹配特定键的元素的范围 | |
| binary_search / ranges::binary_search | <algorithm> / <ranges> | 确定一个元素是否存在于一个部分有序的范围中 |
集合操作 (Set operations)
这些操作要求输入范围必须是已排序的。
| 集合操作 (Set operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| includes / ranges::includes | <algorithm> / <ranges> | 如果一个序列是另一个序列的子序列则返回 true | |
| set_union / ranges::set_union | <algorithm> / <ranges> | 计算两个集合的并集 | |
| set_intersection / ranges::set_intersection | <algorithm> / <ranges> | 计算两个集合的交集 | |
| set_difference / ranges::set_difference | <algorithm> / <ranges> | 计算两个集合的差集 | |
| set_symmetric_difference / ranges::set_symmetric_difference | <algorithm> / <ranges> | 计算两个集合的对称差集 |
合并操作 (Merge operations)
| 合并操作 (Merge operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| merge / ranges::merge | <algorithm> / <ranges> | 合并两个已排序的范围 | |
| inplace_merge / ranges::inplace_merge | <algorithm> / <ranges> | 在原地合并两个有序范围 |
堆操作 (Heap operations)
这些操作用于维护最大堆 (max heap) 的属性。
| 堆操作 (Heap operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| push_heap / ranges::push_heap | <algorithm> / <ranges> | 向最大堆中添加一个元素 | |
| pop_heap / ranges::pop_heap | <algorithm> / <ranges> | 从最大堆中移除最大的元素 | |
| make_heap / ranges::make_heap | <algorithm> / <ranges> | 将一个元素范围创建为最大堆 | |
| sort_heap / ranges::sort_heap | <algorithm> / <ranges> | 将一个最大堆转换为一个升序排序的元素范围 | |
| is_heap / ranges::is_heap | (C++11) | <algorithm> / <ranges> | 检查给定范围是否为最大堆 |
| is_heap_until / ranges::is_heap_until | (C++11) | <algorithm> / <ranges> | 查找作为最大堆的最大子范围 |
最小/最大操作 (Minimum/maximum operations)
| 最小/最大操作 (Minimum/maximum operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| max / ranges::max | <algorithm> / <ranges> | 返回给定值中的较大者 | |
| max_element / ranges::max_element | <algorithm> / <ranges> | 返回范围中的最大元素 | |
| min / ranges::min | <algorithm> / <ranges> | 返回给定值中的较小者 | |
| min_element / ranges::min_element | <algorithm> / <ranges> | 返回范围中的最小元素 | |
| minmax / ranges::minmax | (C++11) | <algorithm> / <ranges> | 返回两个元素中的较小者和较大者 |
| minmax_element / ranges::minmax_element | (C++11) | <algorithm> / <ranges> | 返回范围中的最小和最大元素 |
| clamp / ranges::clamp | (C++17) | <algorithm> / <ranges> | 将一个值钳制在一对边界值之间 |
字典序比较操作 (Lexicographical comparison operations)
| 字典序比较操作 (Lexicographical comparison operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| lexicographical_compare / ranges::lexicographical_compare | <algorithm> / <ranges> | 如果一个范围字典序上小于另一个,则返回 true | |
| lexicographical_compare_three_way | (C++20) | <algorithm> | 使用三路比较比较两个范围 |
排列操作 (Permutation operations)
| 排列操作 (Permutation operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| next_permutation / ranges::next_permutation | <algorithm> / <ranges> | 生成范围元素的下一个更大的字典序排列 | |
| prev_permutation / ranges::prev_permutation | <algorithm> / <ranges> | 生成范围元素的下一个更小的字典序排列 | |
| is_permutation / ranges::is_permutation | (C++11) | <algorithm> / <ranges> | 确定一个序列是否是另一个序列的排列 |
四、数值操作 (Numeric operations)
主要头文件:
<numeric>:包含所有传统的数值算法和 C++17 的并行数值算法。<ranges>:包含ranges::iota。
| 数值操作 (Numeric operations) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| iota / ranges::iota | (C++11) / (C++23) | <numeric> / <ranges> | 用递增的起始值填充一个范围 |
| accumulate | <numeric> | 对一个元素范围求和或折叠 | |
| inner_product | <numeric> | 计算两个元素范围的内积 | |
| adjacent_difference | <numeric> | 计算一个范围中相邻元素之间的差值 | |
| partial_sum | <numeric> | 计算一个元素范围的部分和 | |
| reduce | (C++17) | <numeric> | 类似于 std::accumulate,但无序 |
| exclusive_scan | (C++17) | <numeric> | 类似于 std::partial_sum,但不包括第 \( N \) 个输入元素在第 \( N \) 个和中 |
| inclusive_scan | (C++17) | <numeric> | 类似于 std::partial_sum,包括第 \( N \) 个输入元素在第 \( N \) 个和中 |
| transform_reduce | (C++17) | <numeric> | 应用一个可调用对象,然后无序归约 |
| transform_exclusive_scan | (C++17) | <numeric> | 应用一个可调用对象,然后计算独占扫描 |
| transform_inclusive_scan | (C++17) | <numeric> | 应用一个可调用对象,然后计算包含扫描 |
注意: 除
ranges::iota外,数值算法目前都没有ranges::版本。
五、未初始化内存操作 (Operations on uninitialized memory)
主要头文件:
<memory>:包含所有未初始化内存操作。
| 未初始化内存操作 (Operations on uninitialized memory) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| uninitialized_copy / ranges::uninitialized_copy | <memory> | 将一系列对象复制到一块未初始化内存区域 | |
| uninitialized_copy_n / ranges::uninitialized_copy_n | (C++11) | <memory> | 将指定数量的对象复制到一块未初始化内存区域 |
| uninitialized_fill / ranges::uninitialized_fill | <memory> | 将一个对象复制赋值给一块未初始化内存区域 | |
| uninitialized_fill_n / ranges::uninitialized_fill_n | <memory> | 将一个对象复制赋值给指定数量的未初始化内存区域 | |
| uninitialized_move / ranges::uninitialized_move | (C++17) | <memory> | 将一系列对象移动到一块未初始化内存区域 |
| uninitialized_move_n / ranges::uninitialized_move_n | (C++17) | <memory> | 将指定数量的对象移动到一块未初始化内存区域 |
| uninitialized_default_construct / ranges::uninitialized_default_construct | (C++17) | <memory> | 在一块未初始化内存区域中默认构造对象 |
| uninitialized_default_construct_n / ranges::uninitialized_default_construct_n | (C++17) | <memory> | 在一块未初始化内存区域中默认构造 \( N \) 个对象 |
| uninitialized_value_construct / ranges::uninitialized_value_construct | (C++17) | <memory> | 在一块未初始化内存区域中值构造对象 |
| uninitialized_value_construct_n / ranges::uninitialized_value_construct_n | (C++17) | <memory> | 在一块未初始化内存区域中值构造 \( N \) 个对象 |
| destroy / ranges::destroy | (C++17) | <memory> | 销毁一系列对象 |
| destroy_n / ranges::destroy_n | (C++17) | <memory> | 销毁范围内的 \( N \) 个对象 |
| destroy_at / ranges::destroy_at | (C++17) | <memory> | 销毁给定地址处的对象 |
| construct_at / ranges::construct_at | (C++20) | <memory> | 在给定地址处创建一个对象 |
六、其他算法和工具
随机数生成 (Random number generation)
主要头文件:
<random>
| 随机数生成 (Random number generation) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| ranges::generate_random | (C++26) | <random> | 用均匀随机位生成器填充一个随机数范围 |
C 库函数 (C library functions)
主要头文件:
<cstdlib>(或<stdlib.h>)
| C 库函数 (C library functions) | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|
| qsort | <cstdlib> | 排序一个类型未指定的元素范围 | |
| bsearch | <cstdlib> | 搜索一个类型未指定的数组中的元素 |
附注:执行策略 (Execution policies)
主要头文件:
<execution>
| 执行策略类型 (Execution policy types) | 宏/类/对象 | C++ 版本 | 头文件 | 描述 (功能) |
|---|---|---|---|---|
sequenced_policy (seq) | 类 / 全局对象 | (C++17) | <execution> | 序列化执行(无并行) |
parallel_policy (par) | 类 / 全局对象 | (C++17) | <execution> | 并行执行 |
parallel_unsequenced_policy (par\_unseq) | 类 / 全局对象 | (C++17) | <execution> | 并行且乱序执行 |
unsequenced_policy (unseq) | 类 / 全局对象 | (C++20) | <execution> | 乱序执行(允许编译器向量化) |
| is_execution_policy | 类模板 | (C++17) | <execution> | 测试一个类是否表示一个执行策略 |
| execution-policy | 仅用于说明的概念 | (C++26) | <execution> | 约束“表示执行策略的类型”,替代原先按名字匹配 ExecutionPolicy 模板参数的做法 |
七、C++26 新特性
C++26 对算法库的改动主要集中在并行范围算法和随机数填充两块。
并行范围算法(Parallel range algorithms)
这是 C++26 最重要的一项变化:std::ranges 下的算法开始接受执行策略参数,从而把 Ranges 的便利性和 C++17 的并行能力结合了起来。在此之前,只有经典的 std:: 算法支持执行策略,ranges:: 版本并不接受。
#include <algorithm>
#include <execution>
#include <vector>
std::vector<int> v = /* ... */;
// C++26 起可用:Ranges 算法 + 并行策略
std::ranges::sort(std::execution::par, v);
std::ranges::copy(std::execution::par, src, dst);
需要注意的是,并行范围算法对迭代器有更严格的要求:通常要求 random_access_iterator 与 sized_sentinel_for,范围参数则要求是 sized random access range。此外,如果并行化所需的临时内存不足,会抛出 std::bad_alloc。
ranges::generate_random
std::ranges::generate_random 用一个均匀随机位生成器(uniform random bit generator)填充范围,也可以额外传入一个分布对象。它会优先调用生成器或分布的 generate_random 成员函数(如果存在),否则退化为逐个元素调用 ranges::generate。
#include <algorithm>
#include <random>
std::default_random_engine eng;
int arr[16]{};
std::ranges::generate_random(arr, eng); // 用 eng 填充 arr
其它相关变化
<algorithm>的 freestanding 支持:C++26 起算法库的部分设施可用于 freestanding(无宿主)环境。constexpr稳定排序:stable_sort等稳定排序算法在 C++26 起可用于常量求值。- 列表初始化支持:部分算法在 C++26 起支持对元素类型使用列表初始化(如
ranges::find(v, {1, 2})这类写法)。
提示: 并行范围算法需要标准库与并行后端(如 GCC 的 TBB)支持,实际可用性取决于编译器实现。使用前建议先检查
__cpp_lib_parallel_algorithm的值:C++26 起为202506L。
非修改序列操作 (Non-modifying sequence operations)
非修改序列操作只读取范围内的元素,不会改变元素的值,也不会改变容器的大小。它们主要用于查找、计数、比较和遍历。
主要头文件:
<algorithm>:包含绝大多数经典的非修改序列操作。<ranges>:包含所有ranges::版本的算法。
算法总览
| 算法 | C++ 版本 | 头文件 | 描述 |
|---|---|---|---|
| for_each | <algorithm> | 对范围内的元素应用一个一元函数对象 | |
| for_each_n | (C++17) | <algorithm> | 对序列的前 \( N \) 个元素应用一个函数对象 |
| all_of / any_of / none_of | (C++11) | <algorithm> | 检查谓词对范围内所有/任一/没有元素是否为真 |
| find / find_if / find_if_not | (C++98) / (C++11) | <algorithm> | 查找第一个满足条件的元素 |
| find_last | (C++23) | <algorithm> | 查找最后一个满足条件的元素 |
| find_end | <algorithm> | 查找某一范围内的最后一个子序列 | |
| find_first_of | <algorithm> | 搜索一组元素中的任意一个 | |
| adjacent_find | <algorithm> | 查找第一对相邻且相等(或满足谓词)的元素 | |
| count / count_if | <algorithm> | 返回满足条件的元素数量 | |
| mismatch | (C++14) | <algorithm> | 查找两个范围第一次不同的位置 |
| equal | (C++14) | <algorithm> | 判断两组元素是否相同 |
| search / search_n | <algorithm> | 搜索子范围或连续 N 个元素的首次出现 | |
| contains | (C++23) | <algorithm> | 检查范围是否包含给定元素或子范围 |
| starts_with / ends_with | (C++23) | <algorithm> | 检查范围是否以另一个范围开始或结束 |
| fold | (C++23) | <algorithm> | 对范围进行左折叠或右折叠 |
说明: 表中「C++ 版本」列标注的是该算法(或
ranges::版本)首次引入的标准版本,留空表示自 C++98 起即存在。
共性说明
- 复杂度:除
find_end、search、search_n等子序列搜索算法外,绝大多数非修改序列操作都是线性复杂度 \(O(n)\)。 - 谓词与投影:经典版本接受一元谓词(
Pred),ranges::版本额外支持投影(Proj),可以只对元素的某个成员进行比较。 - 返回类型:
ranges::版本通常返回结构体(如ranges::in_in_result),一次性给出多个有用信息。
std::for_each
std::for_each 对范围 [first, last) 内的每个元素依次调用给定的一元函数对象 f,并按顺序返回该函数对象(C++11 起返回 std::move(f))。
它是最基础的遍历算法,与手写 for 循环相比的优势在于:可以配合执行策略并行化,并且函数对象的状态会被保留并返回。
1. 引入
#include <algorithm>
2. 原理
for_each 的实现非常直接,等价于:
template<class InputIt, class UnaryFunction>
constexpr UnaryFunction for_each(InputIt first, InputIt last, UnaryFunction f)
{
for (; first != last; ++first)
f(*first);
return f; // C++11 起为 return std::move(f);
}
- 迭代器要求:
InputIterator,只要求单遍扫描能力。 - 复杂度:恰好 \(O(n)\) 次函数调用。
- 返回值:返回传入的函数对象。这一点很有用——如果函数对象内部维护了状态(如计数器、累加器),可以通过返回值取出。
ranges::for_each 额外支持投影(Proj),并且返回 ranges::in_fun_result,同时给出末尾迭代器和函数对象。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
std::for_each(v.begin(), v.end(), [](int x) {
std::cout << x << ' '; // 输出: 1 2 3 4 5
});
std::cout << '\n';
// 修改元素:参数需要取引用
std::for_each(v.begin(), v.end(), [](int& x) { x *= 2; });
// v 变为 {2, 4, 6, 8, 10}
}
(2) 谓词与投影
for_each 接受的是一元可调用对象,本身没有谓词概念。但可以利用返回值的特性,把状态收集出来:
struct Sum {
int total = 0;
void operator()(int x) { total += x; }
};
std::vector<int> v{1, 2, 3, 4, 5};
Sum s = std::for_each(v.begin(), v.end(), Sum{});
std::cout << s.total; // 输出: 15
ranges::for_each 支持投影,可以只对元素的某个成员操作:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}};
std::ranges::for_each(people, [](int age) { std::cout << age << ' '; },
&Person::age); // 输出: 30 25
(3) 执行策略
for_each 和 for_each_n 是唯一不要求元素可平凡复制的并行算法——即使元素类型有非平凡的构造/析构函数,也可以安全地使用并行策略。
#include <execution>
std::for_each(std::execution::par, v.begin(), v.end(), [](int& x) { x *= 2; });
但要注意:并行执行时调用顺序不确定,且不同线程可能同时访问同一元素,必须自行保证线程安全。
4. 注意事项
- 函数对象按值传递:
f是按值传入的,如果需要修改外部变量,应使用引用捕获的 lambda 或std::ref。 - 不要修改容器结构:在
for_each中调用push_back、erase等会改变容器大小的操作会导致迭代器失效。 for_each不保证顺序(当使用执行策略时);顺序执行时是严格按顺序的。- 返回值易被忽略:如果函数对象携带状态,忘记接收返回值就取不到结果。
- 与范围
for的选择:只是简单遍历时,基于范围的for循环更简洁;需要并行或需要取回函数对象状态时才用for_each。
5. 相关算法
- for_each_n:只处理前 \( N \) 个元素
- transform:遍历的同时把结果写入目标范围
- generate:用函数返回值填充范围
- count / count_if:统计满足条件的元素数量
std::for_each_n
std::for_each_n 对范围起始处的前 \( N \) 个元素依次调用一元函数对象 f,并返回指向第 \( N \) 个元素之后位置的迭代器。
它是 for_each 的「限定数量」版本,适用于只处理序列开头一部分元素的场景。
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class Size, class UnaryFunction>
constexpr InputIt for_each_n(InputIt first, Size n, UnaryFunction f);
// C++11~C++17 返回 void,C++17 起返回 InputIt
- 迭代器要求:
InputIterator。 - 复杂度:恰好 \( n \) 次函数调用。
- 返回值:C++17 起返回
first + n,即最后一个被处理元素的下一个位置。C++11/14 中返回void。
ranges::for_each_n 返回 ranges::in_fun_result,同时携带末尾迭代器和函数对象。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 只处理前 3 个元素
auto it = std::for_each_n(v.begin(), 3, [](int x) {
std::cout << x << ' '; // 输出: 1 2 3
});
std::cout << '\n';
std::cout << *it; // 输出: 4(第 3 个元素之后的位置)
}
(2) 谓词与投影
和 for_each 一样,for_each_n 接受一元可调用对象,没有谓词概念。ranges::for_each_n 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}, {"Carol", 35}};
std::ranges::for_each_n(people.begin(), 2,
[](int age) { std::cout << age << ' '; },
&Person::age); // 输出: 30 25
(3) 执行策略
与 for_each 一样,for_each_n 也不要求元素类型可平凡复制,可以安全地并行使用:
#include <execution>
std::for_each_n(std::execution::par, v.begin(), 3, [](int& x) { x *= 2; });
4. 注意事项
n必须非负:如果n < 0,行为未定义。- 范围必须足够长:调用者需自行保证
[first, first + n)是有效范围,否则越界。 - C++17 前无返回值:如果需要在 C++11/14 中获取结束位置,需自行计算
std::next(first, n)。 - 函数对象按值传递,需要修改外部变量时用引用捕获或
std::ref。
5. 相关算法
- for_each:处理整个范围
- copy_n:复制前 \( N \) 个元素
- fill_n:给前 \( N \) 个元素赋值
- generate_n:用函数返回值填充前 \( N \) 个元素
std::all_of / std::any_of / std::none_of
这三个算法用同一个一元谓词 p 检查范围内的元素,分别回答三个问题:
all_of:是否所有元素都满足p?any_of:是否存在元素满足p?none_of:是否没有元素满足p?
它们都是短路求值的,一旦结论确定就立即返回,不会继续扫描剩余元素。
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class UnaryPred>
constexpr bool all_of(InputIt first, InputIt last, UnaryPred p);
template<class InputIt, class UnaryPred>
constexpr bool any_of(InputIt first, InputIt last, UnaryPred p);
template<class InputIt, class UnaryPred>
constexpr bool none_of(InputIt first, InputIt last, UnaryPred p);
- 迭代器要求:
InputIterator。 - 复杂度:至多 \( O(n) \) 次谓词调用,实际调用次数取决于何时能得出结论。
- 空范围:
all_of和none_of对空范围返回true(空真命题),any_of返回false。
三者的逻辑关系如下:
| 算法 | 等价写法 | 空范围结果 |
|---|---|---|
all_of | !any_of(..., !p) | true |
any_of | !none_of(...) | false |
none_of | !any_of(...) | true |
ranges:: 版本额外支持投影,可以只对元素的某个成员进行判断。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{2, 4, 6, 8};
bool allEven = std::all_of(v.begin(), v.end(), [](int x) { return x % 2 == 0; });
bool anyOdd = std::any_of(v.begin(), v.end(), [](int x) { return x % 2 != 0; });
bool noneNeg = std::none_of(v.begin(), v.end(), [](int x) { return x < 0; });
std::cout << std::boolalpha
<< allEven << '\n' // true
<< anyOdd << '\n' // false
<< noneNeg << '\n'; // true
}
(2) 谓词与投影
ranges:: 版本支持投影,可以直接对成员做判断:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}};
// 是否所有成年人都年满 18 岁
bool allAdult = std::ranges::all_of(people, [](int age) { return age >= 18; },
&Person::age); // true
(3) 执行策略
这三个算法不支持执行策略。原因是它们需要短路求值,而并行执行无法保证「一旦得出结论就停止」,因此标准没有提供并行版本。
4. 注意事项
- 空范围的真假:
all_of与none_of对空范围返回true,这符合数学上的空真(vacuous truth),但容易与直觉不符,使用前最好先确认范围非空。 - 谓词不应有副作用:标准不保证谓词被调用的次数,也不保证调用顺序。
none_of不等于!all_of:none_of(p)等价于!any_of(p),而不是!all_of(p)。- 短路特性:如果谓词开销大,把最可能失败的判断放在前面可以提前结束。
5. 相关算法
- find / find_if:找出第一个满足条件的元素
- count / count_if:统计满足条件的元素数量
- equal:比较两个范围是否相同
- search:查找子序列
std::find / std::find_if / std::find_if_not
这三个算法在范围内查找第一个满足条件的元素,返回指向它的迭代器;如果找不到,返回 last。
find:查找等于给定值的元素find_if:查找使谓词返回true的元素find_if_not:查找使谓词返回false的元素(C++11 起)
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class T>
constexpr InputIt find(InputIt first, InputIt last, const T& value);
template<class InputIt, class UnaryPred>
constexpr InputIt find_if(InputIt first, InputIt last, UnaryPred p);
template<class InputIt, class UnaryPred>
constexpr InputIt find_if_not(InputIt first, InputIt last, UnaryPred q);
- 迭代器要求:
InputIterator。 - 复杂度:至多 \( O(n) \) 次比较或谓词调用。
- 返回值:指向第一个匹配元素的迭代器;无匹配时返回
last。
由于是线性查找,对已排序范围应改用 lower_bound / binary_search 等二分算法(\( O(\log n) \))。
ranges:: 版本支持投影,返回类型仍是迭代器(borrowed_iterator_t),不是结构体。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{3, 1, 4, 1, 5, 9};
auto it = std::find(v.begin(), v.end(), 4);
if (it != v.end())
std::cout << "找到: " << *it << '\n'; // 找到: 4
// find_if:找第一个偶数
auto it2 = std::find_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; });
std::cout << *it2 << '\n'; // 4
// find_if_not:找第一个非奇数(即偶数)
auto it3 = std::find_if_not(v.begin(), v.end(), [](int x) { return x % 2 != 0; });
std::cout << *it3 << '\n'; // 4
// 未找到时返回 end()
if (std::find(v.begin(), v.end(), 100) == v.end())
std::cout << "未找到 100\n";
}
(2) 谓词与投影
find 使用 operator== 比较;find_if / find_if_not 接受一元谓词。ranges:: 版本支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}};
// 按成员查找
auto it = std::ranges::find(people, 25, &Person::age);
std::cout << it->name; // Bob
(3) 执行策略
find、find_if、find_if_not 不支持执行策略。它们需要返回第一个匹配位置,短路语义与并行执行不兼容。
4. 注意事项
- 必须检查返回值:解引用
end()是未定义行为,使用前务必判断it != last。 find用operator==:对自定义类型需要提供operator==,否则编译失败。- 对已排序范围用二分:
std::find是 \( O(n) \),已排序时应改用std::lower_bound(\( O(\log n) \))。 - C++20 的替代:
std::ranges::contains更适合只判断「是否存在」的场景,无需处理迭代器。 find_if_not的谓词含义:它查找的是使谓词为假的元素,不要与find_if混淆。
5. 相关算法
- find_last:查找最后一个满足条件的元素
- find_first_of:查找一组元素中的任意一个
- adjacent_find:查找相邻的重复元素
- search:查找子序列
- count / count_if:统计满足条件的元素数量
- 二分查找:在有序范围上查找
std::ranges::find_last / find_last_if / find_last_if_not
这三个算法在范围内查找最后一个满足条件的元素,返回一个包含迭代器的结构体。它们只有 Ranges 版本,没有对应的 std:: 经典版本。
find_last:查找最后一个等于给定值的元素find_last_if:查找最后一个使谓词返回true的元素find_last_if_not:查找最后一个使谓词返回false的元素
1. 引入
#include <algorithm>
2. 原理
template<std::forward_iterator I, std::sentinel_for<I> S, class T,
class Proj = std::identity>
requires std::indirect_binary_predicate<ranges::equal_to,
std::projected<I, Proj>, const T*>
constexpr ranges::subrange<I> find_last(I first, S last, const T& value, Proj proj = {});
template<ranges::forward_range R, class T, class Proj = std::identity>
requires std::indirect_binary_predicate<ranges::equal_to,
std::projected<ranges::iterator_t<R>, Proj>, const T*>
constexpr ranges::borrowed_subrange_t<R> find_last(R&& r, const T& value, Proj proj = {});
- 迭代器要求:
ForwardIterator(因为需要保留「上一次找到的位置」)。 - 复杂度:至多 \( O(n) \) 次比较或谓词调用。
- 返回值:返回
ranges::subrange<I>,其begin()指向找到的元素,end()指向原范围的末尾。未找到时返回空子范围。
返回 subrange 而非单个迭代器的好处是:可以直接用 result.empty() 判断是否找到,也可以直接用 result 作为范围参数传给其它算法。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{3, 1, 4, 1, 5, 9, 1};
// 找最后一个 1
auto result = std::ranges::find_last(v, 1);
if (!result.empty())
std::cout << *result.begin() << '\n'; // 输出: 1
// 找最后一个偶数
auto r2 = std::ranges::find_last_if(v, [](int x) { return x % 2 == 0; });
std::cout << *r2.begin() << '\n'; // 输出: 4
// 未找到时返回空子范围
auto r3 = std::ranges::find_last(v, 100);
std::cout << std::boolalpha << r3.empty() << '\n'; // true
}
(2) 谓词与投影
支持投影,可以按成员查找:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}, {"Carol", 30}};
auto r = std::ranges::find_last(people, 30, &Person::age);
std::cout << r.begin()->name; // 输出: Carol
(3) 执行策略
不支持执行策略。查找最后一个匹配元素需要顺序扫描,无法有效并行化。
4. 注意事项
- 只有 Ranges 版本:C++23 才引入,且没有
std::find_last经典版本。老编译器上不可用。 - 返回
subrange而非迭代器:不要写成auto it = ranges::find_last(...)然后解引用it,应该用result.begin()。 - 未找到返回空范围:用
result.empty()或result.begin() == result.end()判断。 - 需要 forward 迭代器:
input_iterator(如istream_iterator)不能用,因为无法回退。 - 性能考虑:查找最后一个元素必须扫描整个范围,无法提前退出。如果频繁需要此操作,考虑反向存储或维护索引。
5. 相关算法
- find / find_if:查找第一个满足条件的元素
- find_end:查找最后一个子序列
- search:查找子序列的首次出现
- contains:只判断是否存在
std::find_end
std::find_end 在范围 [first, last) 中查找子序列 [s_first, s_last) 最后一次出现的位置,返回该子序列起始处的迭代器。如果找不到,返回 last。
它与 search 的区别在于:search 找第一次出现,find_end 找最后一次出现。
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt1, class ForwardIt2>
constexpr ForwardIt1 find_end(ForwardIt1 first, ForwardIt1 last,
ForwardIt2 s_first, ForwardIt2 s_last);
template<class ForwardIt1, class ForwardIt2, class BinaryPred>
constexpr ForwardIt1 find_end(ForwardIt1 first, ForwardIt1 last,
ForwardIt2 s_first, ForwardIt2 s_last, BinaryPred p);
- 迭代器要求:
ForwardIterator。 - 复杂度:至多 \( O(S \cdot N) \) 次比较,其中 \( S \) 为子序列长度,\( N \) 为主范围长度。
- 空子序列:如果
[s_first, s_last)为空,返回last。 - 返回值:指向最后一次匹配的子序列首元素;无匹配时返回
last。
ranges::find_end 支持投影,返回 ranges::subrange。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 1, 2, 3, 4};
std::vector<int> sub{1, 2, 3};
// 查找子序列最后一次出现
auto it = std::find_end(v.begin(), v.end(), sub.begin(), sub.end());
if (it != v.end())
std::cout << "起始索引: " << (it - v.begin()) << '\n'; // 3
// 找不到的情况
std::vector<int> sub2{9, 9};
auto it2 = std::find_end(v.begin(), v.end(), sub2.begin(), sub2.end());
std::cout << std::boolalpha << (it2 == v.end()) << '\n'; // true
}
(2) 谓词与投影
可以传入二元谓词自定义相等判断:
// 忽略大小写地查找
std::string text = "Hello World hello";
std::string pat = "HELLO";
auto it = std::find_end(text.begin(), text.end(), pat.begin(), pat.end(),
[](char a, char b) {
return std::tolower(a) == std::tolower(b);
});
// 指向 "hello" 的起始位置
(3) 执行策略
find_end 不支持执行策略。它需要确定「最后一次」出现,无法有效并行化。
4. 注意事项
- 复杂度较高:最坏情况是 \( O(S \cdot N) \),在大文本中查找长子串时可能很慢。这类场景应考虑更高效的字符串搜索算法(如 KMP、Boyer-Moore)。
- 空子序列返回
last:这一点容易忽略,使用前最好检查子序列非空。 - 与
search的混淆:search找第一次,find_end找最后一次,名字容易记反。 - 需要 forward 迭代器:
input_iterator不可用。
5. 相关算法
- search / search_n:查找子序列的第一次出现
- find_first_of:查找一组元素中的任意一个
- find / find_if:查找单个元素
- find_last:查找最后一个满足条件的单个元素
std::find_first_of
std::find_first_of 在范围 [first, last) 中查找任意一个出现在集合 [s_first, s_last) 中的元素,返回第一个这样的元素的位置。
注意它的语义:不是查找整个子序列,而是查找「集合中的任意一个元素」。
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class ForwardIt>
constexpr InputIt find_first_of(InputIt first, InputIt last,
ForwardIt s_first, ForwardIt s_last);
template<class InputIt, class ForwardIt, class BinaryPred>
constexpr InputIt find_first_of(InputIt first, InputIt last,
ForwardIt s_first, ForwardIt s_last, BinaryPred p);
- 迭代器要求:主范围为
InputIterator,集合为ForwardIterator。 - 复杂度:至多 \( O(N \cdot S) \) 次比较,其中 \( N \) 为主范围长度,\( S \) 为集合大小。
- 返回值:指向第一个匹配元素;无匹配时返回
last。
ranges::find_first_of 支持投影。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
std::vector<int> targets{4, 6, 2};
// 查找 targets 中任意一个元素在 v 中首次出现的位置
auto it = std::find_first_of(v.begin(), v.end(), targets.begin(), targets.end());
std::cout << *it << '\n'; // 2
}
(2) 谓词与投影
二元谓词用于自定义相等判断:
std::string text = "Hello World";
std::string vowels = "aeiouAEIOU";
// 找第一个元音
auto it = std::find_first_of(text.begin(), text.end(),
vowels.begin(), vowels.end());
std::cout << *it; // e
(3) 执行策略
find_first_of 不支持执行策略。
4. 注意事项
- 语义容易误解:它查找的是「集合中任意一个元素」,而不是「集合作为子序列」。如果想找子序列,用
search。 - 复杂度是乘积:\( O(N \cdot S) \),集合很大时性能较差。集合较大时可考虑先排序再二分,或使用哈希集合。
- 集合不能为空:如果
[s_first, s_last)为空,返回last。 - 有序范围可用二分:如果两个范围都有序,可以用
std::set_intersection更高效地求交集。
5. 相关算法
- search / search_n:查找完整的子序列
- find_end:查找子序列的最后一次出现
- find / find_if:查找单个元素
- 集合操作:在有序范围上求交集
std::adjacent_find
std::adjacent_find 在范围内查找第一对相邻且相等的元素(或第一对满足给定二元谓词的相邻元素),返回指向这对元素中第一个的迭代器。
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt>
constexpr ForwardIt adjacent_find(ForwardIt first, ForwardIt last);
template<class ForwardIt, class BinaryPred>
constexpr ForwardIt adjacent_find(ForwardIt first, ForwardIt last, BinaryPred p);
- 迭代器要求:
ForwardIterator。 - 复杂度:恰好
min(N-1, 找到位置)次比较。 - 返回值:指向第一对相邻匹配元素中的第一个;未找到时返回
last。 - 空范围或单元素范围:返回
last。
ranges::adjacent_find 支持投影。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 3, 4, 5};
auto it = std::adjacent_find(v.begin(), v.end());
if (it != v.end())
std::cout << "重复元素: " << *it << " 索引: " << (it - v.begin()) << '\n';
// 重复元素: 3 索引: 2
}
(2) 谓词与投影
用二元谓词自定义「相邻关系」,例如查找相邻的逆序对:
std::vector<int> v{1, 3, 2, 4, 5};
// 找第一对降序相邻元素
auto it = std::adjacent_find(v.begin(), v.end(),
[](int a, int b) { return a > b; });
std::cout << *it << ' ' << *(it + 1); // 3 2
(3) 执行策略
adjacent_find 不支持执行策略,因为需要顺序比较相邻元素。
4. 注意事项
- 只找相邻的:它不会找出所有重复元素,只找第一对相邻的重复。要找出全部重复,需要排序或用哈希表。
- 配合
unique使用:adjacent_find常用来判断范围内是否有连续重复,而unique用来移除它们。 - 返回值是第一个元素:返回的是这对元素中的第一个,不是第二个。
- 需要 forward 迭代器。
5. 相关算法
- unique:移除连续重复元素
- find / find_if:查找单个元素
- search_n:查找连续出现 \( N \) 次的元素
- count / count_if:统计元素数量
std::count / std::count_if
这两个算法统计范围内满足条件的元素数量:
count:统计等于给定值的元素个数count_if:统计使谓词返回true的元素个数
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class T>
constexpr typename iterator_traits<InputIt>::difference_type
count(InputIt first, InputIt last, const T& value);
template<class InputIt, class UnaryPred>
constexpr typename iterator_traits<InputIt>::difference_type
count_if(InputIt first, InputIt last, UnaryPred p);
- 迭代器要求:
InputIterator。 - 复杂度:恰好 \( O(n) \) 次比较或谓词调用。
- 返回值:
difference_type(有符号整数类型),不是size_t。 - 空范围:返回
0。
ranges::count / ranges::count_if 支持投影。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 2, 4, 2, 5};
// 统计值为 2 的元素个数
auto n1 = std::count(v.begin(), v.end(), 2);
std::cout << n1 << '\n'; // 3
// 统计偶数个数
auto n2 = std::count_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; });
std::cout << n2 << '\n'; // 4
}
(2) 谓词与投影
ranges::count 支持投影,可以按成员统计:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}, {"Carol", 30}};
// 统计年龄为 30 的人数
auto n = std::ranges::count(people, 30, &Person::age); // 2
(3) 执行策略
count 和 count_if 不支持执行策略。如果需要并行统计,可以用 transform_reduce 把「是否满足条件」映射为 0/1 后求和。
// 并行统计偶数的替代写法
auto n = std::transform_reduce(std::execution::par,
v.begin(), v.end(), 0, std::plus<>{},
[](int x) { return x % 2 == 0 ? 1 : 0; });
4. 注意事项
- 返回类型是有符号的:
difference_type而非size_t,与size()比较时注意符号问题,可能触发编译器警告。 count用operator==:自定义类型需要提供operator==。- 不要与
count_if混淆:count接受值,count_if接受谓词。 - 大数据量考虑并行:如上所示,可用
transform_reduce替代以获得并行加速。
5. 相关算法
- all_of / any_of / none_of:判断是否全部/存在/没有满足条件
- find / find_if:查找第一个满足条件的元素
- transform_reduce:并行归约统计
- equal:比较两个范围
std::mismatch
std::mismatch 比较两个范围,返回第一对不相等的元素位置。如果所有对应元素都相等,则返回两个范围各自的末尾。
1. 引入
#include <algorithm>
2. 原理
// C++14 起有四个迭代器版本(可比较不同长度的范围)
template<class InputIt1, class InputIt2>
constexpr std::pair<InputIt1, InputIt2>
mismatch(InputIt1 first1, InputIt1 last1, InputIt2 first2);
template<class InputIt1, class InputIt2>
constexpr std::pair<InputIt1, InputIt2>
mismatch(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2);
template<class InputIt1, class InputIt2, class BinaryPred>
constexpr std::pair<InputIt1, InputIt2>
mismatch(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2, BinaryPred p);
- 迭代器要求:
InputIterator。 - 复杂度:至多
min(N1, N2)次比较。 - 返回值:
std::pair,包含两个范围中第一个不匹配位置的迭代器。
C++14 新增的双范围重载(带 last2)更安全,因为可以处理两个范围长度不同的情况。C++98 版本只接受第二个范围的首迭代器,需要调用者保证第二个范围足够长。
ranges::mismatch 返回 ranges::mismatch_result。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> a{1, 2, 3, 4, 5};
std::vector<int> b{1, 2, 9, 4, 5};
auto [it1, it2] = std::mismatch(a.begin(), a.end(), b.begin(), b.end());
if (it1 != a.end())
std::cout << "首个不同: " << *it1 << " vs " << *it2 << '\n';
// 首个不同: 3 vs 9
}
(2) 谓词与投影
可以传入二元谓词自定义「相等」:
std::string s1 = "HELLO";
std::string s2 = "hello";
// 忽略大小写比较
auto [i1, i2] = std::mismatch(s1.begin(), s1.end(), s2.begin(), s2.end(),
[](char a, char b) {
return std::tolower(a) == std::tolower(b);
});
std::cout << std::boolalpha << (i1 == s1.end()); // true(完全相同)
(3) 执行策略
mismatch 不支持执行策略,因为需要返回第一个不匹配位置。
4. 注意事项
- 优先用双范围重载:C++14 起的四迭代器版本能正确处理长度不同的范围,避免越界。
- 长度不同时的行为:单范围版本(三参数)只比较
min(N1, N2)个元素,若第一个范围更长则不会发现问题。 - 返回
pair:C++17 起可以用结构化绑定auto [it1, it2] = ...简化代码。 - 与
equal的区别:mismatch返回位置,equal只返回布尔值。
5. 相关算法
- equal:只判断两个范围是否相同
- lexicographical_compare:按字典序比较
- search:查找子序列
- find / find_if:在单个范围内查找
std::equal
std::equal 判断两个范围内的元素是否逐一相等(或满足给定二元谓词),返回布尔值。
1. 引入
#include <algorithm>
2. 原理
// C++14 起有四个迭代器版本
template<class InputIt1, class InputIt2>
constexpr bool equal(InputIt1 first1, InputIt1 last1, InputIt2 first2);
template<class InputIt1, class InputIt2>
constexpr bool equal(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2);
template<class InputIt1, class InputIt2, class BinaryPred>
constexpr bool equal(InputIt1 first1, InputIt1 last1, InputIt2 first2, InputIt2 last2,
BinaryPred p);
- 迭代器要求:
InputIterator。 - 复杂度:至多
min(N1, N2)次比较。 - 返回值:全部相等返回
true,否则false。 - 空范围:两个空范围比较返回
true。
重要:C++14 之前的三参数版本只比较 N1 个元素,如果第二个范围更短会导致越界(未定义行为);如果第二个范围更长,则多余的尾部元素会被忽略。C++14 起的四参数版本才会真正比较长度。
ranges::equal 支持投影,并且可以接受不同长度的范围。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> a{1, 2, 3};
std::vector<int> b{1, 2, 3};
std::vector<int> c{1, 2, 3, 4};
std::cout << std::boolalpha
<< std::equal(a.begin(), a.end(), b.begin(), b.end()) << '\n' // true
<< std::equal(a.begin(), a.end(), c.begin(), c.end()) << '\n'; // false
}
(2) 谓词与投影
std::string s1 = "HELLO";
std::string s2 = "hello";
// 忽略大小写比较
bool same = std::equal(s1.begin(), s1.end(), s2.begin(), s2.end(),
[](char a, char b) {
return std::tolower(a) == std::tolower(b);
});
std::cout << std::boolalpha << same; // true
ranges::equal 还支持投影:
struct Person { std::string name; int age; };
std::vector<Person> p1{{"Alice", 30}}, p2{{"Bob", 30}};
bool sameAge = std::ranges::equal(p1, p2, {}, &Person::age, &Person::age); // true
(3) 执行策略
std::equal 不支持执行策略。但 ranges::equal 在 C++26 的并行范围算法中也不支持——它需要短路返回,无法有效并行化。
4. 注意事项
- 务必使用四参数版本:三参数版本不检查第二个范围的长度,是经典的越界陷阱。
operator==要求:equal使用operator==比较元素。- 浮点数比较:直接比较浮点数通常不合适,应传入自定义谓词做误差容忍比较。
- 与
mismatch的选择:需要知道「哪里不同」时用mismatch,只需要「是否相同」时用equal。 - 性能:
equal会短路返回,遇到第一个不相等就停止。
5. 相关算法
- mismatch:返回第一个不匹配的位置
- lexicographical_compare:按字典序比较
- is_permutation:判断是否为排列关系
- search:查找子序列
std::search / std::search_n
这两个算法用于搜索子序列:
search:在范围内查找子序列[s_first, s_last)第一次出现的位置search_n:查找某个元素连续出现 \( N \) 次的第一次出现位置
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt1, class ForwardIt2>
constexpr ForwardIt1 search(ForwardIt1 first, ForwardIt1 last,
ForwardIt2 s_first, ForwardIt2 s_last);
template<class ForwardIt, class Size, class T>
constexpr ForwardIt search_n(ForwardIt first, ForwardIt last, Size count, const T& value);
- 迭代器要求:
ForwardIterator。 - 复杂度:
search至多 \( O(S \cdot N) \) 次比较;search_n至多 \( O(N) \) 次比较。 - 返回值:指向匹配子序列的起始位置;未找到时返回
last。 - 空子序列:
search对空子序列返回first。
C++17 起 search 增加了接受 Searcher 策略的重载(如 std::boyer_moore_searcher),可以显著加速大文本搜索。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 1, 2, 3};
std::vector<int> sub{1, 2, 3};
// 查找子序列第一次出现
auto it = std::search(v.begin(), v.end(), sub.begin(), sub.end());
std::cout << "起始索引: " << (it - v.begin()) << '\n'; // 0
// 查找连续 3 个 2
std::vector<int> v2{1, 2, 2, 2, 3};
auto it2 = std::search_n(v2.begin(), v2.end(), 3, 2);
std::cout << "起始索引: " << (it2 - v2.begin()) << '\n'; // 1
}
(2) 谓词与投影
// 忽略大小写查找子串
std::string text = "Hello World";
std::string pat = "WORLD";
auto it = std::search(text.begin(), text.end(), pat.begin(), pat.end(),
[](char a, char b) {
return std::tolower(a) == std::tolower(b);
});
std::cout << (it != text.end() ? "found" : "not found"); // found
(3) 执行策略
search 和 search_n 支持 C++17 执行策略:
#include <execution>
auto it = std::search(std::execution::par, v.begin(), v.end(),
sub.begin(), sub.end());
但并行版本会失去短路特性,可能扫描整个范围,对小数据反而更慢。
4. 注意事项
searchvsfind_end:search找第一次,find_end找最后一次。- 大文本搜索用 Searcher:C++17 的
boyer_moore_searcher、boyer_moore_horspool_searcher可大幅提升性能。 search_n的count必须为正:如果count <= 0,行为未定义。- 空子序列:
search对空子序列返回first,容易忽略。 std::string::find更直接:对字符串搜索,直接用std::string::find通常更简洁。
5. 相关算法
- find_end:查找子序列的最后一次出现
- find_first_of:查找集合中的任意一个元素
- contains:判断是否包含子范围
- starts_with / ends_with:判断前缀/后缀
- find / find_if:查找单个元素
std::ranges::contains / std::ranges::contains_subrange
这两个算法用于判断范围是否包含给定内容,只有 Ranges 版本:
ranges::contains:判断范围是否包含给定元素ranges::contains_subrange:判断范围是否包含给定子范围
它们是 find(...) != end() 和 search(...) != end() 的语义化封装,代码更清晰。
1. 引入
#include <algorithm>
2. 原理
template<std::input_iterator I, std::sentinel_for<I> S, class T, class Proj = std::identity>
requires std::indirect_binary_predicate<ranges::equal_to,
std::projected<I, Proj>, const T*>
constexpr bool contains(I first, S last, const T& value, Proj proj = {});
template<ranges::input_range R, class T, class Proj = std::identity>
requires std::indirect_binary_predicate<ranges::equal_to,
std::projected<ranges::iterator_t<R>, Proj>, const T*>
constexpr bool contains(R&& r, const T& value, Proj proj = {});
- 迭代器要求:
InputIterator。 - 复杂度:
contains至多 \( O(n) \) 次比较;contains_subrange至多 \( O(S \cdot N) \)。 - 返回值:布尔值。
- 空子范围:
contains_subrange对空子范围返回true。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
std::cout << std::boolalpha
<< std::ranges::contains(v, 3) << '\n' // true
<< std::ranges::contains(v, 9) << '\n'; // false
std::vector<int> sub{3, 4};
std::cout << std::ranges::contains_subrange(v, sub) << '\n'; // true
}
(2) 谓词与投影
支持投影,可以按成员判断:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}};
bool has30 = std::ranges::contains(people, 30, &Person::age); // true
(3) 执行策略
不支持执行策略,因为需要短路返回。
4. 注意事项
- 只有 Ranges 版本:C++23 才引入,且必须用
std::ranges::contains,没有std::contains。 - 对有序范围仍用二分:
contains是线性查找,有序范围应改用std::ranges::binary_search。 contains与find的选择:只需要「是否存在」时用contains,需要「位置」时用find。- 关联容器也有
contains:C++20 起std::set、std::map等关联容器有成员函数contains,性能是 \( O(\log n) \) 或平均 \( O(1) \),比ranges::contains更快。
5. 相关算法
- find / find_if:查找元素位置
- search / search_n:查找子序列位置
- starts_with / ends_with:判断前缀/后缀
- count / count_if:统计元素数量
std::ranges::starts_with / std::ranges::ends_with
这两个算法判断一个范围是否以另一个范围开始或结束,只有 Ranges 版本:
ranges::starts_with:判断范围是否以给定范围作为前缀ranges::ends_with:判断范围是否以给定范围作为后缀
1. 引入
#include <algorithm>
2. 原理
template<std::input_iterator I1, std::sentinel_for<I1> S1,
std::input_iterator I2, std::sentinel_for<I2> S2,
class Pred = ranges::equal_to, class Proj1 = std::identity, class Proj2 = std::identity>
requires (std::forward_iterator<I1> || std::sized_sentinel_for<S1, I1>) &&
(std::forward_iterator<I2> || std::sized_sentinel_for<S2, I2>) &&
std::indirectly_comparable<I1, I2, Pred, Proj1, Proj2>
constexpr bool starts_with(I1 first1, S1 last1, I2 first2, S2 last2,
Pred pred = {}, Proj1 proj1 = {}, Proj2 proj2 = {});
- 迭代器要求:
InputIterator(但需要能判断长度,或为 forward 迭代器)。 - 复杂度:至多
min(N1, N2)次比较。 - 返回值:布尔值。
- 空前缀/后缀:对空范围返回
true。
ends_with 的实现需要先求出两个范围的长度,因此对非 forward 迭代器会先计算距离。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
std::cout << std::boolalpha
<< std::ranges::starts_with(v, std::vector{1, 2}) << '\n' // true
<< std::ranges::ends_with(v, std::vector{4, 5}) << '\n'; // true
// 字符串场景
std::string s = "hello world";
std::cout << std::ranges::starts_with(s, std::string_view{"hello"}) << '\n'; // true
}
(2) 谓词与投影
可以传入自定义比较谓词,例如忽略大小写:
std::string s = "Hello World";
std::string prefix = "HELLO";
bool ok = std::ranges::starts_with(s, prefix,
[](char a, char b) {
return std::tolower(a) == std::tolower(b);
}); // true
(3) 执行策略
不支持执行策略,因为需要短路返回。
4. 注意事项
- 只有 Ranges 版本:C++23 才引入,没有
std::starts_with。 - 字符串有更直接的替代:
std::string::starts_with和std::string::ends_with(C++20)更简洁高效。 ends_with的开销:对非 forward 迭代器需要先求长度,可能多一次遍历。- 空范围返回
true:任何范围都以空范围开始和结束,这符合数学定义但需注意。
5. 相关算法
- contains:判断是否包含元素或子范围
- search / search_n:查找子序列位置
- equal:比较两个范围是否相同
- mismatch:查找第一个不匹配位置
std::ranges::fold_left / fold_right 系列
C++23 引入的折叠算法对范围内的元素进行左折叠或右折叠,相当于 C++17 折叠表达式((... op pack))的运行时版本。它们只有 Ranges 版本。
| 算法 | 说明 |
|---|---|
fold_left | 左折叠,需要提供初始值 |
fold_left_first | 左折叠,用第一个元素作为初始值 |
fold_right | 右折叠,需要提供初始值 |
fold_right_last | 右折叠,用最后一个元素作为初始值 |
fold_left_with_iter | 左折叠,返回 in_value_result(迭代器 + 值) |
fold_left_first_with_iter | 左折叠(首元素为初值),返回 in_value_result |
1. 引入
#include <algorithm>
2. 原理
折叠的本质是把二元操作依次应用到元素上。以左折叠为例,fold_left(r, init, f) 计算:
$$ f(f(f(init, x_1), x_2), \dots, x_n) $$
而右折叠 fold_right(r, init, f) 计算:
$$ f(x_1, f(x_2, f(\dots, f(x_n, init)))) $$
- 迭代器要求:
InputIterator(fold_right系列需要BidirectionalIterator)。 - 复杂度:恰好 \( n \) 次函数调用。
- 返回值:折叠结果;
*_with_iter版本返回ranges::in_value_result。 - 空范围:
fold_left/fold_right返回初始值;fold_left_first/fold_right_last对空范围返回std::optional的空值。
函数签名(以 fold_left 为例):
template<std::input_iterator I, std::sentinel_for<I> S, class T,
class F = std::plus<>>
requires std::indirectly_binary_left_foldable<F, T, I>
constexpr auto fold_left(I first, S last, T init, F f = {});
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 左折叠求和:(0+1)+2)+3)+4)+5
int sum = std::ranges::fold_left(v, 0, std::plus<>{});
std::cout << sum << '\n'; // 15
// 用第一个元素作为初始值
int sum2 = std::ranges::fold_left_first(v, std::plus<>{}).value();
std::cout << sum2 << '\n'; // 15
// 左折叠求最大值
int mx = std::ranges::fold_left(v, v.front(),
[](int a, int b) { return std::max(a, b); });
std::cout << mx << '\n'; // 5
}
(2) 谓词与投影
折叠算法接受二元操作 f,本身没有投影参数,但可以在 f 内部处理:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}, {"Carol", 35}};
// 求年龄总和
int total = std::ranges::fold_left(people, 0,
[](int acc, const Person& p) { return acc + p.age; });
std::cout << total; // 90
左折叠与右折叠对非结合运算结果不同,例如字符串拼接:
std::vector<std::string> words{"a", "b", "c"};
auto l = std::ranges::fold_left(words, std::string{}, std::plus<>{}); // "abc"
auto r = std::ranges::fold_right(words, std::string{}, std::plus<>{}); // "abc"
// 但对减法这类运算,两者结果不同
(3) 执行策略
折叠算法不支持执行策略。它们有严格的求值顺序要求,无法并行化。需要并行归约时应使用 std::reduce 或 std::transform_reduce。
4. 注意事项
- 只有 Ranges 版本:C++23 才引入,且没有
std::fold_left经典版本。 fold_left_first返回optional:对空范围返回空optional,使用前需检查或调用.value()(可能抛异常)。fold_right需要双向迭代器:因为要从后往前遍历。- 结合律很重要:如果
f不满足结合律,左折叠和右折叠结果不同,需根据语义选择。 - 与
accumulate的区别:std::accumulate是左折叠的经典版本,但只支持迭代器对且没有optional返回;fold_left_first等是更现代的补充。 - 与
reduce的区别:reduce允许任意顺序,要求运算满足交换律;折叠算法严格按顺序求值。
5. 相关算法
- accumulate:经典左折叠
- reduce:允许无序的并行归约
- transform_reduce:先变换再归约
- for_each:只遍历不求值
修改序列操作 (Modifying sequence operations)
修改序列操作会改变范围内元素的值或位置,但通常不改变容器的大小。需要特别注意「移除类」算法只是逻辑移除——它们把保留的元素移到范围前部,并返回新的逻辑末尾,真正的删除仍需配合容器的 erase。
主要头文件:
<algorithm>:包含绝大多数经典的修改序列操作。<ranges>:包含所有ranges::版本的算法。<utility>:包含std::swap和std::iter_swap(C++11 起)。
算法总览
| 算法 | C++ 版本 | 头文件 | 描述 |
|---|---|---|---|
| copy / copy_if / copy_n | (C++11) | <algorithm> | 将元素范围复制到新位置 |
| copy_backward | <algorithm> | 以向后顺序复制一个元素范围 | |
| move / move_backward | (C++11) | <algorithm> | 将元素范围移动(或向后移动)到新位置 |
| swap / swap_ranges / iter_swap | (C++11) | <utility> | 交换两个对象、范围或迭代器所指元素 |
| transform | <algorithm> | 对元素范围应用函数并写入目标范围 | |
| replace / replace_if | <algorithm> | 将满足条件的值替换为另一个值 | |
| replace_copy / replace_copy_if | <algorithm> | 复制范围并替换其中满足条件的元素 | |
| fill / fill_n | <algorithm> | 将给定值赋值给范围内的元素 | |
| generate / generate_n | <algorithm> | 将连续函数调用的结果赋值给元素 | |
| remove / remove_if | <algorithm> | 逻辑移除满足条件的元素 | |
| remove_copy / remove_copy_if | <algorithm> | 复制范围并省略满足条件的元素 | |
| unique / unique_copy | <algorithm> | 移除(或复制时省略)连续重复元素 | |
| reverse / reverse_copy | <algorithm> | 反转元素顺序或创建反转副本 | |
| rotate / rotate_copy | <algorithm> | 旋转元素顺序或复制并旋转 | |
| shift_left / shift_right | (C++20) | <algorithm> | 左移或右移范围内的元素 |
| shuffle | (C++11) | <algorithm> | 随机重新排序范围内的元素 |
| sample | (C++17) | <algorithm> | 从序列中随机选择 \( N \) 个元素 |
说明: 表中「C++ 版本」列标注的是该算法(或
ranges::版本)首次引入的标准版本,留空表示自 C++98 起即存在。
共性说明
- 移除类算法:
remove、remove_if、unique属于逻辑移除,必须配合容器的erase才能真删除,这就是所谓的 erase-remove 惯用法(C++20 起可用std::erase/std::erase_if替代)。 - 重叠范围:
copy要求目标范围不与源范围重叠,向右复制时应改用copy_backward;move与move_backward同理。 - 执行策略:
copy、move、fill、transform、replace、remove、reverse、rotate、shuffle、generate等支持 C++17 执行策略。
std::copy / std::copy_if / std::copy_n
这三个算法把元素复制到目标位置:
copy:复制整个范围copy_if:只复制满足谓词的元素(C++11)copy_n:复制指定数量的元素(C++11)
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class OutputIt>
constexpr OutputIt copy(InputIt first, InputIt last, OutputIt d_first);
template<class InputIt, class OutputIt, class UnaryPred>
constexpr OutputIt copy_if(InputIt first, InputIt last, OutputIt d_first, UnaryPred pred);
template<class InputIt, class Size, class OutputIt>
constexpr OutputIt copy_n(InputIt first, Size count, OutputIt result);
- 迭代器要求:输入为
InputIterator,输出为OutputIterator。 - 复杂度:
copy恰好 \( n \) 次赋值;copy_if恰好 \( n \) 次谓词调用,至多 \( n \) 次赋值。 - 返回值:指向目标范围中最后一个被复制元素之后的位置。
copy 的经典实现会对 TriviallyCopyable 类型且迭代器为 contiguous_iterator 的情况调用 std::memmove,因此对 int、char 等类型非常高效。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> src{1, 2, 3, 4, 5};
// 复制到已分配空间的容器
std::vector<int> dst(src.size());
std::copy(src.begin(), src.end(), dst.begin());
// 复制到空容器:用 back_inserter
std::vector<int> dst2;
std::copy(src.begin(), src.end(), std::back_inserter(dst2));
// copy_if:只复制偶数
std::vector<int> evens;
std::copy_if(src.begin(), src.end(), std::back_inserter(evens),
[](int x) { return x % 2 == 0; }); // evens = {2, 4}
// copy_n:复制前 3 个
std::vector<int> first3(3);
std::copy_n(src.begin(), 3, first3.begin()); // first3 = {1, 2, 3}
}
(2) 谓词与投影
copy_if 接受一元谓词;ranges::copy_if 还支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 17}, {"Carol", 25}};
std::vector<Person> adults;
std::ranges::copy_if(people, std::back_inserter(adults),
[](int age) { return age >= 18; }, &Person::age);
// adults = {Alice, Carol}
(3) 执行策略
copy、copy_if、copy_n 都支持 C++17 执行策略:
#include <execution>
std::copy(std::execution::par, src.begin(), src.end(), dst.begin());
并行版本会失去短路特性,copy_if 在并行下仍保持稳定(保留相对顺序)。
4. 注意事项
- 目标空间必须足够:
copy不检查目标范围大小,写越界是未定义行为。用back_inserter可以自动扩容。 - 范围不能重叠:如果目标范围与源范围重叠,应改用
copy_backward(向右复制)或std::copy配合临时缓冲。 copy_if是稳定的:保持被复制元素的相对顺序。copy_n的count非负:count < 0时行为未定义。- 优先用
ranges::copy:返回in_out_result,同时给出输入和输出范围的末尾,便于链式操作。
5. 相关算法
- copy_backward:从后往前复制,用于重叠范围
- move / move_backward:移动而非复制
- remove_copy:复制时省略元素
- replace_copy:复制时替换元素
- uninitialized_copy:复制到未初始化内存
std::copy_backward
std::copy_backward 以从后往前的顺序把范围 [first, last) 的元素复制到以 d_last 结尾的目标位置,返回指向目标范围首元素的迭代器。
它主要用于目标范围与源范围重叠且目标在右侧的场景。
1. 引入
#include <algorithm>
2. 原理
template<class BidirIt1, class BidirIt2>
constexpr BidirIt2 copy_backward(BidirIt1 first, BidirIt1 last, BidirIt2 d_last);
- 迭代器要求:
BidirectionalIterator(因为要从后往前)。 - 复杂度:恰好 \( n \) 次赋值。
- 返回值:指向目标范围中第一个被复制元素的迭代器。
复制顺序是从 *(last-1) 到 *(d_last-1),然后依次向前。这样即使目标范围与源范围有重叠,只要目标在右侧,也不会覆盖尚未复制的元素。
重叠范围的选择规则:
| 目标位置 | 应使用的算法 |
|---|---|
| 目标在左侧(目标末尾在源范围之前) | copy |
| 目标在右侧(目标开头在源范围之后) | copy_backward |
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 把前 3 个元素复制到末尾(目标在右侧,重叠)
std::copy_backward(v.begin(), v.begin() + 3, v.end());
// v 变为 {1, 2, 3, 1, 2, 3}? 不对——原 v 只有 5 个元素
// 实际结果:{1, 2, 1, 2, 3}
for (int x : v) std::cout << x << ' '; // 1 2 1 2 3
}
更典型的例子是「在容器内部右移一段元素」:
std::vector<int> v{1, 2, 3, 4, 5, 0, 0}; // 后两个是预留空间
// 把 {1,2,3,4,5} 右移 2 位,得到 {0,0,1,2,3,4,5}
std::copy_backward(v.begin(), v.begin() + 5, v.end());
// v 变为 {1, 2, 1, 2, 3, 4, 5}
(2) 谓词与投影
copy_backward 没有谓词或投影参数,它总是复制整个范围。ranges::copy_backward 也不支持投影。
(3) 执行策略
copy_backward 支持 C++17 执行策略:
#include <execution>
std::copy_backward(std::execution::par, src.begin(), src.end(), dst.end());
4. 注意事项
d_last是目标范围的末尾:不是起始位置,这是最容易出错的地方。目标范围是[d_last - (last - first), d_last)。- 需要双向迭代器:
forward_list的迭代器不可用。 - 重叠判断:只有目标在右侧时才用
copy_backward;目标在左侧应该用copy。 - 返回值的含义:返回目标范围的首迭代器,而非末尾。
5. 相关算法
- copy / copy_if / copy_n:从前往后复制
- move / move_backward:移动版本
- reverse_copy:反转后复制
- rotate:原地旋转
std::move / std::move_backward
这两个算法把范围内的元素移动到目标位置(通过移动赋值/构造),源范围的元素处于「有效但未指定」的状态。
move:从前往后移动move_backward:从后往前移动(用于目标在右侧的重叠场景)
注意:这里的 std::move 是算法(定义在 <algorithm>),与 <utility> 中的 std::move(类型转换)同名但完全不同。
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class OutputIt>
constexpr OutputIt move(InputIt first, InputIt last, OutputIt d_first);
template<class BidirIt1, class BidirIt2>
constexpr BidirIt2 move_backward(BidirIt1 first, BidirIt1 last, BidirIt2 d_last);
- 迭代器要求:
move需要InputIterator;move_backward需要BidirectionalIterator。 - 复杂度:恰好 \( n \) 次移动赋值。
- 返回值:指向目标范围中最后一个被移动元素之后的位置(
move_backward返回首元素位置)。
移动语义避免了拷贝开销,对持有资源的类型(如 std::string、std::vector)尤其高效。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> src{"hello", "world", "foo"};
// 移动到新容器
std::vector<std::string> dst(src.size());
std::move(src.begin(), src.end(), dst.begin());
// dst = {"hello", "world", "foo"}
// src 中的字符串变为空(有效但未指定状态)
for (const auto& s : src)
std::cout << '[' << s << ']'; // [][][]
}
(2) 谓词与投影
move 没有谓词或投影参数。如果需要「有条件地移动」,可以先用 copy_if 配合 std::make_move_iterator:
std::vector<std::string> src{"a", "", "b", ""};
std::vector<std::string> dst;
std::copy_if(std::make_move_iterator(src.begin()),
std::make_move_iterator(src.end()),
std::back_inserter(dst),
[](const std::string& s) { return !s.empty(); });
// dst = {"a", "b"}
(3) 执行策略
move 和 move_backward 支持 C++17 执行策略:
#include <execution>
std::move(std::execution::par, src.begin(), src.end(), dst.begin());
4. 注意事项
- 与
std::move同名:<utility>的std::move是类型转换,这里是算法。同时using namespace std;时要注意区分。 - 源元素状态:移动后源元素处于「有效但未指定」状态,不要假设它们为空,只能重新赋值或销毁。
- 目标空间必须足够:不检查目标范围大小。
- 重叠范围:目标在右侧时用
move_backward。 vector<string>扩容:std::vector内部扩容正是用uninitialized_move把元素搬到新内存。
5. 相关算法
- copy / copy_if / copy_n:复制版本
- copy_backward:从后往前复制
- uninitialized_move:移动到未初始化内存
- remove:配合移动语义实现高效删除
std::swap / std::swap_ranges / std::iter_swap
这三个算法用于交换内容:
swap:交换两个对象的值swap_ranges:交换两个等长范围的元素iter_swap:交换两个迭代器所指向的元素
1. 引入
#include <utility> // std::swap, std::iter_swap(C++11 起)
#include <algorithm> // std::swap_ranges;C++11 前 swap 也在此
2. 原理
// <utility>
template<class T>
constexpr void swap(T& a, T& b) noexcept(/* see below */);
template<class ForwardIt1, class ForwardIt2>
constexpr void iter_swap(ForwardIt1 a, ForwardIt2 b);
// <algorithm>
template<class ForwardIt1, class ForwardIt2>
constexpr ForwardIt2 swap_ranges(ForwardIt1 first1, ForwardIt1 last1, ForwardIt2 first2);
- 迭代器要求:
swap_ranges和iter_swap需要ForwardIterator。 - 复杂度:
swap为常数;swap_ranges恰好 \( n \) 次交换。 - 返回值:
swap_ranges返回第二个范围中最后一个被交换元素之后的位置。
std::swap 的经典实现是三次移动:
template<class T>
void swap(T& a, T& b) {
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
但标准库对标准容器提供了特化版本(如 std::vector::swap),只交换内部指针,是 \( O(1) \) 的。
注意:C++11 起 std::swap 的声明从 <algorithm> 移到了 <utility>。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <utility>
#include <vector>
int main()
{
// 交换两个对象
int a = 1, b = 2;
std::swap(a, b); // a=2, b=1
// 交换两个范围的元素
std::vector<int> v1{1, 2, 3};
std::vector<int> v2{4, 5, 6};
std::swap_ranges(v1.begin(), v1.end(), v2.begin());
// v1 = {4,5,6}, v2 = {1,2,3}
// 交换两个迭代器指向的元素
std::iter_swap(v1.begin(), v1.begin() + 2);
// v1 = {6,5,4}
}
(2) 谓词与投影
这三个算法都没有谓词或投影参数。
(3) 执行策略
swap_ranges 支持 C++17 执行策略:
#include <execution>
std::swap_ranges(std::execution::par, v1.begin(), v1.end(), v2.begin());
std::swap 和 std::iter_swap 是单次操作,不需要执行策略。
4. 注意事项
- 头文件位置:C++11 起
std::swap在<utility>,不是<algorithm>。 - 对容器用成员
swap:vec1.swap(vec2)或std::swap(vec1, vec2)都是 \( O(1) \),只交换内部指针,迭代器保持有效(跟随元素转移)。 iter_swap支持 ADL:ranges::iter_swap会对iter_swap做 ADL 查找,而std::iter_swap不会。- 自定义类型的 swap:应在同命名空间提供
swap重载,以便 ADL 找到更高效的实现。 swap_ranges要求等长:两个范围必须长度相同,不检查第二个范围是否足够长。
5. 相关算法
- copy / copy_if / copy_n:复制而非交换
- move / move_backward:移动而非交换
- reverse:反转顺序
- rotate:旋转顺序
std::transform
std::transform 对输入范围内的元素应用函数,并把结果写入目标范围。它支持一元和二元两种形式:
- 一元:
transform(first, last, d_first, unary_op),把op(*it)写入目标 - 二元:
transform(first1, last1, first2, d_first, binary_op),把op(*it1, *it2)写入目标
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class OutputIt, class UnaryOp>
constexpr OutputIt transform(InputIt first, InputIt last, OutputIt d_first, UnaryOp op);
template<class InputIt1, class InputIt2, class OutputIt, class BinaryOp>
constexpr OutputIt transform(InputIt1 first1, InputIt1 last1, InputIt2 first2,
OutputIt d_first, BinaryOp op);
- 迭代器要求:输入为
InputIterator,输出为OutputIterator。 - 复杂度:恰好 \( n \) 次函数调用。
- 返回值:指向目标范围中最后一个被写入元素之后的位置。
transform 允许原地变换(目标范围等于源范围),这是它比 copy 更灵活的地方。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 一元:每个元素乘 2
std::vector<int> out(v.size());
std::transform(v.begin(), v.end(), out.begin(),
[](int x) { return x * 2; }); // out = {2,4,6,8,10}
// 原地变换
std::transform(v.begin(), v.end(), v.begin(),
[](int x) { return x * x; }); // v = {1,4,9,16,25}
// 二元:两个范围逐元素相加
std::vector<int> a{1, 2, 3}, b{10, 20, 30};
std::vector<int> sum(3);
std::transform(a.begin(), a.end(), b.begin(), sum.begin(),
std::plus<>{}); // sum = {11,22,33}
}
(2) 谓词与投影
transform 接受一元或二元操作,ranges::transform 额外支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}};
std::vector<int> ages;
std::ranges::transform(people, std::back_inserter(ages), &Person::age);
// ages = {30, 25}
配合 std::toupper 做大小写转换:
std::string s = "hello";
std::ranges::transform(s, s.begin(),
[](char c) { return std::toupper(c); }); // "HELLO"
(3) 执行策略
transform 支持 C++17 执行策略:
#include <execution>
std::transform(std::execution::par, v.begin(), v.end(), out.begin(),
[](int x) { return x * 2; });
注意:并行执行时函数对象必须是线程安全的,不能有数据竞争。
4. 注意事项
- 原地变换是允许的:目标可以等于源,这与
copy不同。但二元版本中如果目标与第二个输入范围重叠,行为未定义。 - 目标空间必须足够:不检查目标范围大小。
- 不要用
std::transform做副作用:虽然可以,但语义上应该用for_each。 std::transform与std::ranges::transform的返回类型:后者返回in_out_result,可以链式调用。std::toupper的坑:直接传std::toupper给transform可能因负值字符导致未定义行为,应包一层 lambda 并转换类型。
5. 相关算法
- for_each:只遍历不求值
- copy / copy_if / copy_n:复制时不做变换
- replace / replace_if:按条件替换
- transform_reduce:变换后归约
std::replace / std::replace_if
这两个算法把范围内满足条件的元素替换为另一个值:
replace:把所有等于old_value的元素替换为new_valuereplace_if:把所有使谓词返回true的元素替换为new_value
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt, class T>
constexpr void replace(ForwardIt first, ForwardIt last,
const T& old_value, const T& new_value);
template<class ForwardIt, class UnaryPred, class T>
constexpr void replace_if(ForwardIt first, ForwardIt last, UnaryPred p, const T& new_value);
- 迭代器要求:
ForwardIterator。 - 复杂度:恰好 \( n \) 次比较或谓词调用。
- 返回值:
void。
ranges::replace / ranges::replace_if 支持投影。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 2, 4};
// 把所有 2 替换为 9
std::replace(v.begin(), v.end(), 2, 9);
// v = {1, 9, 3, 9, 4}
// 把所有偶数替换为 0
std::replace_if(v.begin(), v.end(), [](int x) { return x % 2 == 0; }, 0);
// v = {1, 0, 3, 0, 0}
}
(2) 谓词与投影
ranges::replace_if 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 17}};
// 把未成年的年龄改为 0
std::ranges::replace_if(people, [](int age) { return age < 18; }, 0, &Person::age);
(3) 执行策略
replace 和 replace_if 支持 C++17 执行策略:
#include <execution>
std::replace(std::execution::par, v.begin(), v.end(), 2, 9);
4. 注意事项
- 原地修改:
replace直接修改原范围,不创建副本。需要保留原数据时用replace_copy。 - 返回
void:无法链式调用,但ranges::replace同样返回void(返回的是迭代器)。 replace用operator==:自定义类型需要提供operator==。- 谓词不应有副作用:标准不保证谓词调用次数。
5. 相关算法
- replace_copy / replace_copy_if:复制时替换
- transform:按函数变换所有元素
- remove / remove_if:移除而非替换
- fill / fill_n:无条件赋值
std::replace_copy / std::replace_copy_if
这两个算法复制一个范围,并在复制过程中把满足条件的元素替换为另一个值,原范围不被修改。
replace_copy:复制时把等于old_value的元素替换为new_valuereplace_copy_if:复制时把使谓词返回true的元素替换为new_value
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class OutputIt, class T>
constexpr OutputIt replace_copy(InputIt first, InputIt last, OutputIt d_first,
const T& old_value, const T& new_value);
template<class InputIt, class OutputIt, class UnaryPred, class T>
constexpr OutputIt replace_copy_if(InputIt first, InputIt last, OutputIt d_first,
UnaryPred p, const T& new_value);
- 迭代器要求:输入
InputIterator,输出OutputIterator。 - 复杂度:恰好 \( n \) 次比较或谓词调用,以及 \( n \) 次赋值。
- 返回值:指向目标范围中最后一个被写入元素之后的位置。
ranges::replace_copy / ranges::replace_copy_if 支持投影。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> src{1, 2, 3, 2, 4};
// 复制时把 2 替换为 9,原范围不变
std::vector<int> dst(src.size());
std::replace_copy(src.begin(), src.end(), dst.begin(), 2, 9);
// src = {1,2,3,2,4}(不变)
// dst = {1,9,3,9,4}
// 复制时把偶数替换为 0
std::vector<int> dst2;
std::replace_copy_if(src.begin(), src.end(), std::back_inserter(dst2),
[](int x) { return x % 2 == 0; }, 0);
// dst2 = {1,0,3,0,0}
}
(2) 谓词与投影
ranges::replace_copy_if 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 17}};
std::vector<Person> masked;
std::ranges::replace_copy_if(people, std::back_inserter(masked),
[](int age) { return age < 18; }, 0, &Person::age);
(3) 执行策略
replace_copy 和 replace_copy_if 支持 C++17 执行策略:
#include <execution>
std::replace_copy(std::execution::par, src.begin(), src.end(), dst.begin(), 2, 9);
4. 注意事项
- 目标空间必须足够:不检查目标范围大小。
- 范围不能重叠:目标范围与源范围重叠时行为未定义。
- 与
replace的选择:需要保留原数据时用replace_copy,否则用replace更省内存。 replace_copy用operator==:自定义类型需要提供operator==。
5. 相关算法
- replace / replace_if:原地替换
- copy / copy_if / copy_n:只复制不替换
- remove_copy:复制时省略元素
- transform:按函数变换所有元素
std::fill / std::fill_n
这两个算法把给定值赋给范围内的元素:
fill:给整个范围赋值fill_n:给前 \( N \) 个元素赋值
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt, class T>
constexpr void fill(ForwardIt first, ForwardIt last, const T& value);
template<class OutputIt, class Size, class T>
constexpr OutputIt fill_n(OutputIt first, Size count, const T& value);
- 迭代器要求:
fill需要ForwardIterator;fill_n需要OutputIterator。 - 复杂度:恰好 \( n \) 次赋值。
- 返回值:
fill返回void;fill_n返回最后一个被赋值元素之后的位置。
fill 的经典实现会对可平凡赋值的类型调用 std::memset,因此对 int、char 等非常高效。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v(5);
std::fill(v.begin(), v.end(), 7); // v = {7,7,7,7,7}
// 只填充前 3 个
std::vector<int> v2(5);
std::fill_n(v2.begin(), 3, 1); // v2 = {1,1,1,0,0}
// 配合 back_inserter 填充空容器
std::vector<int> v3;
std::fill_n(std::back_inserter(v3), 4, 9); // v3 = {9,9,9,9}
}
(2) 谓词与投影
fill 和 fill_n 都没有谓词参数,它们无条件赋值。需要按条件赋值时用 replace_if 或 transform。
(3) 执行策略
fill 和 fill_n 支持 C++17 执行策略:
#include <execution>
std::fill(std::execution::par, v.begin(), v.end(), 7);
4. 注意事项
fill_n的count非负:count < 0时行为未定义。- 目标空间必须足够:
fill_n不检查范围大小。 fillvsstd::array::fill:std::array和std::vector等容器有成员函数fill,用法类似。memset只适用于 0 和 -1:不要用memset给int数组赋非 0/-1 的值,那会得到错误结果(按字节填充)。- 初始化列表 vs
fill:构造时直接用std::vector<int> v(5, 7)更简洁。
5. 相关算法
- generate / generate_n:用函数返回值填充
- replace / replace_if:按条件替换
- iota:用递增序列填充
- uninitialized_fill:填充未初始化内存
std::generate / std::generate_n
这两个算法用连续调用函数对象的返回值填充范围:
generate:填充整个范围generate_n:填充前 \( N \) 个元素
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt, class Generator>
constexpr void generate(ForwardIt first, ForwardIt last, Generator g);
template<class OutputIt, class Size, class Generator>
constexpr OutputIt generate_n(OutputIt first, Size count, Generator g);
- 迭代器要求:
generate需要ForwardIterator;generate_n需要OutputIterator。 - 复杂度:恰好 \( n \) 次函数调用和赋值。
- 返回值:
generate返回void;generate_n返回最后一个被赋值元素之后的位置。
函数对象 g 按值传递,每次调用后其状态(如果有)会被保留。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
// 用递增计数器填充
int n = 0;
std::vector<int> v(5);
std::generate(v.begin(), v.end(), [&n] { return n++; });
// v = {0,1,2,3,4}
// 用随机数填充
std::mt19937 gen(std::random_device{}());
std::uniform_int_distribution<int> dist(1, 100);
std::vector<int> r(5);
std::generate(r.begin(), r.end(), [&] { return dist(gen); });
// generate_n 配合 back_inserter
std::vector<int> v2;
std::generate_n(std::back_inserter(v2), 3, [&n] { return n++; });
// v2 = {5,6,7}
}
(2) 谓词与投影
generate 接受的是生成器(无参可调用对象),没有谓词或投影参数。
(3) 执行策略
generate 和 generate_n 支持 C++17 执行策略:
#include <execution>
std::generate(std::execution::par, v.begin(), v.end(), [&] { return dist(gen); });
警告:并行执行时生成器的调用顺序不确定,如果生成器依赖共享状态(如上例的 n++),会产生数据竞争。并行版本要求生成器是线程安全的。
4. 注意事项
- 生成器按值传递:需要修改外部变量时用引用捕获。
- 并行下的状态问题:依赖内部状态的生成器(如计数器)在并行下不安全。
generate_n的count非负。iota更适合递增序列:如果只是要 0,1,2,…,用std::iota更简洁。- C++26 的
ranges::generate_random:专门用于用随机数生成器填充范围,比generate配合 lambda 更高效。
5. 相关算法
- fill / fill_n:用固定值填充
- iota:用递增序列填充
- for_each:遍历但不赋值
- transform:基于已有元素变换
std::remove / std::remove_if
这两个算法逻辑移除范围内满足条件的元素:把保留的元素移到范围前部,并返回新的逻辑末尾。它们不会真正删除元素,也不会改变容器大小。
remove:移除所有等于给定值的元素remove_if:移除所有使谓词返回true的元素
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt, class T>
constexpr ForwardIt remove(ForwardIt first, ForwardIt last, const T& value);
template<class ForwardIt, class UnaryPred>
constexpr ForwardIt remove_if(ForwardIt first, ForwardIt last, UnaryPred p);
- 迭代器要求:
ForwardIterator。 - 复杂度:恰好 \( n \) 次比较或谓词调用。
- 返回值:指向新的逻辑末尾(最后一个保留元素之后的位置)。
实现思路(以 remove 为例):
template<class ForwardIt, class T>
ForwardIt remove(ForwardIt first, ForwardIt last, const T& value)
{
first = std::find(first, last, value);
if (first != last)
for (ForwardIt i = first; ++i != last; )
if (!(*i == value))
*first++ = std::move(*i);
return first;
}
真正删除必须配合容器的 erase,这就是著名的 erase-remove 惯用法:
v.erase(std::remove(v.begin(), v.end(), value), v.end());
C++20 起可以直接用 std::erase(v, value) 或 std::erase_if(v, pred),一步完成。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 2, 4, 2, 5};
// 逻辑移除所有 2
auto newEnd = std::remove(v.begin(), v.end(), 2);
// v 的物理内容可能是 {1,3,4,5,4,2,5}(后面是残留元素)
v.erase(newEnd, v.end()); // 真正删除
// v = {1,3,4,5}
// erase-remove 惯用法(一行)
std::vector<int> v2{1, 2, 3, 4, 5, 6};
v2.erase(std::remove_if(v2.begin(), v2.end(),
[](int x) { return x % 2 == 0; }),
v2.end());
// v2 = {1,3,5}
// C++20 起更简洁
std::vector<int> v3{1, 2, 3, 4, 5, 6};
std::erase_if(v3, [](int x) { return x % 2 == 0; }); // v3 = {1,3,5}
}
(2) 谓词与投影
ranges::remove / ranges::remove_if 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 17}};
people.erase(std::ranges::remove_if(people,
[](int age) { return age < 18; },
&Person::age).begin(),
people.end());
(3) 执行策略
remove 和 remove_if 支持 C++17 执行策略:
#include <execution>
std::remove(std::execution::par, v.begin(), v.end(), 2);
4. 注意事项
- 不改变容器大小:这是最关键的坑。忘记配合
erase会留下残留元素。 - 残留元素的状态:被「移除」的元素仍在容器中,处于有效但未指定的状态。
- C++20 的替代:
std::erase/std::erase_if更安全简洁,推荐优先使用。 - 对
list用成员函数:std::list::remove和std::list::remove_if会真正删除元素,且是 \( O(n) \)。 remove用operator==:自定义类型需要提供operator==。
5. 相关算法
- remove_copy / remove_copy_if:复制时省略元素
- unique:移除连续重复元素
- replace / replace_if:替换而非移除
- partition:按谓词分组
std::remove_copy / std::remove_copy_if
这两个算法复制一个范围,并在复制过程中省略满足条件的元素,原范围不被修改。
remove_copy:省略所有等于给定值的元素remove_copy_if:省略所有使谓词返回true的元素
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class OutputIt, class T>
constexpr OutputIt remove_copy(InputIt first, InputIt last, OutputIt d_first, const T& value);
template<class InputIt, class OutputIt, class UnaryPred>
constexpr OutputIt remove_copy_if(InputIt first, InputIt last, OutputIt d_first, UnaryPred p);
- 迭代器要求:输入
InputIterator,输出OutputIterator。 - 复杂度:恰好 \( n \) 次比较或谓词调用,至多 \( n \) 次赋值。
- 返回值:指向目标范围中最后一个被写入元素之后的位置。
ranges::remove_copy / ranges::remove_copy_if 支持投影。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> src{1, 2, 3, 2, 4, 2, 5};
// 复制时省略所有 2
std::vector<int> dst;
std::remove_copy(src.begin(), src.end(), std::back_inserter(dst), 2);
// src 不变,dst = {1,3,4,5}
// 复制时省略偶数
std::vector<int> odds;
std::remove_copy_if(src.begin(), src.end(), std::back_inserter(odds),
[](int x) { return x % 2 == 0; });
// odds = {1,3,5}
}
(2) 谓词与投影
ranges::remove_copy_if 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 17}};
std::vector<Person> adults;
std::ranges::remove_copy_if(people, std::back_inserter(adults),
[](int age) { return age < 18; }, &Person::age);
// adults = {Alice}
(3) 执行策略
remove_copy 和 remove_copy_if 支持 C++17 执行策略:
#include <execution>
std::remove_copy(std::execution::par, src.begin(), src.end(), dst.begin(), 2);
4. 注意事项
- 目标空间必须足够:不检查目标范围大小,用
back_inserter最安全。 - 范围不能重叠。
- 与
remove的区别:remove原地逻辑移除,需要配合erase;remove_copy生成新序列,原范围不动。 remove_copy用operator==。
5. 相关算法
- remove / remove_if:原地逻辑移除
- copy / copy_if / copy_n:复制时不省略
- replace_copy:复制时替换
- unique_copy:复制时去除连续重复
std::unique / std::unique_copy
这两个算法处理连续重复元素:
unique:逻辑移除范围内连续重复的元素,只保留每组重复的第一个unique_copy:复制时省略连续重复的元素
重要:它们只处理相邻的重复元素。要移除所有重复,必须先排序。
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt>
constexpr ForwardIt unique(ForwardIt first, ForwardIt last);
template<class ForwardIt, class BinaryPred>
constexpr ForwardIt unique(ForwardIt first, ForwardIt last, BinaryPred p);
template<class InputIt, class OutputIt>
constexpr OutputIt unique_copy(InputIt first, InputIt last, OutputIt d_first);
- 迭代器要求:
unique需要ForwardIterator;unique_copy输入InputIterator、输出OutputIterator。 - 复杂度:恰好 \( n-1 \) 次比较。
- 返回值:
unique返回新的逻辑末尾;unique_copy返回目标范围的末尾。
和 remove 一样,unique 只是逻辑移除,需要配合 erase:
v.erase(std::unique(v.begin(), v.end()), v.end());
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
// 只移除相邻重复
std::vector<int> v{1, 1, 2, 2, 2, 3, 1, 1};
v.erase(std::unique(v.begin(), v.end()), v.end());
// v = {1,2,3,1}(注意最后的 1 保留了,因为它不与前面的 3 相邻)
// 移除所有重复:先排序
std::vector<int> v2{3, 1, 2, 1, 3, 2};
std::sort(v2.begin(), v2.end());
v2.erase(std::unique(v2.begin(), v2.end()), v2.end());
// v2 = {1,2,3}
// unique_copy
std::vector<int> src{1, 1, 2, 3, 3, 3, 4};
std::vector<int> dst;
std::unique_copy(src.begin(), src.end(), std::back_inserter(dst));
// dst = {1,2,3,4}
}
(2) 谓词与投影
二元谓词自定义「相等」判断:
// 忽略大小写去除连续重复
std::string s = "aAbBcC";
s.erase(std::unique(s.begin(), s.end(),
[](char a, char b) {
return std::tolower(a) == std::tolower(b);
}),
s.end());
// s = "aAbBcC"(因为 a 和 A 相邻,A 被移除;结果 "aAbBcC" -> "abc"? 实际为 "aAbBcC" 的相邻比较)
ranges::unique 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"A", 30}, {"B", 30}, {"C", 25}};
people.erase(std::ranges::unique(people, {}, &Person::age).begin(), people.end());
// 相邻年龄相同的被去除,保留 {A, C}
(3) 执行策略
unique 和 unique_copy 支持 C++17 执行策略。
4. 注意事项
- 只处理相邻重复:这是最大的坑。要移除所有重复必须先排序。
- 不改变容器大小:需要配合
erase。 unique后元素顺序:保留每组重复的第一个元素,顺序不变。- 排序会打乱顺序:如果顺序重要,不能用「排序 + unique」的方式去重,应改用哈希表或
std::set。 - 对
list用成员函数:std::list::unique会真正删除元素。
5. 相关算法
- remove / remove_if:按值或谓词移除
- adjacent_find:查找相邻重复
- sort / stable_sort:排序后去重
- set_operations:有序范围上的集合运算
std::reverse / std::reverse_copy
这两个算法反转元素顺序:
reverse:原地反转范围内的元素顺序reverse_copy:把反转后的序列复制到目标范围,原范围不变
1. 引入
#include <algorithm>
2. 原理
template<class BidirIt>
constexpr void reverse(BidirIt first, BidirIt last);
template<class BidirIt, class OutputIt>
constexpr OutputIt reverse_copy(BidirIt first, BidirIt last, OutputIt d_first);
- 迭代器要求:
reverse需要BidirectionalIterator;reverse_copy输入需要BidirectionalIterator,输出OutputIterator。 - 复杂度:恰好 \( n/2 \) 次交换(
reverse)或 \( n \) 次赋值(reverse_copy)。 - 返回值:
reverse返回void;reverse_copy返回目标范围的末尾。
reverse 的实现就是双指针向中间靠拢并交换:
template<class BidirIt>
void reverse(BidirIt first, BidirIt last)
{
while ((first != last) && (first != --last))
std::iter_swap(first++, last);
}
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 原地反转
std::reverse(v.begin(), v.end()); // v = {5,4,3,2,1}
// 反转后复制
std::vector<int> src{1, 2, 3};
std::vector<int> dst;
std::reverse_copy(src.begin(), src.end(), std::back_inserter(dst));
// src = {1,2,3}(不变),dst = {3,2,1}
}
(2) 谓词与投影
reverse 和 reverse_copy 都没有谓词或投影参数,它们无条件反转。
(3) 执行策略
reverse 和 reverse_copy 支持 C++17 执行策略:
#include <execution>
std::reverse(std::execution::par, v.begin(), v.end());
4. 注意事项
- 需要双向迭代器:
forward_list的迭代器不可用,但forward_list有成员函数reverse。 reverse返回void:不能链式调用。- 反向迭代器更简洁:只是遍历时用
rbegin()/rend()即可,不需要真的反转。 std::string也有reverse:通过std::reverse(s.begin(), s.end())使用。- 性能:
reverse是 \( O(n) \) 且原地操作,非常高效。
5. 相关算法
- rotate / rotate_copy:旋转而非反转
- reverse_iterator:反向遍历
- sort / stable_sort:排序
- next_permutation / prev_permutation:排列
std::rotate / std::rotate_copy
这两个算法旋转范围内的元素,即把中间位置的元素移到开头:
rotate:原地旋转rotate_copy:旋转后复制到目标范围
rotate(first, middle, last) 的效果是把 [first, middle) 移到 [middle, last) 之后,等价于把序列左移 middle - first 位。
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt>
constexpr ForwardIt rotate(ForwardIt first, ForwardIt middle, ForwardIt last);
template<class ForwardIt, class OutputIt>
constexpr OutputIt rotate_copy(ForwardIt first, ForwardIt middle, ForwardIt last,
OutputIt d_first);
- 迭代器要求:
ForwardIterator。 - 复杂度:恰好 \( n \) 次交换或赋值。
- 返回值:
rotate返回原first所指向元素的新位置(即first + (last - middle))。
C++11 起 rotate 的实现优化为 \( O(n) \) 次交换(此前是 \( O(n^2) \) 的朴素实现)。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 把中间位置(索引 2)的元素移到开头
std::rotate(v.begin(), v.begin() + 2, v.end());
// v = {3,4,5,1,2}
// 左移 1 位
std::vector<int> v2{1, 2, 3, 4, 5};
std::rotate(v2.begin(), v2.begin() + 1, v2.end());
// v2 = {2,3,4,5,1}
// 右移 1 位(等价于左移 n-1 位)
std::vector<int> v3{1, 2, 3, 4, 5};
std::rotate(v3.begin(), v3.end() - 1, v3.end());
// v3 = {5,1,2,3,4}
// rotate_copy
std::vector<int> src{1, 2, 3, 4, 5};
std::vector<int> dst;
std::rotate_copy(src.begin(), src.begin() + 2, src.end(), std::back_inserter(dst));
// dst = {3,4,5,1,2}
}
(2) 谓词与投影
rotate 和 rotate_copy 都没有谓词或投影参数。
(3) 执行策略
rotate 和 rotate_copy 支持 C++17 执行策略:
#include <execution>
std::rotate(std::execution::par, v.begin(), v.begin() + 2, v.end());
4. 注意事项
middle必须在范围内:middle可以等于first或last,此时无操作。- 返回值的含义:返回原
first元素的新位置,容易误解为「新开头」。 - C++20 有
shift_left/shift_right:如果只是简单地左移或右移,用std::shift_left/std::shift_right语义更清晰。 std::rotate常用于实现「移动元素到末尾」:例如把某个元素移到容器末尾。
5. 相关算法
- shift_left / shift_right:左移或右移元素
- reverse / reverse_copy:反转顺序
- next_permutation / prev_permutation:排列
- partition:按谓词分组
std::shift_left / std::shift_right
C++20 引入的这两个算法把范围内的元素左移或右移若干位置。被移出范围的元素会被覆盖,空出的位置处于有效但未指定的状态。
shift_left:把元素向左移动,丢弃前面的元素shift_right:把元素向右移动,丢弃后面的元素
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt>
constexpr ForwardIt shift_left(ForwardIt first, ForwardIt last,
typename std::iterator_traits<ForwardIt>::difference_type n);
template<class ForwardIt>
constexpr ForwardIt shift_right(ForwardIt first, ForwardIt last,
typename std::iterator_traits<ForwardIt>::difference_type n);
- 迭代器要求:
shift_left需要ForwardIterator;shift_right需要BidirectionalIterator。 - 复杂度:至多 \( n \) 次移动赋值。
- 返回值:
shift_left返回新的末尾位置(last - n,若n >= size则返回first)shift_right返回新的起始位置(first + n,若n >= size则返回last)
如果 n <= 0,不执行任何操作。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
// 左移 2 位
std::vector<int> v{1, 2, 3, 4, 5};
auto newEnd = std::shift_left(v.begin(), v.end(), 2);
// v 的前 3 个元素变为 {3,4,5},后面是残留元素
v.erase(newEnd, v.end()); // v = {3,4,5}
// 右移 2 位
std::vector<int> v2{1, 2, 3, 4, 5};
auto newBegin = std::shift_right(v2.begin(), v2.end(), 2);
// v2 变为 {?,?,1,2,3},前两个是残留元素
// 需要把前面的位置赋值为想要的值,或者用 erase 删除
}
(2) 谓词与投影
shift_left 和 shift_right 都没有谓词或投影参数。
(3) 执行策略
shift_left 和 shift_right 支持 C++17 执行策略。
4. 注意事项
- 不改变容器大小:和
remove一样,需要配合erase才能真正删除。 - 残留元素:被移出范围的元素仍在容器中,处于有效但未指定的状态,不要依赖它们的值。
n超过范围大小:n >= size时,shift_left返回first(所有元素都被移出),shift_right返回last。shift_right需要双向迭代器。- 与
rotate的区别:rotate是循环移位(不丢元素),shift_*是丢弃元素的线性移位。 std::copy/std::move也能实现:但shift_*语义更明确,且对重叠范围处理正确。
5. 相关算法
- rotate / rotate_copy:循环旋转
- move / move_backward:移动元素
- copy / copy_if / copy_n:复制元素
- remove / remove_if:逻辑移除
std::shuffle
std::shuffle 使用给定的随机数生成器随机重新排列范围内的元素,产生均匀分布的随机排列。
1. 引入
#include <algorithm>
#include <random>
2. 原理
template<class RandomIt, class URBG>
void shuffle(RandomIt first, RandomIt last, URBG&& g);
- 迭代器要求:
RandomAccessIterator。 - 复杂度:恰好 \( n-1 \) 次交换。
- 返回值:
void。
实现基于 Fisher-Yates 洗牌算法:从后往前遍历,对每个位置 i,随机选择 [0, i] 中的一个位置与之交换。
std::shuffle 需要显式传入随机数生成器,这比 C++11 前已废弃的 std::random_shuffle(用 rand())更可控、质量更高。random_shuffle 已在 C++17 中被移除。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <random>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 用随机设备初始化生成器
std::random_device rd;
std::mt19937 g(rd());
std::shuffle(v.begin(), v.end(), g);
// v 被随机打乱,例如 {3,1,5,2,4}
}
(2) 谓词与投影
shuffle 没有谓词或投影参数。
(3) 执行策略
shuffle 不支持执行策略。随机排列必须按顺序生成,无法并行化。
4. 注意事项
- 生成器质量很重要:不要用
rand()或std::default_random_engine的默认种子,推荐std::mt19937配合std::random_device。 - 需要随机访问迭代器:
std::list的迭代器不可用,需要先拷贝到vector。 random_shuffle已移除:C++17 起不再可用,应改用shuffle。- 可复现性:用固定种子初始化生成器可以得到可复现的洗牌结果,便于测试。
std::ranges::shuffle:C++20 起提供,支持范围参数。
5. 相关算法
- sample:随机抽取 \( N \) 个元素
- next_permutation / prev_permutation:按字典序生成排列
- sort / stable_sort:排序
- reverse / reverse_copy:反转顺序
std::sample
C++17 引入的 std::sample 从序列中随机选取 \( N \) 个元素,写入目标范围。选取是无放回的,且每个元素被选中的概率相同。
1. 引入
#include <algorithm>
#include <random>
2. 原理
template<class PopulationIt, class SampleIt, class Distance, class URBG>
SampleIt sample(PopulationIt first, PopulationIt last, SampleIt out, Distance n, URBG&& g);
- 迭代器要求:输入为
InputIterator,输出为OutputIterator。 - 复杂度:\( O(n) \) 次随机数生成(当输入为 forward 迭代器时)。
- 返回值:指向目标范围中最后一个被写入元素之后的位置。
实现基于 reservoir sampling(蓄水池抽样) 的变体,因此即使输入是 InputIterator(不知道总长度)也能保证均匀性。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <random>
#include <vector>
int main()
{
std::vector<int> population{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::random_device rd;
std::mt19937 g(rd());
// 随机抽取 3 个元素
std::vector<int> picked;
std::sample(population.begin(), population.end(),
std::back_inserter(picked), 3, g);
// picked 包含 3 个随机元素,例如 {7, 2, 9}
}
(2) 谓词与投影
sample 没有谓词或投影参数。如果需要从满足条件的元素中抽样,应先过滤:
std::vector<int> evens;
std::copy_if(population.begin(), population.end(), std::back_inserter(evens),
[](int x) { return x % 2 == 0; });
std::vector<int> picked;
std::sample(evens.begin(), evens.end(), std::back_inserter(picked), 2, g);
(3) 执行策略
sample 不支持执行策略。抽样必须按顺序进行。
4. 注意事项
n不能超过总体大小:如果n > size,只会抽取size个元素(不会报错)。- 无放回抽样:每个元素最多被选中一次。需要放回抽样时应循环调用
std::uniform_int_distribution。 - 目标空间必须足够:不检查目标范围大小。
- 生成器质量:同样推荐
std::mt19937配合std::random_device。 std::ranges::sample:C++20 起提供。
5. 相关算法
- shuffle:随机打乱整个范围
- copy / copy_if / copy_n:按条件复制
- generate / generate_n:用随机数填充
- random_shuffle:已移除的旧算法
排序及相关操作 (Sorting and related operations)
这一组算法围绕排序、分区、查找与堆展开。其中二分查找、集合操作、合并操作都要求输入范围已经有序,否则行为未定义。
主要头文件:
<algorithm>:包含绝大多数经典的排序及相关操作。<ranges>:包含所有ranges::版本的算法。
算法总览
分区操作
| 算法 | C++ 版本 | 描述 |
|---|---|---|
| is_partitioned / partition_point | (C++11) | 判断范围是否已分区,或定位分区点 |
| partition / stable_partition | 将范围按谓词划分为两组 | |
| partition_copy | (C++11) | 复制范围并划分为两组 |
排序操作
| 算法 | C++ 版本 | 描述 |
|---|---|---|
| sort / stable_sort | 对范围排序,可选择是否稳定 | |
| partial_sort | 排序范围的前 \( N \) 个元素 | |
| is_sorted | (C++11) | 检查范围是否已排序 |
| nth_element | 部分排序并定位第 \( N \) 个元素 |
二分查找操作
| 算法 | C++ 版本 | 描述 |
|---|---|---|
| lower_bound / upper_bound | 返回第一个不小于 / 大于给定值的元素 | |
| equal_range / binary_search | 返回匹配范围或判断元素是否存在 |
集合操作
| 算法 | C++ 版本 | 描述 |
|---|---|---|
| includes | 判断一个序列是否为另一个的子序列 | |
| set_union / set_intersection | 计算并集 / 交集 | |
| set_difference / set_symmetric_difference | 计算差集 / 对称差集 |
合并与堆操作
| 算法 | C++ 版本 | 描述 |
|---|---|---|
| merge / inplace_merge | 合并两个已排序范围 | |
| make_heap / push_heap / pop_heap / sort_heap / is_heap | (C++11) | 维护最大堆及堆与有序序列的转换 |
最值与比较操作
| 算法 | C++ 版本 | 描述 |
|---|---|---|
| min / max / minmax | (C++11) | 返回给定值中的较小者 / 较大者 / 两者 |
| min_element / max_element / minmax_element | (C++11) | 返回范围中的最小 / 最大 / 两者 |
| clamp | (C++17) | 将值钳制在一对边界之间 |
| lexicographical_compare | (C++20) | 按字典序(或三路)比较两个范围 |
| next_permutation / prev_permutation / is_permutation | (C++11) | 生成排列或判断排列关系 |
说明: 表中「C++ 版本」列标注的是该算法(或
ranges::版本)首次引入的标准版本,留空表示自 C++98 起即存在。
共性说明
- 有序前提:二分查找、集合操作、
merge、includes都要求输入范围已按同一比较器排序,否则结果未定义。 - 稳定性:
stable_sort、stable_partition保持相等元素的相对顺序,代价是额外的内存或时间开销。 - 堆的本质:堆操作默认维护最大堆,配合
std::greater<>可得到最小堆;priority_queue正是基于这组算法实现的。
std::partition / std::stable_partition
这两个算法按谓词把范围内的元素划分为两组:满足谓词的排在前,不满足的排在后。
partition:不保证两组内部的相对顺序stable_partition:保持两组内部的相对顺序
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt, class UnaryPred>
constexpr ForwardIt partition(ForwardIt first, ForwardIt last, UnaryPred p);
template<class BidirIt, class UnaryPred>
constexpr BidirIt stable_partition(BidirIt first, BidirIt last, UnaryPred p);
- 迭代器要求:
partition需要ForwardIterator;stable_partition需要BidirectionalIterator。 - 复杂度:
partition恰好 \( n \) 次谓词调用和至多 \( n \) 次交换;stable_partition在有足够内存时为 \( O(n) \),否则为 \( O(n \log n) \)。 - 返回值:指向第二组(不满足谓词)的第一个元素,即分区点。
partition 的经典实现是双指针相向扫描并交换。stable_partition 则需要额外内存或递归分治来保持顺序。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5, 6};
// 偶数在前,奇数在后
auto it = std::partition(v.begin(), v.end(), [](int x) { return x % 2 == 0; });
// v 可能是 {6,2,4,3,5,1}
std::cout << "分区点索引: " << (it - v.begin()) << '\n'; // 3
// stable_partition 保持相对顺序
std::vector<int> v2{1, 2, 3, 4, 5, 6};
std::stable_partition(v2.begin(), v2.end(), [](int x) { return x % 2 == 0; });
// v2 = {2,4,6,1,3,5}
}
(2) 谓词与投影
ranges::partition 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 17}, {"Carol", 25}};
std::ranges::partition(people, [](int age) { return age >= 18; }, &Person::age);
// 成年人排在前面
(3) 执行策略
partition 支持 C++17 执行策略;stable_partition 不支持(需要保持顺序)。
4. 注意事项
- 分区点很重要:返回值把范围分成
[first, it)和[it, last)两部分,常用于后续处理。 partition不保证顺序:如果需要保持相对顺序,必须用stable_partition,但代价更高。- 谓词不应有副作用。
- 配合
partition_point使用:对已分区范围可以用partition_point二分查找分区点。 nth_element也是一种分区:它按第 N 个元素分区,但额外保证该元素就位。
5. 相关算法
- is_partitioned / partition_point:判断分区或定位分区点
- partition_copy:复制时分区
- nth_element:部分排序
- sort / stable_sort:完全排序
- remove / remove_if:逻辑移除
std::is_partitioned / std::partition_point
这两个算法处理「已分区」的范围:
is_partitioned:判断范围是否已按谓词分区partition_point:返回已分区范围的分区点(第一个不满足谓词的元素)
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class UnaryPred>
constexpr bool is_partitioned(InputIt first, InputIt last, UnaryPred p);
template<class ForwardIt, class UnaryPred>
constexpr ForwardIt partition_point(ForwardIt first, ForwardIt last, UnaryPred p);
- 迭代器要求:
is_partitioned需要InputIterator;partition_point需要ForwardIterator。 - 复杂度:
is_partitioned至多 \( O(n) \);partition_point为 \( O(\log n) \) 次谓词调用(二分查找)。 - 返回值:
is_partitioned返回bool;partition_point返回分区点迭代器。
重要:partition_point 要求范围已经分区,否则行为未定义。它通过二分查找定位分区点,因此比线性扫描快。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{2, 4, 6, 1, 3, 5};
// 判断是否已分区(偶数在前)
bool ok = std::is_partitioned(v.begin(), v.end(),
[](int x) { return x % 2 == 0; });
std::cout << std::boolalpha << ok << '\n'; // true
// 定位分区点
auto it = std::partition_point(v.begin(), v.end(),
[](int x) { return x % 2 == 0; });
std::cout << "分区点索引: " << (it - v.begin()) << ", 值: " << *it << '\n';
// 分区点索引: 3, 值: 1
}
(2) 谓词与投影
ranges::is_partitioned 和 ranges::partition_point 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Carol", 25}, {"Bob", 17}};
auto it = std::ranges::partition_point(people,
[](int age) { return age >= 18; },
&Person::age);
(3) 执行策略
is_partitioned 支持 C++17 执行策略;partition_point 不支持(二分查找需要顺序访问)。
4. 注意事项
partition_point要求已分区:这是硬性前提,违反会导致未定义行为。- 分区点可能等于
last:如果所有元素都满足谓词,返回last。 is_partitioned对空范围返回true。- 与
lower_bound的关系:partition_point是更通用的二分查找,lower_bound相当于谓词为*it < value的特例。
5. 相关算法
- partition / stable_partition:执行分区
- partition_copy:复制时分区
- 二分查找:在有序范围上查找
- is_sorted:判断是否已排序
std::partition_copy
std::partition_copy 按谓词把输入范围的元素分别复制到两个输出范围:满足谓词的写入第一个,不满足的写入第二个。原范围不变。
1. 引入
#include <algorithm>
2. 原理
template<class InputIt, class OutputIt1, class OutputIt2, class UnaryPred>
constexpr std::pair<OutputIt1, OutputIt2>
partition_copy(InputIt first, InputIt last,
OutputIt1 d_first_true, OutputIt2 d_first_false, UnaryPred p);
- 迭代器要求:输入
InputIterator,输出OutputIterator。 - 复杂度:恰好 \( n \) 次谓词调用和赋值。
- 返回值:
std::pair,分别指向两个输出范围中最后一个写入元素之后的位置。
ranges::partition_copy 返回 ranges::partition_copy_result,并支持投影。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5, 6};
std::vector<int> evens, odds;
std::partition_copy(v.begin(), v.end(),
std::back_inserter(evens),
std::back_inserter(odds),
[](int x) { return x % 2 == 0; });
// evens = {2,4,6}, odds = {1,3,5}
// v 不变
// 也可以预先分配空间
std::vector<int> e2(v.size()), o2(v.size());
auto [endE, endO] = std::partition_copy(v.begin(), v.end(),
e2.begin(), o2.begin(),
[](int x) { return x % 2 == 0; });
e2.erase(endE, e2.end());
o2.erase(endO, o2.end());
}
(2) 谓词与投影
ranges::partition_copy 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 17}};
std::vector<Person> adults, minors;
std::ranges::partition_copy(people,
std::back_inserter(adults),
std::back_inserter(minors),
[](int age) { return age >= 18; },
&Person::age);
(3) 执行策略
partition_copy 支持 C++17 执行策略。
4. 注意事项
- 两个输出范围都必须足够大:不检查大小,用
back_inserter最安全。 - 输出范围不能与输入重叠。
- 保持相对顺序:
partition_copy是稳定的,两组内部的相对顺序与原范围一致。 - 与
partition的区别:partition原地分区,partition_copy复制分区。
5. 相关算法
- partition / stable_partition:原地分区
- copy / copy_if / copy_n:按条件复制
- remove_copy:复制时省略元素
- is_partitioned / partition_point:判断分区
std::sort / std::stable_sort
这两个算法对范围内的元素排序:
sort:不保证相等元素的相对顺序stable_sort:保持相等元素的相对顺序
1. 引入
#include <algorithm>
2. 原理
template<class RandomIt>
constexpr void sort(RandomIt first, RandomIt last);
template<class RandomIt, class Compare>
constexpr void sort(RandomIt first, RandomIt last, Compare comp);
template<class RandomIt>
void stable_sort(RandomIt first, RandomIt last);
- 迭代器要求:
RandomAccessIterator。 - 复杂度:
sort:\( O(n \log n) \) 次比较(典型实现为 Introsort:快排 + 堆排 + 插入排序的混合)stable_sort:有足够额外内存时 \( O(n \log n) \),否则 \( O(n \log^2 n) \)
- 返回值:
void。 - 比较器要求:必须是严格弱序(strict weak ordering),否则行为未定义。
sort 不是稳定排序,如果需要稳定性必须用 stable_sort。std::list 和 std::forward_list 有成员函数 sort(基于归并排序,天然稳定)。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{3, 1, 4, 1, 5, 9, 2, 6};
// 升序
std::sort(v.begin(), v.end());
// 降序
std::sort(v.begin(), v.end(), std::greater<>{});
// 自定义比较器
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
// stable_sort 保持相等元素的相对顺序
std::vector<std::pair<int, std::string>> items{
{2, "a"}, {1, "b"}, {2, "c"}, {1, "d"}
};
std::stable_sort(items.begin(), items.end(),
[](const auto& x, const auto& y) { return x.first < y.first; });
// 结果为 {1,b}, {1,d}, {2,a}, {2,c}(同 key 保持原顺序)
}
(2) 谓词与投影
ranges::sort 支持投影,可以直接按成员排序:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}, {"Carol", 35}};
// 按年龄升序
std::ranges::sort(people, {}, &Person::age);
// 按年龄降序
std::ranges::sort(people, std::greater<>{}, &Person::age);
// 多级排序:先按年龄,再按姓名
std::ranges::sort(people, [](const Person& a, const Person& b) {
return std::tie(a.age, a.name) < std::tie(b.age, b.name);
});
(3) 执行策略
sort 支持 C++17 执行策略;stable_sort 也支持,但并行版本的稳定性实现代价较高。
#include <execution>
std::sort(std::execution::par, v.begin(), v.end());
C++26 起 ranges::sort 也支持执行策略。
4. 注意事项
- 比较器必须是严格弱序:使用
<=或>=而非<或>是经典错误,会导致越界或死循环。 sort不稳定:相等元素的相对顺序可能改变。- 迭代器失效:排序会交换元素,所有指向元素的迭代器、指针、引用都会失效(元素本身没被销毁,但位置变了)。
std::list用成员sort:std::sort需要随机访问迭代器,不能用于list。std::greater<>而非std::greater<int>:C++14 起的透明比较器更通用。- 性能:
sort通常比stable_sort快,且不需要额外内存。
5. 相关算法
- partial_sort:只排序前 \( N \) 个
- nth_element:只定位第 \( N \) 个
- is_sorted:检查是否已排序
- 二分查找:在有序范围上查找
- merge / inplace_merge:合并有序范围
std::partial_sort / std::partial_sort_copy
这两个算法只对范围内的前 \( N \) 个元素排序:
partial_sort:原地把最小的 \( N \) 个元素排到前面partial_sort_copy:把最小的 \( N \) 个元素排序后复制到目标范围
当只需要「前 K 名」时,它们比完整排序 sort 更高效。
1. 引入
#include <algorithm>
2. 原理
template<class RandomIt>
constexpr void partial_sort(RandomIt first, RandomIt middle, RandomIt last);
template<class InputIt, class RandomIt>
constexpr RandomIt partial_sort_copy(InputIt first, InputIt last,
RandomIt d_first, RandomIt d_last);
- 迭代器要求:
partial_sort需要RandomAccessIterator;partial_sort_copy输入InputIterator、输出RandomAccessIterator。 - 复杂度:约 \( O(N \log K) \) 次比较,其中 \( N \) 为范围长度,\( K \) 为
middle - first。 - 返回值:
partial_sort返回void;partial_sort_copy返回目标范围中最后一个被写入元素之后的位置。
实现基于堆:先用前 \( K \) 个元素建最大堆,然后遍历剩余元素,比堆顶小的就替换堆顶并调整堆,最后对堆排序。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{9, 3, 7, 1, 5, 8, 2, 6, 4};
// 把最小的 3 个排到前面
std::partial_sort(v.begin(), v.begin() + 3, v.end());
// v 的前 3 个是 {1,2,3},后面元素的顺序不确定
// 降序取前 3 大
std::vector<int> v2{9, 3, 7, 1, 5, 8, 2, 6, 4};
std::partial_sort(v2.begin(), v2.begin() + 3, v2.end(), std::greater<>{});
// v2 前 3 个是 {9,8,7}
// partial_sort_copy:复制前 3 小到新容器
std::vector<int> src{9, 3, 7, 1, 5};
std::vector<int> dst(3);
std::partial_sort_copy(src.begin(), src.end(), dst.begin(), dst.end());
// dst = {1,3,5},src 不变
}
(2) 谓词与投影
ranges::partial_sort 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}, {"Carol", 35}};
// 按年龄取前 2 年轻
std::ranges::partial_sort(people, people.begin() + 2, {}, &Person::age);
(3) 执行策略
partial_sort 和 partial_sort_copy 支持 C++17 执行策略。
4. 注意事项
- 后面元素的顺序不确定:
partial_sort只保证前 \( K \) 个有序,其余元素的顺序是未指定的。 middle必须在范围内:middle可以等于first(无操作)或last(等价于完整排序)。- 比较器必须严格弱序。
- 与
nth_element的选择:只需要「第 K 小的元素」用nth_element(\( O(n) \));需要「前 K 个有序」用partial_sort。 partial_sort_copy的目标大小决定 \( K \):目标范围多大就复制多少个。
5. 相关算法
- sort / stable_sort:完整排序
- nth_element:只定位第 \( N \) 个元素
- 堆操作:
partial_sort的底层实现 - is_sorted:检查是否已排序
std::is_sorted / std::is_sorted_until
这两个算法检查范围是否已排序:
is_sorted:判断整个范围是否已排序is_sorted_until:返回最大的已排序前缀的末尾位置
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt>
constexpr bool is_sorted(ForwardIt first, ForwardIt last);
template<class ForwardIt, class Compare>
constexpr bool is_sorted(ForwardIt first, ForwardIt last, Compare comp);
template<class ForwardIt>
constexpr ForwardIt is_sorted_until(ForwardIt first, ForwardIt last);
- 迭代器要求:
ForwardIterator。 - 复杂度:
is_sorted至多 \( n-1 \) 次比较;is_sorted_until至多 \( n-1 \) 次比较。 - 返回值:
is_sorted返回bool;is_sorted_until返回第一个破坏有序性的元素位置。 - 空范围或单元素范围:返回
true/last。
is_sorted 等价于 is_sorted_until(first, last) == last。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v1{1, 2, 3, 4, 5};
std::vector<int> v2{1, 3, 2, 4, 5};
std::cout << std::boolalpha
<< std::is_sorted(v1.begin(), v1.end()) << '\n' // true
<< std::is_sorted(v2.begin(), v2.end()) << '\n'; // false
// 找出已排序前缀的末尾
auto it = std::is_sorted_until(v2.begin(), v2.end());
std::cout << "已排序前缀长度: " << (it - v2.begin()) << ", 破坏点值: " << *it << '\n';
// 已排序前缀长度: 2, 破坏点值: 2
// 检查降序
std::vector<int> v3{5, 4, 3, 2, 1};
std::cout << std::is_sorted(v3.begin(), v3.end(), std::greater<>{}) << '\n'; // true
}
(2) 谓词与投影
ranges::is_sorted 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 25}, {"Bob", 30}};
bool ok = std::ranges::is_sorted(people, {}, &Person::age); // true
(3) 执行策略
is_sorted 和 is_sorted_until 支持 C++17 执行策略。
4. 注意事项
- 比较器必须与排序时一致:用
std::greater<>排序后,检查时也要用std::greater<>。 is_sorted对相等元素返回true:它检查的是非降序(!comp(next, prev))。- 调试利器:在调用
binary_search等要求有序的算法前,可以用is_sorted做断言检查。 is_sorted_until的返回值:指向第一个「比前一个元素小」的元素,可用于定位数据异常位置。
5. 相关算法
- sort / stable_sort:排序
- 二分查找:要求范围有序
- is_partitioned:判断是否已分区
- is_heap:判断是否为堆
std::nth_element
std::nth_element 部分排序范围,使得第 \( N \) 个位置的元素就位(即如果整个范围排序,该位置就是它),并保证它左边的元素都不大于它,右边的元素都不小于它。
它常用于求「第 K 小/大的元素」,平均复杂度为 \( O(n) \),比完整排序的 \( O(n \log n) \) 更快。
1. 引入
#include <algorithm>
2. 原理
template<class RandomIt>
constexpr void nth_element(RandomIt first, RandomIt nth, RandomIt last);
template<class RandomIt, class Compare>
constexpr void nth_element(RandomIt first, RandomIt nth, RandomIt last, Compare comp);
- 迭代器要求:
RandomAccessIterator。 - 复杂度:平均 \( O(n) \) 次比较(典型实现为 Introselect:快选 + 堆选混合),最坏 \( O(n \log n) \)。
- 返回值:
void。
实现基于快速选择(Quickselect):类似快排的分区步骤,但只递归处理包含目标位置的那一侧。
分区后满足:
[first, nth)中的所有元素都<= *nth[nth, last)中的所有元素都>= *nth
但两侧内部的顺序是未指定的。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{9, 3, 7, 1, 5, 8, 2, 6, 4};
// 找第 5 小(索引 4)
std::nth_element(v.begin(), v.begin() + 4, v.end());
std::cout << "第 5 小: " << v[4] << '\n'; // 5
// 找中位数
std::vector<int> v2{9, 3, 7, 1, 5};
auto mid = v2.begin() + v2.size() / 2;
std::nth_element(v2.begin(), mid, v2.end());
std::cout << "中位数: " << *mid << '\n'; // 5
// 找第 3 大:用 greater 比较器
std::vector<int> v3{9, 3, 7, 1, 5};
std::nth_element(v3.begin(), v3.begin() + 2, v3.end(), std::greater<>{});
std::cout << "第 3 大: " << v3[2] << '\n'; // 5
}
(2) 谓词与投影
ranges::nth_element 支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}, {"Carol", 35}};
// 找年龄中位数
std::ranges::nth_element(people, people.begin() + 1, {}, &Person::age);
(3) 执行策略
nth_element 支持 C++17 执行策略。
4. 注意事项
- 两侧顺序未指定:
nth_element只保证分区性质,不保证两侧有序。需要两侧有序时用partial_sort或sort。 - 比较器必须严格弱序。
- 比
sort快得多:只需要第 K 小时,nth_element是最优选择。 - 可以求前 K 小(无序):
nth_element(v.begin(), v.begin() + k, v.end())后,前 K 个就是最小的 K 个(顺序不定)。 nth必须在范围内。
5. 相关算法
- partial_sort:前 \( N \) 个有序
- sort / stable_sort:完整排序
- partition / stable_partition:按谓词分区
- min / max / clamp:求最值
std::lower_bound / upper_bound / equal_range / binary_search
这四个算法在已排序范围上进行二分查找,复杂度均为 \( O(\log n) \)。
| 算法 | 含义 |
|---|---|
lower_bound | 返回第一个不小于给定值的元素位置 |
upper_bound | 返回第一个大于给定值的元素位置 |
equal_range | 返回 {lower_bound, upper_bound} 构成的区间 |
binary_search | 判断元素是否存在,返回 bool |
1. 引入
#include <algorithm>
2. 原理
template<class ForwardIt, class T>
constexpr ForwardIt lower_bound(ForwardIt first, ForwardIt last, const T& value);
template<class ForwardIt, class T>
constexpr ForwardIt upper_bound(ForwardIt first, ForwardIt last, const T& value);
template<class ForwardIt, class T>
constexpr std::pair<ForwardIt, ForwardIt>
equal_range(ForwardIt first, ForwardIt last, const T& value);
template<class ForwardIt, class T>
constexpr bool binary_search(ForwardIt first, ForwardIt last, const T& value);
- 迭代器要求:
ForwardIterator(实际用随机访问迭代器才能达到 \( O(\log n) \) 次迭代器移动,用 forward 迭代器比较次数仍是 \( O(\log n) \) 但移动是 \( O(n) \))。 - 复杂度:\( O(\log n) \) 次比较。
- 前提:范围必须已按同一比较器排序,否则行为未定义。
四个算法的关系:
已排序范围: 1 2 2 2 3 4 5
^ ^
lower_bound upper_bound (查找值 2)
equal_range = [lower_bound, upper_bound)
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 2, 2, 3, 4, 5};
// lower_bound:第一个 >= 2 的位置
auto lb = std::lower_bound(v.begin(), v.end(), 2);
std::cout << "lower_bound 索引: " << (lb - v.begin()) << '\n'; // 1
// upper_bound:第一个 > 2 的位置
auto ub = std::upper_bound(v.begin(), v.end(), 2);
std::cout << "upper_bound 索引: " << (ub - v.begin()) << '\n'; // 4
// equal_range:等于 2 的区间
auto [first, last] = std::equal_range(v.begin(), v.end(), 2);
std::cout << "2 的个数: " << (last - first) << '\n'; // 3
// binary_search:判断存在性
std::cout << std::boolalpha
<< std::binary_search(v.begin(), v.end(), 3) << '\n' // true
<< std::binary_search(v.begin(), v.end(), 9) << '\n'; // false
// 降序范围:用 greater
std::vector<int> d{5, 4, 3, 2, 1};
auto it = std::lower_bound(d.begin(), d.end(), 3, std::greater<>{});
std::cout << "降序 lower_bound 索引: " << (it - d.begin()) << '\n'; // 2
}
(2) 谓词与投影
ranges:: 版本支持投影,可以直接按成员查找:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Bob", 25}, {"Alice", 30}, {"Carol", 35}};
// 按年龄查找
auto it = std::ranges::lower_bound(people, 30, {}, &Person::age);
std::cout << it->name; // Alice
(3) 执行策略
这四个算法不支持执行策略。二分查找是顺序依赖的过程,无法并行化。
4. 注意事项
- 必须先排序:这是硬性前提,违反会导致未定义行为。建议用
is_sorted做断言。 - 比较器必须一致:排序和查找必须使用同一个比较器(如都用
std::greater<>)。 lower_bound与upper_bound的区别:lower_bound找>=,upper_bound找>。查找单个存在的值时两者只差 1。binary_search只返回bool:需要位置时用lower_bound。- 关联容器有成员版本:
std::set、std::map等有成员函数lower_bound、upper_bound、equal_range,性能更好。 - C++20 的
contains:std::set::contains等成员函数比binary_search更直观。
5. 相关算法
- sort / stable_sort:排序(二分查找的前提)
- is_sorted:检查是否已排序
- find / find_if:线性查找
- contains:判断元素是否存在
- 集合操作:有序范围上的集合运算
std::includes / set_union / set_intersection / set_difference / set_symmetric_difference
这一组算法在已排序范围上执行集合运算。它们都要求输入范围已排序,输出结果也是有序的。
| 算法 | 含义 |
|---|---|
includes | 判断一个序列是否为另一个的子序列 |
set_union | 并集 |
set_intersection | 交集 |
set_difference | 差集(第一个有而第二个没有) |
set_symmetric_difference | 对称差集(并集减去交集) |
1. 引入
#include <algorithm>
2. 原理
template<class InputIt1, class InputIt2, class OutputIt>
constexpr OutputIt set_union(InputIt1 first1, InputIt1 last1,
InputIt2 first2, InputIt2 last2, OutputIt d_first);
template<class InputIt1, class InputIt2, class OutputIt>
constexpr OutputIt set_intersection(InputIt1 first1, InputIt1 last1,
InputIt2 first2, InputIt2 last2, OutputIt d_first);
- 迭代器要求:输入
InputIterator,输出OutputIterator。 - 复杂度:至多 \( 2(N_1 + N_2) - 1 \) 次比较。
- 返回值:指向输出范围末尾的迭代器(
includes返回bool)。
这些算法基于归并思路:同时遍历两个有序范围,按比较结果决定输出。重复元素按「多重集」语义处理——如果某元素在第一个范围出现 \( m \) 次、第二个出现 \( n \) 次,则:
- 并集中出现
max(m, n)次 - 交集中出现
min(m, n)次 - 差集中出现
max(m - n, 0)次
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> a{1, 2, 3, 4, 5};
std::vector<int> b{3, 4, 5, 6, 7};
std::vector<int> u, i, d, sd;
std::set_union(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(u));
// u = {1,2,3,4,5,6,7}
std::set_intersection(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(i));
// i = {3,4,5}
std::set_difference(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(d));
// d = {1,2}
std::set_symmetric_difference(a.begin(), a.end(), b.begin(), b.end(),
std::back_inserter(sd));
// sd = {1,2,6,7}
// includes:判断 a 是否包含 {2,4}
std::vector<int> sub{2, 4};
std::cout << std::boolalpha
<< std::includes(a.begin(), a.end(), sub.begin(), sub.end()) << '\n'; // true
}
(2) 谓词与投影
可以传入自定义比较器(必须与排序时一致):
std::vector<int> a{5, 4, 3, 2, 1}; // 降序
std::vector<int> b{7, 6, 5, 4, 3}; // 降序
std::vector<int> u;
std::set_union(a.begin(), a.end(), b.begin(), b.end(),
std::back_inserter(u), std::greater<>{});
// u = {7,6,5,4,3,2,1}
ranges::set_union 等支持投影。
(3) 执行策略
这些算法不支持执行策略。集合运算需要顺序归并两个范围。
4. 注意事项
- 必须先排序:这是硬性前提。未排序会导致错误结果或未定义行为。
- 比较器必须一致:排序和集合运算必须用同一个比较器。
- 多重集语义:重复元素按上述规则处理,不是简单的「去重」。
- 输出范围必须足够大:用
back_inserter最安全。 includes判断的是子序列:要求第二个范围的每个元素都在第一个范围中,且重复次数不超过。- 容器版本:
std::set等关联容器也支持集合运算,但通过迭代器插入更麻烦,通常直接用这些算法。
5. 相关算法
- merge / inplace_merge:合并两个有序范围
- sort / stable_sort:排序(集合运算的前提)
- binary_search:在有序范围上查找
- unique:去除重复元素
std::merge / std::inplace_merge
这两个算法合并两个已排序范围:
merge:把两个有序范围合并到第三个输出范围inplace_merge:把同一个范围内两个连续的有序段原地合并
1. 引入
#include <algorithm>
2. 原理
template<class InputIt1, class InputIt2, class OutputIt>
constexpr OutputIt merge(InputIt1 first1, InputIt1 last1,
InputIt2 first2, InputIt2 last2, OutputIt d_first);
template<class BidirIt>
void inplace_merge(BidirIt first, BidirIt middle, BidirIt last);
- 迭代器要求:
merge输入InputIterator、输出OutputIterator;inplace_merge需要BidirectionalIterator。 - 复杂度:
merge:至多 \( N_1 + N_2 - 1 \) 次比较inplace_merge:有足够额外内存时 \( O(n) \),否则 \( O(n \log n) \)
- 返回值:
merge返回输出范围末尾;inplace_merge返回void。
merge 是稳定的:相等元素中,第一个范围的元素排在前面。
inplace_merge 常用于归并排序的实现:先递归排序两半,再原地合并。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> a{1, 3, 5, 7};
std::vector<int> b{2, 4, 6, 8};
// 合并到新容器
std::vector<int> merged;
std::merge(a.begin(), a.end(), b.begin(), b.end(),
std::back_inserter(merged));
// merged = {1,2,3,4,5,6,7,8}
// 原地合并:同一容器内两个有序段
std::vector<int> v{1, 3, 5, 2, 4, 6};
// [1,3,5] [2,4,6] 两段各自有序
std::inplace_merge(v.begin(), v.begin() + 3, v.end());
// v = {1,2,3,4,5,6}
}
(2) 谓词与投影
可以传入自定义比较器:
std::vector<int> a{7, 5, 3, 1}; // 降序
std::vector<int> b{8, 6, 4, 2}; // 降序
std::vector<int> m;
std::merge(a.begin(), a.end(), b.begin(), b.end(),
std::back_inserter(m), std::greater<>{});
// m = {8,7,6,5,4,3,2,1}
ranges::merge 支持投影。
(3) 执行策略
merge 和 inplace_merge 支持 C++17 执行策略。
4. 注意事项
- 必须先排序:两个输入范围都必须已按同一比较器排序。
merge是稳定的:相等元素保持第一个范围在前的顺序。inplace_merge的两段必须连续:范围是[first, middle)和[middle, last)。- 输出范围必须足够大:
merge不检查大小。 list::merge是成员函数:std::list有成员merge,会把另一个链表节点搬过来,不拷贝元素,效率更高。inplace_merge的内存开销:实现可能需要临时缓冲区,内存不足时退化为 \( O(n \log n) \)。
5. 相关算法
- sort / stable_sort:排序
- 集合操作:并集、交集等
- unique:去除重复
- binary_search:在有序范围上查找
堆操作:make_heap / push_heap / pop_heap / sort_heap / is_heap
这一组算法在随机访问范围上维护最大堆(max heap)结构。堆是一个完全二叉树,用数组表示,满足父节点不小于子节点。
| 算法 | 含义 |
|---|---|
make_heap | 把范围转换成堆 |
push_heap | 把末尾元素加入堆 |
pop_heap | 把堆顶(最大元素)移到末尾,并调整剩余部分为堆 |
sort_heap | 把堆转换为升序序列 |
is_heap | 判断范围是否为堆(C++11) |
is_heap_until | 返回最大的堆前缀(C++11) |
std::priority_queue 正是基于这组算法实现的。
1. 引入
#include <algorithm>
2. 原理
template<class RandomIt>
constexpr void make_heap(RandomIt first, RandomIt last);
template<class RandomIt>
constexpr void push_heap(RandomIt first, RandomIt last);
template<class RandomIt>
constexpr void pop_heap(RandomIt first, RandomIt last);
template<class RandomIt>
constexpr void sort_heap(RandomIt first, RandomIt last);
template<class RandomIt>
constexpr bool is_heap(RandomIt first, RandomIt last);
- 迭代器要求:
RandomAccessIterator。 - 复杂度:
| 算法 | 复杂度 |
|---|---|
make_heap | 至多 \( 3n \) 次比较 |
push_heap | 至多 \( \log n \) 次比较 |
pop_heap | 至多 \( 2\log n \) 次比较 |
sort_heap | 至多 \( n \log n \) 次比较 |
is_heap | 至多 \( n \) 次比较 |
- 返回值:除
is_heap(返回bool)和is_heap_until(返回迭代器)外,都返回void。
默认是最大堆。传入 std::greater<> 可以得到最小堆。
堆的数组表示:对于索引 i,其父节点为 (i-1)/2,子节点为 2i+1 和 2i+2。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v{3, 1, 4, 1, 5, 9, 2, 6};
// 建堆
std::make_heap(v.begin(), v.end());
std::cout << "堆顶: " << v.front() << '\n'; // 9
// 加入新元素:先 push_back,再 push_heap
v.push_back(10);
std::push_heap(v.begin(), v.end());
std::cout << "新堆顶: " << v.front() << '\n'; // 10
// 弹出堆顶:先 pop_heap,再 pop_back
std::pop_heap(v.begin(), v.end()); // 堆顶移到末尾
std::cout << "被弹出的最大值: " << v.back() << '\n'; // 10
v.pop_back();
// 堆排序:得到升序序列
std::sort_heap(v.begin(), v.end());
// v 变为升序
// 判断是否为堆
std::cout << std::boolalpha << std::is_heap(v.begin(), v.end()) << '\n';
}
(2) 谓词与投影
传入 std::greater<> 得到最小堆:
std::vector<int> v{3, 1, 4, 1, 5};
std::make_heap(v.begin(), v.end(), std::greater<>{});
std::cout << v.front(); // 1(最小元素在堆顶)
ranges::make_heap 等支持投影。
(3) 执行策略
堆操作不支持执行策略。堆的维护是顺序依赖的过程。
4. 注意事项
push_heap前必须先push_back:push_heap只调整堆结构,不添加元素。pop_heap后必须pop_back:pop_heap把堆顶移到末尾,元素还在容器里。sort_heap后不再是堆:排序后堆性质被破坏。- 最大堆 vs 最小堆:默认最大堆;用
std::greater<>得到最小堆。 - 优先用
priority_queue:除非需要直接操作底层容器,否则std::priority_queue更简洁安全。 is_heap是 C++11 起:C++98 没有。
5. 相关算法
- sort / stable_sort:完整排序
- partial_sort:基于堆的部分排序
- nth_element:快速选择
- priority_queue:基于堆的容器适配器
std::min / max / minmax / min_element / max_element / minmax_element / clamp
这一组算法用于求最小值、最大值,以及把值限制在边界内。
| 算法 | 含义 | 版本 |
|---|---|---|
min | 返回两个值中的较小者 | C++98 |
max | 返回两个值中的较大者 | C++98 |
minmax | 同时返回较小者和较大者 | C++11 |
min_element | 返回范围中的最小元素 | C++98 |
max_element | 返回范围中的最大元素 | C++98 |
minmax_element | 同时返回最小和最大元素 | C++11 |
clamp | 把值钳制在一对边界之间 | C++17 |
1. 引入
#include <algorithm>
2. 原理
template<class T>
constexpr const T& min(const T& a, const T& b);
template<class T>
constexpr const T& max(const T& a, const T& b);
template<class T>
constexpr std::pair<const T&, const T&> minmax(const T& a, const T& b);
template<class ForwardIt>
constexpr ForwardIt min_element(ForwardIt first, ForwardIt last);
template<class ForwardIt>
constexpr ForwardIt max_element(ForwardIt first, ForwardIt last);
template<class ForwardIt>
constexpr std::pair<ForwardIt, ForwardIt> minmax_element(ForwardIt first, ForwardIt last);
template<class T>
constexpr const T& clamp(const T& v, const T& lo, const T& hi);
- 迭代器要求:
ForwardIterator。 - 复杂度:
min_element/max_element至多 \( n-1 \) 次比较;minmax_element至多 \( \lfloor 3(n-1)/2 \rfloor \) 次比较(比分别调用两个算法更高效)。 - 返回值:
min/max/clamp返回const T&;*_element返回迭代器;minmax返回std::pair。
注意:min / max 返回的是引用,如果参数是临时对象会导致悬垂引用。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
// 两个值
std::cout << std::min(3, 5) << '\n'; // 3
std::cout << std::max(3, 5) << '\n'; // 5
auto [lo, hi] = std::minmax(3, 5); // lo=3, hi=5
// 范围
std::vector<int> v{3, 1, 4, 1, 5, 9, 2, 6};
auto minIt = std::min_element(v.begin(), v.end());
auto maxIt = std::max_element(v.begin(), v.end());
std::cout << "最小: " << *minIt << ", 最大: " << *maxIt << '\n'; // 1, 9
auto [mn, mx] = std::minmax_element(v.begin(), v.end());
std::cout << "最小: " << *mn << ", 最大: " << *mx << '\n'; // 1, 9
// clamp:把值限制在 [0, 100]
std::cout << std::clamp(150, 0, 100) << '\n'; // 100
std::cout << std::clamp(-10, 0, 100) << '\n'; // 0
std::cout << std::clamp(50, 0, 100) << '\n'; // 50
}
(2) 谓词与投影
可以传入自定义比较器;ranges:: 版本支持投影:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}, {"Carol", 35}};
// 找年龄最小的
auto it = std::ranges::min_element(people, {}, &Person::age);
std::cout << it->name; // Bob
// 自定义比较器:按绝对值比较
std::vector<int> v{-5, 3, -1, 4};
auto it2 = std::min_element(v.begin(), v.end(),
[](int a, int b) { return std::abs(a) < std::abs(b); });
std::cout << *it2; // -1
(3) 执行策略
min_element、max_element、minmax_element 支持 C++17 执行策略(但并行版本可能因分割而失去「返回第一个最值」的保证)。
4. 注意事项
min/max返回引用:auto x = std::max(1, 2);会得到悬垂引用(C++11 起返回const T&)。应写成auto x = std::max<int>(1, 2);或先存变量。min/max对相等值的处理:min(a, b)在相等时返回a,max(a, b)在相等时返回a(不是b)。minmax_element更高效:比分别调用min_element和max_element少约 25% 的比较。clamp的前提:要求lo <= hi,否则行为未定义。*_element返回第一个最值:有多个相同最值时返回第一个。- 空范围:
min_element/max_element对空范围返回last,解引用是未定义行为。
5. 相关算法
- nth_element:求第 \( N \) 小
- sort / stable_sort:完整排序
- is_sorted:检查是否已排序
- accumulate:求和
std::lexicographical_compare / lexicographical_compare_three_way
这两个算法按字典序比较两个范围:
lexicographical_compare:返回布尔值,判断第一个范围是否字典序小于第二个lexicographical_compare_three_way:返回三路比较结果(C++20)
1. 引入
#include <algorithm>
2. 原理
template<class InputIt1, class InputIt2>
constexpr bool lexicographical_compare(InputIt1 first1, InputIt1 last1,
InputIt2 first2, InputIt2 last2);
template<class InputIt1, class InputIt2, class Compare>
constexpr bool lexicographical_compare(InputIt1 first1, InputIt1 last1,
InputIt2 first2, InputIt2 last2, Compare comp);
// C++20
template<class InputIt1, class InputIt2, class Cmp>
constexpr auto lexicographical_compare_three_way(InputIt1 first1, InputIt1 last1,
InputIt2 first2, InputIt2 last2, Cmp comp);
- 迭代器要求:
InputIterator。 - 复杂度:至多 \( 2\min(N_1, N_2) \) 次比较。
- 返回值:
lexicographical_compare返回bool;_three_way版本返回比较类别。
字典序规则:
- 逐元素比较,遇到第一对不相等的元素,比较结果即为整体结果
- 如果所有对应元素都相等,则较短的范围较小
这正是 std::string、std::vector 等容器 operator< 的语义。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
std::vector<int> a{1, 2, 3};
std::vector<int> b{1, 2, 4};
std::vector<int> c{1, 2};
std::cout << std::boolalpha
<< std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end())
<< '\n' // true(3 < 4)
<< std::lexicographical_compare(b.begin(), b.end(), a.begin(), a.end())
<< '\n' // false
<< std::lexicographical_compare(c.begin(), c.end(), a.begin(), a.end())
<< '\n'; // true(c 是 a 的前缀且更短)
// C++20 三路比较
auto result = std::lexicographical_compare_three_way(
a.begin(), a.end(), b.begin(), b.end(), std::compare_three_way{});
std::cout << (result < 0 ? "a < b" : "a >= b") << '\n'; // a < b
}
(2) 谓词与投影
自定义比较器,例如忽略大小写比较字符串:
std::string s1 = "Apple";
std::string s2 = "banana";
bool less = std::lexicographical_compare(
s1.begin(), s1.end(), s2.begin(), s2.end(),
[](char a, char b) { return std::tolower(a) < std::tolower(b); });
std::cout << std::boolalpha << less; // true
(3) 执行策略
lexicographical_compare 不支持执行策略,因为需要短路返回。
4. 注意事项
- 长度不同时的规则:如果一个是另一个的前缀,较短者较小。这与「先比较完所有元素再看长度」的直觉一致。
- 直接比较容器更简单:
std::vector、std::string等都有operator<,直接用a < b即可,内部就是字典序。 _three_way返回比较类别:需要包含<compare>,返回std::strong_ordering等类型。- 空范围:空范围字典序小于任何非空范围,两个空范围相等。
5. 相关算法
- equal:判断两个范围是否相同
- mismatch:找第一个不匹配位置
- is_permutation:判断排列关系
- sort / stable_sort:排序
std::next_permutation / prev_permutation / is_permutation
这三个算法处理排列:
next_permutation:把范围变换为下一个字典序更大的排列prev_permutation:把范围变换为下一个字典序更小的排列is_permutation:判断一个序列是否为另一个序列的排列(C++11)
1. 引入
#include <algorithm>
2. 原理
template<class BidirIt>
constexpr bool next_permutation(BidirIt first, BidirIt last);
template<class BidirIt>
constexpr bool prev_permutation(BidirIt first, BidirIt last);
template<class ForwardIt1, class ForwardIt2>
constexpr bool is_permutation(ForwardIt1 first1, ForwardIt1 last1, ForwardIt2 first2);
- 迭代器要求:
next_permutation/prev_permutation需要BidirectionalIterator;is_permutation需要ForwardIterator。 - 复杂度:排列操作至多 \( n/2 \) 次交换;
is_permutation最坏 \( O(n^2) \) 次比较(若可用则用 \( O(n \log n) \) 的排序方法)。 - 返回值:
next_permutation返回true表示还有下一个排列;如果已经是最后一个排列,则把范围重置为第一个排列并返回false。
next_permutation 的算法步骤:
- 从右往左找第一个满足
*i < *(i+1)的位置i - 从右往左找第一个满足
*i < *j的位置j - 交换
*i和*j - 反转
[i+1, last)区间
要生成所有排列,范围必须先按升序排序。
3. 用法
(1) 基本用法
#include <algorithm>
#include <iostream>
#include <vector>
int main()
{
// 遍历所有排列(必须先排序)
std::vector<int> v{1, 2, 3};
do {
for (int x : v) std::cout << x;
std::cout << ' ';
} while (std::next_permutation(v.begin(), v.end()));
// 输出: 123 132 213 231 312 321
// prev_permutation:需要先降序排序
std::vector<int> v2{3, 2, 1};
do {
for (int x : v2) std::cout << x;
std::cout << ' ';
} while (std::prev_permutation(v2.begin(), v2.end()));
// 输出: 321 312 231 213 132 123
// is_permutation
std::vector<int> a{1, 2, 3};
std::vector<int> b{3, 1, 2};
std::cout << std::boolalpha
<< std::is_permutation(a.begin(), a.end(), b.begin(), b.end()) << '\n'; // true
}
(2) 谓词与投影
可以传入自定义比较器:
std::vector<int> v{1, 2, 3};
// 按降序生成排列
std::sort(v.begin(), v.end(), std::greater<>{});
do {
// ...
} while (std::next_permutation(v.begin(), v.end(), std::greater<>{}));
(3) 执行策略
这三个算法不支持执行策略。
4. 注意事项
- 必须先排序:要生成所有排列,初始范围必须有序(
next_permutation需升序,prev_permutation需降序)。 - 重复元素:有重复元素时,
next_permutation会自动跳过重复的排列,只生成不同的排列。 - 返回
false时范围被重置:next_permutation在最后一个排列时返回false,但会把范围改为第一个排列。用do-while循环是正确的写法。 is_permutation的复杂度:最坏 \( O(n^2) \),如果范围很大且可排序,先排序再比较可能更快。- 排列数量爆炸:\( n! \) 增长极快,只适用于小规模数据。
is_permutation的 C++14 双范围版本:可以处理长度不同的范围。
5. 相关算法
- sort / stable_sort:排序(排列的前提)
- is_sorted:检查是否已排序
- shuffle:随机打乱
- equal:判断范围相同
数值操作 (Numeric operations)
数值算法定义在 <numeric> 中,用于对范围进行求和、折叠、扫描等数值计算。C++17 引入了支持执行策略的并行版本(reduce、scan 等),它们允许以任意顺序组合元素,从而获得更好的并行性能。
主要头文件:
<numeric>:包含所有传统的数值算法和 C++17 的并行数值算法。<ranges>:包含ranges::iota。
算法总览
| 算法 | C++ 版本 | 头文件 | 描述 |
|---|---|---|---|
| iota | (C++11) | <numeric> | 用递增的起始值填充一个范围 |
| accumulate | <numeric> | 对元素范围求和或折叠 | |
| inner_product | <numeric> | 计算两个元素范围的内积 | |
| adjacent_difference | <numeric> | 计算相邻元素之间的差值 | |
| partial_sum | <numeric> | 计算元素范围的部分和 | |
| reduce | (C++17) | <numeric> | 类似于 accumulate,但允许无序归约 |
| exclusive_scan / inclusive_scan | (C++17) | <numeric> | 计算独占扫描或包含扫描 |
| transform_reduce | (C++17) | <numeric> | 先应用可调用对象,再无序归约 |
| transform_exclusive_scan / transform_inclusive_scan | (C++17) | <numeric> | 先应用可调用对象,再计算扫描 |
说明: 表中「C++ 版本」列标注的是该算法(或
ranges::版本)首次引入的标准版本,留空表示自 C++98 起即存在。
共性说明
accumulatevsreduce:accumulate严格按顺序计算,因此要求运算满足结合律即可;reduce允许任意顺序组合,因此运算必须满足交换律和结合律,否则结果不确定。- 扫描的差异:
partial_sum属于包含扫描;exclusive_scan的第 \( N \) 个结果不包含第 \( N \) 个输入元素;inclusive_scan则包含。 - 并行支持:
reduce、exclusive_scan、inclusive_scan、transform_reduce、transform_*_scan支持执行策略;accumulate、inner_product、partial_sum、adjacent_difference不支持。 ranges::版本:目前只有ranges::iota(C++23),其余数值算法暂无 Ranges 版本。
std::iota
std::iota 用递增的起始值依次填充范围:第一个元素赋 value,第二个赋 value + 1,依此类推(实际是对前一个值执行 ++)。
1. 引入
#include <numeric>
2. 原理
template<class ForwardIt, class T>
constexpr void iota(ForwardIt first, ForwardIt last, T value);
- 迭代器要求:
ForwardIterator。 - 复杂度:恰好 \( n \) 次自增和赋值。
- 返回值:
void。
实现等价于:
template<class ForwardIt, class T>
void iota(ForwardIt first, ForwardIt last, T value)
{
while (first != last)
*first++ = value++;
}
注意它用的是 ++ 而非 + 1,因此对支持自增的自定义类型也适用。
ranges::iota 是 C++23 新增的 Ranges 版本,支持投影,且要求 T 与元素类型可赋值。
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <vector>
int main()
{
std::vector<int> v(5);
std::iota(v.begin(), v.end(), 1);
// v = {1,2,3,4,5}
// 从 0 开始
std::vector<int> idx(5);
std::iota(idx.begin(), idx.end(), 0);
// idx = {0,1,2,3,4}
// 填充字符
std::string s(5, ' ');
std::iota(s.begin(), s.end(), 'a');
// s = "abcde"
}
(2) 谓词与投影
iota 没有谓词参数。ranges::iota 支持投影,但投影通常用于只读算法,对 iota 意义不大。
(3) 执行策略
iota 不支持执行策略。它依赖严格的前后依赖关系(每个值基于前一个值),无法并行化。
4. 注意事项
- 递增是连续的:
iota只能生成连续递增序列,步长固定为 1。需要其他步长时用generate配合 lambda。 - 溢出风险:如果
value + n超出类型范围,行为未定义。 iota不是itoa:名字来自 APL 语言的ι(iota),表示「连续整数序列」。- C++23 的
ranges::iota:支持范围参数,用法更简洁。 - 与
fill的区别:fill用固定值填充,iota用递增序列填充。
5. 相关算法
- fill / fill_n:用固定值填充
- generate / generate_n:用函数返回值填充
- partial_sum:计算部分和
- adjacent_difference:计算相邻差值
std::accumulate
std::accumulate 对范围内的元素进行左折叠:从初始值 init 开始,依次对每个元素应用二元操作,返回最终结果。默认操作是加法(求和)。
1. 引入
#include <numeric>
2. 原理
template<class InputIt, class T>
constexpr T accumulate(InputIt first, InputIt last, T init);
template<class InputIt, class T, class BinaryOp>
constexpr T accumulate(InputIt first, InputIt last, T init, BinaryOp op);
- 迭代器要求:
InputIterator。 - 复杂度:恰好 \( n \) 次
op调用。 - 返回值:折叠结果,类型为
T。
实现等价于:
template<class InputIt, class T, class BinaryOp>
T accumulate(InputIt first, InputIt last, T init, BinaryOp op)
{
for (; first != last; ++first)
init = op(std::move(init), *first);
return init;
}
严格按顺序求值,因此对非交换、非结合的操作也能给出确定结果。
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <string>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 求和
int sum = std::accumulate(v.begin(), v.end(), 0);
std::cout << sum << '\n'; // 15
// 求积
int product = std::accumulate(v.begin(), v.end(), 1, std::multiplies<>{});
std::cout << product << '\n'; // 120
// 拼接字符串
std::vector<std::string> words{"a", "b", "c"};
std::string joined = std::accumulate(words.begin(), words.end(), std::string{});
std::cout << joined << '\n'; // abc
// 求最大值
int mx = std::accumulate(v.begin(), v.end(), v.front(),
[](int a, int b) { return std::max(a, b); });
std::cout << mx << '\n'; // 5
}
(2) 谓词与投影
accumulate 接受二元操作,没有投影参数。可以自己处理成员:
struct Person { std::string name; int age; };
std::vector<Person> people{{"Alice", 30}, {"Bob", 25}};
int totalAge = std::accumulate(people.begin(), people.end(), 0,
[](int acc, const Person& p) { return acc + p.age; });
// 55
(3) 执行策略
accumulate 不支持执行策略,因为它要求严格顺序求值。需要并行归约时用 std::reduce(要求操作满足交换律和结合律)。
4. 注意事项
- 初始值类型决定返回类型:
std::accumulate(v.begin(), v.end(), 0)返回int,即使元素是double也会被截断。应写成0.0。 - 初始值类型决定累加类型:常见的坑是
accumulate(v.begin(), v.end(), 0)对vector<double>会丢失小数部分。 - 顺序求值:
accumulate严格按顺序,因此对浮点数求和的结果是可复现的,但也无法并行加速。 - 空范围返回
init。 - 与
reduce的区别:reduce允许任意顺序,要求操作满足交换律和结合律,但支持并行。
5. 相关算法
- reduce:允许无序的并行归约
- transform_reduce:先变换再归约
- partial_sum:计算部分和
- fold:C++23 的折叠算法
- inner_product:内积
std::inner_product
std::inner_product 计算两个范围的内积:把对应元素相乘后累加。默认行为是「乘积累加」,但可以通过两个自定义操作实现更一般的「广义内积」。
1. 引入
#include <numeric>
2. 原理
template<class InputIt1, class InputIt2, class T>
constexpr T inner_product(InputIt1 first1, InputIt1 last1, InputIt2 first2, T init);
template<class InputIt1, class InputIt2, class T, class BinaryOp1, class BinaryOp2>
constexpr T inner_product(InputIt1 first1, InputIt1 last1, InputIt2 first2, T init,
BinaryOp1 op1, BinaryOp2 op2);
- 迭代器要求:
InputIterator。 - 复杂度:恰好 \( n \) 次
op1和 \( n \) 次op2调用。 - 返回值:内积结果,类型为
T。
实现等价于:
template<class InputIt1, class InputIt2, class T, class BinaryOp1, class BinaryOp2>
T inner_product(InputIt1 first1, InputIt1 last1, InputIt2 first2, T init,
BinaryOp1 op1, BinaryOp2 op2)
{
for (; first1 != last1; ++first1, ++first2)
init = op1(std::move(init), op2(*first1, *first2));
return init;
}
默认 op1 是 +,op2 是 *。严格按顺序求值。
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <vector>
int main()
{
std::vector<int> a{1, 2, 3};
std::vector<int> b{4, 5, 6};
// 内积:1*4 + 2*5 + 3*6 = 32
int dot = std::inner_product(a.begin(), a.end(), b.begin(), 0);
std::cout << dot << '\n'; // 32
// 广义内积:先相加再取最大值(相当于求 max(a[i]+b[i]))
int mx = std::inner_product(a.begin(), a.end(), b.begin(), INT_MIN,
[](int x, int y) { return std::max(x, y); },
std::plus<>{});
std::cout << mx << '\n'; // 9
}
(2) 谓词与投影
inner_product 接受两个二元操作,没有投影参数。
(3) 执行策略
inner_product 不支持执行策略,因为要求严格顺序求值。需要并行时用 transform_reduce:
// 并行计算内积
int dot = std::transform_reduce(std::execution::par,
a.begin(), a.end(), b.begin(), 0);
4. 注意事项
- 第二个范围必须足够长:只检查第一个范围的长度,第二个范围较短会越界。
- 初始值类型决定返回类型:同样有
0与0.0的坑。 - 顺序求值:无法并行加速。
transform_reduce是并行替代品:语义相同但允许乱序,要求操作满足交换律和结合律。- 数学上的内积:对向量来说就是点积,是很多数值算法的基础。
5. 相关算法
- accumulate:单范围折叠
- transform_reduce:先变换再归约(可并行)
- adjacent_difference:相邻差值
- partial_sum:部分和
std::adjacent_difference
std::adjacent_difference 计算范围内相邻元素之间的差值:第一个输出元素是第一个输入元素本身,之后的每个输出元素是当前元素与前一个元素的差。
1. 引入
#include <numeric>
2. 原理
template<class InputIt, class OutputIt>
constexpr OutputIt adjacent_difference(InputIt first, InputIt last, OutputIt d_first);
template<class InputIt, class OutputIt, class BinaryOp>
constexpr OutputIt adjacent_difference(InputIt first, InputIt last, OutputIt d_first,
BinaryOp op);
- 迭代器要求:输入
InputIterator,输出OutputIterator。 - 复杂度:恰好 \( n-1 \) 次
op调用。 - 返回值:指向输出范围末尾的迭代器。
计算规则:
*d_first = *first(第一个元素原样输出)*(d_first + i) = op(*(first + i), *(first + i - 1)),默认op是减法
这是 partial_sum 的逆运算:对序列做 partial_sum 再做 adjacent_difference 会得到原序列。
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <vector>
int main()
{
std::vector<int> v{2, 4, 6, 8, 10};
std::vector<int> diff(v.size());
std::adjacent_difference(v.begin(), v.end(), diff.begin());
// diff = {2, 2, 2, 2, 2}
// 原地计算
std::vector<int> v2{1, 3, 6, 10, 15};
std::adjacent_difference(v2.begin(), v2.end(), v2.begin());
// v2 = {1, 2, 3, 4, 5}
// 自定义操作:相邻元素之和
std::vector<int> v3{1, 2, 3, 4};
std::vector<int> sum(v3.size());
std::adjacent_difference(v3.begin(), v3.end(), sum.begin(), std::plus<>{});
// sum = {1, 3, 5, 7}
}
(2) 谓词与投影
接受二元操作,没有投影参数。
(3) 执行策略
adjacent_difference 不支持执行策略,因为每个输出依赖相邻的两个输入。
4. 注意事项
- 输出第一个元素是输入的第一个元素:不是差值,容易忽略。
- 可以原地计算:
d_first == first是允许的(实现会从后往前处理)。 - 输出范围必须足够大:至少与输入等长。
- 与
partial_sum互逆:adjacent_difference(partial_sum(x)) == x。 - 默认操作是减法:
op(*i, *(i-1))的顺序是「当前减前一个」。
5. 相关算法
- partial_sum:部分和(逆运算)
- accumulate:求和
- inner_product:内积
- iota:递增填充
std::partial_sum
std::partial_sum 计算范围的部分和:第 \( i \) 个输出元素是输入范围前 \( i+1 \) 个元素的累加结果。
1. 引入
#include <numeric>
2. 原理
template<class InputIt, class OutputIt>
constexpr OutputIt partial_sum(InputIt first, InputIt last, OutputIt d_first);
template<class InputIt, class OutputIt, class BinaryOp>
constexpr OutputIt partial_sum(InputIt first, InputIt last, OutputIt d_first, BinaryOp op);
- 迭代器要求:输入
InputIterator,输出OutputIterator。 - 复杂度:恰好 \( n-1 \) 次
op调用。 - 返回值:指向输出范围末尾的迭代器。
计算规则:
*d_first = *first*(d_first + i) = op(*(d_first + i - 1), *(first + i))
默认 op 是加法。这是包含扫描(inclusive scan),与 inclusive_scan 语义相同。
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
std::vector<int> sums(v.size());
std::partial_sum(v.begin(), v.end(), sums.begin());
// sums = {1, 3, 6, 10, 15}
// 原地计算
std::vector<int> v2{1, 2, 3, 4, 5};
std::partial_sum(v2.begin(), v2.end(), v2.begin());
// v2 = {1, 3, 6, 10, 15}
// 自定义操作:部分积
std::vector<int> v3{1, 2, 3, 4};
std::vector<int> prods(v3.size());
std::partial_sum(v3.begin(), v3.end(), prods.begin(), std::multiplies<>{});
// prods = {1, 2, 6, 24}
}
(2) 谓词与投影
接受二元操作,没有投影参数。
(3) 执行策略
partial_sum 不支持执行策略,因为每个输出依赖前一个输出。需要并行扫描时用 inclusive_scan 或 exclusive_scan。
4. 注意事项
- 第一个输出等于第一个输入。
- 可以原地计算:
d_first == first是允许的。 - 输出范围必须足够大。
- 溢出风险:累加值可能超出类型范围。
- 与
inclusive_scan的关系:语义相同,但inclusive_scan支持执行策略且允许指定初始值。 - 与
accumulate的区别:accumulate只返回最终结果,partial_sum返回每一步的中间结果。
5. 相关算法
- adjacent_difference:相邻差值(逆运算)
- accumulate:只求总和
- inclusive_scan / exclusive_scan:可并行的扫描
- reduce:可并行的归约
std::reduce
C++17 引入的 std::reduce 与 std::accumulate 类似,都是对范围进行归约,但 reduce 允许以任意顺序组合元素,因此可以并行化。
代价:操作必须满足交换律和结合律,否则结果不确定。
1. 引入
#include <numeric>
2. 原理
template<class InputIt>
constexpr typename iterator_traits<InputIt>::value_type reduce(InputIt first, InputIt last);
template<class InputIt, class T>
constexpr T reduce(InputIt first, InputIt last, T init);
template<class InputIt, class T, class BinaryOp>
constexpr T reduce(InputIt first, InputIt last, T init, BinaryOp op);
template<class ExecutionPolicy, class ForwardIt, class T, class BinaryOp>
T reduce(ExecutionPolicy&& policy, ForwardIt first, ForwardIt last, T init, BinaryOp op);
- 迭代器要求:
InputIterator(并行版本需要ForwardIterator)。 - 复杂度:\( O(n) \) 次
op调用。 - 返回值:归约结果。
与 accumulate 的关键区别:
| 特性 | accumulate | reduce |
|---|---|---|
| 求值顺序 | 严格从左到右 | 任意顺序 |
| 并行支持 | 否 | 是 |
| 操作要求 | 无 | 必须满足交换律和结合律 |
| 结果确定性 | 确定 | 浮点运算可能有细微差异 |
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// 求和
int sum = std::reduce(v.begin(), v.end());
std::cout << sum << '\n'; // 15
// 指定初始值
int sum2 = std::reduce(v.begin(), v.end(), 100);
std::cout << sum2 << '\n'; // 115
// 自定义操作:求积
int product = std::reduce(v.begin(), v.end(), 1, std::multiplies<>{});
std::cout << product << '\n'; // 120
}
(2) 谓词与投影
reduce 接受二元操作,没有投影参数。需要先变换时用 transform_reduce。
(3) 执行策略
reduce 支持 C++17 执行策略:
#include <execution>
int sum = std::reduce(std::execution::par, v.begin(), v.end(), 0);
重要:并行版本要求操作满足交换律和结合律。对于浮点加法,由于精度问题,并行结果可能与串行结果有细微差异。
4. 注意事项
- 操作必须满足交换律和结合律:这是使用
reduce的硬性要求。减法、除法不满足,不能用。 - 浮点数的非确定性:并行归约的分组方式取决于实现,浮点加法不满足结合律,结果可能有微小差异。
- 无初始值版本:
reduce(first, last)要求范围非空,且返回value_type。 - 与
accumulate的选择:需要严格顺序或操作不满足交换律时用accumulate;需要并行加速时用reduce。 reduce的初始值类型:不指定时用value_type{},可能不是期望的类型。
5. 相关算法
- accumulate:严格顺序的归约
- transform_reduce:先变换再归约
- inclusive_scan / exclusive_scan:扫描
- fold:C++23 的折叠算法
std::exclusive_scan / std::inclusive_scan
C++17 引入的这两个算法计算范围的前缀和(扫描),区别在于是否包含当前元素:
inclusive_scan:第 \( i \) 个结果包含第 \( i \) 个输入元素exclusive_scan:第 \( i \) 个结果不包含第 \( i \) 个输入元素
它们都允许以任意顺序计算,因此支持并行。
1. 引入
#include <numeric>
2. 原理
template<class InputIt, class OutputIt>
constexpr OutputIt inclusive_scan(InputIt first, InputIt last, OutputIt d_first);
template<class InputIt, class OutputIt, class T>
constexpr OutputIt exclusive_scan(InputIt first, InputIt last, OutputIt d_first, T init);
template<class ExecutionPolicy, class ForwardIt, class OutputIt>
OutputIt inclusive_scan(ExecutionPolicy&& policy, ForwardIt first, ForwardIt last,
OutputIt d_first);
- 迭代器要求:输入
InputIterator,输出OutputIterator(并行版本需要ForwardIterator)。 - 复杂度:\( O(n) \) 次
op调用。 - 返回值:指向输出范围末尾的迭代器。
两者的差异(以 [1, 2, 3, 4] 为例,操作为加法):
| 算法 | 结果 |
|---|---|
inclusive_scan(初始值 0) | {1, 3, 6, 10} |
exclusive_scan(初始值 0) | {0, 1, 3, 6} |
exclusive_scan 必须提供初始值,inclusive_scan 可以不提供(用第一个元素作为初值)。
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4, 5};
// inclusive_scan:包含当前元素
std::vector<int> inc(v.size());
std::inclusive_scan(v.begin(), v.end(), inc.begin());
// inc = {1, 3, 6, 10, 15}
// exclusive_scan:不包含当前元素
std::vector<int> exc(v.size());
std::exclusive_scan(v.begin(), v.end(), exc.begin(), 0);
// exc = {0, 1, 3, 6, 10}
// 指定初始值
std::vector<int> inc2(v.size());
std::inclusive_scan(v.begin(), v.end(), inc2.begin(), std::plus<>{}, 100);
// inc2 = {101, 103, 106, 110, 115}
}
(2) 谓词与投影
接受二元操作,没有投影参数。需要先变换时用 transform_inclusive_scan / transform_exclusive_scan。
(3) 执行策略
两者都支持 C++17 执行策略:
#include <execution>
std::inclusive_scan(std::execution::par, v.begin(), v.end(), out.begin());
同样要求操作满足交换律和结合律。
4. 注意事项
exclusive_scan必须提供初始值:这是它与inclusive_scan的重要区别。- 输出范围必须足够大。
- 操作必须满足结合律(并行时还需交换律)。
- 与
partial_sum的关系:inclusive_scan语义等同partial_sum,但支持并行。 - 原地操作:
inclusive_scan允许d_first == first;exclusive_scan不允许(因为输出与输入错位)。
5. 相关算法
- partial_sum:顺序版本的包含扫描
- transform_scan:先变换再扫描
- reduce:归约(只要最终结果)
- adjacent_difference:相邻差值
std::transform_reduce
C++17 引入的 std::transform_reduce 先对元素应用变换,再进行归约。它相当于 transform + reduce 的组合,支持并行。
1. 引入
#include <numeric>
2. 原理
// 一元版本:变换后归约
template<class InputIt, class T, class BinaryOp, class UnaryOp>
constexpr T transform_reduce(InputIt first, InputIt last, T init,
BinaryOp reduce, UnaryOp transform);
// 二元版本:两个范围先做变换,再归约(相当于广义内积)
template<class InputIt1, class InputIt2, class T>
constexpr T transform_reduce(InputIt1 first1, InputIt1 last1, InputIt2 first2, T init);
- 迭代器要求:
InputIterator(并行版本需要ForwardIterator)。 - 复杂度:\( O(n) \) 次变换和归约调用。
- 返回值:归约结果。
二元版本等价于 inner_product,但允许乱序,因此可以并行。
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4};
// 先平方再求和:1+4+9+16 = 30
int sumSq = std::transform_reduce(v.begin(), v.end(), 0,
std::plus<>{},
[](int x) { return x * x; });
std::cout << sumSq << '\n'; // 30
// 二元版本:内积
std::vector<int> a{1, 2, 3}, b{4, 5, 6};
int dot = std::transform_reduce(a.begin(), a.end(), b.begin(), 0);
std::cout << dot << '\n'; // 32
// 统计偶数个数(把条件映射为 0/1)
int evens = std::transform_reduce(v.begin(), v.end(), 0, std::plus<>{},
[](int x) { return x % 2 == 0 ? 1 : 0; });
std::cout << evens << '\n'; // 2
}
(2) 谓词与投影
transform_reduce 接受变换和归约两个可调用对象,没有投影参数。
(3) 执行策略
支持 C++17 执行策略,这是它相对 inner_product 的主要优势:
#include <execution>
int sumSq = std::transform_reduce(std::execution::par,
v.begin(), v.end(), 0,
std::plus<>{},
[](int x) { return x * x; });
4. 注意事项
- 归约操作必须满足交换律和结合律(并行时)。
- 二元版本的第二个范围必须足够长。
- 初始值类型决定返回类型。
- 可替代
count_if的并行版本:如上面统计偶数的例子。 - C++20 起
ranges::版本:目前还没有ranges::transform_reduce。
5. 相关算法
- reduce:不带变换的归约
- inner_product:顺序版本的内积
- transform_scan:先变换再扫描
- transform:只做变换
- count / count_if:统计数量
std::transform_exclusive_scan / std::transform_inclusive_scan
C++17 引入的这两个算法先对元素应用变换,再计算扫描(前缀和),相当于 transform + scan 的组合。
transform_inclusive_scan:结果包含当前元素transform_exclusive_scan:结果不包含当前元素
1. 引入
#include <numeric>
2. 原理
template<class InputIt, class OutputIt, class BinaryOp, class UnaryOp>
constexpr OutputIt transform_inclusive_scan(InputIt first, InputIt last, OutputIt d_first,
BinaryOp binary_op, UnaryOp unary_op);
template<class InputIt, class OutputIt, class T, class BinaryOp, class UnaryOp>
constexpr OutputIt transform_exclusive_scan(InputIt first, InputIt last, OutputIt d_first,
T init, BinaryOp binary_op, UnaryOp unary_op);
- 迭代器要求:输入
InputIterator,输出OutputIterator(并行版本需要ForwardIterator)。 - 复杂度:\( O(n) \) 次变换和归约调用。
- 返回值:指向输出范围末尾的迭代器。
注意参数顺序:先归约操作 binary_op,再变换操作 unary_op,这与直觉相反。
3. 用法
(1) 基本用法
#include <iostream>
#include <numeric>
#include <vector>
int main()
{
std::vector<int> v{1, 2, 3, 4};
// 先平方,再包含扫描求和
std::vector<int> inc(v.size());
std::transform_inclusive_scan(v.begin(), v.end(), inc.begin(),
std::plus<>{},
[](int x) { return x * x; });
// 平方后为 {1,4,9,16},扫描得 inc = {1, 5, 14, 30}
// 独占扫描
std::vector<int> exc(v.size());
std::transform_exclusive_scan(v.begin(), v.end(), exc.begin(), 0,
std::plus<>{},
[](int x) { return x * x; });
// exc = {0, 1, 5, 14}
}
(2) 谓词与投影
接受归约和变换两个可调用对象,没有投影参数。
(3) 执行策略
两者都支持 C++17 执行策略:
#include <execution>
std::transform_inclusive_scan(std::execution::par, v.begin(), v.end(), out.begin(),
std::plus<>{}, [](int x) { return x * x; });
4. 注意事项
- 参数顺序反直觉:
binary_op在前,unary_op在后,容易传错。 transform_exclusive_scan必须提供初始值。- 操作必须满足结合律(并行时还需交换律)。
- 原地操作:
transform_inclusive_scan允许d_first == first;transform_exclusive_scan不允许。 transform_inclusive_scan的初始值:可以额外传入init,此时结果会加上该初始值。
5. 相关算法
- inclusive_scan / exclusive_scan:不带变换的扫描
- transform_reduce:变换后归约
- partial_sum:顺序版本的扫描
- transform:只做变换
未初始化内存操作 (Operations on uninitialized memory)
这一组算法定义在 <memory> 中,用于在未初始化的原始内存上构造、复制、移动和销毁对象。它们不负责分配内存,只负责在已分配的内存上管理对象的生命周期,是手写容器(如自定义 vector)的基础设施。
主要头文件:
<memory>:包含所有未初始化内存操作。
算法总览
构造与复制
| 算法 | C++ 版本 | 描述 |
|---|---|---|
| uninitialized_copy / uninitialized_copy_n | (C++11) | 将对象复制到未初始化内存区域 |
| uninitialized_fill / uninitialized_fill_n | 将对象复制赋值到未初始化内存区域 | |
| uninitialized_move / uninitialized_move_n | (C++17) | 将对象移动到未初始化内存区域 |
| uninitialized_default_construct / uninitialized_value_construct | (C++17) | 在未初始化内存中默认构造或值构造对象 |
| construct_at | (C++20) | 在给定地址处构造一个对象 |
销毁
| 算法 | C++ 版本 | 描述 |
|---|---|---|
| destroy / destroy_n / destroy_at | (C++17) | 销毁一系列对象或给定地址处的对象 |
说明: 表中「C++ 版本」列标注的是该算法(或
ranges::版本)首次引入的标准版本,留空表示自 C++98 起即存在。
共性说明
- 异常安全:如果构造过程中抛出异常,这些算法会负责销毁已成功构造的对象,不会造成泄漏。
- 与
std::copy的区别:std::copy要求目标区域已存在对象并执行赋值;uninitialized_copy则在原始内存上执行拷贝构造。 ranges::版本:C++20 起提供了对应的ranges::uninitialized_*、ranges::destroy*版本,支持范围参数和投影。- 典型用途:
std::vector扩容时正是用uninitialized_move把旧元素搬到新内存,再销毁旧对象。
std::uninitialized_copy / uninitialized_copy_n
这两个算法把对象拷贝构造到一块未初始化内存区域。与 std::copy 不同,它们不要求目标区域已有对象,而是直接在原始内存上构造。
uninitialized_copy:复制整个范围uninitialized_copy_n:复制指定数量的元素(C++11)
1. 引入
#include <memory>
2. 原理
template<class InputIt, class NoThrowForwardIt>
ForwardIt uninitialized_copy(InputIt first, InputIt last, NoThrowForwardIt d_first);
template<class InputIt, class Size, class NoThrowForwardIt>
ForwardIt uninitialized_copy_n(InputIt first, Size count, NoThrowForwardIt d_first);
- 迭代器要求:输入
InputIterator,输出ForwardIterator(指向未初始化内存)。 - 复杂度:\( O(n) \) 次拷贝构造。
- 返回值:指向最后一个被构造元素之后的位置。
实现等价于:
template<class InputIt, class NoThrowForwardIt>
NoThrowForwardIt uninitialized_copy(InputIt first, InputIt last, NoThrowForwardIt d_first)
{
using T = typename std::iterator_traits<NoThrowForwardIt>::value_type;
NoThrowForwardIt current = d_first;
try {
for (; first != last; ++first, (void)++current)
::new (static_cast<void*>(std::addressof(*current))) T(*first);
return current;
} catch (...) {
// 销毁已构造的对象
for (; d_first != current; ++d_first)
d_first->~T();
throw;
}
}
异常安全:如果构造过程中抛出异常,已构造的对象会被销毁,不会泄漏。
3. 用法
(1) 基本用法
#include <iostream>
#include <memory>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> src{"hello", "world"};
// 分配原始内存(未构造对象)
std::allocator<std::string> alloc;
std::string* raw = alloc.allocate(src.size());
// 在原始内存上拷贝构造
std::uninitialized_copy(src.begin(), src.end(), raw);
// 此时 raw[0]、raw[1] 是有效对象
std::cout << raw[0] << ' ' << raw[1] << '\n'; // hello world
// 手动销毁并释放
std::destroy_n(raw, src.size());
alloc.deallocate(raw, src.size());
}
(2) 谓词与投影
uninitialized_copy 没有谓词或投影参数。ranges::uninitialized_copy 支持投影。
(3) 执行策略
不支持执行策略。内存构造过程需要精确控制异常安全,无法简单并行化。
4. 注意事项
- 目标必须是真正的未初始化内存:如果目标已有对象,应改用
std::copy(否则会重复构造,导致泄漏或未定义行为)。 - 必须手动销毁:构造出的对象需要用
std::destroy或显式调用析构函数来销毁。 - 异常安全:实现会保证异常时不泄漏已构造的对象。
- 典型用途:
std::vector扩容时把旧元素搬到新内存。 ranges::uninitialized_copy(C++20):返回ranges::uninitialized_copy_result,支持投影。
5. 相关算法
- uninitialized_move:移动到未初始化内存
- uninitialized_fill:填充未初始化内存
- destroy:销毁对象
- copy / copy_if / copy_n:复制到已初始化内存
std::uninitialized_fill / uninitialized_fill_n
这两个算法在未初始化内存区域上拷贝构造给定值的副本:
uninitialized_fill:填充整个范围uninitialized_fill_n:填充前 \( N \) 个元素
1. 引入
#include <memory>
2. 原理
template<class NoThrowForwardIt, class T>
void uninitialized_fill(NoThrowForwardIt first, NoThrowForwardIt last, const T& value);
template<class NoThrowForwardIt, class Size, class T>
NoThrowForwardIt uninitialized_fill_n(NoThrowForwardIt first, Size count, const T& value);
- 迭代器要求:
ForwardIterator(指向未初始化内存)。 - 复杂度:\( O(n) \) 次拷贝构造。
- 返回值:
uninitialized_fill返回void;uninitialized_fill_n返回最后一个被构造元素之后的位置。
与 std::fill 的区别:fill 对已存在的对象执行赋值,uninitialized_fill 在原始内存上执行拷贝构造。
3. 用法
(1) 基本用法
#include <iostream>
#include <memory>
#include <string>
int main()
{
std::allocator<std::string> alloc;
std::string* raw = alloc.allocate(3);
// 在原始内存上构造 3 个 "hi"
std::uninitialized_fill_n(raw, 3, std::string("hi"));
std::cout << raw[0] << raw[1] << raw[2] << '\n'; // hihihi
// 销毁并释放
std::destroy_n(raw, 3);
alloc.deallocate(raw, 3);
}
(2) 谓词与投影
没有谓词或投影参数。ranges::uninitialized_fill 支持投影。
(3) 执行策略
不支持执行策略。
4. 注意事项
- 目标必须是未初始化内存:否则会重复构造导致泄漏。
- 必须手动销毁。
uninitialized_fill_n的count非负。- 异常安全:构造失败时会销毁已构造的对象。
std::vector<T> v(n, value)内部就用它:先分配内存,再逐个构造。
5. 相关算法
- uninitialized_copy:复制到未初始化内存
- uninitialized_move:移动到未初始化内存
- uninitialized_construct:默认/值构造
- fill / fill_n:填充已初始化内存
std::uninitialized_move / uninitialized_move_n
C++17 引入的这两个算法把对象移动构造到未初始化内存区域:
uninitialized_move:移动整个范围uninitialized_move_n:移动指定数量的元素
它们与 uninitialized_copy 的区别在于使用移动构造而非拷贝构造,对持有资源的类型(如 std::string、std::vector)更高效。
1. 引入
#include <memory>
2. 原理
template<class InputIt, class NoThrowForwardIt>
NoThrowForwardIt uninitialized_move(InputIt first, InputIt last, NoThrowForwardIt d_first);
template<class InputIt, class Size, class NoThrowForwardIt>
std::pair<InputIt, NoThrowForwardIt>
uninitialized_move_n(InputIt first, Size count, NoThrowForwardIt d_first);
- 迭代器要求:输入
InputIterator,输出ForwardIterator(指向未初始化内存)。 - 复杂度:\( O(n) \) 次移动构造。
- 返回值:
uninitialized_move返回目标范围末尾uninitialized_move_n返回std::pair,包含源范围和目标范围各自的末尾
移动后源对象处于「有效但未指定」状态。
3. 用法
(1) 基本用法
#include <iostream>
#include <memory>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> src{"hello", "world"};
std::allocator<std::string> alloc;
std::string* raw = alloc.allocate(src.size());
// 移动构造到原始内存
std::uninitialized_move(src.begin(), src.end(), raw);
std::cout << raw[0] << ' ' << raw[1] << '\n'; // hello world
// src 中的字符串现在为空(有效但未指定)
std::destroy_n(raw, src.size());
alloc.deallocate(raw, src.size());
}
(2) 谓词与投影
没有谓词或投影参数。ranges::uninitialized_move 支持投影。
(3) 执行策略
不支持执行策略。
4. 注意事项
- 源对象状态:移动后源对象处于有效但未指定状态,不要假设它们为空。
- 目标必须是未初始化内存。
- 必须手动销毁目标对象。
- 典型用途:
std::vector扩容时用uninitialized_move_if_noexcept决定是移动还是拷贝(如果移动构造可能抛异常且类型可拷贝,则选择拷贝以保证强异常安全)。 uninitialized_move_n返回pair:同时给出源和目标的末尾位置。
5. 相关算法
- uninitialized_copy:拷贝构造到未初始化内存
- uninitialized_fill:填充未初始化内存
- destroy:销毁对象
- move / move_backward:移动到已初始化内存
std::uninitialized_default_construct / uninitialized_value_construct
C++17 引入的这两组算法在未初始化内存区域上构造对象,区别在于初始化方式:
uninitialized_default_construct:默认初始化(T t;,内置类型值不确定)uninitialized_value_construct:值初始化(T t{};,内置类型置零)
各自都有 _n 版本,用于构造指定数量的对象。
1. 引入
#include <memory>
2. 原理
template<class NoThrowForwardIt>
void uninitialized_default_construct(NoThrowForwardIt first, NoThrowForwardIt last);
template<class NoThrowForwardIt, class Size>
NoThrowForwardIt uninitialized_default_construct_n(NoThrowForwardIt first, Size n);
template<class NoThrowForwardIt>
void uninitialized_value_construct(NoThrowForwardIt first, NoThrowForwardIt last);
template<class NoThrowForwardIt, class Size>
NoThrowForwardIt uninitialized_value_construct_n(NoThrowForwardIt first, Size n);
- 迭代器要求:
ForwardIterator(指向未初始化内存)。 - 复杂度:\( O(n) \) 次构造。
- 返回值:非
_n版本返回void;_n版本返回末尾位置。
关键区别:
| 算法 | 等价写法 | int 的初值 |
|---|---|---|
uninitialized_default_construct | ::new (p) T; | 不确定 |
uninitialized_value_construct | ::new (p) T(); | 0 |
对类类型,默认构造和值构造通常等价(都调用默认构造函数);对内置类型则不同。
3. 用法
(1) 基本用法
#include <iostream>
#include <memory>
int main()
{
std::allocator<int> alloc;
int* raw = alloc.allocate(3);
// 值初始化:置零
std::uninitialized_value_construct_n(raw, 3);
std::cout << raw[0] << raw[1] << raw[2] << '\n'; // 000
std::destroy_n(raw, 3);
// 默认初始化:值不确定
std::uninitialized_default_construct_n(raw, 3);
// raw[0..2] 的值是不确定的,读取是未定义行为
std::destroy_n(raw, 3);
alloc.deallocate(raw, 3);
}
(2) 谓词与投影
没有谓词或投影参数。ranges:: 版本支持投影。
(3) 执行策略
不支持执行策略。
4. 注意事项
- 默认初始化 vs 值初始化:这是最容易混淆的地方。内置类型用默认初始化会得到不确定的值。
std::vector<T> v(n)用的是值初始化:所以vector<int> v(5)中的元素都是 0。- 必须手动销毁。
- 目标必须是未初始化内存。
_n版本的n非负。
5. 相关算法
- uninitialized_copy:拷贝构造
- uninitialized_fill:填充值
- construct_at:在单个地址构造
- destroy:销毁对象
std::destroy / destroy_n / destroy_at
C++17 引入的这三个算法销毁对象(调用析构函数),但不释放内存:
destroy_at:销毁给定地址处的单个对象destroy:销毁范围内的所有对象destroy_n:销毁范围内的前 \( N \) 个对象
1. 引入
#include <memory>
2. 原理
template<class T>
void destroy_at(T* location);
template<class ForwardIt>
void destroy(ForwardIt first, ForwardIt last);
template<class ForwardIt, class Size>
ForwardIt destroy_n(ForwardIt first, Size n);
- 迭代器要求:
ForwardIterator。 - 复杂度:\( O(n) \) 次析构调用。
- 返回值:
destroy_at和destroy返回void;destroy_n返回最后一个被销毁元素之后的位置。
实现等价于:
template<class T>
void destroy_at(T* location)
{
location->~T();
}
template<class ForwardIt>
void destroy(ForwardIt first, ForwardIt last)
{
for (; first != last; ++first)
std::destroy_at(std::addressof(*first));
}
3. 用法
(1) 基本用法
#include <iostream>
#include <memory>
#include <string>
int main()
{
std::allocator<std::string> alloc;
std::string* raw = alloc.allocate(3);
std::uninitialized_fill_n(raw, 3, std::string("hi"));
// 销毁所有对象(但不释放内存)
std::destroy_n(raw, 3);
// 内存仍需手动释放
alloc.deallocate(raw, 3);
}
(2) 谓词与投影
没有谓词或投影参数。ranges::destroy 等支持投影。
(3) 执行策略
不支持执行策略。
4. 注意事项
- 只销毁不释放:
destroy调用析构函数,但内存仍需deallocate释放。两者是分开的步骤。 - 不要重复销毁:对已销毁的对象再次调用析构函数是未定义行为。
destroy_n的n非负。std::allocator_traits::destroy:分配器也有destroy成员,语义相同但会考虑分配器的自定义行为。- 典型用途:
std::vector::clear()内部就是调用destroy销毁元素,但保留容量。
5. 相关算法
- construct_at:构造对象
- uninitialized_copy:构造对象
- uninitialized_construct:默认/值构造
- vector 的 clear:内部使用 destroy
std::construct_at
C++20 引入的 std::construct_at 在给定地址处构造一个对象,是 placement new 的标准库封装版本。
1. 引入
#include <memory>
2. 原理
template<class T, class... Args>
constexpr T* construct_at(T* location, Args&&... args);
- 参数:
location是目标地址,args转发给T的构造函数。 - 复杂度:常数。
- 返回值:指向新构造对象的指针(即
location)。
实现等价于:
template<class T, class... Args>
constexpr T* construct_at(T* location, Args&&... args)
{
return ::new (static_cast<void*>(location)) T(std::forward<Args>(args)...);
}
与 placement new 的区别:
construct_at是constexpr的(C++20 起),可用于常量求值- 它会在构造前检查
location是否指向有效的存储 - 可以在
constexpr上下文中使用,而 placement new 不能
3. 用法
(1) 基本用法
#include <iostream>
#include <memory>
#include <string>
int main()
{
std::allocator<std::string> alloc;
std::string* raw = alloc.allocate(1);
// 在原始内存上构造对象
std::construct_at(raw, "hello");
std::cout << *raw << '\n'; // hello
// 销毁并释放
std::destroy_at(raw);
alloc.deallocate(raw, 1);
}
constexpr 上下文中的使用:
constexpr int f()
{
std::aligned_storage_t<sizeof(int), alignof(int)> storage;
int* p = reinterpret_cast<int*>(&storage);
std::construct_at(p, 42);
int result = *p;
std::destroy_at(p);
return result;
}
static_assert(f() == 42);
(2) 谓词与投影
没有谓词或投影参数。ranges::construct_at 是 C++20 的 Ranges 版本,语义相同。
(3) 执行策略
不支持执行策略。
4. 注意事项
- 地址必须指向足够的存储:否则是未定义行为。
- 必须手动销毁:构造出的对象需要用
destroy_at销毁。 - 不要对已有对象调用:会重复构造,导致资源泄漏。
- 优先用
construct_at而非 placement new:它更安全,且支持constexpr。 - C++20 起
std::allocator::construct已被移除:应改用std::construct_at或std::allocator_traits::construct。
5. 相关算法
- destroy:销毁对象
- uninitialized_copy:批量构造
- uninitialized_construct:默认/值构造
- uninitialized_fill:填充构造
标准库
C++ 标准库(Standard Library)是随语言一起发布的庞大组件集合,涵盖字符串、容器、算法、时间、随机数、文件系统、并发等各个方面。
STL(Standard Template Library)最初是一个独立的库,后来被纳入标准库。今天所说的 STL 通常指其中的容器、迭代器、算法、函数对象这几部分,而标准库的范围要更广。
| 组件 | 头文件 | 说明 |
|---|---|---|
| STL | 多个 | 容器、迭代器、算法、函数对象,见 STL |
| 字符串 | <string>、<string_view> | std::string 与字符串视图 |
| 输入输出 | <iostream>、<fstream>、<sstream> | 控制台、文件与内存字符串流 |
| 格式化 | <format>、<print> | C++20/23 的格式化输出 |
| 时间 | <chrono> | 时间点、时长与时钟 |
| 随机数 | <random> | 随机数引擎与分布 |
| 正则表达式 | <regex> | 正则匹配与替换 |
| 文件系统 | <filesystem> | 路径操作与目录遍历 |
| 数值 | <cmath>、<complex>、<numbers> | 数学函数、复数与数学常量 |
| 位操作 | <bitset>、<bit> | 位集合与位运算 |
| 并发 | <thread> 等 | 见 多线程与并发 |
本章内容
| 章节 | 说明 |
|---|---|
| 文件与流 | 流模型与类层次结构总览 |
| 标准输入输出流 (iostream) | cin / cout / cerr / clog 与格式化控制 |
| 文件流 (fstream) | 文件读写、打开模式、二进制与随机访问 |
| 字符串流 (sstream) | 内存字符串的解析与拼接 |
| 字符串 (string) | std::string 接口、SSO 与 std::string_view |
| 时间库 (chrono) | 时长、时间点、时钟与 C++20 日历 |
| 随机数 (random) | 随机数引擎、分布与正确用法 |
| 正则表达式 (regex) | 正则匹配、捕获组、遍历与替换 |
| 文件系统 (filesystem) | 路径操作、目录遍历与文件属性 |
| 格式化输出 (format) | std::format 与 std::print |
文件与流(Files and Streams)
在 C++ 中,流(Stream) 是输入输出系统的核心概念。所有数据的读写操作——无论来自键盘、内存、文件还是网络——都被统一抽象为“流”的形式。 从概念上讲,文件是流的一种具体实现:流是一种数据传输通道,而文件流则是通向磁盘文件的通道。
一、流的基本概念
在程序运行过程中,数据在设备之间不断流动。C++ 将数据的输入输出过程抽象为一个“流(stream)”,即:
数据在内存与外部设备之间的有序传输。
流有两种基本方向:
| 类型 | 类名 | 说明 |
|---|---|---|
| 输入流 | istream | 数据从设备流入程序(例如键盘输入、文件读取) |
| 输出流 | ostream | 数据从程序流向设备(例如屏幕输出、文件写入) |
C++ 的标准输入输出(如 cin、cout、cerr、clog)都是基于这套流机制实现的。
二、C++ 流类层次结构
C++ 标准库为不同的数据来源提供了不同种类的流类,这些类共同组成一个继承体系:
ios_base
└── ios
├── istream // 输入流
│ ├── ifstream // 文件输入流
│ └── istringstream// 字符串输入流
├── ostream // 输出流
│ ├── ofstream // 文件输出流
│ └── ostringstream// 字符串输出流
└── iostream // 输入输出流
├── fstream // 文件输入输出流
└── stringstream // 字符串输入输出流
可以看到,无论是文件流、字符串流还是标准流,它们都共享相同的接口和操作方式。 因此,掌握流的基本用法,就能轻松在不同输入输出介质之间迁移代码。
三、流的分类与用途
| 类型 | 头文件 | 主要类 | 典型用途 |
|---|---|---|---|
| 标准输入输出流 | <iostream> | cin, cout, cerr, clog | 控制台输入输出 |
| 文件流 | <fstream> | ifstream, ofstream, fstream | 读取与写入文件 |
| 字符串流 | <sstream> | istringstream, ostringstream, stringstream | 内存中字符串格式化与解析 |
四、文件作为流的体现
在操作文件时,我们使用 ifstream、ofstream、fstream 来打开磁盘文件并执行读写。
但本质上,这些类并没有引入新的 I/O 模型,而是继承自 istream / ostream,仅仅改变了流的来源或去向:
ifstream:从文件读取数据(输入流)ofstream:向文件写入数据(输出流)fstream:既可读也可写(双向流)
这种统一的流模型让文件操作与普通输入输出完全一致:
std::ifstream fin("input.txt");
std::ofstream fout("output.txt");
int x;
fin >> x; // 从文件读取
fout << x * 2; // 写入文件
同时,为了适应多种文件类型,还支持基于二进制操作文件流:
// 二进制写入
std::ofstream fout_bin("data.bin", std::ios::binary);
int x = 42;
fout_bin.write(reinterpret_cast<const char*>(&x), sizeof(x));
五、字符串流的作用
<sstream> 提供了面向内存字符串的流操作。
它们与文件流类似,但数据读写的目标是内存字符串而非磁盘文件,非常适合:
- 格式化文本(如将数值转为字符串)
- 从字符串中提取结构化数据
- 临时缓冲输出内容
std::stringstream ss;
ss << "Result: " << 42;
std::string text = ss.str(); // "Result: 42"
早期 C++ 还提供
<strstream>实现基于字符数组的流,但由于安全性和内存管理问题,现已由<sstream>完全取代。
六、流状态与错误处理
在 C++ 的流系统中,无论是标准输入输出流、文件流还是字符串流,都共享一套统一的状态机制。 每个流对象都维护着一个内部状态,用于反映当前输入输出操作的健康状况。程序可以通过这些状态来判断流是否处于可用、结束或错误状态,从而实现可靠的错误控制。
1. 状态标志(Stream State Flags)
C++ 通过四种主要的状态标志来描述流的状态,这些标志可能同时存在,用于表达复杂情况:
| 状态名 | 成员常量 | 含义 |
|---|---|---|
goodbit | std::ios::goodbit | 一切正常,流处于可用状态 |
eofbit | std::ios::eofbit | 已到达输入结束(End Of File) |
failbit | std::ios::failbit | 输入失败,通常是格式不匹配(如期望数字却读到字符) |
badbit | std::ios::badbit | 流已损坏,通常是严重的系统性错误(如设备失效) |
流的状态存储在 std::ios 基类中,因此所有继承自它的类(如 istream、ostream、fstream、stringstream)都拥有相同的状态接口。
2. 状态检查接口
流对象提供了多种方式用于检查状态:
| 函数 | 返回值 | 说明 |
|---|---|---|
good() | true / false | 流是否处于正常状态 |
eof() | true / false | 是否到达文件或输入末尾 |
fail() | true / false | 是否发生输入失败 |
bad() | true / false | 是否出现系统性错误 |
rdstate() | iostate | 返回全部状态标志的组合 |
例如:
int x;
std::cin >> x;
if (std::cin.fail()) {
std::cerr << "输入错误:类型不匹配。" << std::endl;
}
当用户输入非数字时,cin.fail() 将为 true,表示提取操作失败。
3. 状态恢复与忽略输入
一旦流进入错误状态,后续输入输出操作将被阻塞。要恢复流,需要手动清除错误标志并可能丢弃无效输入:
std::cin.clear(); // 清除所有错误标志
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 忽略当前行
这段代码常用于防止错误输入导致程序陷入死循环,是交互式程序中非常典型的输入修复模式。
4. 状态机制的意义
C++ 的流状态机制让输入输出更具鲁棒性和通用性。 无论是键盘输入、文件读取,还是内存字符串解析,都可以通过相同的方式检测和处理异常。这种设计体现了“统一的流模型”思想:
- 所有流对象共享同一组状态接口;
- 程序可根据状态灵活决定后续逻辑;
- 错误恢复无需依赖具体 I/O 类型。
这种一致性为大型项目中的 I/O 管理提供了强大的可移植性与可扩展性。
总结
“文件与流”既是 C++ I/O 系统的核心概念,也是工程实践中最常用的技术之一:
- 日志系统基于文件流;
- 配置文件解析常通过字符串流;
- 网络传输底层也可抽象为流。
掌握流的思想,意味着可以用同一套接口处理不同数据源,为程序的输入输出设计提供统一模型。
标准输入输出流(iostream)
C++ 的输入输出体系以 <iostream> 头文件为核心。它定义了程序与外部设备(例如键盘与显示器)交互的最基本机制,是所有流操作的基础。标准输入输出流提供了一组通用接口,用于在内存与外部设备之间以流的形式进行数据传输。
一、标准流对象
C++ 预定义了四个标准流对象,分别用于输入、输出与错误处理:
| 对象名 | 所属类 | 方向 | 描述 |
|---|---|---|---|
cin | istream | 输入 | 从标准输入(键盘)读取数据 |
cout | ostream | 输出 | 向标准输出(控制台)写入数据 |
cerr | ostream | 输出 | 向标准错误输出写入数据(不带缓冲) |
clog | ostream | 输出 | 向标准错误输出写入数据(带缓冲) |
cin 和 cout 是最常用的两个流对象,它们对应于 C 语言中的 stdin 与 stdout。而 cerr 与 clog 则用于错误与日志输出:
cerr立即输出,不经过缓冲,适合错误提示;clog使用缓冲区,适合记录日志或调试信息。
二、输入与输出运算符
C++ 使用运算符重载机制,使输入与输出操作更自然直观:
int a;
std::cin >> a; // 输入,将数据流入变量 a
std::cout << a << '\n'; // 输出,将数据流出到控制台
>> 是提取运算符(extraction operator),从输入流中提取数据;
<< 是插入运算符(insertion operator),向输出流中插入数据。
这两个运算符被广泛重载,可用于所有基本类型与标准容器(通过 operator<< 的重载),使得流式编程成为 C++ 的一大特色。例如:
std::string name;
int age;
std::cin >> name >> age;
std::cout << "Name: " << name << ", Age: " << age << std::endl;
每个操作符都返回流对象自身的引用,从而允许链式调用。
三、流缓冲与刷新机制
所有标准输出流都带有缓冲区。输出内容首先被写入缓冲区,当缓冲区满、遇到换行符、调用 flush 或程序结束时,缓冲内容才会真正输出到设备。
常见的刷新方式有:
std::endl:输出换行并刷新缓冲区;std::flush:仅刷新缓冲区;std::ends:输出一个空字符并刷新缓冲区。
std::cout << "Hello, world!" << std::endl; // 换行并刷新
这种延迟输出机制提高了效率,但在交互程序中需要注意及时刷新,否则可能出现输出滞后。
四、格式化输出
C++ 流提供了丰富的格式化控制机制,用于调整输出格式。最常见的控制包括:
-
操纵符(Manipulators) 通过
<iomanip>头文件可以使用setw,setfill,setprecision,fixed,scientific等控制输出格式:#include <iomanip> double pi = 3.14159265; std::cout << std::fixed << std::setprecision(3) << pi; // 输出 3.142 -
流格式标志 可以通过
setf()、unsetf()修改流的格式状态,例如控制对齐方式、进制表示、是否显示符号等:std::cout.setf(std::ios::showpos); std::cout << 42; // 输出 +42
五、ios 与 ios_base
ios_base 是所有流类的根基,提供全局的格式化与状态管理功能,如:
- 格式标志(
fmtflags) - I/O 状态(
iostate) - 用户自定义存储(
xalloc,iword,pword)
而 ios 则在此基础上增加了缓冲区管理与错误处理机制,是 istream 与 ostream 的共同父类。
六、标准流的重定向
C++ 允许将标准输入输出流重定向到文件或其他流对象,实现灵活的数据通道。例如:
std::ofstream fout("log.txt");
std::streambuf* backup = std::cout.rdbuf(fout.rdbuf()); // 重定向 cout 到文件
std::cout << "This will be written to file." << std::endl;
std::cout.rdbuf(backup); // 恢复
这种技术常用于日志系统或单元测试环境,能够让程序在不修改逻辑的情况下改变输出目标。
七、非格式化输入与其他高级输入方法
提取运算符 >>(格式化输入)在读取数据时会跳过开头的空白符,并且遇到空格、制表符或换行符时会停止,这不适用于读取包含空格的字符串或需要精确控制读取字节数的情况。C++ iostream 库提供了一系列非格式化输入函数来满足这些需求。
1. 读取整行:getline()
std::getline() 是最常用的非格式化输入函数之一,用于读取一行文本,包括其中的空格。
| 函数签名 | 描述 |
|---|---|
std::getline(istream& is, std::string& str, char delim) | 从输入流 is 中读取字符,直到遇到指定的分隔符 delim(默认为 \n),并将读取的内容存入 str。分隔符会被读取,但不会存入 str。 |
std::getline(istream& is, std::string& str) | 使用默认的分隔符 \n 读取一行。 |
#include <iostream>
#include <string>
std::string fullName;
std::cout << "Enter your full name: ";
// 读取整行输入,直到遇到换行符
std::getline(std::cin, fullName);
std::cout << "Welcome, " << fullName << std::endl;
2. 读取单个字符:get()
get() 函数用于从流中读取单个字符,且不会跳过空白符。
| 函数签名 | 描述 |
|---|---|
is.get(char& ch) | 将流中的下一个字符存入 ch。 |
is.get() | 返回流中的下一个字符(作为 int 类型),或返回 EOF(文件结束)。 |
char c1, c2;
std::cin.get(c1); // 读取第一个字符(可能是空格)
std::cin.get(c2); // 读取第二个字符
std::cout << "c1: " << c1 << ", c2: " << c2 << std::endl;
3. 忽略流中字符:ignore()
ignore() 函数常用于清除输入缓冲区中残余的字符,特别是在混合使用格式化输入(>>)和 getline() 时。
| 函数签名 | 描述 |
|---|---|
is.ignore(streamsize count, char delim) | 从输入流中丢弃最多 count 个字符,直到遇到指定的分隔符 delim。分隔符也会被丢弃。 |
int age;
char gender;
std::cin >> age;
// 假设用户输入 "25\n"
// `>> age` 读取了 25,但换行符 '\n' 仍留在缓冲区。
// 清除缓冲区中剩余的字符,直到遇到换行符
std::cin.ignore(10000, '\n');
std::cout << "Enter gender (M/F): ";
std::cin.get(gender);
// 现在 `get()` 可以正确读取新的输入,而不是残留在缓冲区的 '\n'
注意:
10000是一个较大的数字,确保能够处理大多数行长度。
4. 窥视下一个字符:peek()
peek() 函数用于查看流中下一个可用的字符,但不会将其从流中移除。
// 假设流中下一个字符是 'H'
char nextChar = std::cin.peek();
// nextChar 是 'H'
// 'H' 仍然在输入流中,可供下次操作读取
5. 高级操作:read() 与 gcount()
read() 是一个底层的非格式化输入函数,用于读取指定数量的原始字节数据,常用于处理二进制文件。
| 函数签名 | 描述 |
|---|---|
is.read(char* s, streamsize n) | 从流中读取 n 个字节到内存地址 s 开始的缓冲区。 |
在调用 read() 或任何非格式化输入函数后,可以使用 gcount() 来获取最近一次非格式化输入操作实际读取的字符数量。
char buffer[10];
// 尝试从流中读取 10 个字节
std::cin.read(buffer, 10);
// 报告实际读取的字节数(可能小于 10,例如遇到文件末尾)
std::streamsize actualRead = std::cin.gcount();
文件流(fstream)
<fstream> 把文件抽象成流,让文件读写和 cin / cout 用同一套 << / >> 接口。这样从控制台改成从文件读,往往只需要换掉流对象的类型。
三个核心类分工明确:
| 类 | 方向 | 说明 |
|---|---|---|
std::ifstream | 输入 | 从文件读取 |
std::ofstream | 输出 | 写入文件 |
std::fstream | 双向 | 既可读也可写 |
它们都继承自 iostream 体系,因此 getline、seekg、tellp 这些通用接口都能用。
一、打开文件
1. 构造时打开
#include <fstream>
std::ifstream in("input.txt"); // 默认以文本模式读取
std::ofstream out("output.txt"); // 默认截断文件(内容清空)
std::fstream io("data.bin", std::ios::in | std::ios::out | std::ios::binary);
2. 打开模式
模式通过位或组合:
| 模式 | 含义 |
|---|---|
std::ios::in | 读取 |
std::ios::out | 写入 |
std::ios::app | 追加,每次写都在末尾 |
std::ios::ate | 打开后定位到末尾 |
std::ios::trunc | 截断文件(默认用于 out) |
std::ios::binary | 二进制模式 |
几个容易混淆的点:
out默认隐含trunc,会清空文件。想保留原内容要加app或ate。app和ate不同:app强制每次写入都在末尾,ate只是打开时定位到末尾,之后可以seekp到别处。in | out打开的文件必须已存在,不会自动创建。
// 追加写入
std::ofstream log("app.log", std::ios::app);
// 读写已有文件,不清空
std::fstream db("data.db", std::ios::in | std::ios::out | std::ios::binary);
3. 检查是否成功
打开失败不会抛异常,必须显式检查:
std::ifstream in("maybe_missing.txt");
if (!in)
{
std::cerr << "打开失败\n";
return;
}
// 或者用 is_open()
if (!in.is_open()) { /* ... */ }
这是最常见的坑——忘记检查,后面所有读取都静默失败,得到一堆空数据。
二、读取文件
1. 逐词读取
>> 按空白字符(空格、制表符、换行)分割:
std::ifstream in("numbers.txt");
int n;
while (in >> n)
{
std::cout << n << '\n';
}
in >> n 返回流对象,在布尔上下文中表示「读取是否成功」。读到文件末尾或格式不匹配时返回 false,循环自然结束。
2. 逐行读取
std::getline 读取整行(不含换行符):
std::ifstream in("data.txt");
std::string line;
while (std::getline(in, line))
{
std::cout << line << '\n';
}
不要用 while (!in.eof()),这是经典错误:
// 错误:最后一行会被处理两次
while (!in.eof())
{
std::getline(in, line);
process(line); // 读到 EOF 后 line 还是上一行的内容
}
// 正确
while (std::getline(in, line))
process(line);
原因是 eof() 只有在尝试读取并失败后才为真。用 !eof() 判断时,最后一次读取已经失败了,但循环体还是会执行一遍。
3. 混合使用 >> 和 getline
>> 会留下换行符在缓冲区里,紧接着的 getline 会读到空行:
int id;
std::string name;
in >> id; // 读完数字,换行符还在缓冲区
std::getline(in, name); // 读到空字符串!
// 正确:先吃掉换行符
in >> id;
in.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::getline(in, name);
4. 读取整个文件
// 方法一:用迭代器
std::ifstream in("data.txt");
std::string content((std::istreambuf_iterator<char>(in)),
std::istreambuf_iterator<char>());
// 方法二:先求大小再读(适合二进制)
in.seekg(0, std::ios::end);
std::size_t size = in.tellg();
in.seekg(0, std::ios::beg);
std::string buffer(size, '\0');
in.read(buffer.data(), size);
方法二更快(一次读取),但要求文件大小可求——管道、标准输入这类流不支持。
三、写入文件
std::ofstream out("output.txt");
out << "Hello\n";
out << 42 << ' ' << 3.14 << '\n';
// 检查写入是否成功
if (!out)
std::cerr << "写入失败\n";
1. 缓冲区与刷新
写入先进入缓冲区,以下情况才真正落盘:
- 缓冲区满
- 显式调用
flush() - 流对象析构
- 程序正常结束
out << "important data";
out.flush(); // 立即写入
out << std::endl; // 换行并刷新(等价于 '\n' << flush)
程序崩溃时缓冲区内容会丢失。关键数据要主动 flush。
2. 检查写入错误
写入错误(磁盘满、权限问题)不会立即抛出,需要检查:
out << data;
if (!out)
{
std::cerr << "写入失败\n";
// 处理错误
}
四、二进制读写
文本模式会做换行符转换(Windows 上 \n ↔ \r\n),二进制模式不做任何转换。
// 写入
struct Record { int id; double value; };
Record r{1, 3.14};
std::ofstream out("data.bin", std::ios::binary);
out.write(reinterpret_cast<const char*>(&r), sizeof(r));
// 读取
Record r2;
std::ifstream in("data.bin", std::ios::binary);
in.read(reinterpret_cast<char*>(&r2), sizeof(r2));
注意:直接读写结构体内存在跨平台问题——字节序、对齐、sizeof 都可能不同。可移植的格式需要逐字段序列化。
五、定位
文件流支持随机访问:
std::fstream file("data.bin", std::ios::in | std::ios::out | std::ios::binary);
// 定位到第 100 字节
file.seekg(100, std::ios::beg); // 读指针
file.seekp(100, std::ios::beg); // 写指针
// 获取当前位置
auto pos = file.tellg();
// 定位到末尾
file.seekg(0, std::ios::end);
auto size = file.tellg();
三个参考位置:std::ios::beg(开头)、std::ios::cur(当前位置)、std::ios::end(末尾)。
g 后缀是 get(读),p 后缀是 put(写)。fstream 有两个独立的指针。
六、关闭与作用域
close() 可以手动关闭,但通常不需要——析构函数会自动关闭:
{
std::ofstream out("file.txt");
out << "data";
} // 离开作用域,自动关闭并刷新
关闭后流的错误状态会保留。如果之前写入失败,析构时不会抛异常,错误被静默吞掉。所以关键写入要显式检查:
out << data;
if (!out) { /* 处理错误 */ }
out.close(); // 此时可以检查 close 是否成功
七、异常处理
默认情况下文件流不抛异常,通过状态位报告错误。可以开启异常:
std::ifstream in;
in.exceptions(std::ios::failbit | std::ios::badbit); // 打开这些状态时抛异常
try
{
in.open("missing.txt");
}
catch (const std::ios_base::failure& e)
{
std::cerr << "打开失败: " << e.what() << '\n';
}
但要注意:开启 failbit 后,读到文件末尾也会抛异常,因为 EOF 会设置 failbit。这通常不是想要的,所以很多项目选择不开异常。
八、注意事项
必须检查打开是否成功。这是文件操作最常见的错误来源。
不要用 while (!in.eof())。用 while (in >> x) 或 while (getline(in, line))。
ofstream 默认截断文件。想追加必须显式指定 std::ios::app。
>> 和 getline 混用要清缓冲区。用 ignore() 吃掉残留的换行符。
文本模式会转换换行符。跨平台处理二进制数据时记得加 std::ios::binary。
缓冲区内容可能丢失。程序崩溃时未刷新的数据会丢,关键数据主动 flush()。
不要直接读写结构体二进制。字节序和对齐差异会导致跨平台不兼容。
std::endl 有性能开销。它每次都刷新缓冲区,循环里输出大量数据时用 '\n' 更快。
文件句柄是有限资源。打开大量文件而不关闭会耗尽句柄,用 RAII 让析构自动处理。
九、相关章节
字符串流(sstream)
在 C++ 中,字符串流(String Streams) 是一种特殊的内存流,它将字符串抽象为输入输出流,从而允许程序像操作文件或控制台那样读写字符串。字符串流的核心头文件为 <sstream>,主要用于格式化文本、解析数据或在内存中临时缓存输出内容。相比直接操作字符串,字符串流提供了统一、可扩展且安全的接口。
一、字符串流的分类
字符串流继承自标准流类体系,但其输入输出目标不是物理设备,而是内存中的字符串。C++ 提供三种主要类型的字符串流:
-
istringstream面向输入的字符串流,用于从字符串中提取数据。它将字符串当作数据源,实现类似于从文件读取的行为。 -
ostringstream面向输出的字符串流,用于向字符串写入数据。它提供了格式化输出的能力,将数据以文本形式存储在内存字符串中。 -
stringstream同时支持输入和输出,可以在同一个字符串流对象上进行读写操作。适合需要在内存中反复解析和构建字符串的场景。
这种设计与标准流对象保持一致,使得对内存字符串的操作与文件或控制台的操作具有统一的接口和方法。
二、基本用法
使用字符串流时,通常的步骤包括创建流对象、向流中写入或从流中读取数据,然后获取最终字符串或解析结果。示例:
#include <sstream>
#include <string>
#include <iostream>
std::ostringstream oss;
oss << "Name: " << "Alice" << ", Age: " << 30;
std::string result = oss.str();
std::cout << result << std::endl; // 输出 "Name: Alice, Age: 30"
std::string data = "42 3.14 hello";
std::istringstream iss(data); // 从已有的字符串中建立字符流
int i;
double d;
std::string s;
iss >> i >> d >> s; // 从字符串中提取整数、浮点数和字符串
通过这种方式,程序可以像处理标准流那样操作字符串,利用 >> 和 << 运算符进行格式化输入输出。
三、流的格式化能力
字符串流继承自标准流类,因此支持所有流的格式化特性:
- 可以使用操纵符(
setw,setprecision,fixed等)控制输出格式。 - 可以通过
setf()、unsetf()设置流的格式标志,实现对齐方式、数值进制或符号显示的控制。 - 可以链式调用输入输出操作,使字符串构建或解析更加简洁。
示例:
#include <iomanip>
std::ostringstream oss2;
double pi = 3.14159265;
oss2 << std::fixed << std::setprecision(2) << pi;
std::cout << oss2.str(); // 输出 "3.14"
四、常见操作方法
字符串流提供了丰富的成员函数,用于在内存字符串上实现精细控制:
-
获取内容
str():返回当前流中的字符串内容。str(const std::string&):设置或替换流的内容,为流重新赋值。
-
清空或重置流
clear():重置流的状态,使其可重新进行读写操作。seekg()/seekp():调整读写位置,实现从特定位置开始读取或写入。
-
读取与写入
- 使用
>>从字符串中提取数据,使用<<向流中插入数据。 - 可以使用
getline()从字符串中逐行读取内容,与文件或控制台操作保持一致。
- 使用
示例:
std::stringstream ss("123 456");
int a, b;
ss >> a >> b; // 提取两个整数
ss.str(""); // 清空流内容
ss.clear(); // 重置状态
ss << "New content";
五、应用实例
1. 解析数组
解析"[2,-1,3,0,12]"这样的字符串为数组:
std::string s = "[2,-1,3,0,12]";
std::stringstream ss(s);
char ch;
std::vector<int> nums;
std::string temp;
// 读取第一个字符,应该是 '['
ss >> ch;
if (ch != '[') {
std::cerr << "Invalid input format!" << std::endl;
return 1;
}
while (ss >> ch) {
if (ch == ',' || ch == ']') {
if (!temp.empty()) {
nums.push_back(std::stoi(temp)); // 转换字符串为整数
temp.clear();
}
if (ch == ']') break; // 数组结束
} else {
temp += ch; // 累积数字字符,包括负号
}
}
// 输出结果
std::cout << "Numbers: ";
for (int n : nums) std::cout << n << " ";
std::cout << std::endl;
字符串 (string)
std::string 是标准库的字符串类型,封装了动态字符数组,自动管理内存。相比 C 风格的 char*,它无需手动分配释放,也不会因为忘记 \0 而出错。
C++17 引入的 std::string_view 则提供了不拥有数据的字符串视图,用于只读场景,避免不必要的拷贝。
1. 引入
#include <string> // std::string
#include <string_view> // std::string_view
2. 核心概念
1. 构造与赋值
std::string s1; // 空字符串
std::string s2 = "hello"; // 从字面量
std::string s3("hello", 3); // "hel"(前 3 个字符)
std::string s4(5, 'x'); // "xxxxx"
std::string s5(s2); // 拷贝构造
std::string s6(std::move(s2)); // 移动构造
s1 = "world"; // 赋值
2. 常用操作
| 操作 | 说明 |
|---|---|
size() / length() | 字符数(两者等价) |
empty() | 是否为空 |
clear() | 清空 |
c_str() | 返回 C 风格字符串(保证以 \0 结尾) |
data() | 返回底层字符数组指针 |
substr(pos, n) | 取子串 |
find(str) | 查找子串,返回位置或 npos |
rfind(str) | 从后往前查找 |
find_first_of(chars) | 查找任意一个字符首次出现 |
replace(pos, n, str) | 替换 |
insert(pos, str) | 插入 |
erase(pos, n) | 删除 |
append(str) / += | 追加 |
compare(str) | 比较,返回负数/0/正数 |
starts_with(str) | 是否以某串开头(C++20) |
ends_with(str) | 是否以某串结尾(C++20) |
contains(str) | 是否包含子串(C++23) |
std::string s = "Hello World";
std::cout << s.size() << '\n'; // 11
std::cout << s.substr(6, 5) << '\n'; // World
std::cout << s.find("World") << '\n'; // 6
if (s.find("xyz") == std::string::npos)
std::cout << "未找到\n";
s.replace(0, 5, "Goodbye"); // "Goodbye World"
s += "!"; // "Goodbye World!"
3. 遍历与转换
std::string s = "hello";
// 遍历
for (char c : s)
std::cout << c;
// 转数字
int n = std::stoi("42");
double d = std::stod("3.14");
// 数字转字符串
std::string a = std::to_string(42);
std::string b = std::to_string(3.14);
std::stoi 等函数在转换失败时会抛出 std::invalid_argument 或 std::out_of_range。
4. 小字符串优化(SSO)
大多数实现采用小字符串优化:短字符串(通常 15~22 字节以内)直接存储在 string 对象内部的缓冲区中,不进行堆分配。
std::string short_str = "hi"; // 无堆分配
std::string long_str(1000, 'x'); // 堆分配
这意味着短字符串的拷贝成本很低,但 sizeof(std::string) 通常比想象中大(约 32 字节)。
3. 用法
string_view
std::string_view 是对字符序列的非拥有视图,只保存指针和长度:
void process(std::string_view sv) // 不拷贝
{
std::cout << sv.size() << '\n';
}
std::string s = "hello";
process(s); // 从 string 构造,无拷贝
process("world"); // 从字面量构造,无拷贝
process(s.substr(0, 3)); // 注意:substr 返回临时 string,此处有拷贝
优势:
- 传参时避免拷贝,尤其适合只读的字符串参数
- 可以接受
std::string、字符串字面量、char*等多种来源 - 取子串是 \(O(1)\)(只调整指针和长度)
局限:
- 不拥有数据,底层字符串销毁后视图会悬垂
- 不保证以
\0结尾,不能直接传给需要 C 字符串的接口 - 没有
c_str(),需要转换时用std::string(sv)
// 危险:返回指向临时对象的视图
std::string_view bad()
{
std::string local = "temporary";
return local; // 错误:local 已销毁
}
// 安全:返回 string
std::string good()
{
return "safe";
}
何时用哪个
| 场景 | 推荐 |
|---|---|
| 需要拥有数据、修改内容 | std::string |
| 只读参数、临时查看 | std::string_view |
需要以 \0 结尾传给 C API | std::string(c_str()) |
| 成员变量长期持有 | std::string(视图易悬垂) |
| 返回字符串 | std::string(视图可能悬垂) |
4. 注意事项
npos是size_t的最大值。用find判断时要写== std::string::npos,不要写成-1。c_str()的指针可能失效。任何修改字符串的操作都可能触发重新分配,使之前取得的指针悬垂。string_view不拥有数据。绝不要返回指向局部变量的string_view,也不要把string_view作为长期成员。string_view不保证\0结尾。传给printf等 C 函数会越界。s + "a"与"a" + s不同。前者合法,后者中"a"是const char*,指针加法是未定义行为。应写std::string("a") + s。substr返回新字符串。string_view::substr返回视图(\(O(1)\)),string::substr返回新字符串(\(O(n)\)),注意区分。- 字符串拼接的性能。循环中反复
s += x可能多次重新分配,预先reserve()可避免。
5. 相关章节
时间库 (chrono)
C++11 引入的时间库,提供时长(duration)、时间点(time_point)与时钟(clock)三个核心概念,取代了 C 风格的 <ctime>。
它的设计有个重要特点:类型安全。秒和毫秒是不同的类型,编译器会阻止你把它们混用;时间点和时长也是不同的类型,不能相加两个时间点。这让很多时间相关的 bug 在编译期就被拦住了。
1. 引入
#include <chrono>
// C++20 起,字面量后缀在 std::chrono_literals 中
using namespace std::chrono_literals;
2. 核心概念
时间库由三个概念组成,它们的关系是:
时钟 (clock) ──产生──→ 时间点 (time_point)
│
│ 相减
↓
时长 (duration)
1. duration:时长
duration 表示一段时间间隔,由数值和单位两部分组成:
template<class Rep, class Period = std::ratio<1>>
class duration;
Rep:用什么类型存储数值(int、long、double)Period:单位,用std::ratio表示一个周期是多少秒
标准库预定义了一批常用单位:
| 类型 | 含义 | 与秒的比值 |
|---|---|---|
std::chrono::nanoseconds | 纳秒 | 1/1’000’000’000 |
std::chrono::microseconds | 微秒 | 1/1’000’000 |
std::chrono::milliseconds | 毫秒 | 1/1000 |
std::chrono::seconds | 秒 | 1 |
std::chrono::minutes | 分 | 60 |
std::chrono::hours | 小时 | 3600 |
std::chrono::days | 天 | 86400(C++20) |
std::chrono::weeks | 周 | 604800(C++20) |
std::chrono::months | 月 | 约 30.44 天(C++20) |
std::chrono::years | 年 | 约 365.24 天(C++20) |
std::chrono::seconds s(10); // 10 秒
std::chrono::milliseconds ms(500); // 500 毫秒
// C++14 起可以用字面量
auto s2 = 10s;
auto ms2 = 500ms;
auto us = 100us;
auto ns = 50ns;
2. time_point:时间点
时间点是「某个时钟的纪元加上一个时长」:
template<class Clock, class Duration = typename Clock::duration>
class time_point;
auto now = std::chrono::system_clock::now(); // 当前时间点
auto epoch = std::chrono::system_clock::time_point{}; // 纪元(1970-01-01)
两个时间点相减得到时长:
auto start = std::chrono::steady_clock::now();
doWork();
auto end = std::chrono::steady_clock::now();
auto elapsed = end - start; // duration
3. clock:时钟
时钟提供「当前时间点」和「时长单位」:
| 时钟 | 特点 | 用途 |
|---|---|---|
system_clock | 系统时间,可被调整(NTP、用户改时间) | 获取日历时间、与外部时间戳交互 |
steady_clock | 单调递增,不可调整 | 测量耗时 |
high_resolution_clock | 通常是前两者的别名 | 取决于实现,不推荐直接用 |
测耗时必须用 steady_clock。system_clock 可能因为对时或用户操作而跳变,导致测出负数或异常大的值。
3. 用法
1. 测量耗时
这是最常见的场景:
#include <chrono>
#include <iostream>
int main()
{
auto start = std::chrono::steady_clock::now();
// ... 执行一些工作
auto end = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "耗时 " << elapsed.count() << " ms\n";
}
duration_cast 用于在不同精度之间转换。注意它会截断,不是四舍五入:
std::chrono::milliseconds ms(1500);
auto s = std::chrono::duration_cast<std::chrono::seconds>(ms);
std::cout << s.count(); // 1,不是 2
C++17 起可以用 std::chrono::round、floor、ceil 精确控制:
auto s = std::chrono::round<std::chrono::seconds>(ms); // 2
2. 隐式转换
低精度到高精度可以隐式转换,反之必须显式:
std::chrono::seconds s(1);
std::chrono::milliseconds ms = s; // OK:隐式,1 秒 = 1000 毫秒
// std::chrono::seconds s2 = ms; // 错误:可能丢失精度
auto s2 = std::chrono::duration_cast<std::chrono::seconds>(ms); // 显式
这个规则是刻意的——它让「精度损失」这件事在代码里显式可见。
3. 时间运算
时长之间可以自由加减:
auto total = 1s + 500ms; // 结果是 milliseconds(1500)
auto diff = 2s - 500ms; // milliseconds(1500)
auto doubled = 2 * 100ms; // milliseconds(200)
auto halved = 100ms / 2; // milliseconds(50)
// 求余
auto rem = 1500ms % 1s; // milliseconds(500)
时间点可以加减时长:
auto now = std::chrono::system_clock::now();
auto later = now + 1h;
auto earlier = now - 30min;
但两个时间点不能相加——这在语义上没有意义,编译器会阻止。
4. 与 C 风格时间互转
system_clock 可以与 time_t 互转:
// time_point → time_t
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
// time_t → time_point
auto tp = std::chrono::system_clock::from_time_t(t);
C++20 起还可以直接与 std::tm 互转:
std::tm tm = std::chrono::system_clock::to_time_t(now); // 旧写法需要 localtime
5. C++20 的日历功能
C++20 大幅扩展了时间库,加入了日历类型:
using namespace std::chrono;
// 年月日
year_month_day ymd{2026y, January/15d};
std::cout << int(ymd.year()) << "-" << unsigned(ymd.month()) << "-" << unsigned(ymd.day());
// 星期
weekday wd = 2026y/January/15d;
if (wd == Thursday) { /* ... */ }
// 时区
auto local = zoned_time{current_zone(), system_clock::now()};
std::cout << local;
还支持格式化输出:
std::cout << std::format("{:%Y-%m-%d %H:%M:%S}", std::chrono::system_clock::now());
4. 注意事项
测耗时用 steady_clock,不要用 system_clock。后者可能跳变,测出的结果不可信。
duration_cast 会截断。1500 毫秒转成秒是 1 而不是 2。需要四舍五入时用 round。
.count() 返回的是裸数值。它丢掉了单位信息,不要把它存成 int 变量再传出去。
// 不好:单位信息丢失
int ms = elapsed.count();
// 好:保留类型
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed);
high_resolution_clock 不保证是 steady 的。标准允许它别名到 system_clock,因此不适合测耗时。
字面量后缀需要 using namespace std::chrono_literals。否则 1s、100ms 这些无法使用。
ratio 的精度。std::chrono::months 和 years 是平均值(月约 30.44 天),不是精确的日历单位。做日期计算时要注意这一点。
C++20 的日历功能需要编译器支持。GCC 11+、Clang 17+ 才比较完整。
5. 相关章节
随机数 (random)
C++11 引入的随机数库,把随机数生成拆分为引擎(engine)和分布(distribution)两部分,质量远高于 C 的 rand()。
这个拆分是有意设计的:引擎负责产生均匀分布的原始随机位,分布负责把这些位映射成你想要的分布形态。同一个引擎可以配不同的分布,同一个分布也可以配不同的引擎。
1. 引入
#include <random>
2. 核心概念
1. 为什么不用 rand()
rand() 有几个致命问题:
- 质量差:很多实现的低位随机性很弱,
rand() % 2可能交替出现 - 范围固定:通常是
[0, RAND_MAX],RAND_MAX可能只有 32767 - 取模有偏:
rand() % 100在RAND_MAX不是 100 的倍数时,前面的数出现概率更高 - 不可控:全局状态,多线程下不安全,也无法复现
<random> 解决了所有这些问题。
2. 引擎
引擎是均匀随机位生成器(Uniform Random Bit Generator, URBG),负责产生原始随机数。
| 引擎 | 说明 |
|---|---|
std::mt19937 | 推荐默认选择,32 位梅森旋转算法 |
std::mt19937_64 | 64 位版本 |
std::minstd_rand | 线性同余,快但周期短 |
std::ranlux24 / ranlux48 | 质量更高,但更慢 |
std::knuth_b | Knuth 的减法算法 |
std::random_device | 系统提供的真随机源(可能较慢) |
std::mt19937 gen(42); // 用固定种子,结果可复现
std::mt19937 gen2(std::random_device{}()); // 用真随机种子
梅森旋转(Mersenne Twister)是默认推荐——周期长达 \( 2^{19937}-1 \),质量好,速度也不慢。
3. 分布
分布把引擎产生的均匀随机数转换成目标分布。
均匀分布:
| 分布 | 说明 |
|---|---|
std::uniform_int_distribution<T> | 整数均匀分布,闭区间 [a, b] |
std::uniform_real_distribution<T> | 浮点均匀分布,半开区间 [a, b) |
其它分布:
| 分布 | 说明 | 典型用途 |
|---|---|---|
std::normal_distribution | 正态分布 | 模拟自然现象、噪声 |
std::bernoulli_distribution | 伯努利分布(真假) | 概率事件 |
std::binomial_distribution | 二项分布 | 成功次数 |
std::poisson_distribution | 泊松分布 | 单位时间事件数 |
std::exponential_distribution | 指数分布 | 事件间隔 |
std::discrete_distribution | 离散加权分布 | 按权重选择 |
3. 用法
1. 基本用法
#include <iostream>
#include <random>
int main()
{
// 1. 创建引擎并播种
std::random_device rd;
std::mt19937 gen(rd());
// 2. 创建分布
std::uniform_int_distribution<int> dist(1, 6); // 骰子
// 3. 生成随机数
for (int i = 0; i < 10; ++i)
std::cout << dist(gen) << ' ';
}
注意调用形式是 dist(gen)——把引擎传给分布,而不是调用引擎的方法。
2. 各种分布
std::mt19937 gen(std::random_device{}());
// 整数 [1, 100]
std::uniform_int_distribution<int> dice(1, 100);
int n = dice(gen);
// 浮点 [0.0, 1.0)
std::uniform_real_distribution<double> real(0.0, 1.0);
double d = real(gen);
// 正态分布,均值 0,标准差 1
std::normal_distribution<double> normal(0.0, 1.0);
double gauss = normal(gen);
// 伯努利分布,70% 概率为 true
std::bernoulli_distribution coin(0.7);
bool b = coin(gen);
// 加权选择:30%、50%、20%
std::discrete_distribution<int> weighted({30, 50, 20});
int choice = weighted(gen); // 返回 0、1 或 2
3. 随机打乱容器
#include <algorithm>
#include <vector>
std::vector<int> v{1, 2, 3, 4, 5};
std::mt19937 gen(std::random_device{}());
std::shuffle(v.begin(), v.end(), gen);
4. 可复现的随机
固定种子可以让每次运行得到相同序列,这对测试和调试很重要:
std::mt19937 gen(12345); // 固定种子
// 每次运行都产生相同的序列
反过来,需要不可预测时用 random_device:
std::random_device rd;
std::mt19937 gen(rd());
std::random_device在某些平台(如老版本 MinGW)上实现为确定性伪随机。如果需要真随机,要检查rd.entropy()是否非零。
4. 注意事项
不要用 % 取模。这会引入偏差:
// 错误:有偏
int n = gen() % 100;
// 正确:均匀
std::uniform_int_distribution<int> dist(0, 99);
int n = dist(gen);
不要每次生成都新建引擎。构造引擎有开销(梅森旋转要初始化 624 个字):
// 错误:每次循环都构造
for (int i = 0; i < 1000; ++i) {
std::mt19937 gen(std::random_device{}());
int n = dist(gen);
}
// 正确:构造一次
std::mt19937 gen(std::random_device{}());
for (int i = 0; i < 1000; ++i)
int n = dist(gen);
uniform_real_distribution 是半开区间。[a, b) 不包含 b,这与 uniform_int_distribution 的闭区间不同。
引擎和分布都不是线程安全的。多线程下每个线程应该有自己的引擎:
void worker()
{
thread_local std::mt19937 gen(std::random_device{}());
// 每个线程独立的引擎
}
random_device 可能很慢。它通常要读系统熵池,频繁调用会成为瓶颈。正确用法是只用它播种一次:
std::mt19937 gen(std::random_device{}()); // 只调用一次
分布的参数不能改。uniform_int_distribution 的区间在构造时确定,想换区间要新建分布对象(或者用 param() 设置新参数)。
mt19937 的种子不能太简单。用 1、2 这种小种子,前几个输出可能不够随机。用 random_device 或 seed_seq 更好。
5. 相关章节
- 时间库:用时间做种子
- Algorithms:
std::shuffle与std::sample - 多线程与并发:线程局部存储
- 文件与流:随机数输出
正则表达式 (regex)
C++11 引入的正则表达式库,支持匹配、搜索、替换和迭代遍历。
用之前先有个心理准备:<regex> 的性能和编译期开销都不太理想。它的接口设计依赖模板实例化,编译慢;运行时也不如专门的库快。如果正则只是简单模式(比如找子串、判断前缀),用 std::string 的成员函数更快也更清晰。
1. 引入
#include <regex>
2. 核心概念
正则库由三个部分组成:
| 组件 | 作用 |
|---|---|
std::regex | 编译后的正则表达式对象 |
std::match_results(smatch/cmatch) | 保存匹配结果 |
| 算法函数 | regex_match、regex_search、regex_replace |
三个匹配函数
| 函数 | 语义 | 典型用途 |
|---|---|---|
regex_match | 整个字符串必须完全匹配 | 校验格式(邮箱、日期) |
regex_search | 字符串中任意位置匹配即可 | 查找、提取 |
regex_replace | 替换所有匹配 | 文本处理 |
这个区别很关键。regex_match 要求从头到尾完全吻合,regex_search 只要找到一处就行:
std::regex re(R"(\d+)");
std::string s = "abc123def";
std::regex_match(s, re); // false:整个串不全是数字
std::regex_search(s, re); // true:中间有数字
字符类型
| 类型 | 字符类型 | 字符串类型 |
|---|---|---|
std::regex | char | std::string |
std::wregex | wchar_t | std::wstring |
对应的匹配结果类型:
| 类型 | 对应 |
|---|---|
std::smatch | std::string 的匹配结果 |
std::cmatch | const char* 的匹配结果 |
std::wsmatch | std::wstring 的匹配结果 |
3. 用法
1. 校验格式
#include <iostream>
#include <regex>
#include <string>
bool isValidDate(const std::string& s)
{
// 匹配 YYYY-MM-DD
static const std::regex re(R"(\d{4}-\d{2}-\d{2})");
return std::regex_match(s, re);
}
isValidDate("2026-09-19"); // true
isValidDate("2026-9-19"); // false:月份必须是两位
isValidDate("x2026-09-19"); // false:regex_match 要求完全匹配
用原始字符串 R"(...)" 可以避免反斜杠转义,正则里写 \d 而不用写 \\d。
2. 提取内容
用捕获组 (...) 提取子串:
std::string log = "2026-09-19 14:30:25 [INFO] 启动完成";
std::regex re(R"((\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}))");
std::smatch m;
if (std::regex_search(log, m, re))
{
std::cout << "完整匹配: " << m[0] << '\n'; // 2026-09-19 14:30:25
std::cout << "年: " << m[1] << '\n'; // 2026
std::cout << "月: " << m[2] << '\n'; // 09
std::cout << "日: " << m[3] << '\n'; // 19
std::cout << "时: " << m[4] << '\n'; // 14
}
m[0] 是完整匹配,m[1] 起是各个捕获组。m.size() 返回组数加一。
3. 遍历所有匹配
用 sregex_iterator 遍历:
std::string text = "apple 10, banana 20, cherry 30";
std::regex re(R"((\w+)\s+(\d+))");
for (auto it = std::sregex_iterator(text.begin(), text.end(), re);
it != std::sregex_iterator(); ++it)
{
const std::smatch& m = *it;
std::cout << m[1] << " = " << m[2] << '\n';
}
// 输出:
// apple = 10
// banana = 20
// cherry = 30
4. 替换
std::string text = "2026-09-19";
// 把 YYYY-MM-DD 换成 DD/MM/YYYY
std::regex re(R"((\d{4})-(\d{2})-(\d{2}))");
std::string result = std::regex_replace(text, re, "$3/$2/$1");
// 结果:19/09/2026
替换字符串里用 $1、$2 引用捕获组,$& 表示整个匹配。
5. 正则语法速查
| 语法 | 含义 |
|---|---|
. | 任意字符(默认不含换行) |
\d / \w / \s | 数字 / 单词字符 / 空白 |
\D / \W / \S | 上述的补集 |
[abc] | 字符集合 |
[^abc] | 排除集合 |
[a-z] | 范围 |
* / + / ? | 0 次或多次 / 1 次或多次 / 0 或 1 次 |
{n} / {n,m} | 恰好 n 次 / n 到 m 次 |
^ / $ | 行首 / 行尾 |
\b | 单词边界 |
(...) | 捕获组 |
(?:...) | 非捕获组 |
| | 或 |
\1 | 反向引用第 1 组 |
6. 语法选项
// 忽略大小写
std::regex re("hello", std::regex::icase);
// 多行模式:^ 和 $ 匹配每行
std::regex re2("^abc", std::regex::multiline);
// 扩展语法(默认)
std::regex re3("a+", std::regex::ECMAScript);
可用的语法标志:ECMAScript(默认)、basic、extended、awk、grep、egrep。
4. 注意事项
regex_match 和 regex_search 语义不同。前者要求完全匹配,后者只要部分匹配。校验格式用前者,提取内容用后者。
正则对象应该复用。构造 std::regex 需要编译正则表达式,开销很大:
// 错误:每次调用都重新编译
bool check(const std::string& s) {
std::regex re(R"(\d+)");
return std::regex_match(s, re);
}
// 正确:静态复用
bool check(const std::string& s) {
static const std::regex re(R"(\d+)");
return std::regex_match(s, re);
}
std::regex 的性能一般。它在很多实现里是基于回溯的,遇到复杂模式或恶意输入可能指数级退化(ReDoS)。性能敏感场景考虑 RE2、PCRE2 等库。
编译期开销大。<regex> 会引入大量模板实例化,一个简单的 #include <regex> 可能让编译时间增加不少。如果只是简单匹配,用字符串函数更划算。
std::regex 不是线程安全的(构造和修改时)。但构造完成后多个线程可以并发使用同一个 const 对象。
注意 \d 在原始字符串中的写法。用 R"(\d+)" 而不是 "\\d+",前者更易读。
异常处理。正则语法错误会在构造时抛 std::regex_error:
try {
std::regex re("(["); // 括号不匹配
} catch (const std::regex_error& e) {
std::cerr << "正则错误: " << e.what() << '\n';
}
简单场景用字符串函数。判断前缀用 starts_with,查找子串用 find,这些都比正则快得多也清晰得多。
5. 相关章节
文件系统 (filesystem)
C++17 引入的文件系统库,提供跨平台的路径操作、目录遍历与文件属性查询。
在此之前,这些操作要么用 C 的 <dirent.h>、<sys/stat.h>(POSIX)或 <windows.h>(Windows),要么用 Boost.Filesystem。现在标准库有了统一接口。
1. 引入
#include <filesystem>
namespace fs = std::filesystem; // 标准做法:起个短别名
2. 核心概念
1. path:路径
std::filesystem::path 表示一个路径,它不检查路径是否存在,只是字符串的封装,负责处理不同平台的路径格式差异。
fs::path p1 = "/home/user/file.txt"; // POSIX
fs::path p2 = "C:\\Users\\file.txt"; // Windows
fs::path p3 = "/home" / "user" / "file.txt"; // 用 / 拼接
路径拼接用 operator/,它会自动处理分隔符:
fs::path dir = "/home/user";
fs::path file = dir / "data" / "input.txt"; // /home/user/data/input.txt
2. 路径的组成部分
fs::path p = "/home/user/data.txt";
p.root_name(); // ""(Windows 上是 "C:")
p.root_directory(); // "/"
p.parent_path(); // "/home/user"
p.filename(); // "data.txt"
p.stem(); // "data"
p.extension(); // ".txt"
3. 文件类型
enum class file_type {
none, // 不存在或无法确定
not_found, // 不存在
regular, // 普通文件
directory, // 目录
symlink, // 符号链接
block, // 块设备
character, // 字符设备
fifo, // 命名管道
socket, // socket
unknown, // 存在但类型未知
};
3. 用法
1. 查询文件状态
fs::path p = "data.txt";
// 是否存在
if (fs::exists(p)) { }
// 类型判断
fs::is_regular_file(p); // 普通文件
fs::is_directory(p); // 目录
fs::is_symlink(p); // 符号链接
// 文件大小(字节)
auto size = fs::file_size(p);
// 最后修改时间
auto time = fs::last_write_time(p);
这些函数都有两个版本:一个是 fs::exists(p),失败时抛异常;另一个是 fs::exists(p, ec),把错误写进 std::error_code:
std::error_code ec;
if (fs::exists(p, ec)) { }
if (ec)
std::cerr << "错误: " << ec.message() << '\n';
在预期可能失败的地方(比如检查不存在的文件),用 error_code 版本,避免异常开销。
2. 目录操作
// 创建目录
fs::create_directory("newdir"); // 父目录必须存在
fs::create_directories("a/b/c"); // 递归创建,父目录不存在也行
// 删除
fs::remove("file.txt"); // 删除文件或空目录
fs::remove_all("dir"); // 递归删除
// 重命名/移动
fs::rename("old.txt", "new.txt");
// 复制
fs::copy("src.txt", "dst.txt");
fs::copy("srcdir", "dstdir", fs::copy_options::recursive); // 递归复制
3. 遍历目录
非递归遍历:
for (const auto& entry : fs::directory_iterator("."))
{
std::cout << entry.path() << '\n';
}
递归遍历:
for (const auto& entry : fs::recursive_directory_iterator("."))
{
if (entry.is_regular_file())
std::cout << entry.path() << " (" << entry.file_size() << " 字节)\n";
}
遍历顺序是未指定的。如果需要排序,先收集到容器再排:
std::vector<fs::path> files;
for (const auto& entry : fs::directory_iterator("."))
files.push_back(entry.path());
std::sort(files.begin(), files.end());
遍历过程中可以控制:
for (auto it = fs::recursive_directory_iterator("."); it != fs::recursive_directory_iterator(); ++it)
{
if (it->path().extension() == ".tmp")
it.disable_recursion_pending(); // 不进入这个目录
// 或者 it.pop() 跳过当前目录的剩余内容
}
4. 路径转换
fs::path p = "/home/user/data.txt";
// 转字符串
std::string s = p.string(); // 本地编码
std::wstring ws = p.wstring(); // 宽字符
std::u8string u8 = p.u8string(); // UTF-8(C++20)
std::u16string u16 = p.u16string(); // UTF-16
// 从字符串构造
fs::path p2 = std::string("/tmp/file");
在 Windows 上
path::string()可能因为编码问题失败或产生乱码。跨平台代码建议用u8string()(C++20 起返回std::u8string)。
5. 实用示例
递归统计目录大小:
std::uintmax_t directorySize(const fs::path& dir)
{
std::uintmax_t total = 0;
for (const auto& entry : fs::recursive_directory_iterator(dir))
{
if (entry.is_regular_file())
total += entry.file_size();
}
return total;
}
查找特定扩展名的文件:
std::vector<fs::path> findFiles(const fs::path& dir, const std::string& ext)
{
std::vector<fs::path> result;
for (const auto& entry : fs::recursive_directory_iterator(dir))
{
if (entry.is_regular_file() && entry.path().extension() == ext)
result.push_back(entry.path());
}
return result;
}
auto cppFiles = findFiles(".", ".cpp");
确保目录存在:
void ensureDirectory(const fs::path& dir)
{
if (!fs::exists(dir))
fs::create_directories(dir);
}
4. 注意事项
path 不验证存在性。构造 fs::path 只是封装字符串,不会检查路径是否有效。要检查得调用 fs::exists()。
directory_iterator 的顺序未指定。不要假设遍历顺序,需要有序就自己排序。
遍历时修改目录是未定义行为。不要在遍历过程中创建或删除文件,先收集路径再操作。
注意异常与 error_code 的选择。默认版本抛 fs::filesystem_error,带 error_code 参数的版本不抛。在「文件可能不存在」这类正常情况用后者。
file_size 对目录无效。目录的大小是平台相关的,没有可移植的含义。
符号链接的处理。默认情况下,is_directory(p) 会跟随符号链接。要判断链接本身,用 is_symlink(p);要获取链接目标,用 read_symlink(p)。
路径分隔符。写代码时用 /,path 会在 Windows 上自动转换。不要硬编码 \\。
C++17 支持情况。需要链接 stdc++fs(GCC 8)或 c++fs(Clang 7),较新版本已不需要:
# 老版本 GCC
g++ -std=c++17 main.cpp -lstdc++fs
last_write_time 的时钟。它用的是 std::filesystem::file_time_type,不是 system_clock,与 chrono 互转需要额外处理。
5. 相关章节
格式化输出 (format)
C++20 引入的格式化库,用 std::format 取代 printf 风格与流风格的输出;C++23 增加了 std::print。
它解决了两边的痛点:printf 类型不安全、不可扩展;iostream 冗长、状态易被污染、性能一般。std::format 借鉴了 Python 的 str.format 和 {fmt} 库的设计,兼顾安全性和可读性。
1. 引入
#include <format> // C++20:std::format
#include <print> // C++23:std::print
2. 核心概念
1. 为什么不用 printf 和 iostream
printf 的问题:
printf("%d\n", 3.14); // 类型不匹配,未定义行为,编译器通常不报错
printf("%s\n", 42); // 同上
格式串和参数类型不匹配是经典 bug,printf 无法在编译期检查(除非编译器开了 -Wformat,但只覆盖部分情况)。而且 printf 无法直接输出自定义类型。
iostream 的问题:
std::cout << "x = " << x << ", y = " << y << "\n";
冗长,格式和内容混在一起,而且 std::hex、std::setprecision 这类操纵符会改变流的状态,忘记恢复会影响后续输出。
std::format 的写法:
std::string s = std::format("x = {}, y = {}", x, y);
类型安全(编译期检查)、简洁、不改变任何全局状态。
2. 基本形式
std::format(格式串, 参数...)
格式串里用 {} 作为占位符,按顺序对应参数:
std::string s = std::format("{} + {} = {}", 1, 2, 3);
// "1 + 2 = 3"
也可以指定位置:
std::format("{1} {0}", "world", "hello"); // "hello world"
3. 格式说明符
完整语法是 {[索引]:[填充][对齐][符号][#][0][宽度][.精度][类型]}。
常用的部分:
| 说明符 | 含义 | 示例 |
|---|---|---|
{} | 默认格式 | std::format("{}", 42) → "42" |
{:d} | 十进制整数 | std::format("{:d}", 42) → "42" |
{:x} / {:X} | 十六进制(小写/大写) | std::format("{:x}", 255) → "ff" |
{:o} | 八进制 | std::format("{:o}", 8) → "10" |
{:b} | 二进制 | std::format("{:b}", 5) → "101" |
{:f} | 定点浮点 | std::format("{:f}", 3.14) → "3.140000" |
{:e} | 科学计数法 | std::format("{:e}", 314.0) → "3.140000e+02" |
{:g} | 自动选择 | std::format("{:g}", 3.14) → "3.14" |
{:c} | 字符 | std::format("{:c}", 65) → "A" |
{:s} | 字符串 | std::format("{:s}", "hi") → "hi" |
{:?} | 调试格式(C++23) | 带引号的字符串 |
3. 用法
1. 宽度、对齐与填充
std::format("{:10}", "hi"); // "hi "(左对齐,宽度 10)
std::format("{:>10}", "hi"); // " hi"(右对齐)
std::format("{:^10}", "hi"); // " hi "(居中)
std::format("{:*^10}", "hi"); // "****hi****"(用 * 填充)
std::format("{:010}", 42); // "0000000042"(补零)
数字默认右对齐,字符串默认左对齐。
2. 精度
std::format("{:.2f}", 3.14159); // "3.14"
std::format("{:.3e}", 31415.9); // "3.142e+04"
std::format("{:.5}", "hello world"); // "hello"(截断字符串)
3. 符号与进制前缀
std::format("{:+d}", 42); // "+42"
std::format("{: d}", 42); // " 42"
std::format("{:#x}", 255); // "0xff"
std::format("{:#b}", 5); // "0b101"
4. 输出到流或文件
std::format 返回字符串,可以直接输出:
std::cout << std::format("x = {}\n", x);
std::ofstream out("log.txt");
out << std::format("[{}] {}\n", level, message);
C++23 的 std::print 更直接:
std::print("x = {}\n", x); // 输出到 stdout
std::println("x = {}", x); // 自动加换行
std::print(stderr, "错误: {}\n", msg); // 输出到 stderr
print 通常比 cout 快,因为它不需要与 C 的 stdio 同步。
5. 格式化自定义类型
需要特化 std::formatter:
struct Point
{
int x, y;
};
template<>
struct std::formatter<Point>
{
constexpr auto parse(std::format_parse_context& ctx)
{
return ctx.begin(); // 不支持额外格式说明符
}
auto format(const Point& p, std::format_context& ctx) const
{
return std::format_to(ctx.out(), "({}, {})", p.x, p.y);
}
};
Point p{1, 2};
std::cout << std::format("{}", p); // "(1, 2)"
如果要支持格式说明符(比如 {:.2f}),parse 需要解析它们并保存下来。
6. 格式化时间
<chrono> 类型可以直接格式化:
#include <chrono>
#include <format>
auto now = std::chrono::system_clock::now();
std::cout << std::format("{:%Y-%m-%d %H:%M:%S}", now);
7. 写入已有缓冲区
std::format_to 直接写入输出迭代器,避免中间字符串:
std::string buffer;
std::format_to(std::back_inserter(buffer), "x = {}", 42);
// 或者写入固定大小的缓冲区
char buf[100];
auto result = std::format_to_n(buf, sizeof(buf), "x = {}", 42);
// result.out 指向写入末尾
4. 注意事项
编译期检查。格式串是编译期常量时,std::format 会检查参数数量和类型:
std::format("{} {}", 1); // 编译错误:参数太少
std::format("{:d}", "hello"); // 编译错误:字符串不能用 :d
这是相对 printf 的最大优势。但如果格式串是运行时字符串,检查就退化为运行时异常。
需要编译器支持。GCC 13+、Clang 17+、MSVC 19.29+ 才比较完整。老版本可以用 {fmt} 库,接口几乎一样。
std::format 返回新字符串。频繁调用会产生大量临时对象。性能敏感场景用 std::format_to 写入复用缓冲区。
std::print 是 C++23。只支持 C++20 时用 std::format 配合 cout。
{} 与 {{}}。要输出字面量花括号,需要写两个:
std::format("{{}}"); // "{}"
不要混用 printf 和 cout。如果程序里同时用了两者,输出顺序可能不符合预期(除非调用了 std::ios::sync_with_stdio(true),但那样会拖慢 cout)。
格式化宽字符。std::format 处理 char,宽字符用 std::format 的宽字符版本(std::wformat 系列),或者先转成 UTF-8。
5. 相关章节
C++实践
语言语法只是起点,真正把 C++ 用起来还需要面对工程层面的问题:并发程序怎么写、网络怎么通信、服务之间怎么调用。
本章收录这些偏工程实践的内容。与语言特性和标准库章节不同,这里的主题往往涉及操作系统接口、第三方库或平台差异,因此在可移植性和使用方式上需要额外留意。
| 主题 | 说明 |
|---|---|
| 多线程 | 待补充:线程池、任务队列、生产者消费者模式等工程实践。语言层面的并发设施见 多线程与并发 |
| 网络 | 待补充:Socket 编程、TCP/UDP、IO 多路复用 |
| gRPC | 待补充:Protobuf 定义服务、C++ 客户端与服务端实现 |
说明
标准库层面的内容(文件与流、时间、随机数、正则、文件系统等)已归入 标准库 一章。本章只收录需要依赖操作系统接口或第三方库的工程实践主题。
三个主题目前均未展开:
- 多线程:语言设施(
std::thread、互斥量、原子操作、异步任务)见 C++进阶 · 多线程与并发,本章侧重线程池、任务队列等更高层的并发模式。 - 网络:涉及 Socket API 与 IO 多路复用(
select/poll/epoll),以及 asio 等第三方库。 - gRPC:涉及 Protobuf 的接口定义语言、代码生成流程,以及 C++ 客户端与服务端的实现方式。