本节目标:
1.继承的概念以及定义
2.基类和派生类对象赋值转换
3.继承中的作用域
4.派生类的默认成员函数
5.继承与友元
6.继承与静态成员
7.复杂的菱形继承及菱形虚拟继承
8.继承的总结和反思
9.笔试面试题
引入:
对于一个学生管理系统,我们假设有三个类,学生,老师,宿管,定义这三个类,可能会重复定义一些成员变量,成员函数,我们如何解决???
此时需要继承,我们定义一个基类person,把重复的成员变量/成员函数定义出一个类
让学生,老师,宿管去继承这个person类
继承的概念及定义
概念:这是面向对象三大特性之一,封装,继承,多态,继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类的特性的基础上进行扩展,增加功能,这与产生新的类,称之为派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
1.什么叫函数复用:函数复用就是避免冗余的代码,也就是通过函数调用实现,你在要实现的函数中发现需要另外一个函数的功能,直接通过函数调用就可以。
2.继承是类设计层次的复用:也就是你的功能类似于函数调用,你新实现的类的部分功能与已经存在的类的功能一样,可以继承已经存在的类,从而达到代码的复用,避免在写相同的东西
先大致理解三个复用的区别,后面会进行详细的讲解,本节重点在继承
继承定义:
定义格式:person是父类,也称基类, student是子类,也称派生类
继承关系和访问限定符:
1.基类的private成员在派生类中无论以什么方式继承都是不可见的,这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它(只能通过基类对外公有的函数接口去调用)
2.为什么我们前面没有提到protected访问限定符的作用,我们之前只是简单的提和private的作用一样,但这里就能突显protected的作用了,基类private成员在派生类中是不能被访问的,如果基类成员不选在类外直接被访问,但需要在派生类中能访问,就定义称protected。可以看出保护成员限定符是因继承才出现的
3.实际上面的表格我们总结一下就会发现,基类的私有成员无论以哪种继承方式在子类中都是不可见的,基类的其他成员在子类的访问方式==min(成员在基类的访问限定符,继承方式),也就是你只需要比较访问限定符和继承方式,哪个小,就是哪个
4.使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
5.在实际运用中一般使用public继承,几乎很少使用protected/private继承,也不提倡使用,因为protected/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
总结:我们一般使用public继承,如果想在派生类内使用,外不使用,可以在基类中protected,如果想在类外类内都是用,基类可以public,因为如果你是private,无论什么继承,派生类只能通过基类的public的函数接口去调用
简单来说,就是用public继承,然后通过基类成员访问限定符来调整
基类和派生类对象赋值转换
切片/切割:派生类对象可以赋值给基类的对象
向上转型:派生类对象的地址或者别名可以赋值给基类的指针/基类的引用
理解:
1.什么叫切片:
派生类对象的内存中包含基类部分和派生类新增部分。当赋值给基类对象时,仅基类部分的数据会被复制,派生类特有的成员(如属性、函数)会被截断(切片),导致数据丢失。
person p;
student s;
p=s;//直接将子类对象赋值给父类对象
所以_No这个数据会丢失
2.什么叫向上转型:向上转型就是将派生类对象视为基类对象的过程
为什么允许向上转型,因为派生类包含基类的所有成员,因此可以被安全地视为基类的实例。你继承是不是在基类的继承上添加一些你特有的,也就是你派生类包含了基类的所有东西,那就可以进行向上转型
Student s("Alice", 12345);
Person& ref = s; // 引用绑定到完整的Student对象
Person* ptr = &s; // 指针指向完整的Student对象
基类指针 / 引用并不复制对象,而是直接指向派生类对象的完整内存。因此,派生类的所有成员(包括基类和新增部分)均被保留
也就是指针和引用是指向那一部分的,但无法访问到子类特有的,只能访问到父类的那一部分
对于这个图片,ref直接指向student的物理内存,但无法访问到_No
切片与向上转型的区别:切片会导致数据缺失,也就是直接把_No去掉,向上转型不会导致数据的缺失,它是直接指向那块物理内存,但无法访问到子类特有的那部分(_No);
注意:
基类对象不能赋值给派生类对象
基类的指针可以强制类型转换赋值给派生类的指针,但是必须是基类的指针是指向派生类对象时才是安全的。这里的基类如果是多态类型,可以使用RRT(Run-Time Type Information)的dynamic_cast来进行识别后进行安全转换(这个后面会讲,先稍微了解)
继承中的作用域
class persoon
{
protected:
int x;
};
class student
{
int x;
};
1.在继承体系中基类和派生类都有独立的作用域
比如person中有int x,student中也有int x,这种是被允许的,因为类不同作用域就不同
2.子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用基类::基类成员显示访问)
比如当你访问student中的x时,根据最近原则访问的就是student中的,如果需要访问person中的,需要person::x(通过作用域限定符)
3.需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
4.这里需要甄别什么是函数重载和隐藏(重定义)
函数重载是需要在同一作用域,只要函数名相同,参数列表不同就会构成重载
隐藏是在继承中,在不同的作用域,子类屏蔽父类成员的直接访问
5.注意在实际中在继承体系里面最好不要定义同名的成员
因为容易混淆,你两个的名字一样,很容易造成误会
#include <iostream>
class Base {
public:
void display() {
std::cout << "Base::display()" << std::endl;
}
void display(int x) { // 基类的重载函数
std::cout << "Base::display(int): " << x << std::endl;
}
};
class Derived : public Base {
public:
void display() { // 派生类隐藏了基类的同名函数
std::cout << "Derived::display()" << std::endl;
}
};
int main() {
Derived d;
d.display(); // 调用Derived::display()
// d.display(10); // 错误:Derived::display()隐藏了Base::display(int)
d.Base::display(10); // 显式调用基类版本
return 0;
}
第一点:基类的两个函数由于函数名相同,参数列表不同,就会构成函数名重载
第二点:派生类的函数由于和基类的函数名相同,所以隐藏了基类的两个函数
这样当你调用派生类对象display时就会调派生类的,因为隐藏了基类的
当你传参时,由于你隐藏了,编译器就会报错,你只能通过作用域限定符去调用
派生类的六大默认成员函数
6个默认成员函数,通过前面的学习,我们知道默认的意思就是我们不写,编译器也会自动帮我们生成一个,那么在派生类中,这几个成员函数是如何生成的呢???
取地址重载我们很少会自己实现,其余四个需要我们掌握并且熟悉
1.派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员,如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用
也就是构造函数分基类和派生类的,在派生类的初始化列表会调基类的构造函数,如果有默认的你可以不管,如果没有默认的就需要你显示的去传参去调基类的默认构造函数
#include <iostream>
#include <string>
class Person {
protected:
std::string name;
int age;
public:
// 基类的带参构造函数
Person(const std::string& n, int a) : name(n), age(a) {
std::cout << "Person constructor: " << name << ", " << age << std::endl;
}
};
class Student : public Person {
private:
int studentId;
public:
// 派生类的构造函数必须显式调用基类的构造函数
Student(const std::string& n, int a, int id)
: Person(n, a), studentId(id) { // 显式调用Person的构造函数
std::cout << "Student constructor: ID=" << studentId << std::endl;
}
};
int main() {
// 创建Student对象时,必须提供Person构造函数所需的参数
Student s("Alice", 20, 12345);
return 0;
}
就像创建person对象一样,直接用匿名对象的形式person(n,a),但两者没有联系
2.拷贝构造函数
#include <iostream>
#include <string>
using namespace std;
class Person {
protected:
string name;
int age;
public:
// 构造函数
Person(const string& n, int a) : name(n), age(a) {}
// 拷贝构造函数
Person(const Person& other) : name(other.name), age(other.age) {
cout << "Person copy constructor" << endl;
}
};
class Student : public Person {
private:
int studentId;
public:
// 构造函数
Student(const string& n, int a, int id)
: Person(n, a), studentId(id) {}
// 拷贝构造函数
Student(const Student& other)
: Person(other), // 显式调用父类的拷贝构造函数
studentId(other.studentId) {
cout << "Student copy constructor" << endl;
}
};
int main() {
Student s1("Alice", 20, 12345);
Student s2(s1); // 调用Student的拷贝构造函数
return 0;
}
派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类的拷贝初始化
如果派生类和基类都没有显示的定义构造函数,编译器默认生成,那就会进行浅拷贝,浅拷贝的风险之前讲过,可以翻阅之前的文章
当基类有默认构造函数时,派生类构造函数会自动调用它;当基类没有默认构造函数时,派生类必须显式调用基类的带参构造函数。
注意:person(other) 这条语句,结合我们之前所讲的子类的对象可以赋值给父类的对象,也就是切片,所以这种行为是被允许的,相反你不能name(other.name)这样传值,因为父类接受的是一个person对象
3.operator=
派生类的operator=必须要调用基类的operator=完成基类的复制
Student& operator=(const Student& other) {
if (this != &other) {
Person::operator=(other); // 显式调用父类的赋值运算符
studentId = other.studentId;
}
cout << "Student assignment operator" << endl;
return *this;
}
注意这里的Person::operator=(other)要显示调用父类的,因为当你编写时operator=时已经把父类的屏蔽了,也就是隐藏/重定向,如果你不显示调用父类的,就会一直调子类的,这样就会造成栈溢出,overflow。
4.析构函数
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类的成员。因为这样才能保证派生类对象先清理派生类成员在清理基类成员的顺序。 (栈的先进后出)
结合之前的,先调基类的构造函数后初始化派生类的成员变量,析构时先释放派生类的后释放基类
注意:基类~person(),派生类~student() 即使名不同也会构成隐藏
因为编译器做了统一处理,统一把名字都处理称destructor,所以就构成了隐藏(这跟多态有关)
~student()
{
~person();
}
这种是不对的,因为构成了隐藏,所以找不到
~student()
{
person::~person();
}
这种也不对,因为我们刚刚讲过编译器会在调用完派生类的析构函数后,自动调用基类的,所以我们编写代码的时候可以不要管基类的,否则会造成析构两次,从而出现问题
继承与友元
注意:友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
首先一点:我们得明白,如果一个函数是一个类的友元函数,那这个函数就不是类的成员函数
对于这个友元函数的作用域,如果是在类外定义,那就是类外的作用域,如果在类内定义,那跟类的作用域一致,类如果在哪个命名空间中友元函数的作用域也在哪个命名空间,如果在全局,友元也在全局
第二:我们要明白友元关系是单向的,也就是你是基类的友元,你就不会继承到派生类,因为你压根都不是基类的成员函数,所以不会继承
对于友元无论你是友元类还是友元函数都不会继承,如果没有继承,那你就无法访问派生类的私有和保护
#include <iostream>
using namespace std;
class Base {
protected: // 保护成员可被派生类访问,但友元不受访问控制限制
int baseVal = 10;
friend class FriendClass; // 声明 FriendClass 为基类 Base 的友元
};
class Derived : public Base {
private:
int derivedVal = 20; // 派生类私有成员
};
class FriendClass {
public:
void access(Base& b, Derived& d) {
// 可以访问基类 Base 的保护成员(因为是友元)
cout << "Base value: " << b.baseVal << endl;
// 编译错误!FriendClass 是 Base 的友元,但不是 Derived 的友元
// cout << "Derived value: " << d.derivedVal << endl;
}
};
int main() {
Base b;
Derived d;
FriendClass f;
f.access(b, d);
return 0;
}
继承与静态成员
对于静态成员变量,假设static int x;如果基类定义了这样一个静态成员变量,则整个继承体系里面只有一个这样的成员,无论派生出多少个子类,都只有一个static成员实例
对于静态成员变量,继承关系中只有一个,但如果子类定义了同名的成员变量,会继承但同时也会隐藏,要注意访问的x是哪个作用域的x
#include <iostream>
using namespace std;
class Base {
public:
static int x; // 基类静态成员
};
int Base::x = 10; // 静态成员定义
class Derived : public Base {
public:
int x; // 派生类定义同名非静态成员(隐藏基类的 static x)
};
int main() {
// 访问基类的静态成员 x
cout << "Base x: " << Base::x << endl; // 输出:10
Derived d;
d.x = 20; // 访问派生类的非静态成员 x
cout << "Derived x: " << d.x << endl; // 输出:20
// 若要访问基类的静态成员 x,需显式指定作用域
cout << "Base x via Derived: " << Derived::Base::x << endl; // 输出:10
return 0;
}
如果我在定义一个static int x
class Base {
public:
static int x; // 基类静态成员
};
int Base::x = 10;
class Derived : public Base {
public:
static int x; // 派生类定义同名静态成员(隐藏基类 x)
};
int Derived::x = 20; // 定义派生类静态成员
int main() {
cout << "Base x: " << Base::x << endl; // 10
cout << "Derived x: " << Derived::x << endl; // 20
return 0;
}
这样也是构成隐藏,但所有的继承关系也是只有一份,所以要通过作用域限定符去区分
一、核心规则
-
静态成员属于类而非对象:
基类定义的静态成员变量(如static int x
)属于基类本身,所有派生类共享同一份实例。 -
可通过任何子类访问基类静态成员:
无论继承层级多深,子类都可通过子类名::基类名::静态成员
或子类名::静态成员
访问(若子类未隐藏该成员)。
注意:这里说的是如果你没有构成隐藏,你可以Derived::x,但如果你构成了隐藏,你就必须通过多项作用域限定符找到你要的那个x
建议:优先使用类名+成员变量直接访问,这样提高代码的可读性,并且避免定义相同的同名变量
复杂的菱形继承及菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
助教既是老师又是学生,其核心应用场景是让一个类同时具备多个不同类型的属性和行为
菱形继承:菱形继承是多继承的一种特殊情况
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中person成员会有两份
因为assistant中包含两份_name,所以导致了数据冗余,若一开始的person有大量的数据,当你实例化assistant时就会导致额外的开销
二义性:一个人,怎么会有两个名字_name,你访问_name时是访问student的还是teacher的,所以此时需要你去指定student::_name,或者teacher::_name
解决方案 :使用虚继承解决数据冗余和二义性,关键字为virtual,需要注意:虚拟继承不要在其他地方去使用
#include <iostream>
using namespace std;
// 基类:Person
class Person {
public:
string name;
int age;
Person(string n, int a) : name(n), age(a) {}
void introduce() {
cout << "Name: " << name << ", Age: " << age << endl;
}
};
// 学生类:继承自 Person
class Student : public Person {
public:
string studentId;
Student(string n, int a, string id) : Person(n, a), studentId(id) {}
};
// 教师类:继承自 Person
class Teacher : public Person {
public:
string teacherId;
Teacher(string n, int a, string id) : Person(n, a), teacherId(id) {}
};
// 助教类:同时继承 Student 和 Teacher
class TeachingAssistant : public Student, public Teacher {
public:
TeachingAssistant(string n, int a, string sid, string tid)
: Student(n, a, sid), Teacher(n, a, tid) {}
};
int main() {
TeachingAssistant ta("Alice", 25, "S123", "T456");
// 问题1:数据冗余 - Alice 的 name 和 age 被存储了两份
// 问题2:二义性 - 无法直接调用 introduce()
// ta.introduce(); // 编译错误:二义性!
// 必须显式指定路径
ta.Student::introduce(); // 输出 Student 路径下的信息
ta.Teacher::introduce(); // 输出 Teacher 路径下的信息
return 0;
}
#include <iostream>
using namespace std;
// 基类:Person(虚基类)
class Person {
public:
string name;
int age;
Person(string n = "", int a = 0) : name(n), age(a) {} // 默认构造函数
void introduce() {
cout << "Name: " << name << ", Age: " << age << endl;
}
};
// 学生类:虚继承 Person
class Student : virtual public Person {
public:
string studentId;
Student(string n, int a, string id) : Person(n, a), studentId(id) {}
};
// 教师类:虚继承 Person
class Teacher : virtual public Person {
public:
string teacherId;
Teacher(string n, int a, string id) : Person(n, a), teacherId(id) {}
};
// 助教类:同时继承 Student 和 Teacher
class TeachingAssistant : public Student, public Teacher {
public:
// 必须显式调用虚基类 Person 的构造函数
TeachingAssistant(string n, int a, string sid, string tid)
: Person(n, a), Student(n, a, sid), Teacher(n, a, tid) {}
};
int main() {
TeachingAssistant ta("Alice", 25, "S123", "T456");
// 问题解决:
// 1. 数据冗余消除 - 只有一份 name 和 age
// 2. 二义性消除 - 只有一个 introduce()
ta.introduce(); // 正确:直接调用,输出唯一的 Person 信息
return 0;
}
注意是student和teacher才有virtual关键字,而assistant没有
理解虚拟继承解决数据冗余和二义性的原理
我们可以构建一个简单的虚继承来观察,A是祖宗,B和C继承A,D继承B和C
观察如果没有虚继承,访问_a时需要指定在哪个当中,时B还是C
当添加virtual关键字,构成菱形继承时
可以看到B类和C类中存储的是一个指针,这个指针叫虚基表指针,指向的是虚基表,虚基表中存储了偏移量,也就是你在改变a时,他会去找内存的地址,地址里面又存有虚基表的地址,虚基表中存储了偏移量,把偏移量取出来通过原来的地址+偏移量,找到实际当中的a的值,然后更改
存在效率损失:通过虚基表指针->虚基表(存在代码段)->偏移量
指针+偏移量算出公共的成员地址
但这个效率损失在现代计算机可以忽略
实际中不到万不得已,不要弄成虚继承
继承的总结和反思
1. 多继承可以认为是c++的缺陷之一,很多后面的OO语言都没有多继承,如JAVA
2.继承和组合
继承:
class Animal {
public:
void eat() { /* ... */ }
};
class Dog : public Animal { // Dog 是一种 Animal
public:
void bark() { /* ... */ }
};
组合:
class Engine {
public:
void start() { /* ... */ }
};
class Car { // 汽车拥有引擎
private:
Engine engine;
public:
void drive() { engine.start(); /* ... */ }
};
public继承是一种is-a的形式,也就是狗是一种动物,每个派生类对象都是一个基类对象
组合是一种has-a的形式,汽车有引擎,而不是汽车是一种引擎,每个B对象都有一个A对象
优先使用组合而不是继承
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用。白箱就是相对可视化而言,对派生类基本是透明的,但一定程度破坏了基类的封装性,基类的改变对于派生类有很大的影响,派生类和基类间的依赖关系很强,耦合度高
组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格称之为黑箱复用,因为对象的内部细节是不可见的。也就是A对B不透明,A保持它的封装性,组合类之间没有很强的依赖关系,耦合度低,优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合
笔试面试题
1.什么是菱形继承?菱形继承的问题是什么?
2.什么是菱形虚拟继承?如何解决数据冗余和二义性
3.继承和组合的区别是什么?什么时候用继承?什么时候用组合?