空类(empty class)

2017-07-31  本文已影响0人  suesai

空类就是没有静态成员变量的类,却通常带有 typedef 和成员函数。


空类运行时占用的空间

为保证不同的对象的地址是不同的,C++ 要求空类的大小不能为零。

class Empty { };

int main()
{
  std::cout << "sizeof(Empty): " << sizeof(Empty) << '\n';

  Empty arr[10];
  std::cout << "sizeof(arr): " << sizeof(arr) << '\n';

  Empty a, b;
  if (&a != &b) {
    std::cout << "the size of class Empty is not zero" << '\n';
  }
}

上述代码结果如下(本文的测试环境为 Ubuntu-16.04-64bit GCC-5.4.0):

sizeof(Empty): 1
sizeof(arr): 10
the size of class Empty is not zero

如果 Empty 的大小为0,则无法区别 arr 中的十个元素。对于多数平台,Empty 的大小都是1,但是部分平台在对齐上有着较为严格的要求,结果可能会是一个字的大小(比如8)。
对于带有虚函数的空类:

class EmptyWithVirtualFunc
{
public:
  virtual void VirtualFunc() { }
};

int main()
{
  std::cout << "sizeof(EmptyWithVirtualFunc): " << sizeof(EmptyWithVirtualFunc) << '\n';
  std::cout << "sizeof(void*): " << sizeof(void*) << '\n';
}

结果如下:

sizeof(EmptyWithVirtualFunc): 8
sizeof(void*): 8

带有虚函数的空类,编译器会在该空类对象的起始位置(所有非静态成员变量之前)放置一个虚指针,所以该类的大小不是1而是一个指针的大小。


空基类优化

在 C++ 中有一个现象与上述相悖:在空类作为基类的情况下,子类的空间中可能不会出现多出来的那一个字节。 由于带有虚函数的空类实质上还是有一个隐藏的虚指针成员,不算是严格意义上的空类,所以不参与空基类优化。

单继承

class Derived1 : public Empty { };

class Derived2 : public Empty
{
public:
  std::int32_t i32;
};

int main()
{
  std::cout << "sizeof(Derived1): " << sizeof(Devired1) << '\n';
  std::cout << "sizeof(Derived2): " << sizeof(Devired2) << '\n';
}

结果如下:

sizeof(Derived1): 1
sizeof(Derived2): 4

从结果可以看出,Empty 在没有继承情况下多出来的一个字节在子类中并没有体现,这一个字节被“优化”了。
当有子类继承空类 Derived1,既多层继承时:

class Derived3 : public Derived1 { };

class Derived4 : public Derived1
{
public:
  std::int32_t i32;
};

int main()
{
  std::cout << "sizeof(Derived3): " << sizeof(Devired3) << '\n';
  std::cout << "sizeof(Derived4): " << sizeof(Devired4) << '\n';
}

结果如下:

sizeof(Derived3): 1
sizeof(Derived4): 4

从多层继承的结果可以看出,多出的那一个字节是否被优化与空类的继承层数无关。
但是在部分情况下,优化效果会消失:

class Derived5 : public Empty
{
public:
  Empty e;
};

class Derived6 : public Empty
{
public:
  static Empty se;
};
Empty Derived6::se { };

class Derived7 : public Empty
{
public:
  std::int32_t i32;
  Empty e;
};

int main()
{
  std::cout << "sizeof(Derived5): " << sizeof(Devired5) << '\n';
  std::cout << "sizeof(Derived6): " << sizeof(Devired6) << '\n';
  std::cout << "sizeof(Derived7): " << sizeof(Devired7) << '\n';
}

结果如下:

sizeof(Derived5): 2
sizeof(Derived6): 1
sizeof(Derived7): 8

我们分析一下这三个子类的内存布局,
Derived5:

此时空基类优化失去了效果。如果依然进行优化,则无法区分基类 Empty 和子类中的成员 Empty(注意子类 Derived5 中的 Empty 不是基类,所以不参与优化,一定会占用一个字节)。
Derived6:

依然进行空基类优化。因为静态成员变量不属于某个具体的类实例,不占用类实例的空间,所以此时基类 Empty 不会与静态成员变量发生冲突,但是由于 Derived6 是空类,所以还是要占用一个字节空间。
Derived7:

依然进行了空基类优化。因为基类 Empty 与子类中的成员 Empty 的地址空间不是相连的,不发生冲突(注意此时优化掉了基类 Empty 的一个字节,并没有优化子类成员变量 Empty)。在子类成员 Empty 后补齐三个字节,所以整体占用的空间是八个字节。

多重继承

如果不同的空类同时作为一个类的基类时,

class Empty1 { };

class MultiDerived : public Empty, public Empty1 { };

int main()
{
  std::cout << "sizeof(MultiDerived): " << sizeof(MultiDerived) << '\n';
}

结果如下:

sizeof(MultiDerived): 1

编译器认为不同的空类在子类的内存空间是不会发生冲突的。
再考虑如下的情况,

class MultiDerived1 : public Empty { };
class MultiDerived2 : public Empty { };
class MultiDerived3 : public MultiDerived1, public MultiDerived2 { };

int main()
{
  std::cout << "sizeof(MultiDerived3): " << sizeof(MultiDerived3) << '\n';
}

结果如下(暂不考虑虚继承):

sizeof(MultiDerived3): 2

MultiDerived3的内存布局如下:

没有进行空基类优化。由于 MultiDerived1 是一个(is-a)Empty,而且 MultiDerived2 也是一个 Empty,又由于 MultiDerived1 和 MultiDerived2 在子类的内存空间中是连续的,此时如果进行了空基类优化,则两个 Empty 就无法区分。
再考虑如下的情况,

class NotEmpty
{
public:
  std::int32_t i32;
};

class MultiDerived4 : public MultiDerived1, public NotEmpty
{
public:
  Empty e;
};

class MultiDerived5 : public NotEmpty, public MultiDerived1
{
public:
  Empty e;
};

class MultiDerived6 : public NotEmpty, public MultiDerived1 { };

int main()
{
  std::cout << "sizeof(MultiDerived4): " << sizeof(MultiDerived4) << '\n';
  std::cout << "sizeof(MultiDerived5): " << sizeof(MultiDerived5) << '\n';
  std::cout << "sizeof(MultiDerived6): " << sizeof(MultiDerived6) << '\n';
}

结果如下:

sizeof(MultiDerived4): 8
sizeof(MultiDerived5): 8
sizeof(MultiDerived6): 4

我们分析一下这三个子类的内存布局,
MultiDerived4:

进行了空基类优化。由于 MultiDerived1 的 Empty 与子类成员 Empty 中间隔了 NotEmpty,所以不发生冲突,因此可以进行优化。
MultiDerived5:

没有发生空基类优化。因为 MultiDerived1 的 Empty 与子类成员 Empty 是连续的,进行优化会发生冲突。
MultiDerived6:

进行了空基类优化。因为 MultiDerived1 的 Empty 不会与其他 Empty 发生冲突。

特殊的情况

再来看看比较特殊的情况,

class Foo
{
public:
  Empty e[4];
  Derived2 d;
};

class Foo1Helper : public Empty
{
public:
  std::int8_t i8[3];
};

class Foo1 : public Empty
{
public:
  Foo1Helper d;
};

class Foo2 : public Empty
{
public:
  Foo f;
};

int main()
{
  std::cout << "sizeof(Foo): " << sizeof(Foo) << '\n';
  std::cout << "sizeof(Foo1): " << sizeof(Foo1) << '\n';
  std::cout << "sizeof(Foo2): " << sizeof(Foo2) << '\n';
}

结果如下:

sizeof(Foo): 8
sizeof(Foo1): 4
sizeof(Foo2): 12

Foo 中的 Derived2 仍然进行了空基类优化,并没有因为 Foo 中的成员 Empty 与 Derived2 的基类 Empty 相邻而影响优化,从“空基类优化”这个名字也表明了该优化只与继承体系有关系,而不考虑被优化的类之外的干扰。
Foo1 也进行了空基类优化,但是比较特别,编译器首先考虑的是将子类成员变量 Foo1Helper 进行优化(理由同 Foo1 中的 Derived2),此时 Foo1Helper 内存空间中已不存在 Empty,所以也对 Foo1 进行了优化。
Foo2 没有发生空基类优化,因为第一个成员 Foo 的第一个成员变量是 Empty,与基类中 Empty 发生了冲突。

结论

当空类作为一个类的基类的时候,该空类占用的额外一个字节的内存空间在子类中将会被优化掉,除了一种情况外:在子类的内存空间中有连续的相同类型的空类出现时(无论该空类是作为基类,超基类,子类的第一个非静态成员变量,子类的第一个非静态成员变量的基类,子类的第一个非静态成员变量的成员,所有的这些都可以归纳为子类的内存空间中基类的空间与接下来的第一个内存块),为了区分连续的空类,将不进行空基类优化。
此外,在 C++11 中,空基类优化是强制性的,不再是可选的。


空类的应用

std::vector

在标准库中,使用到分配器(allocator-aware)的类大多利用到了空基类优化,进而避免无状态(stateless)的分配器成员占用额外的空间。

template <typename _Tp, typename _Alloc>
struct _Vector_base
{
  typedef _Alloc<_Tp> _Tp_alloc_type; // 分配器的具体类型
  typedef _Tp* pointer; // 存储类型

  // 数据存储的具体实现
  struct _Vector_impl : public _Tp_alloc_type
  {
    pointer _M_start; // 存储的开始
    pointer _M_finish; // 存储的结束
    pointer _M_end_of_storage; // 已经分配的空间,即capacity
  };
};

template <typename _Tp, typename _Alloc = std::allocator<_Tp>>
class vector : protected _Vector_base<_Tp, _Alloc>
{
};

以上是经过简化的 std::vector 的代码。我们需要关注的是 _Vector_base 中的 _Vector_impl。
对于 _Tp_alloc_type,我们也可以不让 _Vector_impl 继承于 _Tp_alloc_type,单独设置一个成员变量,

template <typename _Tp, typename _Alloc>
struct _Vector_base
{
  typedef _Alloc<_Tp> _Tp_alloc_type; // 分配器的具体类型
  typedef _Tp* pointer; // 存储类型

  // _Vector_impl利用该变量进行内存的分配
  _Tp_alloc_type _alloc;
    
  // 数据存储的具体实现
  struct _Vector_impl
  {
    pointer _M_start; // 存储的开始
    pointer _M_finish; // 存储的结束
    pointer _M_end_of_storage; // 已经分配的空间,即capacity
  };
};

由于无状态的分配器是空类,没有任何成员变量,这样处理的话会白白浪费了一个字节的存储空间,像std::vector 这样的使用率非常高的类来说,代价非常高。
所以标准库采用了空基类优化,将分配器额外的存储空间优化掉。

std::enable_if

template <bool _Cond, typename _Tp = void>
struct enable_if { };

template <typename _Tp>
struct enable_if<true, _Tp>
{
  typedef _Tp type;
};

以上是 GCC 中关于 std::enable_if 完整的代码。
enable_if 是空类,但是这里与空基类优化无关。当 _Cond 为 true 时,enable_if 进行了部分模板特化,其中的 typedef 是关键。
下面是 enable_if 的实例,

template <typename T>
typename std::enable_if<std::is_integral<T>::value,bool>::type is_odd(T i)
{
  return (i%2) == 1;
}

template <typename T, typename = typename std::enable_if<std::is_integral<T>::value>::type>
bool is_even(T i)
{
  return (i%2) == 0;
}

int main()
{
  int i { 2 }; // i是整型值
    
  std::cout << std::boolalpha; // bool值会展示成"true", "false"而不是"0", "1"
  std::cout << "i is odd: " << is_odd(i) << '\n';
  std::cout << "i is even: " << is_even(i) << '\n';

  double d { 2.0 }; // d是双精度浮点数

  std::cout << "i is odd: " << is_odd(i) << '\n'; // ERROR, 编译失败
  std::cout << "i is even: " << is_even(i) << '\n'; // ERROR, 编译失败
}

结果如下:

i is odd: false
i is even: true

在上述的两个例子中,_Cond 为 true 的模板特化中的 type 成为了关键,如果 _Cond 为 false,则使用type 会发生编译错误,因为在原型中没有 type。is_odd 利用的 type 作为返回值;is_even 则纯粹是利用 type 作为编译时的验证工具。

利用空类替代friend

关键字 friend 是一种强耦合,甚至强于继承,所以我们应当小心地使用 friend 或者尽量避免。
friend 的常见用途是访问另一个类的私有构造函数,

class Secret
{
  friend class SecretFactory;

private:
  // SecretFactory可以访问该构造函数
  explicit Secret(std::string str) : _data{std::move(str)} {}

  // SecretFactory同时也可以访问该函数,但是这可能会给我们造成麻烦
  void addData(const std::string& moreData) { _data.append(moreData); }

private:
  // SecretFactory无论如何也不应该访问该数据
  std::string _data;
};

在上述例子中,SecretFactory 可以访问不该访问的 _data,这会添加很多麻烦。
我们可以通过空类来限制 SecretFactory 可以访问的函数,

class Secret
{
public:
  class ConstructorKey {
    // 如果其他的类想要访问Secret的构造函数,可以在这里添加友元
    friend class SecretFactory;
  private:
    // 构造函数为private很关键
    ConstructorKey() {}; // ①
    ConstructorKey(const ConstructorKey&) = default; // ②
  };

  // 设置为public是为了让SecretFactory访问
  explicit Secret(std::string str, ConstructorKey) : _data{std::move(str)} {}

private:
  void addData(const std::string& moreData) { _data.append(moreData); }

  std::string _data;
};

class SecretFactory
{
public:
  Secret getSecret(std::string str) {
    // RVO
    return Secret { std::move(str), Secret::ConstructorKey{} };
  }

  void modify(Secret& secret, const std::string& additionalData) {
    // secret.addData(additionalData); // ERROR, addData是私有的,此时空类已经限制了SecretFactory访问Secret的函数
  }
};

int main()
{
  // Secret s { "Secret Class", ConstructorKey{} }; // ERROR, 无法访问ConstructorKey的构造函数

  SecretFactory sf;
  Secret s = sf.getSecret("Secret Class");
}

上例有两点需要解释,
对于①,ConstructorKey 的构造函数的访问权限是 private,只有对其为 friend 的类才能访问构造函数;不能将构造函数设置为 default,即 ConstructorKey() = default;,对于没有非静态成员的类(空类)来讲,即使默认构造为 private,依然可以通过统一初始化方式(uniform initialization)对其进行初始化,

class EmptyUniIni
{
  EmptyUniIni() = default;
};

int main()
{
  EmptyUniIni empty; // ERROE, 无法访问构造函数
  EmptyUniIni empty1 {}; // OK, uniform initialization
}

对于②,需要将复制构造函数设置为 private,否则的话可以通过下面的代码进行构造 Secret,

Secret::ConstructorKey* pk = nullptr;
Secret s { "Secret class", *pk };

这样的话,我们前边所做的努力就白费了。

std::input_iterator_tag, std::output_iterator_tag

// 用来标记input iterator
struct input_iterator_tag { };

// 用来标记output iterator
struct output_iterator_tag { };

// _Category即上述两个标签
template <typename _Category, typename... _Others>
struct iterator { };

// 简略写其他的template参数
template <typename... _Others>
class istream_iterator : public iterator<input_iterator_tag, _Others...>
{
};

template <typename... _Others>
class ostream_iterator : public iterator<output_iterator_tag, _Others...>
{
};

上述是简化的 GCC 代码。
由于 C++ 是强类型语言,input_iterator_tag 和 output_iterator_tag 虽然什么都没有,只有名字不同,他们也是不同的类型,所以 istream_iterator 的父类和 ostream_iterator 的父类是不同的,他们在继承层次上没有任何关系,即 input_iterator_tag 标记了 istream_iterator,output_iterator_tag 标记了ostream_iterator。
而且上述代码不会有任何性能上的缺陷,因为编译器会检查模板中的参数是否被使用,如果没有使用,则将该模板参数省略掉,进而不会影响性能。


总结

通过上述讲解,我们了解了空类的一些特性与应用场景,利用空基类优化或者与模板结合起来,会有奇妙的效果。


参考

[1] C++ Templates: The Complete Guide
[2] classes-and-objects
[3] Passkey Idiom: More Useful Empty Classes
[4] The "Empty Member" C++ Optimization
[5] Empty base optimization

上一篇下一篇

猜你喜欢

热点阅读