【c++】继承(继承的定义格式、赋值兼容转换、多继承、派生类默认成员函数规则、继承与友元、继承与静态成员)

简介: 本文深入探讨了C++中的继承机制,作为面向对象编程(OOP)的核心特性之一。继承通过允许派生类扩展基类的属性和方法,极大促进了代码复用,增强了代码的可维护性和可扩展性。文章详细介绍了继承的基本概念、定义格式、继承方式(public、protected、private)、赋值兼容转换、作用域问题、默认成员函数规则、继承与友元、静态成员、多继承及菱形继承问题,并对比了继承与组合的优缺点。最后总结指出,虽然继承提高了代码灵活性和复用率,但也带来了耦合度高的问题,建议在“has-a”和“is-a”关系同时存在时优先使用组合。

前言

       在c++这门强大的编程语言中,面向对象编程(OOP)是一项核心特性,而继承则是OOP的重要支柱之一。继承机制极大地促进了代码的复用,增强了代码的可维护性和可扩展性。本篇文章,作者将和大家深入探讨C++中的继承机制。


一、什么是继承

       继承(inheritance)是面向对象编程当中实现代码复用最重要的手段。继承允许我们在原有的类的基础之上进行扩展,创建一个新的类(叫做子类或派生类),该类可以继承原有类(叫做父类或基类)的属性和方法。继承体现了面向对象编程的层次结构--从简单到复杂的过程。


举个例子,有两个类teacher和student,分别表示“老师”和“学生”。对于这两者而言,可以具有共同的属性(如姓名、年龄、地址等),那么如果我们分别实现两个类,就会造成代码冗余。而继承就可以很好地解决此类问题。我们首先实现一个类叫做“person”,将它们的共同属性封装起来,然后让这两个类继承person类,再将一些特有属性定义在两类当中。



二、继承的定义

1. 定义格式

       继承的定义格式如下:

class (派生类) : (继承方式) (基类)
{
    //属性和方法
}

接下来我们根据刚才的例子,实现一个简单的继承关系:

#include <iostream>
#include <string>
using namespace std;
 
//基类
class person
{
    string _name;//姓名
    string _address;//地址
    int _age;//年龄
};
 
//派生类
class teacher : public person
{
    string _title;//职称
};
 
//派生类
class student : public person
{
    string _id;//学号
};
 
int main()
{
    person p;
    student s;
    teacher t;
}


调试窗口:



可以看到,student对象和teacher对象都继承了person类的成员。


从内存分布上来讲,派生类对象当中的基类部分位于低地址,派生类自己的成员位于高地址。


特别注意:当基类和派生类都是类模板时,派生类使用基类的成员函数要声明类域。例如:

template<class T>
class Stack : public vector<T>
{
public:
    void push(const T& x)
    {
        //声明类域
        vector<T>::push_back(x);
    }
    //...
};

2. 继承方式

       我们刚才在定义student和teacher时,在基类person之前加了一个 “public” 。该 “public” 并不是访问限定符,而是继承方式。与访问限定符相同,继承方式也用public、protected、private来表示。



那么不同的继承方式所带来的效果有何不同呢?


实际上,继承方式与访问限定符共同决定了派生类访问基类成员的权限:



对于继承方式,需要注意以下几点:


1. 无论以什么方式继承,基类的私有成员在派生类当中都是无法访问的。这里的“无法访问”并不是指基类私有成员没有继承到派生类当中,而是语法限制导致不能访问。


2. 如果想要基类成员在派生类当中可以访问,而在类外无法访问,就将其设置为保护成员。


3. 定义派生类时,继承方式可以省略。此时若派生类标签是struct,则默认继承方式是public;若是class,则默认继承方式是private。不过最好还是显式注明继承方式。


4. 在实际运用当中,public继承最为常用,而protected继承和private继承使用较少。


三、赋值兼容转换

       赋值兼容转换(也叫做切片),指的是派生类的对象可以直接赋值给基类的对象/引用,派生类对象的指针也可以直接赋值给基类的指针,而且赋值过程不会产生临时对象,寓意是将派生类中基类的成员部分切割出来,赋值给基类。



代码示例:

#include <iostream>
using namespace std;
 
class A
{
    int a = 1;
    int b = 2;
};
 
class B : public A
{
    int c = 3;
};
 
int main()
{
    B m;//派生类
 
    A n = m;//派生类对象赋给基类对象,通过调用基类的拷贝构造来完成
 
    A* p = &m;//派生类的指针赋给基类的指针
 
    A& r = m;//派生类对象赋给基类的引用
    return 0;
}


需要注意:


1. 基类的对象不能赋值给派生类对象。


2. 基类的引用或指针可以通过强制类型转换赋值给派生类的引用或指针,但只有在基类的引用或指针指向一个派生类的对象时才是安全的。


四、继承当中的作用域问题

继承当中,基类和派生类都有各自独立的作用域。


       当基类和派生类有同名成员时(前提是保证派生类有权限访问基类的该成员),派生类当中就不能直接访问基类的同名成员,这种状况叫做隐藏。当然,你也可以通过声明类域的方式来访问基类同名成员,但是在继承体系当中最好不要使用同名成员。注意:对于成员函数,只要函数名相同就构成隐藏。


代码示例:

#include <iostream>
using namespace std;
 
class A
{
public:
    int m = 1;
    int n = 2;
};
 
class B : public A
{
public:
    void print()
    {
        cout << "m : " << m << endl;
        cout << "n : " << n << endl;
    }
private:
    int m = 10;//基类中的m被隐藏
};
 
int main()
{
    B b;
    b.print();
    return 0;
}



五、派生类的默认成员函数规则

       接下来我们介绍一下派生类的构造函数、拷贝构造、析构函数、赋值重载的执行规则。


1. 派生类的构造函数需要先调用基类的构造函数来初始化基类部分的成员,然后再初始化自己的成员。如果基类没有默认构造函数,则需要通过初始化列表传参构造,否则会发生编译报错。


2. 与构造函数相同,派生类的拷贝构造函数也需要先调用基类的拷贝构造函数来初始化基类部分的成员。


3. 派生类的赋值重载需要调用基类的赋值重载来完成基类部分的拷贝赋值。需要注意的是,两个赋值重载函数构成隐藏, 所以我们在显式实现派生类赋值重载时,调用基类赋值重载需要声明类域。


4.  派生类的析构函数在完成自己成员的清理之后,会调用基类的析构函数清理基类成员。注意:基类析构函数在不加virtual关键字的情况下,与派生类析构函数构成隐藏。


可以发现,派生类的默认成员函数常常需要调用基类的相应函数,以确保基类部分得到适当的构造、拷贝、赋值或销毁。



那么我们是否可以利用这一规则,来实现一个不能被继承的类呢?


方法1:将基类的构造函数设置为私有成员,那么派生类就无法调用基类的构造函数,无法实例化出对象。


方法2:使用c++11新关键字final,限制该类不能被继承:

class A final

六、继承与友元

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


举个例子:

#include <iostream>
using namespace std;
 
class B;//这里需要进行声明,否则友元函数无法识别类B
class A
{
    friend void fun1(const A& x, const B& y);
protected:
    int a = 1;
};
 
class B : public A
{
private:
    int b = 2;
};
 
void fun1(const A& x, const B& y)
{
    cout << x.a << endl;
    cout << y.b << endl;//报错,无法访问
}


七、继承与静态成员

        当基类定义了一个静态成员,那么整个继承体系当中只有这一个静态成员。无论有多少个派生类,都只有这一个静态成员的实例。


代码示例:

#include <iostream>
using namespace std;
 
class A
{
public:
    static int a;
};
 
int A::a = 0;
 
class B : public A
{
 
};
 
int main()
{
    cout << &A::a << endl;
    cout << &B::a << endl;
    return 0;
}



可以看到,它们的地址是相同的。


八、多继承以及菱形继承问题

单继承:一个派生类只由一个直接基类所继承,称之为单继承。



而多继承, 指的是一个派生类有两个及以上直接基类。



代码示例:

//基类
class B
{
    //...
public:
    int a = 1;
};
//基类
class C
{
    //...
public:
    int b = 2;
};
//派生类
class A : public B, public C
{
    //...
public:
    int c = 3;
};


一般情况下,多继承当中的内存分布是:根据语法层面,先继承的基类部分位于低地址,后继承的基类部分位于高地址,最高地址处是派生类自己的成员。


多继承增加了代码复用性和灵活性,但是该机制看似非常好用,实则十分鸡肋。菱形继承就是一个典型的缺陷。什么是菱形继承呢?


菱形继承是多继承的一种特殊情况,如图:



类A继承了类B和类C,但是B和C又分别继承了类D的成员。此时由于类B和类C都含有一份类D的成员,所以类A当中就会有两份相同成员,造成了数据冗余和二义性的问题。


代码示例:

#include <iostream>
using namespace std;
class A
{
    //...
public:
    int a = 0;
};
class B : public A
{
    //...
public:
    int b = 1;
};
class C : public A
{
    //...
public:
    int c = 2;
};
class D : public B, public C
{
    //...
public:
    int d = 3;
};
int main()
{
    D x;
    cout << x.a << endl;//报错,a不明确
    return 0;
}


对于这种情况,就需要用到虚继承来解决。具体方法是:在菱形继承的腰部(也就是类B和类C处)使用关键字virtual进行继承。代码示例:

//虚继承
class B : virtual public A
{
    //...
public:
    int b = 1;
};
//虚继承
class C : virtual public A
{
    //...
public:
    int c = 2;
};

注意:虚继承不要在其他地方使用。


      不难发现,菱形继承带来的困扰还是比较棘手。在实际应用当中,如果我们设计出多继承,则一定要仔细检查,尽量不要出现菱形继承的情况。


九、继承与组合

       继承在一定程度上破坏了封装性,并且使派生类与基类之间产生了紧密的依赖,耦合度高。考虑到这些不足之处,我们提出“组合”的概念:


继承当中,每个派生类对象都是一个特殊的基类对象,是一种is-a的关系。


而组合指的是:将一个对象作为另一个对象的成员,是一种has-a的关系。


       组合是继承之外的另一种复用选择。组合类之间并没有很强的依赖关系,耦合度低,写出的代码更容易维护,并且不会破坏类的封装性。所以说如果类之间的关系同时符合“has-a”和“is-a”,那么就尽量使用组合,而不是继承。


总结

       本篇文章,我们介绍了c++面向对象编程的重要特性之一--继承。 不难发现,继承使得我们的代码实现更加灵活,提高了代码复用率,但是其缺点也不可忽视。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤

目录
打赏
0
5
6
0
138
分享
相关文章
【C++面向对象——继承与派生】派生类的应用(头歌实践教学平台习题)【合集】
本实验旨在学习类的继承关系、不同继承方式下的访问控制及利用虚基类解决二义性问题。主要内容包括: 1. **类的继承关系基础概念**:介绍继承的定义及声明派生类的语法。 2. **不同继承方式下对基类成员的访问控制**:详细说明`public`、`private`和`protected`继承方式对基类成员的访问权限影响。 3. **利用虚基类解决二义性问题**:解释多继承中可能出现的二义性及其解决方案——虚基类。 实验任务要求从`people`类派生出`student`、`teacher`、`graduate`和`TA`类,添加特定属性并测试这些类的功能。最终通过创建教师和助教实例,验证代码
80 5
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
183 5
在 C++中,realloc 函数返回 NULL 时,需要手动释放原来的内存吗?
在 C++ 中,当 realloc 函数返回 NULL 时,表示内存重新分配失败,但原内存块仍然有效,因此需要手动释放原来的内存,以避免内存泄漏。
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
217 6
【C++篇】深度解析类与对象(下)
在上一篇博客中,我们学习了C++的基础类与对象概念,包括类的定义、对象的使用和构造函数的作用。在这一篇,我们将深入探讨C++类的一些重要特性,如构造函数的高级用法、类型转换、static成员、友元、内部类、匿名对象,以及对象拷贝优化等。这些内容可以帮助你更好地理解和应用面向对象编程的核心理念,提升代码的健壮性、灵活性和可维护性。
【C++进阶】特殊类设计 && 单例模式
通过对特殊类设计和单例模式的深入探讨,我们可以更好地设计和实现复杂的C++程序。特殊类设计提高了代码的安全性和可维护性,而单例模式则确保类的唯一实例性和全局访问性。理解并掌握这些高级设计技巧,对于提升C++编程水平至关重要。
48 16
类和对象(中 )C++
本文详细讲解了C++中的默认成员函数,包括构造函数、析构函数、拷贝构造函数、赋值运算符重载和取地址运算符重载等内容。重点分析了各函数的特点、使用场景及相互关系,如构造函数的主要任务是初始化对象,而非创建空间;析构函数用于清理资源;拷贝构造与赋值运算符的区别在于前者用于创建新对象,后者用于已存在的对象赋值。同时,文章还探讨了运算符重载的规则及其应用场景,并通过实例加深理解。最后强调,若类中存在资源管理,需显式定义拷贝构造和赋值运算符以避免浅拷贝问题。
类和对象(上)(C++)
本篇内容主要讲解了C++中类的相关知识,包括类的定义、实例化及this指针的作用。详细说明了类的定义格式、成员函数默认为inline、访问限定符(public、protected、private)的使用规则,以及class与struct的区别。同时分析了类实例化的概念,对象大小的计算规则和内存对齐原则。最后介绍了this指针的工作机制,解释了成员函数如何通过隐含的this指针区分不同对象的数据。这些知识点帮助我们更好地理解C++中类的封装性和对象的实现原理。
类和对象(下)C++
本内容主要讲解C++中的初始化列表、类型转换、静态成员、友元、内部类、匿名对象及对象拷贝时的编译器优化。初始化列表用于成员变量定义初始化,尤其对引用、const及无默认构造函数的类类型变量至关重要。类型转换中,`explicit`可禁用隐式转换。静态成员属类而非对象,受访问限定符约束。内部类是独立类,可增强封装性。匿名对象生命周期短,常用于临时场景。编译器会优化对象拷贝以提高效率。最后,鼓励大家通过重复练习提升技能!
AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等