【C++进阶:继承】C++为什么要引入继承 | 继承概念及定义 | 基类和派生类对象赋值转换 | 继承中的作用域 | 派生类的默认成员函数 | 继承与友元/静态成员 | 复杂的菱形继承及菱形虚拟继承

【写在前面】

在 C++ 初阶专栏 ➡ 类和对象一文中,我们提出了面向对象的三大特性 —— 封装、继承、多态。但在 C++ 初阶专栏中涉及到的只是封装,而这里我们直接以封装和继承作为 C++ 进阶专栏中的敲门砖。

我们说过 C++ 是大佬从 C 发展出来的,最开始的 C++ 叫做 C With Class,就是在 C 的基础上增加了类。经过 C++ 初阶的学习,我们知道了 C++ 中类,就是为了对标并解决 C 的缺陷,比如构造、析构等。

在 C With Class 时,在类的设计层面,C++ 还面临一个问题,假设:

在这里插入图片描述

  我们发现每个角色都有公共的信息,如果在每个类中都写一份,构造和析构时会对每个冗余的信息都处理一次,这是设计层面上的困境。

面对这种困境,不谈继承,如何解决 ❓

在这里插入图片描述

  我们把公共的信息提取出来封装成一个类,每个角色创建时就调用。这样就完成类层面的复用,这里 C 也是支持的。

这种复用有什么缺陷 ???

  没错,它确实可以解决代码冗余的困境,但是它肯定有缺陷,不然 C++ 就不会有继承了。这里的缺陷在于 Student 里复用 Person 时,并不好访问 Person 的成员,因为一般都会设置为私有。所以 C++ 中就衍生了继承。

一、继承的概念及定义

💦 继承的概念

继承 (inheritance) 机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,而继承是类设计层次的复用。

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
	void print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "DanceBit";
	int _age = 18;
};

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

class Teacher : public Person
{
protected:
	int _jobid;
};

int main()
{
	Student s;
	s.print();

	Teacher t;
	t.print();

	return 0;
}

在这里插入图片描述

  其中 Person 就叫做父类或基类;Student、Teacher 叫做子类或派生类。Student、Teacher 继承了 Person,Student、Teacher 中就拥有了 Person 的成员变量和成员函数,所以可以 s.print()、t.print(),但是对于一个类对象,它只存储成员变量,成员函数存储于公共代码区。所以继承的本质是类级别的复用。

  这里一继承就把父类的所有东西都继承了,这样也不好。好比皇帝不想干了,让年纪较小的太子继位,但并不是所有的事都交给太子来决策,而是重要的事要给皇帝请示。所以对于继承来说,我们需要能灵活控制,针对不同场景,我们可以部分继承、半继承、全继承、暂时不继承。

💦 继承定义
1、定义格式

在这里插入图片描述

  Student 是子类或派生类;Person 是父类或基类;public 是继承方式;C++ 把这块区分后,它的继承方式有 3 种。

2、继承关系和访问限定符

在这里插入图片描述

  Student 继承 Person,Person 里的成员变量在 Student 中到底是什么样的访问方式,它是由这里的继承方式和原来的访问限定符所决定的。

3、继承基类成员访问方式的变化
类成员/继承方式public继承ptotected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员在派生类中不可见在派生类中不可见在派生类中不可见
  • 实际从上面的表格总结会发现,基类的私有成员在子类都是不可见的。其余我们认为派生类中的访问方式是参照类成员原来的访问方式和继承方式,我们认为它们的权限关系是 public > protected > private,那么派生类最终的访问方式是取 Min(访问方式,继承方式)。
  • 在类和对象中我们说过,想被访问的成员设置为 public,不想被访问的成员设置为 private 或 protected。我们当时说 private 和 protected 的区别到了继承才能体现,体现如下。
#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
	void print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "DanceBit";
private:
	int _age = 18;
};

class Student : public Person
{
	void f()
	{
		_name = "dancebit";
		print();
		//_age = 20;
	}
protected:
	int _stuid;
};

class Teacher : protected Person
{
	void f()
	{
		_name = "dancebit";
		print();
		//_age = 20;
	}
protected:
	int _jobid;
};

class Other : private Person
{
	void f()
	{
		print();
		_name = "dancebit";
		//_age = 20;
	}
protected:
	int _jobid;
};

int main()
{
	Student s;
	s.print();
	//s._name;
	//s._age;

	Teacher t;
	//t.print();
	//t._name;
	//t._age;

	Other o;
	//o.print();
	//o._name();
	//o._age();
	
	return 0;
}

📝 说明:

  • Student 中 f 函数里能访问 _name 的原因是因为访问限定符限制的是类外的人。

  • 任何继承方式继承的私有成员都是不可见,它指的是基类的私有成员,虽然还是被继承到了派生类对象中,但语法限制了不管在类里类外都不能访问,也就是说对象的物理空间上存在,但是类里类外都不能使用。注意区分非继承的私有成员,它是类里可以使用,类外不能使用。

  • 在继承之前,我们以前说想被访问的设置为公有,不想被访问的设置为私有。在继承之后,对于 public 继承:想让子类或类外访问的把成员设置为 public;想让子类访问,但不想让类外访问的把成员设置为 protected;不想让子类访问,也不想让类外访问的把成员设置为 private;所以在继承中,一个类,尽量不要使用 private,因为 private 在子类中不可见,尽量用 protected。这里就可以看出保护成员限定符是因继承而出现的。

  • private 和 protected 成员对于父类是一样的,都是在类里可以访问,类外不可以访问;区别在于,public 继承后,对于子类,private 成员不可见,protected 成员可以在类里访问,类外不能访问。

  • 使用关键字 class 时默认的继承方式是 private;使用关键字 struct 时默认的继承方式是 public。不过最好显示的写出继承方式。其实这块设计时应该强制写出继承方式比较好,不写就报错,就如构造函数的内置类型不处理、访问限定符不一定需要写等。这些都是由于早期设计时,在这方面考虑的不是很周到,也没有经验借鉴,同时 C++ 是向前兼容的,所以就有了我们现在所看到的 C++ 很多细细小小的坑。我们也不能站在现在的时代去指责历史,就像你不能跟你爸说:爸,你当初要是好好学习,奋斗一番事业,我现在就不会在这码字了。

    在这里插入图片描述

  • 当时 C++ 设计时,考虑的很健全,但在健全的同时,也增加了学习的成本,且这种成本价值不大,因为实际中常用的只有基类的 public 成员和 public 继承方式、基类的 protected 成员和 public 继承方式,几乎很少使用 protected/private 继承和 private 访问限定符,当然也不提倡使用,因为实际中扩展维护性很差。所以后来的好多语言把这块内容简化了,这也就是 C++ 相对其它语言难学的原因,本质就是有些地方考虑的比较复杂,但是这也没办法,因为 C++ 是早期吃螃蟹的人。这里想说的是虽然 C++ 设计的比较复杂,但是我们要往简单去理解。所以对于 C++ 的一些小缺陷小细节的地方,我们应该谦虚的、包容的去学习,不是说它恶心就厌恶它。

二、基类和派生类对象赋值转换

#include<iostream>
using namespace std;

class Person
{
public:
	/*void f()
	{}*/
protected:
	string _name;
	string _sex;
	int _age;
};
//class Student : protected Person
class Student : public Person
{
public:
	int _No;
};

int main()
{
	Person p;
	Student s;

	p = s;//父类对象 = 子类对象
	Person* ptr = &s;//父类指针 = 子类指针
	Person& ref = s;//父类引用 = 子类

	//s = p;//子类对象 = 父类对象,err
			//子类指针 = 父类指针,ok,但是最好用dynamic_cast,因为这样才是安全的,这里的安全指的是它会去识别父类的指针,如果是指向父类,这个转换就失败,如果是指向子类,这个转换就成功。
			//引用同指针

	return 0;
}
  • 我们都知道同类型的对象赋值是可以的,那父子类呢 ❓

    首先我们得知道父子类为啥要支持赋值呢,究其原因是它们之间存在一些强关联的关系,子类几乎包含父类的成员。

  • 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。这是在 public 继承的前提,因为如果是 public 继承,那么继承下来的成员的访问限定符是不变的;如果是 protected 或 private 继承,那么继承下来的成员的访问限定符可能会改变,进而导致不支持子类对父类赋值,因为可能存在类型转换。

    在这里插入图片描述

  • 基类对象不能赋值给派生类对象。

  • 但是基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Information) 的 dynamic_cast 来进行识别后进行安全转换。(ps:这个后面会谈,这里先了解一下)。

  • 这就完了 ?当然不是,这里是需要和下面的派生类的默认成员函数一起理解。

三、继承中的作用域

#include<iostream>
#include<string>
using namespace std;

class Person
{
protected:
	string _name = "dancebit";
	int _num = 111;
};

class Student : public Person
{
public:
	void Print()
	{
		cout << "姓名: " << _name << endl;
		cout << _num << endl;
		cout << Person::_num << endl;
	}
protected:
	int _num = 999;
};

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

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

void Test1()
{
	Student s;
	s.Print();
}

void Test2()
{
	B b;
	b.fun(10);
	//b.fun();
	b.A::fun();
}

int main()
{
	//Test1();
	Test2();

	return 0;
}
  • 在继承体系中基类和派生类都有独立的作用域,这意味着可以定义同名的成员,就像 STL 中 list 有 push_back,vector 也有 push_back。

    同时也就意味着现在有一个矛盾点是 Student 里有 2 个 _num,我们在访问 _num 时是访问基类的还是派生类的 ❓

      根据我们之前知道的全局变量和局部变量相同,局部优先的特性,我们可以猜测是这里是派生类优先。它们都有一个特性,那就是优先在自己的作用域查找。

  • Test1() 中,当派生类和基类有同名成员变量时,派生类成员变量会屏蔽基类成员变量,以此不能直接访问基类成员变量。这种情况叫做隐藏或重定义。如果想访问基类成员变量需要指定基类成员变量所在的作用域。

  • Test2() 中,当派生类和基类有同名成员函数 fun,但是参数不同时,它们之间存在什么关系 ❓

      首先 A 类的 fun 和 B 类的 fun 一定不构成函数重载,因为以前说过函数重载必须是在同一作用域,而我们刚说基类和派生类是不同的作用域。

      这里规定对于成员函数,构成隐藏的关系只需要函数名相同即可,而不用关心参数、返回值。所以这里 A 类的 fun 和 B 类的 fun 构成的就是隐藏,如果想访问基类中的 fun 需要指定其所在作用域。

  • 注意在实际中继承体系里面最好不要定义同名的成员变量和函数。这里其实也是 C++ 在设计时不好和复杂的地方,但是你也不能说如果同名就报错。就像北京有一个叫张三的,贵州也有一个叫张三的,这当然没有问题;但是同一个家庭不能大哥叫张三,二哥也叫张三,因为你要访问张三,你就要指定一个规则,如默认张三就是大哥、小张三就是二哥。C++ 中不能完全禁止同名的成员,因为一定会存在同名的隐藏关系,本章以及多态会碰到这样的场景。

四、派生类的默认成员函数

在这里插入图片描述

我们之前在类和对象部分中学习了 6 个默认成员函数,“ 默认 ” 的意思就是指我们不写,编译器会帮我们自动生成。那么在派生类中,这几个成员函数是怎么生成的呢。

✔ 测试用例一:

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
	//Person(const char* name = "dancebit")
	Person(const char* name)
		: _name(name)
	{
		cout << "Person(const char* name = \"dancebit\")" << endl;
	}
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
	Person& operator=(const Person& p)
	{
		cout << "Person& operator=(cconst Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name;
	int a;
};
class Student : public Person
{
public:
	Student(const char* name, int id, const char* address)//推荐
		//: _name(name)//err,父类继承下来是一个整体
		: Person(name)
		, _id(id)
		, _address(address)
	{}
	
	//Student(const char* name, int id, const char* address)//不推荐
	//	: _id(id)//初始化列表阶段会先调用父类的默认构造
	//	, _address(address)
	//{}
private:
	int _id;
	string _address;
};

int main()
{
	//Student s1;
	Student s2("DANCEBIT", 1, "China");

	return 0;
}
  • 对于子类的构造函数,我们不写,编译器会默认生成。它针对 a) 内置类型成员不处理,除非声明时给了缺省值; b) 自定义类型成员,调用它的默认构造函数; c) 继承的父类成员作为一个整体,调用父类的默认构造函数;

  • 父类里写了默认构造函数、子类里没写构造函数,我们只定义了 Studnet 的对象,没有定义 Person 的对象,但是这里却调用了 Person 的构造和析构,这里是子类里默认生成的构造函数调用的,同时也看到了这里设计时没有把继承下来的父类成员混淆到自己的内置和自定义类型成员中。这里继承下来的父类成员会作为一个整体调用它的默认构造函数;内置类型不处理 (除非声明时给了缺省值);自定义类型会调用它的默认构造函数。注意严格来说是先处理父类继承下来的,内置类型和自定义类型可以认为是平等的。

    在这里插入图片描述

    在这里插入图片描述

  • 如果父类没有默认构造函数,那么想对父类的成员进行初始化,使用子类默认生成的构造函数是不行的,因为子类默认生成的构造函数要去调用父类的默认构造函数,而父类没有默认构造函数,所以需要自己实现子类构造函数 (Student s1 + 子类全缺省默认构造函数 || Student s2(“DANCEBIT”, 1, “China”) + 子类全缺省默认构造函数/构造函数)。要注意父类是作为一个整体,调用父类的构造函数初始化,对于构造函数我们自己实现是有价值的。

  • 如果父类使用默认生成的构造函数 (注意测试时需要将拷贝构造一起注释掉,因为拷贝构造也是构造),子类的构造函数不调用父类,当然也调用不了父类,它是在子类的初始化列表中调用的,可以看到父类的 _name 依然能初始化,因为 _name 是 string 类型的,它会去调用 string 的默认构造函数初始化。这里对于编译器默认生成的或无参的构造函数在子类就不能显示的初始化了,但是对于全缺省的依然可以显示的初始化。

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

    在这里插入图片描述

      构造函数设计成私有,就可以认为这个类不能被继承了,因为子类要初始化父类继承下来的成员一定要去调用父类的构造函数,而构造函数私有则意味着父类的构造函数在子类中不可见,这里就可以看到 private 还是有点使用的价值的,但也只是在 C++98 中,因为在 C++98 中如果想做到一个不能被继承的类,只能将构造函数私有,但是这样不定义子类对象和不调用父类的构造函数是不会报错的,注意可能是由于编译器检查严格的原因,就算不定义子类对象,在子类中显示的调用了父类的构造函数也会报错,所以你会发现 C++98 这种方式不够彻底和直观。

      在多态中,我们会介绍 C++11 中的关键字 final 用于替代 C++98 中的方式。

✔ 测试用例二:

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
	Person(const char* name = "dancebit")
		: _name(name)
	{
		cout << "Person(const char* name = \"dancebit\")" << endl;
	}
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
	Person& operator=(const Person& p)
	{
		cout << "Person& operator=(cconst Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name;
	int a;
};
class Student : public Person
{
public:
	Student(const char* name, int id, const char* address)
		: Person(name)
		, _id(id)
		, _address(address)
	{}
	Student(const Student& s)
		//: Person(s)//切片行为
		//, _id(s._id)
		//, _address(s._address)

		: _id(s._id)//不显示的调用父类的拷贝构造
		, _address(s._address)
	{}

private:
	int _id;
	string _address;
};

int main()
{
	Student s1("DANCEBIT", 1, "China");

	Student s2(s1);

	return 0;
}
  • 对于子类的拷贝构造,我们不写,编译器会默认生成。它针对 a) 内置类型成员完成值拷贝; b) 自定义类型成员,调用它的拷贝构造; c) 继承的父类成员作为一个整体,调用父类的拷贝构造;

    在这里插入图片描述

    在这里插入图片描述

  • 子类写了拷贝构造,子类就要显示的调用父类的拷贝构造,这里把子类对象里父类的那一部分取出来,本质就是切片行为 (这里把子类对象 s2 传给父类的引用,而父类仅仅使用了 _name)。

    但是实际上这里的拷贝构造没必要自己实现,因为这里一般情况下默认的拷贝构造就足够了,但是如果子类中有一个指针指向一块动态开辟的空间,存在深浅拷贝问题时就需要自己实现。

  • 如果子类中的拷贝构造不显示的调用父类的拷贝构造,那么便不会调用父类的拷贝构造,而是调用了默认的构造函数,因为拷贝构造也是构造,构造函数规定在初始化列表阶段,如果你不调用自定义类型,那就调用它的默认构造。

    在这里插入图片描述

✔ 测试用例三:

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
	Person(const char* name = "dancebit")
		: _name(name)
	{
		cout << "Person(const char* name = \"dancebit\")" << endl;
	}
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
	Person& operator=(const Person& p)
	{
		cout << "Person& operator=(cconst Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name;
	int a;
};

class Student : public Person
{
public:
	Student(const char* name, int id, const char* address)
		: Person(name)
		, _id(id)
		, _address(address)
	{}
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			_id = s._id;
			_address = s._address;
			//operator=(s);//切片行为,err
			Person::operator=(s);//切片行为
		}
		return *this;
	}
private:
	int _id;
	string _address;
};

int main()
{
	Student s1("DANCEBIT", 1, "贵阳市");
	Student s2("DanceBit", 2, "北京市");
	s1 = s2;

	return 0;
}
  • 对于子类的赋值重载,我们不写,编译器会默认生成。我们都知道赋值重载和拷贝构造很类似,它针对 a) 内置类型成员完成赋值重载; b) 自定义类型成员,调用它的赋值重载; c) 继承的父类成员作为一个整体,调用父类的赋值重载;

    在这里插入图片描述

    在这里插入图片描述

      对于子类的赋值重载,默认生成的也够用了。但涉及深浅拷贝问题时就需要自己写。

  • 如果显示的在子类中写拷贝赋值,父类部分只能使用切片,但是我们发现出现死循环最后导致栈溢出了,原因是父子类有同名的成员函数 operator=,构成隐藏关系,所以我们上面说 C++ 不敢设计成同名成员函数就报错,这里就可以看到场景了,所以这里的解决方法是指定父类的域。如果子类中的拷贝赋值不显示调用父类的拷贝赋值,便不会调用,此时赋值是不完整的。

    我们之前说 double d = 1.1,int i = d,它们是相近类型,完成隐式类型转换,而这里为什么子类对象给父类对象这样的一个切片行为是一种天然行为,不存在类型转换 ❓

      两者不能混淆的原因是,如果存在类型转换,其中会产生临时变量 (d ➡ 临时变量 ➡ i),也就意味着 int& i = d 是不行的,const int& i = d 是行的,因为 d 具有常性;而后者如果理解成存在隐式类型转换,Person& ref = s 那么 ref 引用的就是临时变量,但是这里也没有 const,所以我们期望的并不是引用临时变量,而是引用或指针指向的是子类对象当中切割出来的父类的那一部分,所以后者是一种天然行为。

✔ 测试用例四:

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
	Person(const char* name = "dancebit")
		: _name(name)
	{
		cout << "Person(const char* name = \"dancebit\")" << endl;
	}
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
	Person& operator=(const Person& p)
	{
		cout << "Person& operator=(cconst Person& p)" << endl;
		if (this != &p)
			_name = p._name;

		return *this;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
protected:
	string _name;
	int a;
};

class Student : public Person
{
public:
	Student(const char* name, int id, const char* address)//推荐
		//: _name(name)//err,父类继承下来是一个整体
		: Person(name)
		, _id(id)
		, _address(address)
	{}
	~Student()
	{
		//~Person();//err
		//Person::~Person();//析构+1

		//清理自己的资源
		//...
	}
private:
	int _id;
	string _address;
};

int main()
{
	Student s1("DANCEBIT", 1, "贵阳市");

	Student s2(s1);

	Student s3("DanceBit", 2, "北京市");
	s2 = s3;

	return 0;
}
  • 对于析构函数,它和构造函数类似,我们不写,编译器会默认生成,它针对 a) 内置类型不处理;b) 自定义类型调用它的析构函数;c) 父类作为一个整体调用父类的析构函数;

    在这里插入图片描述

  • 如果显示的写, 发现,~Person(); 会报错,这里很多书上都没解释清楚,书上说:子类的析构函数和父类的析构函数构成隐藏关系,所以要指定域。但其实你会发现与上面说的父子类的函数名相同就构造隐藏关系矛盾,这里父子类的析构函数名并不相同。其实本质是因为编译器会对析构函数名做特殊处理,所有类的析构函数名都会被处理成统一名字 destructor(),所以父子类都是 destructor() 就构成隐藏关系。所以说为什么有很多人说 C++ 难学,如果没有一本好书或学习的路径不对 (就像 Primer 如果按着顺序学下去,很容易从入门到放弃)。

    为什么编译器要把所有类的析构函数名统一成 destructor ❓

      因为析构函数要构成多态重写,重写是多态的一个条件,它要求函数名相同, 若不构成重写的话,在某些情况下会出现资源泄漏的问题,具体细节在多态在谈。

    这里就算指定域了,我们发现本应该析构 3 次的,却析构了 6 次 ❓

    在这里插入图片描述

  我们这里只有 3 个对象,但是却析构了 6 次,因为 Person::~Person(); 所以每一个对象里调用了两次析构,所以注释掉它即可。

  • 调试发现子类的析构函数在执行结束后,会再自动调用父类的析构函数 ❓

    C++ 中的对象要保证先定义的后析构,可以想象一下父子对象在栈中的结构,所以这里是先构造父对象,再构造子对象,先析构子对象,再析构父对象。如果显示的写,并显示的调用,就有可能不符合这种特性,所以干脆这里规范不要自己显示调用了。注意如果有对为什么先构造父对象有疑问,可以理解为,初始化列表中出现的先后顺序不重要,重要的是声明的顺序,你可以认为编译器在界定时是认为父类的声明顺序是在最前面的。

    在这里插入图片描述

💨小结:

  • 无论是构造、拷贝构造、赋值,它们里面父类的那部分类似于自定义类型成员,你要去初始化、拷贝、赋值操作时,你都要调用父类对应的函数去完成。对于调用方:因为有初始化列表的存在,所以构造函数里可以不用显示的调用父类;拷贝构造不显示调用父类的话,那么完成的就是构造;赋值不显示调用父类的话,那么便不会赋值;析构不显示调用是规范。
  • 取地址重载就没必要考虑的那么复杂了,它不需要调用父类的,而是取自己的,最后返回。

五、继承与友元

✔ 测试用例一:

#include<iostream>
#include<string>
using namespace std;

class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);//友元
protected:
	string _name; 
};
class Student : public Person
{
	//friend void Display(const Person& p, const Student& s);//友元 
protected:
	int _stuNum; 
};
void Display(const Person& p, const Student& s) {
	cout << p._name << endl;
	cout << s._stuNum << endl;
}
void main()
{
	Person p;
	Student s;
	Display(p, s);
}
  • 友元关系不能被继承,也就是说基类友元不能访问子类私有和保护成员。解决方法就是在派生类中写友元。

六、继承与静态成员

#include<iostream>
#include<string>
using namespace std;
class Person
{
public:
	Person() { ++_count; }
protected:
	string _name; 
public:
	static int _count; 
};
int Person::_count = 0;
class Student : public Person
{
protected:
	int _stuNum; 
};
class Graduate : public Student
{
protected:
	string _seminarCourse; 
};
int main()
{
	Student s1;
	Student s2;
	Student s3;
	Graduate s4;
	
	cout << "人数:" << Person::_count << endl;
	
	Student::_count = 0;
	cout << "人数:" << Person::_count << endl;

	cout << "人数:" << Student::_count << endl;
	cout << "人数:" << Graduate::_count << endl;
	cout << "人数:" << s1._count << endl;
	cout << "人数:" << s4._count << endl;

	return 0;
}
  • 基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例 。
  • 基类的静态成员是属于整个继承体系的类,属于这些类的所有对象。

七、复杂的菱形继承及菱形虚拟继承

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

    在这里插入图片描述

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

    上面的测试用例我们都是用单继承演示的。多继承类似现实中,研究生在学校里面既可以是学生,也可以是老师或助教。实际上,多继承是 C++ 的一个坑,越补,坑就越多,可以说,你打开了多继承的大门,各种头痛的问题来了。所以 java 后续都参考了 C++ 的血泪史,直接不支持多继承。多继承当然没有问题,但是支持多继承,就可能会出现下面要学习的菱形继承。

    在这里插入图片描述

✔ 测试用例一:

#include<iostream>
#include<string>
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()
{
	Assistant a;

	//a._name = "DanceBit";//err,二义性
	
	a.Student::_name = "DanceBit-学生";//指定域
	a.Teacher::_name = "DanceBit-老师";

	return 0;
}
  • 菱形继承:菱形继承是多继承的一种特殊情况。

    如果学生和老师单继承是独立的类,研究生或助教多继承了学生和老师,那都没有问题。但是学生和老师都单继承了人,研究生或助教多继承了学生和老师,就会出现菱形继承。

    菱形继承真正的问题在于 Assistant 中会有两份 Person 成员,这会导致数据冗余和二义性。数据冗余就是两份 Person 浪费空间;二义性就是你要访问 _name 时,访问的是谁的 _name。

  • 虽然指定域能暂时解决二义性的问题,但是根源上的数据冗余并没有解决,并且每次访问、每次指定那也不是个事呀。

    在这里插入图片描述

    在这里插入图片描述

    当然菱形继承不一定是这么规则的,只要形状类似,那么它就是菱形继承。

    在这里插入图片描述

✔ 测试用例二:

#include<iostream>
#include<string>
using namespace std;

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; 
};
int main()
{
	Assistant a;

	a.Student::_name = "DanceBit-学生";//指定域
	a.Teacher::_name = "DanceBit-老师";
	a._name = "DanceBit";

	return 0;
}
  • 针对于上面多继承中菱形继承的特殊场景所引发的问题,C++ 就引入了虚继承 virtual 以解决数据冗余和二义性,虚继承与后面多态中的虚函数共用一个关键字。

  • 注意 virtual 不是在 Assistant 处加,而是在菱形中间腰部 (Student 和 Teacher) 的位置加。

    在监视窗口查看,好像更怪了,有三份 Person。其实监视窗口看到的不一定是真实的,基于一些原因,可能是方便观察,VS 对监视窗口做的一些处理。当然监视窗口也是可以看出来的,你可以认为只有一份是真实的,其它两份是引用,这里每次访问 _name,三份都会改变;当然如果想更直观看它的底层,需要使用内存窗口。

    在这里插入图片描述

  • 如果 C++ 学浅一点,那么这块也就到这了。但是这块作为一个大坑,实际上有些地方会要求我们能去了解它到底坑在哪,复杂在哪,如果学语法光学到这,你是不会感觉到这有什么复杂的,因为这里不就是出现了数据冗余和二义性嘛,那使用 virtual 就解决了呀。

    实际有时面试问到这块问题时,面试官会往下追问,它底层的原理是什么,虚继承是怎么解决的,出于这个角度,我们还要再往下探索,这是其一;其二,你同事设计了菱形继承,你说:不要用菱形继承,它会出现数据冗余和二义性,同事反驳到:没事,我用虚继承就解决了。那你如何劝退同事不要设计菱形继承,没有了解虚继承的底层,那么你就词穷了,出于这个角度,我们还要再往下探索。也就是说从实用的角度,已经没必要再往下探索了,因为虽然有虚继承,但实际中是依旧是很不建议也几乎很少设计菱形继承的。

✔ 测试用例三:

#include<iostream>
using namespace std;

class A 
{
public:
	int _a;
};
//class B : public A
class B : virtual public A 
{
public:
	int _b;
};
//class C : public A
class C : virtual public A 
{
public:
	int _c;
};
class D : public B, public C 
{
public:
	int _d;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 1;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	d._a = 0;

	return 0;
}
  • 这里再使用上面的测试用例去探索就不太合适了,因为这个类太复杂了, 像 string 这样的对象太大了,而且在不同平台下的大小也不一样。所以我们这里设计了四个类,每个类里也只有对应的小写成员变量。

    在这里插入图片描述

  • 不使用 virtual,我们通过内存窗口可以很清晰的看到菱形继承所带来的数据冗余和二义性问题。

    在这里插入图片描述

  • 使用了 virtual,并且在原来的测试代码后加上 d._a = 0; ,此时我们再看内存窗口,可以很清晰的看到 virtual 解决了菱形继承所带来的数据冗余和二义性问题。

    在这里插入图片描述

    此外我们观察对比不使用 virtual 时的内存中成员构成的对象模型:这里把 A 单独拎出来放在最下面,且 BC 中好像还各自多余了 4 个字节的地址,我们再通过其它内存窗口查看 BC 中那两个所谓多余的地址放的到底是啥 (注意这里是小端机),发现 BC 中各自所谓多余的那 4 个字节指向的空间中存储了 20 和 12,它们一点都不多余,其代表的含义其实是对于 A 的偏移量 —— 0x004FFA14 + 20 = 0x004FFA28;0x004FFA1C + 14 = 0x004FFA28;,官方来讲对于 0x004FFA14 和 0x004FFA1C 所指向的内存2和内存3,称之为虚基表。当然为什么要这要设计的原因也很简单,因为它要解决数据冗余和二义性,那么 A 既不能放到 B 里面也不能放到 C 里面,此时 B 里要找 A,C 里要找 A,只能通过 BC 中对应的指针拿到偏移量 + 当前地址。

    在这里插入图片描述

    什么情况下,会去找 B 的 A、C 的 A 呢 ❓

      D d;      B b = d;

      B* p = &d;  p->_a;

      B& r = &d;  r._a;

      这里测试用例四会验证。

    为什么需要偏移量 ❓

      D d,B b = d,切片时,需要去找虚基类的成员;B* p = &d,B* p = &b,p 有可能指向 d,也有可能指向 b,d 和 b 中公共的成员的偏移量是不一样的,所以都要取偏移量才能找到公共成员。

    为什么不在 0x004FFA14、0x004FFA1C 直接存偏移量 ❓

      这样也是可以的,但是使用虚基表来存储的原因是还有其它场景 —— 在引入多态时 0x00DCDB40 和 0x00DCDB48 处还需要在存储一个值。

    virtual 已经能解决菱形继承所带来的问题了,为什么还是不建议使用 ❓

      在解决问题的同时,也要明白效率降低了,它的这个对象模型更复杂了, 以前是编译器编译完直接就可以找到,因为它们是挨着的。现在是必须得通过指针找到偏移量,再与现在的地址相加才可以找到。

✔ 测试用例三:

#include<iostream>
using namespace std;

class A 
{
public:
	int _a;
};
//class B : public A
class B : virtual public A 
{
public:
	int _b;
};
//class C : public A
class C : virtual public A 
{
public:
	int _c;
};
class D : public B, public C 
{
public:
	int _d;
};
int main()
{
	B b;
	b._a = 1;
	b._b = 2;

	return 0;
}
  • 虚继承后,哪怕只给一个 B 对象,它的对象模型也会变,它还是一样把公共的 A 放在最下面。

    在这里插入图片描述

✔ 测试用例四:

#include<iostream>
using namespace std;

class A 
{
public:
	int _a;
};
//class B : public A
class B : virtual public A 
{
public:
	int _b;
};
//class C : public A
class C : virtual public A 
{
public:
	int _c;
};
class D : public B, public C 
{
public:
	int _d;
};
int main()
{
	B b;
	b._a = 1;
	b._b = 2;

	B* p = &b;
	p->_a = 3;
	D d;
	p = &d;
	p->_a = 4;

	return 0;
}
  • 不管是对象、指针、引用要访问继承的虚基类对象成员 _a,都是取偏移量来计算 _a 的位置,可以看到虚继承后解决了菱形继承所带来的问题,但是同时对象模型更复杂了,以至于访问虚基类成员也付出一定的效率代价。

    在这里插入图片描述

八、继承的总结和反思

  • 很多人说 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以最好不要设计出菱形继承,否则在复杂度及性能上都有问题。

  • 可以看到 C++ 在这里自已挖了个坑,但是自己含着泪也要填完,这块后面再引入多态,那就更复杂了,多继承可以认为是 C++ 的缺陷之一,很多后来的 OO 语言都没有多继承,如 Java。

  • 继承和组合

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

    2、组合是一种 has - a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。

    3、优先使用对象组合,而不是类继承

      继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语 “ 白箱 ” 是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高,类模块之间的独立性低 (10 人跟团旅游。假设 A 类有 80 个保护成员、20 个公有成员,那么派生类中可能都使用了基类的成员,这就意味着基类的改变,会对派生类造成很大的影响)。
    在这里插入图片描述
      对象组合是类继承之外的另一种复用选择,这种方式在文章最开始我们就谈了。新的更复杂的功能可以通过组装或组合对象来获得。但是对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-box reuse),因为对象的内部细节是不可见的。对象只以 “ 黑箱 ” 的形式出现。 组合类之间没有很强的依赖关系,耦合度低 (10 人跟团自由旅游。假设 A 类有 80 个保护成员、20 个公有成员,B 类不可能用那 80 个保护成员,所以相对类继承,这的影响就要小很多了),类模块之间的独立性高。优先使用对象组合有助于你保持每个类被封装。
    在这里插入图片描述
      实际尽量多去用组合。组合的耦合度低,代码维护性好,在软件开发中非常推崇 “ 低耦合,高内聚 ”。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承,用组合也不是不能玩多态,但是你就得模拟实现多态的原理。建议类之间的关系是 is - a (司机和人、学生和人),就用继承;类之间的关系是 has - a (脸和眼睛、车和轮胎),就用组合;类之间的关系是既可以是 is - a 也可以是 has - a,就用组合。

//Car和BMW、Car和Benz构成is-a的关系
class Car 
{
protected:
	string _colour = "白色"; 
	string _num = "陕ABIT00"; 
};
class BMW : public Car 
{
public:
	void Drive() { cout << "好开-操控" << endl; }
};
class Benz : public Car 
{
public:
	void Drive() { cout << "好坐-舒适" << endl; }
};

//Tire和Car构成has-a的关系
class Tire 
{
protected:
	string _brand = "Michelin"; 
	size_t _size = 17; 
};
class Car 
{
protected:
	string _colour = "白色"; 
	string _num = "陕ABIT00";
	Tire _t; 
};

九、笔试面试题

  1. 什么是菱形继承 ?菱形继承的问题是什么 ?
  2. 什么是菱形虚拟继承 ?如何解决数据冗余和二义性的 ?
  3. 继承和组合的区别 ?什么时候用继承 ?什么时候用组合 ?
  • 91
    点赞
  • 68
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 43
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

跳动的bit

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

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

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

打赏作者

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

抵扣说明:

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

余额充值