自学内容网 自学内容网

C++ 继承

1.继承的概念及定义

1.1 继承的概念

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

1.2 继承定义

1.2.1 定义格式

1.2.2 继承关系和访问限定符

1.2.3 继承基类成员访问方式的变化

类成员/继承方式public继承protected继承private继承
基类的public成员派生类的public成员派生类的protected成
派生类的private成
基类的protected成
派生类的protected成
派生类的protected成
派生类的private成
基类的private成员在派生类中不可见在派生类中不可见在派生类中不可见

总结: 

  • 基类private成员在派生类中的不可见性

    • 基类的private成员变量和成员函数在派生类中是不可访问的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
    • 但是可以通过基类的非私有函数间接访问。
  • 基类protected成员的访问性

    • 如果基类的成员需要在派生类中访问,但不希望在类外部访问,可以将其定义为protectedprotected成员可以在派生类中访问,但在类外部不可访问。
    • 可以看出保护成员限定符是因继承才出现的
  • 访问限定符总结

    • 基类成员在派生类中的访问权限是由基类成员的访问限定符和继承方式共同决定。
    • 基类成员在派生类中的访问方式 = Min(成员在基类的访问限定符, 继承方式),public > protected > private。
  • 继承方式的默认行为

    • 使用class关键字时,默认的继承方式是private
    • 使用struct关键字时,默认的继承方式是public
    • 为了代码的清晰性和可读性,最好显式地写出继承方式
  • 继承方式的实际应用

    • 在实际应用中,public继承最常见,因为这种继承方式保持了基类成员在派生类中的访问权限,支持最大程度的代码复用和扩展性。
    • 在实际运用中一般使用都是public继承,几乎很少使用protectedprivate继承,也不提倡使用,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

2.基类和派生类对象赋值转换

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

这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。

基类对象不能赋值给派生类对象

基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。

代码示例:

#include <string>
using namespace std;

class Person 
{
protected:
    string _name;  // 姓名
    string _sex;   // 性别
    int _age;      // 年龄
public:
    // 全缺省的默认构造
    Person(const string& name = "", const string& sex = "", int age = 0)
        : _name(name), _sex(sex), _age(age) {}
};

class Student : public Person 
{
public:
    int _No;       // 学号
    string _major; // 专业

    // 全缺省的默认构造
    Student(const string& name = "", const string& sex = "", int age = 0, int No = 0, const string& major = "")
        : Person(name, sex, age), _No(No), _major(major) {}
};

void Test() 
{
    Student sobj;
    // 1.子类对象可以赋值给父类对象/指针/引用
    Person pobj = sobj;
    Person* pp = &sobj;
    Person& rp = sobj;

    // 2.基类对象不能赋值给派生类对象
    // sobj = pobj;  // 编译错误

    // 3.基类的指针可以通过强制类型转换赋值给派生类的指针
    pp = &sobj;
    Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
    ps1->_No = 10;

    pp = &pobj;
    Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
    ps2->_No = 10;    // 越界访问    
}

int main() 
{
    Test();
    return 0;
}

3.继承中的作用域

① 在继承体系中基类派生类都有独立的作用域

② 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问

③ 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏(不同作用域中)。

④ 注意在实际中在继承体系里面最好不要定义同名的成员

// 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++中隐藏的概念:

#include <iostream>
using namespace std;
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:
    void fun()
    {
        cout << "func()" << endl;
    }
};
class B : public A
{
public:
    void fun(int i)
    {
        A::fun();
        cout << "func(int i)->" << i << endl;
    }
};
void Test()
{
    B b;
    b.fun(10);
    b.A::fun(); // 调用A中的fun
};

int main()
{
    Test();
    return 0;
}

输出:

func()
func(int i)->10
func()

结论:隐藏只是影响着编译时的名字查找规则,先子类,后父类,最后全局域。也可以通过指定作用域调用

4.派生类的默认成员函数

6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。(先父后子

实例化派生类的构造初始化顺序先继承父类,根据声明顺序先对父类部分先初始化,且只能在初始化列表中(认为继承的父类部分是先声明的)因为构造子类对象时,子类部分可能会用到父类部分的成员,如果不先初始化会出现随机值
6. 派生类对象析构清理先调用派生类析构再调基类的析构。(先子后父,派生类析构中若显示调用基类析构不会保证前者,同样可能会用到基类的成员,这样防止内存泄露)

#include <iostream>
#include <string>

using namespace std;

class Person {
public:
    // 默认构造(全缺省)
    Person(string name = "", int age = 0)
        : m_name(name)
        , m_age(age)
    {
        cout << "Person()" << endl;
    };

    // 拷贝构造函数
    Person(const Person& p)
        : m_name(p.m_name)
        , m_age(p.m_age)
    {
        cout << "Person(const Person&)" << endl;
    };

    // 赋值重载
    Person& operator=(const Person& p)
    {
        if (this != &p)
        {
            cout << "Person& operator=(const Person&)" << endl;
            m_name = p.m_name;
            m_age = p.m_age;
        }
        return *this;
    };

    // 析构函数
    ~Person() {};

    void Print() {
        cout << "Name: " << m_name << endl;
        cout << "Age: " << m_age << endl;
    }

    string m_name;
    int m_age;
};

class Student : public Person
{
public:
    // 全缺省的默认构造(父类显式调用,可以保证先父后子的初始化顺序)
    Student(string name = "鸽鸽", int age = 18, string major = "唱跳rap篮球")
        : Person(name, age) // 调用基类构造函数
        , m_major(major)
    {
        cout << "Student()" << endl;
    };

    // 拷贝构造函数(如果只是只拷贝并不需要显式给出)
    Student(const Student& st)
        : Person(st) // 自然"切割",调用基类的拷贝构造函数,Person(const Person& p),此时的p引用的是子类切出去的父类部分
        , m_major(st.m_major)
    {
        cout << "Student(const Student&)" << endl;
    };

    // 赋值重载
    Student& operator=(const Student& st)
    {
        if (this != &st)
        {
            cout << "Student& operator=(const Student&)" << endl;
            Person::operator=(st); // 调用基类的赋值操作符,同上面的切割
            m_major = st.m_major;
        }
        return *this;
    };

    // 析构函数  // 由于多态,析构函数的名字会被统一处理成destrutor()
    ~Student() 
    {
            cout << "~Student()" << endl;
            // 不能调用基类的析构函数,因为子类可能会使用父类的资源,导致内存泄漏,也就是不能先子后父的析构顺序
    };

    void Print() // 隐藏父类的Print(),但可以调用父类的Print()
    {
        Person::Print();
        cout << "Major: " << m_major << endl;
    }
private:
    string m_major; // 专业
};

int main()
{
    Person p("小明", 18);
    p.Print();
    cout << endl;

    Student s1; // 调用默认构造,继承了基类的成员变量
    s1.Print();
    cout << endl;

    Student s2("张三", 20, "计算机科学与技术");
    s2.Print();
    cout << endl;

    Student s3 = s2; // 拷贝构造
    s3.Print();
    cout << endl;

    s2 = s3;         // 赋值操作符
    s2.Print();
    cout << endl;

    return 0;
}

输出:

Person()
Name: 小明
Age: 18

Person()
Student()
Name: 鸽鸽
Age: 18
Major: 唱跳rap篮球

Person()
Student()
Name: 张三
Age: 20
Major: 计算机科学与技术

Person(const Person&)
Student(const Student&)
Name: 张三
Age: 20
Major: 计算机科学与技术

Student& operator=(const Student&)
Person& operator=(const Person&)
Name: 张三
Age: 20
Major: 计算机科学与技术

从输出可以看出初始化一个子类对象是先构造它的父类部分,其中Person(st)涉及到的切割原理也叫做赋值兼容。

5.继承与友元

友元关系不能继承,也就是基类友元不能访问子类私有和保护成员。

6. 继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一 个static成员实例 。

6.1 单继承

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

6.2 多继承

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

6.2.1 多继承中的大坑:菱形继承

菱形继承:菱形继承是多继承的一种特殊情况。

 菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant 的对象中Person成员会有两份。

“菱形继承”问题确实是多继承中的一个经典问题。在实践中,要注意类与类之间的继承关系,以实现高内聚、低耦合。

高内聚、低耦合的原则

在实践中,为了提高代码的可维护性和可扩展性,遵循高内聚、低耦合的原则是非常重要的:

  1. 高内聚

    • 高内聚指的是一个模块或类的功能应该尽量单一,并且这些功能是紧密相关的。高内聚的类往往更加专注和易于理解。
  2. 低耦合

    • 低耦合指的是模块或类之间的依赖关系应该尽量减少。不相关的类不应该通过继承或其他紧耦合的方式关联在一起。低耦合可以使系统更灵活,易于修改和扩展。

菱形继承问题

菱形继承(Diamond Inheritance)是指一个类通过多继承方式继承自两个基类,而这两个基类又继承自同一个祖先类,形成一个菱形结构。这种结构可能会导致一些问题,包括:

  1. 二义性问题

    • 当派生类通过两个路径继承了相同的基类时,如果基类中有同名的成员函数或数据成员,会导致二义性问题。编译器无法确定应该调用哪一个基类中的成员。
  2. 重复继承问题

    • 在菱形继承中,祖先类的成员会在最终的派生类中被继承两次,从而导致冗余和不一致的问题。这种重复继承也会增加内存开销
  3. 复杂性问题

    • 多继承,尤其是菱形继承,会使类层次结构变得复杂,难以理解和维护。调试和代码阅读都会变得困难。

代码示例:

// 菱形继承

class Person
{
public:
    string _name;   //姓名
    int _age;       //年龄
    string _sex;    //性别
    string _tel;    //电话号码
    string _id;     // 身份证号
    string _address;// 地址
};
 
class Student : public Person // 学生
{
public:
    string _no;     //学号
    string _school; //学校
    string _major;  //专业
};

class Teacher : public Person // 教师
{
public:
    string _title;  //职称
    string _office;//办公室
};

// Student和Teacher中有重复的部分,导致代码冗余和歧义

class Assistant : public Student, public Teacher //助教
{
public:
    string _job;     //工作职务
    string _salary; //薪水
};

void Test()
{
    Assistant a;
    a._name = "张三";
}

int main()
{
    Test();
    return 0;
}

这里的Student 和Teacher 都继承了Person 类。

 编译报错:

test.cpp(308,7): error C2385: 对“_name”的访问不明确

 可以指定类域访问。

void Test()
{
    Assistant a;
    a.Student::_name = "小张";
    a.Teacher::_name = "张老师";

    // 输出 Student 和 Teacher 中 _name 的地址
    cout << "Address of a.Student::_name: " << &(a.Student::_name) << endl;
    cout << "Address of a.Teacher::_name: " << &(a.Teacher::_name) << endl;
}

输出:

在VS2022调试下监视窗口: 

内存情况:

由于VS2022应该是特殊处理了一下导致打印的地址是一样的,这里我在Linux上演示。

[root@hcss-ecs-9486 dir5]# vim test.cpp
[root@hcss-ecs-9486 dir5]# ./test
Address of a.Student::_name: 0x7fff79fe33c0
Address of a.Teacher::_name: 0x7fff79fe3408

 大部分情况我们是希望Student和Teacher包含的Person是相同的,毕竟一个人的基本信息只有一份。

解决方案

为了解决菱形继承的问题,C++ 引入了虚拟继承(Virtual Inheritance)。通过虚拟继承,派生类在继承基类时,祖先类的成员只会存在一份,从而避免了上述问题。

代码示例:

只需在最开始产生重复的类中加入virtual 关键字。

class Teacher : virtual public Person // 教师
{
public:
    string _title;  //职称
    string _office;//办公室
};

// Student和Teacher中有重复的部分,导致代码冗余和歧义

class Assistant : public Student, public Teacher //助教
{
public:
    string _job;     //工作职务
    string _salary; //薪水
};

从调试下的监视中观察成员的变化:

这样就只保留了一份祖先,所以对不同域的_name 操作实际上是对用一个_name 的操作,是创建一块空间来管理重复的部分。

虚拟继承解决数据冗余和二义性的原理

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

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;
}

下图是菱形继承的内存对象成员模型:这里可以看到数据冗余

下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下面,这个A 同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指 针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。

 下面是上面的Person关系菱形虚拟继承的原理解释:

7. 继承的总结和反思

继承和组合是面向对象编程中两个重要的设计原则,它们提供了复用代码的不同方式。

① public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。

② 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。

《极限编程》(Extreme programming)的指导原则之一是“只要能用,就做最简单的”。一个似乎需要继承的设计常常能够戏剧性地使用组合来代替而大简化,从而使其更加灵活。因此,在考虑一个设计时,问问自己:“使用组合是不是更简单?这里真的需要继承吗?它能带来什么好处?”

继承和组合的比较

面向对象系统中功能复用的两种最常用技术是类继承对象组合(object composition)。正如我们已解释过的,类继承允许你根据其他类的实现来定义一个类的实现。这种通过生成子类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,父类的内部细节对子类可见。

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

继承和组合各有优缺点:

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

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

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

对象组合对系统设计还有另一个作用,即优先使用对象组合有助于你保持每个类被封装,并被集中在单个任务上。这样类和类继承层次会保持较小规模,并且不太可能增长为不可控制的庞然大物。另一方面,基于对象组合的设计会有更多的对象(而有较少的类),且系统的行为将依赖于对象间的关系而不是被定义在某个类中。

这导出了我们的面向对象设计的第二个原则:优先使用对象组合,而不是类继承


原文地址:https://blog.csdn.net/2301_78985459/article/details/140548113

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!