当前位置: 首页 > news >正文

C++封装、继承、多态(虚函数)

目录

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();再调基类的析构。

原理分析

(5)运行时多态是怎么实现的? 

运行时多态怎么实现的(汇编)?例:

函数调用过程

4、静态联编和动态联编

5、例题:memset对vptr的影响:

1. memset 对虚表指针的影响

2. 虚函数调用机制

3. 直接对象调用和指针调用的区别

6、例题:

调用过程分析

7、动态+静态联编例题:

1. 虚函数调用机制

2. 默认参数的绑定规则

3. 总结

C++中,构造函数不能为虚。


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();再调基类的析构。

虚析构函数:

析构函数作用:释放对象在生命期内获得的资源(如动态分配的内存,内核资源)

基类的指针可以指向派生类对象,如果使用基类类型指针指向动态创建的派生类对象,

Object *op = new Base();

由该基类指针撤销派生类对象,则必须将基类析构函数定义为虚函数,实现多态性,自动调用派生类析构函数,否则可能会内存泄漏。

C++中,继承+虚函数。

在继承关系里,当使用基类指针指向动态创建的派生类对象,并且基类的析构函数不是虚函数时,在执行 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, 

调用过程分析
  1. main 函数中调用 base.show():创建了一个 Base 类的对象 base,然后调用其 show 方法。
  2. Base::show 方法中调用 print 方法:在 Base::show 方法里调用了 print 方法,由于 Base 类没有重写 print 方法,所以实际上调用的是基类 Object 的 print 方法。这里的 this 指针指向的是 Base 类的对象 base
  3. 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

编译时,先将父类的a=10,压入栈中,运行时,虚表指针访问Base的func方法,但初始值,和类型关联,*op的类型是 Object类,Object的func默认参数是10

默认参数是在编译时根据调用表达式中指针或引用的静态类型来确定的,而不是运行时根据对象的实际类型。op 的静态类型是 Object*,在 Object 类中 func 函数的默认参数 a 被定义为 10,所以在调用 op->func() 时,使用的默认参数是 10,而不是 Base 类中 func 函数定义的默认参数 20

C++中,构造函数不能为虚。

构造函数的任务:设置虚表指针。 

构造函数的主要作用是初始化对象的成员变量,为对象分配内存并设置初始状态。在创建对象时,编译器已经明确知道要创建的对象类型,因此可以直接调用相应的构造函数,不需要通过虚函数机制在运行时动态确定。

构造函数执行时,对象还未完全创建好,虚表指针可能还未被正确初始化。如果构造函数是虚函数,就需要通过虚表指针来调用它,但此时虚表指针可能还没指向正确的虚表,这会导致无法正确调用构造函数。


文章转载自:
http://calculability.hdqtgc.cn
http://bengalee.hdqtgc.cn
http://bargello.hdqtgc.cn
http://ballroom.hdqtgc.cn
http://chlorite.hdqtgc.cn
http://axillary.hdqtgc.cn
http://aerometry.hdqtgc.cn
http://aleak.hdqtgc.cn
http://alps.hdqtgc.cn
http://bawdyhouse.hdqtgc.cn
http://biramose.hdqtgc.cn
http://admiring.hdqtgc.cn
http://automaticity.hdqtgc.cn
http://careerist.hdqtgc.cn
http://apologise.hdqtgc.cn
http://aragon.hdqtgc.cn
http://cecilia.hdqtgc.cn
http://bullock.hdqtgc.cn
http://cacorhythmic.hdqtgc.cn
http://bulbospongiosus.hdqtgc.cn
http://allatectomy.hdqtgc.cn
http://arhus.hdqtgc.cn
http://benin.hdqtgc.cn
http://canicula.hdqtgc.cn
http://chait.hdqtgc.cn
http://aerogenerator.hdqtgc.cn
http://applicable.hdqtgc.cn
http://bidialectalism.hdqtgc.cn
http://arithmetically.hdqtgc.cn
http://accidented.hdqtgc.cn
http://www.dtcms.com/a/107733.html

相关文章:

  • CISCO ASA防火墙、 VPN基础内容、 IPSec VPN
  • MySQL学习集--sql通用语法和数据类型
  • 机器视觉中的传统视觉与深度视觉
  • 【3.软件工程】3.4 原型及相关模型
  • 2025年3月31日 GGG2
  • 笔记1——数据通信网络基础
  • Ogcloud东南亚SD-WAN专线:经济高效、快速稳定!
  • KingbaseES物理备份还原之备份还原
  • arcgis10.8 Toolbox中没有找到conversion tools模块
  • 工业4.0时代下的人工智能新发展
  • syslog 与 Linux 内核日志系统全面解析
  • 【kafka】Kafka的Topic
  • 实战打靶集锦-35-GitRoot
  • 软件定义无线电39
  • SpringMVC和SpringBoot是否线程安全?
  • 2.5/Q2,GBD数据库最新文章解读
  • 从代码学习深度学习 - NLP之文本预处理 PyTorch版
  • 深度解析Python代码中的广告信息提取与JSON处理
  • C++实用函数:remove_if
  • 后端框架入门:Django
  • 2024年信息素养大赛 C++小学组初赛 算法创意实践挑战赛 真题答案解析
  • 【棒垒球规则】全国幼儿软式棒垒球比赛规则(一)·棒球1号位
  • 【Linux运维】查询指定日期的上月
  • Springboot整合Mybatis+Maven+Thymeleaf学生成绩管理系统
  • 【力扣hot100题】(042)验证二叉搜索树
  • 物联网中,客户端与服务器之间都有哪些通信协议。
  • 无线传感网络协议
  • 【11408学习记录】从混乱到清晰:还原+断开+简化,彻底攻破英语分裂式长难句
  • 深度剖析 MySQL 与 Redis 缓存一致性:理论、方案与实战
  • NX二次开发——读取指定部件或者对象的属性