C++指北,继承详解
1.继承的定义
继承机制是我们面向对象语言中可以使代码复用最重要的手段,能够让我们在保持原有类特性的基础上来达到扩展的效果,增加方法(成员函数)和属性(成员变量),这样产生的新类被称为派生类,而原先的类被称为基类。
例如:
我们在设计person和student两个类时,二者存在共同的成员变量_name和成员函数_identity,这些重复定义显得冗余,但同时也各有专属的成员变量和函数。
class person
{
public:
person(const person& x)
:_name(x._name)
{
cout << "person(const person& x)" << endl;
}
//身份认证
void indentiy()
{
...
}
private:
string _name;
};
class student
{
public:
student(const student& x)
:_name(x._name),_id(x._id)
{
cout << "student(const student& x)" << endl;
}
//身份认证
void indentiy()
{
...
}
private:
string _name;
int _id;
};
下面我们将共有成员都放在person类中,student继承person,就可以复用这些成员,就不需要重复定义了,省去了很多麻烦。
class person
{
public:
person(const person& x)
:_name(x._name)
{
cout << "person(const person& x)" << endl;
}
private:
string _name;
};
class student:public person
{
public:
student(const student& x)
:person(x),_id(x._id)
{
cout << "student(const student& x)" << endl;
}
private:
int _id;
};
2.基类与派生类/父类与子类
2.1继承的格式
上面我们看到,person是基类也叫做父类,而student是派生类也叫做子类,因为是翻译原因,既叫基类与派生类也叫做父类与子类,两者同一意思。
同时继承方式分为三种,与访问限定符同名,分别是public继承、protected继承和private继承。
2.2继承基类成员访问方式的变化
那么当我们选择了不同的继承方式,对于父类的访问方式又是否会发生改变呢?
基类的private成员在派生类中无论以任何方式的继承都是不可见的,这里的不可见是指基类的成员还是被继承到了派生类中,但是由于语法限制,无论是类外是类里,派生类都无法访问该基类的成员,从而保证基类的封装性不被破坏。
简单的来说就是,继承了但用不了。
因为基类的 private 成员无法在派生类中访问。若想让成员不暴露在类外,却能被派生类访问,可定义为 protected,由此可见保护成员限定符正是为适配继承场景而设计的。
基类的私有成员在派生类中是不可见的,但基类的其他成员在派生类中的访问方式==min(成员在基类中的访问限定符,继承方式),public>protected>private。
比如说我以private继承方式,从基类继承成员变量,private成员在派生类中不可访问与使用,而非private成员即是经过private继承方式降级到了private,可以被访问与使用。
由此可见我们能够根据访问限定符与继承方式来任意组合来实现基类继承成员的状态。
但通常我们一般是用public继承方式,如果是其他两种继承下来的只能在派生类的类里面使用,不便于我们后期对类进行拓展应用。
2.3继承类模版
在讲继承类模版时,我们不得不提到queue与stack容器。
在上图中我们发现,queue 和 stack 的接口设计,与我们之前模拟实现的 vector、list、string 等容器有显著不同。它们并非独立设计底层存储,而是以 deque 作为基础来实现,这无疑能为我们省去大量麻烦(毕竟 deque 兼具 vector 和 list 的优势,这点我们之前提到过)。
正是因为这种基于其他容器模板进行封装,能够通过调用底层容器的功能来实现自身逻辑的特性,它们也被称为 “空间适配器”,这也让它们有了区别于普通容器的特殊性。
既然能通过这种组合的方式,那么我们也能够通过继承来模拟实现,两者的本质都是对已有容器能力的复用。
不过在实际选择时,优先用组合会更稳妥,因为组合只是将底层容器作为成员变量来调用,能最大程度保留父类(底层容器)的封装性;而继承会让子类直接暴露父类的内部成员和接口,很容易破坏原有的封装边界,增加代码耦合度。
代码展示如下:
//使用宏定义是为了更好的更换使用容器
#define CONTAINER vector
//因为模版是按需实例化的
//当以继承的方式来写stack,继承的父类是模版的话,就需要指定声明类域
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();
}
bool empty()const
{
return CONTAINER<T>::empty();
}
const T& top()const
{
return CONTAINER<T>::back();
}
};
3.父类与子类的转换
public继承的派生类对象,与基类共有的成员变量是可以赋值给基类的指针/引用,因为这些成员变量本来就是从基类中所继承而来的。
但是基类对象却不可以赋值给派生类。
简单的来说就是派生类对象包含了基类对象,你有的我也有,你没有的我还是有,我里面能找到你所需要的,而你却找不到我所需要的。
也可以理解为切片,派生类赋值给基类的只有基类的那部分,很多东西都被切掉了。
但是基类的指针/引用可以通过强制类型转换赋值给派生类的对象,但是基类的指针必须得指向派生类对象时才是安全的,这⾥基类如果是多态类型,可以使⽤RTTI(Run-Time Type Information)的dynamic_cast来进⾏识别后进⾏安全转换。
至于为什么必须要指向派生类对象,那是因为只有指向派生类对象时,内存中才确实存在派生类对象的完整结构,才能访问到有效的地址和数据,并且通过这种方式,我们可以用同一的基类指针来管理不同的派生类对象而这就是多态。
4.继承中的作用域
在继承体系中,基类与派生类都有着独立的作用域。
接下来我们来看一道题。
A和B类中,两个func函数构成什么关系?
a.重载 b.隐藏 c.没关系
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.fun();
return 0;
};
相信大多数人看到这道题时会选择a。
因为函数名相同,参数列表等不同函数会构成重载关系。
但是我想我们可能忘记了一个前提条件。
那就是必须在同一作用域下,函数名相同,参数列表不同才会构成函数重载。
也有的人没有忘记这点可能会选c,但这道题选b。
因为当派生类与基类中有着同名成员,派生类成员会屏蔽基类对同名成员的直接访问,而这种关系就叫做隐藏。
简单的说,在继承体系中,只要同名,同名函数就构成隐藏关系。
那么这段代码的运行结果又是什么呢?
a.正常运行 b.编译报错 c.程序崩溃
在调用,b.fun()时,由于隐藏关系,在b的作用域下找不到对应的函数,不会去a的作用域查找,所以编译器会直接报编译错误,也就是b。
所以在实际应用继承体系中尽量不要定义同名函数。
成员变量也是一样的。
5.子类中的默认成员函数
在继承体系中,类的六个默认成员函数是否会发生写改变呢?
那么在子类中,这几个成员函数又是怎么生成的?
子类的构造函数必须要调用父类的构造函数初始化父类的那一部分成员,若是父类没有默认构造函数则必须在子类的初始化列表进行显式调用。
student(const char* name,int id)
:person(name),_id(id)
{
cout << "student(int id)" << endl;
}
子类的拷贝构造函数在对于父类的那部分也是要调用父类的拷贝构造函数来进行初始化。
student(const student& s)
:person(s),_id(s._id)
{
cout << "student(const student& s)" << endl;
}
子类的赋值,operator=对于父类的也是要调用父类的赋值,但因为函数名相同构成隐藏关系就需要声明类域。
//同名函数构成隐藏关系
student& operator=(const student& x)
{
cout << "student& operator=(const student& x)" << endl;
if (this != &x)
{
person::operator=(x);
_id = x._id;
}
return *this;
}
子类析构函数会自动调用父类的析构函数,这是编译器隐式处理的确保对象析构时遵循先子类再父类的顺序,无需显式调用。
即使想要显式调用,就需要声明类域,因为在一些场景下析构函数会构成重写,重写之一的条件就是函数名相同,编译器会对析构函数名进行特殊处理为统一格式destructed();所以基类析构函数不声明virtual的情况下就会构成隐藏关系。
5.1实现不能被继承的类
1.若想实现一个不能被继承的类,把基类中的构造函数给私有化,派生类就无法调用父类的构造函数,那么派生类就无法实例化。
当然这种方法也是有着弊端的,基类自身也无法直接创建对象,需通过静态成员函数等间接实例化,增加复杂度。
2.在C++11后添加了一个关键字final,表示为最终类,final能修改基类让派生类无法继承。
class person final
{
public:
...
5.2继承与友元
友元关系不能被继承,那么就是在说基类友元不能访问派生类的私有和保护。
5.3继承与静态成员
基类定义的static静态成员变量是能被继承下来的,但是在整个继承体系中,它是全局唯一实例化的例子,并且派生类与基类共享同一块地址,无论继承了多少个派生类,都不会产生多个副本。
6.继承模型
继承模型分为单继承与多继承。
单继承,一个派生类只有一个直接继承的基类。
多继承,一个派生类有两个及以上的直接继承的基类,多继承对象在内存中的关系是先继承的基类在前面,后继承的基类在后面,最后是派生类。
菱形继承是多继承的一种特殊的情况。
从上图中可以看出与正常的多继承不同,菱形继承会导致数据冗余和二义性的问题,在进行使用时会产生调用歧义,支持多继承就一定会有菱形继承,但我们在设计的时候是可以尽量的去规避这种情况的发生。
但是我们也有解决的办法,那就是虚继承,指定访问类域,将共同的父类person从继承的关系改为组合。
6.1虚继承
虚继承是解决菱形继承的特殊方式,能让间接继承同一基类的多个派生类,在最终派生类对象中仅保留一份共同基类实例,而非多份副本。
在继承的父类前面加上关键字virtual,将基类声明为虚基类,强制所有派生类共享这一份基类副本,跟继承静态成员变量有点像。
例如,class student:virtual public person。
但要注意,必须得是谁有数据有余性就在继承它的处加virtual。
虚基类的构造函数优先于所有非虚基类调用,最终派生类必须直接或间接初始化虚基类。
IO库中的输入流与输出流继承于同一个基类basic_ios,而iostream又同时继承了输入输出流,这就形成了一个典型的菱形继承,最后便是用虚继承解决的。
6.2多继承中指针的偏移问题
多继承中指针的偏移问题?
a. p1==p2==p3 b. p1<p2<p3 c. p1==p2!=p3 d. p1!=p2!=p3
class Base1
{
public: int _b1;
};
class Base2
{
public: int _b2;
};
class comom : public Base1, public Base2
{
public: int _d;
};
int main()
{
comom d;
Base1* p1 = &d;
Base2* p2 = &d;
comom* p3 = &d;
return 0;
}
7.继承与组合
public 继承(is-a 关系),派生类对象可看作基类对象,通过复用基类实现定义自身。属于 “白箱复用”,基类内部细节对派生类可见,但会破坏基类封装,派生类与基类耦合度高,基类改动易影响派生类。
组合(has-a 关系),类中包含另一个类的对象,通过组装对象实现复杂功能。属于 “黑箱复用”,被组合对象仅暴露接口、内部细节不可见,类间耦合度低,能保持类的封装性。
使用原则:优先选择组合,因其耦合度低、维护性好;若类间明确是 is-a 关系,或需实现多态,则用继承;若两种关系均适用,仍优先组合。
更多推荐










所有评论(0)