C++——继承

继承

本章思维导图:
注:本章思维导图对应的.xmind.png文件都已同步导入至资源

文章目录

  • 继承
    • @[toc]
  • 1. 继承的概念
  • 2. 继承的定义
    • 2.1 private继承
    • 2.2 访问限定符protected和private
    • 2.3 默认继承方式
  • 3. 基类和派生类的赋值兼容转换
  • 4. 隐藏关系
    • 4.1 隐藏关系和重载
  • 5. 派生类的默认成员函数
    • 5.1 默认成员函数的调用
      • 5.1.1 构造函数与析构函数
      • 5.1.2 拷贝构造
      • 5.1.3 赋值运算符重载
      • 5.1.4 总结
    • 5.2 默认成员函数的实现
      • 5.2.1 构造函数
      • 5.2.2 拷贝构造
      • 5.3 赋值运算符重载
      • 5.3.4 析构函数
  • 6. 继承与友元关系/静态成员
    • 6.1 继承与友元
    • 6.2 继承与静态成员
  • 7. 单继承与多继承
    • 7.1 单继承
    • 7.2 多继承
      • 7.2.3 菱形继承
      • 7.2.4 虚继承
      • 7.2.5 虚基表
    • 7.3 总结
  • 8. 继承与组合

1. 继承的概念

C++——继承_第1张图片

在C++面向对象编写代码的过程中,我们难免会遇到类似的情况:

  • 我们需要编写一个student类和一个teacher
  • 这两个类有部分成员是相同的,例如名字_name,电话_phone,但是又会有一些成员是不同的,例如student有学号st_idteacher有职工编号te_id
  • 如果我们像以前一样直接对这两个类进行编写,就可能会导致有大量代码是重复的,使代码冗余,降低可读性
class Student
{
	std::string _name;
	int age;

	long long st_id;
};

class Teacher
{
	std::string _name;
	int age;

	long long te_id;
};

为了解决这个问题,C++提供了一种设计模式——继承

  • 例如对于上述问题,我们可以将student类和teacher类共有的_name_phone等成员抽象成另一个具体的类person,再让student类和teacher类对其进行继承,这样就可以解决代码冗余问题了。
  • 我们称Person这些被继承的类为基类或者父类;称继承了其他类的类为派生类或子类

C++——继承_第2张图片

class Person
{
protected:
	std::string _name;
	int age;
};

class Student : public Person
{
	long long st_id;
};

class Teacher : public Person
{
	long long te_id;
};

现在,我们对继承有了较为初步的了解,可以进行总结:

  • 继承使面向对象程序进行代码复用的重要手段
  • 继承可以在不改变原有类的基础上,对原有类进行扩展,生成新的类
  • 不同于以往对函数的复用,继承是对类的复用

2. 继承的定义

C++——继承_第3张图片

定义继承的基本格式为:派生类 : 继承方式 基类

C++——继承_第4张图片

  • 同访问限定符一样,继承方式也分为三钟:公有继承public、保护继承protected、私有继承private
  • 在日常使用中,一般只会使用public公有继承
  • 由于基类成员访问限定符和继承方式二者的相互影响,派生类在继承了基类成员后,继承成员的权限会发生这样的变化:
类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public乘员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

通过对上述表格的分析,我们应该注意以下几点:

2.1 private继承

虽然基类的private成员继承之后在派生类并不可见,但他同样会被派生类继承,不要以为它不会被继承。我们可以进行调试分析:

#include 
#include 

class Person
{
private:
	std::string _name;
	int age;
};

class Student : public Person
{
	long long st_id;
};

int main()
{
	Student st;
	return 0;
}

C++——继承_第5张图片

可以发现,派生类确实继承了被private修饰的基类成员

2.2 访问限定符protected和private

在学习继承制前,我们通常认为访问限定符protectedprivate的作用是一样的,但是通过上面的学习,我们发现了二者的区别:

private修饰的成员在被继承之后就会不可见,无法使用;protected修饰的成员在被继承之后在派生类同样可见

#include 
#include 

class Person
{
private:
	std::string _name;
	int age;
};

class Student : public Person
{
public:
	void func()
	{
		std::cout << age << std::endl;
	}
protected:
	long long st_id;
};

//编译时报错:“Person::age”: 无法访问 private 成员(在“Person”类中声明)·

通过这样的分析,我们应该在类和对象层面更加清楚**private表示私有,二protected表示保护的含义**

2.3 默认继承方式

如果不明确指明继承方式,则:

  • class定义的类的默认继承方式为private
  • struct定于的类默认的继承方式为public

可以进行验证:

#include 
class A
{
public:
	int _a = 1;
};
class B
{
public:
	int _b = 1;
};

struct C1 : A {};	//等价于 struct C1 : public A {};
class C2 : B {};	//等价于 class C2 : private B {};

int main()
{
	C1 c1;
	C2 c2;

	std::cout << c1._a << std::endl;
	std::cout << c2._b << std::endl;
    
	return 0;
}
//编译时报错:“B::_b”不可访问,因为“C2”使用“private”从“B”继承

虽然classstruct定义的类尤其默认的继承方式,但是在实际使用过程中建议明确指明继承方式

3. 基类和派生类的赋值兼容转换

C++——继承_第6张图片

以前我们说,如果两个不同类型的变量进行赋值,那么在赋值的过程中就会产生临时变量:

int a = 1;
double b = 2.0;

b = a;	//在这个过程中会产生临时变量,b得到的就是临时变量的值

而由于临时变量具有常性,因此这种写法就是错误的:

int a = 1;
double& b = a;
//临时变量具有常性,而b为非const引用,发生了权限放大,故错误
//正确的写法为:const double& b = a;

但是到了继承,上面的说法就不那么正确了,我们来看下面的代码:

class Person
{
protected:
	std::string _name;
	int _age;
	int _sex; 
};

class Student : public Person
{
protected:
	long long st_id;
};

int main()
{
	Student student;
	Person& person = student;	// ???

	return 0;
}

大家认为这段代码是否可以编译通过,也就是说Person& person = student;这句代码是否正确?

答案是确实可以通过编译,这是有小伙伴就会疑惑了:PersonStudent两个是不同类型,这个过程难道不会产生临时变量吗?

这就涉及到了我们要讨论的主题:基类和派生类的赋值兼容转换

基类和派生类的赋值兼容转换通常只在public继承的情况下讨论

赋值兼容转换又叫做切割或者切片

基类和派生类发生赋值兼容转换有以下三种情形:

派生类对象赋值给基类对象

Student student;
Person person = student;
  • 在这个过程中,虽然PersosnStudent是两个不同的类型,但是并不会产生临时变量。此时基类对象person就是派生类对象stduent内基类成员的拷贝

    C++——继承_第7张图片

派生类对象赋值给基类对象的引用

Student student;
Person person& = student;
  • 此时,person就是派生类对象student基类成员的别名

    C++——继承_第8张图片

派生类对象的地址赋值给基类对象的指针

Student student;
Person* person = &student;
  • 此时,person指向的就是派生类对象student中的基类成员

    C++——继承_第9张图片

4. 隐藏关系

C++——继承_第10张图片

class Person
{
protected:
	std::string _name;
	int age;
	int sex; 
	int _a = 1;
};

class Student : public Person
{
public:
	void func()
	{
		std::cout << _a << std::endl;
	}
protected:
	long long st_id;
	int _a = 2;
};

int main()
{
	Student student;
	student.func();

	return 0;
}

上述的代码中,派生类Student继承了基类Person,那么问题来了:

Student类和Person类可以定义同名成员吗?

当然可以!因为PersonStudent两个不同的类域,作用域不同,当然可以定义同名成员了

那么std::cout << _a << std::endl;这句访问的又是哪个_a呢?

答案是派生类的_a。这是因为:子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问

  • 这种屏蔽关系就叫做隐藏关系,也成为重定义
  • 如果基类的某个成员与派生类的某个成员构成隐藏关系,就无法直接对其进行访问,而是需要通过域作用限定符::。例如:
    std::cout << Person::_a << std::endl;

4.1 隐藏关系和重载

class Person
{
public:
	void func()
	{
		std::cout << _a << std::endl;
	}
protected:
	std::string _name;
	int age;
	int sex; 
	int _a = 1;
};

class Student : public Person
{
public:
	void func(int b)
	{
		std::cout << _a << std::endl;
	}
protected:
	long long st_id;
	int _a = 2;
};

//问:这两个func()函数是构成重载关系还是隐藏关系??

答案是隐藏关系,因为这两个func()函数满足函数名相同的条件

有小伙伴就疑惑了:为什么不是重载关系?

我们需要回顾以下函数重载的定义:

同一作用域下,如果两个函数名称相同,但是参数不同,那这两个函数就构成重载

PersonStudent是两个不同的类域,怎么会构成重载关系?

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

C++——继承_第11张图片

这里我们只以下面的两个类为基础,讨论四类默认成员函数:构造函数、拷贝构造、赋值运算符重载、析构函数

class Person
{
public:
	Person(const std::string name = std::string(), const int age = 18, const int sex = 1)
		: _name(name)
		, _age(age)
		, _sex(sex)
	{
		std::cout << "Person()" << std::endl;
	}

	Person(const Person& p)
		: _name(p._name)
		, _age(p._age)
		, _sex(p._sex)
	{
		std::cout << "Person(const Person& p)" << std::endl;
	}

	Person& operator= (const Person& p)
	{
		std::cout << "operator= (const Person& p)" << std::endl;
		if (this != &p)
		{
			_name = p._name;
		}

		return *this;
	}

	~Person()
	{
		std::cout << "~Person()" << std::endl;
	}
protected:
	std::string _name;
	int _age = 18;
	int _sex = 1;
};

class Student : public Person
{
public:
protected:
	long long _st_id;
};

5.1 默认成员函数的调用

5.1.1 构造函数与析构函数

int main()
{
	Student st;
	return 0;
}

output:

Person()
~Person()

说明了,派生类的默认构造函数和默认析构函数都会自动调用基类的构造和析构

5.1.2 拷贝构造

int main()
{
	Student st1;
	Student st2(st1);

	return 0;
}

output:

Person()
Person(const Person& p)
~Person()
~Person()

说明了,派生类的默认拷贝构造会自动调用基类的拷贝构造

5.1.3 赋值运算符重载

int main()
{
	Student st1, st2;
	st1 = st2;

	return 0;
}

output:

Person()
Person()
operator= (const Person& p)
~Person()
~Person()

说明了,派生类的默认赋值运算符重载会自动调用基类的赋值运算符重载

5.1.4 总结

实际上,我们可以将派生类的成员分成三部分:基类成员、内置类型成员、自定义类型成员

  • 继承相较于我们以前学的类和对象,可以说就是多了基类那一部分

  • 当调用派生类的默认成员函数时,对于基类成员都会调用对应基类的默认成员函数来处理

5.2 默认成员函数的实现

5.2.1 构造函数

当实现派生类的构造函数时,就算不显示调用基类的构造,系统也会自动调用基类的构造

class Student : public Person
{
public:
	Student(long long st_id = 111)
		: _st_id(st_id)
	{
		std::cout << "Student()" << std::endl;
	}
protected:
	long long _st_id;
};
int main()
{
	Student st;
	return 0;
}

output:

Person()
Student()
~Person()

如果需要显示的调用基类的构造函数,应该这样写:

Student(long long st_id = 111)
	: _st_id(st_id)
	, Person("lwj", 18)
{
	std::cout << "Student()" << std::endl;
}

特别注意:

Person("lwj", 18)如果放在初始化列表,那就使显式调用基类的构造函数;如果放在函数体内,那就使创建一个基类的匿名对象

5.2.2 拷贝构造

当实现派生类的拷贝构造时,如果没有显式调用基类的拷贝构造,那么系统就会自动调用基类的构造

class Student : public Person
{
public:
	Student(long long st_id = 111)
		: _st_id(st_id)
		, Person("lwj", 18)
	{
		std::cout << "Student()" << std::endl;
	}

	Student(const Student& s)
		: _st_id(s._st_id)
	{
		std::cout << "Student(const Student& s)" << std::endl;
	}
protected:
	long long _st_id;
};
int main()
{
	Student st1;
	Student st2(st1);

	return 0;
}

output:

Person()
Student()
Person()	//系统自动调用了基类的构造
Student(const Student& s)
~Person()
~Person()

也可以像显示调用构造函数一样显式调用基类的拷贝构造:

Student(const Student& s)
	: Person(s)
	, _st_id(s._st_id)
{
    std::cout << "Student(const Student& s)" << std::endl;
}

5.3 赋值运算符重载

在实现派生类的赋值运算符重载时,如果没有显式调用基类的赋值运算符重载,系统也不会自动调用基类的赋值运算符重载

class Student : public Person
{
public:
	Student(long long st_id = 111)
		: _st_id(st_id)
		, Person("lwj", 18)
	{
		std::cout << "Student()" << std::endl;
	}

	Student& operator= (const Student& s)
	{
		std::cout << "operator= (const Student& s)" << std::endl;
		if (this != &s)
		{
			_st_id = s._st_id;
		}

		return *this;
	}
protected:
	long long _st_id;
};
int main()
{
	Student st1, st2;
	st1 = st2;

	return 0;
}

output:

Person()
Student()
Person()
Student()
operator= (const Student& s)
~Person()
~Person()

因此,在实现派生类的赋值运算符重载时,必须显示调用基类的赋值运算符重载:

Student& operator= (const Student& s)
{
	std::cout << "operator= (const Student& s)" << std::endl;
	if (this != &s)
	{
		Person::operator=(s);	//由于基类和派生类的赋值运算符重载构成隐藏,因此要用 :: 指定类域
		_st_id = s._st_id;
	}
	return *this;
}

5.3.4 析构函数

在实现派生类的析构函数时,不要显式调用基类的析构函数,系统会在派生类的析构完成后自动调用基类的析构

注意:

  • 和前面的默认成员函数不同,在实现派生类的析构时,基类的析构不能显式调用
  • 这是因为,如果显示调用了基类的析构,就会导致基类成员的资源先被清理,如果此时派生类成员还访问了基类成员指向的资源就,就会导致野指针问题
  • 因此,必须保证析构顺序为先子后父,保证数据访问的安全
class Student : public Person
{
public:
	Student(long long st_id = 111)
		: _st_id(st_id)
		, Person("lwj", 18)
	{
		std::cout << "Student()" << std::endl;
	}
	~Student()
	{
		std::cout << "~Student()" << std::endl;
	}
protected:
	long long _st_id;
};

int main()
{
	Student st1;

	return 0;
}

output:

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

6. 继承与友元关系/静态成员

C++——继承_第12张图片

6.1 继承与友元

友元关系不能被继承,即基类的友元函数不能直接访问派生类的private/protected成员

class Student;
class Person
{
public:
	friend void func(Person& p, Student& s);
protected:
	std::string _name;
	int _age = 18;
	int _sex = 1;
}

class Student : public Person
{
protected:
	long long _st_id = 1;
};

void func(Person& p, Student& s)
{
	std::cout << p._name << std::endl;
	std::cout << s._st_id << std::endl;
}

//编译报错:“Student::_st_id”: 无法访问 protected 成员(在“Student”类中声明)

解决方法就是将func()函数也变为派生类的友元

6.2 继承与静态成员

在学习静态成员的时候,我们就说:

  • 静态成员变量实际上就是一个受类域和访问限定符限制的全局变量
  • 静态成员变量并不属于某一个单独的类对象,而是整个类所有
  • 静态成员变量并不存储在类对象中,而是存储在静态区

因此,如果基类有一个静态成员变量,那么无论他派生了多少个类,这个静态成员变量也只会有一个

class Person
{
public:
	Person() { ++_count; }
protected:
	std::string _name; // 姓名
public:
	static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
	int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
	std::string _seminarCourse; // 研究科目
};
void TestPerson()
{
	Student s1;
	Student s2;
	Student s3;
	Graduate s4;
	std::cout << " 人数 :" << Person::_count << std::endl;
	Student::_count = 0;
	std::cout << " 人数 :" << Person::_count << std::endl;
}

int main()
{
	TestPerson();
	return 0;
}

output:

4
0

7. 单继承与多继承

C++——继承_第13张图片

7.1 单继承

单继承:指一个派生类只有一个直属的基类

C++——继承_第14张图片

我们此前将的所有继承都是单继承,这里不再过多赘述

7.2 多继承

多继承:指一个派生类有多个直属基类

C++——继承_第15张图片

看起来C++支持多继承是一种理所应当的行为,但其实,多继承为C++带来了非常严重的后果。那就是多继承可能会导致菱形继承

7.2.3 菱形继承

C++——继承_第16张图片

C++——继承_第17张图片

从上面的类模型图中我们可以发现菱形继承的问题:Assistant继承了两份Person。这样就会导致数据的冗余和二义性

我们可以用下面的示例代码进行分析:

class A
{
public:
	int _a;
};

class B : public A
{
public:
	int _b;
};

class C : public A
{
public:
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};

int main()
{
	D dd;
    
	dd.B::_a = 1;	//由于D类继承了两份A类,因此访问A类成员时,就需要指明类域
	dd.C::_a = 2;	//由于D类继承了两份A类,因此访问A类成员时,就需要指明类域
	dd._b = 3;
	dd._c = 4;
	dd._d = 5;
	
	return 0;
}

我们打开内存窗口进行调试:

C++——继承_第18张图片

我们发现,类A确实被继承了两份,这样的结果肯定不是我们想要的。为了避免数据的冗余和二义性,我们应该做到类D继承类B和类C时只会继承一份A,为了达成这一目的,就需要用到虚继承

7.2.4 虚继承

针对上面的代码,我们可以在发生菱形继承处添加virtual关键字,让继承变为虚继承,从而达到A类只被继承一份的目的:

class A
{
public:
	int _a;
};

class B : virtual public A	//添加virtual关键字,使继承变为虚继承
{
public:
	int _b;
};

class C : virtual public A	//添加virtual关键字,使继承变为虚继承
{
public:
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};

int main()
{
	D dd;
	dd.B::_a = 1;
	dd.C::_a = 2;
	dd._b = 3;
	dd._c = 4;
	dd._d = 5;
	
	return 0;
}

同样打开内存窗口进行调试:

C++——继承_第19张图片

可以看到虚继承后,类B和类C继承的A被放在了最下方的公共区域,这样类D就只包含了一份类A,也就避免了数据冗余和二义性

这时有细心的小伙伴又发现了一个问题:

B48 7b 5f 00和类C54 7b 5f 00又是什么呢?

7.2.5 虚基表

  • 首先我们应该会读这两串数据:VS是小端机,即低位数据存在地位地址,高位数据存在高位地址,因此这两串数据实际上应该是00 5f 7b 4800 5f 7b 54

  • 这看起来是两串地址,我们不妨进行搜索:

    C++——继承_第20张图片

  • 可以看到00 5f 7b 48指向的区域中,存储着一个数据00 00 00 1400 5f 7b 54存储着一个数据00 00 00 0c。那存储的这两个数据代表的又是什么?

  • 我们不妨再进行一次尝试:将48 7b 5f 00的地址0x003EFDF4加上00 00 00 14(对应十进制20),将54 7b 5f 00的地址0x003EFDFC加上00 00 00 0c(对应十进制12)。可以发现,得到的结果都是地址0x00eEFE08也就是公共区域类A的地址

    C++——继承_第21张图片

我们称地址48 7b 5f 00和地址54 7b 5f 00指向的数据00 00 00 1400 00 00 0c成为偏移量,又将偏移量存在的区域称为虚基表,正是由于虚基表的存在,发生虚继承的类才能找到他们公共成员

C++——继承_第22张图片

7.3 总结

从上面的分析我们可以看出,如果使用了多继承,就可能发生菱形继承。而菱形继承的地城结构是十分复杂的,非常难以控制,因此在日常编写代码的过程中,尽量不要使用多继承,以避免菱形继承的出现

8. 继承与组合

C++——继承_第23张图片

继承的大致结构是这样的:

class A
{
protected:
	int _a = 1;
};

class B : public A
{
protected: 
	int _b = 2;
};

组合的大致结构是这样的:

class A
{
protected:
	int _a = 1;
};

class B
{
protected:
	int _b = 2;
	A _a;
};

可以看到继承和组合都实现了对类的复用,那么这二者的区别是什么?我们应该在什么场景使用它们?

一般认为:

  • public继承是一种is-a关系,即派生类是一个特殊的父类。如果两个类的关系可以用is-a关联,那就可以用public继承。例如PersonStudent就是经典的public继承关系**
  • 组合是一种has-a关系,即假设B组合了A,那么就说B对象有一个A对象。例如汽车和轮胎就是经典的组合关系

在继承和组合都可以使用的情况下,为了达到低耦合,高内聚的要求,更推荐使用组合

  • 继承是一种“白箱复用”,也就是说被继承的基类除privete成员,其他成员在派生类中都是可见的,这在一定程度上破坏了基类的封装,也使基类和派生类产生了较大的耦合度,也就是说由于派生类可以使用基类的大部分成员,基类的改变很可能会影响到派生类,这降低了代码的可维护性
  • 组合是一种“黑箱复用”,也就是说假设B组合了A,B只能访问到A的public成员,这大大降低了类B和类A的耦合度,也就提高了代码的可维护性

有些情况下必须要使用继承,例如多态的实现,这会在下一章详细说明


本篇完
如果错误,欢迎大家指出

你可能感兴趣的:(C++学习之路,c++,java,开发语言)