公司动态
C++11/14 实现高效安全的 std::function 替代方案:类型擦除与小对象优化
1. 项目概述为什么我们需要重写 std::function在 C 项目里摸爬滚打十几年std::function绝对是个让人又爱又恨的家伙。爱它是因为它提供了一个统一、类型擦除的容器来包装任何可调用对象——普通函数、成员函数、Lambda 表达式、仿函数重载了operator()的结构体/类让回调、事件系统、策略模式的实现变得异常优雅。恨它则是因为在性能敏感或资源受限的场景下它的开销有时会让人头疼潜在的堆内存分配、可能发生的多次拷贝以及在 C11/14 时代对移动语义支持的不确定性。这个项目的标题——“C/C 11/14重写一个支援安全、不多次复制 std::function... 函数模板”——直接戳中了这些痛点。它的目标非常明确亲手打造一个属于我们自己的、更高效、更可控的可调用对象包装器。这里的“安全”和“不多次复制”是核心诉求。安全意味着我们要正确处理对象的生命周期避免悬垂引用和内存泄漏不多次复制则要求我们在构造、赋值和传递过程中尽可能地利用移动语义减少甚至消除不必要的深拷贝这对于包装大型捕获列表的 Lambda 或内部有动态资源的仿函数至关重要。为什么要用 C11/14 来重写这是一个非常务实的定位。C11 是一次语言革命带来了右值引用、移动语义、可变参数模板、完美转发等关键特性正是实现高效、安全包装器的基石。C14 则提供了一些便利的补充。限定在这个版本意味着我们的实现具有更广泛的兼容性可以在许多尚未升级到 C17/20 的生产环境中直接使用。同时这也迫使我们去深入理解这些现代 C 特性的本质而不是简单地调用标准库的“黑盒”。这个自制的函数模板需要完整支援标题中提到的三种可调用实体C/C语言普通函数这是基础也是最简单的类型。Lambda λ 表达式现代 C 的明星本质是匿名仿函数但可能包含捕获的状态。重载 operator()(...) 的运算结构即传统的仿函数Functor。通过这个项目我们不仅能获得一个趁手的工具更能深入理解类型擦除Type Erasure、小对象优化Small Object Optimization, SOO、完美转发Perfect Forwarding等高级技术的内幕这对于提升 C 内功至关重要。接下来我们就从设计思路开始一步步拆解如何实现它。2. 核心设计思路与架构拆解要造一个轮子尤其是替代std::function这样的轮子不能一上来就写代码。我们必须先想清楚几个关键问题如何存储不同类型的可调用对象如何保证效率接口应该长什么样2.1 类型擦除统一外衣下的多态std::function的核心魔法是类型擦除。它对外提供一个统一的类型如std::functionint(int, int)但内部可以保存任何满足签名int(int, int)的可调用对象。这很像面向对象中的基类指针指向派生类对象。我们的实现也将采用这个经典模式。我们会定义一个抽象基类CallableBase它声明了调用操作和克隆用于拷贝的虚函数接口。然后针对每一种被包装的具体可调用类型我们派生一个特化类CallableImplCallableType在这个派生类里保存具体类型的对象并实现虚函数。// 抽象基类负责定义操作接口 template class CallableBase { public: virtual ~CallableBase() default; virtual R operator()(Args... args) 0; // 纯虚调用函数 virtual std::unique_ptrCallableBase clone() const 0; // 纯虚克隆函数用于拷贝构造 }; // 具体实现类保存实际的可调用对象 template class CallableImpl : public CallableBase { private: F callable_; // 这里保存着实际的函数、Lambda或仿函数对象 public: explicit CallableImpl(F func) : callable_(std::forwardF(func)) {} R operator()(Args... args) override { return callable_(std::forwardArgs(args)...); // 完美转发参数进行调用 } std::unique_ptrCallableBaseR(Args...) clone() const override { // 调用 F 类型的拷贝构造函数来复制内容 return std::make_uniqueCallableImpl(callable_); } };通过这种方式Function对象内部只需要持有一个CallableBase的智能指针。无论实际存储的是函数指针、Lambda 还是复杂的仿函数对外都只有CallableBase这一层接口类型信息被“擦除”了。注意这里使用std::unique_ptr来管理生命周期保证了异常安全并明确了所有权。clone()方法返回std::unique_ptr这是实现深拷贝拷贝构造/赋值的关键。2.2 小对象优化将性能握在手中std::function的实现通常包含一种重要的优化小对象优化。其思想是如果被包装的对象很小例如一个无捕获的 Lambda 或一个函数指针就直接将其存储在std::function自身的缓冲区一个大小固定的字符数组中避免在堆上分配内存。只有当对象太大缓冲区放不下时才退回到堆分配。这是实现“高效”和“不多次复制”的关键一环。堆分配不仅慢涉及系统调用还可能引发内存碎片。我们的目标就是尽可能利用栈上或对象自身内部的空间。我们将设计一个内部联合体Union或一个带缓冲区的类例如一个std::aligned_storage缓冲区。在构造时判断传入的可调用对象大小如果sizeof(CallableType) BufferSize则使用 placement new 将其构造在内部缓冲区上。如果对象太大则在堆上创建CallableImpl对象并用指针管理。这个BufferSize是个需要权衡的参数通常为sizeof(void*) * 2或sizeof(void*) * 3在64位系统上就是16或24字节这能覆盖绝大多数函数指针和简单Lambda。template class Function { private: static constexpr size_t BufferSize 32; // 示例缓冲区大小可根据平台调整 using BasePtr std::unique_ptrCallableBaseR(Args...); // 类型擦除的调用器指针 BasePtr callable_; // 内部缓冲区用于小对象优化 std::aligned_storage_tBufferSize buffer_; bool isOnHeap() const { return callable_ ! nullptr; } // ... 其他辅助方法用于在buffer_上构造/销毁对象 };2.3 完美转发与引用折叠打造灵活的接口为了支持“不多次复制”我们必须充分利用移动语义。构造函数和赋值运算符应该接受万能引用并结合std::forward进行完美转发。template class Function { public: // 默认构造 Function() default; // 万能引用构造函数支持任意可调用对象 templatetypename F, typename std::enable_if_t!std::is_same_vstd::decay_tF, Function Function(F func) { init(std::forwardF(func)); } // 拷贝构造需要深拷贝 Function(const Function other) { if (other) { callable_ other.callable_-clone(); } } // 移动构造高效转移资源 Function(Function other) noexcept { swap(other); } // 赋值运算符拷贝赋值和移动赋值 Function operator(Function other) noexcept { // 注意按值传递 swap(other); return *this; } // 调用运算符 R operator()(Args... args) const { if (!callable_) { throw std::bad_function_call(); } return (*callable_)(std::forwardArgs(args)...); } private: templatetypename F void init(F func) { using CallableType std::decay_tF; // 判断是否适用小对象优化 if constexpr (sizeof(CallableImplCallableType) BufferSize std::is_nothrow_move_constructible_vCallableType) { // 使用 placement new 在 buffer_ 上构造 CallableImpl new (buffer_) CallableImplCallableType(std::forwardF(func)); // 设置一个特殊的 callable_ 指针指向 buffer_ 中的对象 // 这里需要一个更精巧的设计来统一管理堆上和缓冲区的对象。 } else { // 堆分配 callable_ std::make_uniqueCallableImplCallableType(std::forwardF(func)); } } };实操心得拷贝赋值运算符这里采用了“拷贝并交换”的惯用法。参数Function other是按值传递的。如果外部传入的是左值会触发拷贝构造生成other如果传入的是右值会触发移动构造生成other。然后在函数体内简单地交换*this和other的内容。这种方法代码简洁且提供了强异常安全保证。3. 核心实现细节与关键技术点有了顶层设计我们深入到具体的实现细节。这里有几个“魔鬼”需要对付。3.1 统一管理堆上与缓冲区对象小对象优化的一个设计难点是如何用同一个指针callable_来统一管理堆上分配的对象和缓冲区中构造的对象。一个常见的技巧是让CallableBase包含一个虚函数用于在对象销毁时正确地释放资源对于缓冲区对象调用析构函数即可对于堆对象需要delete this。另一种更简洁的方法是定义两个不同的CallableBase派生类一个用于堆对象HeapCallableImpl一个用于栈对象StackCallableImpl但它们都继承自同一个CallableBase接口。callable_指针可以指向任何一个。这里我们采用一种更直接的方法使用一个自定义的删除器。我们的callable_是std::unique_ptrCallableBase, Deleter其中Deleter是一个可调用对象它知道如何销毁目标。template class Function { private: // 自定义删除器知道对象是在堆上还是缓冲区上 struct Deleter { void operator()(CallableBaseR(Args...)* ptr) const { if (onHeap) { delete ptr; // 堆对象直接 delete } else { ptr-~CallableBase(); // 缓冲区对象只调用析构函数 } } bool onHeap true; }; using BasePtr std::unique_ptrCallableBaseR(Args...), Deleter; BasePtr callable_; std::aligned_storage_tBufferSize buffer_; // ... init 函数需要根据存储位置设置 Deleter 的 onHeap 标志位 };在init函数中如果决定存储在缓冲区我们需要在buffer_上 placement new 构造CallableImpl对象。获取该对象的指针p。构造一个BasePtr用指针p和Deleter{false}初始化它。这个智能指针被设置为使用自定义删除器且标记为“不在堆上”。这种方法将存储位置的差异完全封装在Deleter和unique_ptr的用法里Function的其他部分可以像对待普通指针一样对待callable_。3.2 支持空状态与布尔转换一个健壮的Function应该支持空状态就像std::function一样。我们可以通过检查内部指针是否为空来实现。template class Function { public: // 转换为 bool判断是否持有可调用对象 explicit operator bool() const noexcept { return static_castbool(callable_); } // 与 nullptr 比较 bool operator(std::nullptr_t) const noexcept { return !callable_; } bool operator!(std::nullptr_t) const noexcept { return callable_; } // 重置为空 void reset() noexcept { callable_.reset(); // 注意如果对象在缓冲区reset() 会通过我们的 Deleter 正确调用析构函数 } };3.3 处理成员函数指针与 std::bind标题要求支持普通函数、Lambda 和仿函数。但一个更通用的实现可能还会考虑成员函数指针和std::bind的返回值。这两者本质上也是可调用对象。成员函数指针其调用需要绑定一个对象实例obj.*ptr或obj-*ptr。我们可以不直接支持因为用户很容易用 Lambda[obj](auto... args){ return (obj.*ptr)(std::forwarddecltype(args)(args)...); }来包装它。这是一种更现代、更灵活的方式。std::bind它返回一个未指定类型的仿函数对象。我们的模板构造函数接受万能引用可以完美地接受并存储std::bind的返回结果因为该结果本身就是一个重载了operator()的对象。所以我们的核心设计已经隐式支持了这些情况无需特殊处理。注意事项std::bind返回的对象类型通常是编译器特定的、复杂的且可能比较大。这正好考验了我们小对象优化的能力。如果它太大就会落到堆分配上这是符合预期的。4. 完整实现与代码剖析让我们将上述思路整合成一个初步的、可工作的完整实现。为了清晰我们暂时省略一些极端情况的处理如noexcept规范的全方位应用聚焦于主体逻辑。#include memory #include type_traits #include utility templatetypename class Function; // 前置声明 templatetypename R, typename... Args class FunctionR(Args...) { private: // 抽象基类 class CallableBase { public: virtual ~CallableBase() default; virtual R invoke(Args... args) const 0; virtual std::unique_ptrCallableBase clone() const 0; }; // 具体实现模板类 templatetypename F class CallableImpl final : public CallableBase { F f_; public: explicit CallableImpl(F f) : f_(std::forwardF(f)) {} explicit CallableImpl(const F f) : f_(f) {} R invoke(Args... args) const override { return f_(std::forwardArgs(args)...); } std::unique_ptrCallableBase clone() const override { return std::make_uniqueCallableImpl(f_); } }; // 自定义删除器用于区分堆对象和栈对象 struct Deleter { void operator()(CallableBase* p) const noexcept { if (onHeap) { delete p; } else { p-~CallableBase(); } } bool onHeap true; }; using BasePtr std::unique_ptrCallableBase, Deleter; static constexpr size_t BufferSize sizeof(void*) * 4; // 例如32字节64位系统 // 存储要么在堆上callable_管理要么在缓冲区里callable_指向缓冲区 union { std::aligned_storage_tBufferSize buffer_; // 仅为对齐无实际数据成员 }; BasePtr callable_; // 辅助函数判断类型F是否适合放入缓冲区 templatetypename F static constexpr bool fits_in_buffer sizeof(CallableImplF) BufferSize alignof(CallableImplF) alignof(decltype(buffer_)) std::is_nothrow_move_constructible_vF; // 要求在缓冲区移动时不抛异常 // 初始化函数核心分发逻辑 templatetypename F void init(F f) { using DecayedF std::decay_tF; if constexpr (fits_in_bufferDecayedF) { // 小对象优化在缓冲区构造 auto* p new (buffer_) CallableImplDecayedF(std::forwardF(f)); // 用指向缓冲区的指针和“非堆”删除器构造 unique_ptr callable_ BasePtr(p, Deleter{false}); } else { // 堆分配 callable_ std::make_uniqueCallableImplDecayedF(std::forwardF(f)); // 默认删除器 onHeap true } } // 清理缓冲区如果对象存在且存储在缓冲区 void cleanup_buffer_if_used() noexcept { if (callable_ !callable_.get_deleter().onHeap) { // 此时 callable_.reset() 会通过 Deleter{false} 调用析构函数但指针本身不是new出来的。 // 我们需要手动调用 reset让 unique_ptr 用正确的删除器执行析构。 callable_.reset(); // 这会调用 p-~CallableBase() } } public: // 默认构造函数 Function() noexcept default; // 万能引用构造函数 templatetypename F, typename std::enable_if_t!std::is_same_vstd::decay_tF, Function Function(F f) { init(std::forwardF(f)); } // 拷贝构造函数 Function(const Function other) { if (other.callable_) { callable_ other.callable_-clone(); // 克隆返回的是堆上新对象所以删除器需要是 onHeap true // 但 clone() 返回的是 unique_ptrCallableBase没有自定义删除器。 // 我们需要将其转换为我们的 BasePtr。 // 因此需要修改 clone() 的返回类型或者在这里重新包装。 // 简化处理假设克隆总是产生堆对象对于缓冲区对象克隆到堆也是合理的。 callable_ BasePtr(other.callable_-clone().release()); } } // 移动构造函数 Function(Function other) noexcept { swap(other); } // 析构函数 ~Function() { cleanup_buffer_if_used(); // callable_ 的析构函数会自动调用相应的删除器 } // 拷贝赋值运算符采用拷贝并交换惯用法 Function operator(const Function other) { Function temp(other); swap(temp); return *this; } // 移动赋值运算符 Function operator(Function other) noexcept { cleanup_buffer_if_used(); callable_.reset(); swap(other); return *this; } // 通用赋值运算符从任意可调用对象赋值 templatetypename F, typename std::enable_if_t!std::is_same_vstd::decay_tF, Function Function operator(F f) { // 清理当前内容 cleanup_buffer_if_used(); callable_.reset(); // 初始化新内容 init(std::forwardF(f)); return *this; } // 交换 void swap(Function other) noexcept { // 交换 buffer_ 的内容和 callable_ 指针。 // 注意直接交换 buffer_ 内存和 callable_ 是危险的因为删除器里记录了存储位置。 // 一个更安全但低效的做法是交换两个 Function 对象的整个状态通过创建一个临时对象。 // 这里为了简化我们采用一个朴素的实现但要求两个对象都不使用缓冲区或者交换逻辑非常复杂。 // 实际上许多实现包括 libstdc 的 std::function在交换涉及小对象优化时也可能不是 noexcept。 // 对于学习目的我们可以先提供一个基础版本假设交换总是安全的或者要求用户知晓其限制。 std::swap(buffer_, other.buffer_); std::swap(callable_, other.callable_); // 更健壮的实现需要同时交换删除器的状态这超出了示例的简洁性范围。 // 因此一个生产级的实现会非常复杂。此处我们标记 swap 为可能抛异常。 } // 调用运算符 R operator()(Args... args) const { if (!callable_) { throw std::bad_function_call(); } return callable_-invoke(std::forwardArgs(args)...); } // 布尔转换 explicit operator bool() const noexcept { return static_castbool(callable_); } // 重置为空 void reset() noexcept { cleanup_buffer_if_used(); callable_.reset(); } }; // 特化 swap 函数 templatetypename R, typename... Args void swap(FunctionR(Args...) lhs, FunctionR(Args...) rhs) noexcept(noexcept(lhs.swap(rhs))) { lhs.swap(rhs); }这段代码勾勒出了自制Function的核心骨架。它实现了类型擦除、小对象优化、移动语义和完美转发。当然一个工业强度的实现还需要处理更多细节比如保证swap的异常安全性、提供target()和target_type()成员函数、更精细的noexcept规范等。5. 使用示例与性能对比让我们看看这个自制的Function如何工作并和std::function做个简单对比。5.1 基本用法示例#include iostream #include functional // 用于对比 // 1. 普通函数 int add(int a, int b) { return a b; } // 2. Lambda 表达式 auto multiply [](int a, int b) { return a * b; }; // 3. 仿函数重载 operator() 的结构体 struct Subtractor { int operator()(int a, int b) const { return a - b; } }; int main() { // 使用自制的 Function Functionint(int, int) myFunc; myFunc add; std::cout myFunc add: myFunc(10, 5) std::endl; // 输出 15 myFunc multiply; std::cout myFunc multiply: myFunc(10, 5) std::endl; // 输出 50 myFunc Subtractor{}; std::cout myFunc subtract: myFunc(10, 5) std::endl; // 输出 5 // 支持移动语义 auto bigLambda [data std::vectorint(100)] (int x) mutable - int { data.push_back(x); return data.size(); }; Functionint(int) movedFunc std::move(bigLambda); // 这里应该触发移动构造避免拷贝vector // 与 std::function 对比使用 std::functionint(int, int) stdFunc add; std::cout std::function: stdFunc(10, 5) std::endl; return 0; }5.2 性能考量与小对象优化验证我们可以编写一个简单的测试来观察小对象优化的效果。思路是创建一个捕获了大量数据的 Lambda大到超过缓冲区和另一个小的 Lambda无捕获或捕获很少分别用自制Function和std::function包装并观察其构造、拷贝和移动的开销。#include chrono #include iostream struct BigData { char buffer[1024]; // 1KB 数据 }; void performanceTest() { using namespace std::chrono; const int iterations 1000000; // 小Lambda无捕获应该触发小对象优化 auto smallLambda [] (int x) { return x * x; }; // 大Lambda捕获大对象应触发堆分配 BigData big; auto bigLambda [big] (int x) { return x static_castint(big.buffer[0]); }; // 测试自制 Function 对小Lambda的构造 auto start high_resolution_clock::now(); for (int i 0; i iterations; i) { Functionint(int) f(smallLambda); // 希望是栈存储 volatile auto dummy f(i); // 防止被优化掉 (void)dummy; } auto end high_resolution_clock::now(); auto myFuncSmallTime duration_castmicroseconds(end - start).count(); // 测试 std::function 对小Lambda的构造 start high_resolution_clock::now(); for (int i 0; i iterations; i) { std::functionint(int) f(smallLambda); volatile auto dummy f(i); (void)dummy; } end high_resolution_clock::now(); auto stdFuncSmallTime duration_castmicroseconds(end - start).count(); std::cout Small Lambda Construction (1M times):\n; std::cout My Function: myFuncSmallTime us\n; std::cout std::function: stdFuncSmallTime us\n; std::cout Ratio (My/std): static_castdouble(myFuncSmallTime) / stdFuncSmallTime std::endl; // 类似地测试大Lambda和拷贝/移动操作... }实操心得在实际测试中你会发现对于小对象自制Function的性能可能与std::function相当甚至略优因为我们的缓冲区策略可能更激进或更简单。对于大对象性能差异主要在于内存分配器的效率。std::function的实现通常经过高度优化并且可能使用平台特定的内存分配技巧。我们自制的版本在通用性上可能稍逊但透明性和可控性是最大优势——你知道每一个字节是怎么用的也能根据特定场景调整缓冲区大小。6. 常见问题、陷阱与排查技巧在实现和使用这样的包装器时会遇到不少坑。这里记录一些典型问题和解决思路。6.1 对象生命周期管理这是最核心的安全问题。我们的Function存储了用户传入的可调用对象副本通过值捕获或移动。这意味着按值存储我们拥有对象的独立副本生命周期由Function管理最安全。按引用捕获的 Lambda如果 Lambda 通过引用[]捕获了局部变量然后将这个 Lambda 存入一个生命周期更长的Function中就会导致悬垂引用。这是用户的责任我们的模板无法检测。一个好的实践是在文档中强烈建议优先使用值捕获除非你非常清楚引用对象的生命周期。// 危险示例 Functionvoid() dangerousFunc; { int localVar 42; auto lambda []() { std::cout localVar; }; // 捕获局部变量引用 dangerousFunc lambda; } // localVar 离开作用域被销毁 dangerousFunc(); // 未定义行为访问已销毁的 localVar6.2 异常安全我们的实现需要保证基本的安全。构造函数、赋值运算符等应提供基本的异常安全保证。拷贝构造/赋值如果F类型的拷贝构造函数可能抛异常那么我们的CallableImplF构造也可能抛异常。这会导致Function对象处于一个有效但未指定的状态通常是空状态。我们使用了std::make_unique它保证了在内存分配失败或对象构造失败时不会泄漏资源。移动操作我们尽可能将移动构造函数和移动赋值运算符标记为noexcept这有助于标准库容器如std::vector在重分配时使用更高效的移动而非拷贝。小对象优化中的异常我们在判断fits_in_buffer时加入了std::is_nothrow_move_constructible_vF的条件。这是为了确保在缓冲区上移动构造对象时不会抛异常否则如果在缓冲区构造中途失败清理工作会非常棘手。6.3 与 std::function 的细微差异target()和target_type()std::function提供了这两个成员函数来获取存储对象的指针和类型信息。我们的实现没有提供。如果需要可以在CallableBase中增加一个virtual const std::type_info target_type() const接口并在CallableImpl中返回typeid(F)。target()则可以通过dynamic_cast尝试向下转型。分配器支持std::function的构造函数可以接受一个分配器参数用于控制堆内存的分配。我们的简易实现使用了默认的operator new和std::make_unique。要支持分配器需要将分配器类型作为模板参数传递下去并在堆分配时使用它这会大大增加复杂度。noexcept规范std::function的operator()是否noexcept取决于存储的可调用对象。我们的实现中invoke是虚函数调用很难在编译时确定。我们可以选择不标记noexcept或者提供一个noexcept版本并在运行时检查代价高。6.4 调试技巧如果在使用自制Function时遇到崩溃比如调用空函数、段错误可以按以下步骤排查检查是否为空在调用前使用if (myFunc) { ... }或assert(myFunc)进行检查。生命周期检查被包装的 Lambda 或仿函数是否捕获了已经失效的引用或指针。类型匹配确保Function的签名R(Args...)与实际可调用对象的签名完全匹配。包括const、volatile、引用、noexcept修饰符。我们的模板构造函数使用了完美转发如果签名不兼容会在编译时报错这是好事。小对象优化边界如果你怀疑是缓冲区溢出或错误的内存管理可以临时将BufferSize设为一个很大的值比如 1024强制所有对象都使用堆分配看问题是否消失。这能帮助定位问题是否出在缓冲区的 placement new 或删除逻辑上。通过这个从零重写std::function的项目我们不仅得到了一个定制化的工具更重要的是深入理解了现代 C 中类型擦除、内存管理、移动语义和模板元编程的协同工作方式。这种理解是单纯使用标准库无法获得的深度。最终你可以根据自己项目的具体需求调整缓冲区大小、异常安全策略甚至添加新的特性让它真正成为你代码库中高效、安全的一员。