C++面向对象
2023-12-18 21:58:19 # 技术 # CS基础

C++面向对象知识

内存字节对齐

  • #pragma pack(n) 表示的是设置 n 字节对齐,windows 默认是 8 字节,linux 是 4 字节,鲲鹏是 4 字节
    1
    2
    3
    4
    5
    struct A{
    char a;
    int b;
    short c;
    };
  • char 占一个字节,起始偏移为零,int 占四个字节,min(8,4)=4;所以应该偏移量为 4,所以应该在 char 后面加上三个字节,不存放任何东西,short 占两个字节,min(8,2)=2;所以偏移量是 2 的倍数,而 short 偏移量是 8,是 2 的倍数,所以无需添加任何字节,所以第一个规则对齐之后内存状态为 0xxx|0000|00
  • 此时一共占了 10 个字节,但是还有结构体本身的对齐,min(8,4)=4;所以总体应该是 4 的倍数,所以还需要添加两个字节在最后面,所以内存存储状态变为了 0xxx|0000|00xx,一共占据了 12 个字节;
  • 内存对齐规则
    • 对于结构的各个成员,第一个成员位于偏移为 0 的位置,以后的每个数据成员的偏移量必须是 min(#pragma pack()指定的数, 这个数据成员的自身长度) 的倍数
    • 在所有的数据成员完成各自对齐之后,结构或联合体本身也要进行对齐,对齐将按照 #pragam pack 指定的数值和结构或者联合体最大数据成员长度中比较小的那个,也就是 min(#pragram pack(), 长度最长的数据成员)
  • 需要对齐的原因
    • 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常;
    • 硬件原因:经过内存对齐之后,CPU 的内存访问速度大大提升。访问未对齐的内存,处理器要访问两次(数据先读高位,再读低位),访问对齐的内存,处理器只要访问一次,为了提高处理器读取数据的效率,我们使用内存对齐;

面向对象三大特性

通过类创建一个对象的过程叫实例化,实例化后使用对象可以调用类成员函数和成员变量,其中类成员函数称为行为,类成员变量称为属性。类和对象的关系:类是对象的抽象,对象是类的实例

  • 封装 (数据抽象)
    • 把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。
    • publicprivateprotected
  • 继承
    • 基类(父类)——> 派生类(子类)
  • 多态(动态绑定)
    • 在 C++语言中,当我们使用基类的「引用」或「指针」来调用一个虚函数时将发生动态绑定。
    • 基类将希望其派生类进行覆盖的函数定义为「虚函数」。当我们使用指针或引用调用虚函数时,该调用将被动态绑定。根据引用或指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。
    • 成员函数如果没有被声明为虚函数,则其解析过程发生在「编译时」而非「运行时」。

双冒号、using和namespace

  • namespace主要用来解决命名冲突的问题
    • 必须在全局作用域下声明;
    • 命名空间下可以放函数,变量、结构体和类;
    • 命名空间可以嵌套命名空间;
    • 命名空间是开放的,可以随时加入新成员,添加时只需要再次声明 namespace,然后添加新成员即可;
  • 双冒号::作用域运算符
    • 全局作用域符::name):用于类型名称(类、类成员、成员函数、变量等)前,表示作用域为全局命名空间;
    • 类作用域符(class::name):用于表示指定类型的作用域范围是具体某个类的;
    • 命名空间作用域符(namespace::name): 用于表示指定类型的作用域范围是具体某个命名空间的;
  • using分为using声明和using编译指令
    • using std::cout; // 声明
    • using namespace std; // 编译指令
    • 尽量使用声明而不是编译指令,不同命名空间中可能会有相同的变量名,编译指令执行两个命名空间后,会产生二义性

内联函数和函数重载

  • 内联函数
    • 相当于把内联函数里面的内容写在调用内联函数处;
    • 相当于不用执行进入函数的步骤,直接执行函数体
    • 相当于宏,却比宏多了类型检查,真正具有函数特性;
    • 不能包含循环、递归、switch 等复杂操作;
    • 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数内联函数对于编译器而言只是一个建议,编译器不一定会接受这种建议,即使没有声明内联函数,编译器可能也会内联一些小的简单的函数。
  • C++的函数名称可以重复,称为函数重载。
    • 其中必须在同一作用域下的函数名称相同,不能是一个在全局,一个局部,或者不同的代码块中
    • 可以根据函数参数的个数、类型(const 也可以作为重载条件)、顺序不同进行函数重载,但不能用函数返回值进行重载
    • 当函数重载遇到函数默认参数时,要注意二义性。

      虚函数可以是内联函数吗

  • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联
  • 内联是在编译期内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
  • inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。

    构造函数/析构函数

    构造函数和析构函数,分别对应变量的初始化和清理,变量没有初始化,使用后果未知;没有清理,则内存管理会出现安全问题。
    当对象结束其生命周期,如对象所在的函数已调用完毕时,系统会自动执行析构函数。
  • 构造函数
    • 与类名相同,没有返回值,不写 void可以发生重载,可以有参数,编译器自动调用,只调用一次;
    • 系统会默认给一个类提供三个函数:默认构造函数(无参,函数体为空)、默认拷贝构造和析构函数(无参,函数体为空),其中默认拷贝构造可以实现简单的值拷贝;
    • 提供了有参构造函数,就不提供默认构造函数;提供了拷贝构造函数,就不会提供其他构造函数,若自己定义可有参构造,也需要自定义无参构造函数;
  • 析构函数
    • ~ + 类名,没有返回值,不写 void,不可以发生重载,不可以有参数,编译器自动调用,只调用一次;
    • 如果一个类中有指针,且在使用的过程中动态的申请了内存,那么最好自定义析构函数,在销毁类之前,释放掉申请的内存空间,避免内存泄漏;

拷贝构造函数与深浅拷贝

  • 如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
  • 拷贝构造函数,也即拷贝初始化的使用时机:
    • 使用已经创建好的对象初始化新对象 A a; A b = a; A c(a); b = c; // b = c不是初始化,调用赋值运算符
    • 将一个对象作为实参传递给一个非引用类型的形参时;
    • 从一个返回类型会非引用类型的函数返回一个对象时;
    • 用花括号列表初始化一个数组中的元素或一个聚合类中的成员;
      • 某些类类型还会对它们所分配的对象使用拷贝初始化。例如,当我们初始化标准库容器或是调用其 insertpush 成员时,容器会对其元素进行拷贝初始化(与之相对,emplace 成员创建的元素都进行直接初始化);
  • 深拷贝和浅拷贝:只有当对象的成员属性在堆区开辟空间内存时,才会涉及深浅拷贝,如果仅仅是在栈区开辟内存,则默认的拷贝构造函数和析构函数就可以满足要求
    • 浅拷贝:使用默认拷贝构造函数,拷贝过程中是按字节复制的对于指针型成员变量只复制指针本身,而不复制指针所指向的目标,因此涉及堆区开辟内存时,会将两个成员属性指向相同的内存空间,从而在释放时导致内存空间被多次释放,使得程序 down 掉
    • 浅拷贝的问题:当出现类的等号赋值时,系统会调用默认的拷贝函数——即浅拷贝,它能够完成成员的一一复制。当数据成员中没有指针时,浅拷贝是可行的。但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针将指向同一个地址,当对象快结束时,会调用两次 free 函数,指向的内存空间已经被释放掉,再次 free 会报错;另外,一片空间被两个不同的子对象共享了,只要其中的一个子对象改变了其中的值,那另一个对象的值也跟着改变了所以,这时,必须采用深拷贝;
    • 深拷贝自定义拷贝构造函数,在堆内存中另外申请空间来储存数据,从而解决指针悬挂的问题。需要注意自定义析构函数中应该释放掉申请的内存

我们在定义类或者结构体,这些结构的时候,最后都重写拷贝函数,避免浅拷贝这类不易发现但后果严重的错误产生。

只在堆上/栈上创建对象

  • 若将析构函数设置为私有的,则只能在堆上生成对象。
    • 原因:C++是静态绑定语言,编译器管理栈上对象的生命周期,编译器在为类对象分配栈空间时,会先检查类的析构函数的访问性。若析构函数不可访问,则不能在栈上创建对象
  • 若将 newdelete 重载为私有,则只能在栈上生成对象
    • 原因:在堆上生成对象,需要使用 new 关键词操作,其过程分为两阶段:第一阶段,使用 new 在堆上寻找可用内存,分配给对象;第二阶段,调用构造函数生成对象。将 new 操作设置为私有,那么第一阶段就无法完成,就不能够在堆上生成对象。

      this指针

  • 为什么会有 this 指针
    • 在类实例化对象时,只有非静态成员变量属于对象本身,剩余的静态成员变量,静态函数,非静态函数都不属于对象本身,因此非静态成员函数只会实例一份,多个同类型对象会共用一块代码由于类中每个实例后的对象都有独一无二的地址,因此不同的实例对象调用成员函数时,函数需要知道是谁在调用它,因此引入了 this 指针。this 指针是对象的首地址
  • this 指针的作用
    • this 指针是隐含在对象成员函数内的一种指针。当一个对象被创建后,它的每一个成员函数都会含有一个系统自动生成的隐含指针 thisthis 指针指向被调用的成员函数所属的对象(谁调用成员函数,this 指向谁),this 表示对象本身非静态成员函数中才有 this,静态成员函数内部没有
    • this 指针实际上是编译器对非静态成员函数做出的操作,在定义非静态函数时会往函数里传入 class *this 这个参数,在函数调用时会传入对象的地址。静态成员函数之所以没有 this 指针是因为静态成员函数先于对象产生,并且是所有对象共享的。
    • this 并不是一个常规变量,而是个右值,所以不能取得 this 的地址(不能 &this)。
    • 对非静态成员函数默认添加了this指针,类型为classname *const this
  • this 指针使用
    • 当函数形参与类成员变量名相同时,用 this 指针来区分
    • 为实现对象的链式引用,在类的非静态成员函数中返回对象本身,可以用return *thisthis指向对象,*this表示对象本身。

常函数和常对象

1
2
void func() const // 常函数,此处func为类成员函数
const Person p2; // 常对象
  • 常函数修饰的是this指针,不允许修改this指针指向的值,如果执意要修改常函数,可以在成员属性前加mutable
  • 常对象不允许修改属性,不可以调用普通成员函数,可以调用常函数

delete this合法吗

合法,但有前提:

  • 必须保证 this 对象是通过 new(不是 new[]、不是 placement new、不是栈上、不是全局、不是其他对象成员)分配的;
  • 必须保证调用 delete this 的成员函数是最后一个调用 this 的成员函数(因为成员函数需要使用 this 指针作为参数);
  • 必须保证成员函数的 delete this 后面没有调用 this 了;
  • 必须保证 delete this 后没有人使用了;

为什么空类大小不为0

sizeof(空class) = 1为了确保两个不同对象的地址不同

静态成员变量与静态成员函数

若将成员变量声明为static,则为静态成员变量,与一般的成员变量不同,无论建立多少对象,都只有一个静态成员变量的拷贝,静态成员变量属于一个类,所有对象共享。静态变量在编译阶段就分配了空间,对象还没创建时就已经分配了空间,放到全局静态区。

  • 静态成员变量
    • 最好是类内声明,类外初始化(以免类名访问静态成员访问不到)
    • 无论公有,私有,静态成员都可以在类外定义,但私有成员仍有访问权限
    • 非静态成员类外不能初始化
    • 静态成员数据是共享的。
  • 静态成员函数
    • 静态成员函数可以直接访问静态成员变量,不能直接访问普通成员变量,但可以通过参数传递的方式访问
    • 普通成员函数可以访问普通成员变量,也可以访问静态成员变量
    • 静态成员函数没有 this 指针。非静态数据成员为对象单独维护,但静态成员函数为共享函数,无法区分是哪个对象,因此不能直接访问普通变量成员,也没有 this 指针。

初始化列表的好处和使用条件

  • 对于类的数据成员来说,如果没有在构造函数的初始化列表中显式的初始化成员,则该成员将在构造函数体之前执行「默认初始化」
    • 在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。
  • 如果类的数据成员是 const 或是「引用」的话,则必须使用构造函数的初始化列表进行初始化
    • 类似的,当成员属于某种类型,且该类没有定义「默认构造函数」时,也必须将这个成员初始化
  • 好处
    • 初始化是直接初始化成员
    • 赋值是先初始化再赋值

能否通过初始化列表初始化静态成员变量

不能,静态成员变量最好类内声明,类外初始化。静态成员是单独存储的,并不是对象的组成部分。如果在类的内部定义静态成员变量,在建立多个对象时会多次声明和定义该变量的存储位置。在名字空间和作用域相同的情况下会导致重名的问题。

友元全局函数、友元类、友元成员函数

友元主要是为了访问类中的私有成员(包括属性和方法),会破坏 C++的封装性,尽量不使用。

  • 友元全局函数
    • 友元函数声明可以在类中的任何地方,一般放在类定义的开始或结尾
    • 一个函数可以是多个类的友元函数,只需要在各个类中分别声明
    • 友元函数在类内声明,类外定义,定义和使用时不需加作用域和类名,与普通函数无异。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Building
{
friend void goodGay(Building * building); //goodGay是Building的友元函数,因此goodGay可以访问building的任意成员
public:
Building(){
m_Sittingroom = "客厅";
m_Bedroom = "卧室";
}

string m_Sittingroom;
private:
string m_Bedroom;
};

//和C语言结构体同,传参时尽量不要传递值,尽量传递指针
void goodGay(Building * building){
cout << "别人在访问" << building->m_Sittingroom << endl;
cout << "别人在访问" << building->m_Bedroom << endl; //当不是友元函数时,不能访问私有成员
}

void test01(){
Building building; //或者Building *build = new Building;这里如果定义指针,需要new,否则未初始化
goodGay(&building);
}
  • 友元类
    • 友元不可继承
    • 友元是单向的,类 A 是类 B 的友元类,但类 B 不一定是类 A 的
    • 友元不具有传递性,类 A 是类 B 的友元类,类 B 是类 C 的友元类,但类 A 不一定是类 C 的友元类。
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 Building{
friend class Person; //Person是Building的友元函数,因此Person可以访问Building的任意成员
public:
Building(){
this->m_Sittingroom = "客厅";
this->m_Bedroom = "卧室";
}

string m_Sittingroom;
private:
string m_Bedroom;

};

class Person{

public:
void test(Building *building){
cout << building->m_Bedroom << endl;
}
};


void test01(){
Building *build = new Building;//可以在这里写定义,也可以将定义写在Person的构造函数中
Person p;
p.test(build);
}
  • 友元成员函数
    • 使类 B 中的成员函数成为类 A 的友元函数,这样类 B 的该成员函数就可以访问类 A 的所有成员
    • 当用到友元成员函数时,需注意友元声明和友元定义之间的相互依赖,在该例子中,类 Person 必须先定义,否则类 Building 就不能将一个 Person 的函数指定为友元。然而,只有在定义了类 Person 之后,才能定义类 Person 的该成员函数。更一般的讲,必须先定义包含成员函数的类,才能将成员函数设为友元。另一方面,不必预先声明类和非成员函数来将它们设为友元。
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
//和C语言中结构体的互引用类似,需要先声明一个Building类
class Building;

class Person{

public:
void test(Building *building);
void test1(Building *building);
};

class Building{
friend void Person::test1(Building *building); //先将Person类定义,类友元成员函数声明后,再使用friend
public:
Building(){
this->m_Sittingroom = "客厅";
this->m_Bedroom = "卧室";
}

string m_Sittingroom;
private:
string m_Bedroom;

};

//定义Building类后才能定义Person成员函数
void Person::test1(Building *building){
cout << building->m_Bedroom << endl;
}

void Person::test(Building *building){
cout << building->m_Sittingroom << endl;
}

void test01(){
Building *build = new Building;
Person p;
p.test1(build);
}

运算符重载及++重载实现

运算符重载基本属性

  • 运算符重载的目的是扩展C++中提供的运算符的适用范围,使之能作用于对象,或自定义的数据类型
  • 运算符重载的实质是函数重载,可以重载为普通函数,也可以重载为成员函数
  • 运算符重载也是多态的一种,和函数重载称为静态多态,表示函数地址早绑定,在编译阶段就确定好了地址

运算符重载总结

  • 重载运算符()[]->=的时候,运算符重载函数必须声明为类的成员函数
  • 重载运算符<<>>的时候,运算符只能通过全局函数配合友元函数进行重载
  • 不要重载 &&|| 运算符,因为无法实现短路原则。
1
2
3
4
5
// 重载 << 运算符
ostream& operator<<(ostream& os, const A& a) {
// ...
return os;
}

不能被重载的运算符

  • sizeof
  • . 成员运算符
  • :: 作用域解析运算符
  • ?: 条件运算符
  • typeid 一个RTTI运算符
  • 4种强制类型转换运算符

    i++和++i实现

C++内置类型的后置 ++ 返回的是变量的拷贝,也就是不可修改的右值;前置 ++ 返回的是变量的引用,因此可以作为可修改的左值。即 ++(++a)(++a)++ 都可以,但 ++(a++) 不可以,(C++默认必须修改 a 的值,如果不修改则报错)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//++i
int& int::operator++()
{
*this += 1
return *this
}

//i++,注意后置++有占位参数以区分跟前置++不同
const int int::operator++(int)
{
int oldValue = *this
++(*this);
return oldValue;
}

继承方式、对象模型、同名处理

继承主要是为了减少代码的重复内容,解决代码复用问题。通过抽象出一个基类(父类),将重复代码写到基类中,在派生类(子类)中实现不同的方法。

继承方式

  • 公有继承:保持父类中的访问属性
  • 私有继承:将父类中的所有访问属性改为private
  • 保护继承:除父类中的私有属性,其他改为保护属性

继承的对象模型

  • 子类中会继承父类的私有成员,只是被编译器隐藏起来了,无法访问到,通过sizeof(子类class)可以检查出。
  • 子类创建对象时,先调用父类的构造函数,然后再调用自身的构造,析构顺序与构造顺序相反
    • 由于继承中父类和子类的构造、析构顺序原因,当父类中只提供了有参构造(默认构造等函数会被隐藏),而子类仅仅调用默认构造时,会因为子类创建对象时无法调用父类构造函数而报错,这里可以让子类利用初始化列表来显式调用父类有参构造函数来进行父类构造,然后进行子类构造。
  • 子类会继承父类的成员属性和成员函数,但子类不会继承父类构造函数和析构函数

继承中的同名处理

  • 父类和子类成员属性同名,用子类声明对象调用子类属性,若想调用父类成员,则加上父类的作用域
  • 父类和子类成员函数同名,子类函数不会覆盖父类的成员,只是隐藏起来,用子类声明对象调用子类成员函数,若想调用父类函数(包括重载),则加上父类的作用域
  • 若子类中没有与父类同名的成员函数,子类声明对象后,可以直接调用父类成员函数。

多继承和菱形继承

多继承

多继承会产生二义性的问题。如果继承的多个父类中有同名的成员属性和成员函数,在子类调用时,需要指定作用域从而确定父类。

菱形继承

两个子类继承于同一个父类,同时又有另外一个类多继承于两个子类,这种继承称为菱形继承。

菱形继承会产生问题

  • **浪费空间。羊驼继承了两份动物类中的某些数据和函数,但只需要一份即可;
  • 二义性。从不同途径继承来的同名的数据成员在内存中有不同的拷贝造成数据不一致问题。 调用数据和函数时,会出现二义性,通过 sheep 类得到一个 age,通过 camel 类得到一个 age,两个数据会相互影响,相互修改,导致同一份数据不一致;

    解决菱形继承的问题

    使用虚继承,在**继承方式前加 virtual**(虚继承),这样的话羊驼可以直接访问 m_Age,不用添加作用域,且这样操作的是共享的一份数据。
    虚继承会引入额外的数据结构,称为虚基类表,用于跟踪虚基类的内存布局。这个表用于确保虚基类在派生类中的唯一性。
    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
    class Animal{
    public:
    int m_Age;
    };

    // 使用虚继承后,Animal成为一个虚基类,也会引入一个虚基类表
    class Sheep : virtual public Animal {
    int m_sheep;
    };

    class Camel : virtual public Animal {
    int m_camel;
    };

    class Son : public Sheep, public Camel{
    int m_son;
    };
    void test01(){
    Son son;
    son.m_Age = 10;
    cout << sizeof(Animal) << endl; // 4:m_Age
    cout << sizeof(Sheep) << endl; // 12:sheep-Vbptr,m_sheep,m_Age
    cout << sizeof(Camel) << endl; // 12:camel-Vbptr,m_camel,m_Age
    cout << sizeof(Son) << endl; // 24:sheep-Vbptr,m_sheep,camel-Vbptr,m_camel,m_son,m_Age
    }

  • 虚基类表指针
    • 虚基类表指针是一个指向虚基类表的指针,它存储在包含虚基类的派生类对象中。虚基类表指针用于在运行时定位虚基类的偏移量
    • 当一个类通过虚继承派生自一个或多个共享相同基类的类时,派生类的对象中会包含一个或多个虚基类表指针。这些指针用于在派生类对象中定位虚基类的位置。
  • 虚基类表
    • 虚基类表是一个存储了虚基类相关信息的数据结构。每个包含虚基类的类都有一个对应的虚基类表
    • 虚基类表中的每个条目存储了虚基类的偏移量和其他相关信息。它描述了虚基类在派生类对象中的位置和访问方式。
    • 当一个类通过虚继承派生自一个虚基类时,派生类的虚基类表会继承基类的虚基类表,并在自己的虚基类表中添加新的虚基类信息。
    • 虚基类表的具体实现依赖于编译器和平台。每个编译器可能会使用不同的方式来生成和管理虚基类表和虚基类表指针。
  • 特别注意:此时 son 没有自己的虚基类表和虚基类表指针,只是继承了 sheep 和 camel 的虚基类表指针和虚基类表,并修改了两个虚基类表中的值,修改为当前类中,如何通过继承的虚基类指针查找虚基类数据
  • Son 继承 Sheep 父类,父类中有虚基类表指针 vbptr(virtual base table pointer)对象结构类似结构体,首元素是虚基类指针,其余为自身数据(不包括静态成员和成员函数)
  • **Sheep 的虚基类表指针 vbptr 指向下面 Sheep 的虚基类表 vbtale@Sheep(virtual base table)**,虚基类表是一个整型数组,数组第二个元素值为 20,即 Sheep 的虚指针地址偏移 20 指向 Animal 的 m_Age 地址Camel 父类同理,因此,类中只有一个 m_Age 元素。
  • Son 中包含了两个指针和四个 int 类型,所以大小为 24。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Animal{
public:
int m_Age;
};
class Sheep:virtual public Animal{
int m_sheep;
};
class Camel :virtual public Animal{
int m_camel;
};

class Son :virtual public Sheep, virtual public Camel{
int m_son
};
void test01(){
Son son;
son.m_Age = 10;
cout << sizeof(Animal) << endl; // 4:m_Age
cout << sizeof(Sheep) << endl; // 12:sheep-Vbptr,m_sheep,m_Age
cout << sizeof(Camel) << endl; // 12:camel-Vbptr,m_camel,m_Age
cout << sizeof(Son) << endl; // 28:son-vbptr,m_son,m_Age,sheep-Vbptr,m_sheep,camel-Vbptr,m_camel,
}

  • 注意跟上面的区别,一个是 son 类中的元素顺序,一个是 son 类有了自己的虚基类表指针和虚基类表;
  • 虚继承
    • 一般通过虚基类表指针和虚基类表实现,将共同基类设置为虚基类
    • 每个虚继承的子类(虚基类本身没有)都有一个虚基类指针(占用一个指针的存储空间)和虚基类表(不占用类对象的存储空间),虚基类指针属于对象,虚基类表属于类
    • 当虚继承的子类被当做父类继承时,虚基类表指针也会被继承;
    • 虚表中只记录了虚基类数据在派生类对象中与派生类对象首地址 (虚基类指针) 之间的偏移量, 以此来访问虚基类数据
    • 虚继承不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间;
    • 虚基类表本质是一个整型数组

      静态函数可以是虚函数吗

      不可以,因为虚函数属于对象,不属于类,静态函数属于类。

      类型兼容性原则,为什么会有多态

  • *类型兼容规则是指在需要基类对象的任何地方,都可以使用「公有派生类的对象」来替代,如使用子类对象可以直接赋值给父类对象或子类对象可以直接初始化父类对象时,对于同样的一条语句,不管传入子类还是父类对象,都是调用的父类函数,但我们想实现的是同样的一条语句,传入不同的对象,调用不同的函数**,而这就是多态。
    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
    class Animal{
    public:
    void speak() {
    cout << "Animal speak" << endl;
    }
    };

    class Sheep :public Animal{
    public:
    void speak() { //重定义,子类重新定义父类中有相同名称的非虚函数
    cout << "Sheep speak" << endl;
    }
    };

    void doSpeak(Animal &animal) {
    animal.speak();
    }

    //想通过父类引用指向子类对象
    void test01(){
    Sheep sheep;
    doSpeak(sheep); //Animal speak;
    sheep.speak(); //sheep speak
    sheep.Animal::speak(); //Animal speak; //继承中的重定义可以通过作用域
    }

但我们想传入子类对象调用子类函数,传入父类对象调用父类函数,即同样的调用语句有多种不同的表现形态,这样就出现了多态

重载、覆盖、重写

  • 重载 (overload):(静态多态)
    • 是函数名相同,返回类型相同,但是参数列表不同。
    • 重载只是在同一个类的内部存在,但是不能靠返回类型来判断
    • 重载是在编译器期间根据参数类型和个数决定函数调用 (静态联编)
  • 覆盖 (override):子类重新定义父类中有相同名称参数虚函数。两者的函数特征相同。(动态多态)
    • 函数重写必须发生在父类与子类之间
    • 被重写的函数不能是 static 的。必须是 virtual
    • 重写函数必须有相同的类型,名称和参数列表
    • 重写函数的访问权限可以不同。尽管 virtualprivate 的,子类中重写改写为 public, protected 也是可以的。
  • 重定义 (overwrite):也叫做隐藏。子类重新定义父类中有相同名称的非虚函数 ( 参数列表可以不同 ) 。如果一个类,存在和父类相同的函数,那么,这个类将会隐藏其父类的方法,除非你在调用的时候,强制转换为父类类型或加上父类作用域。

多态实现的基础

  • 继承
  • 虚函数覆盖
  • 父类指针或引用指向子类对象访问虚函数
    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
    54
    55
    class Animal{
    public:
    virtual void speak(){ // 在父类中声明虚函数,可以实现多态,动态联编
    cout << "Animal speak" << endl;
    }
    int m_age = 0;
    };

    class Sheep : public Animal{
    public:
    void speak(){ // 发生多态时,子类对父类中的成员函数进行重写,virtual可写可不写
    cout << "Sheep speak" << endl;
    }
    int m_age = 1;
    };

    void doSpeak(Animal &animal){
    animal.speak();
    }

    void test01(){
    //传入子类对象调用子类成员函数
    Sheep sheep;
    doSpeak(sheep); //sheep speak;

    //子类对象直接调用子类成员函数
    sheep.speak(); //sheep speak;

    //子类对象通过作用域调用父类成员函数
    sheep.Animal::speak(); //animal speak;

    //基类成员不能转换为子类成员,即不能向下转换
    //Animal *animal0 = new Animal();
    //Sheep * sheep0 = animal0;
    //sheep0->speak();

    //同样不能向下转换
    //Animal animal0;
    //Sheep sheep0 = animal0;

    //父类指针指向子类对象
    Sheep *sheep1 = new Sheep();
    Animal *animal1 = sheep1;
    animal1->speak(); //sheep speak;

    //父类引用指向子类对象
    Sheep sheep2;
    Animal &animal2 = sheep2;
    animal2.speak(); //sheep speak;

    //子类对象直接赋值给父类对象,不符合多态条件,符合类型兼容性原则
    Sheep sheep0;
    Animal animal0 = sheep0;
    animal0.speak(); //animal speak;
    }

静态多态和动态多态

  • 静态多态(运算符重载、函数重载)
  • 动态多态(继承、虚函数)
    两者主要的区别:函数地址是早绑定(静态联编)还是晚绑定(动态联编)。即,在编译阶段确定好地址还是在运行时才确定地址。

    虚函数表指针和虚函数表

    在 C++中,虚函数表(Virtual Function Table,VTable)是实现动态多态性的关键机制之一。每个包含虚函数的类都有一个虚函数表,用于存储虚函数的地址。
  • 虚函数表指针(VTable Pointer):
    • 虚函数表指针是一个指向虚函数表的指针。它存储在每个包含虚函数的对象中,用于在运行时确定要调用的虚函数。
    • 当一个类声明了虚函数时,编译器会在该类的对象中插入一个虚函数表指针。这个指针指向类的虚函数表的起始地址。通过这个指针,可以在运行时找到类的虚函数表,并根据函数在虚函数表中的索引来调用正确的虚函数。
  • 虚函数表(VTable)
    • 虚函数表是一个存储了虚函数地址的数组。每个包含虚函数的类都有一个对应的虚函数表。虚函数表中的每个条目对应一个虚函数,存储了该虚函数的地址。
    • 虚函数表是在编译时由编译器生成的,并与类的每个对象实例分开存储。当一个类派生自其他类时,它会继承基类的虚函数表,并在自己的虚函数表中添加新的虚函数或覆盖基类的虚函数
  • 前提发生了多态,每个类中都有虚函数表,最开始的父类创建虚函数表,后面的子类继承父类的虚函数表,然后对虚函数重写
  • 虚函数重写(覆盖)的实质就是重写父类虚函数表中的父类虚函数地址
  • 实现多态的流程:虚函数表指针(vptr)->虚函数表(vtable)->函数指针->入口地址虚函数表(vtable)属于类,或者说这个类的所有对象共享一个虚函数表;虚函数表指针(vptr)属于单个对象
  • 在程序调用时,先创建对象,编译器在对象的内存结构头部添加一个虚函数表指针,进行动态绑定,虚函数表指针指向对象所属类的虚函数表
  • 虚函数表是一个指针数组,其元素是虚函数的指针,每个元素对应一个函数的指针。如果子类对父类中的一个或多个虚函数进行重写,子类的虚函数表中的元素顺序,会按照父类中的虚函数顺序存储,之后才是自己类的函数顺序。
  • 编译器根本不会去区分,传进来的是子类对象还是父类对象,而是关心调用的函数是否为虚函数。如果是虚函数,就根据不同对象的虚函数表指针 vptr 找属于自己的虚函数。父类对象和子类对象都有 vptr 指针,传入对象不同,编译器会根据 vptr 指针,到属于自己虚函数表中找自己的函数。即:vptr->vtable->函数的入口地址,从而实现了迟绑定 (在运行的时候,才会去判断)。

虚基类表指针和虚函数表指针的对比

虚基类表指针 虚函数表指针
生成条件 虚继承时产生 有虚函数时产生
指向 指向虚基类表 指向虚函数表
指向内容 派生类对象中的基类成员对于该对象首地址的偏移量 类中所有虚函数的地址
作用 解决菱形继承时的二义性和数据冗余的问题 是多态的基础

函数指针与指针函数

  • 指针函数int* f(int x, int y)本质是函数,返回值为指针,函数指针int (*f)(int x)本质是指针,指向函数的指针
  • 通常我们可以将指针指向某类型的变量,称为类型指针(如,整型指针)。若将一个指针指向函数,则称为函数指针。
  • 函数名代表函数的入口地址,同样的,我们可以通过根据该地址进行函数调用,而非直接调用函数名
1
2
3
4
5
6
7
8
void test001(){
printf("hello, world");
}

int main(){
void(*myfunc)() = test001; //将函数写成函数指针
myfunc(); // 调用函数指针 hello world
}

test001 的函数名与 myfunc 函数指针都是一样的,即都是函数指针。**test001 函数名是一个函数指针常量,而 myfunc 是一个函数指针变量,这是它们的关系。函数指针多用于回调函数**,回调函数最大的优势在于灵活操作,可以实现用户定制的函数,降低耦合性,实现多样性,如 STL 中。

C语言实现多态

可以让函数指针指向参数类型相同、返回值类型也相同的函数。通过函数指针我们也可以实现C++中的多态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include<iostream>
typedef int (*func)();

int print1(){
printf("hello, print1 \n");
return 0;
}

int print2(){
printf("hello, print2 \n");
return 0;
}

int main(int argc, char * argv[]){
func fp = print1;
fp();

fp = print2;
fp();

return 0;
}

怎么理解多态和虚函数

  • 多态的实现主要分为静态多态和动态多态,静态多态主要是重载,在编译的时候就已经确定;动态多态是用虚函数机制实现的,在运行期间动态绑定;
  • 举个例子:一个父类类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数;
  • 虚函数的实现:在有虚函数的类中,类的最开始部分是一个虚函数表的指针,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段 (.text) 中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率

构造函数能否实现多态/虚函数表指针什么时候初始化

两个问题本质是一样的,构造函数不能实现多态

  • 对象在创建时,由编译器对 vptr 指针进行初始化,只有当对象的构造完全结束后 vptr 的指向才最终确定
  • 子类中虚函数指针的初始化过程:
    • 当定义一个子类对象的时候比较麻烦,因为构造子类对象的时候会首先调用父类的构造函数然后再调用子类的构造函数。当调用父类的构造函数的时候,此时会创建 vptr 指针,该指针会指向父类的虚函数表;然后再调用子类的构造函数,子类继承父类的虚函数指针,此时 vptr 又被赋值指向子类的虚函数表;
    • 也就是说,会先调用父类构造函数,再调用子类构造函数,并不会只调用子类构造函数,是没法实现多态的;

构造函数能否是虚函数

不能,因为在调用构造函数时,虚表指针并没有在对象的内存空间中,必须要构造函数调用完成后才会形成虚表指针。

不要在构造函数中调用虚函数的原因

  • 第一个原因,在概念上,构造函数的工作是为对象进行初始化。在构造函数完成之前,被构造的对象被认为“未完全生成”。当创建某个派生类的对象时,如果在它的基类的构造函数中调用虚函数,那么此时派生类的构造函数并未执行,所调用的函数可能操作还没有被初始化的成员,这将导致灾难的发生。
  • 第二个原因,即使想在构造函数中实现动态联编,在实现上也会遇到困难。这涉及到对象虚指针(vptr)的建立问题。在 Visual C++中,包含虚函数的类对象的虚指针被安排在对象的起始地址处,并且虚函数表(vtable)的地址是由构造函数写入虚指针的。所以,一个类的构造函数在执行时,并不能保证该函数所能访问到的虚指针就是当前被构造对象最后所拥有的虚指针,因为后面派生类的构造函数会对当前被构造对象的虚指针进行重写,因此无法完成动态联编。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

class A{
public:
A() { show(); }
virtual void show() {
cout<<"in A"<<endl;
}
virtual ~A() {}
};

class B:public A{
public:
void show(){
cout<<"in B"<<endl;
}
};

int main(){
A a;
B b;
}

不要在析构函数中调用虚函数的原因

析构函数是用来销毁一个对象的,在销毁一个对象时,先调用该对象所属类的析构函数,然后再调用其基类的析构函数,所以,在调用基类的析构函数时,派生类对象的「善后」工作已经完成了,这个时候再调用在派生类中定义的函数版本已经没有意义了。

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
#include <iostream>
using namespace std;

class A{
public:
virtual void show(){
cout<<"in A"<<endl;
}
virtual ~A(){show();}
};

class B:public A{
public:
void show(){
cout<<"in B"<<endl;
}
};

int main(){
A a;
B b;
}
/*
in A
in A
*/

抽象类和纯虚函数

在程序设计中,如果仅仅为了设计一些虚函数接口,打算在子类中对其进行重写,那么不需要在父类中对虚函数的函数体提供无意义的代码,可以通过纯虚函数满足需求。

  • 纯虚函数的语法格式:virtual 返回值类型 函数名() = 0; 只需要将函数体完全替换为 = 0 即可。
  • 注意
    • 如果父类中出现了一个纯虚函数,则这个类变为了抽象类,抽象类不可实例化对象;
    • 如果父类为抽象类,子类继承父类后,必须实现父类所有的纯虚函数,否则子类也为抽象类,也无法实例对象,但纯虚析构函数例外,因为子类不会继承父类的析构函数

      虚析构和纯虚析构

  • 仅仅发生继承时,创建子类对象后销毁,函数调用流程为:父类构造函数->子类构造函数->子类析构函数->父类析构函数
  • 当发生多态时(父类指针或引用指向子类对象),通过父类指针在堆上创建子类对象,然后销毁,调用流程为:父类构造函数->子类构造函数->父类析构函数不会调用子类析构函数,因此子类中会出现内存泄漏问题。
    • 解决方法:将父类中的析构函数设置为虚函数,设置后会先调用子类析构函数,再调用父类析构函数;
  • 纯虚析构
    • 抽象基类的纯虚析构需要类内声明,类外实现(我们不能在类的内部为一个 = 0 的函数提供函数体)
    • 纯虚析构也是虚函数,该类也为抽象类;
    • 子类不会继承父类的析构函数,当父类纯虚析构没有实现时,子类不是抽象类,可以创建对象;

      为什么析构函数必须是虚函数

  • *因为当发生多态时,父类指针在堆上创建子类对象,销毁时可能会导致内存泄漏**。

为什么C++默认的析构函数不是虚函数

因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。

类模板和函数模板

通过 template<class T>template<typename T> 实现,主要用于数据的类型参数化,简化代码,有类模板和函数模板,函数模板是用于生成函数的,类模板则是用于生成类的。

  • 类模板和函数模板定义
    • template 声明下面是函数定义,则为函数模板,否则为类模板;
    • 注意:每个函数模板前必须有且仅有一个 template 声明,不允许多个 template 声明后只有一个函数模板,也不允许一个 template 声明后有多个函数模板 (类模板同理);
  • 类模板与函数模板的区别
    • 类模板不支持自动类型推导;
    • 数据类型可以有默认参数;