我们能否在C++17中检测“微不足道的可重定位性”?

6
在未来的 C++ 标准中,我们将会引入"平凡可重定位性"的概念,这意味着我们可以简单地将一个对象的字节复制到一个未初始化的内存块中,并且直接忽略/清零原始对象的字节。这样,我们就模仿了 C 风格的对象复制/移动方式。
在未来的标准中,我们可能会像这样使用类型特征 std::is_trivially_relocatable<type> 来判断对象是否具有平凡可重定位性。当前,最接近此特征的是 std::is_pod<type>,但它将在 C++20 中被废弃。
我的问题是,在当前标准(C++17)中,我们是否有方法来确定对象是否具有平凡可重定位性?例如,std::unique_ptr<type> 可以通过复制其字节到一个新的内存地址并清零原始字节来移动,但 std::is_pod_v<std::unique_ptr<int>> 的值为 false
此外,当前标准规定,每个未初始化的内存块都必须通过构造函数才能被视为有效的 C++ 对象。即使我们可以找出对象是否具有平凡可重定位性,如果我们只是移动字节,这仍然违反了标准。因此,另一个问题是——即使我们可以检测到平凡可重定位性,如何实现平凡重定位而不会引起 UB?简单地调用 memcpy + memset(src,0,...) 并将内存地址转换为正确的类型是 UB。
谢谢!

3
发明新事物的原因在于,如果没有新事物,我们就无法完成某些任务。 - Marc Glisse
@MarcGlisse 事物并非被完全标准化,就意味着它不存在或不可能存在。在 C++ 中使用多线程的人们早在 2011 年标准化之前就已经很普遍了,STL 就是另一个例子。 - David Haim
1
该提案讨论了使用属性来标记类型为可平凡重定位。编译器不可能聪明到只需查看代码并确定其是否可平凡重定位。程序员必须查看例如std::unique_ptr的实现并理解它是否可平凡重定位。听起来这就是你所要求的。 - Indiana Kernick
1
@DavidHaim:“我们目前最接近的是std::is_pod<type>,但它将在C++20中被弃用。” FYI:最接近的东西是Trivially copyable,它在任何标准中都不会被弃用。POD定义正在被删除,因为其实用性已被Trivially copyable和standard layout所取代。 - Nicol Bolas
1
在未来的C++标准中,我们将会有一个概念[...]。虽然有一个提案,但我不确定您是否可以非常自信地说它一定会发生。 - Barry
显示剩余18条评论
2个回答

7

我是P1144的作者;不知何故,我现在才看到这个SO问题!

std::is_trivially_relocatable<T>被提议用于一些未来版本的C++,但我不预测它会很快得到认可(肯定不会在C++23中,我打赌不会在C++26中,很可能永远不会)。 这篇论文(P1144R6,2022年6月)应该能回答你很多问题,特别是那些人们正确回答说如果你已经能够在现有的C++中实现这个功能,我们就不需要提议了的问题。 另请参见我的2019 C++Now演讲

迈克尔·肯泽尔的回答表示P1144“最终要求用户手动标记可以进行[平凡重定位]的类型”;我想指出这与本意相反。对于平凡可重定位性而言,现代技术是需要手动标记(“保证”)每一个此类类型;例如,在Folly中,您可以这样说。
struct Widget {
    std::string s;
    std::vector<int> v;
};
FOLLY_ASSUME_FBVECTOR_COMPATIBLE(Widget);

这是一个问题,因为普通的行业程序员不应该费心地尝试弄清楚他们选择的库中std::string是否可以被轻松重定位。(1.5个最大的3家供应商上述注释是错误的!)即使是Folly自己的维护者也无法在100%的时间内正确处理这些手动注释。

因此,P1144的想法是编译器可以为您解决此问题。您的工作从危险的保证您不一定知道的事情转变为仅(可选地)验证您希望成真的事情,通过使用static_assertGodbolt):

struct Widget {
    std::string s;
    std::vector<int> v;
};
static_assert(std::is_trivially_relocatable_v<Widget>);

struct Gadget {
    std::string s;
    std::list<int> v;
};
static_assert(!std::is_trivially_relocatable_v<Gadget>);

在您(原帖作者)的具体用例中,似乎您需要找出给定的lambda类型是否是可以平凡重定位的( Godbolt):
void f(std::list<int> v) {
    auto widget = [&]() { return v; };
    auto gadget = [=]() { return v; };
    static_assert(std::is_trivially_relocatable_v<decltype(widget)>);
    static_assert(!std::is_trivially_relocatable_v<decltype(gadget)>);
}

这是一些你无法使用Folly/BSL/EASTL完成的事情,因为它们的保证机制仅适用于全局作用域中的命名类型。你不能完全像这样使用FOLLY_ASSUME_FBVECTOR_COMPATIBLE(decltype(widget))

在类似于std::function的类型内部,你确实需要知道捕获类型是否可以被轻松重定位。但由于你无法知道这一点,下一个最好的选择(也是你应该实践的)是检查std::is_trivially_copyable。这是目前被认可的类型特征,它的字面意思是“此类型安全地进行memcpy,可以跳过析构函数” - 基本上你将要使用它的所有东西。即使你知道该类型恰好是std::unique_ptr<int>,或其他什么,在当前的C++标准中使用memcpy复制它仍然是未定义行为,因为当前标准规定你不允许memcpy非平凡可复制的类型。

顺便说一句,技术上来讲,P1144并没有改变这个事实。P1144仅表示允许实现省略重定位的效果,这是对实现者的巨大暗示,让他们只需使用memcpy即可。但即使是P1144R6也不允许普通的非实现程序员对非平凡可复制类型进行memcpy:它为某些编译器实现和某些库实现打开了一个门,可以使用__builtin_trivial_relocate函数,在某种神奇的意义上与普通的memcpy有所区别。
最后,你的最后一段提到了memcpy + memset(src,0,...)。那是错误的。平凡重定位就相当于只是memcpy。如果你关心源对象之后的状态——例如,如果你关心它是全零字节——那就意味着你将再次查看它,这意味着你并没有把它视为已销毁,这意味着你并没有真正执行重定位的语义。"复制并将源置空"更常见的是移动的语义。重定位的目的是避免额外的工作。

一直希望有20多年的内置std类型特性支持。查看 https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1144r2.html#polls,当然支持“应将`is_trivially_relocatable <T>`添加到<type_traits>中”。因此,我想知道为什么您担心它的添加“很可能永远不会”?谢谢您的努力,Arthur。 - Dwayne Robinson
我觉得微不足道的可重定位性一定要包括memset吗?就像想象unique_ptr一样,只有包括memset才能使其微不足道地可重定位,而这是您绝对希望能够在其上使用重定位的类型。如果没有它,微不足道的可重定位性就变得不那么有用了。 - user541686
@user541686:不,没有memset。如果您关心源对象之后的状态——例如,如果您关心它是全零字节——那么这意味着您将再次查看它,这意味着您实际上并未将其视为已销毁。反之,如果您确实将源对象视为已销毁,则其位和字节的状态根本无关紧要,因此没有必要将它们设置为任何值。此外,如果您阅读汇编语言,可以自己尝试一下:https://p1144.godbolt.org/z/7G3sY4qcb(GCC的代码生成比Clang更好)。 - Quuxplusone
似乎你正在要求破坏性的可重定位性,然而我(以及其他提出这个问题的人)想要非破坏性的可重定位性。就像移动操作一样,两者都是有效和有用的;这取决于情况。没有一个更好或更差。鉴于移动操作的类比,我可能会将你的版本称为“is_destructively_relocatable”,并将“is_relocatable”保留给非破坏性版本。 - user541686

4
整个平凡可重定位的关键似乎在于即使存在非平凡移动构造函数或移动赋值运算符,也能够启用按字节移动对象。 即使在当前提案P1144R3中,这最终也要求用户手动标记此操作可能适用的类型。 对于编译器来说,通常情况下要弄清楚给定类型是否是平凡可重定位的等价于解决停机问题(它必须理解和推断任意潜在的用户定义的移动构造函数或移动赋值运算符的作用)...
当然,你可以定义自己的is_trivially_relocatable特性,默认为std::is_trivially_copyable_v,并让用户专门为应被视为平凡可重定位的类型进行专门化。 然而,即使如此,这也是有问题的,因为没有办法自动传播此属性到由平凡可重定位类型组成的类型...
即使对于平凡可复制的类型,也不能仅仅将对象表示的字节复制到某个随机内存位置并将地址强制转换为原始对象类型的指针。因为从未创建过对象,该指针不会指向对象。试图访问该指针不指向的对象将导致未定义行为。平凡可复制性意味着您可以将一个现有对象的对象表示的字节复制到另一个现有对象中,并且可以依靠这一点使一个对象的值等于另一个对象的值[basic.types]/3
要对平凡重定位某些对象执行此操作,意味着您必须首先在目标位置构造给定类型的对象,然后将原始对象的字节复制到其中,并以等同于移动该对象的方式修改原始对象。这本质上是一种复杂的移动对象的方法...
存在提案将平凡可重定位概念添加到语言中的原因是:因为您当前无法仅使用语言本身来执行此操作...
请注意,尽管如此,仅仅因为编译器前端无法避免生成构造函数调用,并不意味着优化器无法消除不必要的加载和存储。让我们看一下编译器为移动std::vectorstd::unique_ptr示例生成的代码:
auto test1(void* dest, std::vector<int>& src)
{
    return new (dest) std::vector<int>(std::move(src));
}

auto test2(void* dest, std::unique_ptr<int>& src)
{
    return new (dest) std::unique_ptr<int>(std::move(src));
}

正如你所看到的,即使对于非平凡类型,仅仅执行一个实际的移动操作通常已经归结为只是复制和覆盖一些字节...


谢谢详细的答案,我会在不久的将来接受它。请阅读我的评论:这并不是说我不相信优化器,而是有一个我想要优化的特定情况。 - David Haim
@DavidHaim 如果你的问题中包含了如何解决你的特定情况,我建议你将其添加到你的问题中,因为在评论中很难找到。基于上述原因,我认为除了引入自定义 trait 之外,你在这里没有太多可以做的事情。此外,请注意 std::function 通常已经执行了小缓冲区优化,所以一开始可能没有必要实现自己的优化... - Michael Kenzel
有一个原因,因为糟糕的std::function按值获得可调用对象。我的函数在现场从其参数构建可调用对象。如果您的回调使用大量唯一指针(我的确实如此),std::function将成为代码中的障碍。此外,我的std::function具有一些函数,例如“execute_destroy”,可以在一个函数调用中调用底层可调用对象并销毁它(这当然是间接的)。对于一次性回调(例如提交给线程池、套接字回调等)非常有用。 - David Haim
@DavidHaim 我很难想象有什么情况下这会有影响。那个特定的 std::function 构造函数几乎肯定会被内联。除非您处理具有副作用的用户定义复制构造函数的可调用对象(鉴于此处的问题,我想象不会是这种情况),否则通过值传递可调用对象与就地构造之间没有区别…… - Michael Kenzel
如果您的可调用对象包含unique_ptr,并且您尝试从中构造std::function,则编译将失败,因为您无法复制unique_ptr。 - David Haim
@DavidHaim 我不确定为什么会出现这种情况,因为这个 std::function 构造函数 应该 将其参数 std::movestd::function 对象中。但是要求可调用对象必须是 CopyConstructible,所以我猜这对于某些实现来说是有效的行为。所以好吧,我想我还没有遇到这种情况,因为我不使用 std::function(或类似的东西)做任何事情... - Michael Kenzel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接