新C++(6):继承那些事儿

本文详细探讨了C++中的继承机制,包括继承的概念、切片对象与临时对象的区别、析构函数的调用顺序以及虚继承解决的二义性问题。同时,文章还强调了友元关系不继承以及静态成员变量的特性,并对比了继承与组合两种类设计模式的适用场景。最后,特别指出菱形继承可能导致的数据冗余和二义性问题,以及如何通过虚继承来解决这些问题。
摘要由CSDN通过智能技术生成

"你在酒杯还未干的时间里,收藏这份情谊"

一、回顾继承

什么是继承?

继承是面向对象编程语言的三大特征之一。通过继承机制,面向对象的程序设计可以很大限度地对代码进行复用。

它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。
继承 呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用, 继承是类设计层次的复用。

C++中继承定义格式/继承关系与访问限定符

二、切片对象vs临时对象

我们先来看看下面的代码。

int main()
{
    double i = 2.3;
    int j = i;
    cout << j << endl;
    return 0;
}

初始化一个int类型的变量j,但是我们用的不是同一类型的其他变量i。事实上,不同类型的变量是不能进行这样的操作的,因此,为了保证可行性,这里会发生“隐式类型转换”。不是将i的值赋值给j,而是在这个过程中产生一个临时变量 ,先将i的值给临时变量,再由临时变量赋值给j。

口说无凭,我们来看看下面的现象。临时变量具有常量性,因此不是不能用int&,而是需要将"权限"缩小 。

那么上面提到了,当不同类型进行赋值的时候,是会发生隐式类型转换的。那么我回到继承上来。这时我们想用父类对象引用子类对象。

int main()
{   
    Student s;
    Person p = s;
    Person& rp = s;
    return 0;
}

当父类对象引用子类时,并不需要+const修饰。也就意味着,编译器认为这种情况不同于上述发生隐式类型转换的条件。

"父类对象引用子类,不会发生隐式类型转换,也就不会生成临时变量。"

派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。

三、基类析构函数

我们先来回顾类里的6个重要的默认成员函数。

class Person
{
public:
    Person()
    {
        cout << "Person()" << endl;
    }

    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name; // 姓名
};

class Student :public Person
{
public:
    Student()
    {
        cout << "Student()" << endl;
    }

    ~Student()
    {
        cout << "~Student()" << endl;
    }
};

我们定义Student s,那么它不仅仅会去调用它自身的构造函数,还会去调用继承的父类对象部分的构造函数进行初始化。其次,当析构的时候,先调用自己的析构函数,完成清理本类的资源。

我们没有调用析构,为什么会自动调用父类的析构函数呢?我们能否手动调用父类的析构?

这两个函数析构在我们看来不是 不同吗?为什么需要指定类域?难道构成隐藏了吗?

这个知识牵涉到后面多态在析构函数的处理。

不管是Student的析构函数、还是Person中的析构函数。在编译器看来,最后都会被特殊处理成destructor函数名。

什么时候调用父类的析构函数?

在本段的开头,我们就发现,当定义一个派生类时,即便我们在该类的析构函数中没有显式调用其基类的析构函数,但是最后打印出来却是调用了基类的析构函数。

当子类调用析构后,会自动调用父类的析构函数。

四、友元关系不能继承

也就是说基类友元不能访问子类私有和保护成员。

五、解引用/静态成员变量

在开讲本段之前,我们先来回顾类成员你的存储方式。

(1)类对象的存储方式

类体中的成员分为两类,类变量与类方法(函数)。

上述结果清晰地告知我们,一个类的大小,取决于成员变量的大小。

而类里的方法(函数),是被放在一个公共的代码段。

(3)静态成员变量

我们来看看下面的代码段;

class Person
{
public:
    Person() { }

    void Print()
    {
        cout << this << endl;
    }
public:
    static int _count; // 统计人的个数。
    string _name;
};
int Person::_count = 0;

class Student : public Person
{
protected:
    int _stuNum; // 学号
};

此时基类中有一个静态变量count,那么student继承下去后,会生成一份新的count吗?

答案是不会!静态成员变量在基类,在派生类中也是同一个。"静态变量属于整个类"。

(3)nullptr解引用

    Person* ptr = nullptr;
    ptr->Print();
    cout << ptr->_name << endl;

看完上面的一份代码,是否觉得都会奔溃?

    (*ptr).Print();
    cout << (*ptr)._name << endl;

要理解这个点和类成员的存储方式十分密切。解引用的本质是,访问地址处的类型大小的字节。当我们用ptr->Print()是在解引用吗?当然不是!因为并非是在访问类成员变量,而是直接访问的是类成员方法,这些方法早不在类的大小里!

这也就是为什么ptr->name \ (*ptr).name 才会访问出错,因为name是类成员的变量!此时解引用是对空指针的解引用。

当我们再在print中打印_name时,此时this就是空指针(ptr->name),所以也就 成了对空指针的解引用。我们用ptr->Print()调用时,只传了一个值给Print()函数,那就是this(nullptr)。

六、菱形继承

(1)多继承

一个子类只有一个直接父类时称这个继承关系为:单继承。

一个子类有两个或以上直接父类时称这个继承关系为:多继承。

(2)二义性与数据冗余

C++设计继承有一个很大的坑,就是支持了菱形继承。那么什么是菱形继承呢?我们来看看这模型。

这是一种特殊的多继承情况。

也许仅凭图中的模型,不会让你对这菱形继承望而生畏或者攥紧拳头,我们简单地设计一套继承体系。

class Person
{
public:
    string _name; // 姓名
    // id 家庭住址 身份证号码
};

class Student:public Person
{
protected:
    int _num; //学号
};

class Teacher:public Person
{
protected:
    int _id; // 职工编号
};

class Assistant : public Student, public Teacher
{
protected:
    string _majorCourse; // 主修课程
};

这是一个人,但是他有"学生+老师"的双重身份。

我们定义对象Assistant,此时我们想要给这个对象的内部成员name初始化。但是无法直接进行初始化!因为继承的缘故,_name不仅仅在Student有一份,Teacher中也有一份,从而导致了这样的"二义性"问题!因此,我们不得不指名类域,初始化我们想要的值。

但是,一个人是否仅仅需要一个名字就可以了(在当前这个条件下)?当他是学生的时候有一个名字,当他是老师的名字又有另外一个名字?显然这是很不符合常理的。也许你会说,哎呀一个名字就几个字节大小罢了,多了的那份变量似乎显得不过尔尔。但是如果你发现的这份变量是一个100字节、1000字节甚至更多呢?本来我们仅仅需要一份代码不过100byte,却因为菱形继承,足足增加到了200byte大小却是一模一样的类型。 这种 情况,也叫做"数据冗余"。

(3)虚继承

当我们翻看C++的发展史,不难发现,当支持多继承后,肯定会出现菱形继承这样的不好的场景。为此,后一个版本C++又为解决多继承产生的二义性问题 增添了一个关键字virtual。

我们先来看看没有virtual时的菱形继承;

从内存角度来看,很清楚地看到d继承的各个类变量的分布情况。此时,有两个地址指向同一份int a基类。

virual继承

class Base
{}

//virtual加在腰部类上
class Derived1:virtual public Base
{}

class Derived2:virtual public Base
{}

class Example:public Derived1,public Derived2
{}

当加上virtual虚继承后,本来两份的a变成了一份。最先被初始化为4的a,后来被覆盖成了5。但是,我们却在d对象里发现两份像地址一样的数字。

我们找到地址处,得到它们的内容,其实记录的是从该位置到变量a的偏移量。

同样,当变为虚继承时,存储的方式也会发生变化,我们来看看子类B。

这里是通过了B和C的两个指针,指向的一张表。 这两个指针叫虚基表指针 ,这两个表叫虚基表。虚 基表中存的偏移量 。通过偏移量可以找到下面的A

为什么存储偏移量;

通过vs的内存查询可以看到,对于虚继承的共有成员,都是放在末尾的,那么往后面遍历即可,为什么非要存储偏移量?因为存储 这个偏移量就花费4字节,是否造成浪费了呢?

答案肯定不是!一旦继承是虚继承后,B对象、D对象的存储结构都发生了转变。都存储与base成员

变量的偏移量以便访问。

七、继承vs组合

因为C++支持多继承,难免会遇到设计挫的代码中,会遇到菱形继承。有了菱形继承,就有了菱形虚拟继承,不管是学习上还是使用都变得尤为复杂恼人。因此,对于有些场景,不适用继承的类设计,而是适合"组合"这一类设计的方式。

继承是一种 is a 关系 ; 而组合是一种has a关系。

譬如吧,
狗是一种动物 is-a关系更加地清晰贴合。
狗有两双腿 是一个has-a的关系。
class Car    宝马奔驰 是车
{
protected:
    string _color;
    string _type;
}

class BMW:public Car
{
public:
    void Drive(){}
}

class Benz:public Car
{
public:
    void Drive(){}
}

class Tire    //此时这里车有轮胎 是一个has-a关系
{
public:
    string brand; //品牌
    size_t size; //大小
}

class Car
{
public:
    Tire _tire;
    string color;
    string _type;
}

组合和继承都是针对不同场景,有不同适应的类设计方法。在继承方式中,基于protected继承下来的成员变量可供子类访问,但是在组合方式中,protected限定的成员变量,却对组合对象而言不可见。

继承一定程度上破坏了基类的封装,基类的改变很大程度上会影响派生类的实现。从而使得继承关系是一种依赖关系很强的设计。耦合度高

相反,组合类中没有很强的依赖性,因为对象的内部细节是不可见的不管你怎样改,我不能访问到你的东西始终这些东西不会影响到我。从而使得组合关系是一种依赖性不高的设计,耦合度低

如果你对类的封装性很严谨,不妨试试组合吧!

总结:

①父类对象引用子类 不产生临时变量。不是发送隐式类型转换。

②当子类析构调用完成后,会自动调用父类的析构函数。

③友元关系不能继承

④类的静态成员属于整个类,类对象指针解引用并不全都是"解引用"。

⑤菱形继承是不好的,如果实在遇到菱形继承,为避免代码冗余和二义性,应当使用virtual虚继承。

本篇到此结束,感谢你的阅读

祝你好运,向阳而生~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值