什么情况用指针当参数,什么时候用引用
这是个接口设计问题,核心判据只有一条:引用的三个"不能"。
一、根本判据
引用有三个硬限制:
| 限制 | 后果 |
|---|---|
| 不能为空 | 需要表达"可能没有"时用不了 |
| 不能重新绑定 | 需要改指向、需要迭代时用不了 |
| 不是对象(没有自己的地址) | 不能放容器、不能做数组元素 |
需要这三者之一 → 用指针。否则 → 用引用。
其余的讨论都是这条规则的展开。
二、决策表
| 需求 | 签名 | 说明 |
|---|---|---|
| 只读,大对象 | const T& |
默认选择 |
| 只读,小且平凡(≤16 字节) | T |
拷贝比间接寻址还便宜 |
| 只读,可能不存在 | const T* |
或 std::optional<T>(值语义时) |
| 修改调用者的对象,必然存在 | T& |
|
| 修改,可能不存在 | T* |
|
| 需要保存一份副本 | T + std::move |
sink 参数惯用法 |
| 转移所有权 | std::unique_ptr<T> |
按值 |
| 共享所有权 | std::shared_ptr<T> |
按值(有原子开销) |
| 只是用一下对象,不管所有权 | T& / T* |
绝不要收智能指针 |
| 连续序列 | std::span<T> / std::span<const T> |
C++20 |
| 只读字符串 | std::string_view |
|
| 需要重新指向 / 迭代 | T* 或迭代器 |
|
| 完美转发 | T&&(模板) |
三、可空性是首要判据
签名本身就是契约:
void ; // 契约:w 一定存在且有效,我不检查
void ; // 契约:可能是 nullptr,我会检查
// ✓ 引用:没有"没找到"这种情况
void ;
// ✓ 指针:parent 可以是空(根节点)
void ;
// ✗ 反例:既然可能为空,就不该用引用
void ; // 调用者只好写 setParent(*maybeNull),UB
这条准则的价值在于消除冗余的空检查:
void
void
(严格说可以构造出悬垂引用,但那是调用者的 UB,不改变接口语义。)
如果你的团队想把这个契约强制化,可以用 GSL 的 not_null:
void ; // 构造时检查,之后不用再判空
四、需要"改指向"或"迭代"时只能用指针
// ✓ 指针可以移动
void
// ✓ 需要修改调用者持有的那个【指针】本身
void ; // C 风格
void ; // C++ 写法,更清楚
// ✓ 遍历数组
void ;
不过现代 C++ 里这些大多有更好的替代:
void ; // 替代 const char*
void ; // 替代 (ptr, len) 和 (begin, end)
std::unique_ptr<Widget> ; // 替代输出参数
五、智能指针作为参数 —— 最容易出错的地方
这是 Herb Sutter 在 GotW #91 里总结的准则:
void ; // 可空,【不参与】所有权
void ; // 非空,【不参与】所有权
void ; // 【接管】所有权(sink)
void ; // 要【重新指向】那个 unique_ptr(少见)
void ; // 【共享】所有权,函数会保存一份
void ; // 【可能】共享,避免无谓的原子操作
最重要的一条
如果函数不参与所有权管理,就不要收智能指针 —— 收
T&或T*。
// ✗ 差:把调用方绑死在 shared_ptr 上
void
// ✓ 好:任何来源的 Widget 都能调
void
后者可以这样调用:
Widget stackObj; ;
auto up = std::make_unique<Widget>; ;
auto sp = std::make_shared<Widget>; ;
Widget* raw = ; ;
前者只能用 shared_ptr,而且每次按值传还要付两次原子操作(递增 + 递减)。
按值传 shared_ptr 的代价
void ; // 每次调用:一次原子递增 + 一次原子递减
原子操作在多核竞争下可能上百个周期。热路径上这是真实开销,只有当函数确实要保存一份副本时才值得。
六、输出参数:指针还是引用
这是历史上争议最大的一点。
旧版 Google C++ Style Guide 要求输出参数用指针,理由是调用点可见:
; // 一眼看出 x 可能被修改
; // 看不出 g 会不会改 x
现代观点(Google 现行版本也已放宽):
- 优先用返回值,C++17 保证的复制省略让它零成本
- 多个输出用聚合 + 结构化绑定
- 现代 IDE 会标注非 const 引用参数
// ✗ 老式
void ;
// ✓ 现代
;
ParseResult ;
auto = ;
// ✓ 或者
std::optional<int> ;
std::expected<int, Error> ; // C++23
只在这两种情况下保留输出参数:
// ① 复用调用者已分配的缓冲区(避免重复分配)
void ;
// ② 类型不可移动,或返回代价确实很高
void ;
七、必须用引用的场合
有些地方语法上就不给选:
// 拷贝/移动构造与赋值
;
;
Widget& ;
// 运算符重载(除了少数)
std::ostream& ;
bool ;
// 范围 for 的绑定
for
// 完美转发
void ;
八、必须用指针的场合
// 与 C API 交互
extern "C" void ;
;
// 需要存进容器(引用不是对象)
std::vector<Widget*> widgets; // ✓
std::vector<Widget&> bad; // ✗ 编译错误
std::vector<std::reference_wrapper<Widget>> ok; // ✓ 变通
// 可选参数需要默认值
void ; // 引用没法给"空"默认值
// 指针算术、动态数组
九、常见错误
① 为了读对象而收 const shared_ptr&
void // ✗
void // ✓
② 用引用表达可空
Widget* p = ;
; // ✗ p 可能是 nullptr,解引用即 UB
③ 返回局部对象的引用/指针
const std::string& // ✗ 悬垂
int* // ✗ 悬垂
④ span / string_view 绑到临时对象
std::string_view sv = ; // ✗ 临时 string 立即销毁
std::span<int> s = ; // ✗ 同理
它们是非拥有的视图,不参与生命周期延长(和 const T& 不同)。只适合做参数,不适合做成员或返回值 —— 除非你能保证被引用对象活得更久。
⑤ 用 T** 输出指针
void ; // ✗ C 风格,容易忘检查
std::unique_ptr<Widget> ; // ✓
十、决策流程图
这个参数……
│
├─ 需要转移所有权? → std::unique_ptr<T>(按值)
├─ 函数要保存一份共享引用?→ std::shared_ptr<T>(按值)
│
├─ 是连续序列? → std::span<T> / std::span<const T>
├─ 是只读字符串? → std::string_view
│
├─ 可能"不存在"? → T*(或 std::optional<T>,值语义时)
├─ 需要改指向 / 迭代? → T*
├─ 要放进容器? → T*(或 reference_wrapper)
├─ 对接 C API? → T*
│
├─ 要修改调用者的对象? → T&
├─ 只读,小且平凡? → T(按值)
├─ 要保存一份副本? → T(按值)+ std::move
├─ 模板里要转发? → T&&(配 std::forward)
│
└─ 其他一切 → const T&
一句话总结
默认用
const T&;需要修改就去掉const;只有当"可能为空"、"需要改指向"、"要放进容器"、"对接 C API"这四种情况之一成立时才用指针。 而"这个参数可能是空的"本身就是最强的信号 —— 引用在签名里承诺了"它一定存在",滥用引用等于撒谎。 另外记住:函数不管所有权就别收智能指针,收T&/T*,这样调用方用什么方式持有对象都能调。
先说这段设计本身有几处问题,再回答三个问题。
零、原设计的四个问题
| # | 原文 | 问题 |
|---|---|---|
| 4 & 5 | 「设计拷贝构造函数…+1」「设计复制构造函数…+1」 | 这两条重复了。如果第 5 条指的是赋值运算符 operator=,那就错了 —— 赋值不创建新对象,计数不该变 |
| — | 漏了移动构造函数 | C++11 起 Widget b = std::move(a); 创建了一个新对象,必须 +1。这是最常见的遗漏 |
| — | 「在构造函数中 +1」 | 得是每一个构造函数(默认、带参、拷贝、移动、委托)。手写容易漏 |
| — | 没考虑构造抛异常 | 若在派生类构造函数体末尾 +1,中途抛异常时 +1 没执行、析构也不执行,还算平衡;但如果 +1 在前面就出问题了 |
另外标题说「计算子类的个数」——如果字面理解是"每个派生类各自的实例数",单个 static 计数器做不到,需要 CRTP。
修正版
// CRTP:每个 Derived 有【独立】的计数器
;
;
; // 两者各自独立计数
这个设计解决了原方案的所有问题:
- 不会漏构造函数 —— 基类子对象在任何派生类构造路径上都必然被构造
- 异常安全 —— 基类先构造完成,若派生类构造函数抛异常,基类析构函数仍会被调用,计数自动平衡
- 赋值不误加
- 每个派生类独立计数
- C++17
inline static免去了"类定义外再写一遍size_t Widget::count_ = 0;"
用法:
Widget a, b;
Widget c = a; // 拷贝构造 → 3
Widget d = ; // 移动构造 → 4(a 还活着!)
b = c; // 赋值 → 仍然 4
std::cout << ; // 4
std::cout << ; // 0
1. 线程安全吗?
原方案不是。
static int count;
// ✗ 数据竞争
++count 不是一条指令,是读-改-写三步:
mov eax, [count] ; 读
add eax, 1 ; 改
mov [count], eax ; 写
两个线程交错执行就会丢失更新:
线程 A: 读到 5
线程 B: 读到 5
线程 A: 写 6
线程 B: 写 6 ← 应该是 7,丢了一次
而且这不只是"数值不准"的问题 —— 在 C++ 里这是数据竞争,属于 UB。编译器可以假设不存在竞争,从而把 count 缓存在寄存器里、重排、甚至把整个循环里的自增合并掉。结果可能比"少数几次"糟糕得多。
三种修法的对比
| 方案 | 正确性 | 开销 | 评价 |
|---|---|---|---|
std::atomic<size_t> + fetch_add |
✓ | 一条 lock xadd(约 20–50 周期,无竞争时) |
推荐 |
std::mutex |
✓ | 无竞争约 20 ns,有竞争可能陷入内核 | 杀鸡用牛刀 |
volatile int |
✗ | — | 完全不解决问题 |
volatile 为什么没用
这是最常见的误解。volatile 只保证:
- 不把变量缓存进寄存器,每次都真的访问内存
- 不消除、不合并对它的读写
它不保证:
- 原子性 ——
++v仍然是读-改-写三步 - 内存序 —— 不阻止 CPU 和编译器对其他内存操作的重排
volatile 是给内存映射 I/O 寄存器和 sigatomic_t 用的,不是并发工具。(Java 的 volatile 语义完全不同,这也是混淆的来源之一。)
内存序该选哪个
count_.;
relaxed 就够了,因为:
- 计数器只需要自身的原子性,不需要与其他数据建立同步关系
fetch_add即使是 relaxed 也保证不丢更新(RMW 操作总是看到该变量的最新值)- x86 上
relaxed和seq_cst的fetch_add生成的指令一样(都是lock xadd),但在 ARM/RISC-V 上能省掉内存屏障
什么时候需要更强的序?当计数值要用来触发动作时:
if
这正是 shared_ptr 引用计数的做法:递增用 relaxed,递减用 acq_rel(或 release + 栅栏)。
还有一个"不可避免"的问题
即使用了 atomic,读到的值也只是一个瞬间快照:
if
这不是 bug,是并发计数器的本质。要基于计数做决策,就必须把"检查 + 动作"整体放进锁里,或者用 CAS 循环。
性能陷阱:缓存行争用
高频创建/销毁时,所有核心都在争抢同一条 cache line(存放 count_ 的那条)。每次 lock xadd 都要把 cache line 拉到本核并置其他核为无效 —— 这就是 false sharing 的极端形式。
如果计数真的在热路径上,可以用分片计数:
; // 各占一条 cache line
inline static Shard shards_;
static void
static std::size_t
代价是读取变成 O(shards),且汇总期间的值不是任何一个真实时刻的快照。只在实测证明有瓶颈时才这么做。
2. GSL 是什么库
先消歧义
"GSL" 有两个完全不同的常见含义:
| 缩写 | 全称 | 领域 |
|---|---|---|
| GSL(C++ 语境) | Guidelines Support Library | C++ Core Guidelines 的配套库 |
| GSL(科学计算语境) | GNU Scientific Library | C 的数值计算库(矩阵、积分、随机数) |
我上一条回答里说的是前者。
Guidelines Support Library
C++ Core Guidelines(Bjarne Stroustrup 和 Herb Sutter 主导的现代 C++ 编码规范)里提出了一些"应该有但标准库还没有"的类型。GSL 就是这些类型的参考实现。
主要实现:Microsoft/GSL,header-only,MIT 许可。
核心内容
// ① not_null —— 在类型上表达"不会是空指针"
void
// ② span —— 指针 + 长度的安全封装
void ;
// ★ 已被标准采纳:C++20 的 std::span
// ③ owner —— 零开销的所有权标注,纯粹给静态分析用
gsl::owner<Widget*> ; // 就是 T* 的别名,但工具知道"调用者要负责 delete"
// ④ finally —— scope guard
auto _ = ; // 离开作用域时执行
// ⑤ narrow / narrow_cast —— 有损数值转换
auto a = gsl::narrow_cast<int>; // 明确表达"我知道可能截断"
auto b = gsl::narrow<int>; // 丢失精度时【抛异常】
// ⑥ Expects / Ensures —— 前置/后置条件
int
// ⑦ czstring / zstring —— 表达"以 \0 结尾的 C 字符串"
void ; // 比裸 const char* 表意清楚
现状与取舍
| 组件 | 标准化情况 |
|---|---|
span |
✅ C++20 std::span(GSL 版可以退休了) |
string_span |
被 std::string_view 取代 |
not_null |
❌ 未标准化,仍需 GSL |
finally |
❌ 未标准化(有 scope_exit 提案,进了 Library Fundamentals TS) |
Expects/Ensures |
将被 C++26 的 Contracts 取代 |
narrow |
❌ 未标准化 |
owner |
❌ 纯标注,只对静态分析工具有意义 |
要不要用? 多数团队的做法是:
not_null和finally最有价值,可以单独抄进项目span直接用标准的- 不建议为了这几个类型引入整个依赖,除非团队已经在按 Core Guidelines 走,并且配套用了
clang-tidy的cppcoreguidelines-*检查(那些检查会认识gsl::owner之类的标注)
3. string_view 和智能指针参数该不该用引用
这是两个不同的问题,答案不一样。
string_view / span:你的直觉是对的 —— 按值,不要引用
void ; // ✓ 正确
void ; // ✗ 反模式
为什么
string_view 本身就是一个**"胖指针"**:
sizeof; // 16 —— 一个指针 + 一个长度
sizeof; // 16 —— 同理
按值传时,这 16 字节直接放进两个寄存器(SysV 下是 rdi + rsi),零内存访问。
改成 const& 之后:
按值: 两个寄存器 → 函数里直接用
按引用:一个寄存器存地址 → 函数里每次访问都要【解引用】一次内存
引用反而更慢:多一次间接寻址,而且很可能强制编译器把 string_view 落到栈上(因为要有地址可取)。
同样的道理适用于所有"小而平凡"的类型:
void ; // ✓ 16 字节
void ; // ✓ 8 字节
void ; // ✓ 8 字节
void ; // ✓
经验阈值:sizeof(T) <= 16 且平凡可复制 → 按值传。
但要小心生命周期
string_view 和 span 是非拥有的视图,不参与生命周期延长(这一点和 const T& 不同):
void ;
; // ✓ 安全 —— 临时对象活到 f 返回
std::string_view sv = ; // ✗ 危险 —— 若返回的是临时对象,立刻悬垂
; // ✗ 危险 —— 做成员要格外小心
准则:string_view / span 只适合做参数,不适合做成员或返回值 —— 除非你能证明被引用的对象活得更久。
顺带:string_view vs const std::string&
void ;
void ;
; // ✗ 构造一个临时 std::string —— 一次堆分配!
; // ✓ 零分配
所以只读字符串参数默认用 string_view。例外:函数内部需要 .c_str() 传给 C API 时(string_view 不保证 \0 结尾)。
智能指针:要看意图,引用有时是对的
这里不能一刀切。完整的对照:
| 签名 | 意图 | 什么时候用 |
|---|---|---|
std::unique_ptr<T> p |
接管所有权(sink) | 最常见,工厂函数、容器插入 |
std::unique_ptr<T>& p |
要重新指向调用者的那个 unique_ptr |
少见但合法(reset、重新赋值) |
const std::unique_ptr<T>& p |
几乎没意义 | 你只想访问对象 → 该收 T& |
std::shared_ptr<T> p |
函数会保存一份(共享所有权) | 有两次原子操作 |
const std::shared_ptr<T>& p |
可能保存,想避免无谓的原子开销 | ✓ 热路径上有真实价值 |
T& / T* |
不参与所有权,只是用一下 | 最常见,也最常被写错 |
所以答案是
unique_ptr:几乎总是按值传(转移所有权)。 用引用只有一种正当理由 —— 你要修改调用者持有的那个指针本身:
void
shared_ptr:const& 是有意义的优化。
void ; // 每次调用:一次原子 ++ 和一次原子 --
void ; // 零原子操作
原子操作在多核竞争下可能上百个周期。所以:
- 函数确定要保存副本 → 按值 +
std::move - 函数可能保存(比如有条件地存进容器)→
const&,需要时再拷贝 - 函数根本不管所有权 → 别收智能指针
但最重要的还是这一条
如果函数不参与所有权管理,就不要收智能指针 —— 收
const T&或T*。
// ✗ 把调用方绑死在 shared_ptr 上,还白付原子开销(如果按值传)
void
// ✓ 任何来源的 Widget 都能调
void
Widget stackObj; ;
auto up = std::make_unique<Widget>; ;
auto sp = std::make_shared<Widget>; ;
Widget* raw = ; ;
签名里出现智能指针,就应该意味着"这个函数关心所有权"。 否则那是接口污染。
三个问题的一句话总结
1. 不安全 ——
++count是读-改-写三步,并发下丢失更新且属于数据竞争(UB)。用std::atomic<size_t>+fetch_add(relaxed);volatile完全不解决问题。另外原设计还漏了移动构造函数,并错误地在赋值运算符里计数 —— 用 CRTP 计数基类可以一并解决。 2. C++ 语境下的 GSL 是 Guidelines Support Library(微软实现的 C++ Core Guidelines 配套库),提供not_null、span、finally、narrow、owner等;其中span已成为 C++20 标准。注意别和 GNU Scientific Library 混淆。 3.string_view/span按值传,不要用引用 —— 它们本身就是 16 字节的胖指针,能进寄存器,加引用反而多一次间接寻址。智能指针要看意图:unique_ptr按值(转移所有权),shared_ptr的const&是避免原子开销的正当优化 —— 但最重要的是,不管所有权的函数根本就不该收智能指针。
暂无评论,欢迎留下第一条评论。