C++类继承基础2——虚函数和纯虚函数

什么是虚函数?特点是什么?

在某基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数,
virtual函数声明格式为:

virtual 函数返回类型 函数名(参数表) {函数体};

虚函数的定义不需要使用关键字virtual;
实现多态性,通过指向派生类的基类指针或引用,访问派生类中同名覆盖成员函数。
虚函数特点
:如果一个基类的成员函数定义为虚函数,那么它在派生类中也保持为虚函数;即使在派生类中省略了virtual关键字,也仍然是虚函数。(世世代代虚函数)


作用

虚函数的作用主要是实现了多态的机制。基类定义虚函数,子类可以重写该函数;在派生类中对积累定义的虚函数进行重写时,需要在派生类中声明该方法为虚方法。

如果要在派生类里重新定义基类的办法,通应该把基类方法声明为虚的。为基类声明一个虚析构函数是必要的。

当子类重新定义了父类的虚函数后,当父类的指针指向子类对象的地址时,[即B b; A a = & b;] 父类指针根据赋给它的不同子类指针,动态的调用子类的该函数,而不是父类的函数(如果不使用virtual方法,请看后面★),且这样的函数调用发生在运行阶段,而不是发生在编译阶段,称为动态联编。而函数的重载可以认为是多态,只不过是静态的。

💡 注意,非虚函数静态联编,效率要比虚函数高,但是不具备动态联编能力。
1.如果使用了virtual关键字,程序将根据引用或指针指向的对象类型来选择方法,

2.如果没有使用关键字virtual,程序使用引用类型或指针类型来选择方法。

动态联编性

class A{
private:
    int i;
public:
    A();
    A(int num) :i(num) {};
    virtual void fun1();
    virtual void fun2();

};

class B : public A
{
private:
    int j;
public:
    B(int num) :j(num){};
    virtual void fun2();// 重写了基类的方法
};

// 为方便解释思想,省略很多代码
A a(1);
B b(2);
A *a1_ptr = &a;
A *a2_ptr = &b;

// 当派生类“重写”了基类的虚方法,调用该方法时
// 程序根据 指针或引用 指向的  “对象的类型”来选择使用哪个方法
a1_ptr->fun2();// call A::fun2();
a2_ptr->fun2();// call B::fun1();
// 否则
// 程序根据“指针或引用的类型”来选择使用哪个方法
a1_ptr->fun1();// call A::fun1();
a2_ptr->fun1();// call A::fun1();


可以让成员函数操作一般化,用基类的指针指向不同的派生类的对象时,基类指针调用其虚成员函数,则会调用其真正指向对象的成员函数,而不是基类中定义的成员函数(只要派生类改写了该成员函数)。

若不是虚函数,则不管基类指针指向的哪个派生类对象,调用时都会调用基类中定义的那个函数

注意事项

重新定义不是重载

我们得明白,在基类重新定义虚函数不会生成函数的两个·重载版本。因此如果重新定义继承的方法,应确保与原来的原型完全相同,即返回类型,函数名,函数参数必须相同

我们看个例子

#include<iostream>
using namespace std;
class AA
{
private:
	int a_;
public:
	AA(int a)
	{
		a_ = a;
	}
	virtual int prin(int a, int b)
	{
		return a * b;
	}
};
class BB :public AA
{
private:
	int b_;
public:
	BB(int a, int b):AA(a),b_(b){}
	virtual void prin(int a, int b)//这是不行的,原型的返回类型必须与上面一致
	{
		cout << a * b << endl;
	}
};

但如果返回类型是基类引用或指针,则可以修改为指向派生类的引用和指针

#include<iostream>
using namespace std;
class AA
{
private:
	int a_;
public:
	AA(int a)
	{
		a_ = a;
	}
	virtual AA& prin(int a, int b)
	{
		return a * b;
	}
};
class BB :public AA
{
private:
	int b_;
public:
	BB(int a, int b):AA(a),b_(b){}
	virtual BB& prin(int a, int b)//可以
	{
		cout << a * b << endl;
	}
};

构造函数

构造函数不能是虚函数根据继承的性质,构造函数执行的顺序是:基类的构造函数->派生类的构造函数但是如果基类的构造函数是虚函数,且派生类中也出了构造函数,那么当下应该会只执行派生类的构造函数,不执行基类的构造函数,那么基类的构造函数就不能构造了

析构函数

析构函数应当是虚函数,除非类不用做基函数。

#include<iostream>
using namespace std;
class AA
{
private:
	int a_;
public:
	AA(int a):a_(a){}
	virtual void h()
	{
		cout << "基类" << endl;
	}
	~AA()
	{
		cout << "基类析构" << endl;
	}
};
class BB :public AA
{
private:
	int b_;
public:
	BB(int a,int b):AA(a),b_(b){}
	virtual void h()
	{
		cout << "派生类" << endl;
	}
	~BB()
	{
		cout << "派生类" << endl;
	}
};
int main()
{
	AA* a = new AA(2);
	AA* b = new BB(2, 3);
	a->h();
	b->h();
	delete b;
}

结果是

基类
派生类
基类析构


如果析构函数不是虚的,就将只调用对应于指针类型的析构函数,这意味着只有AA类的虚构函数被调用,即使指针指向了一个BB类对象

如果我们析构函数是虚的,将调用相应类型的析构函数。因此AA指针指向了BB类对象,将先调用BB类的析构函数,再调用AA类的

#include<iostream>
using namespace std;
class AA
{
private:
	int a_;
public:
	AA(int a):a_(a){}
	virtual void h()
	{
		cout << "基类" << endl;
	}
	virtual ~AA()
	{
		cout << "基类析构" << endl;
	}
};
class BB :public AA
{
private:
	int b_;
public:
	BB(int a,int b):AA(a),b_(b){}
	virtual void h()
	{
		cout << "派生类" << endl;
	}
	 ~BB()
	{
		cout << "派生类" << endl;
	}
};
int main()
{
	AA* a = new AA(2);
	AA* b = new BB(2, 3);
	a->h();
	b->h();
	delete b;
}

结果是

基类
派生类
派生析构
基类析构

因此虚析构函数可以确保正确的析构函数序列被调用


最后,给类的析构函数定义析构函数没有错,即使这个类不做基类

友元函数

友元函数不能是虚函数,因为友元不是类成员,而只有成员才能是虚函数
为下面三大特性留下伏笔

总结

虚函数是指使用了修饰符virtual修饰过后的函数,而且定义虚函数的函数必须为类的成员函数,虚函数被继承后所继承的派生类都是为虚函数,友员函数不能被定义为虚函数,但是可以被定义为另外一个类的友员,析构函数可以定义为虚函数,但是构造函数却不能定义为虚函数。

如前所述,在C++语言中,当我们使用基类的引用或指针调用一个虚成员函数时会执行动态绑定。

因为我们直到运行时才能知道到底调用了哪个版本的虚函数,所以所有虚函数都必须有定义。

通常情况下,如果我们不使用某个函数,则无须为该函数提供定义。

但是我们必须为每一个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。

对虚函数的调用可能在运行时才被解析

当某个虚函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数。

被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。

#include<iostream>
using namespace std;
class A {
public:
	virtual void a()
	{
		cout << "基类的a函数" << endl;
	}
};

class B :public A {
public:
	virtual void a()
	{
		cout << "派生类的a函数" << endl;
	}

};
void C(A& t)
{
	t.a();
}
int main()
{
	A a;
	B b;
	C(a);//调用A::a()
	C(b);//调用B::a()
}


在第一条调用语句中,参数t绑定到A类型的对象上,因此当C函数调用a函数时,运行的是A::a()

在第二条调用语句中,情况也是类似的

必须要搞清楚的一点是,动态绑定只有当我们通过指针或引用调用虚函数时才会发生

a = b;
a.a();//调用A::a()

当我们通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将用的版本确定下来。

例如,如果我们使用 a 调用 a(),则应该运行a()的哪个版本是显而易见的。我们可以改变a表示的对象的值(即内容),但是不会改变该对象的类型。因此,在编译时该调用就会被解析成A的a().

关键概念:C++的多态性
OOP 的核心思想是多态性(polymorphism)。多态性这个词源自希腊语,其含义是甜形式”。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。
当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数直正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对.如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。
另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虎看数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论如何都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时编定到该对象所属类中的函数版本上。

当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有Note在这种情况下对象的动态类型才有可能与静态类型不同。

派生类中的虚函数

当我们在派生类中覆盖了某个虚函数时,可以再一次使用virtual关键字指出该函数的性质。

然而这么做并非必须,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数.

一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。

同样,派生类中虚函数的返回类型也必须与基类函数匹配。

该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。

也就是说,如果D由B派生得到,则基类的虚函数可以返回B*,而派生类的对应函数可以返回D*,只不过这样的返回类型要求从D到B的类型转换是可访问的。

基类中的虚函数在派生类隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类的形参必须和派生类中的形参严格匹配

final和 override 说明符

派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为,

编译器将认为新定义的这个的影写基类中原有的函数是相互独立的。

这时,派生类的函数并没有覆盖掉基类中的版本。

class A {
public:
	virtual void a()
	{
		cout << "基类的a函数" << endl;
	}
};

class B :public A {
public:
	 void a(int a)//没有覆盖虚函数
	{
			}

};

就实际的编程习惯而言,这种声明往往意味着发生了错误,因为我们可能原本希望派生类能覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了。

要想调试并发现这样的错误显然非常困难。

在C++11 新标准中我们可以使用override关键字来说明派生类中的虚函数。这么做的好处是在使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误,后者在编程实践中显得更加重要。

如果我们使用 override 标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错:

struct B {

virtual void fl(int)const;
virtual void f2();
voia f3();
};
struct D : B {

void fl(int)const override; //正确:f1与基类中的fl匹配
void f2(int) override; //错误:B没有形如f2(int)的函数
void f3() override; //错误:f3不是虚函数
void f4() override; // 错误:B没有名为f4的函数
};

在D1中,f1的override说明符是正确的,因为基类和派生类中的f1都是const成员,并且它们都接受一个int返回 void,所以D1中的f1正确地覆盖了它从B中继承而来的虚函数。

D1中f2的声明与B中f2的声明不匹配,显然B中定义的f2不接受任何参数而D1的f2接受一个int。因为这两个声明不匹配,所以D1的f2不能覆盖B的f2,它是一个新函数,仅仅是名字恰好与原来的函数一样而已。

因为我们使用 override 所表达的意思是我们希望能覆盖基类中的虚函数而实际上并未做到,所以编译器会报错。

因为只有虚函数才能被覆盖,所以编译器会拒绝D1的f3。该函数不是B中的虚函数,因此它不能被覆盖。类似的,f4的声明也会发生错误,因为B中根本就没有名为f4的函数。

我们还能把某个函数指定为final,如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作都将引发错误:

struct D2 :B {
//从B继承£2()和f3(),覆盖f1(int)
void f1(int) const final; //不允许后续的其他类覆盖f1(int)
};
struct D3 : D2 {
void f2(); // 正确:覆盖从间接基类B继承而来的£2

void fl(int) const;// 错误:D2 已经将 f2 声明成 final
};

final和override说明符出现在形参列表(包括任何const和引用说明符)以及尾置返回类型之后。

虚函数与默认实参

和其他函数一样,虚函数也可以拥有默认实参。

如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。

换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参即使实际运行的是派生类中的函数版本也是如此。

此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符。

如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

回避虚函数的机制

在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版

本。使用作用域运算符可以实现这一目的,例如下面的代码:

// 强行调用基类中定义的函数版本而不管 baseP的动态类型到底是什么
double undiscounted =basep->Quote::net_price (42);


该代码强行调用Quote的net_price函数,而不管basep实际指向的对象类型到底是什么。该调用将在编译时完成解析。

通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。

什么时候我们需要回避虚函数的默认机制呢?

通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。

在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。

如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用城运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值