专栏《Zero to C++》· 单元 04
阅读时间约 15 分钟。本篇把 C 教材里最常出现的内存写法,逐条给出 C++ 的对称物和适用边界。
这一篇解决什么
单元 03 把 C 传下来的知识分成五块,其中「内存布局与指针算术」这一块放在本篇,「编译与链接」放在下一篇。
本篇的读法是逐条对照。左边是你在 C 教材、老代码、同事的代码里会看到的写法;右边是它在 C++ 里的对称物。每一条都配一段能编译运行的代码,说明右边多了什么、少了什么。
先给结论:左边没有一条是错的。 它们只是处在更低的抽象层,需要你自己维持更多不变量。理解右边的价值,在于知道哪一层该你操心、哪一层可以交给类型系统和标准库。
总表
| C 里你学到的 | C++ 的对称物 | 关键差别 |
|---|---|---|
malloc / free |
new / delete |
会调用构造函数和析构函数 |
裸 new / delete |
make_unique / make_shared |
释放时机由类型保证 |
拥有型裸指针 T* |
unique_ptr / shared_ptr / weak_ptr |
所有权从注释变成类型 |
T* p + 长度参数 |
std::span<T> |
指针和长度绑成一个类型 |
T arr[N] 定长数组 |
std::array<T, N> |
有 size(),可拷贝,可越界检查 |
动态数组 + realloc |
std::vector<T> |
自动增长,reserve 对应预分配 |
const char* |
std::string / std::string_view |
拥有和非拥有分开表达 |
NULL |
nullptr |
类型安全,不与整数 0 混淆 |
memcpy 字节拷贝 |
拷贝构造 / 移动构造 | 区分「复制一份」和「搬走」 |
后面逐条展开。
一、malloc/free → new/delete
new 和 malloc 都从堆上要内存,差别在两件事上。
第一,new 会调用构造函数。malloc 给你一块原始字节,那块字节上还没有对象;new Widget("x") 给你一个已经构造完成、处于合法状态的对象。第二,delete 会调用析构函数,free 不会。
cpp
#include <iostream>
#include <memory>
#include <string>
#include <utility>
struct Widget {
std::string name;
explicit Widget(std::string n) : name(std::move(n)) {
std::cout << "构造 " << name << '\n';
}
~Widget() { std::cout << "析构 " << name << '\n'; }
};
int main() {
std::cout << "-- new / delete --\n";
{
Widget* w = new Widget("raw");
delete w;
}
std::cout << "-- unique_ptr 转移所有权 --\n";
{
auto a = std::make_unique<Widget>("moved");
auto b = std::move(a); // 所有权从 a 转到 b
std::cout << "a 还有东西吗: " << (a ? "有" : "没有") << '\n';
} // b 在这里析构
}
输出:
-- new / delete --
构造 raw
析构 raw
-- unique_ptr 转移所有权 --
构造 moved
a 还有东西吗: 没有
析构 moved
这段代码演示的是构造和析构的配对。用 malloc 加 free 写同样的东西,需要手动调一次初始化函数、一次清理函数,而且两条路径分散在代码两头。单元 01 已经演示过那个形状。
二、裸拥有指针 → 智能指针
第二段代码的输出里有一行:a 还有东西吗: 没有。
std::move(a) 之后,a 变成了空指针。这条性质来自 unique_ptr 类型本身,不由运行时约定保证:它表达「独占所有权」,所以不能被复制,只能被搬走;搬走之后原来的那个必然为空。C 里 Widget* w 交给别的函数,你得靠注释说明谁负责 free。这类注释经常过期。
三种智能指针的分工:
| 类型 | 含义 | 什么时候用 |
|---|---|---|
unique_ptr<T> |
独占,只能有一个主人 | 默认选择。局部对象、工厂函数的返回值 |
shared_ptr<T> |
共享,用引用计数决定何时释放 | 多个对象真的需要共同拥有一份数据时 |
weak_ptr<T> |
观察,不参与计数,不延长寿命 | 打破 shared_ptr 之间的循环引用 |
这张表的读法是自上而下:默认用第一行,有明确理由才往下走。 shared_ptr 有引用计数的开销,还有两个 shared_ptr 互相指向对方时谁都不会释放的问题,所以它不该是起手式。weak_ptr 的存在就是为了解决那个循环------关于它的完整讨论在交谈录第四章。
三、指针 + 长度 → std::span
C 里传一段内存进函数,写法是地址加长度两个参数:
c
void sum(const int* data, size_t n);
这对参数分开传,调用方可以传错:长度写大了越界,写小了算漏。std::span 把它们绑成一个类型:
cpp
#include <array>
#include <iostream>
#include <span>
#include <vector>
int sum(std::span<const int> data) {
int total = 0;
for (int x : data) total += x;
return total;
}
int main() {
int raw[4] = {1, 2, 3, 4};
std::vector<int> v{5, 6, 7};
std::array<int, 3> a{8, 9, 10};
std::cout << sum(raw) << " " << sum(v) << " " << sum(a) << '\n';
}
输出:
10 18 27
同一个 sum 吃下三种东西:C 数组、vector、array。std::span<const int> 读作「一段连续的、只读的整数」,长度跟着一起来。函数签名里从此没有裸的指针和长度两个参数,调用方也没法把它们配错。
span 不拥有数据,它只是一个视图。这一点决定了它的适用边界:它引用的那块内存必须活得比它久。
边界具体卡在哪,看一个反面写法:
cpp
std::span<int> bad() {
int local[4] = {1, 2, 3, 4};
return local; // 函数一返回,local 就没了
}
这段能编译。运行时的行为却不保证------有时看起来还能读对,有时读到别的数据。这类问题叫悬垂(dangling):视图还在,它看的那块内存已经不在了。
判断方法只有一条,但够用:拿到一个 span 时,先问它看的那块内存归谁管、能活多久。指向调用方传进来的数据是安全的------那些数据活得比函数调用长。指向自己函数里的局部变量就是悬垂。
string_view 有同一个边界,下一节会看到它的另一面。
四、定长数组 → std::array;动态数组 → std::vector
int arr[10] 在 C++ 里能用,问题是它会退化成指针、不知道自己的长度、也不能整体赋值。std::array<int, 10> 是它的对称物,长度进类型,可以拷贝,可以问 size()。
需要运行时才知道长度、或者会长的序列,用 std::vector。上面那段代码里的 std::vector<int> v{5, 6, 7} 就是它。vector 内部做的正是 C 里「malloc 一块、装满了用 realloc 翻倍」那套动作,只是你不用再写它。想提前分配好容量避免反复增长,用 v.reserve(n),对应 C 里的预分配。
五、const char* → std::string / std::string_view
C 的字符串是以 '\0' 结尾的字符数组,长度靠扫描到结尾才知道。三个后果:每次求长度都要遍历一遍、拼接要手动管容量、忘记结尾符就是越界读。
std::string 是拥有文本的类型。它自己管内存,自己做拼接和比较。《C++ Primer》对 C 风格字符串的措辞是三个词里最重的:
Although C++ supports C-style strings, they should not be used by C++ programs. C-style strings are a surprisingly rich source of bugs and are the root cause of many security problems. They're also harder to use!
C++17 之后还有第三种:std::string_view。它看一段已有文本,不拷贝也不拥有:
cpp
#include <iostream>
#include <string>
#include <string_view>
std::size_t count_words(std::string_view text) {
std::size_t n = 0;
bool in_word = false;
for (char c : text) {
if (c == ' ') { in_word = false; }
else if (!in_word) { in_word = true; ++n; }
}
return n;
}
int main() {
std::string owned = "hello world from cpp";
std::cout << count_words(owned) << '\n'; // 传 string 可以
std::cout << count_words("four words here") << '\n'; // 传字面量也可以
}
输出:
4
3
count_words 接受 string_view,两种调用都不产生文本拷贝。它适合「只看一眼,不保存」的参数------和 span 是同一个思路,只是作用在文本上。需要保存或修改时就用 std::string。
「不拷贝」这件事有个直接后果:改原文本,视图跟着变。
cpp
std::string owner = "hello world";
std::string_view view = owner; // 不复制,只是看着 owner
std::cout << "视图看到:" << view << '\n';
owner[0] = 'H'; // 改的是原字符串
std::cout << "改原串后:" << view << '\n';
实测输出:
视图看到:hello world
改原串后:Hello world
view 从头到尾没有自己的字符数组,它只是记着 owner 从哪开始、有多长。owner 一变,view 看到的就是新的。这既是它省下拷贝的原因,也是它的风险所在。
所以 span 那条边界在这里原样适用:string_view 看的那段文本,必须活得比它久。
cpp
std::string_view bad() {
std::string local = "temporary";
return local; // 局部对象被销毁,视图悬垂
}
这段同样能编译。从函数返回 string_view 之前,先确认它指的不是函数内部的局部数据。
入门阶段的实用规则:参数用 string_view,成员和返回值用 std::string。参数的生命周期由调用方保证,安全;成员和返回值要长期存在,用拥有数据的类型才靠得住。
六、NULL → nullptr
NULL 在 C 里通常就是 0。写 f(NULL) 的时候,如果 f 有重载版本吃整数,编译器会选中那个版本,这不是你想要的结果。
nullptr 有独立类型,不会隐式转成整数。新代码一律用它。
七、memcpy → 拷贝构造 / 移动构造
C 里复制一个结构体用 memcpy,按字节拷。对只含平凡数据(整数、浮点、指针)的结构体这没问题;对含有资源的结构体(比如内部有个 malloc 出来的缓冲区),按字节拷贝会让两个对象持有同一块内存,析构时释放两次。
C++ 用两个语义分明的操作替代它:拷贝构造 表示「照着我复制一份独立的出来」,移动构造 表示「把资源搬过来,我这边不再需要了」。std::string 和 std::vector 都实现了这两个操作,所以它们可以被整体赋值、作为返回值传递,而不用你写任何 memcpy 或内存管理代码。
什么时候仍然用左边
上面七条容易被读成「左边的都不能用了」。这不成立。左边在这些场合是对的:
- 对接 C 接口时。 系统调用、第三方 C 库、POSIX API 都要
char*和长度参数。这时候s.data()/s.size()是入口。 - 实现抽象本身时。
std::vector的内部就得调分配器、做指针算术。你在写容器、分配器、内存池这一类组件时,用的就是第一层的工具。 - 需要精确控制内存布局时。 网络协议、文件格式、嵌入式、共享内存------这些场合要的是字节级布局,
memcpy和位运算是正常选择。 - 和硬件或旧 ABI 打交道时。 这时约束来自外部,不来自语言。
判据可以归纳成一句:当你在使用抽象时,用右边;当你在实现抽象时,会用到左边。 入门阶段的代码几乎全部属于前者。
下一个单元补第一层的另一半:代码从源文件到可执行文件这一路上发生了什么,以及 C++ 在这条路上比 C 多了哪些东西。
附录 · 术语表
本单元出现的词。不要求记住,读的时候遇到不懂的回来查一眼。
本单元的核心概念
堆(heap) --- 程序运行时可以按需申请和归还的一大块内存。malloc 和 new 都从这里取。
对称物 --- 同一个需求在更高抽象层上的写法。std::vector 是「动态数组」的对称物。
视图(view) --- 只看一段已有数据、不拷贝也不拥有它的对象。span 和 string_view 都是。
悬垂(dangling) --- 视图或指针还在,它指向的那块内存已经不在了。能编译,但读出来的是垃圾。判断方法是问「它看的数据归谁管、能活多久」。
引用计数(reference count) --- shared_ptr 内部记的「现在有几个指针指着我」。减到零就释放。
循环引用 --- 两个 shared_ptr 互相指着对方,各自的计数都到不了零,于是都不释放。用 weak_ptr 打破。
退化(decay) --- C 数组作为参数传递时,自动变成指向首元素的指针,长度信息丢失。
本单元的代码里出现的
new / delete --- C++ 的堆分配和释放。分别调用构造函数和析构函数。
malloc / free --- C 的堆分配和释放。只给原始字节,不调用构造和析构。
std::make_unique<T>(...) --- 构造一个 T 并交给 unique_ptr 独占。
std::make_shared<T>(...) --- 同上,交给 shared_ptr。
std::unique_ptr<T> / shared_ptr<T> / weak_ptr<T> --- 三种智能指针:独占、共享、观察。
std::move(x) --- 表示「x 我不再需要了,资源可以搬走」。搬走之后 x 处于合法但不再有内容的状态。
std::span<T> --- C++20 类型,表示「一段连续内存」加它的长度。不拥有数据。
std::array<T, N> --- 定长数组,长度是类型的一部分。
std::vector<T> --- 能自动增长的序列。reserve(n) 预先分配容量。
std::string --- 拥有文本的类型,自己管理内存。
std::string_view --- C++17 类型,看一段已有文本,不拷贝不拥有。
nullptr --- C++ 的空指针字面量,有独立类型。
NULL --- C 的空指针宏,通常是 0,会和整数重载混淆。
memcpy --- C 的按字节拷贝函数。
拷贝构造 / 移动构造 --- 复制一份独立的对象 / 把资源搬走并让源对象进入空状态。
std::size_t --- 无符号整数类型,用来表示长度和下标。
'\0' --- 值为零的字符,C 风格字符串用它标记结尾。