创建一个不可构造的对象:关于平凡可拷贝类型定义的问题?
可以设计不可构造的类,但我设法实例化了这样一个类的对象:
#include <bit>
#include <cstring>
#include <iostream>
// Not constructible
// Not implicit lifetime
struct Test {
int val{1};
Test() = delete;
Test(Test const&) = delete;
Test(Test&&) = delete;
Test& operator=(Test const&) = default;
Test& operator=(Test&&) = default;
~Test() = default;
};
int main() {
char storage[sizeof(Test)];
int i{666};
std::memcpy(storage,&i,sizeof(Test));
auto HeWhoMustNotBeNamed = std::bit_cast<Test>(storage);
std::cout << HeWhoMustNotBeNamed.val;
}
所有测试过的编译器(gcc、clang和 msvc)都将 Test 视为平凡可拷贝的。
Test 和 storage 满足 std::bit_cast 的前提条件,因此clang和 msvc在没有未定义行为的情况下创建了一个不可实例化的类的对象。
只有gcc无法编译,它尝试使用已删除的移动构造函数来返回结果。我猜测msvc和 clang对 std::bit_cast 进行了某种拷贝省略,而gcc没有。
在我看来,这样的行为令人不满意,因为它违背了类的语义。我认为问题出在对“平凡可拷贝”的定义上,该定义允许拥有不可实例化的类(尽管这是一种悖论:所有不能存在的对象都可以被平凡拷贝)。
我的分析有没有错? 注:另有一个替代方案被提出,参见 新问题。
注:这个问题并非完全理论性,因为不可实例化的类也可能是一个基实现类,必须仅通过继承来使用:
// Not constructible
// Not implicit lifetime
struct BaseImpl {
int val{1};
BaseImpl() = delete;
BaseImpl(BaseImpl const&) = delete;
BaseImpl(BaseImpl&&) = delete;
BaseImpl& operator=(BaseImpl const&) = default;
BaseImpl& operator=(BaseImpl&&) = default;
~BaseImpl() = default;
protected:
BaseImpl(int i) : val{i} {};
};
struct Concrete: private BaseImpl
{
Concrete(int i) : BaseImpl{i}{};
int Value() const {return BaseImpl::val;};
};
解决方案
这种行为(创建不可实例化的类的对象)是不可取的,因为它违反了类的语义。
我对语义的理解与你不同。是的,所有构造函数都被删除,但仍有若干非静态成员函数没有被删除。这些成员函数在没有该类型的对象存在时是不能被调用的,那么它们若能被调用,为什么还要明确地定义它们?从这些函数的存在可以推断出,某种程度上希望这个不可实例化的类的对象可以存在。尽管可能并非如此初衷,但你发现的行为支持这个结论。
如果你把每个 default 改成 delete,那么我会同意这个语义就是该类不应该存在对象。当然,如果你这么做,那么 bit_cast 这一行将无法通过编译……修正语义,问题就会消失。
注:析构函数是上述要点的关键。如果没有声明析构函数,我本来会把“在要做的修改中再增加 ~Test() = delete;”一并考虑,以达到期望的语义。
不可实例化的类也可以是一个基实现类,必须仅通过继承来使用
这点在代码中可以更清楚地表达。读者是通过什么来理解这一定义的语义?通过 BaseImpl(int) 构造函数的受保护状态吗?随着类定义变得越来越大,读者要记住每一个构造函数就越困难。也就是说,依赖构造函数来表达“该类只能作为基类使用”的语义并不可靠(因为很容易在公有部分新增一个构造函数,这也很脆弱)。更好的表达方式是将析构函数设为受保护的。
- 另见 C++ Core Guideline C.35: “基类的析构函数应当是公有且虚拟的,或受保护且非虚拟的”(不要与类似的 C.127 混淆,该条仅在有虚函数时才会应用)。诚然,私有继承确实降低了不遵循此指南的风险,但这仍然是有用的建议。
受保护的析构函数将阻止 bit_cast 方法,除非你在使用MSVC进行编译;在这方面,MSVC是不符合规范的。再次强调,若语义更清晰,问题就会消失。
此外,当一个类具有可用的构造函数时,不应称其为“不可构造的”。将一个类的实例作为子对象来构造,仍然是在对该类进行实例化。