C++ 虚函数与抽象类

Abstract Class

Posted by kevin on October 21, 2019

preface

想起之前别人问过我知不知道 C++ 抽象类是什么,我心想,抽象类不就是面向对象的一种说法吗,但是具体怎么说还真不知道,后来才知道当时有多傻,抽象类在 C++ 里面是有具体定义的,所以特意记录一下,顺便当作复习 C++ 了.

多态概念

都知道面向对象的一个特征就是多态,那么为什么我们需要多态呢,通常来讲就是因为我们需要一个方法在不同的对象中的表现不同,一般这种方式是通过派生类重写基类的函数或者在基类中定义虚函数来实现。

虚函数

虚函数的声明很简单,只需要在类型的前面加上一个 virtual 关键字,如下这样就声明了一个虚函数

virtual void func();

至于我们为什么要使用虚函数呢?我们来看个例子:

class Animal{
public:
    Animal(){}
    void shout(){
        cout << "Animal is shouting!" << endl;
    }
};

class cat:public Animal{
public:
    cat(){}
    void shout(){
        cout << "cat is shouting!" << endl;
    }
};

上面定义了两个类, cat 类继承了 Animal 这个基类,并且改写了基类的 shout 方法,那么有下面几种情况来帮助我们理解虚函数的意义:

1.用对象直接调用

Animal animal;
cat tom;
animal.shout();
tom.shout();

由于在基类和派生类中的 shout 方法是不同的,所以直接调用的话,会调用各个类中的方法,因此输出为

Animal is shouting!
cat is shouting!

2.通过指针或者引用调用(不带 virtual 关键字)

现在我们来创建之前两个对象的引用,看看这回调用函数会出现什么情况:

Animal is shouting!
Animal is shouting!

为什么 b 创建的是 cat 类的引用但他还是调用了 Animal 类的方法呢?再继续用指针指向这两个对象,同时调用 shout 方法:

Animal *a = &animal;
Animal *b = &tom;
a->shout();
b->shout();

输出依然跟上面通过引用调用一样,这是为啥呢,原因就是通过这种方式调用的话,程序会根据引用类型或者指针类型来决定调用哪个方法,相反,如果方法带有 virtual 关键字,程序会根据引用或指针指向的对象来选择方法,就像下面这样:

class Animal{
public:
    Animal(){}
    virtual void shout(){
        cout << "Animal is shouting!" << endl;
    }
};

class cat:public Animal{
public:
    cat(){}
    virtual void shout(){
        cout << "cat is shouting!" << endl;
    }
};

2.通过指针或者引用调用(带 virtual 关键字)

用上面的例子,在基类中我们用 virtual 关键字声明了一个虚函数,在派生类中这个函数自动变成了虚函数,但我们还是在派生类中也声明了 virtual 关键字,这也可以让人更加清楚哪些方法是虚函数。

Animal *a = &animal;
Animal *b = &tom;
a->shout();
b->shout();

这里的输出就会变成:

Animal is shouting!
cat is shouting!

因为程序会从指针指向的对象那里判断要调用哪个函数(用引用同理),可以看到,这种虚函数的方法十分好用,当然,如果我们想在派生类中使用基类的方法时,要加上作用解析符,这里如果 tom 对象想调用 Aminal 类的 shout 方法时,就要写成 b->Animal::shout() 的形式。

如果要在派生类中重新定义基类的方法,通常会将基类的方法声明为虚的,并且,通常还会给基类声明一个虚析构函数

虚析构函数

我们来简述一下派生类的构造函数和析构函数,那就是,在派生类对象创建的时候,程序会首先创建基类对象,这意味着基类对象应该在程序进入派生类构造函数之前就被创建,因此我们用成员初始化列表来干这事。我们将上面的类稍做一些改动:

class Animal{
public:
    Animal(int a, char g){
        age = a;
        gender = g;
    }
    virtual void shout(){
        cout << "Animal is shouting!" << endl;
    }
    virtual ~Animal(){}
private:
    int age;
    char gender;
};


class cat:public Animal{
public:
    cat(int a, char f, string c):Animal(a, f),color(c){}
    virtual void shout(){
        cout << "cat is shouting!" << endl;
    }
private:
    string color;
};

这样的话,派生类的构造函数就得调用基类的构造函数了,实际上这也很合道理,没有爸爸哪有儿子呢?如果没有写基类的构造函数的话,系统就会自动调用基类的默认构造函数,所以还是不要这么干。析构的顺序是先析构派生类对象,再调用基类的析构函数来析构基类,这也很合道理,肯定先析构掉儿子再析构爸爸一步一步来的嘛,直接析构爸爸的话儿子肯定直接没了。。

那这里又有一个重要概念,那就是虚析构函数 ,虚析构函数也就是析构函数,都是负责回收对象的内存以及一些清理工作,那么他具体有什么作用呢?

virtual ~Animal(){}

析构函数非虚函数情况

依然是上面的例子,我们给每个类的析构函数做点改动,如果析构函数不是虚函数,那么同理,当我们通过指针指向对象或者引用一个对象时,系统只会调用该指针类型或信用类型的析构函数

class Animal{
public:
    // ignore some code
    ~Animal(){
        cout << "Animal destructor called!" << endl;
    }
};

class cat:public Animal{
public:
	// ignore some code
    ~cat(){
        cout << "cat destructor called!" << endl;
    }
};

我们来用指针新建两个对象,一个指向 Animal 类对象,一个指向 cat 类对象

    Animal *a = new Animal(0, 'f');
    Animal *b = new cat(1, 'm', "brown");
    delete a;
    cout << "--------------------" << endl;
    delete b;

这段代码执行后的输出为:

Animal destructor called!
--------------------
Animal destructor called!

说明这两个对象都只是调用了 Animal 基类的析构函数,析构函数非虚函数的情况下系统只调用对应于指针类型的 析构函数,这里,即使 b 指向的是一个派生的 cat 类对象,但是派生类的析构函数并不会被调用。

析构函数为虚函数

class Animal{
public:
    // ignore some code
    virtual ~Animal(){
        cout << "Animal destructor called!" << endl;
    }
};

class cat:public Animal{
public:
	// ignore some code
    virtual ~cat(){
        cout << "cat destructor called!" << endl;
    }
};
// some code 
Animal *a = new Animal(0, 'f');
Animal *b = new cat(1, 'm', "brown");
delete a;
cout << "--------------------" << endl;
delete b;

当声明了析构函数为虚函数时,系统就会根据指针指向的对象类型来判断该调用哪个析构函数,上述代码的输出如下,可以看到 b 先调用了派生类 cat 的析构函数再调用基类的析构函数,这就很符合析构的顺序,不会造成意外的内存泄露等。

因此,虚析构函数可以确保正确的析构函数系列被调用。虽然有时候可能析构函数这种正确的调用顺序并不是很重要,如上例,这是因为析构函数并没有执行什么操作,然而。如果派生类 cat 包含一个执行了某种操作的析构函数,那么基类 Animal 必须要有一个虚析构函数,即使这个虚析构函数什么都不做。

静态联编和动态联编

程序在调用函数时,将执行哪个可执行代码块呢?这件事是由编译器决定的,将源代码中的函数调用解释为执行特定的函数代码块称为函数名联编(binding) ,在 C 语言中,这很简单,一个函数名就对应一个函数,然而由于 C++ 的重载特性,这会比执行 C 语言代码更困难些,编译器还得查看函数参数和类型才能决定调用哪个函数,但是, C/C++ 编译器可以在编译的过程中完成这种联编。

在编译过程中进行联编称为静态联编(static binding),又称为早期联编(early binding)

然而,虚函数使得这项工作变得更加困难,因为使用哪个函数在编译的时候还是不能确定的,编译器无法知道用户会选择哪种类型的对象,因此编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又叫晚期联编(late binding)

在程序运行过程中进行二点联编成为动态联编

虚函数工作原理

这里说一下虚函数的工作原理,还是挺有趣的。编译器通常处理虚函数的方式为:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针,这种数组称为虚函数表(virtual function table,vtbl) , 这个表中存储了为类对象进行声明的所有虚函数的地址。e.g. 基类对象包含了一个指针,该指针指向了基类中所有虚函数二点地址表,派生类对象也包含一个指向独立地址表的指针。如果派生类提供了虚函数的额新定义,该虚函数表将保存新函数的地址,否则,该 vtbl 保存的是函数原始版本的地址。如果派生类定义了新的虚函数,那么这个函数的地址也会添加到 vtbl 中。但是注意了,不管类中包含了多少个虚函数,都只需要在对象中添加 1 个地址成员,只是这个成员(数组)的大小不同而已。

cpp_pp.jpg

因此,程序调用虚函数时,会先查看存储在对象中的 vtbl 的地址,转去相应的函数地址表,如果使用类声明中定义的第一个虚函数,则程序将使用数组中的第一个函数地址,并执行具有该地址的函数,以此类推。我们也可以通过 debug 方式看到编译器的这一操作:

vtbl

编译器对非虚函数采用静态联编,对虚函数产生动态联编,之所以有这两种联编方式,是因为下面几个原因:

  1. 动态联编的效率更低,因为他要采取一些方法来跟踪基类指针或引用指向的对象类型,这增加了额外的开销。而且,如果类不作为基类或者派生类不重新定义基类方法,就不需要用到动态联编,静态联编会更好。
  2. 使用虚函数动态联编时,在内存和执行速度上有一定成本,因为对每个对象而言,所需内存空间都将增大,增大量为存储的地址的空间;对于每个类,编译器都要创建一个虚函数地址表;对于每个函数的调用,都要额外执行一步操作,即到表中查找地址。
  3. 非虚函数的效率比虚函数稍高,但他不具备动态联编的功能。

抽象(基)类

纯虚函数

在说到抽象类前,先说一下纯虚函数(pure virtual function) ,在函数声明时将其值赋为 0 , 则这就是一个纯虚函数:

virtual void func() = 0;

纯虚函数提供了一个接口给派生类,要求派生类将这个接口给实现。在基类中是不对这个函数进行定义的(当然,要定义的话也是可以的,但是没必要) 。抽象(基)类简称为抽象类(又简称为 ABC),凡是拥有纯虚函数的类都叫抽象类抽象类只能作为基类使用,因为它无法生成对象,只能通过派生类重新将纯虚函数给实现。

仔细想想,这种设计也挺有道理的,例如有个基类几何类,派生出三角形类和矩形类,这两个派生类都有 周长 和 面积 这两种方法,但是实现的代码是不一样的,这时就可以在基类中声明两个纯虚函数:周长 和 面积 ,只是告诉派生类 “你拥有这些方法,但是怎么实现你自己看着办“ ,同时,几何类的实例化也没有任何意义,因为它只是个抽象的概念,而且 C++ 也不允许这么做!

class Geometry
{
public:
    Geometry() {}
    virtual void square() = 0;  // pure virtual function
};

class Rect: public Geometry{
public:
    void square(){
        s = len * height;
    }
private:
    float len;
    float height;
    float s;
};

class Triangle: public Geometry{
public:
    void square(){
        s = 0.5 * height *bottom;
    }
private:
    float height;
    float bottom;
    float s;
};

因此,抽象类可以看作是一种必须实施的接口,要求具体派生类覆盖其纯虚函数。这种模型在基于组件的编程模式下很常见,这种情况下,使用抽象类使得组件设计人员能够指定”接口约定“,确保从抽象类派生的所有组件都至少支持抽象类指定的功能。


reference: 《C++ Primer Plus》