C++继承探究

文章目录

  • 一、继承相关概念
    • 1、 基本概念
    • 2、继承方式
    • 3、如何构造基类
    • 4、基类和派生类对象赋值转换
    • 5、继承中的作用域
  • 二、菱形继承的问题及解决方案
  • 三、虚继承的原理
  • 四、继承 VS 组合

一、继承相关概念

1、 基本概念

代码复用是编程语言设计的核心,对于一个函数,其实就是函数级别的代码复用,对于一个类,代码复用的方式有两种:继承和组合,继承是一种is-a的关系,而组合是一种a part of的关系。

继承可以理解为一个类从另一个类获取成员变量和成员函数的过程。
成员变量继承一份给自己,成员函数和父类共用。
被继承的类称为基类或父类,继承的类称为派生类或子类。
继承和派生是一个概念,只是站的角度不同。
派生类除了拥有基类的成员,还可以定义新的成员,以增强其功能。

语法:
class 派生类名:[继承方式]基类名
{
    派生类新增加的成员
}; 

使用继承的场景:

  1. 如果新创建的类与现有的类相似,只是多出若干成员变量或成员函数时,可以使用继承。
  2. 当需要创建多个类时,如果它们拥有很多相似的成员变量或成员函数,可以将这些类共同的成员提取出来,定义为基类,然后从基类继承。

2、继承方式

类成员的访问权限由高到低依次为:public --> protected -->private,public成员在类外可以访问,private成员只能在类的成员函数中访问。

如果不考虑继承关系,protected成员和private成员一样,类外不能访问。但是,当存在继承关系时,protected和private就不一样了。基类中的protected成员可以在派生类中访问,而基类中的
private成员不能在派生类中访问。

继承方式有三种:public(公有的)、protected(受保护的)和private(私有的)。它是可选的,继承方式如果不写,对于class默认为private,对于struct,默认为public。不同的继承方式决定了在派生类中成员函数中访问基类成员的权限。如图表示了继承到派生类中成员变量的访问权限C++继承探究_第1张图片

继承规则:

  1. 基类成员在派生类中的访问权限不得高于继承方式中指定的权限

例如,当继承方式为protected时,那么基类成员在派生类中的访问权限最高也为protected,高于protected的会降级为protected,但低于protected不会升级。再如,当继承方式为public时,那么基类成员在派生类中的访问权限将保持不变。也就是说,继承方式中的public、protected、private是用来指明基类成员在派生类中的最高访问权限的。

  1. 不管继承方式如何,基类中的private成员在派生类中始终不能使用(不能在派生类的成员函数中访问或调用)。

注意:不能使用不代表没有继承,派生类是完完整整的继承了基类的所有成员的,其内存结构如下:
C++继承探究_第2张图片
基类是作为一个对象完整继承在派生类中的,即使派生类对其不可访问

3.如果希望基类的成员能够被派生类继承并且毫无障碍地使用,那么这些成员只能声明为public 或protected;只有那些不希望在派生类中使用的成员才声明为private。

4.如果希望基类的成员既不向外暴露(不能通过对象访问),还能在派生类中使用,那么只能声明为 protected。

  1. 如果一个类不希望被继承,有两种方法:

C++98:把类的构造函数私有化。
C++11:在类定义时在类名后添加关键字:final

3、如何构造基类

  • 创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。
  • 如果没以指定基类构造函数,将使用基类的默认构造函数。
  • 可以用初始化列表指明要使用的基类构造函数。
  • 基类构造函数负责初始化被继承的数据成员;派生类构造函数主要用于初始化新增的数据成员。
  • 派生类的构造函数总是调用一个基类构造函数,包括拷贝构造函数。
  • 实例化一个派生类对象时,先构造基类,才会构造派生类;析构时,先调用派生类的析构,才会调用基类的析构函数(先进先出的栈模型,如果是先调用基类的析构,会出问题,如:如果析构基类后,派生类析构之前用到了基类的成员呢?所以直接从设计上杜绝了这种问题的发生)

如果基类的拷贝构造、赋值重载这些函数特殊(深拷贝),那么派生类也需要写特定的函数对应,而且对于基类的成员部分初始化要显示调用基类的成员函数

#include          // 包含头文件。
using namespace std;        // 指定缺省的命名空间。
               
class A {        // 基类
public:
    int m_a;
private:
    int m_b;
public:
    A() : m_a(0) , m_b(0)                     // 基类的默认构造函数。
    { 
        cout << "调用了基类的默认构造函数A()。\n";  
    }
    A(int a,int b) : m_a(a) , m_b(b)     // 基类有两个参数的构造函数。
    { 
        cout << "调用了基类的构造函数A(int a,int b)。\n";  
    }
    A(const A &a) : m_a(a.m_a+1) , m_b(a.m_b+1)   // 基类的拷贝构造函数。
    {
        cout << "调用了基类的拷贝构造函数A(const A &a)。\n";
    }
               
    // 显示基类A全部的成员。
    void showA() { cout << "m_a=" << m_a << ",m_b=" << m_b << endl; }
};
               
class B :public A        // 派生类
{        
public:
    int m_c;
    B() : m_c(0) , A()             // 派生类的默认构造函数,指明用基类的默认构造函数(不指明也无所谓)。
    {
        cout << "调用了派生类的默认构造函数B()。\n";
    }
    B(int a, int b, int c) : A(a, b), m_c(c)           // 指明用基类的有两个参数的构造函数。
    {
        cout << "调用了派生类的构造函数B(int a,int b,int c)。\n";
    }
    B(const A& a, int c) :A(a), m_c(c)              // 指明用基类的拷贝构造函数。
    {
        cout << "调用了派生类的构造函数B(const A &a,int c) 。\n";
    }
           
    // 显示派生类B全部的成员。
    void showB() { cout << "m_c=" << m_c << endl << endl; }
};          
             
int main()
{
    B b1;                 // 将调用基类默认的构造函数。
    b1.showA();     b1.showB();
       
    B b2(1, 2, 3);      // 将调用基类有两个参数的构造函数。
    b2.showA();     b2.showB();
            
    A a(10, 20);      // 创建基类对象。
    B b3(a, 30);      // 将调用基类的拷贝造函数。
    b3.showA();     b3.showB();
}     

4、基类和派生类对象赋值转换

派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片
或者切割。寓意把派生类中父类那部分切来赋值过去。基类对象不能赋值给派生类对象。(如果赋值了,那么派生类多出来的成员变量怎么办?)
C++继承探究_第3张图片

5、继承中的作用域

  1. 在继承体系中基类和派生类都有独立的作用域。
  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,而不是函数重载,都不在一个作用域中 (在子类成员函数中,可以使用 基类::基类成员 显示访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  4. 注意在实际中在继承体系里面最好不要定义同名的成员。
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected :
 string _name = "小李子"; // 姓名
 int _num = 111;   // 身份证号
};
class Student : public Person
{
public:
 void Print()
 {
 cout<<" 姓名:"<<_name<< endl;
 cout<<" 身份证号:"<<Person::_num<< endl;
 cout<<" 学号:"<<_num<<endl;
 }
protected:
 int _num = 999; // 学号
};
void Test()
{
 Student s1;
 s1.Print();
};

二、菱形继承的问题及解决方案

C++中的继承有多种形态,单继承,多继承,菱形继承,其中菱形继承是多继承的特殊形态

单继承:一个子类只有一个直接父类时称这个继承关系为单继承
C++继承探究_第4张图片
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
C++继承探究_第5张图片
菱形继承:菱形继承是多继承的一种特殊情况。
C++继承探究_第6张图片
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。
在Assistant的对象中Person成员会有两份。

C++继承探究_第7张图片

针对此问题,C++的设计者通过引入虚继承的概念来解决,如上面的继承关系,在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";
}

三、虚继承的原理

虚拟继承解决数据冗余和二义性的原理 为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型。

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.B::_a = 1;
 d.C::_a = 2;
 d._b = 3;
 d._c = 4;
 d._d = 5;
 return 0;
}

通过内存窗口和对成员变量的赋值,可以看到各个成员变量和对象的内存位置:
C++继承探究_第8张图片

蓝色框的是B类、红色框的是C类,绿色框的是A类;可以看到,通过使用虚继承,会把造成数据冗余的类抽出来,这样就没有了数据冗余和二义性问题了。但是,抽出来单独存放后,怎么找到A类呢?还有B、C类中里面除了成员变量,存的那玩意是啥呢?(e0 7b d1 00 …)

C++继承探究_第9张图片

B和C虚继承后,不再对象内存储A,里面反而多了个指针,这个指针指向指向的一张表。这张表记录了虚继承的基类成员的偏移量。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量 可以找到下面的A。

四、继承 VS 组合

继承:类继承允许你根据其他类的实现来定义一个类的实现。这种通过生成子类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,父类的内部细节对子类可见。继承的操作权限更大,一定程度的破坏了父类的封装性,且父子类之间的耦合度较高。

组合:对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。各个类的封装性得以保持,耦合度低。

继承和组合各有优缺点。类继承是在编译时刻静态定义的,且可直接使用,因为程序设计语言直接支持类继承。类继承可以较方便地改变被复用的实现。当一个子类重定义一些而不是全部操作时,它也能影响它所继承的操作,只要在这些操作中调用了被重定义的操作。

但是类继承也有一些不足之处。首先,因为继承在编译时刻就定义了,所以无法在运行时刻改变从父类继承的实现。更糟的是,父类通常至少定义了部分子类的具体表示。因为继承对子类揭示了其父类的实现细节,所以继承常被认为“破坏了封装性” 。子类中的实现与它的父类有如此紧密的依赖关系,以至于父类实现中的任何变化必然会导致子类发生变化。当你需要复用子类时,实现上的依赖性就会产生一些问题。如果继承下来的实现不适合解决新的问题,则父类必须重写或被其他更适合的类替换。这种依赖关系限制了灵活性并最终限制了复用性。一个可用的解决方法就是只继承抽象类,因为抽象类通常提供较少的实现。

对象组合是通过获得对其他对象的引用而在运行时刻动态定义的。组合要求对象遵守彼此的接口约定,进而要求更仔细地定义接口,而这些接口并不妨碍你将一个对象和其他对象一起使用。这还会产生良好的结果:因为对象只能通过接口访问,所以我们并不破坏封装性;只要类型一致,运行时刻还可以用一个对象来替代另一个对象;更进一步,因为对象的实现是基于接口写的,所以实现上存在较少的依赖关系。

对象组合对系统设计还有另一个作用,即优先使用对象组合有助于你保持每个类被封装,并被集中在单个任务上。这样类和类继承层次会保持较小规模,并且不太可能增长为不可控制的庞然大物。另一方面,基于对象组合的设计会有更多的对象 (而有较少的类),且系统的行为将依赖于对象间的关系而不是被定义在某个类中。
  这导出了我们的面向对象设计的一个原则:
  两者关系在is-a和a part of都相差无几的情况下,优先使用对象组合,而不是类继承。

你可能感兴趣的:(C/C++,c++,java,开发语言)