【C++关键字 virtual】

1、virtual关键字的起源

在C++的早期设计中,通过基类指针可以访问派生类的成员变量,这是由于派生类对象在内存中的布局是基类成员变量在前,派生类成员变量在后。因此,当我们使用基类指针指向派生类对象时,可以正常访问到派生类中从基类继承来的成员变量。
然而,对于成员函数,情况就不同了。在编译时期,成员函数并不会被放入对象的内存空间中,而是存放在一块单独的内存区域,每个类只有一份成员函数的代码。当我们通过基类指针调用成员函数时,编译器会根据指针的静态类型(也就是基类类型)去查找对应的成员函数,而不是动态类型(也就是实际指向的派生类类型)。这就导致了我们无法通过基类指针调用派生类的成员函数。
为了解决这个问题,C++引入了虚函数的概念。通过将基类的成员函数声明为虚函数,我们就可以通过基类指针调用派生类的成员函数,实现了所谓的多态性。这是C++支持面向对象编程的一个重要特性。

2、构成多态的条件

多态是面向对象编程的一个重要特性,它允许我们通过基类指针或引用来操作派生类对象。在C++中,要实现多态,需要满足以下条件:

  • 存在继承关系:多态基于继承,因为只有在存在基类和派生类的情况下,我们才能通过基类来操作派生类。这是多态的基础
  • 被调用的函数必须是虚函数:在C++中,只有声明为虚函数的成员函数才能实现多态。虚函数允许在派生类中被重写,这样当我们通过基类指针或引用调用这个函数时,会根据实际的对象类型来调用相应的函数,这就是动态绑定
  • 虚函数必须被重写:虚函数的重写意味着在派生类中提供了一个与基类虚函数具有相同函数签名(即函数名和参数类型)的函数。这样,当我们通过基类指针或引用调用这个函数时,会调用派生类中的版本,而不是基类中的版本。

满足以上所有条件,我们就可以通过基类指针或引用来操作派生类对象,实现多态。这使得我们的代码更具有通用性和可扩展性,因为我们可以添加新的派生类,只要它们正确地重写了基类的虚函数,就可以被同样的基类指针或引用操作,而无需修改已有的代码。

3、虚函数

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

class base
{
public:
 	base();
 	virtual void test(); //定义的一个虚函数
private:
 	char *basePStr;
};

上述代码在基类中定义了一个test的虚函数,所以在其子类重新定义父类的做法这种行为成为覆盖(override),或者为重写。
virtual示例代码:

#include <iostream>
using namespace std;
class A 
{
public:
    void fool()
    {
        printf("1\n");
    }
    virtual void fun()
    {
        printf("2\n");
    }
    
};
class B : public A 
{
public:
    void fool()     //隐藏,派生类的函数屏蔽了基类的函数
    {
        printf("3\n");
    }
    void fun()      //多态,对基类进行覆盖
    {
        printf("4\n");
    }
};
int main(void){
    A a;
    B b;
    A *p = &a;
    p->fool();  //输出1
    p->fun();   //输出2
    p = &b;
    p->fool(); //取决于指针类型,输出1
    p->fun();  //取决于对象类型,输出4,体现了多态
    B *p1 = &b;
    p1->fool();//取决于指针类型,输出3
    return 0;
}

3.1虚函数的特征

  • 当在基类中定义了虚函数时,如果派生类没有定义新的函数来遮蔽此函数,那么将使用基类的虚函数。
  • 将基类中的函数声明为虚函数,这样所有派生类中具有遮蔽关系的同名函数都将自动成为虚函数。
  • 只需要在虚函数的声明处加上 virtual 关键字,函数定义处可以加也可以不加。
  • 只有派生类的虚函数覆盖基类的虚函数(函数原型相同)才能构成多态(通过基类指针访问派生类函数)。
  • 构造函数不能是虚函数。对于基类的构造函数,它仅仅是在派生类构造函数中被调用,这种机制不同于继承。也就是说,派生类不继承基类的构造函数,将构造函数声明为虚函数没有什么意义。

3.2虚函数表的生成

  • 先将基类中的虚表内容拷贝一份到派生类虚表中;
  • 如果派生类重写了基类中的某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
  • 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类的虚表的最后。

3.3不规范的重写行为

  • 在派生类中依旧保持虚函数的属性,我们只是重写了它,这是非常不规范的。
  • 在重写基类虚函数的时候,派生类的虚函数不加关键字virtual也可以构成重写,但是这种写法不规范,不建议这样使用。

补充:虚函数的主要目的是允许用基类的引用或指针调用派生类的实现,这称为动态绑定或延迟绑定。如果一个函数不是虚函数,那么编译器在编译时就会解析出函数的调用,这称为静态绑定或早期绑定。

4、抽象类和纯虚函数

在C++中,纯虚函数是一种特殊的虚函数,它在基类中没有定义,只有声明。纯虚函数的声明形式如下:

virtual 返回值类型 函数名 (函数参数) = 0;

这里的= 0就表示这是一个纯虚函数。纯虚函数在基类中没有实现,需要在派生类中被重写。
包含纯虚函数的类被称为抽象类(也叫接口类)。抽象类不能被实例化,也就是说,你不能创建一个抽象类的对象。这是因为抽象类包含至少一个没有实现的函数,所以抽象类的对象是不完整的。
当派生类继承了抽象类后,如果派生类没有重写所有的纯虚函数,那么这个派生类也还是一个抽象类,也不能被实例化。只有当派生类重写了所有的纯虚函数,这个派生类才不再是抽象类,可以被实例化。
纯虚函数的存在,规定了所有继承这个抽象类的派生类都必须实现这个函数,这就是所谓的接口继承。接口继承强调的是派生类必须实现的一组公共接口,而不是继承了一些已经实现的行为。
纯虚函数就是你公司的大领导,他的作用就是在办公室挥斥方遒,指点江山,激扬文字,给你一个方针或者战略,但是具体的任务他是不会干的,给下面的人干。如果下面的人还不干,他依然是个方针战略,没有具体的内容。
总的来说,抽象类和纯虚函数是面向对象多态性的一个重要机制,它使得基类可以定义接口,而将具体的实现留给派生类去完成
纯虚函数示例:

#include <iostream>
using namespace std;
class A 
{
public:
    virtual void fool() = 0;
    virtual void fun() = 0;  //定义2个纯虚函数
};
class B : public A 
{
public:
    void fool() override //隐藏,派生类的函数屏蔽了基类的函数
    {                     
        printf("3\n");
    }
    void fun()      //多态,对基类进行覆盖
    {               //override 可写可不写,最好加上
        printf("4\n");
    }
};
int main(void){
    A *ptra;     //基类指针
    B *ptrb;     //派生类指针
    B b;         //派生类实例
    ptra = &b;   //基类指针指向派生类实例
    ptra ->fool();
    ptra ->fun();
    return 0;
}

注:
一个类有虚函数,并不意味着它不能创建实例。虚函数允许派生类覆盖基类的成员函数实现,使得基类的实例可以调用派生类实现的函数。因此,即使一个类有虚函数,也可以创建该类的实例并调用其成员函数。
然而,需要注意的是,如果一个类有纯虚函数(即定义了但没有实现),那么该类就是抽象类,不能被实例化。纯虚函数使得派生类必须覆盖该函数实现,因此抽象类只能作为基类使用,不能直接创建实例。

5、虚拟继承

在C++中,virtual关键字在继承中的使用主要是为了解决多重继承中的菱形继承问题(Diamond Problem)。
菱形继承问题是指在多重继承过程中,一个类可能会通过多个路径继承到同一个基类,这会导致在最底层的派生类中,基类的成员会出现重复,造成资源浪费和可能的命名冲突。
当你使用public Animal进行继承时,这是普通的公有继承。如果一个类通过多个路径继承了Animal,那么在最底层的派生类中,Animal的每个实例都会有一个副本。这可能会导致二义性和不必要的资源浪费。
例如,假设你有以下的类结构:

class Animal {
public:
    void eat();
};

class Mammal : public Animal {
};

class Bird : public Animal {
};

class Bat : public Mammal, public Bird {
};

在这个例子中,Bat类通过Mammal和Bird类继承了Animal类。这意味着在Bat类的对象中,有两个Animal类的实例。如果你调用Bat对象的eat方法,编译器将无法确定应该调用哪个Animal实例的eat方法,这就产生了二义性。
然而,如果你使用virtual public Animal进行继承,这就是虚拟继承。虚拟继承确保无论一个类通过多少个路径继承了基类,基类在派生类中只有一个实例。这就解决了菱形继承问题。
以下是使用虚拟继承的版本:

class Animal {
public:
    void eat();
};

class Mammal : virtual public Animal {
};

class Bird : virtual public Animal {
};

class Bat : public Mammal, public Bird {
};

在这个例子中,Bat类的对象只有一个Animal类的实例,因此调用eat方法时就不会有二义性了。

6、总结

书山有路勤为径,学海无涯苦作舟。

7、转载文章

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值