裸指针还能不能用:不拥有与拥有的边界

「裸指针还能不能用?」网上一边倒的答案是「别用,全换成 std::unique_ptr」。这话对,但只对了一半。C++ Core Guidelines 的真正立场更精细:裸指针(a T*)本身没有错,错的是用它去表达「所有权」。指针可以放心地当「观察者」用,只要你约定它不负责释放。这篇把「不拥有」和「拥有」的边界划清楚,并给你能直接套用的签名写法。

官方文档:R.3: A raw pointer (a T*) is non-owning (Core Guidelines)

问题不在指针,在「所有权」

看这两行:

cpp 复制代码
Widget* create();        // 返回的指针谁负责 delete?
void f(Widget* w);       // w 是借来的,还是要我释放?

第一个签名让人头疼:调用方拿到 Widget* 后,到底该不该 delete?编译器没法告诉你,文档也没写,于是 leaks 和 double-free 就来了。根因是「所有权」被藏进了裸指针里。规约去建立:T* 只表示「我指着一个对象,但我不拥有它」;谁拥有,谁就用智能指针或容器说清楚。

所有权表达方式一览(ASCII 图)

把「不拥有」和「拥有」两套表达摆在一张图里,一眼看出该用什么:

text 复制代码
表达「不拥有」(borrow,不负责释放)
  T&   指向必存在的对象,不可为空、不可重绑
  T*   指向对象,可为 nullptr(可选观察者)

表达「拥有」(own,负责释放/生命周期)
  std::unique_ptr<T>   独占所有权,离开作用域自动释放
  std::shared_ptr<T>   共享所有权,引用计数归零释放
  std::vector<T>       拥有连续的一批元素
  T 局部变量           栈上自动拥有,作用域结束即析构
  ──────────────────────────────────────────────
  红线:不要用裸指针 / 裸引用去「传递所有权」

核心约定:裸指针和裸引用只出现在「借」的位置(函数参数、返回值指向别人拥有的对象);「 ownership 的转移与持有」一律交给 std::unique_ptr / std::shared_ptr / 容器。这样从类型上就能读出一段代码要不要负责释放。

什么时候裸指针仍是正确选择

即使全程用智能指针,下面四类场景裸指针依然是对的:

  1. 非拥有的观察者参数:函数只读或临时借用一个对象,用 const T* 或 T*,明确「我不负责它的生死」(Core Guidelines F.7)。
  2. 可选输出参数:需要「调用方可能不关心结果」时,传 T* out,nullptr 表示「别写」。返回值做不到「可选输出」。
  3. 与 C API 交互:C 接口只认裸指针,桥接处必然出现 T*,这时它就是个不拥有的桥。
  4. 指向栈上对象:局部变量取地址传给别人看,生命周期由栈管,指针只是借道。

官方文档:F.7: For general use, take T* or T& to pass a maybe-modified object (Core Guidelines)

T* 与 T& 表达「不拥有」的区别

两者都「不拥有」,但语义强度不同,别乱换:

维度 T& 不拥有 T* 不拥有
可空(表达「可能没有」) 不能,必存在 能,nullptr 表示无
必须初始化 是 否
可重新指向别处 不能 能
适用 参数一定存在、不可选 可选观察者 / 可选输出

经验法则:参数一定存在就给 T&(强契约),可能不存在才给 T*。返回「找到的元素」时用 const T*(可空 = 没找到),而不是 const T&(引用没法说「没找到」)。

函数签名设计:好 / 坏对比表

下面这些对比直接来自 Core Guidelines,照着改能消掉一大类所有权歧义 bug:

坏签名 问题 好签名
void f(Widget* w) 而 w 永远非空 不该为「必存在」引入可空性,调用方困惑要不要判空 void f(Widget& w)
Widget* create() 返回裸指针 所有权不明:谁 delete?易泄漏 / 重复释放 std::unique_ptr<Widget> create()
int* find(Key) 返回裸指针且隐含拥有 混淆「不拥有」与「拥有」 不拥有用 const Widget*;拥有用 std::unique_ptr<Widget>
void read(std::shared_ptr<Widget> p) 强行要求共享所有权,调用方被迫 make_shared void read(const Widget& w) 或 void read(const Widget* w)

官方文档:I.11: Never transfer ownership by a raw pointer or reference (Core Guidelines)

要点:要「借」就用裸指针/引用,要「转移所有权」就用 std::unique_ptr 按值返回或按值传参。类型本身就写明了生命周期责任,不需要注释兜底。

不拥有用指针,拥有用 unique_ptr

一个程序同时演示上面所有正确姿势:非拥有观察者 const int*、可选输出参数 int*、从容器不拥有地返回 const int*、以及用 std::unique_ptr 明确转移所有权。全程没有裸 new/delete。

cpp 复制代码
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <memory>
#include <vector>

// 不拥有观察者:只读,不负责释放(F.7)
void describe(const int* p) {
    if (p) std::cout << "值: " << *p << '\n';
    else   std::cout << "空指针,无对象\n";
}

// 可选输出参数:传 nullptr 表示「不关心结果」
void maybe_double(int in, int* out) {
    if (out) *out = in * 2;
}

// 不拥有地返回找到的元素(指针可空 = 没找到)
const int* find_first_even(const std::vector<int>& v) {
    for (const int& x : v) if (x % 2 == 0) return &x;
    return nullptr;
}

// 转移所有权:用 unique_ptr 明确「调用方负责释放」
std::unique_ptr<int> make_counter() {
    return std::make_unique<int>(0);   // 无裸 new
}

int main() {
    int a = 7;
    describe(&a);          // 非拥有观察者
    describe(nullptr);     // 可空

    int result = 0;
    maybe_double(5, &result);
    std::cout << "5*2 = " << result << '\n';
    maybe_double(5, nullptr);   // 不关心结果,啥也不做

    std::vector<int> nums{1, 3, 4, 7};
    const int* e = find_first_even(nums);
    if (e) std::cout << "第一个偶数: " << *e << '\n';

    auto c = make_counter();          // 拥有
    std::cout << "counter 初值: " << *c << '\n';
    return 0;
}
text 复制代码
值: 7
空指针,无对象
5*2 = 10
第一个偶数: 4
counter 初值: 0

describe / find_first_even 用的裸指针全部「不拥有」,它们只借看、不会去释放;make_counter 用 std::unique_ptr 把所有权显式交还给调用方,c 离开作用域时自动析构,无泄漏。对比第 5 节那张坏表,create() 若返回裸指针,这段所有权就含糊了。

实证对比:同一个场景,三种所有权写法各写一遍

前面讲的都是「该用什么」的规约。这一节把同一个场景用三种写法各实现一遍,让它们的行为差异直接打在屏幕上。

场景很简单:创建两个对象、求它们的值之和、然后释放。三种写法算出来的和完全一样,但「谁负责释放、什么时候释放」是三个不同的答案。

cpp 复制代码
// ownership_demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 ownership_demo.cpp -o own
#include <iostream>
#include <memory>

struct Node {
    int value;
    explicit Node(int v) : value(v) { std::cout << "  + 创建 Node(" << v << ")\n"; }
    ~Node() { std::cout << "  - 析构 Node(" << value << ")\n"; }
};

// 写法 1:用裸指针表达所有权 ------ 反例,不要这么写
// 谁 new 谁 delete;漏掉任何一条路径(包括抛异常的路径)就是泄漏
int sum_with_raw() {
    std::cout << "[1] 裸指针拥有(反例,不要这么写)\n";
    Node* a = new Node(1);
    Node* b = new Node(2);
    const int s = a->value + b->value;
    delete a;                          // 这两个 delete 必须手动写对
    delete b;
    return s;
}

// 写法 2:unique_ptr 独占所有权 ------ 推荐
int sum_with_unique() {
    std::cout << "[2] std::unique_ptr 拥有\n";
    const auto a = std::make_unique<Node>(3);
    const auto b = std::make_unique<Node>(4);
    return a->value + b->value;        // 没有 delete:离开作用域自动释放
}

// 写法 3:shared_ptr 共享所有权 ------ 代价是引用计数
int sum_with_shared() {
    std::cout << "[3] std::shared_ptr 拥有\n";
    const auto a = std::make_shared<Node>(5);
    const auto b = std::make_shared<Node>(6);
    const std::shared_ptr<Node> alias = a;     // 共享同一个对象,计数 +1
    std::cout << "  a 的引用计数 = " << a.use_count() << '\n';
    return alias->value + b->value;
}

int main() {
    const int r1 = sum_with_raw();
    const int r2 = sum_with_unique();
    const int r3 = sum_with_shared();
    std::cout << "\n三种写法的和: " << r1 << " / " << r2 << " / " << r3 << '\n';
}
text 复制代码
[1] 裸指针拥有(反例,不要这么写)
  + 创建 Node(1)
  + 创建 Node(2)
  - 析构 Node(1)
  - 析构 Node(2)
[2] std::unique_ptr 拥有
  + 创建 Node(3)
  + 创建 Node(4)
  - 析构 Node(4)
  - 析构 Node(3)
[3] std::shared_ptr 拥有
  + 创建 Node(5)
  + 创建 Node(6)
  a 的引用计数 = 2
  - 析构 Node(6)
  - 析构 Node(5)

三种写法的和: 3 / 7 / 11

三种写法算出的和一致,差别全在析构日志的顺序和来源上:

  • 写法 1(裸指针):delete a; delete b; 是手写的,日志里 析构 Node(1)、析构 Node(2) 出现在函数返回之前。这两行的顺序、位置、有无,全部取决于人手 ------ 漏掉一条分支(更别说抛异常提前返回的那条)就是泄漏。这段代码能跑对,是因为它太短了;真实的类有十几个成员、几十条分支时,「每条路径都记得 delete」是人力难以保证的。
  • 写法 2(unique_ptr):日志里没有一行 delete,但 析构 Node(4)、析构 Node(3) 照样出现,顺序是声明的逆序(先析构后声明的 b)。这就是 RAII:释放时机由对象生命周期决定,而不是由你记不记得写决定。
  • 写法 3(shared_ptr):多了一行 a 的引用计数 = 2。alias 和 a 指向同一个对象,所以最后一个持有者析构时才真正释放(日志里 Node(5) 最后才被析构)。这行计数就是共享所有权的运行时代价。

所以「到底能不能用裸指针」的答案落在所有权上:用来「借」完全可以 (比如下面第 8 节那些 const Widget* 参数),用来「拥有」就该换成智能指针(写法 2 或 3)。同一份业务逻辑,写法 2 比写法 1 更短、更安全,而且性能上没有损失。

「不拥有」在真实 API 里的形态:引用还是指针

上面是「谁拥有」的对比,这一节回到最日常的场景:函数参数。参数是「不拥有」指针出现频率最高的地方,而同一个操作往往可以有两种签名:一种承诺「对象一定存在」,另一种允许「这次没有」。

cpp 复制代码
// borrow_api.cpp --- 编译: g++ -std=c++17 -Wall -O2 borrow_api.cpp -o borrow
#include <iostream>
#include <string>
#include <vector>

struct Widget {
    std::string name;
    int width;
};

// 签名 A:引用 ------ 承诺「对象一定存在」,函数体内不需要判空
void render(Widget& w) {
    std::cout << "渲染 <" << w.name << "> 宽度=" << w.width << '\n';
}

// 签名 B:指针 ------ 允许「这次没有」,nullptr 是有意义的取值而不是错误
void render_optional(const Widget* w) {
    if (!w) {
        std::cout << "本次没有 widget,跳过\n";
        return;
    }
    std::cout << "渲染 <" << w->name << "> 宽度=" << w->width << '\n';
}

// 签名 C:可选输出参数 ------ 传 nullptr 就是「别写,我不关心结果」
void measure(const Widget& w, int* out_width) {
    if (out_width) *out_width = w.width;
}

int main() {
    Widget panel{"panel", 320};

    render(panel);                 // 对象必存在 -> 引用(强契约)
    render_optional(&panel);       // 对象存在,但接口允许为空
    render_optional(nullptr);      // 语义明确的「没有」

    int width = -1;
    measure(panel, &width);        // 关心结果
    std::cout << "取到的宽度 = " << width << '\n';
    measure(panel, nullptr);       // 不关心结果,什么都不写
    std::cout << "宽度保持不变 = " << width << '\n';

    std::vector<Widget> widgets{panel};
    const Widget* borrowed = &widgets.front();   // 只是借看,不拥有
    render_optional(borrowed);
}
text 复制代码
渲染 <panel> 宽度=320
渲染 <panel> 宽度=320
本次没有 widget,跳过
取到的宽度 = 320
宽度保持不变 = 320
渲染 <panel> 宽度=320

三个签名都不涉及所有权,谁都不负责释放 Widget。区别只在契约强度:render(Widget&) 用引用把「一定存在」写进类型,因此函数体里一个判空都没有;render_optional(const Widget*) 用指针把「可能没有」显式暴露给调用方,nullptr 是合法输入;measure 的 int* out_width 则是「可选输出参数」,调用方传 nullptr 就等于声明「我不关心这个结果」。

反过来看反面写法:如果写成 void render(std::shared_ptr<Widget> w),就等于要求调用方必须用共享所有权持有这个对象。可是 panel 明明是栈上对象,为了调用这个函数,调用方不得不 std::make_shared<Widget>(panel) 复制一份到堆上,多一次分配、多一个控制块、多一次原子计数,纯粹是被接口逼出来的开销。

易错点与常见误解

关于裸指针的讨论里,下面几条误解流传最广:

  • 「裸指针就是内存泄漏的根源」 ------ 不是。泄漏的根源是所有权没人认领 。const Widget* borrowed = &widgets.front(); 这样的非拥有指针不负责释放,它永远不会泄漏,也不会 double free。Core Guidelines 的观点正是「T* 默认就是不拥有的」,问题出在有人拿它当拥有的用。
  • 「参数统一用 shared_ptr 传最安全」 ------ 恰恰相反。按值收 std::shared_ptr<T> 会强制要求共享所有权,逼调用方把栈对象搬上堆,还多一次原子增减。Core Guidelines F.7 的建议是:只读就 const T&,要改就 T&,可能为空才 const T* / T*。
  • 「返回值用引用比用指针更现代」 ------ 只在「一定能返回一个对象」时成立。查找类函数必须有「没找到」这个取值,这时 const T* 配 nullptr 才是诚实的设计;为了用引用而返回一个静态哨兵对象,调用方根本没法分辨「找到的正好是哨兵」和「没找到」。
  • 「unique_ptr 有运行时代价,热路径上还是得裸指针」 ------ 用默认删除器时 std::unique_ptr<T> 与 T* 同尺寸、同性能 (见下一节的表),它比裸指针只多了一条编译期规则「不能复制」。真需要把指针传给别人看时,get() 借出去就行,不必放弃所有权表达。
  • 「只要是非拥有指针就绝对安全」 ------ 不拥有不等于不会悬垂。非拥有指针必须保证「被观察对象的生命周期不短于观察者」。函数参数场景天然安全(借用只发生在调用期间);一旦把非拥有指针存进成员变量或容器,就得自己担保生命周期 ------ 这正是 std::weak_ptr 存在的理由。

性能与内存视角:三种表达方式的成本

规约之外,还有必要把开销算清楚,因为「智能指针有开销」是很多人在热路径上退回裸指针的理由。实际情况是:独占所有权零开销,共享所有权才有成本。

表达方式 大小(x86-64) 释放动作 能否复制 适合
T* 8 字节 无(它不负责) 能 不拥有的借用
std::unique_ptr<T> 8 字节 1 次 delete 不能,只能移动 独占所有权
std::shared_ptr<T> 16 字节 原子递减引用计数 能 真的需要共享所有权
  • unique_ptr 与裸指针同尺寸、零运行时开销。默认删除器 std::default_delete<T> 是个空类,被空基类优化吸收,不占任何空间;析构时的 delete 调用本来就是你手写版也要做的。它省下的是「忘记 delete」「重复 delete」「异常路径漏 delete」三类 bug,代价只是「不能复制」这条编译期约束。
  • shared_ptr 是两个指针:一个指向对象,一个指向控制块;控制块里放着强/弱引用计数。std::make_shared 能把对象和控制块的分配合并成一次,比 shared_ptr<T>(new T) 少一次堆分配,也更利于缓存局部性。但计数增减是原子操作,在多线程高并发路径上会形成 cache line 争抢 ------ 这也是 Core Guidelines 反对「随手按值传 shared_ptr」的性能理由。
  • 非拥有裸指针本身没有任何成本 。它是观察者,不参与生命周期管理,不需要引用计数,也不产生任何额外指令。所以在只读遍历、可选输出这类「借」的场景里,用 const T* 是既正确又最快的选择 ------ 这两件事在这里并不冲突。

官方文档:R.30: Take smart pointers as parameters only to explicitly express lifetime semantics (C++ Core Guidelines) · std::make_shared (cppreference)

延伸阅读

收个尾

裸指针没错,错在拿它表达所有权。把它和引用严格限定在「不拥有」的借用位置(T& 必存在、T* 可空可选),所有权一律交给 std::unique_ptr / std::shared_ptr / 容器。签名里写清谁负责释放,泄漏和 double-free 就从类型层面消失了。这条边界一旦立住,「要不要用裸指针」就不再是信仰问题,而是看这个位置到底负不负责释放。

相关推荐
Yyyyyy~1 小时前
【C++】STLday1
c++
无名猿1 小时前
左值、右值、纯右值、将亡值:值类别入门
c++·内存管理·现代c++·语法基础
程序员老陆1 小时前
C++ 将亡值(xvalue)深度解析:从表达式分类到参数传递陷阱
c++·程序设计
沫璃染墨2 小时前
《Qt从零入门系列(十二):Qt文件操作详解——从QFile读写到QFileInfo与记事本实战》
开发语言·网络·c++·qt·交互·信号处理·文件
青少儿编程课堂2 小时前
树上启发式合并详解:子树颜色众数统计与复杂度拆解
c++·python·算法·bfs·信息学竞赛
路弥行至2 小时前
【Head First 设计模式】第 2 章:观察者模式 —— 用现代 C++ 重新解读“交互对象的松耦合
c++·经验分享·笔记·观察者模式·设计模式·入门教程·headfirst
YYYing.3 小时前
【设计模式系列 (九) 】装饰器模式
c++·后端·设计模式·装饰器模式·c/c++
elseif1233 小时前
【2026 CSP-J】【逐题精讲】超详细(15道选择,3道阅读,2道完善)
java·开发语言·c++·算法·csp
殷色玫瑰4 小时前
C语言编译和链接:从 .c到 .exe,程序到底经历了什么?
linux·c语言·c++·算法