C++ - 多态语法 - 虚函数使用介绍

多态简单介绍

 多态就是多种形态,是不同的对象去完成同一个动作所产生的结果可能有多种。这种多种的形态我们称之为多态。

比如:我们在买票的时候的时候,可能有成人全价,儿童半价,军人免票等等。对于成人,儿童,军人这三个不同的对象,在买票同一动作当中,就产生了不同的结果。

多态的定义 和 实现

 多态出现在同一继承关系当中的不同类对象,比如上述说的 Person对象买票全价,Student对象买票半价。

多态的组成方面,有两大必须的条件

  • 必须通过基类的指针或者引用调用虚函数
  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
     

虚函数

在知道多态是如何构成之前,我们先来认识一种特殊的成员函数---虚函数

注意

  • 其中的 virtual 虽然可以用来修饰虚函数,和虚继承,但是此时的虚函数和虚继承没有任何关系,可以理解为 virtual 修饰函数就是虚函数;修饰继承关系及时虚继承。
  • 关于虚函数 virtual 的修饰,只要在 函数的返回值之前加上 vitual 修饰的函数就是虚函数了。
  • 只要类当中的成员函数可以加 virtual 修饰 变成虚函数,普通的全局函数是不能加 virtual 变成虚函数的

虚函数定义如:

class Person {
public:
    virtual void buy() { cout << "全价" << endl;}
};

全局函数不能加 virtual 修饰变成虚函数

 虚函数的重写

 虚函数和其他成员函数一样,但是虚函数有一个特征,虚函数支持重写(覆盖)

 如果在派生类当中,有一个和基类当中相同的虚函数(两者之间返回值,函数名,参数列表完全相同),我们认为,此时派生类重写了基类当中的虚函数

class Person
{
public:
	virtual void buy() {
		cout << "Perosn:全价" << endl;
	}
};

class Student : public Person
{
public:
	virtual void buy() {
		cout << "Student:半价" << endl;
	}
};

上述子类(student)就重写了 父类(Perosn)当中的 buy() 这个虚函数。

 对于上述 这种虚函数的使用场景(通过指针或者引用来调用虚函数)

void func(Person& people)
{
	people.buy();
}

int main()
{

	func(Person());

	func(Student());

	return 0;
}

输出:

Perosn:全价
Student:半价

这样的话,我们就可以做到类似于,自动识别对象,然后去购买不同的票了。

 请注意,我们在调用虚函数的时候,一定是使用 引用或者指针的方式来调用虚函数,而且子类父类当中的函数都应该是 virtual 修饰的,子类重写过的虚函数,否则无法实现多态(如下,我们把func()函数当中的 Person& 参数类型 改为 Person

void func(Person people)
{
	people.buy();
}

 输出:

Perosn:全价
Perosn:全价

我们发现结果都是 “全价”。没有多态现象出现。 

同样,如果父类的函数没有加 virtual 修饰,输出结果和上述一样,但是如果父类虚函数加了 virtual 修饰,子类函数没有加 virtual 修饰,是可以实现多态的。----但是就算能够实现多态,建议还是把子类和父类的虚函数都加上 virtual 修饰。

 编译器在这里支持,派生类不用加 virtual ,是因为,编译器对于派生类的检查只是检查,派生类符不符合 “三同”的 多态条件。不同,可能看该函数和父类当中的虚函数函数名相同,就是别成隐藏了;相同才会去认为该函数是虚函数的重写。

 因为 派生类 继承了 父类的 virtual 修饰的虚函数,而子类当中的 重写只是对 父类当中虚函数的实现部分进行 重写。

class Person
{
public:
	virtual void buy() {
		cout << "Perosn:全价" << endl;
	}
};

class Student : public Person
{
public:
	void buy() {
		cout << "Student:半价" << endl;
	}
};

void func(Person& people)
{
	people.buy();
}

int main()
{
	Person perosn;
	func(perosn);

	Student student;
	func(student);

	return 0;
}

 输出:

Perosn:全价
Student:半价

 像上述的实现多态例子中的 Student 类型 对象传参到 Person& 类型参数接收,这里发生了 子类 到 父类的 切割。

有了切割,当传入参数就是父类的时候,不需要切割,这类直接就是调用父类对象的引用来调用buy()这个函数;如果传入的是子类的话,就会发生切割,指向子类,此时就是子类的引用,所以调用的是子类的buy()函数。

 具体切割是如何切割法,可以看以下博客:C++ - 继承_chihiro1122的博客-CSDN博客

 但是这里就有一个问题,我们知道,对象当中只存储成员变量,不存储成员函数;而且就算是子类的引用,只是访问的是父类当中子类的那一部分成员,编译器在此处究竟是如何做到区分两个虚函数的呢
 

虚函数重写的两个特殊情况

 协变

 这种情况是 -- 基类和派生类虚函数的返回值类型不同

但是,这里虚函数的返回值类型是有规定的,如果是只是普通类型的返回值类型不同,是会报错的:

 如果不是协变引起的虚函数返回值类型不同,编译器是会报编译错误的。

 只允许 基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用的情况,而我们把这种称为 协变。(而且,父类虚函数 和 子类虚函数 的返回值类型 必须同时是 指针 或者 引用,如果是像 指针 和 引用 岔着用是不行的,编译器会报错

 如下代码所示:

class Person
{
public:
	virtual Person* buy() {
		cout << "Perosn:全价" << endl;
		return 0;
	}
};

class Student : public Person
{
public:
	Student* buy() {
		cout << "Student:半价" << endl;
		return 0;
	}
};

虽然协变指定是父类虚函数返回值是父类的指针或引用,紫烈虚函数返回值是子类的之怎或引用;但是,只要是满足继承关系的类,按照上述的方式去使用协变,也是可以的(就是说上述返回值不一定是 Person 和 Student,也可以是其他父子关系)。

 如下代码所示(在 Person 和 Student 的虚函数返回值类型使用 A 和  B 其他继承关系):


class A
{
public:

};

class B : public A
{
};

class Person
{
public:
	virtual A* buy() {
		cout << "Perosn:全价" << endl;
		return 0;
	}
};

class Student : public Person
{
public:
	B* buy() {
		cout << "Student:半价" << endl;
		return 0;
	}
};

但是协变是一个 坑,由上面说的种种细节可以看出来,细节很多,不好记。而且协变在日常当中的使用频率也很少。不如不支持这个语法。但是在学校考试 和 面试当中经常考。

 析构函数的重写

class Person {
public:
	virtual ~Person() {
		cout << "~Person()" << endl;
	}
};
class Student : public Person {
public:
	virtual ~Student() { 
		cout << "~Student()" << endl; 
	}
};

虽然上述的 Person 和 Student 两个类的析构函数名字看上去不同,但是实际上,继承当中的 父类 和子类的 析构函数是可以 构成虚函数的

如上述例子, ~Perosn()和 ~Student()两个函数,子类可以重写。

之所以支持,是因为,类的虚构函数都被处理为了destructor 这个统一的名字。这样处理的目的也是为了让 子类和父类的析构函数构成重写。 

如果不这样处理,会出现一些问题,子类重写的话,会出现一些问题:
 

class Person {
public:
	 ~Person() {
		cout << "~Person()" << endl;
	}
};
class Student : public Person {
public:
	 ~Student() { 
		cout << "~Student()" << endl; 
	}
};


int main()
{
	Person* p = new Person();
	delete p;

	p = new Student();
	delete p;

	return 0;
}

如上所示,我们希望输出的结果是 :

~Person()
~Student()
~Person()

但,实际输出却是:
 

~Person()
~Person()

出现这个问题的原因是也 p 指针的类型。我们知道,普通对象 看当前调用的类型来决定调用 哪一个对象的析构函数,当前调用者 (p) 的类型是 Person*,所以自然只会调用 Person 对象的析构函数,(对于 delete p ,释放顺序是 p->destructor  +  operator delete(p)  ),这里调用的是 Person的析构函数,但是这里我们不希望调用 Perosn的析构函数。

这里我们希望 p 指向那个对象就调用哪一个对象的析构函数,而不是看 p 指针的类型来决定调用哪一个对象的 析构函数。如果看类型的话,一直调用的就是 p 的类型的析构函数。但是 p 这个指针有可能指向父类,也有可能指向子类。

我们希望 p->destructor()调用的析构函数,是一个多态调用,而不是一个普通调用。        

 所以这里我们要使用多态来实现,在 detele 底层实现当中,就是使用 指针来调用 析构函数的,指针已经实现了,现在还差重写,所以才有了上述的 析构函数重写。

final   和  override

 上述我们也介绍了,如果实现函数重写,我们也发现,C++当中对于重写函数的规定还不少,缺一样都会导致重写失败。有些错误甚至在编译器时期是不会报错的,只有在程序运行之后才能发现问题,此时在发现问题就只能去debug,在代码量很多的场景当中,特别麻烦。

所以在C++11 当中新增了 两个关键词 final 和 override ,来帮助我们检查是否重写。

final:

final 关键字是用来阻止某一虚函数被子类重写:

 final 关键词修饰位置 和 之前 const 修饰 this 指针一样,是在 参数列表括号的右边。(而且只能放在父类的虚函数上)

 当父类的 虚函数被 final 修饰之后,子类就不能再重写父类的这个虚函数了。

 override:

override用于帮助派生类检查是否完成重写,如果没有,会报错:

 这样就方式我们因为,派生类没有重写完成,而导致后序debug的麻烦了。

  虚函数的指针 与 虚函数表 (多态的一些底层原理)

 下面这个例子,应该输出什么?

class Bass
{
public:
	virtual void func()
	{

	}

private:
	char _b;
};

int main()
{
	cout << sizeof(Bass) << endl;

	Bass b;

	return 0;
}

 上述输出不是1,而是8。我们知道,类的大小只计算成员大小,不计算函数。

我们打开调试发现,在 b 这个对象当中多了一个 _vfptr 指针(virtual function)。

 这指针是 虚函数表 指针,

这就是为什么,没有实现多态,不要把虚函数搞到类当中去;因为虚函数会被放进虚函数表当中;其实严格来说,虚函数还是存储在代码段当中的,而虚函数表当中存储的是各个虚函数的地址。

 这个虚函数表,在重写之后,会发生变化,我们来看下面这个例子:

class Person {
public:
	virtual void BuyTicket() {
		cout << "买票-全价" << endl;
	}
	int _a = 1;
};
class Student : public Person {
public:
	virtual void BuyTicket() {
		cout << "买票-半价" << endl;
	}
	int _b = 2;
};

void Func(Person& p)
{
	p.BuyTicket();
}

int main()
{
	Person Mike;
	Student jason;

	Func(Mike);
	Func(jason);

	return 0;
}

 在上述这个代码当中,Mike 对象(父类对象)当中有下面这两个部分:

 _vfptr 是虚函数表的指针,此时的虚函数表当中存储的是 Person(父类)当中虚函数的地址,此时只有一个地址,因为只写了一个虚函数,如果有多个虚函数的话,有几个虚函数,虚函数表当中就有几个地址。

Jason对象(子类对象)当中有下面两个部分:

 我们发现,在子类对象 jason 当中有一个父类对象,父类对象当中也有一个虚函数表指针,此时虚函数表当中也只有一个地址,这个地址已经发生了改变,指向了子类重写的虚函数。

 总结:重写也可以叫覆盖,重写是我们写代码层面所看到的,覆盖是底层逻辑当中,子类重写的虚函数地址覆盖了父类虚函数的地址。

 此时我们就明白下面这个函数是如果实现,传入父类就调用父类的函数,传入子类就调用子类的函数了

  •  传入父类,看到就是父类,直接调用父类的函数;传入子类,切片之后看到的还是父类;
  • 如果是普通调用,在编译的时候就确定了地址,编译器判断是不是普通的调用很简单,看符不符合多态,不符合就是普通调用。
  • 如果是普通调用,就直接看p的类型,p的类型是Person,那么就直接在Person当中找到这个函数的地址,所以就不能实现多态。
  • 符合多态,就和上述说的一样,运行时到指向的对象的虚函数表当中,找调用。

重载,重写(覆盖),重定义(隐藏)的对比

 虚函数和多态的例题

 很多人,看到满足多态的条件,以为输出的是    B->0  ;但是实际输出却是  B->1

 我们发现,上述的func()函数,满足 虚函数重写,子类父类的虚函数函数名,返回值,参数类型和个数都是相同的(注意,不要看val 的缺省参数不同就认为这里不满足多态,参数列表相同只要求 参数个数 和 参数类型相同即可);

而且,在 test()函数当中调用的 func ()函数,使用指针调用的 ,因为 func()函数是本类当中的成员函数,本类当中的成员是需要用 this->func()  这样的形式来访问的;而这里的this指针是父类还是子类的 指针呢?

答案是父类的。因为,子类继承父类当中的成员,不是直接进行拷贝赋值,而是调用父类的构造函数,在子类当中构造出一个父类的子对象,这个子对象我们可以理解为子类当中父类对象成员。然而,test()函数是存在于代码段的,他也不是在子类和父类当中都有存在,也就是说,test()函数只在代码段当中存储了一份,而不是在子类和父类当中都存储了一份。

因为,父类对象是直接在子类当中存储的,子类不会单独的看test()函数,而是把父类对象看做是一个整体,test()就在这个整体当中,所以,test()对象当中的 调用 func()函数使用的this指针是 A*(父类指针)。

 而在主函数当中的指针p,指向的是 B (子类对象),又满足多态,所以此时肯定是调用子类当中的 func()函数,所以输出 B-> 是正确的。

但是,要注意的是,重写只是重写函数当中 实现部分,对于函数名,返回值,参数列表还是使用的是父类的。所以,此处的 val 的缺省参数才是 父类当中的1,而不是子类当中的0

 可以理解为,重写是,父类 的 函数名,返回值,参数列表   +   子类函数实现。

现在我们把上述例题修改一下,把 test()函数挪到 B 函数当中,其他不变:

 此时输出结果就是 B->0 了 。因为此时的 test()函数不满足多态的条件,此时的test()函数当中调用的 func()函数的 this 指针不是A*(父类指针)了,而是 B* (子类指针)

 所以,此时 test()当中的 调用 func()函数,就只是一个简单的 在本类当中调用本类的其他函数的情况。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

chihiro1122

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值