PAYATHON 2026

如何防止 pseudo-reference 被 auto 存入 automatic storage

技术老齐

结论

C++ 没有标准机制能禁止 auto 推导出某个类型,类型本身也无法知道自己是否正被构造到 automatic storage 中。

即使删除 copy constructor 和 move constructor,下面的代码仍可通过编译:

auto value = object.Get();

从 C++17 开始,如果 Get() 返回同类型的 prvalue,value 会直接作为该 prvalue 的 result object 构造,不涉及复制或移动。这不是优化器省略了 move,而是语言规则没有要求创建中间对象。

可行的处理方式有三种:

  1. 把 pseudo-reference 设计成可安全保存的对象,避免 dangling。
  2. 使用 ref-qualified 成员,禁止保存后的对象执行读写操作。
  3. 在迁移期间增加自定义 Clang-Tidy 检查,将局部 proxy 变量视为构建错误。

如果要求 auto value = ... 这行代码直接编译失败,第三种方案最合适。

删除复制和移动构造函数为什么无效

例如:

class pseudo_ref {
public:
    pseudo_ref(const pseudo_ref&) = delete;
    pseudo_ref(pseudo_ref&&) = delete;
};

pseudo_ref Get()
{
    return pseudo_ref{/* ... */};
}

下面的初始化在 C++17 及以后仍然合法:

auto value = Get();

Get() 返回的 prvalue 会直接初始化 value,不会调用 copy constructor 或 move constructor。

返回语句需要直接构造 prvalue:

return pseudo_ref{/* ... */};

如果先创建具名局部变量再返回,由于 NRVO 并非在所有情况下都有保证,删除 move constructor 后可能无法编译:

pseudo_ref result{/* ... */};
return result;  // 可能需要 move constructor

把析构函数设为 deleted 或 private 也不可行。立即使用的临时对象仍需在完整表达式结束时析构,这种限制通常会连正常用法一起禁掉。

方案一:让 proxy 可以安全保存

既然语言无法限制对象的保存位置,更稳妥的接口设计是让 proxy 的有效性不再依赖 Properties 对象的裸指针或短暂生命周期。

例如,可以让 Properties 和 proxy 共享一个控制块:

struct property_state;

class any_pseudo_ref {
public:
    explicit any_pseudo_ref(std::shared_ptr<property_state> state,
                            std::string key)
        : state_(std::move(state)), key_(std::move(key))
    {
    }

private:
    std::shared_ptr<property_state> state_;
    std::string key_;
};

复制 Properties 时可以创建独立控制块,各控制块最初共享底层 map。proxy 持有相应的控制块,所以原 Properties 对象销毁后,它也不会引用已经释放的对象。写入操作则由控制块执行 copy-on-write。

接口还需要明确 proxy 保存后的语义:

  • proxy 是观察创建它的那个 property box,还是创建时的数据快照;
  • proxy 写入时是否继续修改原 property box;
  • property 被删除或替换后,proxy 应如何表现。

如果无法为这些情况定义安全且容易理解的行为,就不应允许 proxy 长期保存。

方案二:允许构造,但禁止使用具名 proxy

局部变量的声明无法阻止,但可以通过 value category 区分临时对象和具名对象。

临时 proxy 是 rvalue,保存到变量后会成为 lvalue,因此可以只允许在 rvalue 上读写:

class any_pseudo_ref {
public:
    any_pseudo_ref(const any_pseudo_ref&) = delete;
    any_pseudo_ref(any_pseudo_ref&&) = delete;

    template<class T>
    void operator=(T&& value) && {
        write(std::any(std::forward<T>(value)));
    }

    template<class T>
    void operator=(T&&) & = delete;

    operator const std::any&() const && {
        return read();
    }

    operator const std::any&() const & = delete;

private:
    const std::any& read() const;
    void write(std::any value);
};

立即使用仍然有效:

box["a"] = 7;

consume_any(box["a"]);

保存 proxy 本身仍可通过编译:

auto value = box["a"];  // 仍然合法

但后续通过 lvalue 使用它会失败:

value = 7;              // 错误:调用了已删除的 & overload
consume_any(value);     // 错误:lvalue 转换已删除

调用者仍可显式使用 std::move(value) 绕过限制,不过这种写法已经表明了明确意图,旧代码不太可能无意触发。

这个方案有两个限制。未使用 value 的声明不会报错。此外,函数模板在模板实参推导期间通常不考虑用户定义转换,因此原本接受 std::any 的函数模板可能需要增加 proxy overload:

template<class T>
T get_value_or(any_pseudo_ref&& value, T fallback)
{
    // 在完整表达式内读取 value
}

仅修改返回类型,无法保证所有安全的旧代码都能原样通过编译。返回类型的变化还可能影响 overload resolution、模板推导、concept 检查和 decltype。

方案三:用 Clang-Tidy 在迁移期阻止保存

如果要让下面这行代码直接导致构建失败:

auto value = box["a"];

可以编写一个小型 Clang-Tidy check,查找符合以下条件的 VarDecl:

  • 变量具有 local storage;
  • 变量的 canonical type 是 custom_any_pseudo_ref_type;
  • 初始化表达式来自目标 Get() 或 operator[];
  • 如果只禁止 auto,还要检查声明的 TypeLoc 是否包含 AutoTypeLoc。

更稳妥的规则是禁止所有具名局部 proxy,而非只检查 auto,因为显式写出类型同样有风险:

custom_any_pseudo_ref_type value = box["a"];

匹配逻辑大致如下:

varDecl(
    hasInitializer(
        ignoringParenImpCasts(
            expr(hasType(
                qualType(hasDeclaration(
                    cxxRecordDecl(
                        hasName("::custom_any_pseudo_ref_type")
                    )
                ))
            ))
        )
    )
).bind("storedProxy")

这段代码只用于说明 matcher 的结构。实际检查时,还应在回调中确认 VarDecl 的 storage duration、canonical type,以及源码采用的类型写法。不同 Clang 版本支持的 matcher 组合可能略有差异。

把该诊断作为 Clang-Tidy 错误加入 CI 后,迁移期间便可在声明处拦截以下代码:

auto p = box["a"];
custom_any_pseudo_ref_type p = box["a"];

如果还要检查 proxy 被放入 std::optional、容器、类成员、lambda capture,或者从函数继续返回的情况,就需要增加 AST 规则,甚至进行简单的数据流分析。只搜索 auto 会漏掉这些同样会延长生命周期的写法。

不适用的手段

[[nodiscard]] 只能提醒返回值被丢弃,不能阻止保存,其作用方向与这里的需求相反。

[[clang::lifetimebound]] 等生命周期诊断属性可以帮助发现部分 dangling reference,但不能用来禁止某个类型保存到局部变量中。这些属性也属于编译器扩展,诊断范围取决于具体的代码形态。

如果相关的 implicit evaluation 提案尚未纳入项目采用的 C++ 标准和编译器实现,就不能作为可移植方案。当前接口不能假设 auto 完成推导后会自动执行一次用户定义转换。

建议的迁移方式

如果 proxy 适合设计成安全句柄,应优先让它持有共享状态,消除 dangling 风险。

如果 proxy 只能用于单个完整表达式,可以删除 copy/move constructor,并将读写接口限定为 &&。这样,大部分意外保存的代码会在实际使用 proxy 时编译失败。

迁移期间还可以增加一条 Clang-Tidy 规则,禁止任何具名的局部 proxy 对象。这样既能在声明处直接报错,也能覆盖显式类型声明,无需等到后续使用时再由 deleted overload 触发错误。

备注:内容仅供参考。