文章目录
继承的定义及概念
概念:继承是面向对象的三大特性之一。继承在现实生活中的说法就是继承了家里的家产,继承了家里的家业,从长辈那直接得到,而在C++中则是解决一些复用的问题,内层次的复用,它允许我们在保持原有类特性的基础上进行拓展,增加一些成员变量或成员函数,产生新的类,该种类就叫派生类。一句概括就是继承是类设计层次的复用。
举个样例:
现在我们需要实现一个师生管理系统,在没有使用继承前,我们需要设计出两个类Student和Teacher,在这两个类中我们都要设计姓名/年龄/性别/家庭住址/身份确认,两个类中都要设计这些同样的信息,这样的设计就显得内容非常的冗余,当然两个类中也有不同的成员变量/函数,比如Student的类中有学号/学习,Teacher类中有职称/授课
我们可以看到,这两个类都可以实现,但是这两个类中的内容有很多都是重复的,这样的设计就显得很冗余。如果能将这些同样的东西提取出来统一放到一个地方,在设计类时,就不需要每个类都再写一遍这些重复的内容了,恰好继承就解决了这样的问题,继承呢就是将这些相同的内容提取出来统一放到父类中,如下代码:
cpp
class Person //父类/基类
{
public:
void identify() {} //身份识别
private:
string _name; //姓名
string _sex; // 性别
int _age; // 年龄
int _num; // 身份证号
};
class Student : public Person //子类/派生类
{
public:
void studying() {} // 学习
private:
string _stunum; //学号
};
class Teacher : public Person //子类/派生类
{
public:
void teaching() {} // 授课
private:
string _title; //职称
};
上面的代码就叫作继承。相同的信息都被提取出来放到了Person这个类中,Person这个类就被叫作父类或者基类,Student和Teacher这两个类中相同的内容直接继承(复用)父类的内容就可以,但也有不同的内容,不同的内容再在各自的类中实现,这两个继承了父类的类叫作子类或者派生类
继承的格式:
对父类的访问方式相当于继承方式,继承方式一共有三种public(公有)继承、protected(保护)继承、private(私有)继承。对类中的成员访问方式也是三种,也是公有、保护、私有,这三种访问限定符
继承方式不同访问方式也随之不同
用公有继承来举例:
cpp
class Person
{
public:
void Print()
{
cout << _name << endl;
}
protected:
string _name = "李四";
private:
int _age = 18;
};
class Student : public Person //公有继承
{
protected:
int _stunum; // 学号
};
当访问父类的公有成员时:
可看到子类的内外均可对父类的公有成员进行访问
当访问父类的保护成员时:
可看到当访问的是保护成员时,只可在子类内部访问,子类外不可
当访问父类的私有成员时:
可看到当访问父类的私有成员时,子类的内外均不可访问
- public继承
若父类的成员是公有则继承到子类时也是公有,子类内外均可访问;若父类的成员是保护则继承到子类时也是保护,只可在子类内访问;若成员是私有则继承到子类时也是私有,子类内外均不可访问 - protected继承
若父类的成员是公有则继承到子类时是保护,只可在子类内访问;若父类的成员是保护则继承到子类时也是保护,只可在子类内访问;若父类的成员是私有则继承到子类时也是私有,子类内外均不可访问 - private继承
无论父类的成员是公有、保护还是私有,继承到子类中都是私有,子类内外均不可访问 - 不显示继承方式
当创建子类的关键字时=是class时,默认继承方式是私有继承;当创建子类的关键字是struct时,默认继承方式是公有继承。一般最好显示写继承方式
注:一般情况下,父类的成员函数都设成公有,成员变量都设成保护,且实际上在实际运用中一般都是公有继承,几乎不提倡保护或私有继承,这两个继承的拓展性不强
继承类模板
上面我们举例的继承是普通类的继承,下面我们来实现模板类的继承。用实现栈来举例,当我们在没有学习过继承之前实现一个栈用到了容器适配器,用了一个模板参数去实现,那学了继承之后,我们可不可以用继承的方式来实现呢,看下面代码:
cpp
namespace li
{
template<class T>
class stack : public vector<T>
{
public:
void push(const T& x)
{
vector<T>::push_back(x);
}
T& top()
{
return vector<T>::back();
}
void pop()
{
vector<T>::pop_back();
}
bool empty()
{
return vector<T>::empty();
}
};
}
int main()
{
li::stack<int> st;
st.push(1);
st.push(2);
st.push(3);
st.push(4);
while (!st.empty())
{
cout << st.top() << ' ';
st.pop();
}
cout << endl;
return 0;
}
从上面的代码可以看到,继承了vector之后,它里面的内容stack就可以直接复用。在上面的代码中我们还需注意一些问题,基类是类模板,在调用父类的成员时我们需要指定类域,上面的代码中用了vector<>::来对成员函数进行调用,如果是普通的继承我们是可以直接调用的,出现这样的问题是因为类模板是按需实例化的,编译器在编译时实例化了stack<>,也实例化了vector<>,但是vector类中的成员函数是没有实例化的,如果没有指定类域,编译器就找不到要调用的push_back等函数
typename对上述问题的拓展
看下面代码:
cpp
template<class Container>
void Print(const Container& c)
{
Container::const_iterator it = c.begin();
while (it != c.end())
{
cout << *it << ' ';
++it;
}
cout << endl;
}
int main()
{
vector<int> v1 = { 1,2,3,4,5 };
list<int> l1 = { 1,2,3,4,5 };
Print(v1);
Print(l1);
return 0;
}
上面代码看似没问题,实则编译是不通过的
原因就是:上面的代码中,类作为参数模板,当调用Print函数时,只是将类作为参数传过去了而已,还没有对类进行实例化,编译器不确定Container::const_iterator是内嵌类型还是静态成员变量,所以编译就会不通过。要想解决上述问题可以用auto来自动推导类型,也可以在Container::const_iterator前面加上typename,该关键字的作用就是让编译器知道Container::const_iterator是一个内嵌类型,不是静态成员变量。关于typename关键字是否要加上需要程序员自己确定,如当确定Container::const_iterator就是内嵌类型时,就可以在前面加上typename
上述问题的总结:没有实例化的类模板,是不能去类里面去找的,只有实例化之后才可以;类模板的实例化是按需实例化的,不会把整个类都实例化出来,所以再调用里面的函数或变量时要加上域操作符
基类和派生类间的转换
定义:public继承的派生类对象可以赋值给基类指针或基类的引用。赋值给基类指针或引用这个过程我们叫作切片或切割(赋值兼容转换),切出来的是派生类中的基类的部分,使得基类的指针或引用指向切出来的这一部分
注意:基类对象不可以赋值给派生类对象,但是基类的指针可以通过强制类型转换赋值给派生类的指针或引用,必须是基类指针指向派生类对象才是安全的
看下面代码:
cpp
class Person
{
protected:
string _name;
int _age;
string _sex;
};
class Student : public Person
{
protected:
int _stunum;
};
int main()
{
Student s;
Person* p1 = &s;
Person& p2 = s;
return 0;
}
上述代码进行了对派生类的切片/切割,为深入理解看下图:
为什么切片/切割又叫赋值兼容转换呢,因为按以前来讲不同类型在进行赋值时,会产生一个临时对象,但是在派生类对象赋值给基类指针或基类引用时,是不产生临时对象的。当子类对象赋值给基类指针时,基类指向的是派生类的基类部分;当子类对象赋值给基类的引用时,基类引用是子类的基类部分的别名,通过基类指针/基类引用改变切出来的那一部分成员时,改变的还是派生类的成员
注:派生类对象也可以赋值给基类对象。赋值时,是把派生类中的基类部分拷贝给基类对象,这不叫切割/切片
继承中的作用域
隐藏规则
定义:在继承中,基类和派生类是独立的作用域,当在基类和派生类中出现同名成员时,派生类成员将会屏蔽对基类成员的直接访问,这种情况叫作隐藏
深入理解上述定义,请看下面代码:
cpp
class Person
{
protected:
string _name = "李四";//姓名
int _num = 1234;//身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << "姓名:" << _name << endl;
cout << "身份证号:" << _num << endl;
}
protected:
int _num = 2222;//学号
};
int main()
{
Student s;
s.Print();
return 0;
}
上面的输出结果并不是我们所期望的,输出的身份证号是学号,这是因为基类中的_num成员变量与子类中的_num成员变量构成了隐藏关系,当在子类中访问基类的_num成员变量时,子类的同名成员会对其产生屏蔽的效果,将其隐藏,导致访问到的是与基类同名成员的子类成员,要想解决这个问题,显示访问(指定作用域)即可
cpp
cout << "身份证号:" << Person::_num << endl;
注意:
- 若想要成员函数构成隐藏,只需函数名相同即可
- 在实际的继承体系中,最好不要出现同名的情况
派生类中的默认成员函数
在初学类和对象那块知识点时,就可知默认成员函数共有6种,分别是构造函数、拷贝构造函数、析构函数、赋值运算符重载、普通对象和const对象取地址重载,在普通类中这些默认成员函数的行为我们已了解,后面两个默认成员函数我们暂且先不讨论,因为用的不多,那这些成员函数在派生类中又是怎样的行为呢,对于这几个默认成员函数,默认的意思就是我们不写,编译器会自动生成一个,那么在派生类中又是如何生成的呢?
为方便学习,我将用下面的代码作为基类样例来进行讲述:
cpp
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;
};
该基类代码的默认成员函数其实是不用自己实现的,因为成员变量是自定义类型,编译器会自动调用它的默认成员函数,但为了待会更好的讲述,所以我显示实现了出来
派生类的构造函数
cpp
class Student : public Person
{
public:
Student()
{
cout << "Student()" << endl;
}
protected:
int _num;
string _address;
};
int main()
{
Student s1;
return 0;
}
输出结果可以看到:编译器走了父类的构造也走了子类的构造
总结:派生类默认构造 = 子类成员 内置类型(缺省值/不确定)自定义类型(调用对应的默认构造函数)+ 父类成员(必须调用父类的构造函数) ,所以在派生类的默认构造中,我们可以将子类中继承下来的父类看成一个整体,它的构造也是得调用它自己的默认构造,如果没有,跟自定义类型一样编译器就会报错
因为可以将子类中继承下来的父类看成一个整体,所以父类在子类中走初始化列表时,得这样写:
cpp
class Student : public Person
{
public:
Student(int num,const char* address,const char* name)
:_num(num)
,_address(address)
,Person(name)//将父类看作一个整体,调用父类的默认构造函数
{
cout << "Student()" << endl;
}
protected:
int _num;
};
注:当父类构造要我们显示写时,父类的默认构造要显示调用,不显示写时,编译器会自动调用
派生类的拷贝构造
cpp
class Student : public Person
{
public:
Student()
{
cout << "Student()" << endl;
}
Student(const Student& s)
:_num(s._num)
,_address(s._address)
,Person(s)
{
cout << "Student(const Student& s)" << endl;
}
protected:
int _num;
string _address;
};
int main()
{
Student s1;
Student s2(s1);
return 0;
}
将上述代码与基类样例代码结合起来看,输出结果:
可以看到构造还是老样子也走了父类构造,但我们还看到了拷贝构造也走了父类的拷贝构造
总结:派生类默认拷贝构造 = 子类成员 内置类型(只拷贝/浅拷贝)自定义类型(调用对应的拷贝构造)+ 父类成员(必须调用父类的拷贝构造),所以对于上述代码实际上是不需要显示写出基类和子类的拷贝构造的,因为派生类的默认拷贝构造就够用了,但为了更好的叙述这些结论,所以我显示写了出来
还要注意的一点是,上述代码中派生类继承下来的基类那一部分在走初始化时,也是将那一部分看成一个整体,不用一个个的拷贝过去,所以初始化列表那给的是一个派生类对象,然后调用基类的拷贝构造,这里用到了切片/切割,将派生类中基类部分切割下来,拷贝给父类
注:当父类拷贝构造要我们显示写时,父类的默认构造要显示调用,不用显示写时,编译器会自动调用
赋值重载
cpp
class Student : public Person
{
public:
Student()
{
cout << "Student()" << endl;
}
Student& operator=(const Student& s)
{
cout << "Student& operator=(const Student& s)" << endl;
if (this != &s)
{
_num = s._num;
_address = s._address;
Person::operator=(s); //避免被派生类成员屏蔽,显示调用
}
return *this;
}
protected:
int _num;
string _address;
};
int main()
{
Student s1;
Student s2;
s2 = s1;
return 0;
}
将上述代码与基类样例代码结合起来看,输出结果:
可看到构造还是老样子,而派生类的赋值运算符重载也走了父类的赋值运算符重载
总结:派生类赋值运算符重载与派生类的拷贝构造类似
上述代码中还要注意一点,在调用父类的赋值运算符重载时,需要显示调用,因为基类与子类的赋值运算符重载构成了隐藏的关系,所以防止子类的赋值运算符重载屏蔽了父类的运算符重载,需要显示调用
注:当父类的赋值运算符重载要我们显示写时,父类的赋值运算符重载要显示调用,不用显示写时,编译器会自动调用
派生类析构函数
从上面的三个默认成员函数来看,每个派生类的默认行为在完成继承下来的父类那一部分时,都必须调用父类的默认成员函数,那么是否派生类的析构也是如此呢?将下面的代码与基类样例代码结合来看,我们先假设派生类的析构也要调用父类的析构
cpp
class Student : public Person
{
public:
Student()
{
cout << "Student()" << endl;
}
~Student()
{
cout << "~Student()" << endl;
~Person();
}
protected:
int _num;
string _address;
};
int main()
{
Student s1;
return 0;
}
出现上述的错误是由于多态的一些原因,析构函数的函数名会被特殊处理,处理成destructor(),所以导致基类的析构与子类的析构构成隐藏关系,所以当我们要调用基类的析构时要显示调用
cpp
~Student()
{
cout << "~Student()" << endl;
Person::~Person();
}
此时编译成功,再来看输出结果:
从输出的结果中我们有发现了新的问题,为什么父类的析构函数会调用两次?这是由于一些规定,要先析构子再析构父,所以就有了派生类析构函数的特点当派生类的析构函数结束时,编译器会自动调用基类的析构函数,所以对于派生类的析构函数,我们不需要显示调用基类的析构函数来完成派生类继承下来的基类部分
cpp
~Student()
{
cout << "~Student()" << endl;
}
该输出结果应证了派生类析构函数的特点
总结:派生类析构 = 子类成员 内置类型(不做处理)自定义类型(调用对应的析构函数)+ 父类成员(调用父类的析构函数,但不需要显示调用)
注:无论父类的析构函数是否要我们显示写,父类的析构函数都不需要显示调用,编译器会自动调用
实现一个不能被继承的类
-
法一:把父类构造函数私有化
如下代码:
上面的代码就是把父类的构造放到了私有的位置。我们知道派生类在创建对象时会调用默认构造函数,对创建的对象进行实例化,而派生类的默认构造会调用自己的构造加上父类的构造,而父类的构造被设成了私有,不准类外访问,导致了派生类对象创建不出来,就间接导致了父类不能被继承
-
法二:添加final关键字
直接在类后面加上final 即可
继承与友元
友元关系不能被继承,也就是说基类友元不能访问派生类的私有和保护成员,样例代码如下:
可以看到func函数是父类的友元,但不是子类的友元,所以func函数访问不了派生类的私有和保护成员,要想解决上述问题,只需把func函数变成子类的友元即可
继承与静态成员
当基类定义了一个static成员时,那么整个继承体系中只有一个这样的成员,换个说法就是,无论有多少个派生类,基类与这些派生类都共同使用这一个static成员
上面的两段代码中,静态成员的地址是一样的,而非静态的不一样,说明了静态成员在继承体系中是共用一份的,而非静态成员是各一份的
多继承及菱形问题
单继承
一个派生类只有一个直接基类时,这个继承关系就叫单继承,继承关系图如下
多继承
一个派生类有两个或两个以上的直接基类,这个继承关系就叫多继承,继承关系图如下
在代码中多继承的格式如下:
cpp
class A
{
//....
}
class B
{
//....
}
class C : public A,public B //用逗号隔开
{
//....
}
菱形继承
有多继承就会有菱形继承,继承关系图如下
我们看上面的关系图,表面上是没什么问题,但是我们会发现菱形继承里面会有两份A类的信息,因为D类继承的B中有一份A类的信息,继承的C类中也有一份A类的信息,这就导致了数据冗余和二义性,要想解决这样的问题,就得用到一个新的关键字virtual--虚继承,将该关键字放到继承A类的B,C类中即可,代码如下
cpp
class B : virtual public A //虚继承
{
//.....
}
class C : virtual public A // 虚继承
{
//.....
}
继承和组合
- 继承:public继承相当于is--a的关系。每个派生类的对象都是一个基类对象,例如:基类是植物,派生类是玫瑰,玫瑰是一种植物,它两就属于一种继承的关系
在继承关系中,几乎大部分的基类对派生类都是可见的,且从一定程度上来说,继承破坏了封装,基类的改变,会对派生类产生巨大的影响,它两之间的依赖性很强,就是人们常说的,耦合度高
- 组合:组合是一个has--a的关系。假设B组合了A,则每个B类对象中都有一个A类对象,例如,B类是汽车,A类是轮胎,每辆汽车都有轮胎,它两就属于组合的关系
我们什么时候会用到组合呢,如下:
cpp
//两个栈实现队列,这种就用到了组合
class Queue
{
private:
stack _Push;
stack _Pop;
}
显然对于组合来说,组合的对象的内部是不可见的,且组合之间的依赖性不是很大,耦合度低
为什么说组合的耦合度比继承的低呢?
在继承中,大部分的基类成员都是保护和公有,对派生类来说,都是可以直接访问的,而在组合中,只能访问公有的成员,对于保护的成员是访问不了的,所以从一定程度上来讲,组合的耦合度较低
所以,在实际运用中,当两个类即适合继承又适合组合时,应优先使用组合,组合的耦合度低,代码维护性好