单元四 · 对称认知·上:内存与指针

专栏《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/freenew/delete

newmalloc 都从堆上要内存,差别在两件事上。

第一,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

这段代码演示的是构造和析构的配对。用 mallocfree 写同样的东西,需要手动调一次初始化函数、一次清理函数,而且两条路径分散在代码两头。单元 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 数组、vectorarraystd::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。参数的生命周期由调用方保证,安全;成员和返回值要长期存在,用拥有数据的类型才靠得住。

六、NULLnullptr

NULL 在 C 里通常就是 0。写 f(NULL) 的时候,如果 f 有重载版本吃整数,编译器会选中那个版本,这不是你想要的结果。

nullptr 有独立类型,不会隐式转成整数。新代码一律用它。

七、memcpy → 拷贝构造 / 移动构造

C 里复制一个结构体用 memcpy,按字节拷。对只含平凡数据(整数、浮点、指针)的结构体这没问题;对含有资源的结构体(比如内部有个 malloc 出来的缓冲区),按字节拷贝会让两个对象持有同一块内存,析构时释放两次。

C++ 用两个语义分明的操作替代它:拷贝构造 表示「照着我复制一份独立的出来」,移动构造 表示「把资源搬过来,我这边不再需要了」。std::stringstd::vector 都实现了这两个操作,所以它们可以被整体赋值、作为返回值传递,而不用你写任何 memcpy 或内存管理代码。

什么时候仍然用左边

上面七条容易被读成「左边的都不能用了」。这不成立。左边在这些场合是对的:

  1. 对接 C 接口时。 系统调用、第三方 C 库、POSIX API 都要 char* 和长度参数。这时候 s.data() / s.size() 是入口。
  2. 实现抽象本身时。 std::vector 的内部就得调分配器、做指针算术。你在写容器、分配器、内存池这一类组件时,用的就是第一层的工具。
  3. 需要精确控制内存布局时。 网络协议、文件格式、嵌入式、共享内存------这些场合要的是字节级布局,memcpy 和位运算是正常选择。
  4. 和硬件或旧 ABI 打交道时。 这时约束来自外部,不来自语言。

判据可以归纳成一句:当你在使用抽象时,用右边;当你在实现抽象时,会用到左边。 入门阶段的代码几乎全部属于前者。

下一个单元补第一层的另一半:代码从源文件到可执行文件这一路上发生了什么,以及 C++ 在这条路上比 C 多了哪些东西。


附录 · 术语表

本单元出现的词。不要求记住,读的时候遇到不懂的回来查一眼。

本单元的核心概念

堆(heap) --- 程序运行时可以按需申请和归还的一大块内存。mallocnew 都从这里取。

对称物 --- 同一个需求在更高抽象层上的写法。std::vector 是「动态数组」的对称物。

视图(view) --- 只看一段已有数据、不拷贝也不拥有它的对象。spanstring_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 风格字符串用它标记结尾。

相关推荐
529宝宝起名网2 小时前
用 Python 开发名字字源查询工具:从甲骨文到楷书的字形演变与文化寓意解析
开发语言·python
-dzk-2 小时前
【二叉树】LC 236.二叉树的最近公共祖先
数据结构·二叉树
国科安芯2 小时前
ASM9048S(4 路高速 LVDS 接收器)在商业航天等场景中的应用研究
嵌入式硬件·电源模块·卫星通信·抗干扰·抗辐射
白远山2 小时前
家政服务派单平台搭建实战指南:从需求分析到系统设计全流程解析
java·开发语言·架构·uni-app·需求分析
午彦琳2 小时前
2026.9.11
数据结构·python·算法
HUI-4742 小时前
EG11752|ESOP‑8 内置 200V‑2A 异步降压 DC‑DC,10‑200V 超宽压小功率辅助电源优选
嵌入式硬件·硬件工程
HRTOS2 小时前
HRTOS 4.0 驱动库实例:LCD1602显示屏使用示例
c语言·单片机·嵌入式硬件·51单片机
EleganceJiaBao2 小时前
【嵌入式】STM32 状态机设计:枚举、位掩码、结构体、变量与宏定义的合理选择
stm32·单片机·嵌入式硬件·枚举·状态机·位掩码
Tairitsu_H3 小时前
[C++] C++11右值引用与移动语义揭秘
开发语言·c++·c++11·右值引用·移动语义