文章目录
- 一、继承概念
- 二、继承定义
- 定义格式
- 继承后基类成员访问方式的变化
- 类模板的继承
- 三、基类和派⽣类间的转换(赋值兼容转换)
- 四、继承中的作用域
- 隐藏规则
- 两道笔试常考题
- 五、派生类的默认成员函数
- 四个常见默认成员函数
- 实现⼀个不能被继承的类
- 六、继承与友元
- 七、继承与静态成员
- 八、多继承及其菱形继承问题
- 继承模型
- 虚继承
- 九、继承和组合
一、继承概念
继承(inheritance)机制是⾯向对象程序设计使代码可以复⽤的最重要的⼿段,它允许我们在保持原有类特性的基础上进⾏扩展,增加⽅法(成员函数)和属性(成员变量),这样产⽣新的类,称派⽣类。继承呈现了⾯向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复⽤,继承是类设计层次的复⽤。
下面小编来举个例子,如果要创建两个类teacher和student,我们就可以把类公有的信息或者函数比如姓名/地址/电话/年龄等成员变量,都有identity⾝份认证的成员函数放在父类里,创建teacher和student时直接继承父类里的内容,把类独有的定义在各自的子类里,这样就可以简化代码,避免重复定义。
//父类
class Person
{
public:// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证void identity(){cout << "void identity()" << _name << endl;}
protected:string _name = "张三"; // 姓名string _address; // 地址string _tel; // 电话int _age = 18; // 年龄
};//子类
class Student : public Person
{
public:// 学习void study(){// ...}
protected:int _stuid; // 学号
};//子类
class Teacher : public Person
{
public:// 授课void teaching(){//...}
protected:string title; // 职称
};
二、继承定义
定义格式
下⾯我们看到Person是基类,也称作⽗类。Student是派⽣类,也称作⼦类。
继承方式有三种,和我们之前介绍的访问限定符同名。
继承后基类成员访问方式的变化
我们前面介绍了继承方式和访问限定符,那么父类的访问限定符里的内容继承到子类后在子类中的访问方式是怎样的呢?我们首先要清楚一共有9中排列组合的方式,因父类中的一种访问方式有三种继承方式,详情见下图:
回顾:类中的public成员是类里类外都可以访问,protect和private成员是类外不可访问,类里可以访问。
1、我们先看表格中最特别的最后一行,在基类的private成员不论什么继承方式在子类中都不可见,不可见是比private更高一级的限制,它是指不可见的内容在派生类的类里类外都无法访问,当然这里的无法访问不是绝对的,可以通过派生类里的共有或者保护成员函数间接访问,在实践中我们是很少把基类成员定义为私有的。
2、剩下的六种成员在派生类里的访问方式是将成员在基类的访问限定符和继承方式相比较取小,大小关系如下:public > protected > private
3、基类private成员在派⽣类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派⽣类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
4、继承方式也可以像访问限定符一样不显示写,使⽤关键字class定义派生类时默认的继承⽅式是private,使⽤struct时默认的继承⽅式是public,不过最好显⽰的写出继承⽅式。
5、在实际运⽤中⼀般使⽤都是public继承,⼏乎很少使⽤protetced/private继承,也不提倡使⽤protetced/private继承,因为protetced/private继承下来的成员都只能在派⽣类的类⾥⾯使⽤,实际中扩展维护性不强。
类模板的继承
在此之前小编先科普一下复用,复用有两种方式,一种的组合,也就是我们熟悉的容器适配器模式,类里面直接包含,我直接包含你比如stack类直接包含deque,有些大佬称之为has-a,还有一种就是继承,一个类继承于另一个类,我是一个特殊的你,这是is-a。
小编回到主题来讲类模板的继承,我们前面实现的普通类型父类继承到子类后子类是可以直接调用父类的函数的,但如果是类模板的话,子类是无法直接调用父类的成员函数的,因为父类是模板没有实例化成具体代码,所以编译器无法确定父类成员的合法性,需要显示声明函数的来源才能调用,比如下面通过访问限定符:
namespace bit
{//template<class T>//class vector//{};// stack和vector的关系,既符合is-a,也符合has-atemplate<class T>class stack : public vector<T>{public:void push(const T& x){// 基类是类模板时,需要指定⼀下类域,// 否则编译报错:error C3861: “push_back”: 找不到标识符// 因为stack<int>实例化时,也实例化vector<int>了// 但是模版是按需实例化,push_back等成员函数未实例化,所以找不到//push_back(x);vector<T>::push_back(x);}void pop(){vector<T>::pop_back();}const T& top(){return vector<T>::back();}bool empty(){return vector<T>::empty();}};
三、基类和派⽣类间的转换(赋值兼容转换)
首先我们要明确两个普通类是无法像下面三种方式一样转换的。
1、通常情况下我们把一个类型的对象赋值给另一个类型的指针或者引用时,不一般会发生构造+拷贝构造,存在类型转换,中间会产生临时对象,所以需要加 const,如:int a = 1; const double& d = a;。 在C++中,完全独立的类型是不支持隐式类型转换的,除非两个类有继承关系。public 继承中,就是一个特殊处理的例外,派生类对象可以赋值给基类的指针 / 基类的引用,而不需要加 const,(意味着没有产生临时对象)这里的指针和引用绑定是派生类对象中的基类部分,如下图所示。也就意味着一个基类的指针或者引用,可能指向基类对象,也可能指向派生类对象。
2、除了给引用和指针,子类对象也可以直接赋值给父类对象。派生类对象赋值给基类对象是通过基类的拷贝构造函数或者赋值重载函数完成的
(这两个函数的细节后面小节会细讲),这个过程就像派生类自己定义部分成员切掉了一样,所以也被叫做切割或者切片,如下图中所示。
3、基类对象不能赋值给派生类对象。
4、基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI (Run-Time Type Information) 的 dynamic_cast 来进行识别后进行安全转换。(我们后面在多态章节再细讲)
四、继承中的作用域
隐藏规则
- 在继承体系中基类和派⽣类都有独⽴的作⽤域,所以基类和派生类中可以定义同名成员,在派生类成员中访问同名成员时默认会先访问派生类的,派生类没有才回去访问基类的,如果只想访问基类的同名成员可以指定作用域访问。
- 派⽣类和基类中有同名成员,派⽣类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。成员变量隐藏的底层逻辑是作用域查找规则,因为访问一个变量时会去找它的定义,查找顺序是默认会先在派生类查找,派生类没有才会去基类找。(在派⽣类成员函数中,可以使⽤ 基类::基类成员 显⽰访问)
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏,成员函数隐藏的底层逻辑是函数隐藏规则,当派生类定义了一个与基类同名的函数(不管参数列表是否相同),基类中的同名函数就会被隐藏。我们以下面例题的代码为例,一旦在派生类中定义了 void fun(int i),基类中的 void fun() 在派生类的作用域内就不再可见,并且也编译器也不会主动再到基类里查找是否有匹配的函数。(除非使用作用域解析运算符 :: 显式指定调用基类版本 )。
- 注意在实际中在继承体系⾥⾯最好不要定义同名的成员。
两道笔试常考题
1、A和B类中的两个func构成什么关系()
A. 重载 B. 隐藏 C.没关系
2、下⾯程序的编译运⾏结果是什么()
A. 编译报错 B.运⾏报错 C. 正常运⾏
class A
{
public:void func(){cout << "func()" << endl;}
};class B : public A
{
public:void func(int i){cout << "func(int i)" << i << endl;}
};int main()
{B b;b.func(10);b.func();return 0;
};
首先要明确函数重载要求在同一作用域,这里显然在两个作用域,又因为两个函数同名,符合隐藏规则的第三点,所以第一题选B。
第二题我们根据隐藏规则的第三点,当调用 b.fun() 时,因为函数隐藏规则,编译器只会在派生类 B 的作用域内查找匹配的函数。由于在 B类中只定义了 void fun(int i) ,没有 void fun() ,所以编译器找不到匹配的函数,就会报错,选A。
五、派生类的默认成员函数
基类的默认成员函数符合我们在类和对象章节介绍的规则,这里我们来探讨派生类的默认成员函数。学习默认成员函数要从以下几个方面入手:我们不写编译器会不会自动生成、怎么生成?默认生成的符不符合我们预期?不符合预期话我们自己写要如何写?
四个常见默认成员函数
我们首先要认识到默认成员函数是用来处理成员变量的,派生类的默认成员函数的行为和普通类的默认成员函数高度相类似,只是派生类的成员变量有两部分,派生类的默认成员函数会把两个部分的成员变量分开处理,一个是继承自父类的基类成员,另一个是派生类自己定义的派生类成员,其中派生类成员的处理方式和普通类一样,并且是由派生类自己的的默认成员函数处理。基类成员会被打包看成一个整体,然后调用基类的默认成员函数,统一由基类的默认成员函数处理。可以简单理解成派生类相比普通类多了一个自定义类型成员。
小记:const成员、引用成员,没有默认构造的自定义类型成员都必须显示在构造函数的初始化列表初始化。(默认构造对其他成员可以随便给一个值,反正在类里还可以赋值修改,const成员和引用成员无法做到)
const成员、引用成员在类的作用域里只有一次给值也就是初始化的机会,引用成员规定不能先定义再初始化,因为引用只能引用一个成员无法变更去引用其他成员,const成员一旦定义后就无法更改了所以它俩都必须在定义时就初始化,在类里变量都要最先走初始化列表初始化,所以const成员、引用成员只能显示在初始化列表初始化。
在初始化列表阶段编译器会自动调用自定义类型成员的默认构造函数,如果这个自定义类型成员没有默认构造函数,编译器就无法完成自动初始化,构造函数体内部的代码是在所有成员都完成初始化之后才执行的,如果成员的类没有默认构造函数,编译器在进入函数体之前就会因无法初始化该成员而报错,(语法规定允许内置类型在进入构造函数体之前不初始化,自定义类型成员必须在进入构造函数体之前初始化)所以没有默认构造的自定义类型成员也必须在初始化列表显示初始化。
接下来我们以下面这段代码为例依次介绍派生类的其中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; //学号string _address;
};int main()
{Student s1;return 0;
}
- 构造函数
派⽣类的构造函数必须调⽤基类的默认构造函数初始化基类的那⼀部分成员。如果基类没有默认的构造函数,就相当于我们上面小记里的第三种特殊情况,则必须在派⽣类构造函数的初始化列表阶段显⽰调⽤。
上面这段代码因为基类有默认构造函数初始化派生类里的基类成员,代码不会报错,如果基类没有默认构造那么我们就需要自己显示写构造函数来初始化基类成员了,基类成员必须调用基类的构造函数初始化,并且调用只能走派生类构造函数的初始化列表,因为系统默认先初始化父类的成员(通过父类的构造函数),再初始化子类的成员,最后执行子类构造函数体的代码,(如果有成员未初始化是不会进入构造函数体内部的)而且只有这样可以避免先初始化子类成员导致访问未初始化的父类资源,引发未定义行为。这也和我们下面介绍的析构函数顺序对称。
class Student : public Person
{
public:Student(const char* name, int num, const char* address)// 显示调用基类构造函数初始化基类成员 :Person(name) //:_name(name) 错误写法,_num(num),_address(address){ }protected:int _num; //学号string _address;
};
(自己想的补充:两个独立的类之间,不能像子类调用父类构造函数那样
“跨类初始化成员”,但可以在一个类的初始化列表中调用另一个类的构造函数,来初始化自身包含的该类类型成员。)
- 拷贝构造
派⽣类的拷⻉构造函数必须调⽤基类的拷⻉构造完成基类的拷⻉初始化。一般拷贝构造都不需熬我们自己写,除非需要深拷贝。构造函数一般都要自己写,因为需要传参去构造。
派⽣类的拷⻉构造函数拷贝父类成员就需要我们在派生类中传一个父类对象的引用去调用父类的拷⻉构造函数,派生类调用基类拷贝构造过程中不会产生临时对象,首先赋值兼容转换不会产生,然后调用基类拷贝构造时因为是同类型直接构造也不会产生临时对象。
下面的例子其实不用手动写编译器默认生成的就够用了,这里小编只是演示一下如果需要自己手动写要如何写。
Student(const Student& s): Person(s) //调用父类拷贝构造(赋值兼容转换支持), _num(s._num), _address(s._address)
{//编译器自动生成的就够用了//存在深拷贝才自己写
}
- 赋值运算符重载
- 派⽣类的operator=必须要调⽤基类的operator=完成基类的复制。需要注意的是派⽣类和基类的赋值运算符重载是同名函数,所以派⽣类的operator=隐藏了基类的operator=,所以显⽰调⽤基类的operator=,需要指定基类作⽤域。
//因为要判断避免自己给自己赋值所以不走初始化列表
Student& operator=(const Student& s)
{if (this != &s){Person::operator=(s); //同名函数必须指定作用域_num = s._num;_address = s._address;}return *this;
}
- 析构函数
这里要注意一点派生类的析构函数规则和前面三个成员函数不同,派生类的析构函数只用显示释放派生类自己创建的资源,如果有资源的话。因为派生类的析构执行完毕后会自动调用基类的析构,如果在派生类的析构函数中再显示释放基类资源的话就会释放两次。
析构资源的顺序和构造正好相反,先析构子类,再析构父类。如果先析构父类那么就有可能因为在子类中访问父类被析构的成员而报错,若先析构子类的话父类是访问不到子类的成员的。
所以这里我们也明白了为什么基类析构函数是自动调用。系统为了保证先析构子类,再析构父类这个顺序,所以在执行完派生类构造函数后会去自动调用基类的构造函数,如果在派生类构造函数中显示析构父类和派生类的话这个顺序就无法保证。
实现⼀个不能被继承的类
⽅法1:基类的构造函数私有,派⽣类的基类成员必须调⽤基类的构造函数初始化,但是基类的构造函数私有化以后,派⽣类看不⻅无论是显示还是编译器自动都无法调用到基类的构造函数了,那么派⽣类就⽆法实例化出对象。(语法规定实例化对象必须经过两个步骤:分配内存和初始化成员)这样将基类的构造函数私有后基类也无法实例化出对象了。
⽅法2:C++11新增了⼀个final关键字,final修改基类,派⽣类就不能继承了。
// C++11的⽅法
class Base final
{
public:void func5() { cout << "Base::func5" << endl; }
protected:int a = 1;
private:// C++98的⽅法//Base()//{}
};class Derive :public Base
{void func4() { cout << "Derive::func4" << endl; }
protected:int b = 2;
};int main()
{Base b;Derive d;return 0;
}
六、继承与友元
友元关系不能继承,也就是说基类友元不能访问派⽣类私有和保护成员。解决方法就是让基类友元也成为派⽣类的友元。
//前置声明
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; //无法访问
}
int main()
{Person p;Student s;// 编译报错:error C2248: “Student::_stuNum”: ⽆法访问 protected 成员// 解决⽅案:Display也变成Student 的友元即可Display(p, s);return 0;
}
七、继承与静态成员
静态成员会被继承。基类定义了static静态成员,则整个继承体系⾥⾯只有⼀个这样的成员。⽆论派⽣出多少个派⽣类,都只有⼀个static成员实例。
class Person
{
public:string _name;static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum;
};
int main()
{Person p;Student s;// 这⾥的运⾏结果可以看到⾮静态成员_name的地址是不⼀样的// 说明派⽣类继承下来了,⽗派⽣类对象各有⼀份cout << &p._name << endl;cout << &s._name << endl;// 这⾥的运⾏结果可以看到静态成员_count的地址是⼀样的// 说明派⽣类和基类共⽤同⼀份静态成员cout << &p._count << endl;cout << &s._count << endl;// 公有的情况下,⽗派⽣类指定类域都可以访问静态成员cout << Person::_count << endl;cout << Student::_count << endl;return 0;
}
八、多继承及其菱形继承问题
继承模型
单继承:⼀个派⽣类只有⼀个直接基类时称这个继承关系为单继承
多继承:⼀个派⽣类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前⾯,后⾯继承的基类在后⾯,派⽣类成员在放到最后⾯。
菱形继承:菱形继承是多继承的⼀种特殊情况。菱形继承的问题,从下⾯的对象成员模型构造,可以看出菱形继承有数据冗余和⼆义性的问题,在Assistant的对象中Person成员会有两份。⽀持多继承就⼀定会有菱形继承,像Java就直接不⽀持多继承,规避掉了这⾥的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。
数据冗余是指同一个基类的数据在派生类中存在多份拷贝,浪费内存空间,也可能导致数据不一致。
⼆义性小编用下面的例子解释,当Assistant对象访问基类Person对象成员_name时编译器无法确定应该访问来自Student的_name还是Teacher的。
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()
{// 编译报错:error C2385: 对“_name”的访问不明确Assistant a;//a._name = "peter";// 需要显⽰指定访问哪个基类的成员可以解决⼆义性问题,但是数据冗余问题⽆法解决a.Student::_name = "xxx";a.Teacher::_name = "yyy";return 0;
}
虚继承
虚继承是用来解决菱形继承数据冗余和⼆义性的。
很多⼈说C++语法复杂,其实多继承就是⼀个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂,性能也会有⼀些损失,所以最好不要设计出菱形继承。多继承可以认为是C++的缺陷之⼀,后来的⼀些编程语⾔都没有多继承,如Java。
虚继承格式是在继承方式符前面加virtual,例子如下。虚继承应在父类派生多个子类时加,多个父类派生一个子类是正常的多继承,不用加。
虚继承的底层原理小编大致说一下,就相当于把person 从student和teacher里拿出来,放在Assistant对象整体的开头或者结尾,具体看编译器。
class Person
{
public:string _name; // 姓名/*int _tel;* int _age;string _gender;string _address;*/// ...
};// 使⽤虚继承Person类
class Student : virtual public Person
{
protected:int _num; //学号
};// 使⽤虚继承Person类
class Teacher : virtual public Person
{
protected:int _id; // 职⼯编号
};// 教授助理
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};int main()
{// 使⽤虚继承,可以解决数据冗余和⼆义性Assistant a;a._name = "peter";return 0;
}
九、继承和组合
继承和组合都是复用思想的体现。
1、public继承是⼀种is-a的关系。也就是说每个派⽣类对象都是⼀个基类对象。
2、组合是⼀种has-a的关系。假设B组合了A,每个B对象中都有⼀个A对象。例如容器适配器,stack里有一个deque。
3、继承允许你根据基类的实现来定义派⽣类的实现。(因为一般都是共有继承,子类能访问父类成员)这种通过⽣成派⽣类的复⽤通常被称为⽩箱复⽤(white-box reuse)。术语“⽩箱”是相对可视性⽽⾔:在继承⽅式中,基类的内部细节对派⽣类可⻅
。继承⼀定程度破坏了基类的封装,基类的改变,对派⽣类有很⼤的影响。派⽣类和基类间的依赖关系很强,耦合度⾼。
4、对象组合是类继承之外的另⼀种复⽤选择。新的更复杂的功能可以通过组装或组合对来获得。对象组合要求被组合的对象具有良好定义的接⼝。这种复⽤⻛格被称为⿊箱复(black-box reuse),因为对象的内部细节是不可⻅的。对象只以“⿊箱”的形式出现。
组类之间没有很强的依赖关系,耦合度低。优先使⽤对象组合有助于你保持每个类被封装。
5、优先使⽤组合,⽽不是继承。实际尽量多去⽤组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系适合继承(is-a)那就⽤继承,另外要实现多态,也必须要继承。类之间的关系既适合⽤继承(is-a)也适合组合(has-a),就⽤组合。
以上就是小编分享的全部内容了,如果觉得不错还请留下免费的关注和收藏如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~