2. Placement¶
Placement 是 ObjectArray 的基石。而讨论 Placement 的作用及性质,则要从 平凡性 谈起。
2.1. 平凡性¶
关于平凡性的更多细节,可参考 平凡性 。 这里仅仅谈论两个与 Placement 有关的:
如果一个类是 可平凡构造 的,系统不会为对象的创建,生成任何构造代码;
如果一个类是 可平凡析构 的,则系统不会为对象的销毁,生成任何析构代码。
因而,如果一个类,即是 可平凡构造 ,又是 可平凡析构 的, 你就无需担心在一个数组中直接存放这种类型。(事实上,一个类如果是 可平凡构造 的,就必然是 可平凡析构 的;但反过来不成立)。
因为静态数组的空间是固定的,但其中元素的个数却是可变的。你不希望在数组事实上还是空的时候, 就将数组中所有的 slot ,都调一遍 默认构造函数 (默认的并不一定是平凡的);在数组销毁的时候,即使数组 是空的,也会再将所有对象都销毁一遍。
更不用说,如果一个类没有 默认构造函数 ,则数组中的对象将必须由用户亲自明确构造。但事实上此时用户又不知道 该怎样构造(毕竟还没有实际对应的对象)。此时用户只有两种选择:
为其构造一套非法值,或者默认值(这相当于还是为其提供了 默认构造函数 );
还是回到问题的本质:静态数组只是代表了空间的预留,而并非已有实际的对象,此时,就应该用一种类似于”占位符”( PlaceHolder/Placement )的概念来表达此种语意。
而一旦选择了 Placement
的方式,将直接带来两种好处:
让问题回归其本质;
让非平凡的对象在不实际需要创建之前,系统什么都不用做。
完美!
理论上,即便一个类型其 构造 和 析构 都是平凡的,如果也使用 Placement ,
除了语法上,用户需要通过通过 Emplace
来构造对象,通过 operator*
来访问对象之外
也没有什么实际的坏处。
但用户总是觉得在不必要时,还是直接来得痛快。因而就需要选择:
1、当一个类型构造和析构都是平凡时,直接使用类型;
2、否则,使用 Placement
。
但选择不是免费的,它需要程序员付出脑细胞的代价。更重要的是,世界是变化的,一个曾经无比平凡的类, 也可能在某个时候悄悄地变得不再平凡。系统则在悄无声息中偷偷地增加了运行时代价。
面对这样的困境,至少有两种解决方案:
放弃做出选择,一致使用
Placement
;让机器帮我们做出选择。
作为有追求的程序员,我们毫无疑问会直奔第二种方案。
2.2. 如何实现¶
作为一种针对某种类型对象的占位符, Placement<T>
必须提供如下特质:
其所占内存的大小必须和
T
的大小相等;其所占内存的对齐方式必须和
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++ 11 , union 的这种约束被取消。而上述矛盾的解决办法则是:如果一个 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> 所携带的由 T 和 union 所导致的状态信息,会进一步传播到使用 Placement<T> 的类, 至于它们会如何应对,则不再是 Placement<T> 所需关心的。
毕竟,所有 T 所拥有的非平凡特殊函数在 Placement<T> 上对应的函数都被删除了, 这种信息已经强大到任何使用 Placement<T> 的类都在需要时不可能忽视掉(毕竟,还有什么比删除掉不让你用,你一用就编译出错更强大的信息呢?), 因而也不会导致潜在的风险与错误。(不得不佩服 C++ 11 union 提案的深思熟虑)。
最后再强调一下: Placement<T> 自身并不说明其所持内存上对象的有效性。 Placement<T> 在销毁时,也无法自动调用 T 的析构。 因而,对其所持对象的销毁,是用户的责任。而用户使用 Placement<T> 时,必须要自定义说明 T 有效性的方式。 比如,像 optional<T> 那样,通过一个 bool 类型的标记来说明,或在数组中,通过数组中元素的数目来说明。