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

时间:2016-09-13 19:16:20

标签: c++ gcc c++14

我正在编写类似于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

3 个答案:

答案 0 :(得分:13)

首先,使用std::aligned_storage_t。这就是它的意思。

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

特别是,如果我们struct A {}; struct B:A{};,则无法保证除非您是标准布局,指针指向B可以reintepret为指针指向A(特别是通过void*)。其中包含virtual的类不是标准布局。

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

我们可以解决这个问题。

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而不是{ {1}}。这无可非议:从&data 返回的指针是指向新构造对象的指针。访问其生命周期已结束的new可能无效,但之前也无效。

当对象开始存在时,它们结束时在标准中相对模糊。我已经看到解决此问题的最新尝试是P0137-R1,其中引入了data,以便以非常清晰的方式消除别名问题。

T* std::launder(T*)返回的指针的存储是我清楚明白并且在P0137之前没有遇到任何对象别名问题的唯一方法。

标准确实说明了:

  

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

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

实际上,除了没有创建RTTI之外,上面的内容与许多C ++实现在这样的简单情况下对虚函数表的作用大致相同。

答案 1 :(得分:6)

更好的选择是使用标准提供的工具进行对齐存储以创建对象,称为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_castWhat is the purpose of std::launder?;如果std::launder不可用,您可以假设您的编译器是P0137之前的,reinterpret_cast对于“指向”规则是足够的( [basic.compound] / 3)。您可以使用std::launder测试#ifdef __cpp_lib_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中,确实可以通过包裹{{1}来关闭gcc在char c[64];虽然严格来说在P0137之后,你应该使用struct而不是普通的unsigned char。然而,这是标准的一个快速发展的领域,这可能在未来发生变化。如果您使用提供的设施,您可以更好地保证它将继续有效。

答案 2 :(得分:2)

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

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<T> *base *完全有效(N4431§4.10/ 3):

  

类型为“指向cv D的指针”的prvalue,其中D是类类型,可以转换为类型为“指针”的prvalue   至cv B“,其中B是D. [..]

的基类(第10条)

由于各个成员函数都是虚函数,因此通过基指针调用它们实际上会调用派生类中的相应函数。