为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?

2年前 (2022) 程序员胖胖胖虎阿
202 0 0

为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?


你好,我是安然无虞。

文章目录

  • 继承的概念及定义
    • 继承的概念
    • 继承的定义
      • 定义格式
      • 继承关系和访问限定符
      • 继承基类成员访问方式的变化
  • 基类和派生类对象赋值转换
  • 继承中的作用域
  • 派生类的默认成员函数
  • 继承与友元
  • 继承与静态成员
  • 复杂的菱形继承和菱形虚拟继承
    • 虚拟继承解决数据冗余和二义性的原理
  • 继承的总结和反思
    • 继承和组合
  • 笔试面试题

继承的概念及定义

我们知道面向对象的三大特性有:封装、继承和多态。可能有铁子会误以为面向对象只有这三大特性,其实不然,还有其他的特性,比如反射、抽象等。
那今天我们就好好来说说C++是如何设计继承的,为什么说比Java的继承要复杂呢?请看下文:

继承的概念

首先谈谈继承的概念:

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

概念听起来有点生硬,那就举个栗子吧:
我们在学校的一些大作业中可能听过这样一个“系统”:图书管理系统,它的角色类分为:学生、老师、保安、保洁、后勤……
比如学生类是这样设计的:

class Student
{
	string _name;//姓名
	string _tel;//电话
	string _address;//地址
	int _age;//年龄
	//……
	string _stuID;//学号
};

老师类是这样设计的:

class Teacher
{
	string _name;
	string _tel;
	string _address;
	int _age;
	//……
	string _workID;//工号
};

很明显,有些数据和方法是每个角色都有的,比如姓名、电话、地址和年龄,这样也就设计重复了,而有些数据和方法是每个类独有的,比如学号和工号。

所以这个时候继承就起到作用了,我们把重复的数据和方法设计到Person类中:

class Person
{
	string _name;
	string _tel;
	string _address;
	int _age;
};

这样时候设计Student类和Teacher类可以通过继承Person类:
Student类:

class Studet : public Person
{
	string _stuID;
};

Teacher类:

class Teacher : public Person
{
	string _workID;
};

这样就实现继承了,继承的本质是类设计角度的复用。上面的Person类是父类,也叫基类;Student类和Teacher类是子类,也叫派生类。

铁子可能对上面讲的不是很明白,没事,下面带着大家一起调试一段代码:

class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
		cout << endl;
	}
protected:
	string _name = "SL";//这是C++11的新玩法
	int _age = 18;//不是初始化,给的是缺省值
};

//继承后父类的Person成员的成员变量和成员函数都会变成子类的一部分
//这里体现出了Student类和Teacher复用了Person类的成员
class Student : public Person
{
protected:
	int _stuID;
};

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

int main()
{
	Student s;
	Teacher t;
	s.Print();
	t.Print();

	return 0;
}

调试上段代码,通过监视窗口查看Student类对象s和Teacher类对象t,可以看到变量_name和_age的复用。
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
调用Print函数时可以看到成员函数的复用,这里我就不演示了,大家可以下去自行调试哦。

继承的定义

定义格式

下面我们看到Person类是父类,也叫基类;Student类是子类,也叫派生类。
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?

继承关系和访问限定符

继承方式分为:

  • public继承
  • protected继承
  • private继承

访问限定符分为:

  • public访问
  • protected访问
  • private访问

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

请仔细看看下面表格:

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

注意:对于最后一行基类的private成员,它虽然在派生类中不可见,但是它继承到派生类当中了。

总结:

  1. 基类private成员在派生类中无论以什么继承方式都是不可见的,这里不可见指的是语法上限制了派生类对象不管在类里面还是在类外面都不能访问它,但是基类的私有成员还是继承到了派生类当中
  2. 基类的private成员在派生类中是不能被访问的,如果基类成员不想在类外直接被访问,但是需要在派生类当中能被访问,就需要定义为protected成员。这个时候就能发现,原来protecte访问限定符是为了继承而设置的;
  3. 对上面的表格进一步总结就会发现,基类的私有成员在子类当中都是不可见的,基类的其他成员在子类当中的访问方式 == 成员在基类当中的访问限定符和继承方式,二者取小的那一个,注意哦,这个是继承原则;
  4. 使用关键字class时默认继承方式是private,使用struct时默认继承方式是public,不过最好显示的写出继承方式;
  5. 在实际运用当中一般都是public继承,几乎很少使用protected/private继承,现实中也不提倡使用,因为protected/private继承下来的成员都只能在派生类里面使用,实际中的扩展维护性不强,大佬早期设计的时候想复杂了,实际当中的基类成员都是保护或者公有,继承方式都是公有继承

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

这部分只需要注意三点内容:

1.派生类对象可以赋值给基类的对象/基类的指针/基类的引用。这里有个很形象的说法叫切片或切割,寓意是把派生类中基类那部分切来赋值过去。
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?

2.基类对象不能赋值给派生类对象

3.基类对象的指针或引用可以通过强制类型转换赋值给派生类对象的指针或引用,但是必须是基类对象的指针必须指向派生类对象时才是安全的。(具体放到后面讲解,这里大家知道有这点即可)

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

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

void Test()
{
	Student s;

	//1、派生类对象可以赋值给基类对象/指针/引用
	Person p = s;
	Person* pp = &s;
	Person& rp = s;

	//2、基类对象不能赋值给派生类对象,注意哦,强制类型转换也不可以
	//s = p;

	//3、基类指针可以通过强制类型转换赋值给派生类指针,但有些情况会导致越界,后面会详细说明
	pp = &s;
	Student* ps1 = (Student*)pp;
}

继承中的作用域

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

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

//Student的_num和Person的_num构成隐藏关系,很容易混淆
class Person
{
protected:
	string _name = "张三";
	int _num = 168;//身份证号
};

class Student : public Person
{
public :
	void Print()
	{
		cout << "姓名:" << _name << endl;
		cout << "身份证号:" << Person::_num << endl;//指定作用域
		cout << "学号:" << _num << endl;
	}
protected:
	int _num = 999;//学号
};

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

4.注意实际当中的继承体系里不要定义同名的成员(包括成员变量和成员函数)。

//B中的fun()和A中的fun()不是构成重载,因为不是在同一作用域
//基类和派生类都有各自独立的作用域

//B中fun()和A中fun()构成隐藏,成员函数满足函数名相同构成隐藏关系
class A
{
public :
	void fun()
	{
		cout << "fun()" << endl;
	}
};

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

void Test()
{
	B b;
	b.fun(10);
}

运行结果:
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
对Test()函数变形:

void Test()
{
	B b;
	b.fun();//编译报错,A中的fun()函数被隐藏了,默认调用的是派生类的成员函数,改成b.A::fun()才正确
	b.fun(10);
}

派生类的默认成员函数

6个默认成员函数,“默认”的意思就是我们不写,编译器也会自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?

1、派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用;

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

3、派生类的operator=必须要调用基类的operator=完成基类的复制;

4、派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。

5、派生类对象初始化先调用基类构造再调用派生类构造;

6、派生类对象析构清理先调用派生类析构再调用基类析构;

7、因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同,那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以基类析构函数在不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。(这点在后面多态部分详细讲解)

为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?

class Person
{
public:
	Person(const char* name = "张三")
		:_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:
	//初始化列表初始化顺序是按照声明的顺序,一般我们认为父类是在前声明的
	//所以即使改成:_num(num) ,Person(name)也是先初始化父类的对象
	Student(const char* name = "", int num = 0)
		:Person(name)//注意这里不是_name(name),父类一定要调用父类的默认构造初始化
		,_num(num)
	{
		cout << "Student()" << endl;
	}
//子类构造函数原则:(析构、拷贝、赋值也类似)
//a.调用父类构造函数初始化继承自父类成员;
//b.自己再初始化自己成员
	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::~Person(),这样也是错误的,原因是没必要
		//为了保证析构顺序,先子后父,子类析构函数完成后会自动调用父类的析构函数,所以不需要我们显示调用
		cout << "~Student()" << endl;
	}

protected:
	int _num;//学号
};

void Test()
{
	Student s1("张三", 20510);
	Student s2(s1);
	s1 = s2;
}

有这样一道题目:
如何设计一个不能够被继承的类?
答案是:将父类构造函数私有化(这样在定义对象的时候就会报错)

class A
{
private:
	A()
	{}
};
//父类的构造函数私有化后,子类就无法构造对象
class B : public A
{
}

继承与友元

友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。是父类的友元,但不是子类的友元。

class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name;
};

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

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

继承与静态成员

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

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 _course;//学科
};

void Test()
{
	Student s1;
	Student s2;
	Student s3;
	Graduate s4;
	cout << "人数:" << Person::_count << endl;
	Student::_count = 0;
	cout << "人数:" << s4._count << endl;
	
	cout << "s1._count地址:" << &s1._count << endl;
	cout << "s4._count地址:" << &s4._count << endl;
}

为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
用的是同一个static成员,地址也一样。

复杂的菱形继承和菱形虚拟继承

1、单继承:一个子类只有一个直接父类时,称这个继承关系为单继承。
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
2、多继承:一个子类有两个或两个以上的直接父类时,称这个继承关系为多继承。
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
3、菱形继承:菱形继承是多继承的一种特殊情况。
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
菱形继承存在的问题:
从下面的对象成员模型可以看出,菱形继承有数据冗余和二义性的问题
在Assistant对象中Person成员会有两份。
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?

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

void Test()
{
	//存在二义性,不知道访问哪一个
	Assistant a;
	//a._name = "诸葛大力";

	//需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题依旧不能解决
	a.Student::_name = "张三";
	a.Teacher::_name = "李四";
}

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在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 = "诸葛大力";
}

虚拟继承解决数据冗余和二义性的原理

为了研究虚拟继承原理,接下来给出一个简化的菱形继承体系,再借助调试状态下的内存窗口观察对象成员的模型。

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 d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	return 0;
}

为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
将代码改成菱形虚拟继承,如下:

class A
{
public :
	int _a;
};

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

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 = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;

	return 0;
}

下图是菱形虚拟继承的内存对象成员模型:这里我们可以分析出D对象中将A放到了对象组成的最下面,A被放到一个单独的空间中,这个A同时属于B和C。
但是这样的话会有一个问题:B和C如何去找到这个公共的A呢?其实在VS下是通过B和C的两个指针,分别指向各自的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的是偏移量(本题是距离A存储位置的偏移量)。通过偏移量可以找到下面的A
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
下图是对于上面Person关系菱形虚拟继承的原理解释:
为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?
可能我们会疑问,为什么D中B和C部分要去找属于自己的A?那么大家看看当发生下面的赋值时,d是不是要去找B和C成员中的A才能赋值过去?

D d;
//会切片,通过偏移量来找各自的A
B b = d;
C c = d;

继承的总结和反思

1、我们都说C++语法复杂,其实多继承就是一个体现。有了多继承,就会存在菱形继承,有了菱形继承就会有菱形虚拟继承,它们的底层实现相当复杂。所以一般不建议使用多继承,更不要使用菱形继承,因为在复杂度和性能上都会存在问题。
2、多继承可以认为是C++的设计缺陷之一,后来很多OOP都没有多继承,比如Java。

继承和组合

对于继承和和组合的理解:

public 继承是一种 is-a 的关系,也就是说每一个派生类对象都是一个基类对象。比如:学生->人,狗->动物

class A
{};
//继承
class B : public A
{};

组合是一种 has-a 的关系,假设B组合了A,每个B对象中都有一个A对象。比如:车->轮胎,头->眼睛

class C
{};
//组合
class D
{
	C _c;
};

注意:适合 is-a 关系建议继承,适合 has-a 关系建议组合,都适合的话建议组合。也就是说,优先使用对象组合,而不是类继承。

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

补充部分:
我们知道,模块间的关系是低耦合,高内聚。
什么是耦合呢?
打个比方吧,比如我们去旅游,选择团体出行的话,人和人之间的关系太紧密,这就叫耦合度高;选择自由出行的话,人和人之间的关系松散,没有很多具体要求,这就叫耦合度低。

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

注意比较白箱复用和黑箱复用:
白箱复用:A对象的保护成员B能直接用,公有成员也是直接用;
黑箱复用:C对象的保护成员D不能直接用,公有成员可以直接用。

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

笔试面试题

看完了这篇文章,这几道笔试面试题一定难不到你了,快来检测自己一下吧!

  • 什么是菱形继承?菱形继承的问题是什么?
  • 什么是菱形虚拟继承?如何解决数据冗余和二义性的?
  • 继承和组合的区别?什么时候用继承?什么时候用组合?

种一棵树最好的时间是十年前,其次是现在。
还等什么呢,快上牛客来检验自己的学习成果吧:牛客网
​​为什么C++中的继承比Java设计的要复杂,原因竟出在这儿?

相关文章

暂无评论

暂无评论...