C++基础——虚函数、抽象类、友元详解

虚函数

C++能够通过继承等方法实现快速开发,为了满足多态和泛型编程这一特性,C++使用虚函数来完成这一操作。虚函数是运行时决定,其他语言是通过 编译时决定的

虚函数的实现是由两个部分组成的,虚函数指针与虚函数表。

虚函数的定义与声明

class Base
{
public:
	virtual void func()const
	{
		cout << "Base!" << endl;
	}
};
class Derived :public Base
{
public:
	virtual void func()
	{
		cout << "Derived!" << endl;
	}
};

void show(Base& b)
{
	b.func();
}
Base base;
Derived derived;

int main()
{
	show(base);
	show(derived);
	base.func();
	derived.func();
	return 0;
}

函数输出:

Base!
Base!
Base!
Derived!

也不一定虚函数的声明与定义要求非常严格,只有在子函数中的虚函数与父函数一模一样的时候(包括限定符const)才会被认为是真正的虚函数,不然的话就只能是重载。这被称为虚函数定义的同名覆盖原则,意思是只有名称完全一样时才能完成虚函数的定义。
虚函数指针

虚函数指针从本质上来说就是一个指向函数的指针,与普通的指针并无区别。它指向所定义的虚函数,具体是在子类里实现,当子类调用虚函数的时候,实际上是通过调用该虚函数指针从而找到接口。虚函数指针是确实存在的一种数据类型,在一个被实例化的对象中,它总是被存放在该对象地址首位,这种做法的目的是为了保证运行的快速性。与对象的成员不同,虚函数指针对外部是完全不可见的,除非通过直接访问地址的做法,否则它不可见也不能被外部调用。只有拥有虚函数的类才会拥有虚函数指针,每一个虚函数也都会对应一个虚函数指针。所有拥有虚函数的类的所有对都会因为虚函数产生额外的开销,并且也会在一定程度上降低程序的速度。与java不同,C++将是否使用虚函数这一权利交个开发者。

虚函数表
每个类的实例化对象都会拥有虚函数指针并且都排列在对象的地址首部。而它们也都是按照一定的顺序组织起来的,从而构成了一种表状结构,称为虚函数表。

C++中一个类是共用一张虚函数表的,基类有基类的虚函数表,子类是子类的虚函数表,这极大的节省了内存。

虚函数实现原理及虚函数表

虚函数的地址存放于虚函数表之中。运行期多态就是通过虚函数和虚函数表实现的。类的对象内部会有指向类内部的虚表地址的指针。通过这个指针调用虚函数。虚函数的调用会被编译器转换为对虚函数表的访问:

ptr->f();   //ptr代表this指针,f是虚函数
*(ptr->vptr[1])(ptr);

上述代码中,ptr代表一个this指针,ptr指向的vptr是类内部的虚表指针。这个虚表指针会被放在类的最前方(VS2017),1就是虚函数指针在虚函数表中的索引值。在这个索引值表示的虚表的槽中存放的就是f()的地址。

虚表指针的名字也会被编译器更改,所以在多继承的情况下,类的内部可能存在多个虚表指针。通过不同的名字被编译器标识。

虚函数表中可能还存在其他的内容,如用于RTTI的type_info类型。或者直接将虚基类的指针存放在虚表中。

压制多态可以通过域操作符进行。

class A1
{
public:
    virtual void f() { cout << "A1::f" << endl; }
};
class C : public A1
{
public:
    virtual void f() { cout << "C::f" << endl; }
};
c.A1::f();  //A1::f
c.f();  //C::f
单继承下的虚函数表

这种情况下,派生类中仅有一个虚函数表。这个虚函数表和基类的虚函数表不是一个表(无论派生类有没有重写基类的虚函数),但是如果派生类没有重写基类的虚函数的话,基类和派生类的虚函数表指向的函数地址都是相同的。

class A1
{
public:
    A1(int _a1 = 1) : a1(_a1) { }
    virtual void f() { cout << "A1::f" << endl; }
    virtual void g() { cout << "A1::g" << endl; }
    virtual void h() { cout << "A1::h" << endl; }
    ~A1() {}
private:
    int a1;
};
class C : public A1
{
public:
    C(int _a1 = 1, int _c = 4) :A1(_a1), c(_c) { }
    //virtual void f() { cout << "C::f" << endl; }
    //virtual void g() { cout << "C::g" << endl; }
    //virtual void h() { cout << "C::h" << endl; }
private:
    int c;
};

类C没有重写A的虚函数,所以虚函数表内部的情况如下:

可以看出,两个类的__vfptr的值不同,但是每个槽内部的函数地址都是相同的。

如果类C中重写了A类中的函数:

class C : public A1
{
public:
    C(int _a1 = 1, int _c = 4) :A1(_a1), c(_c) { }
    virtual void f() { cout << "C::f" << endl; }
    virtual void g() { cout << "C::g" << endl; }
    virtual void h() { cout << "C::h" << endl; }
private:
    int c;
};
那么就会覆盖A类的虚函数,重写一部分就会覆盖一部分,重写全部就会覆盖全部。

如果C中重新写了一些别的虚函数,那么这些虚函数将排在父类的后面,这里编译器无法显示,可以通过打印虚表来进行。

打印的过程比较简单,通过访问类C的前8字节(64位编译器)找到虚函数表,再一次遍历虚函数表即可。虚函数表最后一项用的是0,代表虚函数表结束。

C c;
long long *p = (long long *)(*(long long*)&c);
typedef void(*FUNC)();        //重定义函数指针,指向函数的指针
void PrintVTable(long long* vTable)  //打印虚函数表
{
    if (vTable == NULL)
    {
        return;
    }
    cout << "vtbl:" << vTable << endl;
    int  i = 0;
    for (; vTable[i] != 0; ++i)
    {
        printf("function : %d :0X%x->", i, vTable[i]);
        FUNC f = (FUNC)vTable[i];
        f();         //访问虚函数
    }
    cout << endl;
}

通过这样的打印可以得知C的虚函数表为:

vtbl:00007FF6CD2CBE68
function : 0 :0Xcd2c115e->A1::f
function : 1 :0Xcd2c146a->A1::g
function : 2 :0Xcd2c1113->A1::h
vtbl:00007FF6CD2CBEA8
function : 0 :0Xcd2c115e->A1::f
function : 1 :0Xcd2c146a->A1::g
function : 2 :0Xcd2c1113->A1::h
function : 3 :0Xcd2c1023->C::f
function : 4 :0Xcd2c132a->C::g
function : 5 :0Xcd2c11d1->C::h

具体的图解为:

多继承下的虚函数表

多继承情况下,派生类中有多个虚函数表,虚函数的排列方式和继承的顺序一致。派生类重写函数将会覆盖所有虚函数表的同名内容,派生类自定义新的虚函数将会在第一个类的虚函数表的后面进行扩充。

class A1
{
public:
    A1(int _a1 = 1) : a1(_a1) { }
    virtual void f() { cout << "A1::f" << endl; }
    virtual void g() { cout << "A1::g" << endl; }
    virtual void h() { cout << "A1::h" << endl; }
    ~A1() {}
private:
    int a1;
};
class A2
{
public:
    A2(int _a2 = 2) : a2(_a2) { }
    virtual void f() { cout << "A2::f" << endl; }
    virtual void g() { cout << "A2::g" << endl; }
    virtual void h() { cout << "A2::h" << endl; }
    ~A2() {}
private:
    int a2;
};
class A3
{
public:
    A3(int _a3 = 3) : a3(_a3) { }
    virtual void f() { cout << "A3::f" << endl; }
    virtual void g() { cout << "A3::g" << endl; }
    virtual void h() { cout << "A3::h" << endl; }
    ~A3() {}
private:
    int a3;
};

class B : public A1, public A2, public A3
{
public:
    B(int _a1 = 1, int _a2 = 2, int _a3 = 3, int _b = 4) :A1(_a1), A2(_a2), A3(_a3), b(_b) { }
    virtual void f1(){ cout << "B::f" << endl; }
    virtual void g1(){ cout << "B::g" << endl; }
    virtual void h1(){ cout << "B::h" << endl; }
private:
    int b;
};

这里通过编译器的部分可以看出来,未被重写的虚函数指针将和基类指向同一个位置,一旦被重写,函数指针就指向新的位置。

在B类中,函数指针指向的位置不变:

而这时候B类中第一个虚函数表已经增加了新的项,从打印结果可知。

vtbl:00007FF7DD62BF38
function : 0 :0Xdd621177->A1::f
function : 1 :0Xdd621497->A1::g
function : 2 :0Xdd621127->A1::h
vtbl:00007FF7DD62BF78
function : 0 :0Xdd6212df->A2::f
function : 1 :0Xdd62105f->A2::g
function : 2 :0Xdd6213fc->A2::h
vtbl:00007FF7DD62BFB8
function : 0 :0Xdd621032->A3::f
function : 1 :0Xdd62129e->A3::g
function : 2 :0Xdd621221->A3::h
vtbl:00007FF7DD62BFF8
function : 0 :0Xdd621177->A1::f
function : 1 :0Xdd621497->A1::g
function : 2 :0Xdd621127->A1::h
function : 3 :0Xdd62144c->B::f
function : 4 :0Xdd621019->B::g
function : 5 :0Xdd62133e->B::h

而如果B类重写了函数,那么打印结果将是:

vtbl:00007FF720C8BF38
function : 0 :0X20c8117c->A1::f
function : 1 :0X20c814b5->A1::g
function : 2 :0X20c8112c->A1::h
vtbl:00007FF720C8BF78
function : 0 :0X20c812f8->A2::f
function : 1 :0X20c8105a->A2::g
function : 2 :0X20c8141a->A2::h
vtbl:00007FF720C8BFB8
function : 0 :0X20c8102d->A3::f
function : 1 :0X20c812b2->A3::g
function : 2 :0X20c81230->A3::h
vtbl:00007FF720C8BFF8
function : 0 :0X20c814ab->B::f
function : 1 :0X20c81370->B::g
function : 2 :0X20c81393->B::h

并且此时B类的信息为:

从编译器给出的信息我们可以看到在第二个虚函数表中有adjustor{16}的字样,这就是A类的大小,也就是说,这就是告诉编译器,需要进行16字节的偏移(thunk技术)。这就引出了接下来的一个问题:

B类用不同的基类指针指向的时候,运行的是不同的基类中的虚函数(这就是多态的表现),这里可以知道,当A2类指针指向B的时候,虚函数指针是自动跳到B类中A2类所在的地方的,这个跳转是怎么进行的呢?

首先在编译期,就可以知道一个指针需要偏移多少个字节:

A2 *p = new B;

编译器会将这个代码改为:

B *tmp = new B;
A2 *p = tmp ? tmp + sizoef(A1) : 0;

经过这样的调整A1,A2,A3都会指向正确的类的位置。但是这样还不够。

由上面的编译器信息图我们可以知道,当B类重写了函数之后,A2,A3的虚函数表所指对象已经不再是简单的函数指针了,而是一个thunk对象。这就是C++的thunk技术。

所谓的thunk就是一段汇编代码,这段汇编代码可以以适当的偏移值来调整this指针以跳到对应的虚函数中去,并调用这个函数,也就是说当使用A1的指针指向B的对象,不需要发生偏移,而使用A2的指针指向B则需要进行偏移sizeof(A1)个字节。并跳转到A1中的函数来执行。这就是通过thunk的jmp指令跳转到这个函数。

所以具体的虚函数表中的情况如下:

1、如果两个基类中的虚函数名字不同,派生类只重写了第二个基类的虚函数,则不会产生thunk用以跳转。
2、如果基类中虚函数名字相同,派生类如果重写,将会一次性重写两个基类的虚函数,这时候第二个基类的虚函数表中存放的就是thunk对象,当指针指向此处的时候,会自动跳转到A类的对应虚函数(已经被B重写)执行。
3、第一个基类的虚函数被重写与否都不会产生thunk对象,因为这个类是被别的基类指针跳转的目标,而这个类的指针施行多态的时候是不会发生跳转的。
4、派生类的重新定义的虚函数将会排在第一个虚函数表内部A1虚函数的后面,但是当A2调用这个函数的时候,会通过thunk技术跳转回第一个类的虚函数表以执行相对应的虚函数。
5、除了第一个基类的虚析构函数,其他基类的析构函数都是thunk对象。

综上所述,thunk对象用于所有基类都被派生类重写后,调用虚函数将跳到最开始的基类部分。或者派生类中定义的虚函数也会跳转到第一个基类的虚函数表中。而仅出现在后面的基类的虚函数表中的虚函数,无论被重写与否都不会产生thunk对象。因为这里不会在第一个基类中由对应的虚函数指针。

纯虚函数

形式:virtual 函数原型=0;
定义:在定义一个表达抽象概念的基类时,有时无法给出某些函数的具体实现方法,就可以将这些函数声明为纯虚函数。
特点:无具体实现方法。

  1. 虚函数和纯虚函数可以定义在同一个类(class)中,含有纯虚函数的类被称为抽象类(abstract class),而只含有虚函数的类(class)不能被称为抽象类(abstract class)。

  2. 虚函数可以被直接使用,也可以被子类(sub class)重载以后以多态的形式调用,而纯虚函数必须在子类(sub class)中实现该函数才可以使用,因为纯虚函数在基类(base class)只有声明而没有定义。

  3. 虚函数和纯虚函数都可以在子类(sub class)中被重载,以多态的形式被调用。

  4. 虚函数和纯虚函数通常存在于抽象基类(abstract base class -ABC)之中,被继承的子类重载,目的是提供一个统一的接口。

  5. 虚函数的定义形式:virtual {method body}
      纯虚函数的定义形式:virtual { } = 0;
    在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时候要求前期bind,然而虚函数却是动态绑定(run-time bind),而且被两者修饰的函数生命周期(life recycle)也不一样。

  6. 虚函数必须实现,如果不实现,编译器将报错,错误提示为:
    error LNK****: unresolved external symbol “public: virtual void __thiscall
    ClassName::virtualFunctionName(void)”

  7. 对于虚函数来说,父类和子类都有各自的版本。由多态方式调用的时候动态绑定。

  8. 实现了纯虚函数的子类,该纯虚函数在子类中就编程了虚函数,子类的子类即孙子类可以覆盖
    该虚函数,由多态方式调用的时候动态绑定。

  9. 虚函数是C++中用于实现多态(polymorphism)的机制。核心理念就是通过基类访问派生类定义的
    函数

  10. 多态性指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。C++支持两种多态性:编译时多态性,运行时多态性。
    a.编译时多态性:通过重载函数实现
    b 运行时多态性:通过虚函数实现。

  11. 如果一个类中含有纯虚函数,那么任何试图对该类进行实例化的语句都将导致错误的产生,因为抽象基类(ABC)是不能被直接调用的。必须被子类继承重载以后,根据要求调用其子类的方法。

#include
using namespace std;

class Virtualbase

{

public:

    virtual void Demon() = 0; //prue virtual function

    virtual void Base() { cout << "this is farther class" << endl; }

};

//sub class

class SubVirtual :public Virtualbase

{

public:

    void Demon() {
        cout << " this is SubVirtual!" << endl;
    }

        void Base() {
            cout << "this is subclass Base" << endl;
        }

    };


        void main()

        {

            Virtualbase* inst = new SubVirtual(); //multstate pointer

            inst->Demon();

            inst->Base();

            // inst = new Virtualbase();

            // inst->Base()
            system("pause");

            return;

        }
抽象类

定义:声明了纯虚函数的类,都成为抽象类。
主要特点:抽象类只能作为基类来派生新类,不能声明抽象类的对象。(既然都是一个抽象概念了,纯虚函数没有具体实现方法,故不能创造该类的实际的对象)
但是可以声明指向抽象类的指针变量或引用变量,通过指针或引用,就可以指向并访问派生类对象,进而访问派生类的成员。(体现了多态性)

作用:因为其特点,基类只是用来继承,可以作为一个接口,具体功能在派生类中实现(接口)

 1 #include <iostream>
 2 using namespace std;
 3 //定义一个形状抽象类
 4 class Shape
 5 {
 6 protected:
 7     double x;
 8     double y;
 9 public:
10     void set(double i, double j)
11     {
12         x = i;
13         y = j;
14     }
15     virtual void area() = 0;     //定义纯虚函数,用来某形状计算面积
16 };
17 //定义一个矩形类
18 class Rectangle :public Shape
19 {
20     //具体实现方法
21     void area()
22     {
23         cout << x * y << endl;     //x和y为矩形的长和宽
24     }
25 };
26 //定义一个直角三角型类
27 class Triangle :public Shape
28 {
29     //具体实现方法
30     void area()
31     {
32         cout << x * y * 0.5 << endl; //x和y为直角三角形的直角边
33     }
34 };
35 int main()
36 {
37     Rectangle rec;       //定义一个矩形对象
38     Triangle tri;        //定义一个直角三角型对象
39 
40     Shape *p = &rec;     //定义一个抽象类的指针p,并使它指向矩形对象
41     p->set(2, 4);        //调用矩形类中的设置参数方法
42     p->area();           //调用矩形类中计算矩形面积的方法
43 
44     p = &tri;            //让指针p指向直角三角形对象
45     p->set(2, 4);        //调用直角三角形类中的设置参数方法
46     p->area();           //调用直角三角形类中计算面积的方法
47     system("pause");
48     return 0;
49 }
成员函数的重载、覆盖与隐藏

成员函数的重载、覆盖(override)与隐藏很容易混淆,C++程序员必须要搞清楚概念,否则错误将防不胜防。

成员函数被重载的特征:

(1)相同的范围(在同一个类中)
(2)函数名字相同
(3)参数不同
(4)virtual 关键字可有可无

覆盖是指派生类函数覆盖基类函数,特征是:

(1)不同的范围(分别位于派生类与基类)
(2)函数名字相同
(3)参数相同
(4) 基类函数必须有virtual 关键字

这里“隐藏”是指派生类的函数屏蔽了与其同名的基类函数
规则如下:

(1)如果派生类的函数与基类的函数同名,但是参数不同。此时,不论有无virtual关键字,基类的函数将被隐藏(注意别与重载混淆)。
(2)如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆)。

函数Base::f(int) 与 Base::f(float)相互重载
而Base::g(void)被 Derived::g(void)覆盖

#include 
class Base
{
public:
void f(int x)    { cout << "Base::f(int) " << x << endl; }
void f(float x)    { cout << "Base::f(float) " << x << endl; }
virtual void g(void)    { cout << "Base::g(void)" << endl;}
};


class Derived : public Base
{
public:
virtual void g(void)    { cout << "Derived::g(void)" << endl;}
};
void main(void)
{
Derived d;
Base *pb = &d;
pb->f(42); // Base::f(int) 42

pb->f(3.14f); // Base::f(float) 3.14
pb->g(); // Derived::g(void)
}
友元(友元函数、友元类和友元成员函数)

有些情况下,允许特定的非成员函数访问一个类的私有成员,同时仍阻止一般的访问,这是很方便做到的。例如被重载的操作符,如输入或输出操作符,经常需要访问类的私有数据成员。

友元(friend)机制允许一个类将对其非公有成员的访问权授予指定的函数或者类,友元的声明以friend开始,它只能出现在类定义的内部,友元声明可以出现在类中的任何地方:友元不是授予友元关系的那个类的成员,所以它们不受其声明出现部分的访问控制影响。通常,将友元声明成组地放在类定义的开始或结尾是个好主意。

友元函数

友元函数是指某些虽然不是类成员函数却能够访问类的所有成员的函数。类授予它的友元特别的访问权,这样该友元函数就能访问到类中的所有成员。

#include 

using namespace std;

class A
{
public:
    friend void set_show(int x, A &a);      //该函数是友元函数的声明
private:
    int data;
};

void set_show(int x, A &a)  //友元函数定义,为了访问类A中的成员
{
    a.data = x;
    cout << a.data << endl;
}
int main(void)
{
    class A a;

    set_show(1, a);

    return 0;
}
友元类

友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。当希望一个类可以存取另一个类的私有成员时,可以将该类声明为另一类的友元类。
关于友元类的注意事项:

(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明。

#include 

using namespace std;

class A
{
public:
    friend class C;                         //这是友元类的声明
private:
    int data;
};

class C             //友元类定义,为了访问类A中的成员
{
public:
    void set_show(int x, A &a) { a.data = x; cout<<a.data<<endl;}
};

int main(void)
{
    class A a;
    class C c;

    c.set_show(1, a);

    return 0;
}
友元成员函数

使类B中的成员函数成为类A的友元函数,这样类B的该成员函数就可以访问类A的所有成员了。
当用到友元成员函数时,需注意友元声明和友元定义之间的相互依赖,在该例子中,类B必须先定义,否则类A就不能将一个B的函数指定为友元。然而,只有在定义了类A之后,才能定义类B的该成员函数。更一般的讲,必须先定义包含成员函数的类,才能将成员函数设为友元。另一方面,不必预先声明类和非成员函数来将它们设为友元。

#include 

using namespace std;

class A;    //当用到友元成员函数时,需注意友元声明与友元定义之间的互相依赖。这是类A的声明
class B
{
public:
    void set_show(int x, A &a);             //该函数是类A的友元函数
};

class A
{
public:
    friend void B::set_show(int x, A &a);   //该函数是友元成员函数的声明
private:
    int data;
    void show() { cout << data << endl; }
};

void B::set_show(int x, A &a)       //只有在定义类A后才能定义该函数,毕竟,它被设为友元是为了访问类A的成员
{
    a.data = x;
    cout << a.data << endl;
}

int main(void)
{
    class A a;
    class B b;

    b.set_show(1, a);

    return 0;
}
友元小结

在需要允许某些特定的非成员函数访问一个类的私有成员(及受保护成员),而同时仍阻止一般的访问的情况下,友元是可用的。
优点:

可以灵活地实现需要访问若干类的私有或受保护的成员才能完成的任务;
便于与其他不支持类概念的语言(如C语言、汇编等)进行混合编程;
通过使用友元函数重载可以更自然地使用C++语言的IO流库。

缺点:

一个类将对其非公有成员的访问权限授予其他函数或者类,会破坏该类的封装性,降低该类的可靠性和可维护性。

你可能感兴趣的:(C++,c++)