[C++进阶] 16. 继承

面向对象三大特性:封装、继承、多态。

一. 概念

1)代码可以复用的最重要的手段,它允许我们在保持原有类(基类/父类)特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类/子类。(基类对应派生类,父类对应子类,最好不要混用)

2)继承是类设计层面的复用。

3)引入

如果不使用继承,当我们要在校园的管理系统中描述老师和结构这两个实体时,要定义两个不同的类,他们虽然是不同的实体,但是在特征描述上(也就是类的设计上)有很多相似之处,比如年龄、性别、身份证号、查询年龄、查询获奖情况....,同时他们还有许多各自独有的特征,比如老师会有职称,办公室号...,学生会有学号,成绩,寝室号...

如果相似之处很多那么在两个类的设计中写两份代码是不是有点冗余,所以我们用到了继承,提取他们公共的部分作为一个基类,让想复用这个基类的派生类继承它,避免在多个类中重复定义的麻烦。

cpp 复制代码
#include <iostream>

using namespace std;

// 公共的成员都放到Person类中,Student和teacher都继承Person
// 父类/基类
class Person
{
public:
	// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
	void identity()
	{
		cout << "void identity()" << _name << endl;
	}
protected:
	string _name = "张三"; // 姓名
	string _address; // 地址
	string _tel; // 电话
	int _age = 18; // 年龄
};

// 子类/派生类: 重复的都继承下来了,只写一些自己独有的即可
class Student : public Person
{ 
public:
	// 学习
	void study()
	{
		// ...
	}
protected:
	int _stuid; // 学号
};

// 子类/派生类
class Teacher : public Person
{
public:
	// 授课
	void teaching()
	{
		//...
	}
protected:
	string title; // 职称
};


int main()
{
    // identity函数Student类和Teacher类里都没有,但是可以调,说明真的继承了
	Student s;
	s.identity();

	Teacher t;
	t.identity();
	return 0;
};

二. 继承定义格式

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

1)基类private成员在派生类中无论以什么方式继承都是不可见的。 这⾥的不可⻅是指基类的私有成员还是被继承到了派⽣类对象中,但是语法上限制派⽣类对象不管在类⾥⾯还是类外⾯都不能去访问它。

2)如果基类成员不想在类外直接被访问,但需要在派⽣类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。基类中我们一般不用private限定成员,而是protected,因为我们设计基类不就是想让派生类继承并使用他的成员么,而private成员在派生类中不可见,通常不符合需求。

可间接使用父类私有成员,在父类中定义公有函数,在这个公有函数中操作父类私有成员。

3)基类中除了private之外的成员,在派⽣类的访问⽅式 == min(成员在基类的访问限定符,继承⽅式) ,public > protected >private。

4)实际运用中一般都使用public继承。

5)使⽤关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显式的写出继承方式。

|-------------|-----------------|-----------------|---------------|
| 基类成员\继承方法 | public继承 | protected继承 | private继承 |
| public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
| protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
| private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |

四. 继承类模板

模板也是可以继承的。

cpp 复制代码
namespace laosi
{
	template<class T>
	class vector
	{
		// ....
	};

	// stack和vector的关系,既符合is-a,也符合has-a
	template<class T>
	class stack : public std::vector<T>
	{
	public:
		void push(const T& x)
		{
			// 基类是类模板时,需要指定⼀下类域,否则编译报错,找不到标识符
			// 因为stack<int>实例化时,虽然也实例化vector<int>了
			// 但是模版是按需实例化,push_back等成员函数未实例化,所以找不到
			vector<T>::push_back(x);
			//push_back(x);
		}
		void pop()
		{
			vector<T>::pop_back();
		}
		const T& top()
		{
			return vector<T>::back();
		}
		bool empty()
		{
			return vector<T>::empty();
		}
	};
}


int main()
{
	laosi::stack<int> st;
	st.push(1);
	st.push(2);
	st.push(3);
	while (!st.empty())
	{
		std::cout << st.top() << " ";
		st.pop();
	} 
	return 0;
}

五. 基类和派生类之间的转换/赋值兼容转换

1)public继承的派生类对象可以赋值给基类的指针或者引用。这种做法称为切片 或者切割 ,把派生类中基类的那一部分切割出来,基类指针或引用指向的是派生类中切出来的基类那部分。

2)基类对象不能反过来赋值给派生类对象。

3)不同于之前的隐式类型转换会产生临时对象,这种切片赋值是经过特殊处理的,可以直接赋值,不会产生临时对象。

cpp 复制代码
#include<iostream>
#include<string>
using namespace std;

class Person
{
protected :
	string _name; // 姓名
	string _sex; // 性别
	int _age; // 年龄
};

class Student : public Person
{
public :
	int _No; // 学号
};


int main()
{
	Student sobj;
	// 1.派⽣类对象可以赋值给基类的指针/引⽤
	// 特殊处理,直接转换,不会产生临时对象
	Person* pp = &sobj;
	Person& rp = sobj;

	// 普通的隐式类型转换会产生临时对象,不加const会报错
	int i = 1;
	const double& d = i;

	// ⽣类对象可以赋值给基类的对象是通过调⽤后⾯会讲解的基类的拷⻉构造完成的
	Person pobj = sobj;

	////2.基类对象不能赋值给派⽣类对象,这⾥会编译报错
	//sobj = pobj;

	return 0;
}

六. 继承中的作用域

1)隐藏规则

  1. 分为成员变量的隐藏和成员函数的隐藏。

  2. 继承体系中积累和派生类都有独立的作用域,也就是说可以定义同名变量,不会冲突。

  3. 如果派生类和基类有同名成员,派生类成员将屏蔽基类同名的成员,这种情况叫隐藏 (重定义)。

比如派生类和基类都有一个成员叫_name,派生类中访问_name成员时优先使用自己的。如果就想用基类的_name,需要指定域来进行显式访问,基类::基类成员

  1. 如果是成员函数的隐藏,只要函数名相同就可以构成隐藏。

  2. 所以在实际应用中建议不要在继承体系中定义同名成员。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

// Student中的_num会将Person中同名的_num隐藏
class Person
{
protected :
	string _name = "laosi"; // 姓名
	int _num = 210210531; // ⾝份证号
};

class Student : public Person
{
public:
	void Print()
	{
		cout << " 姓名:" << _name << endl;
		// 显式调用基类中的_num
		cout << " 身份证号:" << Person::_num << endl;
		cout << " 学号:" << _num << endl;
	}
protected:
	int _num = 23006; // 学号
};

int main()
{
	Student s1;
	s1.Print();
	return 0;
}

2)两道相关题目

  1. 下面代码中A和B两个类中的func构成什么关系?

A. 重载 B. 隐藏 C. 没关系

  1. 下面程序编译运行的结果是什么?

A.编译报错 B.运行报错 C.正常运行

cpp 复制代码
#include <iostream>
using namespace std;

class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};

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

int main()
{
	B b;
	b.fun(10);
	b.fun();
	return 0;
};
  1. 第一题选B,两个函数都不在一个作用域,根本谈不上重载。他们的类之间有继承的关系,上面我们说过,成员函数只要函数名相同就构成隐藏。

  2. 第二题选A,fun()被fun(int i)隐藏了,继承并不是将父类拷贝到子类,而是编译器的查找逻辑,先在子类查再到父类查。本题编译时在子类就查到fun(int i)了,一看参数不匹配直接就报错了。如果就想让这段代码通过就显式指明作用域。

七. 派生类的默认成员函数

六个默认成员函数我们前面类和对象介绍过,其中四个比较常见。默认的意思就是我们可以不写,编译器会自动帮我们生成一个。
那么派生类中这些默认成员函数是如何生成的,又是如何处理基类部分成员的?

简单说,派生类自己的成员分内置类型和自定义类型,在这几个函数中的表现和普通类一样,具体的可以参考类和对象部分文章;对于基类成员,看作一个整体,调用基类的相关默认成员函数来处理。

1)构造函数

  1. 派生类的构造函数必须调用基类的构造函数来初始化基类的那一部分成员。如果基类没有默认构造函数,则必须在派生类的构造函数的初始化列表阶段显式调用其他构造函数整体构造。

默认构造函数(不传实参就能调用的构造)

①我们不写,编译器自动生成的构造

②我们写的,无参的构造函数

③我们写的,全缺省的构造函数

  1. 即使将初始化列表的顺序改变也不影响先调用基类构造在构造派生类的顺序,因为我们前面就知道初始化列表按声明的顺序执行而不是在初始化列表中的顺序,基类成员在最前面。

  2. 编译器自动生成的构造函数通常不能满足我们的需求,基本都要自己写。

2)拷贝构造

  1. 通常不用我们自己写,编译器默认生成的就够用,除非有什么资源需要管理,需要我们手动实现深拷贝。

  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。

3)赋值运算符重载operator=

  1. 通常不用自己写,我们拷贝构造和这部分的实现都是假设有需要深拷贝的资源时,其他部分怎么写。

4)析构函数

  1. 基类和派生类的析构函数构成隐藏
  1. 我们明明只调了一次基类的析构,为什么实际上调了两次?

因为有规定:

① 派生类对象初始化:先调用基类构造再调派生类构造。

② 派生类对象析构:先调用派生类析构再调基类的析构(避免派生类还要用基类的成员,结果基类已经析构了)。

如果我们自己在派生类的析构函数中显式调用基类的析构一定会违背②,所以C++的做法是:派生类的析构函数在被调用完成后,会自动调用 基类的析构函数清理基类成员。因此我们的做法就是不管基类,不自己显式调。

cpp 复制代码
#include <iostream>
using namespace std;

class Person
{
public:
	Person(const char* name = "peter")
		: _name(name)
	{
		cout << "Person()" << endl;
	} 
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	} 
	Person& operator=(const Person& p)
	{
		cout << "Person operator=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;
		return *this;
	} 
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name; // 姓名
};

class Student : public Person
{
public:
	Student(const char* name, int num)
		: _num(num)
		, Person(name)
	{
		cout << "Student()" << endl;
	}
	Student(const Student& s)
		: Person(s)
		, _num(s._num)
	{
		cout << "Student(const Student& s)" << endl;
	} 
	Student& operator = (const Student& s)
	{
		cout << "Student& operator=(const Student& s)" << endl;
		if (this != &s)
			{
			// 构成隐藏,所以需要显⽰调⽤
			Person::operator=(s);
			_num = s._num;
			} 
		return* this;
	} 
	~Student()
	{
		//Person::~Person(); // 不要显式调
		cout << "~Student()" << endl;
	}
protected:
	int _num; //学号
};

int main()
{
	Student s1("jack", 18);
	Student s2(s1);
	Student s3("rose", 17);
	s1 = s3;
	return 0;
}

八. 如何实现一个不能被继承的类

1)C++98的方法

**将基类的构造函数私有。**派生类的构造必须调用基类的构造函数,但是基类的构造被设为私有,派生类不可见也不能调用,那么就无法实例化出对象。

cpp 复制代码
#include <iostream>
using namespace std;

class Base
{
public:
	void func5() { cout << "Base::func5" << endl; }
protected:
	int a = 1;
private:
	//C++98的⽅法
	Base()
	{}
};

class Derive : public Base
{
	void func4() { cout << "Derive::func4" << endl; }
protected:
	int b = 2;
};

int main()
{
	// 不定义对象就不报错,定义了就要调默认构造,调不了就报错
	//Derive d;

	return 0;
}

2)C++11新增的方法

用final关键字修饰基类。 表示这个类不可以再被继承,这种方法更常用,方法一只有在定义对象时才会报错,这种方法不定义对象也会报错,语法错误直接检查了。

九. 友元关系不能继承

也就是说,基类的友元并不是派生类的友元,这个友元关系不会被继承。基类的友元函数可以访问基类的私有和保护成员,但是不能访问派生类的。

cpp 复制代码
#include <iostream>
using namespace std;

class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name = "lsy"; // 姓名
};

class Student : public Person
{
// 解决方法:在派生类中也加友元声明
//public:
//	friend void Display(const Person& p, const Student& s);

protected:
	int _stuNum = 11; // 学号
};

void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	//cout << s._stuNum << endl; // 报错!
} 

int main()
{
	Person p;
	Student s;
	// 编译报错:error C2248: "Student::_stuNum": ⽆法访问 protected 成员
	// 解决⽅案:Display也变成Student 的友元即可
	Display(p, s);
	return 0;
}

十. 静态成员会被继承

静态成员会被继承下来,但是不像普通被继承的成员是基类成员的一个副本,而是基类定义了static成员,则整个继承体系中只有一个这个成员。无论派生出多少个派生类,都只有一个static成员实例。

cpp 复制代码
#include <iostream>
using namespace std;

class Person
{
public :
	string _name;
	static int _count;
};

int Person::_count = 0;

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

int main()
{
	Person p;
	Student s;
	// 这⾥的运⾏结果可以看到非静态成员_name的地址是不⼀样的
	// 说明派⽣类继承下来了,⽗派⽣类对象各有⼀份
	cout << &p._name << endl;
	cout << &s._name << endl;

	// 这⾥的运⾏结果可以看到静态成员_count的地址是⼀样的
	// 说明派⽣类和基类共⽤同⼀份静态成员
	cout << &p._count << endl;
	cout << &s._count << endl;

	// 公有的情况下,基类、派⽣类指定类域都可以访问静态成员
	cout << Person::_count << endl;
	cout << Student::_count << endl;

	return 0;
}

十一. 多继承 -> 菱形继承问题

1)单继承:一个派生类只有一个直接基类时,称这个继承关系为单继承。

2)多继承:一个派生类有两个或以上直接基类时,称这个关系为多继承。

多继承在内存中的存放顺序是:先继承的基类在前面,后继承的基类在后面,派生类成员在最后。

3)菱形继承

  1. 菱形继承是多继承可能会导致的一种特殊情况。菱形继承会导致数据冗余和二义性,想解决这两种问题也很麻烦,所以实践中非常不建议设计出菱形继承模型。一些语言比如Java就直接不支持多继承,直接杜绝了这个问题的发生。

2. 解决数据冗余和二义性的问题

① 二义性:显式指明访问哪个基类的成员。
cpp 复制代码
#include <iostream>
using namespace std;

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; // 主修课程
};

int main()
{
	// 编译报错:error C2385: 对"_name"的访问不明确
	Assistant a;
	//a._name = "peter";

	// 需要显⽰指定访问哪个基类的成员可以解决⼆义性问题,但是数据冗余问题⽆法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
	return 0;
}
② 数据冗余:虚继承

上面的例子中,如果Person中成员很多,都有两份,那么会有很大的浪费。解决的方法就是虚继承,虚拟继承底层实现很复杂,性能也会有一些损失。所以我们可以设计出多继承,但是不要设计出菱形继承。

语法上就是在菱形腰部加上关键词virtual。实际虚继承可以同时解决数据冗余和二义性的问题。



cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

class Person
{
public:
	string _name; // 姓名
	/*int _tel;
	int _age;
	string _gender;
	string _address;*/
	// ...
};

// 使⽤虚继承Person类
//class Student : public Person
class Student : virtual public Person
{
protected:
	int _num; //学号
};

// 使⽤虚继承Person类
//class Teacher : public Person
class Teacher : virtual public Person
{
protected:
	int _id; // 职⼯编号
};

// 教授助理
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};


int main()
{
	// 使⽤虚继承,可以解决数据冗余和二义性
	Assistant a;
	cout << &a.Student::_name << endl;
	cout << &a.Teacher::_name << endl;

	a._name = "peter";

	return 0;
}

4)菱形虚继承构造问题举例

cpp 复制代码
#include <iostream>
using namespace std;

class Person
{
public:
	Person(const char* name)
		:_name(name)
	{}
	string _name; // 姓名
};

class Student : virtual public Person
{
public:
	Student(const char* name, int num)
		:Person(name)
		,_num(num)
	{}
protected:
	int _num; //学号
};

class Teacher : virtual public Person
{
public :
	Teacher(const char* name, int id)
		:Person(name)
		,_id(id)
	{}
protected:
	int _id; // 职⼯编号
};

// 不要去玩菱形继承
class Assistant : public Student, public Teacher
{
public:
	Assistant(const char* name1, const char* name2, const char* name3)
		:Person(name3)
		,Student(name1, 1)
		,Teacher(name2, 2)
	{}
protected:
	string _majorCourse; // 主修课程
};

int main()
{
	// 这⾥a对象中_name是"张三", "李四", "王五"中的哪⼀个?王五。
	Assistant a("张三", "李四", "王五");
	cout << a._name << endl;

	return 0;
}

5)多继承中指针偏移问题

下面这段代码中p1, p2, p3之间是什么关系?

A. p1==p2==p3 B. p1<p2<p3 C. p1==p2!=p3 D. p1!=p2!=p3

cpp 复制代码
#include <iostream>
using namespace std;

class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };

int main()
{
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	return 0;
}


Base1部分只有一个int类型的对象,占四个字节,从监控窗口的地址也能看出,p2比p1和p3多四个字节。

十二. IO库中的菱形虚拟继承

虽然菱形继承以及它引出的虚继承等基本没人用,但是IO流的库是标准的菱形继承成实现的。


源码中istream和ostream是虚继承的。

cpp 复制代码
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits>
{};

template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits>
{};

十三. 继承与组合

1)public继承 是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。

2)组合 是一种has-a的关系。就是我组合了你,那么每个我里面都有一个你。

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

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

5)优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承(is-a)也适合组合(has-a),就用组合 (比如第四点继承类模板中,用vector实现stack,我们说过,既可以用继承,也可以用组合)

cpp 复制代码
#include <iostream>
using namespace std;

// Tire(轮胎)和Car(⻋)更符合has-a的关系
class Tire 
{
protected:
	string _brand = "Michelin"; // 品牌
	size_t _size = 17; // 尺⼨
};

class Car 
{
protected:
	string _colour = "白色"; // 颜⾊
	string _num = "辽B55531"; // ⻋牌号
	Tire _t1; // 轮胎
	Tire _t2; // 轮胎
	Tire _t3; // 轮胎
	Tire _t4; // 轮胎
};

class BMW : public Car 
{
public:
	void Drive() { cout << "好开-操控" << endl; }
};

// Car和BMW/Benz更符合is-a的关系
class Benz : public Car 
{
public:
	void Drive() { cout << "好坐-舒适" << endl; }
};


template<class T>
class vector
{};

// stack和vector的关系,既符合is-a,也符合has-a
//// 继承
//template<class T>
//class stack : public vector<T>
//{};

// 组合
template<class T>
class stack
{
public:
	vector<T> _v;
};

int main()
{
	return 0;
}
相关推荐
实心儿儿2 小时前
C++ —— 继承
开发语言·c++
AMoon丶2 小时前
C++基础-类、对象
java·linux·服务器·c语言·开发语言·jvm·c++
17(无规则自律)2 小时前
Leetcode第二题:用 C++ 解决字母异位词分组
c++·leetcode·哈希算法
样例过了就是过了2 小时前
LeetCode热题100 子集
数据结构·c++·算法·leetcode·dfs
free-elcmacom2 小时前
C++三种参数传递方式:从交换函数看值、指针与引用的区别
开发语言·c++
柏木乃一2 小时前
Linux线程(8)基于单例模式的线程池
linux·运维·服务器·c++·单例模式·操作系统·线程
Trouvaille ~3 小时前
【贪心算法】专题(三):排序、博弈与区间的贪婪法则
c++·算法·leetcode·青少年编程·面试·贪心算法·蓝桥杯
IT19953 小时前
C++工作笔记-动态库中的单例类存储方式
开发语言·c++·笔记
一起搞IT吧3 小时前
Android功耗系列专题理论之十五:相机camera功耗问题分析方法
android·c++·数码相机·智能手机·性能优化