C++继承和多态


本文较长,建议收藏或者关注观看,其实我就是骗关注的

继承

C++有封装、继承、多态三大特性,本文我们就聊聊关于C++的后两个特性继承和多态。
首先关于继承,继承就是创建一个类,他的类继承父类,拥有父类的全部或者一部分,下面用代码实现一个简单的类。

继承简单实现

class Person
{
public:
	void showName();
	{
		cout << _name << endl;
	}
protected:
	string _name;
}

class Student : public Person
{
protected:
	int _num;
}

可以看到继承的语法就是在类名后加: 继承方式 父类类名,子类中就有了父类的成员(全部或者一部分,视继承方式而定),所以继承的概念其实很简单,下面讨论一下不同继承之间的继承之后的类内成员的变化。

private和protected

在不用继承和多态时,我们不是很注意protectedprivate这两个关键字的区别,因为其作用是一样的,但是在继承中,这两个关键字的不同就体现出来了,我们先看下这两个关键字之间的区别。

private:private表示私有,除了该类内的成员函数、和该类有关的友元函数可以访问外,其他均不可访问,该类定义的对象也不能访问

protected:protected表示受保护的,类内成员函数,和该类相关的友元函数,子类的类内成员函数、子类相关的友元函数可以访问以外,其他均不能访问,包括该类定义的对象

继承前后类内成员的变化

继承方式基类的public成员基类的protected成员基类的private成员继承引起的访问控制关系变化概况
public继承仍为public成员仍为protected成员不可见基类的非私有成员在子类的访问属性不变
protected继承变为protected成员仍为protected成员不可见基类的非私有成员都成为子类的保护成员
private继承变为private成员变为private成员不可见基类的非私有成员都成为子类的私有成员

基类就是父类,派生类就是子类。

小结

  1. 父类的私有(private)成员在子类中是不能访问的,如果一些父类成员不想被对象直接访问,但需要在子类中能访问,就定义成受保护的成员(protected),这样父类子类的对象就不能直接访问,但是父类和子类中都可见且可在类内访问,可以看出保护成员限定符是因继承出现的。
  2. public继承是一个接口继承,保持is-a原则,每个父类可用的成员对子类也可用,因为每个子类对象都是一个父类对象。
  3. protected/private继承是一个实现继承,父类的部分成员并未完全成为子类的一部分,是has-a的关系,所以一般不会去使用这两种继承,绝大多数时候我们用的都是public继承。
  4. 所有继承方式都可以在父类内访问公有成员和保护成员,但是父类的私有成员存在只是在子类不可见,不能访问。
  5. 继承可以不写继承方式,编译时class默认使用private继承,struct默认是public,但是最好显示的写出继承方式。
  6. 在实际应用中,我们都用的是public继承,很少有用protected/private继承,C++标准中建议我们在所有继承都可以用的时候优先使用protected/private继承。

关于is-a和has-a

这一块我还没有彻底弄明白是怎么一回事,但是大概讲一下,我会就在这几天去问老师,更正,建议大家多看看几篇讲解博客,综合一下

is-a

关于is-a举个例子,汽车有很多,吉利是车的其中一种,车能实现的功能吉利也能实现,吉利还有自己的东西,对应到C++中这就是最基本的类继承的实现,向上面最开始举的例子,学生和人的关系,也是一个is-a的关系。

class Car
{
public:
	...
}

class Benz : public Car
{
	...
}

公有继承子类可以自由使用父类中的接口,父类中公有接口在子类中仍是公有,公有成员子类也全都有

has-a

has-a关系相当于一个合成关系,汽车由很多部分构成,汽车有轮胎,但是轮胎只能是汽车的一部分,他不能实现车的功能。
是关联关系的一种,是整体和部分(通常为一个私有的变量)之间的关系,并且代表的整体对象负责构建和销毁代表部分对象,代表部分的对象不能共享。

class Tyre
{
	...
};

class Car
{
private:
	Tyre tyre;
	...
};

然后和今天继承有关的部分,对于私有和保护继承来说,父类中的公有接口就不再是公有,变为私有或者保护,也就不再是可以在外部定义一个对象使用这些接口了。

子类的六大成员函数

六大成员函数包括:构造函数、析构函数、拷贝构造函数、赋值操作符重载,取地址符重载,const修饰的取地址操作符重载。
这几个默认成员函数在子类中会默认合并,需要注意的是在构造和析构时不需要显示调用父类的构造和析构函数,举个例子,看下面代码

class Person
{
public:
	Person()
	{}
	
	~Person()
	{}
	...
};

class Student : public Person
{
public:
	Student()
	{}
	
	~Student()
	{
		Person::~Person();
		//这样调用是错误的,不需要这么调用,系统会自动合成
		//如果这么调用,遇到父类中存在new分配的对象时,会delete两次
		//delete两次的后果就是抛异常,导致代码出问题,所以不能这么调用
	}
private:
	...
};

下面分析一下为什么不能这么调用,借此复习一下内存中的分段,下图中由于变量在栈中是从上往下定义的,如abcd变量,释放时则是先释放d,再是c然后b最后是a,所以类比Student类,由于类创建对象先是Person(父类)变量先创建,然后才是Student自己有的变量创建,所以如果释放应该遵循先释放Student自己定义的变量,再释放Student,但是如果显示调用,会出现先释放Person的变量再释放Student变量,显然是不符合栈的堆叠规则的,所以c++做了合成成员函数的事情,你不需要去调,在Student的析构函数结束之后会自动调用Person的析构,这点我们可以运行代码测试一下。
示意图
下面用代码测试一下我们所述的现象

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

	~Person()
	{
		cout << "~Person()" << endl;
	}
};

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

	~Student()
	{
		cout << "~Student()" << endl;
	}
};

int main()
{
	Student student;

	return 0;
}

运行结果,和我们所述一致,创建Student对象时,先创建Person父类中的变量,然后才是自己的变量,释放先释放Student的变量,然后才是Person
在这里插入图片描述

如何设计一个不能被继承的类

一个小问题,问题的答案就是:把父类的构造函数设成private

假设有一个子类继承了一个父类,但是父类的构造函数是私有的,结果创建对象的时候出问题了,子类需要初始化父类的变量,合成父类的构造函数,居然不可见,那就创建不了啊,本来想继承结果人家说这是我的不让你继承,所以把构造设置成private就能达成不能被继承的条件。

我可不是胡说,看图说话
在这里插入图片描述

友元函数和继承

友元函数不能被继承,也就是说父类的友元函数不能访问子类私有和保护的成员

静态成员与继承

当父类定义了一个静态成员,那么所有整个继承体系中,不管有多少子类,都只有这一个静态成员。

公有继承中的赋值兼容规则

针对于public继承而言

  1. 子类对象可以赋值给父类
  2. 父类对象不能赋值给子类
  3. 父类指针/引用可以指向子类对象
  4. 子类指针/引用不能指向父类对象(但是可以强转,强转后仍有问题存在)

这其中的道理不难理解,子类继承了父类,父类成员有的子类都有,但是子类成员父类就不一定有了,所以子类给父类肯定是父类所有变量都能给,但是父类给子类,子类自己的变量父类没办法给,给不了,赋值就是这么个道理,这个道理其实就是切片的概念。如果对切片仍有疑惑,可以尝试联系下图理解。
切片
同样的对于父类指针可以指向子类成员,也很好理解了,毕竟父类指针指向子类只指向父类大小的空间,那部分成员子类都有,但是子类指向父类就会造成越界访问,出现崩溃。

继承中的重定义(隐藏)

当子类中有成员和父类同名即构成隐藏(函数只需要函数名相同即可,与参数无关)
子类和父类的作用域是互相独立的,如果要访问被隐藏的成员需要使用::域作用符。
实际使用中尽量不要重定义成员。
例如:

class Person
{
public:
	string name;
}

class Student : pblic Person
{
public:
	string name;
}

int main()
{
	Student student;
	student.name;
	student.Person::name;
	//可以通过“基类::基类成员”的形式来访问
	return 0;
}

单继承和多继承

继承关系

菱形继承

class Person
{
public:
	string _name;
};

class Student : public Person
{
public:
	int _number;
};

class Teacher : public Person
{
public:
	int _id;
};

class Assistant : public Student, public Teacher
{
private:
	void show()
	{
		cout << _name << endl;//指向不明确
	}
public:
	string _majorCourse;
};

菱形继承
菱形继承指的是两个子类继承了同一个父类,另一个子类又继承这两个子类,在示意图上可以看成是一个菱形,所以称之为菱形继承,菱形继承是C++中的一个坑,是一个语法缺陷,他存在数据冗余和继承二义性。

数据冗余指的是子类拥有多个相同的父类成员,数据重复,在上图的例子中,名字每个人只有一个,很明显两个_name就重复了,造成空间浪费。

数据二义性在下图中,可以看到写出_name在预编译阶段就根本编不过,指向不明确,可以指向Student_name还是指向Teacher_name,无从判断,二义性指的是这。
二义性说明

虚继承

为了解决菱形继承的数据二义性和冗余性,C++中设置了一个虚继承的概念,使用关键字virtual即可将继承变为虚继承,那么他是怎么解决的?

虚继承将原来的应该存成员的位置改成存储了一个地址,这个地址存储了一个四字节的变量,变量中存储的是偏移量,通过这个偏移量从原本成员位置处偏移,来找到这个成员,也就是说多了是间接查找,虚继承以性能为代价来解决问题,带来性能上的损耗。
虚基表

在实际情况下,一般不会用虚继承(至少老师没用过),我的理解是虚继承同样浪费了空间,尽管浪费的空间可能少了一点,浪费了指针的大小,而且寻找成员还需要找偏移量才能找到成员,很明显太浪费了,这是对资源的一种浪费,还不如直接不定义这种菱形继承,虚继承也就没必要用到了,我还能有性能上的提升。
偏移量

多态

什么是多态,首先来理解一个概念,虚函数,然后再来看什么是多态

虚函数

在类的成员函数前面添加关键字virtual,函数就定义为虚函数。

虚函数的重写,当子类成员函数和父类成员函数均为虚函数,且返回值、函数名和参数完全相同时,就构成虚函数的重写,举例,下面是一个虚函数的重写:

class Person
{
public:
	string _name;
	virtual void BuyTicket()
	{
		cout << "buy ticket full price" << endl;
	}
};

class Student : public Person
{
public:
	int _num;
	virtual void BuyTicket()
	{
		cout << "buy ticket half price" << endl;
	}
};

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

int main()
{
	Person p;
	Student s;
	Buy(p);
	Buy(s);
	system("pause");
	return 0;
}

多态简单理解

紧接着我们来代用上面的代码来看一下多态是什么,我们调用后发下调用Buy函数,结果参数本来是Person的对象,结果传入类对象不同,结果调用的是不同的成员函数,这就是多态,当我们将虚函数重写时,用同一个参数调用不同类对象,出现不同结果,这就是多态

当使用基类的指针或引用调用重写的虚函数时,使用父类对象调用调的就是父类的虚函数,子类对象调用的就是子类的虚函数。
多态

产生多态的条件

多态有两个必要条件,从上面代码也能看出:

  1. 子类和做类的虚函数必须重写
  2. 参数是父类的指针或引用

破坏任一条件不能形成多态。

多态的原理——虚基表

多态如何实现,将刚才的代码拿过来重新调试,通过调试窗口查看,为了让效果更明显我在父类Person类中加了一个Show成员虚函数,但不在子类重写他,但是什么都不做,然后调试观察,发现有如下图的结果
虚基表
在父类和子类中多了一个_vfptr,且可以看到这是一个函数指针数组,其中存了BuyTicket函数和Show函数,我们发现如果重写了虚函数,那么子类和父类所指向的地址是不一样的,但是如果没有重写,函数指针指向同一块地址。

也就是说如果我们把父类对象的指针或者引用当做参数传入,这个时候调用成员函数将会在这个_vfptr函数指针数组中寻找需要调用的成员函数,这样就实现了多态。

其实这个_vfptr就是虚基表,他存储了虚函数的函数指针,当需要调用成员函数为虚函数时,就会到虚基表来寻找函数。

虚基表中函数的顺序为父类中虚函数定义的顺序。

虚表存在哪?

虚表在编译时就开始创建,构造函数实际是初始化_vfptr指向的位置。
也就是说虚表不可能放在堆,栈,代码段和数据段都可以,都有可能。
一般虚表放在静态区(数据段中)。

虚基表和虚表的关系

他们之间没有半毛钱关系!!!!

多态的一些需要注意的地方

  1. 子类重写父类的虚函数实现多态,要求函数名、参数列表、返回值均相同(协变函数除外)
    协变函数:指的是类成员函数返回值不同,但返回值必须是子类和父类的引用或指针,是虚函数重写的特殊情况
  2. 父类中定义了虚函数,在子类中该函数保持虚函数的特性
  3. 只有类的成员函数才能定义成虚函数
  4. 静态成员函数不能定义为虚函数
    为什么?
    静态成员函数不需要定义对象就能调用,不需要初始化,而且没有(this)指针,但是虚表需要初始化,静态函数都没有初始化就可以用,自然是不能定义为虚函数的。
  5. 如果在类外定义虚函数,那么在类外的函数前不能加virtual关键字,只能在声明的时候加关键字。
  6. 构造函数不能为虚函数,operator=虽然可以定义成虚函数,但是最好不要定义成虚函数,因为在使用时容易引起混淆。
  7. 不要在构造函数和虚构函数里面调用虚函数,在构造和析构函数中,对象时不完整的,可能会发生未定义的情况。
    虚表在构造函数运行的时候还没有初始化,而且构造函数需要合并子类和父类,如果这个时候调用,变量很有可能会不完整。
  8. 最好把父类的析构函数声明为虚函数。
    为什么要把析构函数定义为虚函数?
    首先说一下析构函数比较特殊,子类析构和父类析构尽管不同名,但是在编译后实际会改成一个名为destructor的函数,构成同名函数
    举例说明:
Person* p = new Student;
delete p;//这个时候只会调用Person的析构函数
//但是如果我们重写,就会调用Student的析构

纯虚函数

纯虚函数定义是声明一个纯虚函数并在后面写=0,包含纯虚函数的类叫抽象类(也叫接口类),抽象类不能实例化出对象,纯虚函数在子类中被重新定义后(重写),子类才能实例化出对象。

也就是说如果我不写这个虚函数,那么子类将不能创建对象,纯虚函数相当强制子类重写虚函数的一个机制。

class Person
{
	virtual void Show() = 0;
protected:
	string _name;
};

class Student : public Person
{
public:
	int _number;
};

感谢能看完这篇文章,哈哈哈

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页