c++进阶--继承
大家好,今天我们来学习c++中的进阶部分,今天学习的内容是c++的继承。
目录
1. 继承的概念及定义
1.1 继承的概念
1.2 继承的定义
1.2.1 定义格式
1.2.2 继承基类成员访问⽅式的变化
1.3 继承类模板
2. 基类和派⽣类间的转换
3. 继承中的作用域
4. 派⽣类的默认成员函数
5. 继承与友元
6. 继承与静态成员
7. 多继承及其菱形继承问题
7.1 继承模型
7.2 虚继承
7.3 多继承中的指针偏移问题
8. 继承和组合
1. 继承的概念及定义
1.1 继承的概念
继承(inheritance)机制是⾯向对象程序设计使代码可以复⽤的最重要的⼿段,它允许我们在保持原有类特性的基础上进⾏扩展,增加⽅法(成员函数)和属性(成员变量),这样产⽣新的类,称派⽣类。继承呈现了⾯向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复⽤,继承是类设计层次的复⽤。下⾯我们看到没有继承之前我们设计了两个类Student和Teacher,Student和Teacher都有姓名/地址/电话/年龄等成员变量,都有identity⾝份认证的成员函数,设计到两个类⾥⾯就是冗余的。当然他们也有⼀些不同的成员变量和函数,⽐如⽼师独有成员变量是职称,学⽣的独有成员变量是学号;学⽣的独有成员函数是学习,⽼师的独有成员函数是授课。
class Person {
protected:
string name;
string address;
string tel;
int age;
};
class Student :public Person {
protected:
int stuid;
};
class Teacher :public Person {
protected:
string title;
};
我们定义一个父类是人,两个子类分别是学生和老师。对于学生和老师两种群体共同点是都有姓名年龄地址和电话号。所以我们可以把两个类共同的部分放在一个类里让两个子类继承,就可以复⽤这些成员,就不需要重复定义了,省去了很多⿇烦。
1.2 继承的定义
1.2.1 定义格式
1. public继承2. protected继承3. private继承
1.2.2 继承基类成员访问⽅式的变化
类成员/继承方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 子类的public成员 | 子类的protected成员 | 子类的private成员 |
基类的protected成员 | 子类的protected成员 | 子类的protected成员 | 子类的private成员 |
基类的private成员 | 在子类中不可见 | 在子类中不可见 | 在子类中不可见 |
1. 基类private成员在派⽣类中⽆论以什么⽅式继承都是不可⻅的。这⾥的不可⻅是指基类的私有成员还是被继承到了派⽣类对象中,但是语法上限制派⽣类对象不管在类⾥⾯还是类外⾯都不能去访问它。2. 基类private成员在派⽣类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派⽣类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。3. 实际上⾯的表格我们进⾏⼀下总结会发现,基类的私有成员在派⽣类都是不可⻅。基类的其他成员在派⽣类的访问⽅式 == Min(成员在基类的访问限定符,继承⽅式),public > protected > private。4. 使⽤关键字class时默认的继承⽅式是private,使⽤struct时默认的继承⽅式是public,不过最好显⽰的写出继承⽅式。5. 在实际运⽤中⼀般使⽤都是public继承,⼏乎很少使⽤protetced/private继承,也不提倡使⽤protetced/private继承,因为protetced/private继承下来的成员都只能在派⽣类的类⾥⾯使⽤,实际中扩展维护性不强。
对于class定义的类,默认继承方式是private继承;struct定义的类,默认继承方式是public继承。
虽然子类不能访问父类的private成员,但可以通过父类的成员函数来间接调用父类的private成员。
1.3 继承类模板
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();
}
};
这是一个继承了vector的stack类,当父类是类模板时,子类调用父类函数需要指定父类的类域,但是模版是按需实例化,push_back等成员函数未实例化,所以找不到。不指定类域的话,会找不到要调用的函数。但对于普通的类,子类在实例化的时候父类也会实例化。
#define CONTAINER std::vector
template<class T>
class stack :public CONTAINER<T> {
public:
void push(const T& x)
{
CONTAINER<T>::push_back(x);
}
void pop()
{
CONTAINER<T>::pop_back();
}
const T& top()
{
return CONTAINER<T>::back();
}
bool empty()
{
return CONTAINER<T>::empty();
}
};
我们可以使用宏定义,让stack类继承一个宏定义的容器,这样方便我们修改父类,不需要再写一个继承只需要修改宏就可以了。
2. 基类和派⽣类间的转换
public继承的派⽣类对象 可以赋值给 基类的指针 / 基类的引⽤。这⾥有个形象的说法叫切⽚或者切割。寓意把派⽣类中基类那部分切出来,基类指针或引⽤指向的是派⽣类中切出来的基类那部分。基类对象不能赋值给派⽣类对象。基类的指针或者引⽤可以通过强制类型转换赋值给派⽣类的指针或者引⽤。但是必须是基类的指针是指向派⽣类对象时才是安全的。这⾥基类如果是多态类型,可以使⽤RTTI(Run-Time TypeInformation)的dynamic_cast 来进⾏识别后进⾏安全转换。(ps:这个我们后⾯类型转换章节再单独专⻔讲解,这⾥先提⼀下)
还是用之前的学生和人来举例子,可以看到子类Student中的成员是比父类Person多的,所以在使用子类对象给父类对象/指针/引用赋值的时候,可以对子类对象进行切割。这样就得到了一个父类对象,相反,父类对象不能给子类对象赋值,因为子类对象中有父类对象没有的成员。
父类的指针或引用是可以赋值给子类的指针或引用的,但涉及到类型转换,我们后面再学习。
3. 继承中的作用域
1. 在继承体系中基类和派⽣类都有独⽴的作⽤域。2. 派⽣类和基类中有同名成员,派⽣类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏(在派⽣类成员函数中,可以使⽤ 基类::基类成员 显⽰访问)。3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。4. 注意在实际中在继承体系⾥⾯最好不要定义同名的成员。
当父类和子类中有同名成员时,子类成员会对父类成员进行隐藏,若想使用父类成员,则需要指定类域。
当父类和子类中有同名函数时,两个函数构成隐藏但不构成重载,因为重载函数需要函数都在一个作用域内,而父类和子类分别属于两个独立的作用域。这时想调用父类函数需要指定类域。
4. 派⽣类的默认成员函数
1. 派⽣类的构造函数必须调⽤基类的构造函数初始化基类的那⼀部分成员。如果基类没有默认的构造函数,则必须在派⽣类构造函数的初始化列表阶段显⽰调⽤。2. 派⽣类的拷⻉构造函数必须调⽤基类的拷⻉构造完成基类的拷⻉初始化。3. 派⽣类的operator=必须要调⽤基类的operator=完成基类的复制。需要注意的是派⽣类的operator=隐藏了基类的operator=,所以显⽰调⽤基类的operator=,需要指定基类作⽤域4. 派⽣类的析构函数会在被调⽤完成后⾃动调⽤基类的析构函数清理基类成员。因为这样才能保证派⽣类对象先清理派⽣类成员再清理基类成员的顺序。5. 派⽣类对象初始化先调⽤基类构造再调派⽣类构造。6. 派⽣类对象析构清理先调⽤派⽣类析构再调基类的析构。7. 因为多态中⼀些场景析构函数需要构成重写,重写的条件之⼀是函数名相同(这个我们多态章节会讲解)。那么编译器会对析构函数名进⾏特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派⽣类析构函数和基类析构函数构成隐藏关系。
class Person {
public:
Person(const char* name)
:_name(name)
{}
Person(const Person& p)
:_name(p._name)
{}
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
}
return *this;
}
~Person()
{}
private:
string _name;
};
class Student :public Person {
public:
Student(const char* name,int num)
:Person(name)
,_num(num)
{}
Student(const Student& S)
:Person(S)
,_num(S._num)
{}
Student& operator=(const Student& S)
{
if (this != &S)
{
Person::operator=(S);
_num = S._num;
}
return *this;
}
~Student()
{}
private:
int _num;
};
子类在调用构造函数,拷贝构造函数,赋值重载函数的时候,要调用父类的构造函数,拷贝构造函数,赋值重载函数。因为父类和子类的赋值重载函数名相同,在调用父类的赋值重载函数时需要指定父类的类域。在代码中我们实现子类的拷贝构造和赋值重载函数时给父类的函数传的参数是子类的对象,和前面讲的相同,子类对象可以赋值给父类的对象/指针/引用,因为子类对象可以切割成为父类对象。
而对于父类和子类的析构函数,由于析构函数都会被处理成destructor()函数,所以子类和父类的析构函数是同名的,在调用的时候要指定父类析构函数的类域,但是在这里不需要在子类的析构函数中调用父类的析构函数,这是为了保证后实例化的先析构,也就是子类要在父类前面析构。
若子类中没有需要深拷贝(需要动态申请)的资源,那么系统默认生成的拷贝构造,赋值重载和析构函数就够用了,不需要再显示定义这三种函数。
另外,若想实现一个不能被继承的类,有两个方法:
⽅法1:基类的构造函数私有,派⽣类的构成必须调⽤基类的构造函数,但是基类的构成函数私有化以后,派⽣类看不⻅就不能调⽤了,那么派⽣类就⽆法实例化出对象。⽅法2:C++11新增了⼀个final关键字,final修改基类,派⽣类就不能继承了。
5. 继承与友元
友元关系不能继承,也就是说基类友元不能访问派⽣类私有和保护成员。
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()
{
Person p;
Student s;
Display(p, s);
return 0;
}
原本在父类中的友元函数,可以访问父类的成员,若是没有在子类中进行声明,则该友元函数还是只能访问父类的成员,在访问子类成员的时候会报错。
因为该友元函数的参数中有子类的对象,但子类的定义在下面,所以要在友元函数上面先进行一下子类的声明,让函数知道这是子类的对象,不会报错。
6. 继承与静态成员
基类定义了static静态成员,则整个继承体系⾥⾯只有⼀个这样的成员。⽆论派⽣出多少个派⽣类,都只有⼀个static成员实例。
也就是说,在父类中定义的static静态成员,到了子类中也还是static静态成员,不过这些static静态成员都是同一个,在修改时都会被修改。
7. 多继承及其菱形继承问题
7.1 继承模型
一个子类可以继承一个父类,这种继承方式叫做单继承,但一个子类也可以继承多个父类,这种继承方式叫做多继承。
此外,当多继承和单继承同时出现时,有可能也会出现另一种继承方式:菱形继承。
当一个子类多继承的类有共同的父类时,就构成了菱形继承,在菱形继承中,会出现数据冗余和二义性的问题。
比如在上图中,Assistant这个子类中就有来自Student和Teacher两个父类中的共同的部分,也就是Person类中的成员会出现两次。
如果调用时不进行类域的限定,计算机就不知道访问的是Student和Teacher中的哪一个,就会出现二义性,这个问题可以被类域修饰解决,但数据冗余无法被解决。
7.2 虚继承
此时,我们可以使用虚继承的方式来解决这两个问题。
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;
};
int main()
{
// 使⽤虚继承,可以解决数据冗余和⼆义性
Assistant a;
a._name = "peter";
return 0;
}
使用了虚继承之后,在子类Assistant中只有一份来自Person的成员,被虚继承的父类不会在子类中再生成一份数据,但在子类中这些数据还是可以被访问。
在一个继承关系中,一个类若是有二义性,那么它的子类就该使用虚继承。
7.3 多继承中的指针偏移问题
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main()
{
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}
对于这样三个类,p1,p2,p3的关系应该是怎样的呢?
在多继承中,先被继承的父类的地址应该在前面,并且讲子类对象赋值给父类的指针时,会对子类对象进行切割,所以p1和p3指向的是同一个地址,但p2指向的位置是子类中第二个父类开始的地址,所以结果为p1=p3!=p2。
8. 继承和组合
1.public继承是⼀种is-a的关系。也就是说每个派⽣类对象都是⼀个基类对象。2.组合是⼀种has-a的关系。假设B组合了A,每个B对象中都有⼀个A对象。3.继承允许你根据基类的实现来定义派⽣类的实现。这种通过⽣成派⽣类的复⽤通常被称为⽩箱复⽤(white-box reuse)。术语“⽩箱”是相对可视性⽽⾔:在继承⽅式中,基类的内部细节对派⽣类可⻅ 。继承⼀定程度破坏了基类的封装,基类的改变,对派⽣类有很⼤的影响。派⽣类和基类间的依赖关系很强,耦合度⾼。4.对象组合是类继承之外的另⼀种复⽤选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接⼝。这种复⽤⻛格被称为⿊箱复⽤(black-box reuse),因为对象的内部细节是不可⻅的。对象只以“⿊箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使⽤对象组合有助于你保持每个类被封装。5.优先使⽤组合,⽽不是继承。实际尽量多去⽤组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就⽤继承,另外要实现多态,也必须要继承。类之间的关系既适合⽤继承(is-a)也适合组合(has-a),就⽤组合。
比如说我们自己实现的stack类继承了list类,那就是继承,但若是我们在stack类内使用的list来完成对stack的实现,那么就是组合。
通常情况下,使用组合更好,但并不是绝对的。
对于符合继承的类,使用继承;对于符合组合的类,使用组合;对于继承和组合都符合的类,则优先使用组合。
这就是继承的全部内容,我们下次再见。