继承
- 继承的引言
- 基类和子类的赋值转换
- 继承中的作用域
- 派生类中的默认成员函数
- 继承与友元
- 继承与静态成员
- 多继承的结构
- 棱形继承的结构
- 棱形虚拟继承的结构
- 继承与组合
继承的引言
- 概念
继承(inheritance)机制是面向对象程序设计使代码可以复用
的最重要的手段,它允许程序员在保
持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象
程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继 承是类设计层次的复用。
- 定义
class People
{
public:
People(string name = "John", int age = 18)
{
_name = name;
_age = age;
}
void print()
{
cout << "姓名->" << _name << endl;
cout << "年龄->" << _age << endl;
}
protected:
int _age ;
string _name;
};
class Student : public People
{
public:
Student(string name = "muyu", int age = 20, string id = "9210401227")
:People(name, age)
{
_id = id;
}
protected:
string _id;
};
class Teacher : public People
{
public:
Teacher()
{
People::_name = "mutong";
People::_age = 22;
}
protected:
int _JobNumber;
};
int main()
{
People p;
p.print();
cout << endl;
Student s;
s.print();
cout << endl;
Teacher t;
t.print();
return 0;
}
运行结果:
姓名->John
年龄->18
姓名->muyu
年龄->20
姓名->mutong
年龄->22
-
解释
class Student : public People
People类是父类/ 基类
, Student类是子类/ 派生类
Student类继承People类的本质就是复用
⇒Student 对象可以使用 People类里面的成员
成员包括成员变量 和 成员函数
.
成员变量是在对象空间内的, 而成员函数是不在对象空间内的, 属于整个类.
成员的访问限定符
有三种public, protected, private
继承方式不同 && 基类成员的访问限定符不同
⇒决定了基类的成员在派生类中的存在情况
-
继承的方式
继承方式有三种:public, protected, private
成员的限定符
有三种public, protected, private
所以, 一共有九种继承方式
👇👇👇
- 基类中的private成员, 在派生类中都是
不可见的
- 不可见 和 private成员是不一样的, private成员是
类里面可以访问, 类外面不可访问
, 不可见是类里面看不见/ 不可访问, 类外面不可访问
- 不可见 和 private成员是不一样的, private成员是
- 其余继承方式, 派生类中的情况是
继承方式 和 类成员访问限定符中 权限小的那一个
- 权限的大小:
public > protected > private
- 权限的大小:
- 父类如果是
class, 默认继承是 私有继承
, 父类如果是struct, 默认继承是 公有继承
. 不过建议显示继承方式 - 常用的继承方式为
图中绿色的区域
⇐ 继承的本质是复用
, 私有继承 和 基类中的私有成员在继承中是没有复用的意义的.
- 基类中的private成员, 在派生类中都是
-
为什么 派生类没有
print函数
, 但能调用 print函数?
我们可以认为子类对象里面包含两个部分: 父类对象成员变量 + 子类本身成员变量
子类对象中的成员变量 = 自己本身的成员变量 + 父类的成员变量 (受访问限定符 和 继承方式共同限制)
子类对象中的成员函数 = 自己本身的成员函数 + 父类的成员函数 (受访问限定符 和 继承方式共同限制)
print函数
是公有继承 && 访问限定符是公有 ⇒ 子类对象可以调用 -
为什么在
Teacher类中
可以People::_name = "mutong";
我们已经知道了 派生类对象的基本结构了.
那么派生类对象在初始化阶段, 即调用默认构造
是先父类还是先子类呢?
通过调试, 我们发现:子类对象调用构造函数的时候, 先调用父类的默认构造函数去初始化子类中父类对象的那一部分, 然后在调用子类对象的默认构造函数
Person类中有默认构造函数, 但是我们想改变一下Teacher类对象中的 关于父类对象的那一部分
, 那我们该怎么做呢?
首先, 我们不能直接写
_name = "mutong";
_age = 22;
因为受 域
的影响, 域是编译器在编译阶段查找变量的规则.
虽然, 我们可以认为子类对象中有 父类对象成员 + 子类对象成员
, 但彼此是 独立的
.
调用默认构造函数还是去 Person类中去调用
编译器在 编译阶段
默认查找的顺序是 局部域 , 子类域, 父类域, 全局域
我们在子类中去给父类对象成员赋值 ⇒ 我们应该告诉编译器, 这个变量直接去父类中去查找就OK
即, 这个时候我们要用 Person(父类)::
- 为什么在
Student类中
可以:People(name, age)
子类对象调用构造函数的时候, 先调用父类的默认构造函数去初始化子类中父类对象的那一部分, 然后在调用子类对象的默认构造函数.
那么如果父类对象没有默认构造函数呢?
我们就需要在子类的初始化列表处 显示调用父类的构造
基类和子类的赋值转换
int main()
{
People p;
Student st;
st = p; // error
p = st; // 可以进行转换
return 0;
}
- 父类对象
不能
赋值给子类对象, 而子类对象可以
赋值给父类对象
可以这样想:子类对象的成员 > 父类对象的成员
⇒可以 变小一点, 但不能变大一点
父类对象 = 子类对象, 属于不同类型之间的赋值
⇒ 一般都会发生 类型转换
⇒ 类型转换, 那就意味着要产生 临时常量拷贝
. 但结果真的如我们想的这般吗?
- 验证
父类对象 = 子类对象
是否有临时常量拷贝
拷贝是常量的
⇒ 要进行区分, 我们可以使用引用 &
如果生成了临时拷贝, 我们用普通引用 就会导致权限的放大
, 就会报错
如果没有生成临时拷贝, 我们用普通引用, 就是权限的平移
, 就不会报错
int main()
{
// 类型转换
int i = 0;
double d = i;
// double& dd = i // error
const double& dd = i;
// 赋值兼容转换
Student st;
People ps = st;
People& p = st;
return 0;
}
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫 切片 或者切割
. 寓意把派生类中父类那部分切来赋值过去
🗨️那么这个切片是怎样完成的呢?
继承中的作用域
🗨️在继承过程中, 可能会出现 父类中的成员名 和 子类中的成员名相同的情况
, 那么派生类对象调用该成员会是怎样的情况呢?
- 先看下面的代码:
class People
{
public:
People(string name = "John", int age = 18)
{
_name = name;
_age = age;
}
void print()
{
cout << "class People" << endl;
}
protected:
int _age ;
string _name;
};
class Student : public People
{
public:
Student(string name = "muyu", int age = 20, string id = "9210401227")
:People(name, age)
{
_id = id;
}
void print()
{
cout << "class Student : public People" << endl;
}
protected:
string _id;
};
void test1()
{
Student st;
st.print();
}
int main()
{
test1();
return 0;
}
运行结果:
class Student : public People
父类和子类中都有 print函数
, 通过结果显示 派生类内部的print函数
这是因为 域
, 跟上面的People::_name = "muyu";
是一样的道理
那么, 如果我们非要通过派生类对象 调用基类中的print函数
呢?👇👇👇
void test1()
{
Student st;
st.People::print();
}
总结:
- 子类和父类中的成员尽量不同名!
- 上面的例子, 子类和父类有同名的成员, 子类隐藏父类的成员, 这种关系叫做
隐藏/ 重定义
- 注意:
隐藏 != 重载
重载的前提条件是同一作用域
, 而隐藏是父类和子类成员同名
隐藏 != 重写
隐藏是子类中同名成员隐藏父类中同名成员
, 而重写是子类中重写父类有关函数的实现
- 注意:
派生类中的默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类
中,这几个成员函数是如何生成的呢?
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。
如果基类没有默认 的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能
保证派生类对象先清理派生类成员再清理基类成员的顺序。 - 派生类对象初始化先调用基类构造再调派生类构造。
- 派生类对象析构清理先调用派生类析构再调基类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲
解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加
virtual的情况下,子类析构函数和父类析构函数构成隐藏关系
class Person
{
public:
Person(string name = "muyu", int age = 20)
:_name(name)
,_age(age)
{
cout << "Person()" << endl;
}
Person(const Person& tem)
{
_name = tem._name;
_age = tem._age;
cout << "Person(const Person& tem)" << endl;
}
Person& operator=(const Person& tem)
{
_name = tem._name;
_age = tem._age;
return *this;
cout << "Person& operator=(Person& tem)" << endl;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name;
int _age;
};
class Student : public Person
{
public:
Student(const string name,const int age, const int num)
: Person(name,age)
, _num(num)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s)
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator = (const Student& s)
{
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s)
{
Person::operator =(s);
_num = s._num;
}
return *this;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num; //学号
};
void test2()
{
Student st1("牧童", 20, 20230101);
Student st2(st1);
Student st3("沐雨", 18, 20230102);
st3 = st1;
}
int main()
{
// test1();
test2();
return 0;
}
运行结果:
Person()
Student()
Person(const Person& tem)
Student(const Student& s)
Person()
Student()
Student& operator= (const Student& s)
~Student()
~Person()
~Student()
~Person()
~Student()
~Person()
🗨️其他函数都是 先父类, 后子类
, 唯独 析构函数 先子类后父类
?
- 首先, 构造函数是
先父类, 后子类
栈
, 先进后出 ⇒ 析构的时候,先子类, 后父类
.
其次, 父类可以调用子类的成员, 而子类不能调用父类的成员
如果先析构父类, 如果子类对象还想调用父类的成员,那就完蛋了!
🗨️在子类的析构函数中, 调用父类的析构函数
- 首先,
~Student()
{
~Person(); // 提示有一个重载
cout << "~Student()" << endl;
}
纳闷? 这个还能有重载?
因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲
解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor().
那么子类和父类中的 析构函数名 都是 destruction
⇒ 那么就构成了隐藏关系
那么我们在子类中调用父类的析构函数应该如下:
~Student()
{
Person::~Person();
cout << "~Student()" << endl;
}
结果如下:
- 编译器默认帮我们
先调用了父类的析构函数
⇒不信任我们用户, 由编译器自己完成
继承与友元
基类的友元, 派生类不会继承, 即基类的友元不能访问 子类中的 私有和保护成员
// 类的声明
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
// friend void Display(const Person& p, const Student& s);
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._stuNum << endl; // error: “Student::_stuNum”: 无法访问 protected 成员(在“Student”类中声明)
}
void test3()
{
Person p;
Student s;
Display(p, s);
}
int main()
{
test3();
return 0;
}
解决方法就是: 让 Display函数也充当 子类的友元
👇👇👇
class Student : public Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
int _stuNum; // 学号
};
继承与静态成员
基类中定义了一个静态成员, 则在整个继承体系中, 仅此一份. 子类不会单独拷贝一份, 继承的是使用权
🗨️只创建子类对象, 问一共创建了多少个子类对象?
- 1. 可以在子类的默认构造中创建一个静态成员变量.
class A
{
public:
A(){}
};
class B :public A
{
public:
B()
{
_count++;
}
public:
static int _count;
};
int B::_count = 0;
void test4()
{
B b1;
B b2;
B b3;
B b4;
B b5;
B b6;
cout << "子类中的个数->" << B::_count << endl;
}
int main()
{
test4();
return 0;
}
运行结果:
子类中的个数->6
- 可以在父类的默认构造中创建一个静态成员变量.
class A
{
public:
A()
{
++_count;
}
public:
static int _count;
};
int A::_count = 0;
class B :public A
{
};
void test4()
{
B b1;
B b2;
B b3;
B b4;
B b5;
B b6;
cout << "子类的个数->" << A::_count << endl;
}
int main()
{
test4();
return 0;
}
运行结果:
子类中的个数->6
多继承的结构
一个子类只有一个直接父类, 叫做 单继承
一个子类有两个及以上的父类, 叫做 多继承
-
单继承的结构
其实在内存中不是这样 "点开" 的关系, 而是连续的空间
-
多继承的机构
当然, 也是连续的空间
棱形继承的结构
多继承会有一种情况是 棱形继承
D继承B和C, B和C又同时继承A ⇒ 就会导致D对象中有两个A对象成员
这样就会导致 冗余性和二义性
其实, 解决 访问不明确/ 二义性
可以使用 基类::
但是 内存中D还是存储了两份 A类对象 造成的数据冗余性问题还没解决呢?
棱形虚拟继承的结构
棱形虚拟继承解决的就是 数据冗余性 和 二义性的问题
通过 内存窗口
, 我们发现:
- 把A从B 和 C中抽出来了, 让A既不属于B, 也不属于C
- B和C类中多了一个位置出来
- B和C类中多了一个位置的用处是什么?
我们发现: 地址指向的空间第一个位置是 0, 第二个位置分别是20(十六进制转二进制) 和 12(十六进制转二进制)
虽然, 把A类单独放在一个空间, 但A类中的成员还是B和C类得一部分
=>
这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针
,这两个表叫虚基表
。虚基表中存的偏移量
。通过偏移量可以找到下面的A。
那么这个时候, 我们修改A类的对象, 就不会有 冗余性和二义性的问题了
👇👇👇
继承与组合
继承是一种 is-a
的关系, 是一种 白箱复用
, 子类跟父类之间的 耦合度高
对象
组合是一种 has-a
的关系, 是一种 黑箱复用
, 耦合度低
-
is-a 和 has-a
is-a,就表示子类是一个特殊的父类
has-a, 就表示A对象中有B对象
-
白箱复用 和 黑箱复用
白箱复用,透明的
, 即子类知道父类内部的细节, 方法的实现
黑箱复用,不透明的
, 即对象之间不知道彼此的内部的细节
-
耦合度
打个比方:
父类A中的成员 有20个是public, 80个是protected的; 派生类是public继承
那么在派生类B中, A的成员都是可见的 ⇒耦合度是 100%
同样的,
A对象中的成员, 有20个是public, 80个是protected的;
那么B对象想用A对象里面的成员, 只能使用 20个public的成员 ⇒耦合度是 20%
🗨️那对象组合这么好, 我们就用对象组合, 不用继承了是吧?
- 首先,
存在即合理 ⇒ 全部都这样, 或者全部都那样的想法就是错误的
- 合理使用: 符合
is-a
关系的就使用继承
; 符合has-a
关系就使用对象组合
; 如果既符合has-a, 又符合 is-a
关系使用对象组合
- 实现
多态
, 必须使用继承
- 合理使用: 符合
学后反思:
- 什么是菱形继承?菱形继承的问题是什么?
- 什么是菱形虚拟继承?如何解决数据冗余和二义性的
- 继承和组合的区别?什么时候用继承?什么时候用组合?
与朋友论学,须委曲谦下,宽以居之。 — — 王阳明
译文:与朋友谈论学问,必须婉转曲从谦虚下问,与之宽和相处