c++ virtual 关键字的解释

C++面向对象之virtual

$KaTeX parse error: Expected '}', got 'EOF' at end of input: …10 代码难度: 代码量: $}

正文

先从virtual说起

面向对象的三大特征,封装,继承,多态。说到多态,就绕不开virtual。C++的多态又如此的不一样,它分为静态绑定和动态绑定,其中有一个非常重要的关键字,它就是virtual.

C++中的virtual关键字修饰方法 约等于 Java中abstract关键字修饰方法。

virtual,望文生义。虚拟的意思。它是用来修饰一个方法的,当编译器看到这个关键字,就懂了。哦,这个方法调用的时候,要看实际对象的真正类型,而非它的指针或者引用的类型。简而言之,virtual,实现动态绑定。

这里由不得不涉及内存模型。一个指针变量,一个引用,他们都是有类型的,说到底,在执行的时候,他们都是一个地址,然而这个地址还附带一个类型信息。但是这个类型信息只是被声明的类型而非真正的类型,因为子类可以代替父类。

如果没有virtual修饰这个方法,那么这个方法就没有动态绑定的特征。也就是说,编译器直接认为这个方法调用的是这个指针变量或者引用的声明类型的方法。还是那句话,C++认为,速度就是哲学。

是的,virtual 虽然比较灵活,可以以统一的方法调用来执行不同的具体操作,屏蔽实现细节,非常的灵活,但是是有代价的。

说一说virtual的代价吧。首先,它是动态绑定,也就是在运行的时候才能确定调用哪个方法,为什么这样子呢?为什么要等到运行时才能决定哪个方法呢?因为virtual要根据实际创建的对象去调用方法。那有人说了,我看new后面的类的类名称不就知道调用哪个方法了吗?那不也可以在编译器就知道方法的位置了吗?说的好。我其实一开始也无法理解。 我猜测一个原因是,new 操作可能遇到内存不够用等等情况,所以要等到运行的时候才确定方法的位置。另外一个原因是,一个变量(本质上是地址)只有一个类型,万一涉及运行过程中的类型转换,到底这个实例被转换成什么类型是不确定的。而virtual的实现是,首先找到指针或者引用对应的动态内存中的对象,然后找到对象的类的虚拟方法表,根据虚拟方法表找到具体的方法的位置。而如果一个方法非virtual, 编译器的统一处理是直接根据指针变量(或者引用)的类型找到方法的位置,也就是静态绑定。
也就是说,virtual 不根据类型来选择方法,而根据内存中的对象持有的虚函数表vtbl去找放方法.

virtual的第二个成本是,它会在对象中引入一个虚拟方法表指针,一个隐藏成员。而对于类本身而言,增加了一个虚拟方法表数组,vtbl(virtual function table)。两次寻址,才能真正找到一个虚拟方法的位置。以上,是C++ primer plus说的,这里我也是蛮晕的。为什么要做这种设计?直接去找真正的实现类,然后根据那个类去找那个方法的位置不就好了吗?为什么要引入一个虚拟方法指针?
(ps:这里再补充一下,后面看了一些其他的书,我有了一些其他的看法,要vtbl的意义在于简化 C++ 的内存模型设计。如果我们知道 Java里的内存对象模型, 我们就知道, 每个Java对象其实都有一个 markword, 指向它真正的类型,C++应该是没有的,所以它有其他的机制来做到这一点!!)

还有一个成本就是增加了一个指针的大小,这个可能导致和C语言,其他语言的内存模型不兼容,等等。

virtual 什么时候用?

如果一个类的方法要派生类被定制化修改(重写),用virtual修饰这个方法。

如果假设以后派生类会新增成员变量,那么应该把这个派生类的基类的析构函数定义成virtual的,但是派生类如果没有必要被继承,就无需用virtual修饰派生类的方法,直接定义成普通方法即可。

因为在用多态的时候,一个变量被声明的父类型实际上是用子类new出来的,我们要调用子类的析构函数,需要把父类析构声明成virtual。如果我们要手动实现子类的析构,没有必要声明子类的析构为virtual。但是如果我们自己不声明,那么继承过来的子类的析构就是virtual的,因为virtual会一直传递下去。

另外,只有指针和引用才能使用多态,这点是个隐性知识,在C++ primer plus里 简笔带过,未有深究。

构造函数和析构函数

我们创建一个动态内存中的对象,调用delete,就会调用析构函数,在析构函数中,不要写 delete this,否则就会造成死循环或者栈溢出。

构造函数和析构函数的调用顺序是,对于构造函数而言,先调用基类构造函数,然后子类构造函数。对于析构函数而言,先调用子类析构函数,在调用基类析构函数。(如何理解,基类是内核,派生类是外壳,是不断加入的东西,是后面的)

这里可能有些难以理解,之前我甚至是必须依靠死记硬背。但是总结了一下,其中的逻辑是统一的,构造函数执行的时候,会包含一个基类的隐藏实例,所以要调用基类的构造函数。而且我们必须知道,即使派生类的构造函数里没有写明,编译器也会在派生类的构造函数最开始加上一句调用基类构造函数的语句,同理,即使派生类的析构函数里没有写明,编译器也会在派生类的析构函数最结尾加上一句调用基类析构函数的语句,我称之为“强制调用父类对应函数特质”。构造函数和析构函数是和其他成员函数不一样的,其他的函数是没有这个特质的,一旦重写父类方法,必须显式调用父类的对应方法。

有一个问题是,如果自己手动写了调用基类构造方法呢?如果第一句不是基类构造方法,而第二句是呢?(不是第一句可能会报错!)

构造方法是不会被继承的.只能被自己本类调用.析构同理. 之所以我们看起来它像是被继承了,其实是因为编译器替我们自动补全了调用基类构造和析构的代码而已!

构造函数不能是virtual的

构造函数不能是virtual的,为什么呢?因为基类的构造函数必须被派生类调用,但是不继承(注意了,派生类调用基类构造方法但是不继承),因此构造函数不能是被virtual修饰。而析构函数则比较奇怪,首先调用派生类本身的析构函数释放派生类特有的动态内存性质的成员变量,然后再调用基类的析构函数自动释放所有成员变量的空间,即使你自己实现了析构函数,里面并没有写任何delete 语句表示要释放成员变量,但是编译器会给你自动加上的。所以,一再强调,析构函数和构造函数是比较神奇的,编译器会围绕这两个函数做大量自动化的补全工作,因为其意义重大,作用特殊。

我们不理解C++语言的麻烦,就是因为我们不理解编译器的行为。我们学习语言的要点,就在于理解编译器的设计思路,虽然我们不能实现一个C++编译器,但是编译器的选择,却是我们必须理解的。

重载父类成员函数将会隐藏父类同名不同参函数

这个特点,是我百思不得其解的。如果我们重载了基类的一个函数,那么同名的函数会被隐藏起来(C++ Primer plus里的原话)。怎么个隐藏法?是声明为private了吗? 无论如何,如果我们重载了基类的一个函数,必须显示声明所有的同名不同参的函数,并且参数列表和返回参数保持一致(参数列表类型不能使用子类,但是返回参数类型可以),在方法题里直接调用基类对应的方法。

为什么啊!!编译器,你为什么要这么设计?C++你为什么这么复杂!
ps: 后来再看的时候,明白了,这种东西类似于 namespace 的机制。 一个class 就如同一个 namespace, 一旦遇到同名的, 就会把外面传进来覆盖掉, 也就是本层的优先级更高。这里可以参考 Effective C++ 里面说的。下文我有再次提到这个问题。

一段神奇的代码见证C++的多态

最后,放一段神奇的代码,看看自己的了解程度怎么样。

首先定义两个类

using namespace std;

class Base{
    int a;
public:
    Base():a(0){
    }
    Base(int pa):a(pa){}
    virtual ~Base(){
       cout << "Base deconstructor" << endl;
    };
    virtual void f1(){
       cout << "Base f1" << endl;
    }
};

class Derived: public Base{
    int *b;
public:
    Derived(): b(new int(0)){  // TODO ,如何写调用哪一个基类构造方法呢?
    }

    Derived(int pb): b(new int(pb)){}

    ~Derived(){
       // cout << "Derived deconstructor" << endl;
    }

    virtual void f1(){
        cout << "Derived f1" << endl;
    }
};

现在请求分析以下几个测试方法输出结果的原因:

void main_f2(){
    Base *item = new Derived(1);
    delete item;
    // Derived deconstructor
    // Base deconstructor
}

void main_f3(){
    Base item = Derived(1); //这里生成了一个临时对象, 临时对象会先调用自己的析构,然后调用基类析构
    // 打印结果:
    // Derived deconstructor
    // Base deconstructor
    // Base deconstructor
}

void main_f4(){
    Base item = Derived();
    // 打印结果:
    // Derived deconstructor
    // Base deconstructor
    // Base deconstructor
  	// 调用两次基类的析构函数
}

void main_f5(){
    Base * item = new Derived;
    item->f1();
    delete item;
    // 打印结果:
    // Derived deconstructor
    // Base deconstructor
}

void main_f6(){
    Base item = Derived();
    item.f1();
    // 打印结果:
    // Base f1,如果不是用指针,或者引用,则不会使用多态
}

void main_f7(){
    Base item = Derived();
    Base& item2 = item;
    item2.f1();
    // 打印结果:
    // Base deconstructor
    // Base f1
    // Base deconstructor
    // 即便是引用,然而引用本身指向的对象不是在动态内存中,那么也是不会用到多态的
}

int main() {
    main_f7();
    return 0;
}

virtual修饰一个方法, 多数时候表明这个方法要被重写,这个类要要用作基类,要发生多态场景。是的,这是第一反映。virtual修饰析构函数,尤其表名了这个类要当做基类。

那么反过来说,如果一个类的析构函数不是virtual,那么不要去继承这个类。否则若为多态使用,会早成内存泄漏。如果从编译的角度来实现类似Java里的Final呢?

所以,我们正式的提出一个问题,如何自己实现类似 final的效果来避免 继承非virtual析构函数的类的坑呢?

第二个问题,如何实现禁止对象的拷贝呢?禁止拷贝构造函数和赋值运算函数。

第三个问题,什么是链接错误?什么是编译错误?

构造函数能调用自己类的虚函数吗?

构造函数的过程是创建一个对象,而这个过程才刚刚开始,而虚函数是在多态中使用的,如果在一个构造函数中调用了自己的虚函数,因为自己的类还没构造完成,对象尚且不完整,难以确定类的类型?所以是不合适的。

**如果父类B 有一个虚函数叫func_A,子类D 也实现这个函数,在子类D 的构造函数当中去调用这个func_A,运行的是谁的实现?**运行的是子类D 的实现。因为子类构造函数调用的时候对象的虚表指针指向的是子类的虚函数表,因为子类实现了func_A所以调用的是子类自己的func_A。因此我们必须优先构造子类的虚函数表。我们必须知道的一点是,构造函数的调用,是晚于普通成员函数存在的(构造函数可以调用普通函数,用来做二次初始化)。而且虚函数表是在编译期完成的。

为什么编译器会在重载的时候自动把基类对应的同名不同参函数隐藏起来?

我猜测,编译器基于以下原理,既然是重载,那么应该是不满意现有的参数列表,要不然为什么要重载。

这和编译器对构造函数的自动生成是一样的。如果你自己写了一个有参数的构造函数,那么编译器不会为你生成默认无参的构造函数,因为编译器觉得你可以很容易的自己声明一个无参的默认构造函数,却要花很大劲去禁止调用自动生成的无参构造函数,所以干脆就不帮你生成。(如果你自己不定义,那么编译器确定,你是需要自动生成的,如果你自己定义了,编译器无法知道,你到底需不需要一个无参的构造函数,有的时候你压根不想要,因为无参构造可能带来麻烦,因为有些初始值必须显示指定,所以编译器干脆直接放弃这个行为)

在《Effective C++》6.33里,又说到,避免使用遮掩继承而来的名称里对此也有详细的说明,更为权威一点,本质上是命名空间覆盖的问题。

参考

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