【C++】—— 继承(上)
- 1 继承的概念与定义
- 1.1 继承的概念
- 1.2 继承定义
- 1.2.1 定义格式
- 1.2.2 继承父类成员访问方式的变化
- 1.3 继承类模板
- 2 父类和子类对象赋值兼容转换
- 3 继承中的作用域
- 3.1 隐藏规则
- 3.2 例题
- 4 子类的默认成员函数
- 4.1 构造函数
- 4.1.1 父类有默认构造
- 4.1.2 父类没有默认构造
- 4.2 拷贝构造
- 4.2.1 不需要自己显式写
- 4.2.2 自己显式写
- 4.3 赋值重载
- 4.4 析构函数
- 4.4.1 重载
- 4.4.2 顺序
- 4.5 实现不能被继承的类
- 4.5.1 法一:设为私有
- 4.5.2 法二:final
- 4.6 总结
1 继承的概念与定义
1.1 继承的概念
继承(inheritance)机制是面向对象设计使代码可以复用的最重要的手段,它允许我们在保存原有特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复用,继承是类设计层次的复用。
下面我们通过一个例子来初步感受一下继承:
class Student
{
public :
// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
void identity()
{
// ...
}
// 学习
void study()
{
// ...
}
protected:
string _name = "peter"; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄
int _stuid; // 学号
};
class Teacher
{
public:
// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
void identity()
{
// ...
}
// 授课
void teaching()
{
//...
}
protected:
string _name = "张三"; // 姓名
int _age = 18; // 年龄
string _address; // 地址
string _tel; // 电话
string _title; // 职称
};
上面,我们看到没有继承之前我们设计了两个类 Student
和 Teacher
,
S
t
u
d
e
n
t
Student
Student 和
T
e
a
c
h
e
r
Teacher
Teacher 都有 姓名 / 地址 / 电话 / 年龄 等成员变量,都有
i
d
e
n
t
i
t
y
identity
identity ⾝份认证的成员函数,设计到两个类里面就是冗余的。当然他们也有⼀些独有的成员变量和函数,比如老师独有成员变量是职称,学生的独有成员变量是学号;学⽣的独有成员函数是学习,⽼师的独有成员函数是授课。
既然 S t u d e n t Student Student 和 T e a c h e r Teacher Teacher 两个类的设计有些冗余,那我们能不能把公共的信息提取出来呢?
下面我们公共的成员都放到 Person 中,Student 和 Teacher 都继承 Person,就可以复⽤这些成员,就不需要重复定义了,省去了很多麻烦。
class Person
{
public:
// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
void identity()
{
cout << "void identity()" << _name << endl;
}
protected:
string _name = "张三"; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄
};
class Studen : public Person
{
public:
// 学习
void study()
{
// ...
}
protected:
int _stuid; // 学号
};
class Teacher : public Person
{
public:
// 授课
void teaching()
{
//...
}
protected:
string title; //职称
};
虽然
S
t
u
d
e
n
t
Student
Student类 的成员变量看起来只有int _stuid;
,但它继承了Person类,它还有string _name
、string _address;
等等成员变量。成员函数也不止void study()
,还有void identity()
1.2 继承定义
1.2.1 定义格式
下面我们看到 Person
是父类,也称作基类。Student
是子类,也称作派生类。(因为翻译的原因,所以既叫父类/子类,也叫基类/派生类)
继承方式与访问限定符一样,都有三个:公有、保护、私有
1.2.2 继承父类成员访问方式的变化
类成员/继承方式 | p u b l i c public public 继承 | p r o t e c t e d protected protected 继承 | p r e v a t e prevate prevate 继承 |
---|---|---|---|
基类的 p u b l i c public public 成员 | 派生类的public成员 | 派生类的 p r o t e c t e d protected protected 成员 | 派生类的 p r i v a t e private private 成员 |
基类的 p r o t e c t e d protected protected 成员 | 派生类的 p r o t e c t e d protected protected 成员 | 派生类的 p r o t e c t e d protected protected 成员 | 派生类的 p r i v a t e private private 成员 |
基类的 p r i v a t e private private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
-
父类的
private成员
在子类中无论以什么方式继承都是不可见的。这里的不可见是指父类的私有成员还是被继承到了子类对象中,但是语法上限制子类对象不管在类里面还是类外我们都不能去访问它- 子类想访问父类的 p r i v a t e private private 成员虽然不能直接访问,但能间接访问。虽然在子类中是不能访问,但在父类中并没有相关限制,只用父类提供相关访问 p r i v a t e private private 成员变量的成员函数,子类调用其函数就能间接访问。
-
父类 p r i v a t e private private 成员在子类中是不能被访问的,如果父类成员不想在类外直接被访问,但需要在子类中能访问,就定义为
protected
。可以看出保护成员限定符是因继承才出现的。 -
实际上面的表格我们进行一下总结会发现,父类的私有成员在子类都是不可见。父类其他成员在子类的访问方式为: M i n Min Min(成员在父类的访问限定符, 继承方式), p u b l i c public public > p r o t e c t e d protected protected > p r i v a t e private private。
-
使用关键字
class
时默认的继承方式是private
,使用struct
时默认的继承方式是public
,不过最好显式的写出继承方式class Student:Person //默认为private继承
、struct Student:Person //默认为public继承
-
在实际运用中一般使用的都是 p u b l i c public public 继承,几乎很少使用 p r o t e c t e d protected protected / p r i v a t e private private 继承,也不提倡使用 p r o t e c t e d protected protected / p r i v a t e private private 继承,因为 p r o t e c t e d protected protected / p r i v a t e private private 继承下来的成员都只能在子类的类里面使用,实际中扩展维护性不强。这里可以认为是 C++ 过度设计了。
看起来上面的规则很复杂,实际实践过程中是很简单的,一般都是:父类我们就用公有和保护,继承方式我们就用公有。其他方式都很少使用。
1.3 继承类模板
上述都是一些普通类的继承,那如果我们想继承类模板又该怎样呢?
之前,我们模拟实现栈使用的适配器模式,其实还有一种方法:继承
namespace ganyu
{
template<class T>
class stack : public std::vector<T>
{
public:
void push(const T& x)
{
push_back(x);
}
void pop()
{
vector<T>::pop_back();
}
const T& top()
{
return vector<T>::back();
}
bool empty()
{
return vector<T>::empty();
}
};
}
当基类是类模板时,需要指定类域去访问
,否则会编译报错。(普通类的继承不存在这个问题)
int main()
{
ganyu::stack<int> st;
st.push(1);
st.push(2);
st.push(3);
while (!st.empty())
{
cout << st.top() << " ";
st.pop();
}
return 0;
}
为什么编译报错呢?这与按需实例化有关系
ganyu::stack<int> st;
这句代码实例化栈,将
T
T
T 实例化成
i
n
t
int
int,也间接将
v
e
c
t
o
r
vector
vector 实例化(严格来说只实例化了栈的构造函数)。但我们将
v
e
c
t
o
r
vector
vector 实例化时不会把
v
e
c
t
o
r
vector
vector 中所有的成员函数都实例化,我们调用谁才实例化谁。
我们调用
p
u
s
h
push
push 函数时,编译器去找
p
u
s
h
push
push_
b
a
c
k
back
back 函数,在子类和父类中都找不到,因为还没有实例化。所以我们要指定类域去访问
,表示调用的是
v
e
c
t
o
r
vector
vector<
T
T
T> 中的
p
u
s
h
push
push_
b
a
c
k
back
back,此时编译器看到
T
T
T 已经被实例化成
i
n
t
int
int 了,就会将
v
e
c
t
o
r
vector
vector<
T
T
T> 中的
p
u
s
h
push
push_
b
a
c
k
back
back 实例化出一份
i
n
t
int
int 版本的出来。
我们可以结合 #define
,能灵活更改
s
t
a
c
k
stack
stack 的底层容器,达到类似适配器模式的效果
#define CONTAINER vector
namespace ganyu
{
template<class T>
class stack : public std::CONTAINER<T>
{
public:
void push(const T& x)
{
CONTAINER<T>::push_back(x);
}
void pop()
{
CONTAINER<T>::pop_back();
}
const T& top()
{
return CONTAINER<T>::back();
}
bool empty()
{
return CONTAINER<T>::empty();
}
};
}
2 父类和子类对象赋值兼容转换
- p u b l i c public public继承的前提下,子类对象可以赋值给父类的对象 / 父类的指针 / 父类的引用。这里有个形象的说法叫切片或者切割。寓意把子类中父类那部分切割开来赋值给父类对象/指针/引用
- 但反过来就不成立:父类对象不能赋值给子类对象
例如:现在有一个 S t u d e n t Student Student 对象, S t u d e n t Student Student 对象可以赋值给父类对象 P e r s o n Person Person,当然,指针和引用也是可以的;但反过来就不成立(总不能无中生有出一个 _ N o No No 成员吧)。
class Person
{
protected :
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public :
int _No; // 学号
};
int main()
{
Student sobj;
// 1.派⽣类对象可以赋值给基类的对象/指针/引⽤
Person pobj = sobj;
Person* pp = &sobj;
Person& rp = sobj;
//2.基类对象不能赋值给派⽣类对象,这⾥会编译报错
//sobj = pobj;
return 0;
}
这里并没有发生类型转换。
虽然我们前面讲过不同类型的对象之间进行赋值,支持的是类型转换
int i = 0;
double d = i;
将
i
i
i 赋值给
d
d
d 走的就是类型转换,中间会生成一个临时对象。
但是切片并不是类型转换,中间并没有产生临时变量,这是一种特殊处理。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的 dynamic_cast 来进行识别后进行安全转换。(ps:这个我们后面再单独专门介绍,这里先提⼀下)
3 继承中的作用域
3.1 隐藏规则
- 在继承体系中父类和子类都有独立的作用域
- 子类和父类中有同名成员,
子类成员
将屏蔽父类
的同名成员的直接访问,这种情况叫 隐藏。(在子类成员函数中,可以使用父类::父类成员 显式访问
)
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; // 学号
};
int main()
{
Student s1;
s1.Print();
return 0;
}
运行结果:
- 如果是成员函数的隐藏,
只需要函数名相同就构成隐藏
- 注意:在实际中在继承体系里面最好不要定义重名的成员或函数
3.2 例题
class A
{
public :
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public :
void fun(int i)
{
cout << "func(int i)" << i << endl;
}
};
int main()
{
B b;
b.fun(10);
b.fun();
return 0;
};
-
A A A 和 B B B 类中的两个 f u n c func func函数 构成什么关系()
A. 重载 B. 隐藏 C.没关系 -
下面程序的编译运行结果是什么()
A. 编译报错 B. 运行报错 C. 正常运行
- 第一题:第一眼看上去,他们构成重载关系:函数名相同,参数类型不同。但如果选 A 就错了,这题选B。别忘了,只有在同一作用域的函数才构成函数重载,而隐藏是父类和子类中的函数名相同就构成隐藏
- 第二题:选A,因为子类和父类的
f
u
n
c
func
func函数 构成
隐藏
,除非指定父类的作用域去调用,否则同名成员或函数是不会去父类中查找的。b.fun();
没有传递参数,编译报错。
4 子类的默认成员函数
6 个默认成员函数,意思是我们不写,编译器会给我们自动生成。父类的默认成员函数与普通类没有任何差别,但在派生类中,这几个成员函数是如何生成的呢?
4.1 构造函数
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显式调用。
4.1.1 父类有默认构造
class Person
{
public:
Person(const char* name = "peter")
: _name(name)
{
cout << "Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
protected:
int _num; //学号
string _sex; //性别
};
首先,我们来回忆一下普通类的默认生成的构造函数的行为:
- 对
内置类型
:默认生成的构造函数是不确定的- 对
自定义类型
:会调用它的默认构造函数
现在,比起之前多出来一部分:
父类成员
- 我们把继承的父类成员看成一个整体对象,子类的默认构造会自动调用父类的默认构造完成父类成员的初始化
4.1.2 父类没有默认构造
class Person
{
public:
Person(const char* name, double height)
: _name(name)
,_height(height)
{
cout << "Person()" << endl;
}
protected:
string _name; // 姓名
double _height; //身高
};
class Student : public Person
{
public:
protected:
int _num; //学号
string _sex; //性别
};
现在,父类没有默认构造,派生类还能默认生成构造函数吗?.
可见,默认生成的只能调用默认构造。这时,就需要我们在子类显式写一个构造函数了
Student(const char* name, double height, int num, const char* sex)
:_name(name)
,_height(height)
,_num(num)
,_sex(sex)
{}
这样写可不可以呢?
不可以。编译器不允许直接去初始化父类的成员,子类要求必须调用父类的构造函数来初始化父类的成员,要把父类当成一个整体。
显示调用父类方法如下:
Student(const char* name, double height, int num, const char* sex)
:Person(name, height)
,_num(num)
,_sex(sex)
{}
int main()
{
Student s1("张三", 1.80, 1, "男");
Student s2("李四", 1.70, 2, "未知");
return 0;
}
有点像调用一个匿名对象一样。
4.2 拷贝构造
派生类的拷贝构造函数必须调用基类的拷贝构造完成基本的拷贝初始化
对默认生成的拷贝构造,其行为也像上述构造函数一样分成三类
内置类型
:完成浅拷贝自定义类型
:调用其拷贝构造父类整体
:调用父类的拷贝构造
4.2.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;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
//构造函数
Student(const char* name, int num, const char* sex)
:Person(name)
, _num(num)
, _sex(sex)
{}
//未写拷贝构造
//···
protected:
int _num; //学号
string _sex; //性别
};
int main()
{
Student s1("张三", 1, "男");
Student s2 = s1;
return 0;
}
严格来说,
S
t
u
d
e
n
t
Student
Student类是不用我们自己写拷贝构造的,默认生成的拷贝构造已经完成了我们的需求。前面,我们通过学习知道拷贝构造、赋值重载、析构是一体的。一个不需要写,三个都不需要写;一个要写,三个都要写。因此
S
t
u
d
e
n
t
Student
Student类 的赋值重载和析构函数都不需要自己写
如果有需要深拷贝的资源,才需要自己实现
4.2.2 自己显式写
那假设 S t u d e n t Student Student 类中有指向的资源,需要我们自己写拷贝构造,又该怎么写呢?
class Student : public Person
{
public:
protected:
int _num; //学号
string _sex; //性别
int* _ptr = new int[10];//假设有指向的资源
};
Student (const Student& s)
:_num(s._num)
,_sex(s._sex)
,//显示调用父类的拷贝构造
{
//深拷贝
memcpy(_ptr, s._ptr, sizeof(int) * 10);
}
如何显式调用父类的拷贝构造呢?
调用父类的拷贝构造,需要传递父类的对象,但现在没有父类的对象,咋办呢?
这时,我们就可以运用前面学习的赋值兼容转换
Student(const Student& s)
:_num(s._num)
,_sex(s._sex)
,Person(s)
{
//深拷贝
memcpy(_ptr, s._ptr, sizeof(int) * 10);
}
Person(s)
:
s
s
s 是子类对象的引用,要拷贝父类那一部分,需要将父类那一部分拿出来, 怎么拿出来呢?我把子类对象传给父类的引用,这时父类的引用,引用的是子类对象中切割出来的父类的那一部分。
这里有个小细节,走初始化列表时,编译器会先走Person(s)
,在走_num(s._num)
和_sex(s._sex)
这是因为初始化列表初始化的顺序与成员在列表中的顺序无关,只与声明的顺序有关。
所以继承以后,它将父类对象当成一个整体,而父类对象是最先被声明的
那如果不在初始化列表显示初始化父类呢?
Student(const Student& s)
:_num(s._num)
, _sex(s._sex)
{
//深拷贝
}
我们说过,所有成员都会走初始化列表,父类 P e r s o n Person Person 没有显示调用,也会走初始化列表。但此时编译器会调用 P e r s o n Person Person 的默认构造,虽然编译能通过,但很可能不符合你的需求;如果 P e r s o n Person Person 没有默认构造,那么编译报错
4.3 赋值重载
和拷贝构造一样,
S
t
u
d
e
n
t
Student
Student 类严格来说不需要写赋值。
但如果我们需要显式写要怎么写呢
- 派生类的 o p e r a t o r operator operator= 必须要调用基类的 o p e r a t o r operator operator =。
Student& operator=(const Student& s)
{
if (this != &s)
{
operator=(s);
_num = s._num;
_sex = s._sex;
}
return *this;
}
复制拷贝与拷贝构造是类似的,都是传递子类对象的引用给父类即可。
但是,如果运行程序会发现:程序陷入死循环
。
为什么呢?
子类中的同名函数与父类的构成了隐藏!
operator=(s);
其实一直调的是子类的
o
p
e
r
a
t
o
r
operator
operator=,因此程序陷入死循环
因此我们要指定调用定父类的 o p e r a t o r operator operator=。
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s);
_num = s._num;
_sex = s._sex;
}
return *this;
}
总结:派生类的 o p e r a t o r operator operator= 必须要调用基类的 o p e r a t o r operator operator= 完成基类的赋值。需要注意的是派生类的 o p e r a t o r operator operator= 屏蔽了基类的 o p e r a t o r operator operator=,所以显式调用基类的 operator=,需要指定基类作用域
4.4 析构函数
首先,严格来说
S
t
u
d
e
n
t
Student
Student 并不需要我们显式写析构函数
那如果有需要显式释放的资源,析构函数又该怎么写呢?
4.4.1 重载
我们还是以
S
t
u
d
e
n
t
Student
Student类 为例
首先,如果显式实现析构函数,_
n
u
m
num
num 和 _
s
e
x
sex
sex 是不用管的。因为int _num
是内置类型,而 string _sex
会自己调用其析构。我们只需要管父类即可
~Student()
{
~Person();
}
但这样会报错
析构是可以显示调用的,但为什么这里调不动呢?
这里有个小知识点:子类的析构会和父类的析构构成隐藏关系。
因为一些特殊的原因,析构函数的函数名会被特殊处理成
d
e
s
t
r
u
c
t
o
r
destructor
destructor(),所以父类的析构函数和子类的析构函数构成隐藏关系。实际上并没有什么 ~
S
t
u
d
e
n
t
Student
Student() 和 ~
P
e
r
s
o
n
Person
Person(),只有
d
e
s
t
r
u
c
t
o
r
destructor
destructor()。
所以我们要指定类域调用
~Student()
{
Person::~Person();
}
4.4.2 顺序
我们来尝试调用一下析构函数
class Person
{
public:
//成员函数
//···
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
//成员函数
//···
~Student()
{
Person::~Person();
}
protected:
int _num; //学号
string _sex; //性别
};
int main()
{
Student s1("张三", 1, "男");
Student s2("李四", 2, "未知");
return 0;
}
运行结果:
大家有没有发现析构函数调的有点多啊,我一个就两个对象,你怎么就调用 4 次析构函数了呢?
像构造、赋值重载等,我们显式写的都需要显式调用父类的对应函数,但析构不需要显式调用。调用了子类析构函数之后,系统会自动调用父类的析构(这点与自定义类型的成员很像)。
为什么要这样的。这样可以保证析构顺序是先子后父。后定义的先析构,而对象构造时,是先构造(初始化)父类,在初始化子类;析构是就需要先析构子类,在析构父类。如果显式调用就不能保证先子后父,而是取决于实现的人。
4.5 实现不能被继承的类
要实现一个不能被继承的类,有两种方法
4.5.1 法一:设为私有
将父类的构造函数设置为私有
class Base
{
public :
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
private:
// C++98的⽅法
Base()
{}
};
class Derive :public Base
{
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
为什么呢?因为子类的构造函数,不论是自动生成还是我们自己显式实现,都必须调用父类的构造函数。但是父类的 p r i v a t e private private成员在子类中是不可见的,因此子类调不到父类的构造函数。
但是这种方式不够明显,如果不调用子类的对象编译器是不会报错的
4.5.2 法二:final
C++11中新增了一个关键字:
f
i
n
a
l
final
final
用
f
i
n
a
l
final
final 修饰一个类,表示该类是最终类,无法再被继承。
这种方式更直观一些,不管子类定不定义,直接报错
class Base final
{
public :
Base()
{}
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive :public Base
{
void func4() { cout << "Derive::func4" << endl; }
protected:
int b = 2;
};
4.6 总结
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表显示调用
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
- 派生类的 o p e r a t o r operator operator= 必须要调用基类的 o p e r a t o r operator operator= 完成基类的复制。需要注意的是派生类的 o p e r a t o r operator operator= 隐藏了基类的 o p e r a t o r operator operator=,所以显示调用基类的 operator=,需要指定基类作用域
- 派生类的析构函数会在被调用完成之后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员在清理基类成员的顺序
- 派生类对象初始化先调用基类的构造再调派生类的构造
- 派生类对象析构清理先调用派生类析构再调基类的析构
- 因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数进行特殊处理 ,处理成 d e s t r u c t o r destructor destructor(),所以基类析构函数不加 virtual 的情况下,派生类析构函数和基类析构函数构成隐藏关系
- 大多数情况下,派生类中拷贝构造、赋值、析构都是不需要自己写的;如果需要,那这个继承的设计太过复杂,可以考虑重新设计。
好啦,本期关于继承的知识就介绍到这里啦,希望本期博客能对你有所帮助。同时,如果有错误的地方请多多指正,让我们在 C++ 的学习路上一起进步!