
从 Function 理解类型擦除与拷贝移动
阅读 stl1weekend 的 _Function.hpp 时,我想弄清两个问题:_FuncBase 和 _FuncImpl 如何协作,让 Function 保存不同类型的仿函数?隐藏了具体类型后,Function 又如何实现拷贝和移动?
接口与具体实现
Function 按函数签名特化,例如 Function<int(int)>。同一个签名可以对应许多不同类型的可调用对象,因此包装器不把仿函数类型作为自己的模板参数,而是通过基类指针持有它:
// 摘取并简化自原实现
struct _FuncBase {
virtual _Ret _M_call(_Args... args) = 0;
virtual std::unique_ptr<_FuncBase> _M_clone() const = 0;
virtual std::type_info const& _M_type() const = 0;
virtual ~_FuncBase() = default;
};
template<class _Fn>
struct _FuncImpl : _FuncBase {
_Fn _M_f;
// 实现调用、克隆和类型查询
};
std::unique_ptr<_FuncBase> _M_base;_FuncBase 定义类型擦除后的统一接口;_FuncImpl<_Fn> 知道具体的 _Fn,并真正保存仿函数 _M_f。因此,两个不同 lambda 即使具有不同类型,也可以分别包装在不同的 _FuncImpl 中,再由相同类型的 Function<int(int)> 持有。
例如,下面三个对象都能以 int(int) 的形式调用,但它们的具体类型不同:
int twice(int x) { return x * 2; }
struct Add {
int amount;
int operator()(int x) const { return x + amount; }
};
auto lambda = [offset = 3](int x) { return x + offset; };
auto pointer = &twice;
Add add{5};
Function<int(int)> a(lambda); // 保存 lambda 的闭包对象
Function<int(int)> b(pointer); // 保存函数指针
Function<int(int)> c(add); // 保存 Add 仿函数对象
// a(2) == 5,b(2) == 4,c(2) == 7这里 lambda 的类型是编译器为该 lambda 表达式生成的闭包类型,pointer 的类型是 int (*)(int),add 的类型是 Add。三个变量都是左值,因此构造函数中的转发引用模板参数 _Fn 分别推导为这些类型的引用;源码用 std::decay_t<_Fn> 去掉引用后,实例化对应的 _FuncImpl,并将对象或指针的副本保存在 _M_f 中。lambda 和 Add 是带 operator() 的函数对象,普通函数指针则是另一种可调用对象;下文在涉及通用情况时也将它们称为“目标对象”。
类型被擦除的意思是:Function 的外层代码不需要静态地知道 _Fn,但对象内部并没有丢失具体类型。调用时,Function::operator() 通过 _M_base->_M_call(...) 进行虚函数分派,最终进入对应的 _FuncImpl<_Fn>,由它用 std::invoke 调用 _M_f。同样,具体实现还可以通过 _M_type() 提供类型信息。
std::in_place_t 标识内部构造
_FuncImpl 使用一个带标签的转发构造函数,直接用传入参数初始化成员 _M_f:
// 摘自原实现
template<class... _CArgs>
explicit _FuncImpl(std::in_place_t, _CArgs&&... args)
: _M_f(std::forward<_CArgs>(args)...) {}std::in_place_t 是标签类型,std::in_place 是它的标签对象。第一个参数只用于选择并标识这个构造函数;后面的参数才转发给 _Fn 的构造函数。例如,创建目标和克隆目标分别使用:
// 源码的两条路径,略去外围代码
std::make_unique<_FuncImpl<std::decay_t<_Fn>>>(
std::in_place, std::forward<_Fn>(f)); // 用传入的可调用对象构造 _M_f
std::make_unique<_FuncImpl>(std::in_place, _M_f); // 克隆时拷贝 _M_f标签区分包装与拷贝
包装类的转发构造函数可能接收包装类自身,与拷贝构造函数产生重载竞争。例如,一个没有标签或约束的 template<class U> Wrapper(U&&),遇到非 const 的 Wrapper 左值时可推导出 U = Wrapper&,其参数 Wrapper& 可能比拷贝构造函数的 const Wrapper& 更匹配。结果是选中了转发构造函数,尝试用整个包装器构造成员;这称为转发构造函数劫持拷贝构造,并不一定表现为编译器报告“歧义”。
std::in_place 给“构造内部对象”一条明确的路径:Wrapper(std::in_place, value) 与 Wrapper(other) 的参数列表不同,后者不会误选这个带标签的构造函数。对于能够把另一个包装器作为内部目标的设计,这也让调用者能明确表达意图。
cppreference 对 std::in_place 的解释称它是 “disambiguation tag”(用于区分构造意图的标签),用来表明所含对象应原位构造。因此,区分包装和拷贝是它在包装类中的一个用途;它的含义更一般,是显式选择原位构造。源码中的 _FuncImpl 利用标签区分内部目标的构造与 _FuncImpl 自身的拷贝、移动;克隆路径传入的也是内部 _M_f,没有把整个包装器作为目标重新包装。
另外,外层 Function 的转发构造函数通过 !std::is_same_v<std::decay_t<_Fn>, Function<_Ret(_Args...)>> 排除了 Function 自身,这也是为了让包装器自身的拷贝/移动走对应的特殊成员函数;它与 _FuncImpl 的 in_place 标签分别作用在不同层次。
为什么构造参数使用新的模板形参
_Fn 是类模板参数,决定成员 _M_f 的类型;_CArgs... 是构造函数模板参数,在每次构造时推导,用于正确实现转发引用。例如,确定 _FuncImpl<Add> 时 _Fn 已固定为 Add,而传入的目标对象仍可能是左值或右值。
// 这样写不是转发引用
explicit _FuncImpl(std::in_place_t, _Fn&& args);除了这些带 std::in_place 的构造函数模板特化,类还可能有编译器隐式声明的拷贝和移动构造函数;它们构造的是整个 _FuncImpl,与带标签、直接构造内部 _M_f 的路径不同。构造函数模板本身不是拷贝或移动构造函数。
拷贝通过虚接口回到具体类型
_M_base 是 unique_ptr,不能直接复制;而只复制一个原始地址也会让两个包装器指向同一个对象,无法得到各自独立的目标对象。这里的解决办法是让基类声明虚函数 _M_clone(),再由知道 _Fn 的派生类完成复制:
// 摘自原实现
std::unique_ptr<_FuncBase> _M_clone() const override {
return std::make_unique<_FuncImpl>(std::in_place, _M_f);
}
Function(Function const& that)
: _M_base(that._M_base ? that._M_base->_M_clone() : nullptr) {}拷贝非空 Function 时,虚函数分派找到正确的 _FuncImpl<_Fn>::_M_clone(),后者在新分配的 _FuncImpl 中用原来的 _M_f 拷贝构造一个目标对象。空包装器则拷贝为空。
这里的独立是指两个包装器持有不同的仿函数对象;如果仿函数自己保存共享指针等共享状态,复制后是否共享该状态仍由仿函数的拷贝语义决定。
为什么要求可拷贝
这份 Function 提供值语义的拷贝构造:拷贝包装器时,也要复制其中的目标对象。因此 _M_clone() 必须能够从原来的 _M_f 创建副本,目标类型需要支持拷贝构造。源码的模板构造函数也包含 std::is_copy_constructible_v<_Fn> 约束,意在筛掉不可拷贝的对象。
这意味着,即使传入一个右值可以在初次构造时移动它,也不能据此让只可移动、不可拷贝的仿函数满足这个包装器的要求。例如,按值捕获 std::unique_ptr 的 lambda 通常只能移动,不能作为这份实现的目标。
有一个实现细节:源码检查的是 _Fn,实际保存的是 std::decay_t<_Fn>。传入左值时 _Fn 可能被推导成引用类型,所以这个检查并不总能准确表达“保存的类型可拷贝”;最终 _M_clone() 仍需要实际目标类型可拷贝。更直接表达这一要求的写法是检查 std::is_copy_constructible_v<std::decay_t<_Fn>>。
同一包装器类型的统一接口
Function<int(int)> 持有类型擦除后的指针,具体目标的信息需要通过运行时接口获取;而拷贝、移动构造函数是否可用须在编译期确定,因此无法根据每个对象当前保存的目标类型选择性地启用这些接口。std::function 选择提供拷贝和移动,要求目标可拷贝;C++23 的 std::move_only_function 选择只提供移动,也可以保存只可移动的目标。
与 vector 的元素类型要求不同
std::vector<T> 的 T 是容器类型的一部分,例如 vector<A> 和 vector<B> 是不同类型;Function<int(int)> 的目标类型却被擦除,不体现在包装器类型中。对 vector<T>,还需要分别看具体操作对 T 提出的要求:某些操作可以用于不可拷贝、不可移动的 T,不意味着所有操作都可用。
例如,对可默认构造但不可拷贝、不可移动的 T,可以使用 std::vector<T> v(3) 直接默认构造三个元素。相反,reserve 的标准前置条件要求 T 满足 Cpp17MoveInsertable;重分配需要在新存储中构造已有元素。不能简单理解成“容器自动提供一个不扩容的版本”,也不能假定容量足够时任意插入都可用:每个操作有自己的类型要求和前置条件。Cpp17MoveInsertable 是容器与分配器相关的要求,也不等同于必须声明移动构造函数;适用时从右值复制同样可能满足它。
因此,这里比较的是两件事:Function 是否允许某个目标进入一个承诺可复制的类型擦除包装器,以及 vector<T> 的某个具体操作对已知的元素类型有什么要求。