Clang 实现 std::function 移动语义背后的推理

Gre*_*ape 31 c++ clang std-function libc++ c++20

libc++的实现中std::function,如果要擦除其类型的函数对象足够小以适合 SBO,则移动操作将复制它,而不是移动它。然而,并不是每个堆栈内存占用较小的对象都适合复制。为什么要复制而不是移动?

使用 Clang 考虑这个示例(使用shared_ptr它是因为它具有引用计数):

https://wandbox.org/permlink/9oOhjigTtOt9A8Nt

中的语义与使用显式副本test1()的语义相同。帮助我们看到这一点。test3()shared_ptr

另一方面,GCC 的行为是合理且可预测的:

https://wandbox.org/permlink/bYUDDr0JFMi8Ord6

两者都是标准允许的。std::function要求函数可复制,移出的对象处于未指定状态,等等。为什么要这么做?同样的推理也适用于std::map:如果键和值都是可复制的,那么为什么不每当有人std::movesa时就制作一个新副本std::map?这也符合标准的要求。

根据cppreference.com 的说法,应该有一个举动,并且应该是目标。

这个例子:

#include <iostream>
#include <memory>
#include <functional>
#include <array>
#include <type_traits>

void test1()
{
    /// Some small tiny type of resource. Also, `shared_ptr` is used because it has a neat
    /// `use_count()` feature that will allow us to see what's going on behind the 'curtains'.
    auto foo = std::make_shared<int>(0);

    /// Foo is not actually a trivially copyable type. Copying it may incur a huge overhead.
    /// Alas, per-C++23 we don't have a pleasure of `move_only_function`, 
    /// so 'staying standard' we're stuck with the std::function.
    static_assert(!std::is_trivially_copyable_v<std::decay_t<decltype(foo)>>);
    static_assert(!std::is_trivially_copy_constructible_v<std::decay_t<decltype(foo)>>);

    std::cout << std::endl;
    std::cout << "Test 1: tiny function that is probably stored in SBO" << std::endl;
    std::cout << "Ref count: " << foo.use_count() << std::endl;
    
    std::function<void()> f = [foo] {
        /// Do stuff.  
    };

    std::cout << "Ref count: " << foo.use_count() << std::endl;

    {
        auto g = std::move(f);

        /// Underlying, type-erased data is actually copied not moved
        std::cout << "Ref count: " << foo.use_count() << std::endl;
    }

    std::cout << "Ref count: " << foo.use_count() << std::endl;
}

void test2()
{
    auto foo = std::make_shared<int>(0);

    std::cout << std::endl;
    std::cout << "Test 2: non-tiny function that doesn't fit in SBO" << std::endl;
    std::cout << "Ref count: " << foo.use_count() << std::endl;
    
    std::function<void()> f = [foo, bar = std::array<char, 1024>()] {
        (void)bar;
        /// Do stuff.
    };

    std::cout << "Ref count: " << foo.use_count() << std::endl;

    {
        auto g = std::move(f);

        std::cout << "Ref count: " << foo.use_count() << std::endl;
    }

    std::cout << "Ref count: " << foo.use_count() << std::endl;
}

void test3()
{
    auto foo = std::make_shared<int>(0);

    std::cout << std::endl;
    std::cout << "Test 3: tiny function but using a copy" << std::endl;
    std::cout << "Ref count: " << foo.use_count() << std::endl;
    
    std::function<void()> f = [foo] {
        /// Do stuff.  
    };

    std::cout << "Ref count: " << foo.use_count() << std::endl;

    {
        auto g = f;

        std::cout << "Ref count: " << foo.use_count() << std::endl;
    }

    std::cout << "Ref count: " << foo.use_count() << std::endl;
}

int main()
{
    test1();
    test2();
    test3();
    return 0;
}
Run Code Online (Sandbox Code Playgroud)

Bri*_*ian 34

这是libc++ 中的一个错误,无法立即修复,因为它会破坏 ABI。显然,这是一个符合要求的实现,尽管显然它通常不是最佳的。

目前还不清楚为什么 Clang 开发人员首先做出这样的实现选择(尽管如果你真的很幸运,也许来自 Clang 的人会出现并回答这个问题)。这可能与以下事实有关:Clang 的策略避免了为移动构建提供“vtable”条目,从而简化了实现。另外,正如我在其他地方写的那样,如果可调用对象一开始就不可抛出复制构造,则 Clang 实现仅使用 SOO,因此它永远不会将 SOO 用于必须从堆分配的内容(例如包含 a 的结构std::vector)所以它永远不会在移动构建时复制这些东西*这意味着它进行复制而不是移动的情况的实际效果是有限的(尽管在某些情况下它肯定仍然会导致性能下降,例如std::shared_ptr复制操作必须使用原子指令而移动操作几乎是免费的) 。

*好吧,这里有一个警告:如果您使用分配器扩展的移动构造函数,并且提供的分配器与源对象中的分配器不相等,则您将强制 libc++ 实现执行复制,因为在不相等的情况下分配器,它不能仅仅获取指向源对象所持有的外线可调用对象的指针的所有权。但是,无论如何,您都不应该使用分配器扩展的移动构造函数;C++17 中删除了分配器支持,因为实现存在各种问题。

  • 注意,如果您选择在 libc++ 中启用此功能,[此处](https://libcxx.llvm.org/DesignDocs/ABIVersioning.html) 概述了正确的方法(您真的不想只定义 `_LIBCPP_ABI_OPTIMIZED_FUNCTION`.. .)。 (6认同)
  • 可能相关的提交:https://github.com/llvm-mirror/libcxx/commit/c1935f105d483f67eb011e60a253aae503d70eee。当我定义 `_LIBCPP_ABI_OPTIMIZED_FUNCTION` 时,我使用 libc++ 得到了与 libstdc++ 相同的结果:https://godbolt.org/z/6Wa1KPrqG。 (5认同)