站在编译器和C的角度剖析c++原理, 用代码说话
重写与重载
在我们上一节中已经引入了多态,其中有一点就是必须是对父类虚函数的重写. 那么重写与重载有什么不同呢?
函数重载:
1. 必须在同一个类中进行
2. 子类无法重载父类的函数,父类同名函数将被名称覆盖
3. 重载是在编译期间根据参数类型和个数决定函数调用
函数重写:
1. 必须发生于父类与子类之间
2. 并且父类与子类中的函数必须有完全相同的原型
3. 使用virtual声明之后能够产生多态(如果不使用virtual,那叫重定义)
4. 多态是在运行期间根据具体对象的类型决定函数调用
class Parent01
{
public:
Parent01()
{
cout<<"Parent01:printf()..do"<<endl;
}
public:
void func()
{
cout<<"Parent01:void func()"<<endl;
}
void abcd()
{
cout<<"Parent01:void func()"<<endl;
}
virtual void func(int i)
{
cout<<"Parent:void func(int i)"<<endl;
}
virtual void func(int i, int j)
{
cout<<"Parent:void func(int i, int j)"<<endl;
}
};
class Child01 : public Parent01
{
public:
void abcd(int a, int b)
{
cout<<"Parent01:void func()"<<endl;
}
void func(int i, int j)
{
cout<<"Child:void func(int i, int j)"<<" "<<i + j<<endl;
}
void func(int i, int j, int k)
{
cout<<"Child:void func(int i, int j, int k)"<<" "<<i + j + k<<endl;
}
};
void run01(Parent01* p)
{
p->func(1, 2);
}
int main()
{
Child01 c;
// c.abcd();
c.Parent01::abcd();
run01(&c);
return 0;
}
我们能够看出c.abcd();
是执行不成功的,你或许会说这个函数是从父类中继承而来,为什么这个地方不能使用? 但是上面也说了,在子类中有了重载的函数,这样的话,子类的重载函数就会覆盖了父类继承下来同名的函数. 所以直接调用会报错,因为子类中的同名函数是带参数的. 那么如何能访问到父类的这个方法呢? c.Parent01::abcd()
使用这种方式. 子类和父类有相同的名字(变量名字或者是函数名字的时,子类名字覆盖父类名字,如果想使用父类的资源,需要加::).
虚函数表指针(VPTR)
我们在回顾一下多态的简单案例来抛出VPTR
class Parent
{
public:
Parent(int a = 0)
{
this->a = a;
}
virtual void printAbc()
{
}
virtual void print()
{
cout<<"父类函数"<<endl;
}
protected:
private:
int a;
};
class Child : public Parent
{
public:
Child(int b = 0)
{
this->b = b;
}
void print()
{
cout<<"子类函数"<<endl;
}
protected:
private:
int b ;
};
void howToPrintf(Parent *pBase)
{
pBase->print();
}
int main()
{
Parent p1;
Child c1;
howToPrintf(&p1);
howToPrintf(&c1);
return 0;
}
这个就是最简单的多态案例,那么编译器在哪里动了手脚呢?使得能实现运行时的动态联编. 编译器第一个动手脚的地方就是virtual void print()
这个函数,相当于是进行了标志. 第二个动手脚的地方就是在howToPrintf
函数中, 传来一个子类对象,调用子类的函数,传来一个父类对象,调用父类的函数. 应该是在你不知道的情况下,增加判断条件. 其实在Parent p1;
的时候,编译器已经提前布局, 给函数有虚函数表的对象,提前加了vptr指针. 那么到底有没有个多余的指针在提前布局了呢?
class AA
{
public:
virtual void print()
{
printf("dddd\n");
}
protected:
private:
int b;
};
int main()
{
printf("AA%d \n", sizeof(AA));
AA a;
return 0;
}
程序运行的结果是:没加virtual之前是4,之后是8, 正好说明增加了一个指针的大小, 也就是说在编译阶段编译器尽量的帮我们在做之情,它会将有类定义的都定义出来,但是没有进行任何的初始化,比如AA a
会提前定义出来,包括b的值虽然是乱码,但是就是在这个阶段,检测到类中的虚函数,所以直接在栈中定义了一个vptr指针,指向了虚函数的入口地址.
那么现在我们如果在类的构造函数中调用了虚函数,那么会不会实现多态呢?
class Parent
{
public:
Parent(int a = 0)
{
print();
this->a = a;
}
void printAbc()
{
printf("父类abc");
}
virtual void print()
{
cout<<"父类函数"<<endl;
}
protected:
private:
int a;
};
class Child : public Parent
{
public:
Child(int b = 0)
{
this->b = b;
}
virtual void print()
{
cout<<"子类函数"<<endl;
}
protected:
private:
int b ;
};
int main()
{
Child c1;
return 0;
}
这个案例的意思就是说, 当我们进行c1初始化的时候会先调用父类的构造方法,但是在父类中调用了print()
这个虚函数,这样的话,会不会走子类中重写了的print呢? 也即是实现多态呢? 其实并没有. 这里涉及到一个很关键的点.
首先我们要知道, 虚函数表是一个存储成员函数指针的数据结构, 虚函数表是由编译器自动生成与维护的, virtual成员函数会被编译器放入虚函数表中, 存在虚函数时,每个对象都有一个指向虚函数的指针(vptr指针), 在实现多态的过程中,父类和派生类都有vptr指针. 对象在创建时,由编译器对vptr指针进行初始化, 只有当对象的构造完全结束后vptr的指向才最终决定下来, 父类对象的vptr指向父类的虚函数表,子类对象的vptr指向子类的虚函数表. 定义子类对象时,vptr先指向父类的虚函数表,在父类构造完成之后,子类的vptr才指向自己的虚函数表。(这也就是在父类或者子类的构造函数中调用虚成员函数不会实现多态的原因).也就是说子类的vptr指针的初始化是分步完成的. 当在Child c1
执行之前已经有了c1类了,但是其中的成员变量还没初始化,其实父类也是有了的,但是main中没有定义而已,所以我们编译器中并不会显示,但是其实有了的,并且都有了vptr指针. 即使是执行完Child c1;
, 和父类也是没关系的,父类中的成员变量也还是未初始化了的。子类只是借用父类初始化了一下vptr指针.
C+++编译器多态实现原理专题讲座(面试必考)
- 多态的实现效果:
多态:同样的调用语句有多种不同的表现形态; - 多态实现的三个条件:
有继承、有virtual重写、有父类指针(引用)指向子类对象。 - 多态的C++实现:
virtual关键字,告诉编译器这个函数要支持多态;不要根据指针类型判断如何调用;而是要根据指针所指向的实际对象类型来判断如何调用 - 多态的理论基础
动态联编PK静态联编。根据实际的对象类型来判断重写函数的调用 - 多态的重要意义
设计模式的基础 - 实现多态的理论基础
函数指针做函数参数
C++中多态的实现原理:
当类中声明虚函数时,编译器会在类中生成一个虚函数表
虚函数表是一个存储类成员函数指针的数据结构
虚函数表是由编译器自动生成与维护的
virtual成员函数会被编译器放入虚函数表中
存在虚函数时,每个对象中都有一个指向虚函数表的指针(vptr指针)
编译器确定func是否为虚函数:
1. func不是虚函数, 编译器可直接确定被调用的成员函数(静态联编,直接根据parent类型来确定)
2. func是虚函数, 编译器根据parent * p
对象p的Vptr指针,所指的虚函数表中查找func方法,并调用. 查找和调用在运行时完成(动态联编)
这里需要说明一点:
通过虚函数表指针VPTR调用重写函数是在程序运行时进行的,因此需要通过寻址操作才能确定真正应该调用的函数。而普通成员函数是在编译时就确定了调用的函数。在效率上,虚函数的效率要低很多. 所以出于效率考虑,没有必要将所有成员函数都声明为虚函数。
虚析构函数
构造函数不能是虚函数,因为建立一个派生类对象时,必须成类层次的根开始,沿着继承路径逐个调用基类的构造函数. 但是析构函数是可以成为虚函数的,虚析构函数用于指引delete运算正确析构动态对象.
我们用非虚析构函数和析构函数来进行对比:
class A{
public:
~A(){
cout << "A::~A() is called\n" << endl;
}
};
class B : public A{
public:
~B(){
cout << "B::~B() is called" << endl;
}
};
int main(void){
A *Ap = new B;
B *Bp = new B;
cout << "first time to delete \n" << endl;
delete Ap;
cout << "Second time to delete \n" << endl;
delete Bp;
return 0;
}
运行结果
first time to delete
A::~A() is called
Second time to delete
B::~B() is called
A::~A() is called
这样我们能看出来,两者的调用结果是不一样的,这样的话first time to delete我们是创建了B对象的,但是并没有析构B对象,这样的析构是不完整的. 另外一点需要注意就是当使用new去创建对象的时候,必须使用delete才能调用析构函数
那么我们看虚析构函数案例:
class A{
public:
virtual ~A(){
cout << "A::~A() is called\n" << endl;
}
};
class B : public A{
public:
virtual ~B(){
cout << "B::~B() is called" << endl;
}
};
int main(void){
A *Ap = new B;
B *Bp = new B;
cout << "first time to delete \n" << endl;
delete Ap;
cout << "Second time to delete \n" << endl;
delete Bp;
return 0;
}
运行结果:
first time to delete
B::~B() is called
A::~A() is called
Second time to delete
B::~B() is called
A::~A() is called
我们能够发现,两者是一样的, 并且发现析构的很干净. 所以通过父类指针,把所有的子类析构函数都执行一遍. 这就是虚析构函数的意义.
父类指针和子类指针步长
我们进行这样的一个实验:
class Parent01
{
protected:
int i;
int j;
public:
virtual void f()
{
cout<<"Parent01::f"<<endl;
}
};
class Child01 : public Parent01
{
public:
//int k;
public:
Child01(int i, int j)
{
printf("Child01:...do\n");
}
virtual void f()
{
printf("Child01::f()...do\n");
}
};
void howToF(Parent01 *pBase)
{
pBase->f();
}
int main()
{
int i = 0;
Parent01* p = NULL;
Child01* c = NULL;
Child01 ca[3] = {Child01(1, 2), Child01(3, 4), Child01(5, 6)};
p = ca; //第一个子类对象赋值给p,p是基类指针,
c = ca;
p->f(); //有多态发生
p++;
p->f();//有多态发生
return 0;
}
这样的代码是不会发生错误的,但是只要将子类中的k放开,那么就会报错,为什么呢? 我们用图来只管的分析这个过程.
当我们没加入k的时候:
子类中的变量都是从父类中继承下来的.
当加入k后:
我们很容易看出他们的长度不一样了. 那么在代码中我们定义了一个子类数组, 其中定义了三个数组:
一开始我们的p指针和c指针都指向了子类数组的首地址,虽然是p指针,但是因为所指对象是子类对象随意还是会发生多态,但是p指针的大小是固定的,这个可是由数据类型定义的,因为p是父类对象,父类的大小是12字节,所以p++
移动12字节,但是在数组中这时指到了k, 而不是下一个子类的vptr指针,只有vptr指针才存放这多态函数的入口地址,当调用func函数的时候,并不是函数的入口地址,所以就跪了.
c++中的纯虚函数和抽象类
有关多继承的说明:
被实际开发经验抛弃的多继承
工程开发中真正意义上的多继承是几乎不被使用的
多重继承带来的代码复杂性远多于其带来的便利
多重继承对代码维护性上的影响是灾难性的
在设计方法上,任何多继承都可以用单继承代替
具体到多继承中的二义性,我们上一篇文章已经介绍到了. 学过java的人或许会问,c++中有没有想爱你过步像java中一样的接口呢? 绝大多数面向对象语言都支持接口的概念, 但是C++中没有接口的概念,C++中可以使用纯虚函数实现接口. 一个具有纯虚函数的基类就是抽象类. 接口类中只有函数原型定义,没有任何数据的定义.
class Interface
{
public:
virtual void func1() = 0;
virtual void func2(int i) = 0;
virtual void func3(int i) = 0;
};
多重继承接口不会带来二义性和复杂性等问题, 接口类只是一个功能说明,而不是功能实现, 子类需要根据功能说明定义功能实现.
纯虚函数定义:
纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”
引入原因:
1. 为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
2. 在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重载以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
抽象类
含有纯虚函数的类被称为抽象类。抽象类只能作为派生类的基类,不能定义对象,但可以定义指针。在派生类实现该纯虚函数后,定义抽象类对象的指针,并指向或引用子类对象。
1)在定义纯虚函数时,不能定义虚函数的实现部分;
2)在没有重新定义这种纯虚函数之前,是不能调用这种函数的。
抽象类的唯一用途是为派生类提供基类,纯虚函数的作用是作为派生类中的成员函数的基础,并实现动态多态性。继承于抽象类的派生类如果不能实现基类中所有的纯虚函数,那么这个派生类也就成了抽象类。因为它继承了基类的抽象函数,只要含有纯虚函数的类就是抽象类。纯虚函数已经在抽象类中定义了这个方法的声明,其它类中只能按照这个接口去实现。
那么我们就来举一些抽象类的实例吧:
1. 抽象类IShape作为基类:只有头文件,没有实现文件
#ifndef SHAPE_H
#define SHAPE_H
#include
using std::string;
//interface
class IShape
{
public:
virtual float getArea()=0; //纯虚函数,获得面积
virtual string getName()=0; //纯虚函数,返回图形的名称
};
#endif
- 派生类Circle类继承自抽象类IShape:
Circle.h头文件
#ifndef CIRCLE_H
#define CIRCLE_H
#include"Shape.h"
class CCircle : public IShape //公有继承自IShape类
{
public:
CCircle(float radius); //构造函数
public:
virtual float getArea(); //实现声明实现两个基类的函数,声明的时候需要加virtual关键字,实现的时候就不需要加virtual关键字了。
virtual string getName();
private:
float m_fRadius; //派生类可以拥有自己的成员
};
#endif
Circle.cpp实现文件:
#include"Circle.h"
CCircle::CCircle(float radius)
:m_fRadius(radius) //使用构造函数的初始化列表初始化
{
float CCircle::getArea() / /实现实现两个基类的函数
virtual string getName();
{
return 3.14 * m_fRadius * m_fRadius;
}
string CCircle::getName()
{
return "CCircle";
}
- 派生类Rect类继承自抽象类IShape:
Rect.h头文件:
#ifndef RECT_H
#define RECT_H
#include"shape.h"
class CRect : public IShape
{
public:
CRect(float nWidth, float nHeight);
public:
virtual float getArea();
virtual string getName();
private:
float m_fWidth; //矩形类具有自己的两个属性,宽和高
float m_fHeight;
};
Rect.cpp实现文件:
#include"Rect.h"
CRect::CRect(float fWidth, float fHeight)
:m_fWidth(fWidth), m_fHeight(fHeight)
{}
float CRect::getArea()
{
return m_fWidth * m_fHeight;
}
string CRect::getName()
{
return "CRect";
}
4.测试文件main.cpp:
#include
#include"Rect.h"
#include"Circle.h"
using namespace std;
int main()
{
IShape* pShape = NULL; //定义了一个抽象类的指针,注意抽象类不能定义对象但是可以定义指针
pShape = new CCircle(20.2); //基类指针指向派生类的对象
cout<getName()<<" "<getArea()<<endl;
delete pShape; //释放了CCirle对象所占的内存,但是指针是没有消失的,它现在就是一个野指针,我们在使用之前必须对它赋值
pShape = new CRect(20, 10); //基类指针指向派生类的对象
cout<getName()<<" "<getArea()<<endl;
return 0;
}
可以看到,我们使用父类的指针调用同一个函数,分别调用了这两个派生类的对应函数,它根据指针指向的类型的不同来决定调用的方法。即使我们以后需要新增加几个类,我们还是这种调用方法,这就是多态的巨大魅力。
抽象类和接口类的区别
纯虚函数肯定是某个类的成员函数,包含纯虚函数的类就叫做抽象类。抽象类的特点:
1. 因为纯虚函数无法通过对象直接调用到(因为函数指针为0),所以含有纯虚函数的抽象类无法直接实例化对象(从栈和堆中都无法实例化)
2. 纯虚函数的子类也可能是抽象类,抽象类的子类只有把抽象类中的所有纯虚函数都做了实现以后,这个子类才能实例化对象。
接口类:
仅含有纯虚函数而不含有任何其他成员函数和成员变量的类就叫做接口类。接口类没有.cpp文件,因为没有函数实现,而且接口类除了没有成员变量和普通成员函数以外,也没有构造函数和析构函数。如下图所示,就是一个抽象类的例子。
接口类其他特点:
1. 可以使用接口类指针指向其子类对象,并调用子类对象中实现的接口类中的纯虚函数
2. 一个类可以继承一个接口类也可以继承自多个接口类, 但是一个类只能继承一个普通类包括抽象类
3. 一个类可以继承自接口类的同时也继承非接口类
4. 接口类当然也是抽象类
//这就是多继承的真是应用场景
class Interface1
{
public:
virtual void print() = 0;
virtual int add(int i, int j) = 0;
};
class Interface2
{
public:
virtual int add(int i, int j) = 0;
virtual int minus(int i, int j) = 0;
};
class parent
{
public:
int i;
};
class Child : public parent, public Interface1, public Interface2
{
public:
void print()
{
cout<<"Child::print"<<endl;
}
int add(int i, int j)
{
return i + j;
}
int minus(int i, int j)
{
return i - j;
}
};
int main()
{
Child c;
c.print();
cout<<c.add(3, 5)<<endl;
cout<<c.minus(4, 6)<<endl;
Interface1* i1 = &c;
Interface2* i2 = &c;
cout<<i1->add(7, 8)<<endl;
cout<<i2->add(7, 8)<<endl;
return 0;
}
多态之socket案例
在我的GitHub上有一个划分层次的开发业务,运用的就是多态, 接口以及抽象类的特性. 有兴趣可以看看.
联系方式: reyren179@gmail.com