序言:
在之前,我们已经完成了对 C++ 初阶的讲解。接下来,我将带领大家学习关于C++ 进阶的相关知识,而今天我给大家介绍的就是关于 C++三大特性之一的——继承。
目录
(一)继承的概念及定义
1、继承的概念
2、继承定义
1️⃣定义格式
2️⃣继承关系和访问限定符
3️⃣继承基类成员访问方式的变化
(二)基类和派生类对象赋值转换
(三)继承中的作用域
(四)派生类的默认成员函数
1、构造和析构
2、拷贝构造
3、operator赋值
4、析构函数
(五)继承与友元
(六)继承与静态成员
(七)复杂的菱形继承及菱形虚拟继承
1、虚拟继承解决数据冗余和二义性的原理
(八)笔试面试题
总结
(一)继承的概念及定义
1、继承的概念
- 继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类;
- 继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程;
- 以前我们接触的复用都是函数复用,继承是类设计层次的复用。
接下来,我们简单的通过代码来大家认识:
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
class Student : public Person
{
protected:
int _stuid; // 学号
};
int main()
{
Student s;
s.Print();
return 0;
}
【分析】
- 这段代码中定义了两个类 Person 和 Student,其中 Student 继承自 Person;
- Person 类包含了一个 Print 函数,用于打印其成员变量 _name 和 _age;
- Student 类包含了一个新的成员变量 _stuid,表示学号,但是没有覆盖 Person 类的 Print 函数。
在主函数中定义了一个 Student 对象 s,然后直接调用了 s 的 Print 函数。由于 Student 类没有覆盖 Person 类的 Print 函数,所以调用的是来自 Person 类的 Print 函数。在函数中,使用了成员变量 _name 和 _age,它们分别从 Person 类中继承而来,而且都赋有默认值。因此,最终程序输出的结果会是:
这里需要注意的是,虽然 Student 类包含了 Person 类中的所有成员变量和函数,但是 Person 类中的成员变量都被声明为了 protected,因此在 Student 类的外部是无法直接访问到这些成员变量的。如果将 _name 和 _age 的访问权限改为 private,则编译器会报错。
下面我们使用监视窗口查看Student对象,可以看到变量的复用。调用Print可以看到成员函数的复用。
一句话来说继承就是:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展
2、继承定义
1️⃣定义格式
下面是 C++ 中定义继承的格式:
class 派生类名 : 访问控制 基类名
{
// 派生类的成员定义
};
- 其中,“派生类名”是新定义的类名,“访问控制”指定了基类成员在派生类中的可访问性,只能为 public、protected 或 private;
- 基类名表示被继承的类名,可以是父类、超类,也可以是基类。
下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类。
2️⃣继承关系和访问限定符
在 C++ 中,派生类可以通过继承基类的成员变量和成员函数来拓展其功能。同时,通过访问限定符,派生类可以控制如何访问继承来的成员。
C++ 中有三种访问限定符:public、protected 和 private。它们定义了成员变量和成员函数的可访问性,如下所示:
- - public:表示公有的,从基类继承来的公有成员将会在派生类中保持公有的可见性,可以被任何地方的代码访问。
- - protected:表示被保护的,从基类继承来的受保护成员将会在派生类中保持受保护的可见性,只能被派生类的成员函数和友元函数访问,不能被外部代码访问。
- - private:表示私有的,从基类继承来的私有成员将无法在派生类中被访问,也不能被派生类的成员函数和友元函数访问。
💨 另外需要注意的是,派生类的成员函数可以访问派生类自己的成员变量和成员函数,以及所有可访问的基类成员变量和成员函数。
例如,如果基类中某个成员变量被声明为 protected,那么派生类可以在自己的成员函数中访问到这个变量。但是,在派生类的成员函数中不能访问基类的 private 成员,因为它们根本就无法被继承。
- 示例如下:
- 报错如下:
3️⃣继承基类成员访问方式的变化
【总结】
- 1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面 都不能去访问它。
- 2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
-
访问权限
外部
派生类
内部
public
✔️
✔️
✔️
protected
❌
✔️
✔️
private
❌
❌
✔️
- 4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
- 5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡 使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
(二)基类和派生类对象赋值转换
在 C++ 中,可以将派生类对象赋值给基类对象。这种赋值操作是安全的,因为派生类对象包含了基类对象的数据成员和成员函数,所以可以将派生类对象当成基类对象来使用。这里有个形象的说法叫切片 或者 切割。寓意把派生类中父类那部分切来赋值过去。
- 例如,如果还是以上述类为基础:
//创建一个 Student 对象:
Student s;
Person p = s;
Person& rp = s; //可以将它赋值给基类 Person 对象:
Person* rrp = &s;
💨 基类对象不能直接赋值给派生类对象,因为基类对象并不包含派生类对象的数据成员和成员函数。
(三)继承中的作用域
在继承体系中基类和派生类都有独立的作用域。
在 C++ 中,继承会改变成员变量和成员函数的作用域;
具体来说,子类可以访问父类的 public 和 protected 成员,但不能直接访问父类的 private 成员;
此外,如果子类的成员函数和父类的成员函数名称相同,那么子类的成员函数会覆盖父类的同名成员函数。
【注意】
派生类中的同名成员变量会覆盖基类中的同名成员变量,如果在子类中需要访问从父类继承来的同名成员变量,可以使用作用域解析运算符 (::) 指定访问父类的成员变量(在子类成员函数中,可以使用 基类::基类成员 显示访问)。这样的写法可以明确表明你要访问的是哪个作用域内的成员变量。
例如:
// 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();
};
- 输出如下:
此时,我们即可指定作用域进行限制:
- 接下来,我给大家以下代码,大家判断一下以下代码中两个 【func】构成什么?
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中的fun和A中的fun不是构成重载,因为不是在同一作用域 ;
- B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
👉 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏👈
(四)派生类的默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类 中,这几个成员函数是如何生成的呢?
1、构造和析构
首先,先给大家看这样的一段代码:
class Person
{
public:
Person(const char* name = "peter")
: _name(name)
{
cout << "Person()" << endl;
}
Person(const Person& p)
: _name(p._name)
{
cout << "Person(const Person& p)" << endl;
}
Person& operator=(const Person& p)
{
cout << "Person operator=(const Person& p)" << endl;
if (this != &p)
_name = p._name;
return *this;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _num; //学号
};
int main()
{
Student s1;
return 0;
}
- 在派生类中我们什么都没有写,接下来当我们去运行程序时,会发生什么呢?
【分析】
- 我们可以发现,即使我们没有写,派生类中也会自动调用构造函数和析构函数。
此时,问题又来了上述是因为我们没有写。那么当我们想显示给出时应该怎么做呢?
具体如下:
当然还有种方法。具体如下:
【小结】
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员;
- 如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2、拷贝构造
那么对于拷贝构造函数是怎么样的呢?
- 输出显示:
同上述一样,当我们想显示的给出时,我们应该怎么做呢?
- 此时,我们就需要用到上述讲到的切片的知识了。具体如下:
【小结】
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3、operator赋值
【小结】
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
4、析构函数
因此,在派生类中的析构函数不用显示的给出。
【小结】
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能 保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象析构清理先调用派生类析构再调基类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲 解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加 virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
总之,派生类默认会继承基类的构造函数、析构函数、拷贝构造函数和赋值操作符重载函数。这些默认成员函数提供了对从基类继承的成员变量和行为的处理和管理。
(五)继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
接下来,我们简单的验证一下。
有如下程序:
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl;
}
int main()
{
Person p;
Student s;
Display(p, s);
}
- 1、首先,带大家看看父类对象是否可以访问友元
2、首先,带大家看看字类对象是否可以访问友元
那么有没有一种方式可以使我子类也能做到呢?
- 有的,那就是在定义一个友元即可实现上述功能
(六)继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子 类,都只有一个static成员实例 。
我们以下述代码进行验证
class Person
{
public:
Person() { ++_count; }
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
💨 这里的【count】是静态成员
(七)复杂的菱形继承及菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承(Diamond Inheritance)是指通过多层继承关系,最终导致派生类间接继承同一个基类,形成了一个菱形的继承结构。这种继承结构可能会引发一些问题和困扰。
下是菱形继承存在的主要问题:
-
二义性(Ambiguity):在菱形继承中,派生类直接或间接继承了两个相同的基类,因此如果两个基类中有同名的成员函数或成员变量,就会产生二义性。编译器无法确定具体使用哪个基类的成员,导致编译错误。这个问题需要通过作用域解析运算符(
::
)来解决,明确指定使用哪个基类的成员。 -
冗余数据:由于派生类间接继承了两个相同的基类,其中每个基类都有自己的成员变量,导致派生类中存在两份相同的数据副本,造成内存空间的浪费。
-
虚基类初始化:为了解决冗余数据问题和二义性问题,C++引入了虚继承(Virtual Inheritance)。通过在派生类对共同基类进行虚继承,可以确保只有一份共同基类的实例。但是,虚继承会引入额外的开销和复杂性,派生类需要在构造函数中显式调用共同基类的构造函数。
-
复杂性和混乱:菱形继承增加了类之间的复杂性,使得代码难以理解和维护。继承关系变得模糊,而且可能会导致设计和重构上的困难。
- 例如以下:
class Person
{
public:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _num; //学号
};
class Teacher : public Person
{
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse; // 主修课程
};
int main()
{
// 这样会有二义性无法明确知道访问的是哪一个
Assistant a;
a._name = "张三";
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "小王";
a.Teacher::_name = "老王";
return 0;
}
【分析】
- 虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和 Teacher的继承Person时使用虚拟继承,即可解决问题;
- 需要注意的是,虚拟继承不要在其他地方去使用。
1、虚拟继承解决数据冗余和二义性的原理
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成 员的模型。
我们先通过以下例子来给大家分析一波:
class A
{
public:
int _a;
};
class B : virtual public A
{
public:
int _b;
};
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关系菱形虚拟继承的原理解释:
【小结】
虚拟继承的原理可以简单概括为以下几点:
-
虚基类子对象的内存布局:虚基类子对象的内存布局在派生类中是共享的,只有一份实例存在。其他派生类通过相对偏移来访问该虚基类子对象。
-
虚基类构造函数的调用:虚基类的构造函数由最终派生类负责调用,通过调用语法指定哪个派生类的构造函数初始化虚基类。这样可以避免不必要的虚基类构造函数调用。
-
虚基类的初始化顺序:虚基类总是在最终派生类的构造函数初始化列表中进行初始化,确保虚基类在其他非虚基类之前初始化。
通过使用虚拟继承,派生类就不会产生多个对同一个虚基类的拷贝,从而避免了数据冗余和二义性问题。它在多重继承场景中提供了一种有效的机制,使得类之间的关系更清晰、易于理解,并且能够正确地处理派生类中的成员访问和调用。
(八)笔试面试题
1. 什么是菱形继承?菱形继承的问题是什么?
菱形继承(Diamond Inheritance)是指一个派生类同时继承了两个或多个基类,而这些基类又共同继承自同一个基类(称为虚基类),形成了继承体系中的菱形结构。
- 下面是一个示例菱形继承的继承关系图:
- 在上述示例中,类 A 是虚基类,它被类 B 和类 C 继承,然后类 D 再从 B 和 C 中派生。这就形成了一个菱形继承结构。
菱形继承带来的主要问题是二义性和数据冗余:
-
二义性(Ambiguity):由于类 D 继承了类 B 和类 C,而这两个基类又都继承了类 A,所以在类 D 中可能存在对于类 A 成员访问的二义性。如果类 B 和类 C 分别定义了与类 A 相同名字的成员函数或成员变量,那么在类 D 中调用这些成员时会产生二义性,编译器无法确定使用哪个基类的成员。
-
数据冗余(Data Redundancy):当派生类有多个对同一个虚基类的直接或间接继承时,同样的数据在派生类中会存在多个拷贝,导致数据冗余。这样会占用额外的内存空间,并且可能引发数据一致性问题。
为了解决菱形继承带来的问题,C++ 中引入了虚拟继承(Virtual Inheritance)机制。使用虚拟继承可以共享虚基类的子对象,避免数据冗余,同时也消除了二义性问题。通过在派生类对虚基类的继承前加上关键字 "virtual",可以指定该基类为虚基类,使得菱形继承问题得以解决。
2. 什么是菱形虚拟继承?如何解决数据冗余和二义性的
菱形虚拟继承是指在菱形继承结构中,通过使用虚拟继承机制来解决数据冗余和二义性的问题。
在菱形继承中,如果派生类有多个对同一个虚基类的直接或间接继承,那么数据在派生类中会存在多个拷贝,导致数据冗余。而二义性则是由于不同的基类可能具有相同的成员函数或成员变量,使得在派生类中调用这些成员时产生二义性。
为了解决这些问题,可以使用虚拟继承。虚拟继承通过共享虚基类的子对象来避免数据冗余,并消除二义性问题。具体解决方法如下:
-
在派生类对虚基类的继承前加上关键字 "virtual",将虚基类声明为虚基类。例如:
class B : virtual public A
。 -
虚基类子对象的内存布局:虚基类子对象在派生类中只存在一份实例,其他派生类通过相对偏移来访问该虚基类子对象。
-
虚基类构造函数的调用:虚基类的构造函数由最终派生类负责调用,在构造函数初始化列表中通过调用语法指定哪个派生类的构造函数初始化虚基类。这样可以避免不必要的虚基类构造函数调用,确保只有一次虚基类的构造。
通过虚拟继承,派生类中只会存在一个虚基类的实例,避免了数据冗余,节省了内存空间。同时,由于虚基类的子对象在内存布局中共享,消除了对虚基类成员的二义性,确保成员的访问和调用是明确的。
需要注意的是,菱形虚拟继承并没有完全解决了多重继承的问题,而是在某些特定情况下使用虚拟继承来避免数据冗余和二义性。在设计类的继承关系时,仍需谨慎考虑继承的复杂性和可能带来的其他问题。
3. 继承和组合的区别?什么时候用继承?什么时候用组合?
继承(Inheritance)和组合(Composition)是面向对象编程中的两种不同的关系建立方式,它们有以下区别:
-
定义:继承是从已有的类中派生出新的类,新的类继承了原有类的属性和方法。组合是将其他类的对象组合成新的类的一部分,新的类与被组合的类没有继承关系。
-
关系:继承是一种"是-一个"(is-a)的关系,即子类是父类的特殊类型。组合是一种"有-一个"(has-a)的关系,即一个类包含其他类的对象作为其成员。
-
代码重用:继承可以通过继承基类的属性和方法来实现代码的重用。组合通过包含其他类的对象来实现代码的重用。
-
灵活性:继承在一定程度上限制了类的灵活性,因为子类与父类之间存在了耦合关系。组合更加灵活,可以根据需要动态地改变所组合的对象。
在选择继承或组合时,需要根据具体情况进行考虑:
-
使用继承的情况:
- 当新的类是已有类的特殊类型,并且在功能上扩展了原有类的功能。
- 当需要重用已有类的代码和数据,并且新的类与原有类之间存在"是一个"的关系。
-
使用组合的情况:
- 当新的类需要使用其他类的功能,但并不是已有类的特殊类型。
- 当需要动态地改变所组合对象的类型或数量。
- 当需要将不同类的功能模块化,并通过组合的方式构建更复杂的类。
总而言之,继承适用于描述"是-一个"的关系和属性、行为的扩展,用于建立类与类之间的层次结构。组合适用于描述"有一个"的关系和模块化的功能组合,用于构建更灵活的类及其组件。选择继承还是组合应根据具体的需求和设计目标进行判断。
- 最后给大家看一到代码题,不知大家是否知道以下代码的输出结果:
#include<iostream>
using namespace std;
class A{
public:
A(char *s) { cout<<s<<endl; }
~A(){}
};
class B:virtual public A
{
public:
B(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};
class C:virtual public A
{
public:
C(char *s1,char*s2):A(s1) { cout<<s2<<endl; }
};
class D:public B,public C
{
public:
D(char *s1,char *s2,char *s3,char *s4):B(s1,s2),C(s1,s3),A(s1)
{ cout<<s4<<endl;}
};
int main() {
D *p=new D("class A","class B","class C","class D");
delete p;
return 0;
}
【分析】
- 首先,定义了一个类 A,包含一个带有参数的构造函数和一个析构函数。
- 类 B 和类 C 分别继承了虚基类 A,它们的构造函数中调用了 A 的构造函数。
- 类 D 多重继承自类 B 和类 C,同时也直接继承了类 A。在其构造函数中分别调用了类 B、类 C 和类 A 的构造函数,以及输出了一个字符串。
- 在主函数中,创建了一个类 D 的对象 p,并传递了一些字符串作为参数。然后通过 delete 关键字释放了这个对象的内存。
根据类的继承关系和构造函数的调用顺序,代码执行的过程如下:
- 创建类 D 的对象 p,首先会调用类 A 的构造函数,输出 "class A"。
- 接着,类 B 的构造函数被调用,它会将参数 "class B" 和 "class A" 传递给类 A 的构造函数,并输出 "class B"。
- 然后,类 C 的构造函数被调用,它同样会将参数 "class C" 和 "class A" 传递给类 A 的构造函数,并输出 "class C"。
- 最后,类 D 的构造函数会将参数 "class D" 传递给 cout 输出流,并输出 "class D"。
- 对象 p 创建完成后,程序执行 delete p 释放对象的内存。
- 最后,main 函数返回 0,程序结束。
因此,程序的输出顺序为:"class A" -> "class B" -> "class C" -> "class D"。
需要注意的是,在类 B 和类 C 的构造函数中,调用了类 A 的构造函数,并使用了虚基类继承,确保类 A 的构造函数只被调用一次。这是因为类 D 中同时继承了类 B 和类 C,如果不使用虚基类继承,会导致类 A 的构造函数被调用多次,引发二义性和数据冗余的问题。通过虚基类继承,可以避免这些问题。
总结
到此,关于继承的相关知识便讲解完毕了。接下来,我们简单的回顾一下本文都学了什么
- 1、首先,我先给大家讲解了关于什么叫做继承,以及继承的定义等相关的知识;
- 2、其次,我给大家介绍了基类和派生类对象的赋值转换的相关的知识;
- 3、接下来就是继承中的作用域和继承与友元的关系二者之间的关系;
- 4、最后就是关于菱形继承的知识。很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设 计出菱形继承。否则在复杂度及性能上都有问题。
以上便是本文的全部内容了,感谢大家的观看与支持!!!