2. Placement

PlacementObjectArray 的基石。而讨论 Placement 的作用及性质,则要从 平凡性 谈起。

2.1. 平凡性

关于平凡性的更多细节,可参考 平凡性 。 这里仅仅谈论两个与 Placement 有关的:

  1. 如果一个类是 可平凡构造 的,系统不会为对象的创建,生成任何构造代码;

  2. 如果一个类是 可平凡析构 的,则系统不会为对象的销毁,生成任何析构代码。

因而,如果一个类,即是 可平凡构造 ,又是 可平凡析构 的, 你就无需担心在一个数组中直接存放这种类型。(事实上,一个类如果是 可平凡构造 的,就必然是 可平凡析构 的;但反过来不成立)。

因为静态数组的空间是固定的,但其中元素的个数却是可变的。你不希望在数组事实上还是空的时候, 就将数组中所有的 slot ,都调一遍 默认构造函数 (默认的并不一定是平凡的);在数组销毁的时候,即使数组 是空的,也会再将所有对象都销毁一遍。

更不用说,如果一个类没有 默认构造函数 ,则数组中的对象将必须由用户亲自明确构造。但事实上此时用户又不知道 该怎样构造(毕竟还没有实际对应的对象)。此时用户只有两种选择:

  1. 为其构造一套非法值,或者默认值(这相当于还是为其提供了 默认构造函数 );

  2. 还是回到问题的本质:静态数组只是代表了空间的预留,而并非已有实际的对象,此时,就应该用一种类似于”占位符”( PlaceHolder/Placement )的概念来表达此种语意。

而一旦选择了 Placement 的方式,将直接带来两种好处:

  1. 让问题回归其本质;

  2. 让非平凡的对象在不实际需要创建之前,系统什么都不用做。

完美!

理论上,即便一个类型其 构造析构 都是平凡的,如果也使用 Placement , 除了语法上,用户需要通过通过 Emplace 来构造对象,通过 operator* 来访问对象之外 也没有什么实际的坏处。

但用户总是觉得在不必要时,还是直接来得痛快。因而就需要选择:

1、当一个类型构造和析构都是平凡时,直接使用类型; 2、否则,使用 Placement

但选择不是免费的,它需要程序员付出脑细胞的代价。更重要的是,世界是变化的,一个曾经无比平凡的类, 也可能在某个时候悄悄地变得不再平凡。系统则在悄无声息中偷偷地增加了运行时代价。

面对这样的困境,至少有两种解决方案:

  1. 放弃做出选择,一致使用 Placement

  2. 让机器帮我们做出选择。

作为有追求的程序员,我们毫无疑问会直奔第二种方案。

2.2. 如何实现

作为一种针对某种类型对象的占位符, Placement<T> 必须提供如下特质:

  1. 其所占内存的大小必须和 T 的大小相等;

  2. 其所占内存的对齐方式必须和 T 相同;

否则,将来就无法恰如其分的在此位置上创建对象。因而,我们快速给出如下的实现:

template <typename T>
struct Placement {
   alignas(alignof(T)) char storage[sizeof(T)];

   // ....
};

对于这样的 Placement ,你不可能给出任何有价值的 非平凡构造非平凡析构 。 毕竟,你没有任何额外的信息来记录在 storage 上究竟已经放置了一个有效的对象,还是依然保持无效。

除了什么都不做,你别无选择。

因而,对于任何 Placement<T> ,其都是 可平凡构造/析构 的。 即便 T 本身完全不平凡(这正是我们需要 Placement 的动机)。

这就导致了 T 本身的平凡性信息在这里被丢失了。

当然,在 Placement 内部,你是无从知道对象是否已经被创建的。在没有被创建的情况下,平凡性信息的丧失无足轻重( 甚至就应该一切都是平凡的);但在对象已经存在情况下,这种信息的丧失就会导致风险。

比如,一个类 Foo 持有一个 unique_ptr ,因而必然是 非平凡构造/析构 的, 更重要的是,它必然是禁止 copy 的。

而如果有一段框架代码通过判断一个类是否是可平凡拷贝,来自动进行 ::memcpy 的 话, Placement<Foo> 会被判断为可拷贝的,导致一个对象被两个 unique_ptr 所有, 最终导致系统的崩溃。

由于 Placement<T> 自身具备的 二态性 (对象存在与否),导致无论你怎么看待它的平凡性,似乎都是不够通顺的。

而正是这个原因,在 C++ 11 之前,同样具备这种二态(甚至多态)性的 union 完全不允许持有任何 非平凡类型

到了 C++ 11union 的这种约束被取消。而上述矛盾的解决办法则是:如果一个 union ,其内部任何一个成员, 如果其某个特殊函数是 非平凡 的,则整个 union 则会删除对应的特殊函数,从而自动丧失对应函数的平凡性。

比如下面的 union :

union {
  std::string          s;
  char                 c;
};

由于 std::string 拥有明确的 自定义构造析构copy/move 构造/赋值 (因而这些特质都是非平凡的), 即便最终你在此 union 上实际构造的是无比平凡的 char c ,但整个 union构造析构copy/move 构造/赋值 都 依然会统统被删除。

现在已经不是平不平凡的问题,而是存不存在的问题。这些函数的删除,导致你完全无法对这个 union 做任何事情: 无法构造,无法拷贝,无法移动,当然也就谈不上析构。

而一个对象能够被 构造析构 是最基本的需求。这就强迫程序员必须手动为其明确定义构造和析构。 而你一旦为其明确定义了 构造 ,其将不再是 可平凡构造 的;同样,一旦你明确为其定义了 析构 , 则其 默认构造析构 都不再平凡。

Placement 一样, union 自身并不具备哪个成员有效的信息。 所以这种强迫性主要在 匿名union 的场景下特别有意义。比如:

struct Foo {
private:
   enum class Kind {
     NIL,
     STRING,
     CHAR
   };

public:
   Foo() : kind{Kind::NIL} {}
   Foo(std::string const& str) : s{str}, kind{Kind::STRING} {}
   Foo(char ch) : c{ch}, kind{Kind::CHAR} {}

   ~Foo() {
      if(kind == Kind::STRING) {
         using namespace std;
         s.~basic_string();
      }
   }

private:
   union {
     std::string s;
     char        c;
   };
   Kind kind;
};

在匿名的场景下,其外围的类 Foo 变为其宿主,因而由 std::string 所带来的构造/析构/赋值函数的删除问题 发生在 Foo 身上,而 Foo 作为一个 class ,可以拥有 union 自身所不可能拥有的谁有效的信息。 这就会有效的强迫程序员必须明确的为 Foo 实现 构造析构 (如果需要,还要实现 copy/move 构造/赋值 函数, 上述实现中它们依然处于被删除状态)。

但我们的 Placement 实现显然不能使用上述匿名 union 技术,因为我们的 Placement 有可能被用在 不同场景(数组, optional )等, 因而 Placement 必须保持像 union 一样对自身状态的无知(否则就需要额外的内存来保存状态信息)。

所以,如果我们想让 Placement 携带足够的平凡性信息(以及其它诸如是否可拷贝/移动/复制等能力信息),同时又增加额外的 内存开销,以保持 Placement 本身的职责,那么在 Placement 上添加任何 非平凡构造/析构 就没有任何意义。

我们只需要在 union 上添加即可。

template< typename T
        , bool = std::is_trivially_default_constructible_v<T>
        , bool = std::is_trivially_destructible_v<T>>
struct UnionTrait {
    union Type {
        T obj;
    };
};

template<typename T>
struct UnionTrait<T, true, false> {
    union Type {
        ~Type() {}
        T obj;
    };
};

template<typename T>
struct UnionTrait<T, false, true> {
    union Type {
        Type() {}
        T obj;
    };
};

template<typename T>
struct UnionTrait<T, false, false> {
    union Type {
        Type() {}
        ~Type() {}
        T obj;
    };
};

////////////////////////////////////////
template<typename T>
struct Placement {
   Placement() = default;
   ~Placement() = default;
   //....
private:
    using Storage = typename UnionTrait<T>::Type;
    Storage storage;
};

通过简单的模版编特化技术,我们让 Placement<T> 可以被构造和析构(平凡与否则取决于 T 的构造/析构是否平凡), 同时继续保持 T 所导致的其它特殊函数( copy/move 构造/复制)的状态。即,如果 T 所在的 union 导致它们中某些 或全部被删除,则 Placement<T> 也拥有同样的性质。

Placement<T> 所携带的由 Tunion 所导致的状态信息,会进一步传播到使用 Placement<T> 的类, 至于它们会如何应对,则不再是 Placement<T> 所需关心的。

毕竟,所有 T 所拥有的非平凡特殊函数在 Placement<T> 上对应的函数都被删除了, 这种信息已经强大到任何使用 Placement<T> 的类都在需要时不可能忽视掉(毕竟,还有什么比删除掉不让你用,你一用就编译出错更强大的信息呢?), 因而也不会导致潜在的风险与错误。(不得不佩服 C++ 11 union 提案的深思熟虑)。

最后再强调一下: Placement<T> 自身并不说明其所持内存上对象的有效性。 Placement<T> 在销毁时,也无法自动调用 T 的析构。 因而,对其所持对象的销毁,是用户的责任。而用户使用 Placement<T> 时,必须要自定义说明 T 有效性的方式。 比如,像 optional<T> 那样,通过一个 bool 类型的标记来说明,或在数组中,通过数组中元素的数目来说明。