目录
1、封装
2、继承
继承方式:
(1)公有继承;public
(2)保护继承;protected
(3)私有继承;private
菱形继承:
同名隐藏?
含义:
产生原因:
同名覆盖?(函数重写)
定义
作用
3、多态
(1)多态的分类
(2)虚表:
(3)代码示例
指针:
引用实现:
(1)派生类对象可以给基类,但基类不能给派生类。
(2)强制类型转换后,查的仍然是Base的虚表:
(3)定义obj类型的对象,访问的仍是Obj的虚表,
(4)继承关系中,动态创建派生类对象,但是是拿基类对象指向的,Object * op = new Base();在delete *op时,调用派生类的析构函数,解决办法是将基类的析构函数设为虚函数,之后,就可以先调用~Base();再调基类的析构是为什么?
原理分析
1、封装
封装是面向对象编程(OOP)的四大基本特性之一(另外三个是继承、多态和抽象),它是一种将数据(属性)和操作这些数据的方法(行为)捆绑在一起,并对外部隐藏对象的内部实现细节的机制。
class 类名:继承方式 基类名1,继承方式 基类名2,。。。继承方式 基类名n
{
派生类成员定义
};
2、继承
单继承:一个类只有一个直接基类。
多继承:一个类拥有多个直接基类。
继承方式:
(1)公有继承;public
- 基类的私有成员在派生类中不能直接访问。
- 基类的保护成员只能在派生类内部访问,不能在派生类外部访问, 在派生类中,继承而来的基类保护成员依然是protected。
- 基类的公有成员在派生类内部和外部都可以被访问得到,在派生类中,继承而来的基类的公有成员依然是public。
(2)保护继承;protected
- 基类的私有成员在派生类中不能直接访问。
- 基类的保护成员只能在派生类内部访问,不能在派生类外部访问, 在派生类中,继承而来的基类保护成员依然是protected。
- 基类的公有成员在派生类内部可以被访问得到,在派生类中,继承而来的基类的公有成员变成了是protected。
(3)私有继承;private
- 基类的私有成员在派生类中不能直接访问。
- 基类的保护成员只能在派生类内部访问,不能在派生类外部访问, 在派生类中,继承而来的基类保护成员是private。
- 基类的公有成员在派生类内部可以被访问得到,在派生类中,继承而来的基类的公有成员变成了private。
注:基类的私有成员在派生类中时存在的,但是不能在派生类中直接访问,即无论通过何种方式继承,都无法在派生类内部直接访问继承自基类的私有成员。只能通过基类中的公共函数,来访问基类的私有成员。绝大多数情况下的继承是公有继承。
菱形继承:
C在继承了B1类和B2类之后对于B1和B2中同样来自于A类的数据就会造成访问二义性问题。 会造成数据冗余。来自A的数据有两份。
解决办法:使用虚继承
派生类访问间接基类的数据时,实际上访问的是该类对象中的虚基表指针,通过虚基表指针访问到了虚基表,而虚基表中存储的内容是当前虚基表指针位置到间接基类成员的地址偏移量。那么这样子就能够在使用派生类访问间接基类成员时,通过偏移量直接找到继承而来的间接基类的成员。所以在内存中只用保留一份间接基类的成员就行 。
同名隐藏?
含义:
同名隐藏指在继承关系里,当派生类定义了和基类中同名的成员(包含成员变量和成员函数)时,基类的同名成员会被派生类的成员隐藏。这意味着在派生类的作用域内,若直接使用该成员名,默认访问的是派生类的成员,基类的同名成员就好像 “被隐藏” 了,若要访问基类的同名成员,需要使用作用域解析运算符(
::
)。产生原因:
这种机制源于 C++ 等语言在处理继承时的名称查找规则。当在派生类中使用一个名称时,编译器会先在派生类的作用域内查找该名称,若找到就使用该名称对应的成员,不再去基类的作用域中查找;若在派生类的作用域内没找到,才会去基类的作用域中查找。
同名覆盖?(函数重写)
在面向对象编程中,同名覆盖(也常被称为函数重写,Override)是一种重要的多态性机制,主要发生在具有继承关系的类之间。以下是关于它的详细介绍:
定义
当派生类中定义了一个与基类中虚函数具有相同签名(函数名、参数列表、返回值类型)的函数时,就发生了同名覆盖。此时,派生类的对象在调用该函数时,会执行派生类中重写的版本,而不是基类中的版本。
作用
同名覆盖是实现多态性的关键手段之一。通过它,我们可以在不修改基类代码的情况下,在派生类中根据具体需求对基类的虚函数进行重新定义,从而实现不同的行为。这样,当使用基类指针或引用指向不同的派生类对象时,调用相同的函数名可以产生不同的效果,提高了代码的可扩展性和可维护性。
3、多态
(1)多态的分类
编译时多态,在程序编译时确定同名操作和具体的操作对象。(早期绑定)
强制多态—强制类型转换
重载多态—函数重载和运算符重载
参数化多态—类模板及函数模板
运行时多态,在程序运行时才会确定同名操作和具体的操作对象。通过类继承关系和虚函数来实现。
包含多态—虚函数重写
虚函数的重写:三同:函数名、返回类型、参数列表
(2)虚表:
在 C++ 里,只要类包含虚函数,编译器就会为该类创建一个虚表(Virtual Table,简称 VTable)。虚表本质上是一个存储类的虚函数地址的指针数组,这个数组的首元素上存储RTTI(运行时类型识别信息的指针),从数组下标0开始依次存储虚函数地址。最后面放了一个nullptr。类的每个对象中都有一个指向该类虚表的指针(虚表指针,vptr)。
指针数组是指一个数组,其元素的类型为指针。也就是说,指针数组中的每个元素都存储着一个内存地址
虚函数地址表在 .data 区。
运行时多态:必须用指针或引用调用虚函数,对象.虚函数,这是编译时,不是运行时多态。
(3)代码示例
#include<stdio.h>
#include<iostream>
#include <cassert>
using namespace std;class Object
{
private:int value;
public:Object(int x = 0) :value(x){}~Object(){}virtual void add() { cout << "Object::add" << endl; }virtual void func() { cout << "Object::func" << endl; }virtual void print()const { cout << "Object::printf" << endl; }
};class Base :public Object
{
private:int num;
public:Base(int x=0):Object(x),num(x+10){}//重写虚函数virtual void add() { cout << "Base::add" << endl; }virtual void func() { cout << "Base::func" << endl; }virtual void show() { cout << "Base::show" << endl; }};class Test :public Base
{
private:int count;
public:Test(int x=0):Base(x),count(x+10){}virtual void add() { cout << "Test::add" << endl; }virtual void show() { cout << "Test::show" << endl; }virtual void print()const { cout << "Test::printf" << endl; }};void funcPobj(Object* pobj)
{assert(pobj != nullptr);pobj->add();pobj->func();pobj->print();
}
int main()
{Test test(10);funcPobj(&test);return 0;
}
以上代码,在内存中的虚表大致如下:
sizeof(Object):8;int+一个指向虚表的指针(32位操作系统)
指针:
通过虚表指针,访问Test类的虚表
引用实现:
(1)派生类对象可以给基类,但基类不能给派生类。
(2)强制类型转换后,查的仍然是Base的虚表:
(3)定义obj类型的对象,访问的仍是Obj的虚表,
访问obj的虚表,obj中没有派生类的show方法,执行到“000000”报错。这种强转可以理解为:无效的。
(Base*) & obj
和(Test*) & obj
)只是简单地改变了指针的类型,而不会改变对象本身的实际类型。obj
实际上是Object
类型的对象,尽管你把它的指针强制转换为Base*
或Test*
类型,但对象的内存布局和实际类型依旧是Object
。
- 虚函数调用:
Base
和Test
类继承自Object
类,并且有各自的虚表。当你把Object
类型的指针强制转换为Base*
或Test*
类型并调用虚函数时,程序会依据转换后的指针类型去访问相应的虚表。然而,obj
实际上是Object
类型的对象,它只有Object
类的虚表,这就会导致程序访问错误的虚表,从而引发未定义行为。- 成员访问:
Base
和Test
类可能包含Object
类没有的成员变量和成员函数。当你通过强制转换后的指针访问这些额外的成员时,程序会尝试访问不存在的内存位置,这也会导致未定义行为。
(4)继承关系中,动态创建派生类对象,但是是拿基类对象指向的,Object * op = new Base();在delete *op时,调用派生类的析构函数,解决办法是将基类的析构函数设为虚函数,之后,就可以先调用~Base();再调基类的析构。
在继承关系里,当使用基类指针指向动态创建的派生类对象,并且基类的析构函数不是虚函数时,在执行
delete
操作时只会调用基类的析构函数,这可能会造成派生类对象的部分资源无法正确释放,进而引发内存泄漏等问题。当基类的析构函数不是虚函数时,
delete
操作依据指针的静态类型来决定调用哪个析构函数。由于指针类型是基类指针,所以只会调用基类的析构函数,派生类的析构函数不会被调用。
基类析构不是虚函数示例代码如下:
#include <iostream>class Object {
public:~Object() {std::cout << "Object::~Object()" << std::endl;}
};class Base : public Object {
public:~Base() {std::cout << "Base::~Base()" << std::endl;}
};int main() {Object* op = new Base();delete op; return 0;
}
当把基类的析构函数设为虚函数后,
delete
操作会依据对象的实际类型来决定调用哪个析构函数。因为对象的实际类型是派生类,所以会先调用派生类的析构函数,然后再调用基类的析构函数。
#include <iostream>class Object {
public:virtual ~Object() {std::cout << "Object::~Object()" << std::endl;}
};class Base : public Object {
public:~Base() {std::cout << "Base::~Base()" << std::endl;}
};int main() {Object* op = new Base();delete op; return 0;
}
Base::~Base()
Object::~Object()
原理分析
- 虚表机制:当基类的析构函数被声明为虚函数时,编译器会为基类和派生类分别创建虚表。在对象的内存布局中,会有一个虚表指针指向对应的虚表。当执行
delete
操作时,程序会通过对象的虚表指针找到对应的虚表,然后从虚表中获取析构函数的地址并调用。由于对象的实际类型是派生类,所以会先调用派生类的析构函数。- 析构顺序:在 C++ 里,析构函数的调用顺序与构造函数的调用顺序相反。当创建派生类对象时,会先调用基类的构造函数,再调用派生类的构造函数;而在销毁对象时,会先调用派生类的析构函数,再调用基类的析构函数,以此确保对象的资源能够被正确释放。
(5)运行时多态是怎么实现的?
运行时多态主要基于继承和虚函数实现。当基类指针或引用指向派生类对象时,通过该指针或引用调用虚函数,程序会在运行时根据对象的实际类型来决定调用哪个类的虚函数,从而实现不同的行为。
用一个指针指向一个对象,调用函数的时候,指向对象虚表的地址给edx,调用第几个函数就(edx+偏移量 4n)
运行时多态怎么实现的(汇编)?例:
#include <iostream>class Base {
public:virtual void func1() {std::cout << "Base::func1()" << std::endl;}virtual void func2() {std::cout << "Base::func2()" << std::endl;}
};class Derived : public Base {
public:void func1() override {std::cout << "Derived::func1()" << std::endl;}void func2() override {std::cout << "Derived::func2()" << std::endl;}
};int main() {Base* ptr = new Derived();ptr->func1();ptr->func2();delete ptr;return 0;
}
当创建
Derived
类的对象并让Base
类型的指针ptr
指向它时,Derived
对象的内存布局起始位置会有一个虚表指针,该指针指向Derived
类的虚表。函数调用过程
- 获取虚表指针:当执行
ptr->func1()
时,程序首先通过ptr
指针找到对象的内存地址,进而获取对象的虚表指针,通常会把这个虚表指针的值存到某个寄存器(如你所说的edx
)中。- 计算函数地址:虚表本质是一个存储函数指针的数组,每个函数指针在虚表中按声明顺序排列,且每个指针占一定字节数(在 32 位系统中一般是 4 字节,64 位系统中是 8 字节)。要调用第
n
个虚函数,就需要在虚表指针的基础上加上偏移量4n
(32 位系统)或8n
(64 位系统)来获取该函数的地址。例如,调用func1()
时,偏移量为 0;调用func2()
时,偏移量为 4(32 位)或 8(64 位)。- 调用函数:获取到函数地址后,程序就会跳转到该地址处执行相应的函数代码。
4、静态联编和动态联编
静态联编:在编译和链接阶段,就将函数实现和函数调用关联起来。
C语言中,所有的联编都是静态联编。
C++语言中,函数重载和函数模版也是静态联编。
C++中,对象.成员运算符,去调用对象虚函数,也是静态联编。
动态联编:程序执行的时候才将函数实现和函数调用关联起来。
C++中,使用引用、指针->,则程序在运行时选择虚函数的过程称为动态联编。
5、例题:memset对vptr的影响:
class Object
{
private:int value;
public:Object(int x = 0) :value(x){memset(this, 0, sizeof(Object));}void func(){ cout << "func" << endl; }virtual void add(int x) { cout << "obj add" << endl;}
};
int main()
{Object obj;Object* op = &obj;obj.add(1); //静态联编op->add(2); //报错
}
op->add(2);编译会报错
原因:
1.
memset
对虚表指针的影响在 C++ 里,要是一个类包含虚函数,编译器会为这个类创建虚表,并且在类的每个对象里插入一个虚表指针(
vptr
),此指针一般处于对象内存布局的起始位置。memset(this, 0, sizeof(Object));
这个操作会把对象的整个内存区域都置为 0,这就包含了虚表指针。一旦虚表指针被置为 0,就无法正确指向对应的虚表。2. 虚函数调用机制
当借助基类指针(这里是
op
)调用虚函数(像op->add(2);
)时,程序会通过对象的虚表指针找到对应的虚表,再从虚表中获取该虚函数的地址,最后调用这个函数。但由于虚表指针被memset
置为 0 了,程序就无法找到正确的虚表,从而引发运行时错误。3. 直接对象调用和指针调用的区别
obj.add(1);
:这是直接通过对象调用虚函数。在这种情形下,编译器能够在编译时就确定要调用的函数,所以不会借助虚表指针,也就不会受到memset
操作的影响。op->add(2);
:这是通过指针调用虚函数,需要在运行时依靠虚表指针来确定要调用的函数。由于虚表指针被置为 0,程序就无法找到正确的虚表,进而导致运行时错误。
6、例题:
class Object
{
private:int value;
public:Object(int x = 0) :value(x){}void print() {cout << "obj::print" << endl;add(1);}virtual void add(int x) { cout << "obj::add"<<x << endl;}
};
class Base :public Object {
private:int num;
public :Base(int x = 0) :Object(x + 10), num(x){}void show() { cout << "Base::show" << endl; print(); //this->print();}virtual void add(int x) { cout << "base::add" << x << endl; }
};
int main()
{Base base;base.show();return 0;
}
类的成员函数在调用数据时有this,
调用过程分析
main
函数中调用base.show()
:创建了一个Base
类的对象base
,然后调用其show
方法。Base::show
方法中调用Base::show
方法里调用了Base
类没有重写Object
的this
指针指向的是Base
类的对象base
。Object::print
方法中调用add
方法:在Object::print
方法中调用了add(1)
。因为add
方法在Object
类中被声明为虚函数(virtual void add(int x)
),并且Base
类重写了该虚函数,所以在运行时会根据this
指针所指向对象的实际类型来决定调用哪个add
方法。由于this
指针指向的是Base
类的对象base
,所以会调用Base
类中重写的add
方法。
如果在构造、析构函数里调用虚函数,调用谁的?答:调用自身类型的。不会查虚表。
7、动态+静态联编例题:
class Object
{
private:int value;
public:virtual void func(int a=10) { cout << "obj::func: a"<<a << endl; }
};
class Base :public Object {private:virtual void func(int b = 20) { cout << "Base::func: b"<<b << endl; }
};
int main()
{Base base;Object* op = &base;op->func();return 0;
}
1. 虚函数调用机制
在 C++ 里,当使用基类指针(如
Object* op
)指向派生类对象(如Base base
),并且通过该指针调用虚函数(如op->func()
)时,会在运行时依据对象的实际类型来决定调用哪个类的函数版本。由于op
指向的是Base
类的对象base
,所以会调用Base
类中重写的func
函数。2. 默认参数的绑定规则
默认参数是在编译时确定的,而不是运行时。当调用
op->func()
时,编译器会查看指针的静态类型(也就是Object*
)来确定默认参数的值。在Object
类中,func
函数的默认数a
被设定为 10,所以在调用func
函数时,默认参数的值会使用Object
类中定义的 10,而非Base
类中定义的 20。3. 总结
结合虚函数调用机制和默认参数的绑定规则,
op->func()
会调用Base
类的func
函数,不过默认参数会使用Object
类中定义的 10,因此输出结果为Base::func: b 10
。
C++中,构造函数不能为虚。
构造函数的任务:设置虚表指针。
构造函数的主要作用是初始化对象的成员变量,为对象分配内存并设置初始状态。在创建对象时,编译器已经明确知道要创建的对象类型,因此可以直接调用相应的构造函数,不需要通过虚函数机制在运行时动态确定。
构造函数执行时,对象还未完全创建好,虚表指针可能还未被正确初始化。如果构造函数是虚函数,就需要通过虚表指针来调用它,但此时虚表指针可能还没指向正确的虚表,这会导致无法正确调用构造函数。