您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center   Code  
会员   
   
 
     
   
 
 订阅
C++继承,万字笔记简单易懂
 
作者:寄一片海给你
  1507  次浏览      17 次
 2022-5-7
 
编辑推荐:
本文对我们在实际生产中尝试这一技术方案时,遇到的问题与一些实践经验做了完整的梳理,希望能为大家提供一些参考或帮助。
文章来自于51CTO,由火龙果Alice编辑推荐。

开篇一览

继承的定义

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类(基类)特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。在继承之前一般都是函数的复用,而继承是类层次的复用。

简单来说继承本质上就是代码复用,而且是类层次的复用,子类继承父类,子类可以复用父类的成员(成员变量和成员函数)。基类又称为父类,派生类又称为子类

比如要定义一个学生类和一个老师类实现相应的功能,而两个类都有很多相同的成员变量,实现起来也会产生相同的成员函数,那么我们就可以将这些公有的成员封装成父类,然后学生和老师封装成子类

//基类(父类)
class Person
{
public:
void print()
{
cout << _age << endl;
}
protected:
string _name; // 姓名
int _age; // 年龄
};
//派生类(子类) /继承方式 /(基类)父类
class Student : public Person
{
protected:
string _number;//学号
};
class Teacher : public Person
{
protected:
string _position;//职称
};

int main()
{
Student s;
s.print();
Teacher t;
t.print();
}

这里Person称为父类,也称为基类,Student和Teacher称为子类,也称为派生类

继承关系和访问限定符

记住这个表格只需要掌握两点:

基类的private成员在派生类中无论以什么方式继承都不可见,这里的不可见不是派生类没有继承,而是派生类对象不管是在类里面还是类外都无法访问

基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式),权限:public > protected > private

补充:

关键字class默认的继承方式是private,关键字struct默认的继承方式是public,和访问限定符类似

实际中一般都用public继承,且访问限定符一般用public和protected,因为在没有继承时,访问限定符private和protected作用类似,但有继承后,如果基类是private成员,那不管以什么方式继承,在派生类中都不可见

基类和派生类的赋值转换

派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这一现象叫切片或者切割。寓意把派生类中父类那部分切来赋值过去,前提条件是:派生类必须是public继承,因为protected和private继承就涉及权限的转化了

子类赋值给父类是赋值兼容的,不存在类型的转换(通过下面的传引用不需要加const就可看出)

而父类对象无法赋值给子类对像,父类只能由指针和引用强转才能赋值给子类,这应会存在越界访问(ps:特殊情况下可以使用dynamic cast进行安全转换,之后的多态文章会讲解)

class Person
{
protected:
string _name; // 姓名
string _sex;//性别
int _age; // 年龄
};
class Student : public Person
{
private:
string _number;//学号
};
int main()
{
Person p;
Student s;
//父类=子类,赋值兼容,叫切片,不存在类型转换,是天然的
p = s;
Person* ptr = &s;
Person& ref = s;

//子类=父类只能强转
Student* ps=(Student*)& p;
Student& st=(Student&)p;
return 0;
}

这里的切片是十分有意义的,在多态十分重要

继承中的作用域

每个类都是一个独立的作用域,在继承体系中,基类和派生类都有独立的作用域

子类和父类都有同名的成员,子类将屏蔽对父类同名成员的直接访问(访问父类的隐藏成员时需要加访问限定符),叫做隐藏也叫重定义

如果是成员函数的隐藏,只需要函数名相同就构成隐藏,参数可相同也可不相同

class A
{
public:
void func()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void func(int i)
{
A::func();
cout << "func(int i)->" << i << endl;
}
};
void Test1()
{
B b;
b.fun(10);
};
void Test2()
{
B b;
b.fun();
}

笔试题:这里Test1与Test2的结果分别是什么?(随机组合)

打印func(int i)->10

打印func()

报错

首先两个func不构成函数重载,因为不在同一作用域,所以是构成隐藏,父类的func在子类中被隐藏了,所以Test2直接调用父类的func会报错

b.A::fun();//指定作用域调父类的fun

派生类的默认成员函数

移动构造和移动赋值暂且不讨论

这里重点讨论构造、拷贝构造、析构和赋值

我们不写默认生成的派生类的构造和析构

从父类继承下来的(调用父类的默认构造和析构)

子类中自己的(对内置类型不处理,对自定义类型调用它的默认构造和析构)

我们不写默认生成的派生类的拷贝构造和operator=

从父类继承下来的(调用父类的默认拷贝构造和operator=)

自己的(对内置类型完成浅拷贝,对自定义类型调用它的默认拷贝构造和operator=)

总结:从父类继承下来的调用父类的处理,自己的按照普通类处理

class Person
{
public:
Person(const char* name = "Bob")
: _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 = "peter", int num = 1)
:Person(name)//利用匿名对象调用父类的构造函数初始化
,_num(num)
{}
Student(const Student& s)
:Person(s)//调用父类的拷贝构造
,_num(s._num)
{
cout << "Student(const Student&)" << endl;
}
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s);//切片
_num = s._num;
}
return *this;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num; //学号
};
int main()
{
Student s1;
Student s2(s1);
Student s3("Bob", 20);
s2 = s3;

return 0;
}

这里单独讨论一下子类的析构函数

由于调用构造和析构的时机与栈后进先出的原则类似,先构造的后析构,后构造的先析构,当我们创建子类对象时,父类先构造,之后才构造子类自己的,所以析构的时候父类需要后析构,如果父类析构的调用交给我们完成的话可能会影响调用顺序,所以父类的析构是交给编译器完成的,所以在子类的析构函数中不需要我们显示调用父类的析构函数。

ps:就算要手动调用父类的析构函数,也需要加访问限定符指定作用域,因为析构函数名\~Person()和~Student()会被统一处理成destructor(),所以子类和父类的函数名相同就构成隐藏,所以需要指定作用域

~Student()
{
Person::~Person();
cout << "~Student()" << endl;
}

这里我们没有显示调用父类的析构函数,编译器会自动调用

显示调用后就会多调用一次,同一块空间析构两次就会报错

继承和友元&&继承和静态成员

在普通类中友元是单向的,我是你的friend,但你不一定是我的friend,继承中同样如此。

Display虽然是Person的友元,虽然Student继承了Person,但Display不是Student的友元,所以这里是无法调用s中的_stuNum的

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;
}
int main()
{
Person p;
Student s;
Display(p);
}

基类定义了static静态成员也和普通类的类似,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例(可以通过静态成员计算构造了多少个对象)

菱形继承

单继承:只有一个父亲

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

菱形继承是多继承的一种特殊情况,只要有公共祖先就是菱形继承

可以发现Student和Teacher中都有类Person,Assistant继承了Student和Teacher后就会有数据冗余和二义性

class Person
{
public:
string _name; // 姓名
int _arr[1000];
};

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

Assistant类有两份Person类即数据冗余,Assistant中就会有两个_name即二义性,这里指定_name后虽然可以消除二义性但是解决不了数据冗余问题

虚继承

要解决菱形继承中的数据冗余和二义性就引入了虚拟继承,在继承体系的腰部加关键字virtual。

这里的腰部是指:直接在继承公共祖先的两个类加virtual,因为数据冗余和二义性是因为Student和Teacher都存在的一份Person,所以在这两个类加virtual

ps:这也是菱形继承,是在B和D加virtual

通过虚继承后就可以解决数据冗余和二义性的问题了

虚继承原理

下面通过一个简单的代码测试虚继承是如何解决数据冗余和二义性的

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; //不加virtual时
cout << sizeof(A) << endl; // _a
cout << sizeof(B) << endl; //_a _b
cout << sizeof(C) << endl; // _a _c
cout << sizeof(D) << endl; //_a _a _b _c _d

return 0;
}

没有采用虚继承时,d因为继承了B和C,所以有两个A类的_a

采用虚继承后,d继承了B类中的_b和C类中的_c,然后将A类的_a(公共的)放在了最后。

可以看到B类方框里不仅放了_b的值,还有一个地址,C类也是如此,通过这个地址就可以找到虚基表,虚基表里存放了当前位置相对于公共类A的偏移量,通过偏移量就可以找到公共的A。这样d只需要继承一份A,解决了数据冗余和二义性问题

继承总结

继承中通常采用public继承,且成员一般是public和protected的

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

组合是一种has-a的关系,B组合A,每个B对象都有一个A对象。比如汽车组合轮胎,汽车和轮胎就是has-a的关系

class A
{
private:
int _a;
};
//B和A是has-a关系
class B
{
private:
A _aa;
int _b;
};

通常完全符合is-a的关系就用继承,完全符合has-a的关系就用组合,如果两种关系都符合的话会优先使用对象组合而不是类继承。

使用类继承,派生类和基类的依赖关系很强,耦合度高,改变基类就会影响派生类

而组合类之间没有很强的依赖关系,耦合度底。

实际开发中肯定是希望低耦合、高内聚的,每个模块之间不会产生很强的依赖关系,也方便维护。

既然组合那么好,能不能直接抛弃继承都用组合呢?

虽然组合的耦合度底,代码维护性好,但继承也不是没有用武之地的,继承作为面向对象的三大基本特性之一是不可或缺的,有些关系是is-a而不适合has-a就需要用继承,且要实现多态就需要继承,因为多态是建立在继承的基础之上

常见笔试面试题

什么是菱形继承?菱形继承的问题是什么?

什么是菱形虚拟继承?如何解决数据冗余和二义性的

继承和组合的区别?什么时候用继承?什么时候用组合?

菱形继承是特殊的多继承,当多继承中有公共的祖先时就是菱形继承。菱形继承的问题就是数据冗余和二义性

菱形虚拟继承是解决菱形继承存在问题的一种继承方式,在直接继承公共祖先的两个类加上关键字virtual构成虚拟继承。正因为有公共祖先所以存在继承多份祖先类的问题,也就存在数据冗余和二义性,所以将祖先类放在一个公共位置,派生类存放当了对应的虚基表地址,虚基表中存放了当前位置相对祖先类的偏移量,根据偏移量就可以找到祖先类,从而解决了数据冗余和二义性的问题

第三点在继承总结已经详细说明了

   
1507 次浏览       17
相关文章

深度解析:清理烂代码
如何编写出拥抱变化的代码
重构-使代码更简洁优美
团队项目开发"编码规范"系列文章
相关文档

重构-改善既有代码的设计
软件重构v2
代码整洁之道
高质量编程规范
相关课程

基于HTML5客户端、Web端的应用开发
HTML 5+CSS 开发
嵌入式C高质量编程
C++高级编程

最新活动计划
MBSE(基于模型的系统工程)4-18[北京]
自然语言处理(NLP) 4-25[北京]
基于 UML 和EA进行分析设计 4-29[北京]
以用户为中心的软件界面设计 5-16[北京]
DoDAF规范、模型与实例 5-23[北京]
信息架构建模(基于UML+EA)5-29[北京]
 
 
最新文章
.NET Core 3.0 正式公布:新特性详细解读
.NET Core部署中你不了解的框架依赖与独立部署
C# event线程安全
简析 .NET Core 构成体系
C#技术漫谈之垃圾回收机制(GC)
最新课程
.Net应用开发
C#高级开发技术
.NET 架构设计与调试优化
ASP.NET Core Web 开发
ASP.Net MVC框架原理与应用开发
成功案例
航天科工集团子公司 DotNet企业级应用设计与开发
日照港集 .NET Framewor
神华信 .NET单元测试
台达电子 .NET程序设计与开发
神华信息 .NET单元测试