多态与虚函数
1. 什么是多态
所谓多态,就是同一个函数名具有多种状态,或者说一个接口具有不同的行为;C++的多态分为编译时多态和运行时多态,编译时多态也称为为静态联编,通过重载和模板来实现,运行时多态称为动态联编,通过继承和虚函数来实现。
1.1 编译时多态
重载(Overloading)
是指同一可访问区内被声明的几个具有不同参数列(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。注意区分重写(是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同(花括号内),派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有virtual修饰。)
- 静态绑定:在重载中,方法的选择是在编译时确定的,因此它被称为静态绑定或早期绑定。编译器会根据方法调用中提供的参数类型来决定要调用哪个方法。
- 方法签名:方法的重载是根据方法的签名来区分的,方法签名包括方法的名称、参数的数量和参数的类型。编译器使用方法签名来决定要调用的方法。
- 无需运行时类型信息:由于重载是在编译时解决的,因此不需要运行时类型信息或动态分派(与运行时多态相反)。这降低了运行时的开销,使代码更加高效。
尝试用重载的方式来模拟LOL中英雄的互相攻击:
先创建一个英雄基类,然后创建三个英雄类,分别是盖伦,伊泽瑞尔和瑞兹
#include<iostream>
class Ezreal;
class Ryze;
class Hero
{
};
class Garen : public Hero
{
public:
void Attack(Ezreal* pEzreal)
{
std::cout << name << "Garen attack Ezreal" << std::endl;
}
void Attack(Ryze* pRyze)
{
std::cout << "Garen attack Ryze" << std::endl;
}
};
class Ezreal : public Hero
{
public:
void Attack(Garen* pGaren)
{
std::cout << "Ezreal attack Garen" << std::endl;
}
void Attack(Ryze* pRyze)
{
std::cout << "Ezreal attack Ryze" << std::endl;
}
};
class Ryze : public Hero
{
public:
void Attack(Garen* pGaren)
{
std::cout << "Ryze attack Garen" << std::endl;
}
void Attack(Ezreal* pEzreal)
{
std::cout << "Ryze attack Ezreal" << std::endl;
}
};
int main()
{
Garen* garen = new Garen();
Ezreal* ezreal = new Ezreal();
Ryze* ryze = new Ryze();
garen->Attack(ezreal);
garen->Attack(ryze);
}
输出结果:
Garen attack Ezreal
Garen attack Ryze
如果用重载的方法写英雄类,工作量十分巨大,而且每次增加英雄都要修改所以英雄类的 Attack
方法。而使用运行时多态,也就是虚函数的方法则可以大大减小工作量。
1.2 运行时多态
虚函数与函数重写(Override)
重写是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同(花括号内),派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有virtual修饰(不加virtual则是在子类中重定义)。
如果我们不使用 virtual
设置虚函数,
#include<iostream>
class Hero
{
public:
void Attack()
{
std::cout << "hero attack" << std::endl;
}
};
class Garen : public Hero
{
public:
void Attack()
{
std::cout << "Garen attack" << std::endl;
}
};
class Ezreal : public Hero
{
public:
void Attack()
{
std::cout << "Ezreal attack" << std::endl;
}
};
class Ryze : public Hero
{
public:
void Attack()
{
std::cout << "Ryze attack" << std::endl;
}
};
int main()
{
Hero* hero = new Ryze();
hero->Attack();
}
输出结果是
hero attack
调用了基类的方法
如果设置虚函数,修改 Hero
类
class Hero
{
public:
virtual void Attack()
{
std::cout << "hero attack" << std::endl;
}
};
输出结果是
Ryze attack
可以用基类指针调用派生类的重写的函数。
我们再用虚函数实现最开始的功能
//Hero基类
class Hero
{
public:
virtual void Hurted() = 0;
};
//Ryze类
class Ryze : public Hero
{
public:
void Attack(Hero* pHero)
{
pHero->Hurted();
}
void Hurted()
{
std::cout << "Ryze is Hurted" << std::endl;
}
};
//Ezreal类
class Ezreal : public Hero
{
public:
void Hurted()
{
std::cout << "Ezreal is Hurted" << std::endl;
}
};
//Garen类
class Garen : public Hero
{
public:
void Hurted()
{
std::cout << "Garen is Hurted" << std::endl;
}
};
int main()
{
Ryze* ryze = new Ryze();
Garen* garen = new Garen();
Ezreal* ez = new Ezreal();
ryze->Attack(garen);
ryze->Attack(ez);
ryze->Attack(ryze);
}
输出结果
Garen is Hurted
Ezreal is Hurted
Ryze is Hurted
2. 虚函数指针与虚函数表
参考文章:
- C++中的虚指针与虚函数表
- [如何高效的理解C++的多态?](如何高效的理解C++的多态? - 南风的回答 - 知乎 https://www.zhihu.com/question/333280913/answer/2745601633)
3. 关于virtual
某个方法在基类中声明为虚方法,一旦一个方法被声明为虚方法,它在后续继承过程中将永远是一个虚方法,不管重写的时候是否使用 virtual
关键字,在父类中声明了虚方法,子类中重写的方法可以不使用关键字 virtual
。
class Base
{
public:
virtual void Print()
{
std::cout << "I am Base" << std::endl;
}
};
class Derived1 : public Base
{
public:
void Print()
{
std::cout << "I am Derived1" << std::endl;
}
};
class Derived2 : public Derived1
{
public:
void Print()
{
std::cout << "I am Derived2" << std::endl;
}
};
int main()
{
Base* p1 = new Derived1();
p1->Print();
Base* p2 = new Derived2();
p2->Print();
Derived1* p3 = new Derived2();
p3->Print();
}
输出结果:
I am Derived1
I am Derived2
I am Derived2
3.1 override & final
- override:保证在派生类中声明的重载函数,与基类的虚函数有相同的签名;
- final:阻止类的进一步派生 和 虚函数的进一步重写。
加了override,明确表示派生类的这个虚函数是重写基类的,如果派生类与基类虚函数的签名不一致,编译器就会报错。
如果不希望某个类被继承,或不希望某个虚函数被重写,则可以在类名和虚函数后加上 final 关键字,加上 final 关键字后,再被继承或重写,编译器就会报错。
3.2 为什么C++的构造函数不可以是虚函数,而析构函数可以是虚函数
简言之:构造函数不能是虚函数,因为虚函数是基于对象的,构造函数是用来产生对象的,若构造函数是虚函数,则需要对象来调用,但是此时构造函数没有执行,就没有对象存在,产生矛盾,所以构造函数不能是虚函数。
析构函数可以为虚函数,而且当要使用基类指针或引用调用子类时,最好将基类的析构函数声明为虚函数,否则会存在内存泄露的问题。【内存泄漏:析构函数是虚函数,因为若有父类指针指向子类对象存在,需要析构的是子类对象,但父类析构函数不是虚函数,则只析构了父类,造成子类对象没有及时释放,引起内存泄漏。】
4. 多态面经
- inline可以是虚函数吗?
可以。inline需要 展开 ,编译时不存在地址,但是虚函数需要将其地址存入虚表中,表现上来说,两者是互斥的。但是需要注意,inline只是一个建议性关键字,关键取决于编译器,不会强制性执行。两者关键字存在的时候,如果是多态调用,编译器会自动忽略inline这个建议,因为没法将这个虚函数直接展开,这个建议无了。不是多态就可以利用此建议。 - static函数可以是虚函数吗?
不可以。静态成员函数没有this指针,直接利用类域指定的方式调用。虚函数都是为多态服务的。多态是运行时决议,而静态成员函数都是编译性决议。 - 构造函数可以是虚函数吗?
不可以。构造函数之前,虚表没有进行 初始化 。virtual函数是为了实现多态,运行时去虚表找对应虚函数进行调用。对象的虚表也是在构造函数的初始化列表进行初始化的。 - 析构函数可以是虚函数。
- 拷贝构造和赋值可不可以是虚函数?
拷贝构造不可以,拷贝构造同样也是构造函数。
赋值可以,但是没有意义。
(但是可以简单实现一下父类给给子 ... ... )但是,赋值一般是同类对象之间数据进行拷贝,这样就不存在实际价值。 - 6.对象访问普通函数快还是虚函数快?
不构成多态一样快,否则普通函数快。 - 虚函数表是在什么阶段生成的,存在哪里的?
构造函数初始化列表初始化的是虚函数表指针,对象中也是存的指针。
存在代码区--利用验证法,和只读常量或者静态变量的地址进行验证。 - 在(基类的)构造函数和析构函数中调用虚函数会怎么样 ?
从语法上讲,调用没有问题,但是从效果上看,往往不能达到需要的目的(不能实现多态);因为调用构造函数的时候,是先进行父类成分的构造,再进行子类的构造。在父类构造期间,子类的特有成分还没有被初始化,此时下降到调用子类的虚函数,使用这些尚未初始化的数据一定会出错;同理,调用析构函数的时候,先对子类的成分进行析构,当进入父类的析构函数的时候,子类的特有成分已经销毁,此时是无法再调用虚函数实现多态的。
Reference
- C++——来讲讲虚函数、虚继承、多态和虚函数表
- C++ 一篇搞懂
- [如何高效的理解C++的多态?](如何高效的理解C++的多态? - 南风的回答 - 知乎 https://www.zhihu.com/question/333280913/answer/2745601633)
- 【游戏开发面经汇总】- 计算机基础篇
- 虚函数一定是运行期才绑定么?