文章目录
- 前言
- 一、继承的概念及定义
- 1.概念
- 2.继承定义
- 定义格式
- 继承关系和访问限定
- 继承基类成员访问方式的变化
- 二、基类和派生类对象赋值转换
- 三、继承中的作用域
- 四、派生类的默认成员函数
- 五、继承与友元
- 六、继承与静态成员
- 七、复杂的菱形继承及菱形虚拟继承
- 1.单继承与多继承
- 2.菱形继承的问题
- 3.虚拟继承
- 4.虚拟继承的模型及原理
- 八、继承的总结和反思
- 继承反思
- 继承与组合
前言
万物皆对象,对象是具体的世界事物,面向对象的三大特征封装,继承
,多态
,封装
,封装说明一个类行为和属性与其他类的关系,低耦合,高内聚
;继承是父类和子类的关系,多态说的是类与类的关系。
前边我们对封装有了一个系统的认识,今天我们来学习继承
的相关内容。
一、继承的概念及定义
1.概念
继承(inheritance)机制
是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保
持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类
。继承呈现了面向对象
程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用
,继
承是类设计层次的复用
。
概念看起来不太好懂,我来举一个例子,让大家了解到继承是怎么样的。比如在一个学校管理系统里边,有很多不同的角色,例如老师,学生,保安,宿管阿姨,每实现一个角色都得去实现一个类,但是这些类里边有很多属性是相同的,例如这些角色都是人,他们都有人的属性,例如姓名,年龄,电话号等等,所以当我们需要把这些共有的属性实现为一个类,在实现每个不同的角色时进行继承,就可以节省空间,也可以使代码简洁。
class person
{
public:
char* _name;
int _age;
};
//进行继承
class student : public person
{
public:
int _studentId;
};
class teacher : public person
{
public:
int _workId;
};
为了验证上述的代码,我们来调试观察一下:
我们发现在学生类的老师类中也有person类的属性,这也就是继承成功了。
2.继承定义
定义格式
在上述的例子中,我们把person类称为基类
,也叫父类
,把继承父类的类称为子类
,或者派生类
,但是大家切记,子类和父类是对应的,派生类和基类对应的。
在继承时应该遵循下图的格式:
继承关系和访问限定
C++的继承关系和访问限定比较复杂,这是因为C++是早期开始面向对象的语言,所以在设计上难免有不妥当的地方,但是语言必须向前兼容,所以C++的继承关系和访问限定复杂就留了下来,下边来看他们:
继承基类成员访问方式的变化
由于在一个类中,访问限定有三种,分别是public,protected,private
。
而一个类在继承父类时,又有三种继承方式,分别是public,protested,private
。
所以按道理来说,在继承之后,子类中应该有九种情况要分别讨论:
如果让我们背过这几种关系也并不难,但是千万不能死记硬背,而是去寻找规律的记忆他们。
通过观察,我们应该可以得到以下的结论:
1.基类中的私有成员
,在继承到派生类之后,都在派生类中不可见
。这里的不可见是指基类的私
有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面
都不能去访问它。
2.我们认为权限的大小为public>protected>provate
,有了这样的公式,我们发现当类成员的访问限定符与在子类中的继承方式相遇时,最后在子类中的访问关系取他们之中权限较小
的一个,例如基类的public成员使用pretected方式继承,最终派生类中的成员的访问属性就是protected的。
3.class类和struct结构体如果没有写出继承方式,默认class与默认权限一样,都是private
,struct默认继承方式和默认权限相同,都是public
,但是最好写出继承方式。
代码示例:
上边的例子,当class不显式的给定继承关系时,会默认采用私有继承,但是私有继承的成员在子类中是不可见的,所以下边访问他们就报错。
4.在实际的使用中,并不会经常用到private/protected
继承,而大多数只会使用公有继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里
面使用,实际中扩展维护性不强。
二、基类和派生类对象赋值转换
1.派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用
。这里有个形象的说法叫切片
或者切割
。寓意把派生类中父类那部分切来赋值过去。
2.基类对象不能赋值给派生类对象
。
3.基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类
的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(RunTime Type Information)的dynamic_cast 来进行识别后进行安全转换。(这些东西后续的多态章节会讲到)。
可能在看完文字描述之后大家还不是很清楚,我们来通过图片看一下什么叫做切片:
当我们使用person继承得到一个student类时,在公有继承的情况下,我们会发现,子类成员有了父类成员所有的属性,除过私有属性,这时,我们就可以通过赋值转换,也叫切片技术来使用子类给父类赋值。
class Person
{
protected:
string _name; // 姓名
string _sex; //性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
void Test()
{
Student sobj;
// 1.子类对象可以赋值给父类对象/指针/引用
Person pobj = sobj;
Person* pp = &sobj;
Person& rp = sobj;
//2.基类对象不能赋值给派生类对象
//sobj = pobj;
// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
pp = &sobj;
Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
ps1->_No = 10;
pp = &pobj;
Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
ps2->_No = 10;
}
通过上述的代码我们发现:
1.子类对象可以给父类对象赋值,也可以是指针和引用。
2.而父类不能给子类赋值,这点应该很容易想清楚,父类成员数量一定是小于等于子类的,所以不能对子类赋值。
3.父类指针可以通过强转赋值给子类的指针,但是可以会越界,其实指针只是一段地址,即使他们的地址相同,但是指针类型的不同意味着他们能往后看到空间的大小不同,理解这一点是很重要的。
三、继承中的作用域
1.父类和子类都有自己的作用域。
2.如果父类和子类有成员变量名是相同
的,也是可以存在的,但是我们直接访问,访问到的是子类中的成员,如果想访问到父类继承下来的成员,必须指定作用域
。
3.函数变量在继承时,只要函数名相同就会构成隐藏
,并不会判断函数的参数是否相同。
我们先通过一段程序来验证一下,在父类和子类中有相同的成员变量,直接访问就是子类的变量。
class Person
{
protected:
string _name = "小李迪"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 学号:" << _num << endl; // 999
cout << " 身份证号:" << Person::_num << endl; // 111
}
protected:
int _num = 999; // 学号
};
接下来继续通过程序来验证一下函数名相同时的情况:
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "func(int i)->" << i << endl;
}
};
int main()
{
B b;
b.fun(10);
b.A::fun();
return 0;
}
我们先来思考一下问题,这两个函数名相同的函数构成函数重载吗?答案肯定不构成,因为函数重载的前提是在同一作用域,而这里分为父类和子类两个作用域。此处的两个同名函数构成隐藏。
四、派生类的默认成员函数
在前边的类与对象章节学习过,C++类中有六个默认成员函数
,但是最常用的是前四个默认成员函数,也是我们要重点学习的。
我们可以认为,子类继承时,对父类的操作都必须把父类成员看成一个整体,会调用父类的相关函数,而子类成员调用子类相关函数,所以可以把自己看做是合成形成的。
- 默认的构造函数对内置类型不做处理,自定义类型回去调用他们的构造函数,而对于从父类继承的变量,会回去调用他们父类的构造函数。派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能
保证派生类对象先清理派生类成员再清理基类成员的顺序。 - 派生类对象初始化先调用基类构造再调派生类构造。
- 派生类对象析构清理先调用派生类析构再调基类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲
解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加
virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
(1)派生类的构造函数
class A
{
public:
A(const string& name)
//:_name(name)
{}
string _name;
};
class B : public A
{
public:
B(int age = 18)
//:A("tmt")
:_age(age)
{
}
int _age;
};
int main()
{
B b;
return 0;
}
如果父类没有默认构造函数,就必须在子类显式在初始化列表显式调用。
class A
{
public:
A(const string& name)
:_name(name)
{}
string _name;
};
class B : public A
{
public:
B(int age = 18,string name = "tmt")
:A(name)
,_age(age)
{
}
int _age;
};
int main()
{
B b;
return 0;
}
(2)子类的析构函数
子类函数的析构函数,对于自定义类型去调用它的析构函数,对于内置类型不做处理,对于继承的成员回去调用父类的析构函数。但是不用去显式调用父类的析构函数,为了保证父类成员先构造先析构,我们不能去主动析构父类成员,而是在子类的析构函数后自动回去调用父类析构函数。
并且,还有一个细节,就是父子类的析构函数会构成隐藏,虽然我们看到函数名称不同,但是编译器会将他们都处理成constructer,所以构成隐藏,调用时必须加上域作用符。
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:
Student(const char* name ,int num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num; //学号
};
void Test()
{
Student s1("张三",18);
Student s3("王五",17);
}
(3)子类的拷贝构造函数
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:
Student(const char* name ,int num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s)
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num; //学号
};
void Test()
{
Student s1("张三",18);
Student s2(s1);
}
(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:
Student(const char* name ,int num)
: Person(name)
, _num(num)
{
cout << "Student()" << 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()
{
cout << "~Student()" << endl;
}
protected:
int _num; //学号
};
void Test()
{
Student s1("张三",18);
Student s2("李四",19);
s2 = s1;
}
五、继承与友元
友元不能继承,也就是说基类的友元函数内部不能访问子类的私有和保护成员。
//后边会使用,所以先声明
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;
}
int main()
{
Student s;
Person p;
Display(p, s);
}
六、继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子
类,都只有一个static成员实例 。也就是说,即使子类继承了父类的静态成员,静态成员也只有一份,存在静态区,不属于任何一个对象。
下边通过一段程序来验证一下,当我们通过student类来修改人数时,我们来观察person类的count是否会发生变化:
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; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数 :" << Person::_count << endl;
Student::_count = 0;
cout << " 人数 :" << Person::_count << endl;
}
七、复杂的菱形继承及菱形虚拟继承
1.单继承与多继承
单继承:
一个子类只有一个直接父类的继承方式,叫做单继承。
多继承:
友两个或两个以上的直接父类的继承方式,叫做多继承。
菱形继承:
是多继承的一种特殊情况,继承图形好像一个菱形。
2.菱形继承的问题
但是菱形继承势必会出现许多的问题,当student和teacher类继承了person类的成员后,assistant类再来继承他们,这是person类中的内容就会被继承两份,这肯定会造成代码冗余和二义性的问题。
为了解决二义性的问题,我们可以通过指定作用域来进行对重复部分的赋值和查看:
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
但是代码冗余的问题还是没有解决,所以我们要引入虚拟继承。
3.虚拟继承
使用virtual关键字就可以实现虚拟继承,在继承方式前加上virtual关键字,这个继承就成为虚拟继承,但是切记在菱形继承的问题中,应该是在父类为同一个类的两个子类继承时变为虚拟继承。
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在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 = "peter";
}
此时再去直接访问就不会报错了,因为只生成一份公有的成员,不论指定哪一个作用域,都是同一份。
4.虚拟继承的模型及原理
我们通过以下的程序通过内存和监视窗口来探究一下,虚继承的模型。
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._a = 1;
d._a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
通过调试,我们可以很清楚的看到,_a这个共有的成员,首先被存储在了最后的位置,然后再去按顺序存储其他的变量,但是在内存界面,有两个看不懂的东西是什么,其实那是地址,地址存储什么呢?我们再来看看:
十六进制的14就是十进制的20,而十六进制的C就是十进制的12,那么他们代表什么意思呢?其实是代表了他们与公有成员的距离。
所以虚拟继承的类,有以下的模型:
有个上边模型的理解,再来理解每个类的大小,就很容易了:
由于虚拟继承的类会存储一个地址,该地址存储有关虚基类的相关内容,所以大小会发生变化。
下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下
面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指
向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量
可以找到下面的A。
八、继承的总结和反思
继承反思
- 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱
形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设
计出菱形继承。否则在复杂度及性能上都有问题。 - 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
继承与组合
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
例如:我们可以说玫瑰花是一种植物,玫瑰花一定继承了植物的相关属性,他们是一种is-a的关系,还有人和动物也是is-a的关系,但是生活中也有很多has-a的关系,例如车和轮胎的关系就是,车里边有轮胎。
总结:
优先使用对象组合,而不是类继承 。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称
为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的
内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很
大的影响。派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象
来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复
用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被
封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有
些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用
继承,可以用组合,就用组合。