c++继承与多态

前言

本次博客主要讲解继承与多态的基本概念,以及一些注意事项。

1 继承

1.1 继承的概念:

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保
持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。那么原来的类叫做基类

继承与函数重载类似,继承是对类的复用,函数重载是对函数重载复用。

1.2 继承初识

class animal {
public:
	void move()
	{
		printf("你干嘛?\n");
	}
private:
	int _height;
	int _weight;
	string _name;
};
class cat :public animal
{
public:
	void voice()
	{
		printf("喵~\n");
	}
private:
	string _character;
};
class dog :public animal
{
public :
	void voice()
	{
		printf("汪汪~\n");
	}
private:
	string _character;
};
int main()
{
	dog a;
	cat b;
	a.voice();
	b.voice();a.move();
    b.move();

	return 0;
}

既然是继承那么来看看,继承后的类中的成员

OK,此时得出结论,基类的成员全部被继承下来了,并且自己类中的成员也在。

接下来看看派生类能否调用基类的函数。

那么,继承初步可以认为是把基类的成员函数以及成员全部给派生类。

1.3 继承的定义

格式

 继承关系和访问限定符

认真观察他们几乎是一样的,那么有什么区别和意义?

 继承基类成员访问方式的变化

这么表,不难记。

1 基类的私有成员无论怎样继承都无法再派生类中可见。但是可以通过函数访问到

2 派生类继承的成员将要成为的属性取决基类的原本类型和继承的方式,而继承方式是一个“口子”,私有继承口子最小,public继承口子最大。口子就是允许的最大权限。比如public继承基类的任意成员,那么它的成员原来是什么属性,继承后就是什么属性,如果是private继承那么口子最小,无论基类是什么类型,继承后都是private类型。

3 protected成员,protected成员本质上是为了继承而出现的,比如说,我们想要被继承的成员变量,只能被派生类内访问,但是不能在类外访问,此时就出现了protected成员。

4 class默认是private继承,struct默认是public继承。

5一般而言,继承方式最好不要省略,养成好的代码习惯,并且一般建议使用public继承。

我们举例子,看看如何访问公有继承中,基类的私有成员。

class animal {
public:
	 string name()
	{
		 return _name;
	}
private:
	int _height;
	int _weight;
	string _name="每一天开心!\n";
};
class cat :public animal
{
};
class dog :public animal
{

};
int main()
{
	cat a;
	cout<<a.name();
	return 0;
}

1.4 基类和派生类对象赋值转换

1 派生类对象可以赋值给基类的指针,对象,引用,这叫做切片。

2但是基类对象不能赋值给派生类。

3基类的指针,引用经过类型转换可以赋值给派生类,但是基类只有指向派生类时才会安全。

记住就好,没什么好纠结的!

class Person
{
protected :
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public :
int _No ; // 学号
};
void Test ()
{
Student sobj ;
// 1.子类对象可以赋值给父类对象/指针/引用
Person pobj = sobj ;
Person* pp = &sobj;
Person& rp = sobj;
//2.基类对象不能赋值给派生类对象
sobj = pobj;
// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
pp = &sobj
Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
ps1->_No = 10;
pp = &pobj;
Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问
题
ps2->_No = 10;
}

1.5 继承中的作用域

1. 在继承体系中基类和派生类都有独立的作用域。


2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,
也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)


3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。


4. 注意在实际中在继承体系里面最好不要定义同名的成员。

class animal {
public:
	string _name = "每一天开心!\n";};
class cat :public animal
{
public:
	void print()
	{
		cout << "my name is " << _name << endl;
		cout << "i want to " << animal::_name << endl;
	}
	string _name = "生气猫\n";
};
int main()
{
	cat a;
	a.print();
	return 0;
}

看重载与隐藏的区别

class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
A::fun();
cout << "func(int i)->" <<i<<endl;
}
};

很明显,fun()不构成重载,作用域有所不同。构成隐藏,只要函数名字相同就构成隐藏。

1.6 派生类的默认成员函数

派生类的默认成员函数,与类的成员函数是一样的。

1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认
的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能
保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构。
7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲
解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加
virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

这里只需要理解语法含义即可!不要问为什么!!!

可以有技巧,基类相对于派生类而言是一个需要特殊处理的自定义类型,当没有指向资源空间时可以默认不写的。

看代码吧!

class person
{
public:
	person(const char *str)
		:_name(str)
	{
		cout << "person(const char *str)" << endl;
	}
	~person()
	{
		cout << "~person()" << endl;
	}
	person(const person& per)
		:_name(per._name)
	{
		cout << "person(const person& per)" << endl;
	}
	person& operator=(const person& per)
	{
		_name=per._name;
		cout << "person& operator=(const person& per)" << endl;
		return *this;
	}
private:
	string _name;
};
class student :public person {
public:
	student(const char *str ,int num)
		:_id(num),person(str)
	{
		cout << "student(const char *str ,int num)" << endl;
	}
	student(const student& stu)
		:person(stu),_id(stu._id)
	{
		cout << "student(const student& stu)" << endl;
	}
	~student()
	{
		cout << "~student()" << endl;
	}
	student& operator=(const student&stu)
	{
	  (*this).person::operator=(stu);
	  _id = stu._id;
	  cout << "student& operator=(const student&stu)" << endl;
	  return *this;
	}
private:
	int _id;
};
int main()
{
	student a("张三",10);
	student b(a);
	student c("李四", 20);
	c = a;
	return 0;
}

看看结果:

看起来很复杂呢!其实不然!

一步一步来看:

第一步:由于person类没有默认构造,而student要调用构造,那么就必须要调用person的构造,并且在构造函数,必须在初始化列表中显示调用。所以这一步,有两行内容。

第二步:要执行student的拷贝构造,又由于person没有默认的拷贝构造,所以必须要显示调用person()的拷贝构造。所以这一步也是有两行内容。

第三步:又是一个构造,与第一步一样。

第四步:是赋值,赋值同理,基类没有默认赋值,则必须显示调用person的赋值,这一步同样是两步。

最后的析构是先调用派生类的析构再调用基类的析构,并且派生类的析构会自动调用基类的析构。

1.6 继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子
类,都只有一个static成员实例。

class text {

public:
	static int a;
};
class son1 :public text
{
};
class son2 :public text
{
};
int text::a = 20;
int main()
{
	son1 a;son2 b;
	cout << &text::a << endl;
	cout << &(a.a) << endl;
	cout << &(b.a) << endl;
	return 0;
}

1.7  菱形继承及菱形虚拟继承

单继承

单继承:一个子类只有一个直接父类时称这个继承关系为单继承。

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

菱形继承:菱形继承是多继承的一种特殊情况。

菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。
在Assistant的对象中Person成员会有两份。

class Person
{
public:
	string _name; // 姓名
};
class Student : public Person
{
protected:
	int _num; //学号
};
class Teacher : public Person
{
protected:
	int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};

 虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和
Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地
方去使用。

class Person
{
public:
	string _name; // 姓名
};
class Student : virtual public Person
{
protected:
	int _num; //学号
};
class Teacher : virtual public Person
{
protected:
	int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};
void Test()
{
	Assistant a;
	a._name = "peter";
}
int main()
{
	Test();
	return 0;
}

 

那么它的底层原理是什么?

主要虚继承中,会添加虚机表,不同的基类相同的类型,通过虚机表以及偏移量可以找到对应基类的冗余的数据。这个多态会细说。

1.8 has-a和is-a组合与继承

1. 组合就是把某个类直接包含,使他成为类的一部分,使每一个对象都包含这个类,这就叫组合。也就是has-a。 public继承就是is-a,是继承类的特性。

2 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称
为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的
内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很
大的影响。派生类和基类间的依赖关系很强,耦合度高。

3 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象
来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复
用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被
封装。

4实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有
些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用
继承,可以用组合,就用组合。

2 多态

2.1 多态的概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会
产生出不同的状态。

比如说公交车上缴费,老人有老人卡,小孩可以免费,成人有成人票,针对不同的对象,会有不同的结果。

2.2 多态的定义及实现

多态构成的条件:

1 必须要有继承关系。

2 只能是基类的指针或者是引用去调用函数

3 必须构成重写关系,被调用的函数必须得是虚函数。

虚函数的概念:

被virtual修饰的成员函数就是虚函数。

虚函数的重写:

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的
返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。

class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
void Func(Person& p)
{ p.BuyTicket(); }
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因
为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

另外即父类与子类的虚函数有缺省值的不同,但只要类型一样也同样构成重写。

虚函数重写的两个例外:

1. 协变(基类与派生类虚函数返回值类型不同)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class A{};
class B : public A {};
class Person {
public:
virtual A* f() {return new A;}
};
class Student : public Person {
public:
virtual B* f() {return new B;}
};

2. 析构函数的重写(基类与派生类析构函数的名字不同)
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,
都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,
看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处
理,编译后析构函数的名称统一处理成destructor。

class Person {
public:
virtual ~Person() {cout << "~Person()" << endl;}
};
class Student : public Person {
public:
virtual ~Student() { cout << "~Student()" << endl; }
};
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}

只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函
数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。

2.3 C++11 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数
名字母次序写反而无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有
得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮
助用户检测是否重写。

1. final:修饰虚函数,表示该虚函数不能再被重写

class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() {cout << "Benz-舒适" << endl;}
};

2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

class Car{
public:
virtual void Drive(){}
};
class Benz :public Car {
public:
virtual void Drive() override {cout << "Benz-舒适" << endl;}
};

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

 用文字表述就是:

同一类域的,重名函数,参数相同构成重载,但是返回值不能作为重载的条件。

一个在派生类一个在基类的同名函数,就构成隐藏,只要同名即可。

一个在派生类一个在基类的同名虚函数,但是参数得相同,就构成了重写,重写是多态的一个条件。

2.4 抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象
。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

class Car
{
public:
virtual void Drive() = 0;
};
class Benz :public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW :public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
void Test()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}

辨析:接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实
现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成
多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

3.多态的原理

3.1虚函数表

class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};

通过调试可以发现类中除了成员变量还多了一个_vfptr

类型是void **,这个其实就是虚函数指针数组指针,这个指针指向了虚函数指针数组。

一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数
的地址要被放到虚函数表中。

仅仅是这样还无法真正的分析出内容那我再来加一些东西。

// 针对上面的代码我们做出以下改造
// 1.我们增加一个派生类Derive去继承Base
// 2.Derive中重写Func1
// 3.Base再增加一个虚函数Func2和一个普通函数Func3
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}

可以通过打开监视窗口观察:

注意看,d中的fun1是来自自己本身的函数,而func2是来自父类的函数。

我们可以得出一下几个结论:

1.派生类也是有虚函数表指针的,而且一部分来自基类,一部分来自自己。

2.来自自己的函数是完成了重写的虚函数,没有完成重写就继承基类

3.另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,所以不是虚函
数,不会放进虚表。

4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。

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

3.2 多态原理解析

class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
return 0;
}

1. 观察下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的虚表中找到虚
函数是Person::BuyTicket。
2. 观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中
找到虚函数是Student::BuyTicket。
3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态。
4. 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调
用虚函数。


再通过下面的汇编代码分析,看出满足多态以后的函数调用,不是在编译时确定的,是运行
起来以后到对象的中取找的。不满足多态的函数调用时编译时确认好的。

void Func(Person* p)
{
p->BuyTicket();
}
int main()
{
Person mike;
Func(&mike);
mike.BuyTicket();
return 0;
}

void Func(Person* p)
{
...
p->BuyTicket();
// p中存的是mike对象的指针,将p移动到eax中
001940DE mov eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
001940E1 mov edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE mov eax,dword ptr [edx]
// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来
以后到对象的中取找的。
001940EA call eax
00头1940EC cmp esi,esp
}

上面是多态时的汇编

下面是普通函数调用时的汇编:

3.3 动态绑定与静态绑定

1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,
比如:函数重载
2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体
行为,调用具体的函数,也称为动态多态。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值