C++继承以及菱形继承

C++继承以及菱形继承,第1张

C++面向对象——继承 问题的引出

假如我们需要给某个高校制作一款人员信息管理系统,学习过C++之后我们知道可以给每个职业设置一个class,到每个个体的时候再具体实例化出一个对象就行了,假如该高校的人员信息管理系统中只需要给学生、老师、保安人员三类人群进行设计。


我们将三个类放置在下面:

class Student{
    string _name;//姓名
    int age;//年龄
    int _stdID;//学生卡ID
 	
};

class Teacher{
    string _name;
    int age;
    int _thID;//教师卡ID
};

class Worker{
    string _name;
    int age;
    int _wkID;//工卡卡号
}

我们明显发现,这三个类中大部分的成员变量其实是重复的,真正每个人不同的地方只有证件编号不同。


那如何解决大量重复字段的冗余问题呢?面向对象编程语言为我们实现了解决的方式:继承(inheritance)机制。


继承是面向对象程序设计语言中使得代码得以复用的重要手段,它允许程序员在保持原有类特征的基础上进行扩展,增加功能。


原有的类可以叫父类/基类,而扩展后产生的新的类叫子类/派生类。


继承向我们展现了面向对象程序设计的层次结构。


因此我们可以位上面的例子,定义出一个基础类Person,该类有两个成员变量,分别是姓名、年龄,当派生类继承了基类后,派生类中就已经拥有了基类的成员(成员变量、成员函数)。


我们就不用再把这两个成员变量写入派生类当中了。


class Person{
public:
    //公共的成员方法,可以打印一下该对象的基本信息
    void printInfo(){
        std::cout <<"name: " << _name << std::endl;
        std::cout <<"age: " << _age << std::endl;
    }
protected:
    string _name = "张三";//缺省值
    int _age = 18;
};

class Student : public Person{
protected:
    int _stuID;
};

class Teacher : public Person{
protected:
    int _thID;
};

class Worker : public Person{
    int _wkID;
};
继承定义 定义格式:

下面我们看到的Person是父类,也称作基类。


Student是子类,也可以叫做派生类

class Sudent : public Person

先写上class关键字,后面跟上派生类的名称,然后写上冒号‘ : ’,冒号后面的public是继承方式,最后面的是基类的类名。


继承方式与访问方式一样,有三种权限限定符:public、protected、private

继承方式与访问限定符不同导致成员访问方式的变化
类成员/继承方式public继承protected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员派生类不可见派生类不可见派生类不可见

学习过C++的class后,我们都知道访问限定符的访问权限是public>protected>private,所以上面的表格,只需要取横纵上面限定符权限最小的那个就可以了。


而且我们注意到,基类的private成员不管是什么继承方式,在派生类中都是不可见的。


同时,C++中class的默认继承方式是private,而C++兼容的C语言中的结构体struct的默认继承方式是private。


实际使用中,绝大多数情况下使用的是public继承,很少使用到protected/private继承,因为使用了protected和private继承的成员都只能在派生类的类内部使用。


实际中的扩展性很弱,也难以后期维护。


建议大家在写继承格式的时候显式地写出继承方式。


特别需要说明的一点是,基类的private成员在继承过程中,并不是没有继承给派生类才导致它不可见;在继承过程中,基类部分会作为一个整体一起继承给派生类,因此基类的private成员是占据着派生类对象的物理空间的,只是这部分物理空间派生类无法访问罢了。


所以一个类作为基类的时候,尽量不要使用private修饰符修饰自己的成员,因为这样在派生类中是不可见的,尽量使用protected。


基类和派生类对象之间的赋值转换

派生类对象可以将自己赋值给基类的对象/基类的指针/基类的引用。


我们可以把这种行为叫做切片,即将派生类中属于基类的那部分切下来赋值过去。


但是基类对象不可以将自己赋值给派生类的对象

基类的指针可以通过强制类型转换赋值给派生类的指针,但是必须是基类的指针指向了派生类的对象时才是安全的。


如果基类是多态类型,可以使用dynamic cast识别后再进行安全转换。


继承中的作用域问题
  1. 在继承体系中,基类与派生类之间各自有各自独立的作用域。


  2. 基类和派生类如果有同名成员,派生类的成员会将基类的同名成员屏蔽掉,这种情况被称为隐藏,也可以叫做重定义。


    如果想要访问基类的同名成员,需要在该成员名称前面加上基类的类域名即可。


  3. 如果是该同名成员是成员函数,那么只要函数名相同就会构成隐藏关系,这里要着重说明一下,函数隐藏与函数重载是不一样的!函数重载的前提是两个函数在同一作用域下,而我们刚刚提过隐藏发生在基类和子类两个不同的类作用域之中,其次是函数重载要求返回值不相同或者参数不同,但是隐藏关系没有这些要求,只要两个函数名相同,不管返回值、参数相同或是不同,就直接构成了隐藏关系。


  4. 因此,建议大家在使用C++的继承机制时,尽量不要在基类和子类中定义相同名称的成员。


派生类的默认成员函数

C++会为每个类默认生成六个成员函数,默认成员函数是程序员不写,编译器自动帮我们生成的成员函数。


接下来挨个分析一遍。


默认构造:

首先是构造函数,派生类的构造函数必须去调用基类的构造函数来初始化它继承自基类的那部分成员。


如果基类没有默认的构造函数(只要是不用传参的都是默认构造函数),则必须在派生类的构造函数的初始化列表阶段显式调用。


现在我们分析一下当我们不写,编译器默认生成的那个默认构造函数,它的处理方式是:①将从基类那里继承来的基类成员作为一个整体,调用基类的默认构造函数初始化;②自己的自定义类型成员,调用它的默认构造函数;③自己的内置类型成员不会做处理(除非在声明时给了缺省值)

class Person{
public:
    string _name;
    int _age;
    
    Person(string name, int age)
        :_name(name)
        ,_age(age){}//提供了一个带参数的构造,这样编译器就无法给我们生成默认构造函数了
};

class Student{
private:
    string _address;
    int _stuNO;
};

int main(){
    Student s1;//这里就会报错
    return 0}

为什么会报错呢,因为我们根据上面的①可以知道,我们没有给派生类Student写构造函数,编译器会帮我们生成一个默认的构造函数,它会去调用基类Person的默认构造函数来把从基类继承过来的这部分进行初始化,但是我们给基类Person写了一个带参的构造函数,基类没有默认构造函数可以使用了,这样就会报错了,VS2022中报错如下:

所以推荐大家使用初始化列表来显式地给派生类初始化,使用一个基类的匿名对象,来给子类继承到的基类部分初始化:

Student(const char* name, int age, const char* adderss, int stuNO)
    :Person(name, age)//基类的匿名对象,并且可以使用参数显示赋值初始化
    , _address(address)
    , _stuNO(stuNO){}

int main(){
    Student s1("张三", 18, "北京", 111);
    return 0
}

上面这种初始化方式不管基类有自己的默认构造函数,还是有带参数的构造函数,都可以完成对派生类的初始化了。


拷贝构造:

派生类的拷贝构造,我们不写,编译器会默认生成一个,其处理顺序与构造函数类似:①继承的基类的成员作为一个整体,调用基类的拷贝构造;②自己的自定义类型成员,调用它的拷贝构造;③自己的内置类型成员,调用值拷贝


正常来说编译器为我们默认生成的拷贝构造就可以使用不用再自己去写了,除非派生类自己内部有指针,指向了动态开辟的空间,这时需要我们手动将其改为深拷贝。


//手动写出派生类的拷贝构造
Student(const Student& s)
    :Person(s)//相当于将派生类对象直接赋给父类的匿名对象,通过上面提到的切片行为完成
    ,_address(s._address)
    ,_stuNO(s._stuNO){}

注意在显式写出派生类的拷贝构造时,一定要调用基类的拷贝构造,即上面完成切片 *** 作的Person(s),如果没有调用基类的拷贝构造,则编译器会去调用基类的默认构造函数,就会仅仅只是将派生类继承自基类的部分进行构造初始化,而并没有完成拷贝的作用!

//父类的默认构造函数
Person(){
	_name = "xxx";
    _age = 10;
}

Student(const Student& s)
    //:Person(s)
    :_address(s._address)
    ,_stuNO(s._stuNO){}

int main(){
	Student s1("张三", 18, "西安", 111);
    Student s2(s1);//拷贝构造,用s1拷贝构造s2
    return 0
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d04aK1uw-1648359386517)(C:\Users\83883\Desktop\没完成拷贝功能.png)]

我们可以看到,因为调用到的是父类的默认构造没有调用到父类的拷贝构造,所以s2明显只完成了子类自己的成员的拷贝,而继承下来的父类成员并没有实现拷贝,而是使用了父类默认构造函数中的缺省值。


赋值重载:

赋值重载在很多情况下与拷贝构造相似,它们的处理方式也很相似,这里就不再过多赘述。


只要知道子类的赋值重载需要调用父类的赋值重载完成父类部分的成员的 *** 作即可,

析构函数:

子类析构函数我们如果不写的话,编译器会帮助生成一个默认的析构函数,处理方式也与上面的一致:①继承的父类成员作为一个整体,调用父类的析构函数;②子类自己的自定义类型成员去调用它的析构函数;③子类自己的内置类型成员不作处理

如果我们要自己实现的话,要注意:子类的析构函数与父类的析构函数构成了隐藏关系

~Student(){
    //释放掉子类自己的一些资源
    ~Person();
}
//按正常逻辑来说应该是这样,先子类调用自己的析构函数,将父类的部分调用它的析构函数。


但是实际上这样编译器会报错

子类的析构函数和父类的析构函数,编译器都会进行特殊处理,所有类的析构函数名会被被编译器修改为destructor(),所以子类析构函数和父类的析构函数会构成隐藏关系。


编译器这样处理的原因是析构函数要构成多态重写,重写的一个要求就是函数名必须相同。


所以我们想要在子类的析构函数中调用父类的析构函数,需要添加域名:

~Student(){
    Person::~Person();
}//这样编译器就不会再报错了

但是我们这样 *** 作后,会重复调用父类的析构函数,因为子类的析构函数在执行结束后会自动去调用父类的析构函数,因为C++中规定先定义的后析构,后定义的先析构。


所以我们不用在子类的析构函数里显式调用父类的析构函数,编译器会自动去调用的。


继承和友元

先说结论:友元关系不能继承。


如果想要子类也实现友元,没有别的方法只能在子类中再次声明一下友元关系即可。


class Person{
public:
    friend void printInfo(const Person& p, const Student& s);
protected:
    string _name;
};

class Student : public Person{
protected:
    int _stuNO;
};

void printInfo(const Person& p, const Student& s){
    std::cout << p._name << std::endl;//正确,因为该函数是Person类的友元函数
    std::cout << s._name <<std::endl;//错误,Student类不会将友元关系继承下来,所以不可以访问被保护的成员_name
}
继承与静态成员

基类定义的static静态成员,则整个继承体系里只会有一个这样的成员,不论继承了多少层,都只会有一个static成员实例。


class Person{
public:
    static int _count;//人数
    
    Person(){
        ++ _count;//每调用一次Person类的构造函数就把_count加1
    }
protected:
    string _name;
};

int Person::_count = 0;

class Student : public Person{
protected:
    int _stuNO;
};

class Pupils : public Student{
protected:
    int _age;
}

int main(){
	Person p1;
    Person p2;
    Student s1;
    Student s2;
    Pupils pu1;
    std::cout << Person::_count << std::endl;
    std::cout << Student::_count << std::endl;
    std::cout << Pupils::_count << std::endl;
    //这三个输出结果是一样的,都是5
    return 0
}
菱形继承和菱形虚拟继承

之前我们举出的所有例子都是单继承,即一个子类有且只有一个直接的父类,多代继承也是单继承,上面这个例子就是多代继承,即Pupils继承自Student,Student继承自Person,他们都是单继承。


如果有一个类,它有两个或两个以上的直接父类,这种继承关系叫做多继承,多继承是存在一定风险的,因此晚于C++的面向对象编程语言Java就直接取消了多继承,只允许单继承。


原因在于多继承可能会导致菱形继承。


这样的多继承并没有什么问题,现实生活中也会有这种的例子,真正产生问题的是下面这种菱形继承:

class B、class C均继承于class A,这里是没有任何问题的,因为到这里只是两个单继承,但是一旦class D发生多继承,继承了B、C,那么根据我们之前说过的继承方面的知识,我们可以知道,D会获得两份A的成员,分别来自B和C,那么这两个A的成员不仅会产生数据冗余,还会造成二义性,如果要给D中继承下来的A类的成员赋值,是给从B继承过来的部分赋值还是给从C中继承过来的部分赋值呢?此时编译器就会报“访问不明确”的错误。


C++为了解决菱形继承的问题,就引入了”virtual“关键字,这里与多态部分的虚函数使用了同一个关键字。


语法为class B : virtual public class Aclass C : virtual public class A注意virtual关键字是加在B、C两个类的地方,而不是加在D的位置。


那菱形虚拟继承是如何改善菱形继承所造成的数据冗余和二义性问题呢?我们下面使用四个类来模拟出菱形继承,并在内存窗口中观察一下菱形继承的数据是如何存储的。


class A{
public:
    int _a;
};
class B : public A{
public:
    int _b;
};
class C : public A{
public:
    int _c;
};
class D : public B, public C{
public:
    int _d;
};

int main(){
    D d;
    d.B::_a = 1;//从B继承而来的A的成员_a设置为1
    d.C::_a = 2;//从C继承而来的A的成员_a设置为1
    d._b = 3;
    d._c = 4;
    d._d = 5;
  
    return 0;
}

在VS2022中打开内存窗口,并输入"&d",获取到d的地址:

那我们加上虚拟继承,即class B : virtual public A | class C : virtual public A之后,再来观察一下内存的情况:

明显发现,使用了虚拟继承后,在原来没有使用虚拟继承的地方,本该存放从B、C中继承而来的A的成员_a值的地方, 却变成了两个像是地址的东西,我们再打开一个新的内存监视窗口,将这个地址输入进去,看一下具体是什么东西。


输入后发现该地址所存储的值都是0,但是下面一行却都存储着一个整数,分别是十六进制的’14’,即20;16进制的’0c’,即12。


20和12都是偏移量,它们分别标记的是在菱形虚拟继承中产生冗余的那一块数据,即将这块数据作为公共部分,只保留一份,原来应该存放B、C中继承A而来的成语_a地方变成了获取偏移量的地址,所以我们想要访问B::_a还是C::_a,都只会拿到偏移量的地址,再通过偏移量访问到唯一一份的_a。


菱形虚拟继承虽然解决了数据冗余和二义性问题,但是明显数据访问变得更加繁琐,因此会造成部分的性能损失。


因此我们应该尽量避免设计多继承,如果实在没办法,一定要使用多继承,那么就一定不要设计出菱形继承,上面我们看到了为了解决菱形继承问题的虚拟继承对复杂度和性能都有很大的影响。


欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/562291.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-01
下一篇 2022-04-01

发表评论

登录后才能评论

评论列表(0条)

保存