深入了解C++中的继承

- 本人的LeetCode账号:魔术师的徒弟,欢迎关注获取每日一题题解,快来一起刷题呀~

一、继承的概念

1 初识继承

  继承是一种类设计上的代码复用。

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

  比如我们的学校管理系统,不同的人有着不同的特有信息,但它们共有一些信息,可以把共有的信息设计为基类,然后继承这个基类增加特有的信息构成特别的类型:老师、学生、食堂阿姨。

  如果不使用继承体系,每个角色都单独设计一个类,公共信息就会被重复设计,产生冗余。

  一个简单的public继承体系:

class Person
{
public:
	void print()
	{
		cout << "name:" << _name << ' ' << "age:" << _age << endl;
	}
protected:
	string _name = "路由器";
	int _age = 20;
};
// 继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了Student和
// Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可以看到变量的复用。
// 调用Print可以看到成员函数的复用。
class Student : public Person
{
protected:
	int _stuid; // 学号
};
class Teacher : public Person
{
protected:
	int _jobid; // 工号
};
int main()
{
	Student s;
	Teacher t;
	s.Print();
 	t.Print();
	return 0;
}

  继承可以继承父类的全部成员,不仅仅是成员变量,成员函数也一并继承。

2 继承的定义

  继承的语法含义分别如下:

3 继承方式和访问权限修饰符

  C++的继承方式和它的访问权限修饰符的个数一样,有三种:

  继承的方式的设计是考虑到父类的成员有三种:publilcprivateprotected,C++的设计者考虑了这不同的三种成员在子类中的权限变化,分别给出了public继承、private继承、protected继承,3 * 3得到了9种不同的继承方法,相比非常复杂。

  通常C++书籍中都会给这么一个表,如果只看表非常的难记忆难理解。

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

  不过我们可以总结出以下规律:

  • 基类的private成员无论是哪种继承方式,都会派生类中不可见,不可见的含义就是它虽然被继承了,但是在派生类内和类外都不能直接访问那个成员,不过可以通过基类的继承而来的成员函数来访问它。所以如果父类有一个成员,不想给子类用,就可以定义成private成员。
  • 基类的其他成员,这个成员在子类的访问权限是继承方式和基类的访问权限中的较小者,Min(成员在基类的访问限定符,继承方式),权限的大小关系是:public > protected > private
  • protected成员和private成员在基类中没有区别,但是在子类中,基类的private成员一定是不可见的,基类的protected成员一定是可见的,这就是protectedprivate的区别,可以说,private成员无法被“继承”。
  • C++还支持缺省方式的继承,使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式

  我们在实践中,通常使用的都是public继承,很少使用privateprotected继承,我们也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

  所以实践中最常用的继承方式就是父类成员都是publicprotected,子类的继承方式是public继承

二、 基类和派生类对象的赋值转换(切割)

  C++中有类型转换的概念,仿照类型转换,如果我们考虑父类对象赋值给子类对象,子类对象赋值给父类对象,会发生什么呢?

  这被称为父类=子类的赋值兼容,或被称为切割、切片,只有共有继承支持这个操作,它不是类型转换,它是语法支持的一种天然行为。

  我们不仅仅可以用对象,还可以用指针和引用:

  让父类的指针只能看到父类自己的成员,子类的成员看不到。

  引用的底层是指针,道理也是类似。

  它为什么不是类型转换呢?如果是类型转换的过程中会产生临时变量,而我们的切割并不会产生临时变量,这从我们的引用不需要用临时对象的const引用看出。

  为啥只有共有继承支持这个操作呢?如果是其他方式的继承,那么子类的成员在继承后权限关系会发生了变化,而在你父类指针和父类引用的视角下,我这些成员又变成了你父类成员下权限关系,权限关系变了,这肯定不合理啊。

  子类给父类赋值的操作(切割)是合法的,但是父类对象给子类对象的赋值是非法的,因为子类可能有父类没有的成员,不可能凭空增加,哪怕强制类型转换也会报错。

  但是bug的事情出现了,父类指针或引用可以赋值给子类的指针或引用,不过要强制类型转换。

  但是这时非常危险,你如果用强转后的指针访问子类成员,就可能会越界访问,如果要安全的转化,需要在后续C++的类型转化中学习。

  一点小细节,父类的私有成员,被子类不可见的拿到以后,在切割的时候还是会被赋值。

  切片是很重要的,C++中多态的实现正是依赖于切片机制。

三、继承中的作用域

  在继承体系中,父类和子类都有各自的作用域,回忆之前学习的就近原则,当局部域和全局域有同名的成员时,优先访问局部域中的成员,在继承中,这一点是类似的。

1 隐藏

  假如子类中有和父类同名的成员,那么当我们访问子类的该成员时,根据就近原则,我们会访问子类的那个成员,如果我们想访问父类的那个成员,需要指定是父类的作用域。

  子类和父类出现同名成员,这一现象被称为隐藏(隐藏了父类的成员)或重定义。

class fathernum
{
public:
	int _num = 1;
};

class sonnum : public fathernum
{
public:
	int _num = 999;
};

int main()
{
	sonnum s;
	cout << s._num << endl;
	cout << s.fathernum::_num << endl;
	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;
    }
};
void Test()
{
    B b;
    b.fun(10);// 构成隐藏
    b.fun();// 报错
};

  1. A和B的func函数构成函数重载
  2. 编译报错
  3. 运行报错
  4. A和B的func构成隐藏

  A的fun和B的fun(int)都不在一个作用域中,何谈函数重载,这种情况下构成函数隐藏,不论函数的参数是否相同,只要函数名相同,就构成隐藏。

  b.fun()想调用A作用域的fun()的函数,被隐藏了,无法被调用,如果想调用得指定作用域,想调用得b.A::fun()

  继承体系中不建议定义同名成员,不过语法上没有限制死,还是不建议使用。

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

1 不显示的写

  我们知道,如果我们不写一些成员函数,编译器会默认生成6个成员函数,那么如果我们不给子类写,它的4个默认成员函数(构造、析构、拷贝构造、赋值)生成了会干什么事情呢?如果我们要显示的写要做些什么事情比较好呢?

  先什么都不写,看一看:

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:
protected:
	int _num; //学号
};

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

  我们调用了父类的构造函数和析构函数。

构造函数和析构函数

  如果我们不写,默认生成的构造函数和析构会干两个事情,分两部分:

  • 对从父类继承下来的,构造函数调用父类的构造函数,初始化从父类继承来的部分,析构函数调用父类的析构函数处理。
  • 对自己的(内置类型和自定义类型),内置类型不处理,自定义类型调用它的对应的构造函数和析构函数。

拷贝构造和赋值

  如果我们不写,

  • 对于从父类继承下来的部分,调用父类的拷贝构造和赋值;
  • 对于自己的成员,它普通类默认生成的这俩函数一样,对内置类型完成值拷贝,对自定义类型调用其拷贝构造和赋值运算符重载。
int main()
{
	Student s;
	Student s1(s);
	Student s2;
	s2 = s;
 	return 0;
}

  派生类的默认成员函数的原则:继承下来的部分调用父类的处理,自己的按照普通类成员处理。

2 自己写

  什么情况下必须自己写这些默认成员函数呢,自己应该怎么写这些默认成员函数呢?

  如果父类的部分没有默认构造函数,那么你在无参构造子类对象时,就会报错,需要我们显示写默认构造。

  如果子类自己的成员有堆上的资源需要释放,就需要自己写析构。

  如果子类自己的成员有浅拷贝问题,则需要自己实现拷贝构造函数和赋值,实现深拷贝。

  那么怎么解决呢?

  父类的成员要显示调用父类的默认成员函数处理,自己的成员按普通类处理。

构造函数

拷贝构造

  这里利用了切片,父类的拷贝构造参数是引用,我们传一个子类对象过去,它就会变成父类那部分的引用,也就是利用切片机制。

赋值

  这里记得要限制类域,由于隐藏的机制,如果不指定父类的类域,它会反复调用子类的赋值运算符重载,就寄了。

析构

  直接写不能调用:

  指定类域才能调用:

  为什么呢?因为析构函数的名字会被编译器同一处理成:destructor,所以子类的析构函数和父类的析构函数就构成了隐藏,指定类域才能调用。

  析构函数为什么会被同一处理成这个名字呢,是为了配合多态的机制。

  这里还有一个坑:

  这里为什么会调用两次析构函数呢?如果父类中有资源等待释放,调两次析构函数就会崩溃了。

  这是因为父类部分的析构函数不需要我们显示调用,子类析构函数结束时,会自动调用父类的析构函数。

  子类对象中,子类自己的成员和父类成员构造和析构的顺序如下:

  这是因为子类对象也是像一个压栈顺序构造,出栈顺序析构的,所以为了避免顺序混淆,我们的子类的析构函数出了作用域后会自动调用父类部分的析构函数,不需要显示调用。

  所以自己实现子类析构函数时,不需要自己显示调用父类的析构函数

  总结:子类自己的成员就按普通类的方式来处理,继承来的成员调用父类的对应默认成员函数处理(析构函数除外)。

五、友元关系与继承

  友元关系不能继承

  但是子类中的父类部分仍然可以通过父类的友元访问:

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



class Student : public Person
{
public:
	Student(const char* str = "haha", int num = 1)
		: Person(str), _num(num)
	{}
protected:
	int _num;
};

void display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	cout << s._name << endl;
}

int main()
{
	Person p("hahaha");
	Student s("sjfkajkf", 20);
	display(p, s);
}

六、静态成员和继承

  如果基类定义了一个静态成员,那么整个继承体系都只有这一份静态成员。

  由于这个特性,可以用来统计基类及其派生类一共创建了多少个对象:

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()
{
    Person p;
    Student s;
    Graduate g;
    cout << "人数" << Person::_count << endl;
    cout << &Person::_count << endl;
    cout << &Student::_count << endl;
    cout << &Graduate::_count << endl;
    return 0;
}

七、菱形继承与菱形虚拟继承

1 单继承和多继承

  一个类只有一个直接父类,我们就称为单继承。

  多继承:有两个或两个以上的直接父类。

  多继承设计的初衷是好的,继承多个类不是挺好的。

  菱形继承:由多继承构成的一种继承体系,继承关系如下。

  菱形继承就会造成问题:Assistant会有数据冗余和二义性,它其中有两份Person,那你要访问的时候究竟要访问学生中的Person还是Teacher中的Person呢?

  为了解决菱形继承造成的问题,C++又引入了复杂的虚拟继承,又是一个大坑。

  总之多继承就是一个设计的坑,尽量不要用,大部分语言直接删掉了多继承。

// 菱形继承二义性的问题
class Person
{
public:
	string _name; // 姓名
};

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

int main()
{
	Assistant a;
	a._id = 1;
	a._num = 2;
	a._name = "fkdfjsk";
	return 0;
}

  我们来看看a的结构:

  二义性可以通过指定作用域解决:

int main()
{
	Assistant a;
	a._id = 1;
	a._num = 2;
	a.Teacher::_name = "fkdfjsk";
	a.Student::_name = "hafkaf";
	return 0;
}

  我们这样勉强解决了二义性,那数据冗余呢,如果Person中有个大数组,本来我们只要一份,你菱形继承框框给我整两个大数组,很浪费空间。

  数据冗余和二义性还是要解决的。

2 虚继承解决数据冗余和二义性

  在腰部的继承位置增加virtual关键字。

  那么它的原理是什么呢?

3 虚继承的原理

  监视窗口的对象模型是优化过的,并不能直接表明对象的结构,为了看清虚拟内存中这个对象是怎么存储的,我们去看看内存。

  给一个更简单的继承关系,我们先看看不是虚拟继承的情况:

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

  打开内存窗口,调整一行为4个字节,输入&d找到d的位置:

  配合断点调试可以看出:

  先继承的在前面,后继承的在后面,最后是自己的成员。

  现在改成虚继承看看。

  只存了一份,但是B和C中新增了两个我们不认识的东西,我们的电脑是小端存储,所以它看起来可能是个地址,我们再调用一个内存窗口看看它们是什么。

  0x14就是20,0x0c就是12,这个东西其实是偏移量,是B和C的公共成员A的偏移量/相对距离

  为什么要偏移量呢,是为了给B和C能够找到公共的成员A在哪,在虚继承中,A一般叫虚基类,在D中,A放到一个公共的位置,有时候B需要找A,C需要找A就需要通过虚基表中的偏移量来计算找到到A的位置,总结如下图:

  那为什么需要找呢,考虑一下场景:

B b = d;
C c = d;

  这时候会发生切片,为了把d中B的东西切过去,需要找到d的B中a的东西,它被放在公共区域,就需要偏移量去找,如下图:

  我们也可以通过VS开发者命令行的查看对象模型命令找到对象的模型:

  首先打开Developer Command Prompt for VS 2019,然后:盘名切换盘,接着cd 项目路径,到达对应路径,最后,输入:cl /d1 reportSingleClassLayout类名 文件名即可显示对应对象模型。

4 虚继承的使用注意事项

  哪个类具有数据冗余和二义性的问题,那么要在它的父类产生的时候(继承的时候)进行虚继承。

八、关于继承的总结与反思

  • C++的语法很复杂,多继承及其出现的数据冗余和二义性的问题的虚继承设计的非常复杂,而大部分面向对象的语言都没有多继承语法,这就体现了C++设计上的缺陷(目前我们知道的另一个缺陷就是C++没有垃圾回收)、
  • 关于继承和组合,组合就是直接在某个类内定义一个其他类的成员,如:
class A;
class B
{
private:
    A _a;
    int b;
};

  也就是说,组合是一种has-a的关系,它也可以完成复用,而继承我们再熟悉不过了:

class A;
class B : public A;

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

  符合is-a关系的就适合用继承,符合has-a关系就适合用组合。如人和学生的关系,适合用继承;人和人的器官,人有这些器官。

  如果既是is-a关系又是has-a关系,优先用组合而不是继承,除非你要用到多态的性质

  在这篇文章中:优先使用类型组合,而不是类继承,分析了继承是一种白箱复用,即对于继承来的类来说,我们是可以看到父类的完整类成员的,而组合是一种黑箱复用,我们组合的类对象的私有成员和保护成员都是不可见的。

  白箱复用某种程度上破坏了类的封装,子类中的实现与它的父类有如此紧密的依赖关系,以至于父类实现中的任何变化必然会导致子类发生变化。当你需要复用子类时,实现上的依赖性就会产生一些问题。如果继承下来的实现不适合解决新的问题,则父类必须重写或被其他更适合的类替换。这种依赖关系限制了灵活性并最终限制了复用性。

  而在软件工程的设计中,希望类之间、模块之间满足低耦合高内聚,即每个模块中希望只有和自己有关的东西,每个模块之间的关联关系最好越弱越好,这样设计方便维护

  组合的耦合度更低(只有公有成员与另一类有关系),而继承的耦合度更高(全部成员都和父类有关系),所以组合在面向对象设计中是更好的。

  一般UML图就是来表示这些类之间的耦合等各种关系的,写项目或者了解项目一般就是先去了解模块及其关系,这样项目写出来一般比较符合设计经验,更多设计经验详见《设计模式》。

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值