继承目录
🌴继承简介
继承一词在我们脑海中不由想起世袭般的继承方式,子承父业,接续发展,当然C++中的继承也有类似的含义。
假如要实现一个简单的教务系统,教务系统中有学生、老师等等的信息存储。
下面来看看这样一段代码:
class Student //学生类
{
public:
//Student函数成员
private:
string _name;
size_t _age;
string _num; //学号
string _adderss;
}
class Teacher
{
public:
//Teacher函数成员
private:
string _name;
size_t _age;
string _id; //工号
string _address;
}
学生类和教师类的成员变量都有相同之处,学生和老师都是人,围绕人的信息进行对应职位细分修饰:
学生的基本信息:名字、年龄、学号
老师的基本信息:名字、年龄、工号
当然具体实现的教务系统细节会更多,这里只是举例展示,一所学校所组成的成员不单单只有学生和老师,还有主任、校长、食堂阿姨… …等等。若是每个成员类都是如上设计,相同的成员变量(如:名字、年龄),不同成员变量只有少数;这样写类的成员不乏过于冗余。
为了解决如上问题,C++提出了继承的语法,往下看:
🌳继承概念
- 继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
简单的说派生类继承基类后,基类对应的成员或是函数会当成派生类的一员,形成复用的情况,但是不同的访问方式会造成派生类能不能访问基类成员函数,这个后面会讲到。
教务系统的成员都是人,实现一个人的类,再用不同职位(如:学生、教师)类去继承,这样就可以大大减少冗余的情况;
- 被继承的人这个类称为:基类或是父类
- 继承的学生类、老师类被称为:派生类或是子类
🌳继承定义
🌲格式
- 格式:派生类
:
继承方式 + 基类
这里先用public继承方式进行继承演示:
//基类/父类
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected: //成员设置为保护
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
//派生类/子类
class Student : public Person
{
public:
//Student函数成员
private:
string _num; //学号
};
//派生类/子类
class Teacher : public Person
{
public:
//Teacher函数成员
private:
string _id; //工号
};
int main()
{
Student st;
Teacher tr;
st.Print();
tr.Print();
return 0;
}
这里用到了public
的方式进行继承,所创建st
对象和tr
对象,都成功调用了Person类的Print
函数;
派生类用不同的继承方式继承基类后,可以按照相关规则进行访问基类成员或是调用对应函数,当然还会受到基类访问限定符的规定进行限定访问,并不是说派生类继承基类后就可以为所欲为的访问。好比就是父亲是否要将财产全部继承给儿子一样,父亲是有一定的决定权。
那么继承方式和访问限定符有哪些呢?
🌲继承方式和访问限定符
-
继承的方式
继承的方式有三种分别为:public继承、protected继承、private继承 -
访问限定符
与继承方式要区别开,访问限定符也有三种:public访问、protected访问、private访问
在类和对象中只有
public
访问和private
访问,protected
访问是C++继承出来后新添加的访问类中一种方式。
由于继承方式与访问方式可以两两组合,使得派生类与基类之间相互访问关系产生九种对应访问情况:
下面举几个特殊例子:
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
//基类/父类
class Person
{
private: //基类成员和函数都为私有,派生类及以外的类都不可以访问
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
string _name = "peter";
int _age = 18;
};
//派生类/子类
class Student : public Person //公有继承
{
public:
//Student函数成员
private:
string _num;
};
int main()
{
Student st;
st.Print(); //访问失败
}
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。此时基类的保护成员或是函数被继承到了派生类对象中。
//基类/父类
class Person
{
protected: //基类成员和函数都为保护,派生类可以访问,除派生类以外的类都不可以访问
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
string _name = "peter";
int _age = 18;
};
//派生类/子类
class Student : public Person //公有继承
{
public:
//Student函数成员
private:
string _num;
};
int main()
{
Student st;
st.Print(); //可以访问
}
- 继承方式也可以省略不写,但是这样编译器会按照创建类的关键字进行默认继承;使用关键字
class
时默认的继承方式是private
,使用struct
时默认的继承方式是public
,不过最好显示的写出继承方式。有点像类中不写访问限定符时,也有默认访问方式。
//派生类/子类
class Person
{
protected: //基类成员和函数都为保护,派生类可以访问,除派生类以外的类都不可以访问
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
string _name = "peter";
int _age = 18;
};
class Student : Person //默认继承
{
public:
//Student函数成员
private:
string _num;
};
int main()
{
Student st;
st.Print(); //访问失败
return 0;
}
改成struct
关键字后的默认继承:
struct Student : Person //默认继承
{
public:
//Student函数成员
private:
string _num;
};
int main()
{
Student st;
st.Print(); //访问成功
return 0;
}
总的来说,基类的私有成员在子类都是不可见的;基类的其他成员在子类的访问方式(成员在基类的访问限定符,继承方式)
- public > protected > private
看到以上情况不妨让人头大,如此多的组合方式记下来把自己脑袋绕晕。然而,并不用把表格内容都记下来,记住下面常用即可
- 常用的继承方式一般使用都是
public
继承:
几乎很少使用protetced/private
继承,也不提倡使用protetced/private
继承,因为protetced/private
继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
🌴基类和派生类对象之间的赋值
在C语言中,不同类型的内置类型是可以进行相互赋值的,但是会发生类型转换,如下:
int main()
{
double a = 11.11;
int b = a;
//int& rb = a; //报错,类型转换会产生临时变量
const int& rb = a; //临时变量具有常性,用const引用才能接收
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "rb = " << rb << endl;
return 0;
}
与内置类型相同的是,派生类和基类之间也可以进行赋值。但是都可以相互赋值么?
举个例子试验一下:
//基类/父类
class Person
{
public:
//默认构造
Person(const char* name = "", int age = 0)
:_name(name)
,_age(age)
{}
void PersonPrint()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name; // 姓名
int _age; // 年龄
};
//派生类/子类
class Student : public Person
{
public:
//构造
Student(const char* name = "", int age = 0, const char* num = "")
:Person(name, age)
,_num(num)
{}
void StudentPrint()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
cout << "num:" << _num << endl;
}
private:
string _num; //学号
};
int main()
{
Student st1("张三", 18, "111111");
st1.StudentPrint();
Person pn;
pn = st1; //派生类赋值给基类
pn.PersonPrint();
return 0;
}
观察如上代码,我们创建了一个
Student
派生类的对象st1
,并且将其初始化了对应名字、年龄和学号;此时,再创建一个Person
基类的对象pn
,将派生类对象st1
赋值给基类对象pn
,并且打印结果:
上面是将派生类赋值给基类,可以看到赋值成功;
如果我们将其调换一下,将基类进行初始化后赋值给派生类会如何?
int main()
{
Person pn("张三", 18);
pn.PersonPrint();
Student st1;
st1 = pn; //基类赋值给派生类
st1.StudentPrint();
return 0;
}
- 派生类对象可以直接赋值给基类,但是基类对象不能赋值给派生类对象
如果拿基类给派生类进行赋值的话,那派生类中的
_num
这个成员编译器不知道如何处理,基类没有对应成员进行赋值,直接置于随机值吗?这个是不行的。因此只容许派生类给基类赋值,不能基类给派生类赋值。
回到最前面看到的,内置类型相互赋值会产生临时变量,那么派生类赋值给基类会不会产生临时变量?
int main()
{
Student st1("张三", 18, "111111");
Person& pn = str1; //天然支持,不会发生隐式类型转换
return 0;
}
直接接收不会产生临时变量。
- 派生类对象不仅仅可以赋值给基类的对象, 还可以进行派生类对象地址赋予基类的指针 、 派生类对象赋予基类的引用,这里有个形象的说法叫切片或者切割,寓意把派生类中父类那部分切来赋值过去
上面提到的代码中student派生类中还含有
_num
这个成员,基类中没有该成员,此时基类为了接收对应成员的值会将派生类中多出来的_num
成员进行切割。大白话说就是不要了,你给我我只拿对我有用的,没有用的我切割掉。
拿上面派生类和基类为例子:
- 派生类对象赋予基类的引用:
int main()
{
Student st1("张三", 18, "111111");
Person& pn = str1; //基类引用
return 0;
}
此时pn所担任的角色是,str1派生类对象中_name
成员和_age
成员加起来一起的别名。
若是对pn对象中_name
成员和_age
成员内容进行修改,那么会使str1对象的_name
成员和_age
成员内容发生改变。
- 派生类对象地址赋予基类的指针:
int main()
{
Student st1("张三", 18, "111111");
Person* ptrp = &str1; //基类指针
return 0;
}
ptrp基类指针指向的是str1对象中的_name
成员和_age
成员的地址;
与引用类似,ptrp指针解引用改变_name
成员和_age
成员内容,也会使str1对象的_name
成员和_age
成员内容改变。
🌴继承中的作用域
继承的作用是为了提高类的复用,防止造成代码冗余;
看到这里,有没有想到就是派生类中的成员要是和基类的成员有相同,又是怎么样去处理的呢?
上例子:
class Person
{
protected :
string _name = "小明";
int _num = 111;
};
class Student : public Person //公有继承
{
public:
void Print()
{
cout << "_name:" << _name << endl;
cout << "_num:" << _num << endl;
}
protected:
int _num = 999;
};
int main()
{
Student s1;
s1.Print();
return 0;
}
派生类和基类中都包含有_num成员,不妨猜一下此时打印结果是派生类_num成员的,还是基类_num成员的;
运行结果为派生类_num成员的。
再来看看函数成员的例子:
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类和B类中都含有fun
函数成员,当B类继承A类后,这两个fun
函数构成函数重载吗?
如果构成函数重载的话,那么b对象都可以调用fun
函数,运行看看:
编译报错。
🌳隐藏
- 继承体系中基类和派生类都有独立的作用域,注意!这里的作用域不要和上面的访问限定搞混了。
- 派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏,也叫重定义(在子类成员函数中,可以使用 基类::基类成员 显示访问)
- 需要注意的是派生类和基类都有相同成员函数的话,不管这两个函数是不是参数个数不同、参数顺序不同都不会构成函数重载,只需要函数名相同就构成隐藏
注意:函数重载只能作用于同一个作用域
上面两个例子也都证明了,派生类和基类的作用域是独立分开的;
如果我们要用派生类对象去调用基类成员的话就要明确指明作用域:
int main()
{
B b;
b.fun(10);
b.A::fun();
return 0;
}
在实际中在继承体系里面最好不要定义同名的成员,这样往往会给自己挖坑。但是有些情况下是不能避免的,如:派生类和基类中的赋值重载,下面会提到。
🌴派生类的默认成员函数
在C++中,如果去实现一个类自然而然是少不了默认成员函数的。
默认成员函数分别是:构造、析构、拷贝构造、赋值重载 和 取地址重载(常常让编译器实现)
提示:如果对类的默认成员函数还不熟悉的老铁可以先看看这篇:类的默认成员函数
对于普通类来说默认成员函数是再熟悉不过,但是下面所要介绍的是派生类的默认成员函数。
首先我们来实现一个Person类:
class Person
{
public:
//构造
Person(const char* st = "", size_t age = 0)
:_name(st)
, _age(age)
{
cout << "Person()" << endl;
}
//拷贝构造
Person(const Person& p)
:_name(p._name)
,_age(p._age)
{
cout << "Person(const Person& p)" << endl;
}
//赋值重载
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
cout << "Person& operator=(const Person& p)" << endl;
return *this;
}
//析构
~Person()
{
cout << "~Person()" << endl;
_age = 0;
}
protected:
string _name; //名字
size_t _age; //年龄
};
创建一个学生类,然后去继承Person类:
class Student : public Person
{
public:
//让编译器生成的默认函数
private:
string _id; //学号
};
int main()
{
Student st1; //调用构造
Student st2(st1); //调用拷贝构造
Student st3;
st3 = st1; //调用赋值重载
return 0;
}
派生类和普通类也有相似的点,如果我们不去实现默认成员函数,编译器也会自动生成默认的成员函数,但是也有不一样的地方;
运行结果:派生类的默认成员函数会调用对应基类的默认成员函数
编译器会自己生成,那么就让编译器实现就行了,为什么我们要自己去实现呢?对吧。
对于上面这个派生类来说是完全可以让编译器去实现默认成员函数的,但是每个派生类都一样吗?答案是,不一定。
上面提到的派生类的成员变量是没有对资源进行申请,编译器实现的默认成员函数是完全够用;
若实现的其他派生类中,某个成员进行动态内存进行申请后,那么编译器默认生成的默认成员函数中 拷贝构造、赋值重载 进行的是浅拷贝工作。这样的结果是完全达不到我们需求的,因此对于特殊情况还是要去实现。
下面是Student派生类的默认成员函数实现过程,注意!为了让大家看得清楚进行对比,就从类中单独抽出来,实际的实现内容都是在各自类内部。
🌳派生类的构造函数
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用
//基类的构造:
Person(const char* name = "", size_t age = 0) //全缺省,默认构造函数
:_name(name)
, _age(age)
{
cout << "Person()" << endl;
}
//派生类的构造:
Student(const char* name = "", size_t age = 0, const char* id = "")
:Person(name, age) //初始化列表调用基类的构造函数
,_id(id)
{
cout << "Student()" << endl;
}
如果基类中存在默认构造函数,在派生类的构造函数中可以调可不调基类的构造函数。
如果基类中没有默认构造函数,则一定需要在派生类的初始化列表进行调用。
总结以上两点的话:不管有没有默认构造函数都推荐在派生类的初始话列表调用基类的构造函数。
int main()
{
Student st1("悟空", 99999, "水帘洞");
return 0;
}
调用结果如下:
这里之所以会调用基类的析构函数是因为:派生类中没有实现析构函数,这个是由编译器默认生成的派生类的析构函数调用基类的析构函数结果。
🌳派生类的拷贝构造函数
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
拷贝构造属于构造函数的重载形式,也可以使用初始化列表
//基类的拷贝构造
Person(const Person& p) //基类将派生类成员进行切割:Person& p = st;
:_name(p._name)
,_age(p._age)
{
cout << "Person(const Person& p)" << endl;
}
//派生类的拷贝构造
Student(const Student& st)
:Person(st) //调用基类的拷贝构造
,_id(st._id)
{
cout << "Student(const Student& st)" << endl;
}
派生类调用基类的拷贝构造,只能在初始化列表进行调用,在拷贝构造内部会报错
int main()
{
Student st1("悟空", 99999, "水帘洞"); //调用构造
Student st2(st1); //调用拷贝构造
return 0;
}
调用结果如下:
🌳派生类的赋值运算符重载
稍微有点绕的点来了,我们往下看:
//基类的赋值重载
Person& operator=(const Person& p) //基类将派生类成员进行切割:Person& p = st
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
cout << "Person& operator=(const Person& p)" << endl;
return *this;
}
//派生类的赋值重载
Student& operator=(const Student& st)
{
if (this != &st) //防止自己赋值给自己
{
operator=(st); //调用赋值重载
_id = st._id;
}
cout << "Student& operator=(const Student& st)" << endl;
return *this;
}
在这里能够直接使用赋值重载吗?不行,会造成无限递归。
- 派生类的operator=必须要调用基类的operator=完成基类的复制
由于派生类和基类都是有各自的作用域的,在这里派生类的赋值重载和基类的赋值重载同名,编译器会将基类的赋值重载进行隐藏;
在这里的派生类的赋值重载调不到基类的赋值重载,派生类的赋值重载会一直调用自己的赋值重载,造成无限递归。
这就是为什么不让派生类成员名和基类的成员名字一样了,往往不注意就会给自己挖坑;
运算符重载关键字operator是规定死的了,在这里避免不了,要调用基类的赋值重载的话只能限定作用域:
Student& operator=(const Student& st)
{
if (this != &st) //防止自己赋值给自己
{
Person::operator=(st); //限定基类的作用域去调用基类的赋值重载
_id = st._id;
}
cout << "Student& operator=(const Student& st)" << endl;
return *this;
}
int main()
{
Student st1("悟空", 99999, "水帘洞"); //调用构造
Student st2;
st2 = st1; //调用赋值重载
return 0;
}
运行结果:
🌳派生类的析构函数
依照上面各种默认成员函数实现的经验,每次都是先调用基类的默认成员函数以后才会调用派生类的默认成员函数,那么习惯性的会将派生类的析构函数实现如下:
//基类的析构函数
~Person()
{
//_name是自定义类型成员,会调用自己的析构函数,这里不做处理
_age = 0;
cout << "~Person()" << endl;
}
//派生类的析构函数
~Student()
{
//_num是自定义类型成员,自己会调用自己的析构函数,这里不做处理
~Person(); //调用基类的析构函数
cout << "~Student()" << endl;
}
析构函数待程序结束后会自己调用:
int main()
{
Student st1("张三", 28, "广东");
return 0;
}
报错的结果让人摸不着头脑,在这里调不动基类的析构函数;
- 受到C++多态语法的原因,编译器会对析构函数名进行特殊处理,派生类和基类的析构函数都处理成
destrutor()
(重命名),所以基类的析构函数不加virtual
关键字的情况下,派生类的析构函数和基类的析构函数名字都是destrutor()
因此构成隐藏关系
那就指定基类的作用域,去调用基类的析构函数:
~Student()
{
//_num是自定义类型成员,自己会调用自己的析构函数,这里不做处理
Person::~Person(); //指定作用域调用基类的析构函数
cout << "~Student()" << endl;
}
运行走起:
这里的基类的析构函数很莫名其妙的出现了两次。
由于栈帧是后进先出的,对于普通类定义多个对象的时,程序运行结束后(如果没有特殊情况),首先会调用最后创建对象的析构函数,然后再依次往回去调用其他对象的析构函数。
- 为了保持栈帧的后进先出的特性,派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能
保证派生类对象先清理派生类成员再清理基类成员的顺序
因此在派生类的析构函数中,我们只需要清理派生类的成员对应内容即可,基类的析构函数会在派生类析构函数中自动调用,并不需要我们手动去调。
因此Student派生类的析构函数实现如下即可:
~Student()
{
//_num是自定义类型成员,自己会调用自己的析构函数,这里不做处理
cout << "~Student()" << endl;
}
🌴继承的其他一些细节
🌳关于友元函数
如果一个不在类中的函数要想访问类中的保护成员或是私有成员,那么这个函数需要在类中被声明是友元函数,才能进行访问。
在继承中,如果一个函数是基类的友元函数,那么该函数可以在派生类中,访问派生类的私有成员或是包含成员吗?
上例子:
//基类
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);
return 0;
}
编译运行结果如下:
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
关于友元函数,能不用就尽量不去使用,友元函数的存在会破坏封装的整体特性。
🌳关于静态成员
🌲类的静态成员概念
- 声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数
普通类的静态成员有以下特性:
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制
- 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问,前提是不受限定访问
- 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明
在继承中,基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例
//基类
class Person
{
public :
//构造
Person () {++ _count ;}
protected :
string _name ; // 姓名
public :
static int _count; // 静态成员:公有
};
int Person :: _count = 0; //定义静态成员
//派生类
class Student : public Person
{
protected :
int _stuNum ; // 学号
};
int main()
{
Person rp1;
Person rp2;
Person rp3;
Student s1;
Student s2;
Student s3;
cout << " 人数 :" << Person::_count << endl;
Student ::_count = 0;
cout <<" 人数 :"<< Person ::_count << endl;
return 0;
}
不管创建多少个基类还是派生类,整个继承体系里面只有一个_count
的成员,利用静态成员的作用,可以帮助我们计算创建了多少个对象。
来思考这样两个问题:
- 基类对象中包含了所有基类的成员变量
- 子类对象中不仅包含了所有基类成员变量,也包含了所有子类成员变量
上述两个观点都是错误的,基类对象中不包含静态变量;同理,派生类对象也如此。
🌴继承与组合
- 继承:继承是允许你根据基类的实现来定义派生类的实现。
继承是一种is-a的关系;也就是说每个派生类对象都是一个基类对象。
继承例子就犹如该篇文章都有多次提及就不再举例了。
- 这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。
- 继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 组合:对象组合是类继承之外的另一种复用选择。
组合是一种has-a的关系;假设B组合了A,每个B对象中都有一个A对象。
汽车与轮胎的关系(has-a):
class Tire{
protected:
string _brand = "Michelin"; // 品牌
size_t _size = 17; // 尺寸
};
class Car{
protected:
string _colour = "白色"; // 颜色
string _num = "陕ABIT00"; // 车牌号
Tire _t; // 轮胎
};
- 新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
- 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装
- 总结:实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合
继承的基础知识点就讲到这里,多继承/菱形继承会单独写出一篇,感谢大家支持!!!