【问题标题】:Moving functors into std::function's while avoiding copies将仿函数移动到 std::function\'s 同时避免复制
【发布时间】:2022-05-22 18:17:26
【问题描述】:

我正在尝试将仿函数移动到对象内的 lambda 中,如下所示:

#include <functional>
#include <iostream>

#include \"boost/stacktrace.hpp\"

#define fwd(o) std::forward<decltype(o)>(o)

struct CopyCounter {
  CopyCounter() noexcept = default;
  CopyCounter(const CopyCounter &) noexcept {
    std::cout << \"Copied at\" << boost::stacktrace::stacktrace() << std::endl;
    counter++;
  }
  CopyCounter(CopyCounter &&) noexcept = default;

  CopyCounter &operator=(CopyCounter &&) noexcept = default;
  CopyCounter &operator=(const CopyCounter &) noexcept {
    std::cout << \"Copied at \" << boost::stacktrace::stacktrace() << std::endl;
    counter++;
    return *this;
  }

  inline static size_t counter = 0;
};

struct Argument : CopyCounter {};

struct Functor : CopyCounter {
  int operator()(Argument) { return 42; }
};

template <class Result>
class Invoker {
  std::function<void()> invoke_;
  Result* result_ = nullptr;

  template <class Functor, class... Args>
  Invoker(Functor&& f, Args&&... args) {
    if constexpr (std::is_same_v<Result, void>) {
      invoke_ = [this, f = fwd(f), ... args = fwd(args)]() mutable {
        f(fwd(args)...);
      };
    } else {
      invoke_ = [this, f = fwd(f), ...args = fwd(args)]() mutable { 
        result_ = new Result(f(fwd(args)...));
      };
    }
  }
  template <class Functor, class... Args>
  friend auto make_invoker(Functor&& f, Args&&... args);

public:
  ~Invoker() {
    if (result_) delete result_;
  }
};

template <class Functor, class... Args>
auto make_invoker(Functor&& f, Args&&... args) {
  return Invoker<decltype(f(args...))>(fwd(f), fwd(args)...);
}

int main() {
  Functor f;
  Argument a;
  auto i = make_invoker(std::move(f), std::move(a));
  assert(CopyCounter::counter == 0);
  return 0;
}

有点令人惊讶的是,最后一个断言在 libc++ 上失败,但在 libstdc++ 上没有。堆栈跟踪提示执行的两个副本:

Copied at  0# CopyCounter at /usr/include/boost/stacktrace/stacktrace.hpp:?
 1# 0x00000000004C812E at ./src/csc_cpp/move_functors.cpp:38
 2# std::__1::__function::__value_func<void ()>::swap(std::__1::__function::__value_func<void ()>&) at /usr/lib/llvm-10/bin/../include/c++/v1/functional:?
 3# ~__value_func at /usr/lib/llvm-10/bin/../include/c++/v1/functional:1825
 4# __libc_start_main in /lib/x86_64-linux-gnu/libc.so.6
 5# _start in ./bin/./src/csc_cpp/move_functors

Copied at  0# CopyCounter at /usr/include/boost/stacktrace/stacktrace.hpp:?
 1# std::__1::__function::__value_func<void ()>::swap(std::__1::__function::__value_func<void ()>&) at /usr/lib/llvm-10/bin/../include/c++/v1/functional:?
 2# ~__value_func at /usr/lib/llvm-10/bin/../include/c++/v1/functional:1825
 3# __libc_start_main in /lib/x86_64-linux-gnu/libc.so.6
 4# _start in ./bin/./src/csc_cpp/move_functors

invoke_ 的移动赋值期间,似乎在库中仿函数和参数被复制到swap 中。 有两个问题:

  1. 为什么这是理想的行为?这个设计解决方案背后的动机是什么?
  2. 有什么好的方法可以更新代码以达到与 libstdc++ 中相同的语义?
  • 请注意,我已经将 Boost.Stacktrace 分类为 an independent library,不需要 Boost。此外,如果您使用 C++23,您可以使用`std::stacktrace
  • 为什么不在 Invoker 的构造函数中使用初始化列表?
  • 如果您已经在使用 lambda,为什么还要首先推出自己的仿函数类?

标签: c++ c++11 templates libstdc++ libc++


【解决方案1】:

libstdc++ 和 libc++ 使用不同的小对象优化策略。

In libc++,如果满足以下条件,则可调用对象存储在本地:

  • 可调用对象适合本地缓冲区;
  • 可调用对象不可复制;和
  • 分配器不可复制构造。

(注意:分配器支持已从 C++17 的标准中删除。但是,实际上,它不能从标准库实现中删除,至少在很长一段时间内不会,不会破坏现有代码。)

In libstdc++,如果满足以下条件,则可调用对象存储在本地:

  • 可调用对象适合本地缓冲区;
  • 可调用对象是可简单复制的类型。

(我在掩饰对齐问题,因为它在这里并不是特别相关。)

libstdc++ 对何时使用小对象优化的选择意味着无论它适用于何处,都可以通过复制为其提供存储的数组来简单地复制可调用对象。但是您的代码中的 FunctorArgument 类型不是可简单复制的,因此 lambda 闭包类型也不是。 lambda 的存储是离线分配的,并且拥有的可调用对象是从 lambda 移动构造的。此后再也不需要复制或移动它。

另一方面,libc++ 将您的可调用对象存储在小对象缓冲区中。在分配给invoke_ 期间,它必须将包含小对象缓冲区中的闭包对象的std::function 与默认构造的std::function 交换。这意味着可调用对象必须从一个std::function 实例的小对象缓冲区移动到另一个实例的小对象缓冲区。 libc++ 通过复制可调用对象然后销毁副本的源来完成此操作。

为什么 libc++ 不使用移动构造函数?显然,这是一个错误。请参阅LLVM bug 33125,这是关于移动构造函数而不是swap 函数,但适用相同的原则。当源将可调用对象存储在小对象缓冲区中时,std::function 的移动构造函数使用复制构造函数,而不是移动构造函数。原因是 libc++ 使用类型擦除接口来知道如何处理可调用对象而忘记其类型是一个名为 __base 的类,它具有用于复制可调用对象的虚函数和用于销毁可调用对象的虚函数,但是没有任何东西可以移动它——显然,添加移动支持会破坏 ABI,因此在另行通知之前无法完成。

请注意,这只是一个错误,因为它具有次优性能。从不符合标准的意义上说,这不是一个错误。 libstdc++ 和 libc++ 都有有效的实现策略。该标准没有说明允许复制多少次可调用对象。

最好的办法是更新您的代码,使其正确性不取决于可调用对象被复制的次数。如果实在无法承担复制的成本而需要用libc++构建,还有一些其他的策略比如:

  • 填充可调用对象(通过捕获足够大小的虚拟对象)使其不适合小对象缓冲区;
  • 使可调用对象的复制构造函数noexcept(false)(通过使用noexcept(false) 复制构造函数捕获对象)使其不会进入小对象缓冲区;或者
  • std::function 中存储一个引用语义包装类,该类拥有实际可调用的std::shared_ptr 到实际可调用类型,operator() 转发到实际可调用对象。

【讨论】:

    【解决方案2】:

    部分答案:

    如果您使用初始化列表初始化invoke_

    class Invoker {
      std::function<void()> invoke_;
      int result_;
    
    public:
      template <class Functor, class... Args>
      Invoker(Functor&& f, Args&&... args) :
        invoke_([this, f = fwd(f), ...args = fwd(args)]() mutable { 
          result_ = f(fwd(args)...);
        }) { }
    };
    

    断言does not fail。所以,我猜交换是​​在默认初始化的invoke_ 和你分配给invoke_ 的函子之间(的字段)。

    【讨论】:

    • 谢谢,如果您不在此作业的正文中使用if constexpr,这会很好用(遗憾的是,我的情况)。也许人们仍然可以通过一些小技巧来解决这个问题
    • @MikeLand:我不确定我是否遵循......什么的“身体”? “这个任务”你的意思是对结果的任务?您想对作业施加什么条件?
    • 我已经更新了问题以反映我的意思
    • @MikeLand 我建议将初始化委托给另一个构造函数并使用标签调度来区分void 和非void 情况。
    • @MikeLand:我认为事后更改问题有点不合适,使我的答案无效。也就是说——你可以按照布赖恩的建议去做;或者您可以将 if constexpr 放在 lambda 体内。
    猜你喜欢
    • 2012-07-30
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-09-15
    相关资源
    最近更新 更多