C++虚函数、虚继承:virtual

发布时间 2023-08-23 22:18:35作者: 无夜千幕雪

1.引子

在类的继承当中曾经出现过这样一种情况:B、C继承自A,D继承自B和C。

 

之前提到过,这种情况下,关于类A当中的内容,会被复制成两份给到D,当进行访问的时候,需要指定C或者B,才能够定位到A当中的变量是来自哪里。就像下面这样。

 代码表示:

class A {
public:
    A(int a) : m_A(a) {}
    int m_A;
};
class B :  public A {
public:
    B(int a) : A(a) {}
};
class C :  public A {
public:
    C(int a) : A(a) {}
};
class D : public B, public C {
public:
    D(int a = 1) : B(a),C(a) {}
};
int main() {
    D d;
    //std::cout << "size of D.m_A is " << d.m_A << endl; d.m_A++;
    std::cout << "size of D.B::m_A is " << d.B::m_A << endl; d.B::m_A++;    // 输出1
    std::cout << "size of D.C::m_A is " << d.C::m_A << endl; d.C::m_A++;    // 因为与B中继承的A不是同一参数,因此++无效,输出1
    std::cout << "size of D.A::m_A is " << d.A::m_A << endl;    //依据类的构造顺序,决定直接访问A时指定的位置,这里B类先构造,所以默认是指向B当中的A,++后输出2
}

需要注意的是,可以直接使用d.A::m_A来访问子类当中的成员,这是依据类的构造顺序,决定直接访问A时指定的位置,这里B类先构造,所以默认是指向B当中的A。

2.虚继承

通常在上面的情况下,我们是不希望从A当中得到两个衍生变量的,只想得到其中的一个。那么虚继承就是用来解决这个问题的。

虚继承的写法是在常规继承的优先级前面,加上virtual关键字。

class B : virtual public A

B和C分别虚继承A,就可以使得后面所有同时继承B、C的子类当中,只会保留一份A的内容。

class A {
public:
    A(int a) : m_A(a) {}
    int m_A;
};
class B : virtual public A {
public:
    B(int a) : A(a) {}
};
class C : virtual public A {
public:
    C(int a) : A(a) {}
};
class D : public B, public C {
public:
    D(int a = 1) : B(a),C(a),A(a) {}    //注意,这里需要对A进行额外构造
};
int main() {
    D d;
    std::cout << "size of D.m_A is " << d.m_A << endl; d.m_A++;    // 输出1
    std::cout << "size of D.B::m_A is " << d.B::m_A << endl; d.B::m_A++;    // 输出2
    std::cout << "size of D.C::m_A is " << d.C::m_A << endl; d.C::m_A++;    // 输出3
    std::cout << "size of D.A::m_A is " << d.A::m_A << endl;    // 输出4
}

因此在上面这个例子当中,每次的++都是对同一变量的操作,因此会分别输出1、2、3、4.

注意:一旦使用了虚继承,那么必须在后面的子类当中,都对虚继承的基类进行构造。

3.虚函数

virtual用以修饰继承,就是虚继承。如果用来修饰函数,那么就是虚函数,它的基本格式如下。

class test{
    virtual void Function()
};

虽然都使用了virtual关键字,但它们解决的根本问题并不一样。

虚函数最主要的作用是C++多态性的表现,即同一事件(函数),发生在不同对象(类)当中,会呈现不同的特性。和函数重载类似,只不过多态性是在不同类当中的相同函数,而实现的方法,则是使用类指针。

3.1.类指针

如果在基类和派生类当中,都存在一个名称和参数完全相同的函数,在派生类当中就会确实地存在两个相同的函数。派生类直接调用就是自己的函数,指定基类就可以调用基类的函数,然而这样一种用法并不方便,于是我们会使用类的指针。

通常,对于引用和指针来说,必须要求参数类型完全一样。

int a;
double* p_d = &a;    //错误,不允许不同类型的转换
char& c = a;    //错误,不允许不同类型的转换

对于类来说,基类和派生类的指针和引用,是可以互相转换的。

将派生类引用或指针转换为基类引用或指针被称为向上转换

class Base{
};
class Drived : public Base{
};
Drived d;
Base * p_b = &d;
Base & r_b = d;

将基类引用或指针转换为派生类引用或指针被称为向下转换但是向下转换必须使用显式的转换。

class Base{
};
class Drived : public Base{
};
Base d;
Drived * p_d = (Drived*)&b;
Drived & r_d = (Drived&)b;

那么相互转换的意义在哪里呢?向下转换意义不是很大,但通过向上转换,即基类指针指向派生类对象,可以调用让基类指针对象调用派生类当中的函数,这个过程也被叫动态绑定。

3.2.静态绑定和动态绑定

程序执行时,调用哪段函数块的过程,被称为函数的绑定

在C语言当中比较简单,全部都会在编译时完成,这就被称为静态绑定

对于C++来说,因为有重载和虚函数的存在,想要找到对应的函数块就没那么简单,而有时候则会在程序运行时,根据场景选择不同的函数块,这就被称为动态绑定。

class Base {
public:
    virtual void Fun1(void) {
        std::cout << "Base::Fun1..." << endl;
    }
    void Fun2(void) {
        std::cout << "Base::Fun2..." << endl;
    }
};
class Derived :public Base {
public:
    void Fun1(void) {
        std::cout << "Derived::Fun1..." << endl;
    }
    void Fun2(void) {
        std::cout << "Derived::Fun2..." << endl;
    }
};

int main() {
    Base* p_b;
    Derived d;

    p_b = &d;   
    p_b->Fun1();    // 基类指针指向派生类,虚函数调用派生类
    p_b->Fun2();    // 基类指针指向派生类,函数调用基类
    return 0;
}

在上面的例子当中,Fun1在基类当中是虚函数,Fun2不是,因此在使用基类指针指向派生类对象方法的调用中,调用的是派生类的Fun1和基类的Fun2.

3.3.纯虚函数和抽象类

不是所有时候,基类的函数都需要实现。

例如这种情况,对于不同的图形,定义一个基类shape,其中包含了一个函数draw用以绘制图形。然而在不确定图形是什么的情况下,draw没有任何意义。即一个函数在基类当中没有意义,但是在派生类当中存在意义,那么这样的基类函数就可以被定义为纯虚函数。

标准写法是,在函数后面写上'= 0 ',并且不给出函数实现:

class test{
    virtual void Fun() = 0;
};

则上面这个例子用代码可以这样实现:

class Shape {
public:
    virtual void Draw(void) = 0;
};
class Circle :public Shape {
public:
    void Draw(void) {
        std::cout << "Draw::Circle..." << endl;
    }
};
class Square :public Shape {
public:
    void Draw(void) {
        std::cout << "Draw::square..." << endl;
    }
};

int main() {
    Shape* p_s;
    //Shape s;  //纯虚函数不允许直接作为对象
    Circle c;   Square sq;
    p_s = &c;
    p_s->Draw();    //绘制圆形
    p_s = &sq;
    p_s->Draw();    //绘制正方形
    return 0;
}

4.虚函数想要解决的问题

费了这么大劲了,虚函数到底是想要干什么?主要目的是为了代码后续的维护性。

在C语言当中,库文件都是依据编译好的,使用时需要包含这些库文件后,再将这些文件添加到程序内容当中。我们修改的部分是程序的框架,而里面的模块即库文件是写好的。

虚函数带来这样一种用法,我们可以先将程序的框架搭好,然后再填充库文件或配置文件,这样更加有利于模块化的开发过程,每个小组或成员只需要负责自己模块的那部分内容。

例如:使用Process类作为基类,run虚函数作为方法,在主函数当中读取配置文件,遍历调度表当中的类模块,并执行它们。这样只需要更改配置文件和各个模块,就可以实现程序的更改。

5.虚函数的注意事项

5.1.构造函数不能是虚函数

尽管声明的是基类指针,但是我们首先必须要有一个派生类的对象,这个对象在构造的时候,会自动调用基类的构造函数,因此将基类的构造函数声明为虚函数没有任何意义。

5.2.析构函数必须是虚函数

除非当前类不用作基类,否则析构函数尽量声明为虚函数。在删除基类指针时,如果基类的虚构函数不是虚函数,那么就无法调用到派生类的析构函数,可能会造成内存风险。

5.3.友元不能是虚函数

因为友元函数不是类成员,所以友元函数不能是虚函数。

5.4.不能重定义

如果基类的虚函数,在派生类当中没有重新定义,那么还是会使用基类当中的该函数。

5.5.如果不涉及到类的指针,那么定义虚函数在不具备太大的意义