这是个接口设计问题,核心判据只有一条:引用的三个"不能"

一、根本判据

引用有三个硬限制:

限制 后果
不能为空 需要表达"可能没有"时用不了
不能重新绑定 需要改指向、需要迭代时用不了
不是对象(没有自己的地址) 不能放容器、不能做数组元素

需要这三者之一 → 用指针。否则 → 用引用。

其余的讨论都是这条规则的展开。


二、决策表

需求 签名 说明
只读,大对象 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 process(Widget& w);   // 契约:w 一定存在且有效,我不检查
void process(Widget* w);   // 契约:可能是 nullptr,我会检查
// ✓ 引用:没有"没找到"这种情况
void render(const Scene& scene);

// ✓ 指针:parent 可以是空(根节点)
void setParent(Node* parent);

// ✗ 反例:既然可能为空,就不该用引用
void setParent(Node& parent);   // 调用者只好写 setParent(*maybeNull),UB

这条准则的价值在于消除冗余的空检查

void f(Widget* w) {
    if (!w) return;      // 每个收指针的函数都要写这一行
    w->use();
}

void f(Widget& w) {
    w.use();             // 不需要检查,契约保证了
}

(严格说可以构造出悬垂引用,但那是调用者的 UB,不改变接口语义。)

如果你的团队想把这个契约强制化,可以用 GSL 的 not_null

#include <gsl/pointers>
void f(gsl::not_null<Widget*> w);   // 构造时检查,之后不用再判空

四、需要"改指向"或"迭代"时只能用指针

// ✓ 指针可以移动
void print(const char* s) {
    while (*s) putchar(*s++);      // 引用做不到 s++
}

// ✓ 需要修改调用者持有的那个【指针】本身
void allocate(Widget** out);       // C 风格
void allocate(Widget*& out);       // C++ 写法,更清楚

// ✓ 遍历数组
void sum(const int* begin, const int* end);

不过现代 C++ 里这些大多有更好的替代:

void print(std::string_view s);         // 替代 const char*
void sum(std::span<const int> data);    // 替代 (ptr, len) 和 (begin, end)
std::unique_ptr<Widget> allocate();     // 替代输出参数

五、智能指针作为参数 —— 最容易出错的地方

这是 Herb Sutter 在 GotW #91 里总结的准则:

void f(Widget* w);                          // 可空,【不参与】所有权
void f(Widget& w);                          // 非空,【不参与】所有权
void f(std::unique_ptr<Widget> w);          // 【接管】所有权(sink)
void f(std::unique_ptr<Widget>& w);         // 要【重新指向】那个 unique_ptr(少见)
void f(std::shared_ptr<Widget> w);          // 【共享】所有权,函数会保存一份
void f(const std::shared_ptr<Widget>& w);   // 【可能】共享,避免无谓的原子操作

最重要的一条

如果函数不参与所有权管理,就不要收智能指针 —— 收 T&T*

// ✗ 差:把调用方绑死在 shared_ptr 上
void draw(const std::shared_ptr<Widget>& w) { w->render(); }

// ✓ 好:任何来源的 Widget 都能调
void draw(const Widget& w) { w.render(); }

后者可以这样调用:

Widget stackObj;              draw(stackObj);
auto up = std::make_unique<Widget>();   draw(*up);
auto sp = std::make_shared<Widget>();   draw(*sp);
Widget* raw = getFromCApi();  draw(*raw);

前者只能用 shared_ptr,而且每次按值传还要付两次原子操作(递增 + 递减)。

按值传 shared_ptr 的代价

void f(std::shared_ptr<Widget> w);   // 每次调用:一次原子递增 + 一次原子递减

原子操作在多核竞争下可能上百个周期。热路径上这是真实开销,只有当函数确实要保存一份副本时才值得。


六、输出参数:指针还是引用

这是历史上争议最大的一点。

旧版 Google C++ Style Guide 要求输出参数用指针,理由是调用点可见:

f(&x);      // 一眼看出 x 可能被修改
g(x);       // 看不出 g 会不会改 x

现代观点(Google 现行版本也已放宽):

  1. 优先用返回值,C++17 保证的复制省略让它零成本
  2. 多个输出用聚合 + 结构化绑定
  3. 现代 IDE 会标注非 const 引用参数
// ✗ 老式
void parse(const std::string& in, int* out, bool* ok);

// ✓ 现代
struct ParseResult { int value; bool ok; };
ParseResult parse(std::string_view in);
auto [value, ok] = parse(s);

// ✓ 或者
std::optional<int> parse(std::string_view in);
std::expected<int, Error> parse(std::string_view in);   // C++23

只在这两种情况下保留输出参数

// ① 复用调用者已分配的缓冲区(避免重复分配)
void readInto(std::vector<char>& buffer);

// ② 类型不可移动,或返回代价确实很高
void fill(HugeMatrix& out);

七、必须用引用的场合

有些地方语法上就不给选:

// 拷贝/移动构造与赋值
Widget(const Widget&);
Widget(Widget&&);
Widget& operator=(const Widget&);

// 运算符重载(除了少数)
std::ostream& operator<<(std::ostream& os, const Widget& w);
bool operator==(const Widget& a, const Widget& b);

// 范围 for 的绑定
for (const auto& item : container)

// 完美转发
template<class T> void f(T&& x);

八、必须用指针的场合

// 与 C API 交互
extern "C" void c_callback(void* userdata);
qsort(arr, n, size, cmp);

// 需要存进容器(引用不是对象)
std::vector<Widget*> widgets;                       //std::vector<Widget&> bad;                           // ✗ 编译错误
std::vector<std::reference_wrapper<Widget>> ok;     // ✓ 变通

// 可选参数需要默认值
void log(const char* msg, Context* ctx = nullptr);  // 引用没法给"空"默认值

// 指针算术、动态数组

九、常见错误

① 为了读对象而收 const shared_ptr&

void f(const std::shared_ptr<Widget>& w) { w->use(); }   //void f(const Widget& w) { w.use(); }                     //

② 用引用表达可空

Widget* p = find(...);
process(*p);            // ✗ p 可能是 nullptr,解引用即 UB

③ 返回局部对象的引用/指针

const std::string& f() { return std::string("hi"); }   // ✗ 悬垂
int* g() { int x = 0; return &x; }                     // ✗ 悬垂

span / string_view 绑到临时对象

std::string_view sv = std::string("hi");   // ✗ 临时 string 立即销毁
std::span<int> s = getVector();            // ✗ 同理

它们是非拥有的视图,不参与生命周期延长(和 const T& 不同)。只适合做参数,不适合做成员或返回值 —— 除非你能保证被引用对象活得更久。

⑤ 用 T** 输出指针

void create(Widget** out);              // ✗ C 风格,容易忘检查
std::unique_ptr<Widget> create();       //

十、决策流程图

这个参数……
│
├─ 需要转移所有权?        → 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。

修正版

#include <atomic>
#include <cstddef>

template <class Derived>          // CRTP:每个 Derived 有【独立】的计数器
class Counted {
public:
    static std::size_t count() noexcept {
        return count_.load(std::memory_order_relaxed);
    }

protected:
    Counted()                noexcept { bump(); }
    Counted(const Counted&)  noexcept { bump(); }   // 拷贝构造:新对象
    Counted(Counted&&)       noexcept { bump(); }   // ★ 移动构造:也是新对象!

    // ★ 赋值不产生新对象,计数不变
    Counted& operator=(const Counted&) noexcept { return *this; }
    Counted& operator=(Counted&&)      noexcept { return *this; }

    ~Counted() noexcept { count_.fetch_sub(1, std::memory_order_relaxed); }
    // 析构函数 protected 且非虚 —— 禁止 delete Counted*,也不用付 vptr

private:
    static void bump() noexcept { count_.fetch_add(1, std::memory_order_relaxed); }

    inline static std::atomic<std::size_t> count_{0};   // C++17:类内定义,不用 .cpp
};

class Widget : public Counted<Widget> { /* ... */ };
class Gadget : public Counted<Gadget> { /* ... */ };   // 两者各自独立计数

这个设计解决了原方案的所有问题:

  • 不会漏构造函数 —— 基类子对象在任何派生类构造路径上都必然被构造
  • 异常安全 —— 基类先构造完成,若派生类构造函数抛异常,基类析构函数仍会被调用,计数自动平衡
  • 赋值不误加
  • 每个派生类独立计数
  • C++17 inline static 免去了"类定义外再写一遍 size_t Widget::count_ = 0;"

用法:

Widget a, b;
Widget c = a;                    // 拷贝构造 → 3
Widget d = std::move(a);         // 移动构造 → 4(a 还活着!)
b = c;                           // 赋值 → 仍然 4
std::cout << Widget::count();    // 4
std::cout << Gadget::count();    // 0

1. 线程安全吗?

原方案不是。

static int count;
Widget() { ++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_.fetch_add(1, std::memory_order_relaxed);

relaxed 就够了,因为:

  • 计数器只需要自身的原子性,不需要与其他数据建立同步关系
  • fetch_add 即使是 relaxed 也保证不丢更新(RMW 操作总是看到该变量的最新值)
  • x86 上 relaxedseq_cstfetch_add 生成的指令一样(都是 lock xadd),但在 ARM/RISC-V 上能省掉内存屏障

什么时候需要更强的序?当计数值要用来触发动作时

if (count_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
    // 我是最后一个 —— 现在可以安全地清理共享资源
    // 需要 acq_rel 来保证看到其他线程的所有写入
}

这正是 shared_ptr 引用计数的做法:递增用 relaxed,递减用 acq_rel(或 release + 栅栏)。

还有一个"不可避免"的问题

即使用了 atomic,读到的值也只是一个瞬间快照

if (Widget::count() == 0) {
    // 到这一行时,别的线程可能已经创建了 10 个 Widget
}

这不是 bug,是并发计数器的本质。要基于计数做决策,就必须把"检查 + 动作"整体放进锁里,或者用 CAS 循环。

性能陷阱:缓存行争用

高频创建/销毁时,所有核心都在争抢同一条 cache line(存放 count_ 的那条)。每次 lock xadd 都要把 cache line 拉到本核并置其他核为无效 —— 这就是 false sharing 的极端形式。

如果计数真的在热路径上,可以用分片计数

struct alignas(64) Shard { std::atomic<std::size_t> v{0}; };   // 各占一条 cache line
inline static Shard shards_[64];

static void bump() {
    shards_[thread_id() % 64].v.fetch_add(1, std::memory_order_relaxed);
}
static std::size_t count() {          // 读的时候才汇总,读变慢、写变快
    std::size_t s = 0;
    for (auto& sh : shards_) s += sh.v.load(std::memory_order_relaxed);
    return s;
}

代价是读取变成 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 许可。

核心内容

#include <gsl/gsl>

// ① not_null —— 在类型上表达"不会是空指针"
void f(gsl::not_null<Widget*> w) {
    w->use();                     // 不用判空,构造时已经检查过
}

// ② span —— 指针 + 长度的安全封装
void process(gsl::span<int> data);
// ★ 已被标准采纳:C++20 的 std::span

// ③ owner —— 零开销的所有权标注,纯粹给静态分析用
gsl::owner<Widget*> create();     // 就是 T* 的别名,但工具知道"调用者要负责 delete"

// ④ finally —— scope guard
auto _ = gsl::finally([&]{ fclose(fp); });   // 离开作用域时执行

// ⑤ narrow / narrow_cast —— 有损数值转换
auto a = gsl::narrow_cast<int>(bigValue);    // 明确表达"我知道可能截断"
auto b = gsl::narrow<int>(bigValue);         // 丢失精度时【抛异常】

// ⑥ Expects / Ensures —— 前置/后置条件
int divide(int a, int b) {
    Expects(b != 0);              // 违反则终止(可配置行为)
    return a / b;
}

// ⑦ czstring / zstring —— 表达"以 \0 结尾的 C 字符串"
void log(gsl::czstring msg);      // 比裸 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_nullfinally 最有价值,可以单独抄进项目
  • span 直接用标准的
  • 不建议为了这几个类型引入整个依赖,除非团队已经在按 Core Guidelines 走,并且配套用了 clang-tidycppcoreguidelines-* 检查(那些检查会认识 gsl::owner 之类的标注)

3. string_view 和智能指针参数该不该用引用

这是两个不同的问题,答案不一样。

string_view / span:你的直觉是对的 —— 按值,不要引用

void f(std::string_view s);          // ✓ 正确
void f(const std::string_view& s);   // ✗ 反模式

为什么

string_view 本身就是一个**"胖指针"**:

sizeof(std::string_view);   // 16 —— 一个指针 + 一个长度
sizeof(std::span<int>);     // 16 —— 同理

按值传时,这 16 字节直接放进两个寄存器(SysV 下是 rdi + rsi),零内存访问。

改成 const& 之后:

按值:  两个寄存器  →  函数里直接用
按引用:一个寄存器存地址  →  函数里每次访问都要【解引用】一次内存

引用反而更慢:多一次间接寻址,而且很可能强制编译器把 string_view 落到栈上(因为要有地址可取)。

同样的道理适用于所有"小而平凡"的类型:

void f(std::span<T> s);              // ✓ 16 字节
void f(std::pair<int,int> p);        // ✓ 8 字节
void f(std::chrono::milliseconds d); // ✓ 8 字节
void f(int* p, std::size_t n);       //

经验阈值sizeof(T) <= 16 且平凡可复制 → 按值传。

但要小心生命周期

string_viewspan非拥有的视图不参与生命周期延长(这一点和 const T& 不同):

void f(std::string_view s);
f(std::string("temp"));              // ✓ 安全 —— 临时对象活到 f 返回

std::string_view sv = getString();   // ✗ 危险 —— 若返回的是临时对象,立刻悬垂
struct S { std::string_view sv; };   // ✗ 危险 —— 做成员要格外小心

准则:string_view / span 只适合做参数,不适合做成员或返回值 —— 除非你能证明被引用的对象活得更久。

顺带:string_view vs const std::string&

void f(const std::string& s);
void g(std::string_view s);

f("literal");            // ✗ 构造一个临时 std::string —— 一次堆分配!
g("literal");            // ✓ 零分配

所以只读字符串参数默认用 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 reset_if_stale(std::unique_ptr<Conn>& c) {   // ✓ 合法:会 c.reset(...)
    if (c && c->expired()) c = make_conn();
}

shared_ptrconst& 是有意义的优化。

void f(std::shared_ptr<Widget> w);          // 每次调用:一次原子 ++ 和一次原子 --
void g(const std::shared_ptr<Widget>& w);   // 零原子操作

原子操作在多核竞争下可能上百个周期。所以:

  • 函数确定要保存副本 → 按值 + std::move
  • 函数可能保存(比如有条件地存进容器)→ const&,需要时再拷贝
  • 函数根本不管所有权别收智能指针

但最重要的还是这一条

如果函数不参与所有权管理,就不要收智能指针 —— 收 const T&T*

// ✗ 把调用方绑死在 shared_ptr 上,还白付原子开销(如果按值传)
void draw(const std::shared_ptr<Widget>& w) { w->render(); }

// ✓ 任何来源的 Widget 都能调
void draw(const Widget& w) { w.render(); }
Widget stackObj;                          draw(stackObj);
auto up = std::make_unique<Widget>();     draw(*up);
auto sp = std::make_shared<Widget>();     draw(*sp);
Widget* raw = getFromCApi();              draw(*raw);

签名里出现智能指针,就应该意味着"这个函数关心所有权"。 否则那是接口污染。


三个问题的一句话总结

1. 不安全 —— ++count 是读-改-写三步,并发下丢失更新且属于数据竞争(UB)。用 std::atomic<size_t> + fetch_add(relaxed)volatile 完全不解决问题。另外原设计还漏了移动构造函数,并错误地在赋值运算符里计数 —— 用 CRTP 计数基类可以一并解决。 2. C++ 语境下的 GSL 是 Guidelines Support Library(微软实现的 C++ Core Guidelines 配套库),提供 not_nullspanfinallynarrowowner 等;其中 span 已成为 C++20 标准。注意别和 GNU Scientific Library 混淆。 3. string_view / span 按值传,不要用引用 —— 它们本身就是 16 字节的胖指针,能进寄存器,加引用反而多一次间接寻址。智能指针要看意图unique_ptr 按值(转移所有权),shared_ptrconst& 是避免原子开销的正当优化 —— 但最重要的是,不管所有权的函数根本就不该收智能指针