如何防止 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,而是语言规则没有要求创建中间对象。
可行的处理方式有三种:
- 把 pseudo-reference 设计成可安全保存的对象,避免 dangling。
- 使用 ref-qualified 成员,禁止保存后的对象执行读写操作。
- 在迁移期间增加自定义 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 触发错误。
备注:内容仅供参考。
