什么是C++对象模型

分为两类,其一是语言中直接面对程序设计的部分,其二是各种底层的实现机制。(相当于一个是怎么用,一个是如何实现的吧)。

底层实现其实会随着编译器的不同而变化的。

关于对象

布局成本(是存储成本吗?)

从c到C++,一个很大的变化是加了封装,数据和函数都放到同一个对象内,但这其实并没有增加多少布局成本,由于函数虽然声明在对象中,但编译器不会把那些函数的代码存放到对象中,而且每个函数只会有一个代码实例。C++在布局和存储时间上的主要开销是由virtual引起的:

  • virtual function机制,用来支撑执行期绑定
  • virtual base class,用来实现多次出现在继承体系中的基类,有一个单一而被共享的实例

C++对象模式

C++中有两种类成员:static与non-static,有三种类成员函数:static、non-static与virtual。

C++对象模型

非静态数据成员放到每一个对象中,静态数据成员,静态和非静态的函数放到其他地方。虚函数通过vtbl和vtpr实现。每一个对象有一个指针(vptr)指向vtbl。

关键字带来的差异

为了支持C的声明操作,8种整数,struct,C++可以比现在简单些。

对象的差异

常见的范式:(貌似是一种编程规范?)

  • 程序模式:像C一样的

  • 抽象数据类型(OB):抽象的类型?封装

  • 面对对象模型(OO):模型如果有相关的类型,要通过一个抽象的基类封装起来。封装、抽象、多态

构造函数语义学

默认构造函数

在以下场合,如果用户没有定义,则会由编译器来合成默认构造函数:

  1. 带有默认构造函数的成员类对象,这种情况下,该类的构造函数是nontrivial的,需要被编译器合成。但是也只会初始化带有默认构造函数的成员类对象,其他对象和内置变量不会初始化。即编译器合成的默认构造函数只是为了满足继续编译最低程度的需求,超过此限度,一律不管。

    如果用户已经定义好了默认构造函数,但没有初始化成员类对象,那么相应的初始化代码会被编译器悄悄地加在默认构造函数的最前面。

    子类有默认构造函数 子类没有默认构造函数,但有带参数的构造函数
    父类有构造函数函数(不管是不是默认构造函数,编译器都不会合成了) 编译器会在构造函数前插入代码,使其调用所有子类的默认构造函数 合成失败,只能手动定义,如下代码所示
    父类无任何默认构造函数 编译器合成基本的默认构造函数 合成失败,只能手动定义
    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
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    class Member {
    public:
    Member(int value) : value(value) {
    std::cout << "Member constructed with value: " << value << std::endl;
    }

    void display() const {
    std::cout << "Value: " << value << std::endl;
    }

    private:
    int value;
    };

    class Container {
    public:
    // Container 的构造函数需要初始化 Member
    // Container(int memberValue) : member(memberValue) {
    // std::cout << "Container constructed." << std::endl;
    // }

    void displayMember() const {
    member.display();
    }

    private:
    Member member; // Member 没有默认构造函数
    };

    int main() {
    Container container{};
    container.displayMember();
    return 0;
    }
    -------------------------------------------------
    init.cpp: In function ‘int main()’:
    init.cpp:33:25: error: use of deleted function ‘Container::Container()’
    33 | Container container{};
    | ^
    init.cpp:17:7: note: ‘Container::Container()’ is implicitly deleted because the default definition would be ill-formed:
    17 | class Container {
    | ^~~~~~~~~
    init.cpp:17:7: error: no matching function for call to ‘Member::Member()’
    init.cpp:5:5: note: candidate: ‘Member::Member(int)’
    5 | Member(int value) : value(value) {
    | ^~~~~~
    init.cpp:5:5: note: candidate expects 1 argument, 0 provided
    init.cpp:3:7: note: candidate: ‘constexpr Member::Member(const Member&’
    3 | class Member {
    | ^~~~~~
    init.cpp:3:7: note: candidate expects 1 argument, 0 provided
    init.cpp:3:7: note: candidate: ‘constexpr Member::Member(Member&&)’
    init.cpp:3:7: note: candidate expects 1 argument, 0 provided
  2. 带有默认构造函数的基类。同上,

    • 没有定义构造函数,则会合成一个默认构造函数,调用基类的默认构造函数

      • 基类有默认构造函数:成功
      • 基类没有默认构造函数,报错
    • 定义了构造函数(无论是不是默认的),会检查每个构造函数,确保有对基类构造函数的调用,没有就添加默认的构造函数。

      • 基类有默认构造函数:成功
      • 基类没有默认构造函数,报错
  3. 带有虚函数的类

    主要是需要设定vptr初值。对于类的每一个构造函数,编译器都需要在其中安插代码设定vptr初值。如果没有任何构造函数,那么编译器就需要生成一个默认构造函数,并设定vptr初值。

  4. 带有一个虚基类的类

    同上,主要是为了设定vbptr

拷贝函数语义学

当没有显示的构造函数时,是否会合成一个默认拷贝函数?关键在于类是否实现了bitwise copy 语义,实现了则可以直接拷贝(应该是将类的地址上的值逐字节拷贝到另一个类吧),而不需要构造函数了,如果没有实现,则需要编译器合成一个构造函数。如何才能不要bitwise copy 语义呢?

  1. 一个类中的某一个成员有拷贝构造函数时(无论该函数是被程序员写的还是被编译器合成的)(很好理解吧:如果定义了更精细化的赋值操作,为什么还需要bitwise拷贝呢,)

  2. 当class继承自一个基类,而该基类有拷贝构造函数时(同上)

  3. 当类中有一个或多个虚函数时

    主要是类中的变量多了个vptr,这不应该被随便复制。(一旦涉及到将派生类对象赋值给基类对象,就会出大问题)。所以这里需要通过函数来显式指定vptr。

  4. 当类派生自一个继承串链,其中有一个或多个虚基类时。

    其实为了实现虚基类,派生类中会包含vbptr指针,这个指针与vptr一样,不能把派生类的vbptr直接赋值给基类的vbptr,所以不能用bitwise copy。(同类的bitwise copy都是可以的,不同类的才会出错)

插入一点虚基类:

C++ 虚继承实现原理(虚基类表指针与虚基类表)-CSDN博客

程序转换语义学

显示的初始化操作

X x1(x0)会被转化为两步伪代码,X x1;x1.X::X(x0)

(隐式的)参数的初始化
  • 一种策略是引入临时对象,调用拷贝构造函数将其初始化,然后以引用的方式将其传递给函数
  • 一种方式是以拷贝建构的方式将实际参数直接建构在他应该的位置上,
返回值的初始化

一种做法是在调用函数之前先建一个对象,以引用类型的参数的方式传入函数,然后在函数返回之前,将要返回的对象拷贝到该参数中。

在使用者层面做优化

返回一个临时对象

在编译器层面做优化

如果程序员定义的函数中,返回的参数对象都是相同的,那么编译器可能用引用类型的参数替代函数中定义的变量。即NRVO(命名返回值优化)

NRVO优化 对于下列代码:

1
2
3
4
5
6
C a, b; C c = add(a, b); 
//如果编译器未开启NRV优化,会生成如下东东:
C __temp0; // 构造函数.
add(__temp0, a, b);
C c(__temp0); // 拷贝构造函数. 而开启NRVO优化后,只会生成下列代码:
add(c, a, b);

所谓的NRVO优化,即保存返回值的变量不再使用编译器内部生成的__temp0这样的东西,而是直接把c作为返回变量。摘自NRVO优化

RVO:如果返回值是一个临时对象,将会直接在调用者的内存上构造函数

NRVO:如果函数按值返回类的某一个对象(不能返回不同的对象喔,只可能返回一个具体的类的对象),并且返回语句的表达式是一个具有自动存储期限的非易失性对象的名称(即不是一个函数参数),那么可以省略优化编译器所要进行的拷贝/移动。如果是这样的话,返回值就会直接构建在函数的返回值所要移动或拷贝的存储器(即被拷贝省略优化掉的拷贝或移动的存储器)中。

可以看看例子:Copy elision - cppreference.com(RVO)和(NRVO)

关于67页的纠错:第二章构造函数语义学–关于NRV优化和copy constructor

拷贝构造函数

tips:如果需要定义拷贝构造函数,那么使用memcpy、memset比memberwise copy 高效,但只对类内部没有vptr、vbptr值时才能使用。

成员的初始化列表

当出现一下状况时,建议使用初始化列表

  1. 初始化一个引用成员时

  2. 初始化一个const成员时

  3. 调用一个基类的构造函数,而它有一组参数时

  4. 调用一个member class的构造函数,而它有一组参数时

    类的构造函数,首先完成基类的初始化,然后完成初始化列表的初始化(按照声明顺序;如果某个类成员对象没有被初始化,也要调用默认构造函数),最后执行函数体中用户定义的语句。

DATA语义学

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <bits/stdc++.h>
using namespace std;
class X{};
class Y: public virtual X{};
class Z: public virtual X{};
class A: public Y, public Z{};
int main(){
X x;
Y y;
Z z;
A a;
cout << sizeof(x) << endl << sizeof(y) << endl << sizeof(z) << endl << sizeof(a) << endl;

return 0;
}
--------------------------------------------------------
1
8
8
16

Data Member的确定

?讲了什么,历史上的规则吗

Data Member的布局

C++标准要求,同一个access section(private 等)的members在内存上的排列顺序按照声明顺序进行,可以不连续,只要保证先声明的在前面就行了,不同access section就不知道了。

Data Member的存取

通过类的点运算符和通过指针直接访问类的成员变量有什么区别:

静态数据成员

这是通过指针和对象来访问成员,结论完全相同的唯一一种情况。因为该对象并不在class object之中,存取并不需要通过类对象。

非静态数据成员

每一个非静态数据成员的偏移位置在编译时就可知,因此存取一个非静态数据成员,他的效率和存取C结构体是一样的(非静态数据成员的地址由对象地址和偏移地址确定)

如果涉及到虚拟继承时:

  • 如果你访问的非静态数据成员是类对象,单一继承多重继承的情况下都完全相同。
  • 但如果你访问的非静态数据成员是虚基类的成员,存取速度会慢一点。

继承与数据成员

只有继承没有虚函数

具体继承(concrete inheritance,相对于虚拟继承vitrual inheritance)不会增加空间和时间上的负担。将本可以在一个类中定义完的值分到基类中去定义,有如下两个可能的缺点:

  • 可能会设计一些操作相同的函数(如拷贝构造和拷贝函数),其实本来可以通过调用inline函数来实现。
  • 可能会由于内存对齐导致花费更大的空间。
单一继承并且有虚函数

有虚函数后,会带来一定空间和时间负担:

  • 导入一个与类有关的虚表,存放虚函数
  • 在每一个类对象新定义一个vptr,
  • 加强构造初始函数,为vptr设定初值,
  • 加强析构函数,使它能够删除vptr

把vptr放到尾部,便于实现C结构体struct性质;把vptr放到头部,便于实现多重继承等。

多重继承

单一继承提供了一种自然多态的形式,派生类和基类的转换非常自然:

1
2
Point3d p3d;
Point2d *p = &p3d;

如上,由于两个类开头的地址都是一致的,这个转换非常容易,效率也高。但是,如果基类没有虚函数,派生类有虚函数,这时将派生类转化为基类就需要编译器的介入了。多重继承有虚函数时也需要介入。

对于多重派生对象,只有将地址复制给最左端对象的指针,情况才和单一继承相同,对于第二个以及之后的对象,都需要修改地址。(让指针指向该对象被放置的地址)

tips:多重继承,没有虚基类,派生类对象中应该包含每一个基类对象,子类有几个vptr,派生类也有几个。

虚拟继承

继承时,istream和ostream中不变的部分直接保存,共享区域的数据(ios)被间接存取。(为什么要分开存储?不分开存储怎么保证类中只包含一个虚基类呢)不同的编译器实现不同。主要包含两种方式:

  • 引入虚基类表,每个类对象会包含一个虚基类指针,指向虚基类表,在虚基类表中获取到虚拟类的地址。(按照书中说法Microsoft应该是这么实现的)

  • 在虚函数表的前面放置虚基类的偏移。(比如vptr[0]放虚函数地址,vptr[-1]放虚基类的偏移)

对象成员的效率

作者比较了通过数组,结构体,class来存取数据的时间消耗差异。发现开了优化后,时间消耗都相等。

指向数据成员的指针

可以获取类的成员的指针(还没有生成对象哈),该地址是该数据在类中的偏移地址,该地址其实是真实偏移地址+1,使用该地址时,也需要-1;

指向数据成员的指针的效率

虚拟继承会妨碍优化的有效性,导致效率降低

函数语义学

成员函数主要包含:静态函数、非静态成员函数、虚函数

Member的各种调用方式

非静态成员函数

C++提出的一个准则:非静态成员函数必须和一般的非成员函数有相同的效率。

非静态成员函数转化为非成员函数包含如下步骤:

  1. 改写函数签名(原型)以安插一个额外的参数到成员函数中,即this指针
    • 如果用了const修饰,则会传const XXX *const this指针。
    • 没有const修饰,则传XXX *const this指针。
  2. 将每一个对非静态数据成员的存取操作改为对this指针进行存取。
  3. 将成员函数重写为一个外部函数,

比如说:

名称的特殊处理(Name Mangling)

编译器一般会根据函数的名称,参数个数,参数类型来生成独一无二的名字。如果两个类型有相同的名字,就会导致链接器无法决议(resolved)。

虚拟成员函数

将会通过虚函数表调用:(*ptr->vptr[1])(ptr);

但如果是通过对象来访问虚函数,通过虚函数表来获取虚函数地址就显得多此一举了,毕竟只能访问当前类的函数。

静态成员函数

参数中不会被编译器加上this指针,他的地址就是在内存中的地址,他的地址类型是一个非成员函数的指针,而不是一个指向成员函数的指针,比如

  • 他的函数类型可以是 unsigned int (*)();
  • 如果是成员函数,unsinged int (Point3d::*)();

静态成员函数非常像非成员函数,十分适合用在线程函数身上

虚拟成员函数

通过指针调用虚函数时,需要知道指针所指对象的真实类型,以及函数的地址。

虚函数表主要包含两部分

  • 一个类型信息

  • 函数包括,这一类所定义的函数实例、基类的函数实例,纯虚函数实例,其中不包括非虚函数。

当继承自一个有虚函数的类时,改写虚函数表有三种情况:

  1. 继承基类的虚函数,
  2. 使用自己覆写的(不知道词准不准)虚函数,对应索引的函数地址就必须换成自己新定义的。(一定要对应啊)
  3. 定义新的虚函数,就需要在表末端加上函数的地址。

对于一个式子:ptr->z();在编译时不知道具体的函数地址(如果是通过对象调用的,那应该知道吧),但是该语句可以被变成:(*ptr->vptr[4])();在执行期才会知道虚表的第四个位置究竟放的是哪一个函数。

在一一个单一继承体系中, virtual function 机制的行为十分良好,不但有效率而且很容易塑造出模型来。但是在多重继承和虚拟继承之中,对virtual functions的支持就没有那么美好了。

多重继承下的虚函数

派生类支持虚函数的困难的,都落在了第二个以及后续的基类上。

如果要将派生类的指针赋值给基类的指针,需要加上一个偏移。

如果要通过基类指针访问基类的成员,通过上述的偏移就够了。

如果还需要通过基类指针访问派生类的函数,

  • 第一需要知道派生类的函数地址,
  • 第二需要知道本指针对应的派生类的this指针(作为第一个this参数)
1
2
3
4
(*pbase2->vptr[1])( pbase2 ) ;
将被变为:
( *pbase2->vptr[1].faddr )
( pbase2 + pbase2->vptr[1].offset ) ;

如何处理更改this地址呢,一种做法是Thunk技术:其实就是一段汇编代码,他的任务有两个:

  • 调整this指针
  • 跳转到虚函数中去

这样虚函数表既可以填虚函数地址,也可以填一个thunk。

通过非第一个基类指针访问派生类函数、以及通过指向派生类的指针访问从子类继承来的虚函数、运行返回值类型可以改变时,指针都需要调整。

虚拟继承

太难了,之后写吧

函数的效能