Inside C++ Object Model 读书笔记: Part2

(P)review: C++ 继承和设计

virtual

虚函数机制和继承几乎总是一起提的。编译器和链接器会保证:

  • 对象和(包括虚函数的)函数的正确关联。

这一点提供了运行期的多态。顺便提一嘴,实际上在 C++ 上运行期多态和编译器多态还是很清晰的,如果你没写过 C++ 直接去写 Rust 的话,可能会对下面的区别感到头晕:

1
2
3
4
5
6
7
trait Sample {
fn call();
}

fn call_sample1(s: Box<dyn Sample>) {}

fn call_sample2<T: Sample>(s: T) {}

我们可以回顾一下上一个笔记写的:

0FBB20FB-1264-4C0B-AE42-5211962234B8

你需要/可能 overridevirtual, 建议显式这么做。(overload = override )。同时也记住 final 这个关键字。

返回类型放松

C++ 返回是协变的,这意味着:

1
2
3
4
5
6
7
class Expr {
public:
Expr();
Expr(const Expr&);
virtual Expr* clone() = 0;
// ...
}

可以直接写:

1
2
3
4
5
6
7
class Cond: public Expr {
public:
Cond();
Cond(const Cond&);
Cond* clone() override { return new Cond(*this);}
// ...
}

实际上这个还是很常见的。

而一定的逆变性来自于:基类的指针可以承载子类对象。

virtual base class

AB0CEA77-F056-413A-A504-4D6D4BDFB768

这是 C++ Reference 中的建议。建议认为,虚基类应该为了“共享对象”而承载成员。

虚基类的构造语义有下列的补充:

  • 虚基类构造函数保证只调用一次,由最终对象调用
  • 虚基类的构造函数会在派生类的构造函数之前呗调用。

在 C++ 中,基类可以为了下列的目的或者混合的目的:

  • 接口继承
  • 实现继承

书上比较有意思的是写了个 lval_box 的例子,这里用 protect 来继承 implement,用 public 继承接口。

书上给了个很有趣的例子:

1
2
3
class X: public virtual Interface, protected Implement {

}

在继承链中把 interface 当成 virtual class 使用。

同时,关于虚继承有下列一些特点:

所有虚基类子对象都在任何非虚基类子对象之前初始化,故只有最终派生类会在其成员初始化器列表中调用虚基类的构造函数

Data 语义学

NonStatic Data Members

当对一个 nonstatic data member 做 load/store 的时候,编译器的基本操作会是:

  • 编译时已经知道对应的地址的偏移量
  • 效率等同于存取 C struct member

在引入虚拟继承之前,代价都是一致的,在引入虚拟继承后,如果通过 virtual base class 的引用访问它的可访问成员,那么这会在运行时决定具体访问的对象。(问题:在继承/多继承的层次中,基类指针是怎么表示的?怎么样导致这种 bias 的?)

当然,编译器可能有一定的上下文,来优化掉这种开销。

继承与 data member

实现的时候,如果没有 virtual base class,一个子类的对象布局类似于:

1
2
3
base class members
---
my members

一般来说,非 virtual base member 的基类成员,都会在对象的头部出现。

当 user 不需要运行时多态,即不使用 virtual class 的时候,对象会被”拼接“在一起。不过下面有几个疑问:

  • 如何处理 padding ?
  • 如何支持 RTTI?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// The parts below means 3.1 in the projects.

struct Concrete {
int32_t value;
char f1;
char f2;
char f3;
};

struct PartConcrete1 {
int32_t value;
char f1;
};

struct PartConcrete2 {
PartConcrete1 p1;
char f2;
};

struct PartConcrete3 {
PartConcrete2 p2;
char f2;
};

struct DerivedConcrete2: public PartConcrete1 {
char f2;
};

struct DerivedConcrete3: public DerivedConcrete2 {
char f3;
};

// the code below is testing code: println("below is 3.1 data part");
print_t_size<Concrete>(); // 8
print_t_size<PartConcrete1>(); // 8

print_t_size<PartConcrete2>(); // 12
print_t_size<PartConcrete3>(); // 16

print_t_size<DerivedConcrete2>(); // 12
print_t_size<DerivedConcrete3>(); // 12

这回可不是 ebo 了!

  • 组合的时候,clang 实现上还是会保证 padding 的
  • 继承的时候,可能会优化掉可能的 padding

ACD1A13D-FEC3-43E5-885C-DDF0E7BAEF06

书上的代码成书的时候实现还是比较类似组合这样的。接下来再对我们上述贴的类型进行一点小实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

DerivedConcrete2 d2 {1, 'a', 'b'};
DerivedConcrete3 d3 { 2, 'c', 'd', 'e'};

PartConcrete1* p1, *p2;
{
DerivedConcrete3 d_temp = d3;
p1 = &d_temp, p2 = &d2;
*p1 = *p2;
std::cout << d_temp.f2 << ' ' << d_temp.f3 << '\n'; // d e
}
{
DerivedConcrete3 d_temp = d3;
DerivedConcrete2* p21, * const p22 = &d2;
p21 = &d_temp;
*p21 = *p22;
std::cout << d_temp.f2 << ' ' << d_temp.f3 << '\n'; // b e
}

如果你没看前面的内容,只是自己写这些代码的话,上面的内容应该是非常符合直觉的,*p21 = *p22 如果改了 d_temp.f3 的话怎么想都是不合适的。但是你知道 DerivedConcrete2DerivedConcrete3 一样大之后,感觉就会奇怪起来。

实际上,可以在 cppreference 上找到子类型相关的定义:

In general, for any trivially copyable type T and an object obj1 of T, the underlying bytes of obj1 can be copied (e.g. by means of std::memcpy or std::memmove) into an array of char, unsigned char or std::byte or into obj2, a distinct object of T. Neither obj1 nor obj2 may be a potentially-overlapping subobject.

再加上 non-virtual 的多态后,对象需要添加一个 word 大小的 vptr,并且为这个类生成一个 vtable. 现在很多编译器将其置放在对象的起始部位。在多继承中,上述两种机制被混合起来,对象一个个排列,并且遵照之前说的方式进行。

但是这种转化有一个疑问就是,转化之后指针赋值应该怎么处理呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Base1 {
// members
int32_t v1;
};

class Base2 {
// members
int32_t v2;
};

class Derived: public Base1, public Base2 {
int32_t v3;
};

{
Derived d_object{};
Base1* pb1 = &d_object;
Base2* pb2 = &d_object;
println(pb1); // 0x7ffee0f0a940
println(pb2); // 0x7ffee0f0a944
println(reinterpret_cast<void*>(pb1) == reinterpret_cast<void*>(pb2)); // false
auto pd1 = static_cast<Derived*>(pb1);
auto pd2 = static_cast<Derived*>(pb2);
println(pd1); // 0x7ffee0f0a940
println(pd2); // 0x7ffee9496940
println(pd1 == pd2); // true
println(&d_object == pd1); // true
}

实际上,这里做了一个地址的 cast。

引入虚拟继承

2119B240-BD4F-4303-A8B9-D41000E16E28

在引入虚拟继承之后,class 会生成一个 vbase,同时动态的决定 vbase 对象的位置。同时,继承自 vbase 的类,会带有一个 vbase 对象的 ptrdiff。这种情况下,多个对象分部的 bias 会被指向同一个具体的布局位置。这种情况下,对象带来了一次 bias 寻址的开销。

Reference