C语音是面向过程的语言,而C++在其之上多了面向对象的特性,面向对象三大特性:封装性、继承性、多态性。今天主包来讲讲自己学到的关于C++继承特性的知识。
一、继承是什么
继承是提高代码复用的一种重要手段。正如C++的模版、泛型编程等等都是为了实现代码复用,避免我们重复写一些代码使得整体结构冗余。比如我们定义了一个类:Person,那么Person类该有的一些成员比如姓名,身份证号、年龄等等。那么现在我们想要有一个学生类Student,但是我们发现学生也有和Person类共有的一些特征比如姓名年龄等,但是学生类跟Person类不同肯定有其独特的地方,比如学生有学号、年级、成绩等等特性。 这时候为了避免重复写一些共有的成员,我们就使用继承的方式来使代码更加简洁。
我们定义的Student类要继承Person类,那么我们就说Student是Person的子类或者派生类。而Person类是被Student继承了,那么这时候我们成Person类为父类或基类。
二、怎么实现继承
1、继承的格式
我们就以上面提到的Person类与Student类来举例说明
class Student: public Person
{
public:
//学生特有
int _id; //学号
float _score; //考试成绩
int grade; //年级
//...
}
首先,Student为派生类,Person为基类, 定义派生类时后面加上":",表示继承,而public表示继承方式,相应地,共有public、protected、private三种继承方式,,当然实际使用我们一般以public继承方式为主每种继承方式都会有着不同的特性,这个我们下个模块再讲。
2、一个完整的继承案例
我们还是以上面的Person类与Student类为例。
首先定义一个基类Person
class Person
{
public:
//查询姓名
void FindName()
{
cout << _name << endl;
}
//查询年龄
void FindAge()
{
cout << _age << endl;
}
protected:
string _name = "张三"; //姓名
int _age = 18; //年龄
string _id; //身份证号
string _email; //电子邮箱
};
接着我们要搞一个派生类Student,Student类相比Person类多了学号、年级、分数等信息。
class Student :public Person
{
public:
//查询学号
void FindStuid()
{
cout << _stuid << endl;
}
//查询年级
void FindGrade()
{
cout << _grade << endl;
}
protected:
int _stuid; //学号
float _score = 89.5; //分数
string _grade = "大二"; //年级
};
我们发现在子类中没有出现父类中的成员变量及成员函数,但是由于我们已经继承了Person类所以我们可以在某些情况下访问父类中的成员变量及成员函数,可以看到通过继承的方式明细提高了我们的代码复用,整体看着也更加清晰明了。
通过测试,我们发现子类中虽然没有明确写出其关于姓名、年龄等成员变量及访问方式,但通过继承的方式,子类也具有与其父类相同的成员变量及成员函数。
三、继承的一些规则
1、继承基类成员访问方式的变化
上面我们提到,继承方式有public、protected、private三种。那么这三种不同的继承方式当然会有区别了。
类成员\继承方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
那么,这个表格什么意思呢,我们一一来看。
首先看最后一行,基类中的成员都是private的,那么这时候对于派生类对象,他虽然继承了基类的成员,但他是不能够访问基类的成员,无论是在类里面还是在类外面。基类private成员在派⽣类中⽆论以什么⽅式继承都是不可⻅的。
例如,当基类成员访问限定符是protected时,在派生类中进行测试,发现能访问。
当将基类成员访问限定符改为private后,就会出现错误,当然我们非要想访问也不是不可以,在基类中写一个public类型的get函数来间接访问。
而当基类的成员是public类型限定时,那么派生类继承后这个成员在派生类中的访问限定方式就是继承方式,比如基类成员是public类型,你使用private的继承方式,那么在派生类中他就是private成员。同样对于基类的protected成员,我们发现继承方式是public时,在派生类中就是public限定,继承方式是protected,在派生类中就是protected限定,继承方式是private,在派生类中就是private。我们要知道,访问限定符权限由大到小是:public、protected、private。struct定义类时默认继承方式是public,class定义类时默认继承方式是private。
于是我们就得出了一个结论,派生类的成员的访问限定符是基类成员访问限定符和继承方式中权限最小的那一个,即基类的其他成员 在派⽣类的访问⽅式 == Min(成员在基类的访问限定符,继承⽅式)。
2、继承类模版
这是一个stack继承类模版vector的例子
namespace xiaoli
{
template<class T>
class stack : public std::vector<T>
{
public:
void push(const T& 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();
}
};
}
需要注意,当我们的派生类对应的基类是类模版时,在成员中访问时需要指定类域。否则就会编译报错,找不到标识符。这是因为模版在需要时才会推导其类型进行实例化,直接访问会导致找不到。
3、基类和派生类之间的转换
public继承方式的派生类对象可以将其赋值给基类的指针/基类的引用。但是按照我们的认识,派生类会在基类的基础上多一些成员,那么这个赋值是怎么实现的呢。有个形象的说法就是切片,派生类将属于基类的那部分切出来,因此基类的指针和引用指向的是派生类中切出来的基类部分。
但是基类对象却不能赋值给派生类对象。而基类的指针或基类的引用可以通过强制类型转换赋给派生类的指针或引用,但是要求该基类的指针或基类的引用指向的是派生类对象时这个行为才是安全的。
int main()
{
Student s1;
// 派⽣类对象可以赋值给基类的指针/引⽤
Person* p1 = &s1;
Person& p2 = s1;
// 派⽣类对象可以赋值给基类的对象是通过调⽤基类的拷⻉构造完成的
Person p3 = s1;
// 基类对象不能赋值给派⽣类对象,这⾥会编译报错
s1 = p3;
return 0;
}
我们发现将基类对象赋给派生类对象时会发生报错。
4、继承中的隐藏
在继承中,基类和派生类都有其对应的独立的作用域。
如果基类和派生类中有同名成员,那么派生类成员会屏蔽对基类同名成员的直接访问,这就是隐藏。当然如果你非要想访问,可以通过指定类域显示访问。而对于成员函数,只要函数名相同就会构成隐藏,因此尽量不要定义同名的成员或成员函数。
5、默认成员函数
我们都知道类中有一些默认的成员函数,自己不显示实现,编译器就会自动生成一个,比如构造函数、析构函数、拷贝构造函数等等。那么在继承体系中,这几个函数的行为是怎样的呢。
(1)构造函数
派生类的构造函数需要调用基类的构造函数来初始化基类的那一部分成员。如果基类没有默认构造函数,则需要在派生类构造函数的初始化列表中显示调用.。派生类对象初始化时先调用基类构造函数再调用派生类构造函数
(2)析构函数
派生类的析构函数在被调用完成后会自动调用基类的析构函数清理基类成员,这样保证了先清理派生类成员再清理基类成员的顺序。派生类对象先调用派生类的析构函数再调用基类的析构函数。在不是虚继承的情况下,派生类析构函数和基类析构函数构成隐藏关系。
(3)拷贝构造函数
派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类的拷贝初始化。
(4)赋值运算符重载函数
派生类的赋值运算符重载函数operator=需要调用基类的operator=完成基类成员的赋值。要注意在重载赋值运算符时,派生类的operator=隐藏了基类的operator=,因此要指定类域使用。
6、实现一个不能被继承的类
方法一:
将基类的构造函数私有化,派生类的构造函数需要调用基类的构造函数,当基类的构造函数使用private修饰后,无论以何种方式继承,派生类都不能调用,因此派生类无法实例化出对象。
方法二:
使用final关键字,使用final关键字修饰基类,则其就不能被继承了。
class Person final
{
public:
void func()
{
cout << _age << endl;
}
protected:
string _name;
string _id;
int _age;
};
7、友元与继承
友元关系不能被继承,即基类的友元不能访问派生类的私有和保护成员。
class Student;
class Person
{
public:
friend void Print(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
friend void Print(const Person& p, const Student& s);
protected:
int _stuid; // 学号
};
void Print(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuid << endl;
}
解决方式就是使其也成为派生类的友元。
8、static静态成员与继承
基类定义了static静态成员,则整个继承体系⾥⾯只有⼀个这样static修饰的成员。⽆论派⽣出多少个派⽣类,都 只有⼀个static成员实例。
class Person
{
public:
//为了后面打印地址便于观察
string _name;
static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuid;
};
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;
}
通过观察打印其地址我们成功验证了上面的结论,static修饰的成员在整个继承体系中只有一份。
四、多继承及菱形继承问题
1、多继承
我们上面常见的一个派生类直接继承一个基类,这个继承关系称为单继承。那么在C++中,一个派生类也可以有两个或多个基类,这种继承关系就叫做多继承。
2、菱形继承
由多继承就引发了菱形继承问题,菱形继承是多继承的⼀种特殊情况。下面为网上随便找的一张图用来说明。
可以看到,类A和类B分别继承了类Base,而类C又继承了类A和类B,这就是典型的一种菱形继承。
//错误示例
C c_tmp;
c_tmp._name = "Tom";
//正确示例
C c_tmp;
c_tmp.A::_name = "Jone";
C_tmp.B::_name = "Peter";
这种方式虽然解决了二义性的问题,但数据冗余的问题仍然没有解决。
3、虚继承
上面我们可以看到,由于C++多继承的原因,出现了菱形继承的问题。为了解决这种问题,C++又提出了虚继承。通过在继承方式前加一个virtual的方式实现虚继承。
// 使⽤虚继承Person类
class Student : virtual public Person
{
protected:
int _stuid; //学号
};
// 使⽤虚继承Person类
class Teacher : virtual public Person
{
protected:
int _teachid; //教职工编号
};
//博士
class Doctor : public Student, public Teacher
{
protected:
int _count; //论文数量
};
int main()
{
// 使⽤虚继承,可以解决数据冗余和⼆义性
Doctor a;
a._name = "Peter";
return 0;
}
我们在使用多继承时,尽量避免使用菱形继承,会使得代码耦合度变高,变得更复杂,不利于我们的维护代码,可以看到多继承也是有一定缺陷的,因此java就取消了多继承这个东西。
五、继承和组合的讨论
通过继承,每个派生类对象都是一个基类对象。而对于组合,假设B组合了A,那么每个B对象中都有一个A对象。这两种方式会带来很多差别。在前面使用容器适配器模拟实现stack时,使用vector作为container,这就是一种组合关系。
在继承⽅式中,基类的内部细节对派⽣类可⻅。继承⼀定程度破坏了基类的封装,基类的改变,对派⽣类有很⼤的影响。派⽣类和基类间的依赖关系很强,耦合度⾼。
组合方式是继承之外另一种复用代码的选择。组合类之间没有很强的依赖关系,耦合度低,代码维护性好。
在实际使用中,我们尽量多使用组合而非继承,当然这并不是绝对的,要依据代码适配哪种方式来决定,比如类之间的关系更适合继承或者需要实现多态,那么就需要继承。在既适合继承又适合组合时,我们尽量选用组合方式实现代码复用。