小对象堆栈存储、严格别名规则和未定义行为

Posted

技术标签:

【中文标题】小对象堆栈存储、严格别名规则和未定义行为【英文标题】:Small object stack storage, strict-aliasing rule and Undefined Behavior 【发布时间】:2017-01-21 11:49:27 【问题描述】:

我正在编写一个类似于std::function 的类型擦除函数包装器。 (是的,我见过类似的实现,甚至是p0288r0 提案,但我的用例非常狭窄而且有些专业。)。下面高度简化的代码说明了我当前的实现:

class Func
    alignas(sizeof(void*)) char c[64]; //align to word boundary

    struct base
        virtual void operator()() = 0;
        virtual ~base()
    ;

    template<typename T> struct derived : public base
        derived(T&& t) : callable(std::move(t))  
        void operator()() override callable(); 
        T callable;
    ;

public:
    Func() = delete;
    Func(const Func&) = delete;

    template<typename F> //SFINAE constraints skipped for brevity
    Func(F&& f)
        static_assert(sizeof(derived<F>) <= sizeof(c), "");
        new(c) derived<F>(std::forward<F>(f));
    

    void operator () ()
        return reinterpret_cast<base*>(c)->operator()(); //Warning
    

    ~Func()
        reinterpret_cast<base*>(c)->~base();  //Warning
    
;

Compiled,GCC 6.1 警告strict-aliasing:

warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
         return reinterpret_cast<T*>(c)->operator()();

我也知道strict-aliasing rule。另一方面,我目前不知道利用小对象堆栈优化的更好方法。尽管有警告,但我所有的测试都通过了 GCC 和 Clang,(并且额外的间接级别阻止了 GCC 的警告)。我的问题是:

我最终会在这种情况下无视警告而被烧死吗? 有没有更好的就地对象创建方法?

查看完整示例:Live on Coliru

【问题讨论】:

忽略警告,您正在使用 UB 以及 现在 与您的 current 编译器(版本)可能一起工作的内容i> 将来使用不同的编译器或当前编译器的不同版本随机中断。忽略 UB 后果自负。它最终咬人。 @JesperJuhl,警告并不总是完美的,编译器经常抱怨与给定代码库无关的问题。我不是严格混叠专家,但可能有一个完全符合标准的解决方案仍然会产生这些警告 @AaronMcDaid,不,这是违规行为,一清二楚。 一个教育性的解释,也许是完全成熟的答案,会有所帮助 @AaronMcDaid,因为您只能在有限的情况下通过不同类型的指针访问对象。这些都不适用于这里。 【参考方案1】:

首先,使用std::aligned_storage_t。这就是它的意义所在。

其次,virtual 类型及其后代的确切大小和布局由编译器确定。在内存块中分配派生类,然后将该块的地址转换为基类型可能有效,但标准中不能保证它会有效。

特别是,如果我们有struct A ; struct B:A;,则无法保证除非您是标准布局指向-B 的指针可以被reintepreted 作为指向-@ 的指针987654327@(尤其是通过void*)。并且其中带有virtuals 的类不是标准布局。

所以重新解释是未定义的行为。

我们可以解决这个问题。

struct func_vtable 
  void(*invoke)(void*) = nullptr;
  void(*destroy)(void*) = nullptr;
;
template<class T>
func_vtable make_func_vtable() 
  return 
    [](void* ptr) (*static_cast<T*>(ptr))();, // invoke
    [](void* ptr) static_cast<T*>(ptr)->~T(); // destroy
  ;

template<class T>
func_vtable const* get_func_vtable() 
  static const auto vtable = make_func_vtable<T>();
  return &vtable;


class Func
  func_vtable const* vtable = nullptr;
  std::aligned_storage_t< 64 - sizeof(func_vtable const*), sizeof(void*) > data;

public:
  Func() = delete;
  Func(const Func&) = delete;

  template<class F, class dF=std::decay_t<F>>
  Func(F&& f)
    static_assert(sizeof(dF) <= sizeof(data), "");
    new(static_cast<void*>(&data)) dF(std::forward<F>(f));
    vtable = get_func_vtable<dF>();
  

  void operator () ()
    return vtable->invoke(&data);
  

  ~Func()
    if(vtable) vtable->destroy(&data);
  
;

这不再依赖于指针转换保证。它只需要void_ptr == new( void_ptr ) T(blah)

如果您真的担心严格的别名,请将new 表达式的返回值存储为void*,并将其传递给invokedestroy,而不是&amp;data。这是无可非议的:从new返回的指针指向新构造对象的指针。生命周期结束的data的访问可能是无效的,但之前也是无效的。

对象何时开始存在以及何时结束在标准中相对模糊。我看到的解决此问题的最新尝试是 P0137-R1,其中引入了 T* std::launder(T*) 以非常清晰地消除别名问题。

new 返回的指针的存储是我清楚明确地知道在 P0137 之前不会遇到任何对象别名问题的唯一方法。

标准确实规定:

如果一个类型为 T 的对象位于地址 A 处,那么一个值为地址 A 的类型为 cv T* 的指针被称为指向该对象,而不管值是如何获得的

问题是“新表达式是否真的保证对象是在相关位置创建的”。我无法说服自己它如此明确地陈述。但是,在我自己的类型擦除实现中,我不存储该指针。

实际上,在这种简单的情况下,上面的内容与许多 C++ 实现对虚函数表所做的工作大致相同,只是没有创建 RTTI。

【讨论】:

为了节省空间,也许Funcvtable 成员可以被删除,get_func_vtable 每次都可以显式调用。它每次都会返回相同的地址,希望这会被优化掉 @AaronMcDaid 不,因为T 类型仅在构造期间可用,而get_func_vtable&lt;T&gt; 是一个模板类,它创建一个新的函数级static const vtable 并为每个返回一个指向它的指针类型 T 传递给它。这称为“类型擦除”:我们手动执行 OP 对 virtual 函数和析构函数所做的操作,从而避免了 UB。 有趣。你是说get_func_vtable 如果用相同的T 重复调用会返回不同的地址?或者会为同一个T 创建多个vtable?这很难相信,所以我怀疑还有其他一些与我有关的沟通障碍 @AaronMcDaid 我是说T 被调用的每个Func 的构造都不同。 T 不是Func 的模板参数,而是Func::Func 的模板参数。我们只有在构造Func 时才知道。 vtable 映射调用并销毁到“如何对T 执行此操作”。后来,Func 执行这些操作不知道(除了 vtable)它正在对什么执行操作。 对不起。我完全错过了。我被其他问题分心了,忘记了T 在关键点上是未知的。事实上,这就是类型擦除的全部意义所在,当我第一次阅读这个问题时,我完全理解这一点!我很想删除我在这个答案中添加的两个 cmets。有异议吗?【参考方案2】:

更好的选择是使用标准提供的工具来对齐存储以创建对象,称为aligned_storage

std::aligned_storage_t<64, sizeof(void*)> c;

// ...
new(&c) F(std::forward<F>(f));
reinterpret_cast<T*>(&c)->operator()();
reinterpret_cast<T*>(&c)->~T();

Example.

如果可用,您应该使用std::launder 来包装您的reinterpret_casts:What is the purpose of std::launder?;如果 std::launder 不可用,您可以假设您的编译器是 P0137 之前的版本,并且根据“指向”规则 ([basic.compound]/3),reinterpret_casts 就足够了。您可以使用#ifdef __cpp_lib_launder 测试std::launder; example.

由于这是一个标准工具,因此您可以保证,如果您按照库描述(即如上所述)使用它,则不会有被烫伤的危险。

作为奖励,这也将确保任何编译器警告都被抑制。

原始问题未涵盖的一个危险是您将存储地址转换为派生类型的多态基类型。仅当您确保多态基具有相同的地址([ptr.launder]/1:“在其生命周期内的对象 X [ ...] 位于地址 A") 作为构造时的完整对象,因为标准不保证这一点(因为多态类型不是标准布局)。您可以通过assert 进行检查:

    auto* p = new(&c) derived<F>(std::forward<F>(f));
    assert(static_cast<base*>(p) == std::launder(reinterpret_cast<base*>(&c)));

正如 Yakk 建议的那样,将非多态继承与手动 vtable 一起使用会更简洁,因为这样继承将是标准布局,并且保证基类子对象与完整对象具有相同的地址。


如果我们查看aligned_storage的实现,它相当于你的alignas(sizeof(void*)) char c[64],只是包裹在struct中,实际上可以通过将char c[64]包裹在struct中来关闭gcc;虽然严格来说在 P0137 之后你应该使用unsigned char 而不是普通的char。然而,这是标准中一个快速发展的领域,未来可能会发生变化。如果您使用提供的设施,您可以更好地保证它会继续工作。

【讨论】:

@SergeyA 不;在 P0137 之前,我们可以依赖“指向”规则 ([basic.compound]/3),在 P0137 之后,我们可以根据 [intro.object]/3 使用 std::launder @SergeyA [intro.object]/3; [basic.compound]/4 不成立,因为类型不是标准布局,但如果派生对象和基础对象具有相同的地址,仍然可以。 @ecatmur 如果没有标准的布局,“同一个地址”如何以标准的方式以定义的方式表达? &amp;c(忽略别名)(最多)有一个派生:将其解释为 base 严重未定义,不是吗? @Sergey 当您使用不是该位置对象类型的类型访问内存时,会发生别名。在c 占用的存储空间中明确存在base 类型的对象,因为new 表达式创建了这样一个对象。这里唯一的问题是是否有合法的方法来获取指向该对象的指针。 @SergeyA 不,因为在double d; int i = reinterpret_cast&lt;int&amp;&gt;(d); 中,double 类型的对象d 正在通过int 类型的glvalue 访问。在aligned_union_t&lt;int&gt; c; new (&amp;c) int; int i = reinterpret_cast&lt;int&amp;&gt;(c); 中,我们通过int 类型的glvalue 访问int 类型的未命名嵌套对象,这很好。【参考方案3】:

另一个答案基本上是重建大多数编译器在后台所做的事情。当您存储放置 new 返回的指针时,无需手动构建 vtables :

class Func    
    struct base
        virtual void operator()() = 0;
        virtual ~base()
    ;

    template<typename T> struct derived : public base
        derived(T&& t) : callable(std::move(t))  
        void operator()() override callable(); 
        T callable;
    ;

    std::aligned_storage_t<64 - sizeof(base *), sizeof(void *)> data;
    base * ptr;

public:
    Func() = delete;
    Func(const Func&) = delete;

    template<typename F> //SFINAE constraints skipped for brevity
    Func(F&& f)
        static_assert(sizeof(derived<F>) <= sizeof(data), "");
        ptr = new(static_cast<void *>(&data)) derived<F>(std::forward<F>(f));
    

    void operator () ()
        return ptr->operator()();
    

    ~Func()
        ptr->~base();
    
;

derived&lt;T&gt; *base * 完全有效(N4431 §4.10/3):

类型为“pointer to cv D”的纯右值,其中D是类类型,可以转换为“pointer”类型的纯右值 到 cv B”,其中 B 是 D 的基类(第 10 条)。 [..]

而且由于各个成员函数都是虚函数,所以通过基指针调用它们实际上是调用了派生类中的各个函数。

【讨论】:

我做了类似的事情,但不是通过将指针存储在对象中,而是暂时reinterpret_cast在函数范围内使用它。但是,根据 ecatmur 的回答,我还需要使用 std::launder 以防止被编译器的优化器烧毁。不过还是谢谢。 +1

以上是关于小对象堆栈存储、严格别名规则和未定义行为的主要内容,如果未能解决你的问题,请参考以下文章

std::remove 与 vector::erase 和未定义的行为

是否需要在同一环境中使用同一编译器对同一程序进行编译之间保持一致的未指定和未定义行为?

是否需要在同一环境中使用同一编译器对同一程序进行编译之间保持一致的未指定和未定义行为?

c++ 模板化抽象基类数组,不违反严格别名规则

C++ SSE:存储到数组后的未定义行为

媒体基础多个视频播放导致内存泄漏和未定义时间范围后崩溃