CPP拾遗
C++ 内存、STL、模板与张量广播笔记
C++ 基础与内存操作
sizeof 运算符
基本含义
sizeof 返回类型或对象所占用的字节数,返回值类型为 std::size_t。
编译时求值
对于标准 C++ 类型,sizeof 的结果通常是编译期常量,可以用于:
- 数组长度
- 模板参数
static_assertconstexpr表达式
1 | static_assert(sizeof(int) >= 2); |
sizeof 的操作数通常处于未求值上下文,表达式不会真正执行。
1 | int x = 1; |
静态类型大小
sizeof(expression) 根据表达式的静态类型计算大小。
1 | struct Base { |
动态类型需要通过虚函数机制或 RTTI 判断,sizeof 本身只处理编译期类型信息。
数组与指针
真正的数组对象保存全部元素,因此 sizeof 返回整个数组的大小。
1 | int arr[10]; |
指针只保存地址。
1 | int* ptr = arr; |
数组作为 sizeof 的直接操作数时会保留数组类型,不发生数组到指针的转换。
结构体内存对齐
结构体成员通常按照各自的对齐要求放置。编译器可能在成员之间和结构体末尾插入填充字节。
1 | struct A { |
一种常见布局:
1 | 偏移 0:char c,占 1 字节 |
因此:
1 | sizeof(A); // 常见结果为 8 |
成员顺序会影响结构体大小。
1 | struct B { |
常见结果:
1 | sizeof(B); // 12 |
结构体大小通常满足以下规律:
- 每个成员的起始地址满足自身对齐要求。
- 结构体整体对齐值通常等于成员中的最大对齐值。
- 结构体总大小通常是整体对齐值的整数倍。
- 具体布局由编译器、ABI 和编译选项决定。
可以使用 alignof 查看对齐要求:
1 | alignof(int); |
sizeof(std::vector) 与迭代器大小
std::vector<T> 对象本身保存管理信息,元素存储在动态内存中。
常见实现使用三个指针或等价信息:
1 | begin 指向第一个元素 |
因此在部分 64 位标准库实现中:
1 | sizeof(std::vector<int>) == 3 * sizeof(void*) |
这个结果属于实现细节,C++ 标准未规定 vector 的对象布局。
1 | std::vector<int> v(1'000'000); |
元素数量不会直接改变 sizeof(v)。
迭代器是独立类型,其大小和 vector 对象大小没有固定关系。
1 | sizeof(v); |
发布模式下,普通 vector<T>::iterator 经常封装一个 T*。调试模式可能额外保存:
- 所属容器指针
- 边界检查信息
- 迭代器链表节点
- 调试状态
因此下面的断言缺乏可移植性:
1 | static_assert( |
memcpy 原理与优化
基本用途
std::memcpy 将一段内存中的字节复制到另一段内存。
1 |
|
函数原型概念上类似:
1 | void* memcpy(void* destination, |
基础实现可以理解为逐字节复制:
1 | void* simple_memcpy(void* destination, |
实际标准库实现通常根据数据大小、地址对齐和处理器特性选择更高效的复制方式。
类型限制
对于任意对象类型,直接按字节复制需要关注对象语义。
memcpy 适合:
- 基本数值类型
- 指针值的底层复制
- 字节缓冲区
- 可平凡复制类型
std::is_trivially_copyable_v<T>为真的类型
1 |
|
带有自定义复制逻辑、虚函数、动态资源或复杂不变量的类型适合使用:
1 | std::copy_n(source, count, destination); |
对齐处理与批量传输
高性能实现通常包含几个阶段:
- 复制少量前缀字节,使目标地址达到合适的对齐边界。
- 使用机器字、向量寄存器或缓存行大小进行批量复制。
- 处理末尾不足一个批次的剩余字节。
概念性示例:
1 | while (count > 0 && address_not_aligned(dst)) { |
现代编译器常将固定长度的 memcpy 直接展开成若干条加载和存储指令。
1 | struct Data { |
这类代码可能直接编译为寄存器移动,无需真正调用库函数。
SIMD 优化
SIMD 指令可以一次处理多个字节,例如:
- SSE
- AVX
- AVX2
- AVX-512
- ARM NEON
典型实现会使用运行时 CPU 特性检测,根据平台选择优化版本。
对于较小内存块,函数调用和复杂分支的开销可能高于复制本身。编译器通常会内联固定长度复制。
非临时存储
非临时存储指令可以减少大块复制对 CPU 缓存的污染,适合:
- 数据量远大于缓存
- 写入后短期内不会再次读取
- 连续的大块内存复制
对于小数据或即将被读取的数据,普通缓存写入通常更合适。
重叠内存与 memmove
memcpy 要求源区域和目标区域互不重叠。区域重叠时,程序行为未定义。
1 | char buffer[] = "abcdef"; |
std::memmove 支持重叠区域。
1 | std::memmove(buffer + 1, buffer, 5); |
常见实现根据地址关系选择复制方向:
1 | if (destination < source) { |
明确不存在重叠时优先使用 memcpy,编译器和标准库拥有更大的优化空间。
数组退化与形参传递
函数形参中的数组调整
函数参数中的数组声明会调整为指针类型。
1 | void process(int arr[10]); |
编译器按下面的形式处理:
1 | void process(int* arr); |
数组长度 10 不属于参数类型的一部分。
1 | void process(int arr[10]) { |
即使传入真正的数组,函数内部的 arr 仍然是指针参数。
真数组中的 sizeof
1 | int values[10]; |
传入函数后:
1 | void test(int values[]) { |
保留数组长度
使用数组引用模板可以保留长度:
1 | template<class T, std::size_t N> |
也可以直接将数组引用作为函数参数:
1 | template<class T, std::size_t N> |
使用 std::array
1 |
|
显式传递长度
1 | void process(const int* data, std::size_t size); |
C++20 可以使用 std::span 表达连续但不拥有的数据:
1 |
|
STL 容器
std::array
std::array<T, N> 是固定长度数组的标准库封装。
1 |
|
主要特点
- 长度在编译期确定
- 数据连续存储
- 没有额外动态分配
- 支持值语义
- 提供标准容器接口
- 通常与内置数组具有相同的存储开销
常用接口:
1 | values.size(); |
与内置数组的区别
内置数组无法直接赋值:
1 | int a[3] = {1, 2, 3}; |
std::array 支持整体复制:
1 | std::array<int, 3> a = {1, 2, 3}; |
内置数组在大量表达式中会退化为指针。std::array 作为类对象传递,不会自动退化。
1 | void use(const std::array<int, 3>& values); |
需要原始指针时显式调用:
1 | int* ptr = values.data(); |
std::vector 详解
内存模型
std::vector<T> 管理一段连续动态内存。
逻辑上维护三个边界:
1 | data begin 第一个元素 |
由此得到:
1 | size = data_end - data_begin |
对应接口:
1 | v.size(); |
关系始终满足:
1 | size <= capacity |
vector 对象通常存放在栈上或其他对象内部,元素缓冲区通常位于动态存储区。
容量增长规则
当插入元素导致 size() > capacity() 时,vector 通常执行:
- 分配更大的连续内存。
- 将旧元素移动或复制到新内存。
- 销毁旧元素。
- 释放旧内存。
- 插入新元素。
常见标准库可能使用约 1.5 倍或 2 倍增长策略。具体增长倍率由标准库实现决定。
C++ 标准主要保证连续存储和 push_back 的摊还常数复杂度。
reserve
reserve(n) 请求至少容纳 n 个元素的容量。
1 | std::vector<int> values; |
作用:
- 减少重复分配
- 减少元素移动或复制
- 提高批量插入性能
- 提高容量范围内迭代器和指针的稳定性
reserve 改变容量,不改变元素数量。
1 | values.reserve(100); |
resize
resize(n) 改变元素数量。
1 | std::vector<int> values; |
当 n > size():
- 创建新元素
- 必要时重新分配
当 n < size():
- 销毁末尾元素
- 通常保留原有容量
1 | values.resize(5); |
移动语义的影响
移动整个 vector 时,标准库通常可以直接转移内部缓冲区的所有权。
1 | std::vector<int> a(1'000'000); |
在分配器条件允许时,这通常只需转移内部管理信息。
扩容过程中,元素类型的移动构造函数是否标记为 noexcept 会影响策略。
1 | struct Item { |
为了维护异常安全保证,vector 在部分情况下会优先复制可能抛异常的移动类型。
clear 与 erase
clear() 销毁全部元素并将 size() 设为零,通常保留容量。
1 | values.clear(); |
erase() 删除指定元素,后续元素会向前移动,容量通常保持不变。
1 | values.erase(values.begin() + 3); |
shrink_to_fit
shrink_to_fit() 请求释放未使用容量。
1 | values.shrink_to_fit(); |
它属于非强制性请求。实现可以:
- 重新分配到接近
size()的容量 - 保持原容量
- 使迭代器、引用和指针失效
C++11 之前常使用临时对象交换:
1 | std::vector<int>(values).swap(values); |
这个技巧通过创建紧凑副本后交换缓冲区来释放多余容量,但会产生复制或移动成本。
迭代器失效
发生重新分配时,指向旧缓冲区的以下对象全部失效:
- 指针
- 引用
- 迭代器
1 | std::vector<int> values = {1, 2, 3}; |
无重新分配的尾部插入通常保留已有元素的引用和迭代器,但 end() 会变化。
erase 会使删除位置及其后的迭代器失效。
vector<bool> 特化
std::vector<bool> 是标准库针对布尔值的空间压缩特化。
多个布尔值通常压缩存放在机器字的不同位中:
1 | 普通 bool 数组:每个元素通常至少占 1 字节 |
单个位通常无法提供普通 bool&,因此:
1 | std::vector<bool>::reference |
是代理对象。它内部可能保存:
- 指向机器字的指针
- 位掩码或位索引
1 | std::vector<bool> flags = {true, false}; |
此时 value 通常推导为代理类型,仍然关联 flags 的底层存储。
需要独立布尔值时显式转换:
1 | bool value = flags[0]; |
修改元素可以使用代理引用:
1 | flags[0] = false; |
保存代理对象后再让 vector 扩容、移动或销毁,代理对象可能悬垂。
1 | auto ref = flags[0]; |
迭代器与容器对象大小
常规迭代器
在发布模式下,连续容器的迭代器经常封装原始指针:
1 | std::vector<int>::iterator |
概念上可能类似:
1 | class iterator { |
因此常见结果为:
1 | sizeof(std::vector<int>::iterator) == sizeof(int*) |
标准只规定迭代器行为和能力,不规定其内部布局。
调试模式迭代器
调试标准库可能给迭代器增加:
- 容器身份
- 边界信息
- 有效性状态
- 调试链表
- 线程或版本信息
因此调试模式下迭代器可能明显大于一个指针。
vector<bool>::iterator
vector<bool> 以位为单位访问元素,迭代器通常需要保存:
- 指向存储块的指针
- 当前位偏移
因此它通常无法直接表示为 bool*。
关于三个指针断言
下面的断言依赖具体标准库实现:
1 | static_assert( |
适合分析当前编译器实现,不适合作为可移植程序逻辑。
可靠结论包括:
vector元素连续存储。data()指向首元素存储。size()返回有效元素数量。capacity()返回当前可容纳元素数量。- 容器对象大小由实现决定。
模板与类设计
类模板基础
类模板允许同一数据结构适配不同元素类型。
1 | template<class T> |
使用方式:
1 | Tensor4D<float> a; |
模板定义通常需要放在头文件中,因为编译器实例化模板时需要看到完整定义。
下面的示例使用固定四维形状和动态元素存储。
1 |
|
形状数组的深拷贝
1 | std::copy_n(shape, 4, shape_); |
对于 std::size_t 这类可平凡复制类型,也可以使用:
1 | std::memcpy(shape_, shape, sizeof(shape_)); |
std::copy_n 更容易适配泛型代码和复杂类型。
元素数据分配
1 | data_ = std::make_unique<T[]>(size_); |
std::unique_ptr<T[]> 会在对象析构时自动调用 delete[],并能提高构造异常时的资源安全性。
使用原始指针时需要手动管理:
1 | T* data_ = nullptr; |
禁止复制与移动
1 | Tensor4D(const Tensor4D&) = delete; |
适用场景:
- 对象绑定不可转移资源
- 对象地址必须稳定
- 内部存在外部注册关系
- 复制成本过高
- 设计上要求唯一实例
一般拥有动态资源的值类型也可以实现深拷贝和移动语义。禁止复制与移动会限制对象在标准容器和函数返回值中的使用。
算法:张量广播
广播规则
对于相同维数的两个张量,每个维度需要满足:
1 | shape_a[d] == shape_b[d] |
输出维度通常取两者中的较大值:
1 | output_shape[d] = std::max(shape_a[d], shape_b[d]); |
例如:
1 | A: [2, 3, 4, 5] |
B 在第 0 维和第 2 维广播。
四维连续张量步长
对于行主序连续布局:
1 | shape = [D0, D1, D2, D3] |
步长为:
1 | stride[3] = 1 |
坐标 (i, j, k, l) 的线性偏移:
1 | offset = |
手动广播索引
广播维度始终读取下标 0:
1 | std::size_t ai = shape_a[0] == 1 ? 0 : i; |
完整偏移:
1 | std::size_t offset_a = |
使用有效步长优化
广播维度的坐标变化不会改变源数据位置,因此可以将对应步长设置为 0。
1 | std::size_t effective_stride_a[4]; |
随后直接计算:
1 | std::size_t offset_a = |
这会消除内层循环中的条件判断。
四层循环实现
1 | template<class T> |
单循环与除模反推
也可以使用单层循环遍历全部输出元素,再根据输出步长恢复四维坐标。
1 | for (std::size_t linear = 0; linear < total; ++linear) { |
优点:
- 代码结构统一
- 容易扩展为动态维数
- 便于并行划分线性区间
性能成本主要来自循环中的整数除法和取模运算。
固定四维场景通常适合:
- 嵌套循环
- 预计算有效步长
- 内层连续访问
- 将最连续维度放在最内层
PyTorch 广播机制原理
PyTorch 张量通常由以下信息描述:
- 数据存储
- 形状
sizes - 步长
strides - 存储偏移
- 数据类型
- 设备信息
广播视图可以通过修改元数据实现。
原始张量:
1 | shape = [1, 3, 1, 5] |
扩展为:
1 | shape = [2, 3, 4, 5] |
第 0 维和第 2 维步长为 0,因此这些维度的任意坐标都指向同一批底层元素。
这类扩展通常只创建视图,不复制元素数据。
手动实现中的:
1 | index = shape[d] == 1 ? 0 : output_index; |
与零步长机制表达相同的地址映射关系。
TensorIterator
PyTorch 的许多逐元素运算通过统一迭代框架处理:
- 广播
- 数据类型转换
- 多输入和多输出
- 连续维度合并
- CPU 并行
- 向量化
- GPU 内核索引
- 不同内存布局
概念流程:
- 检查输入形状是否兼容。
- 计算共同输出形状。
- 为广播维度生成零步长。
- 合并可连续遍历的维度。
- 根据设备和类型选择内核。
- 并行遍历输出元素。
手动四维实现适合学习索引原理。通用框架通过统一元数据和内核调度处理更多数据类型、维度和设备。
现代 C++ 最佳实践与陷阱
auto 类型推导
普通 auto
1 | const int value = 10; |
普通 auto 会剥离:
- 顶层
const - 顶层引用
底层 const 通常会保留:
1 | const int* ptr = &value; |
auto&
1 | int value = 10; |
auto& 保留引用关系。
对于只读访问:
1 | const auto& ref = value; |
这可以避免大型对象复制,并延长临时对象生命周期。
1 | const auto& result = create_large_object(); |
auto&&
在类型推导上下文中,auto&& 可以绑定左值和右值。
1 | auto&& a = value; |
范围循环中常用:
1 | for (auto&& element : container) { |
vector<bool> 代理对象
1 | std::vector<bool> flags = {true, false}; |
x 可能是 std::vector<bool>::reference,继续关联容器存储。
需要独立值时:
1 | bool x = flags[0]; |
需要修改元素时:
1 | auto reference = flags[0]; |
范围遍历 vector<bool> 时,可以使用:
1 | for (auto&& bit : flags) { |
内存安全与生命周期
常见悬垂来源
返回局部变量引用:
1 | const int& bad() { |
保存 vector 元素地址后触发扩容:
1 | int* ptr = &values[0]; |
保存临时对象内部地址:
1 | const char* ptr = std::string("abc").c_str(); |
保存 vector<bool> 代理后改变底层存储:
1 | auto bit = flags[0]; |
生命周期管理原则
- 明确对象所有权。
- 使用 RAII 管理资源。
- 优先使用标准容器。
- 将只读非拥有访问表示为引用、指针或
std::span。 - 将独占所有权表示为
std::unique_ptr。 - 将共享所有权限制在确实需要共享生命周期的场景。
- 容器扩容后重新获取元素指针、引用和迭代器。
值语义与禁止复制
值语义
值语义类型可以像普通数值一样复制、赋值和返回。
1 | std::vector<int> create_values() { |
值语义通常带来:
- 清晰的所有权
- 易组合的接口
- 容器兼容性
- 异常安全
- 更简单的生命周期管理
使用 = delete
1 | class Resource { |
适用对象包括:
- 锁
- 文件映射
- 操作系统句柄包装
- 注册到外部系统的对象
- 地址必须固定的对象
- 单例或作用域守卫
移动语义
移动语义允许将资源所有权从一个对象转移到另一个对象。
1 | class Buffer { |
移动后,源对象仍然处于有效且可析构的状态,其具体内容由类型约定决定。
Rule of Zero
优先让标准库资源类型负责析构、复制和移动。
1 | class Tensor { |
编译器可以自动生成正确的:
- 析构函数
- 复制构造函数
- 复制赋值运算符
- 移动构造函数
- 移动赋值运算符
这类设计通常比直接管理 new[] 和 delete[] 更安全。
