本期我们来学习c++中的继承
目录
一、啥是继承
二、继承的定义
1.1 定义格式
1.2 继承方式
三、基类和派生类对象赋值转换
四、继承中的作用域
五、派生类的默认成员函数
5.1 派生类的构造函数和基类构造函数的关系
5.2 派生类的拷贝函数和基类拷贝函数的关系
5.3 派生类的析构函数和基类析构函数的关系
六、继承与友元
七、继承与静态成员
八、无法被继承的类
九、复杂的菱形继承及菱形虚拟继承
9.1 多继承
9.2 菱形继承
9.2.1 菱形虚拟继承
9.2.2 虚拟继承的原理
十、继承的总结和反思
一、啥是继承
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保 持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象 程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
光说概念没有用,下面来看代码:
class Teacher
{
private:int _age;char _sex;std::string _name;std::string _Tea_id;
};class Student
{
private:int _age;char _sex;std::string _name;std::string _stu_id;
};
现在我们有两个类Teacher和Student,但是这两个类中的成员都一样,那我们能不能简化一下代码?当然,我们再来看到下面:
class person
{
public:void Print(){std::cout << _age << " " << _sex << " " << _name << " " << _id << std::endl;}
private:int _age=18;char _sex=‘M’;std::string _name=“Elon Musk”;std::string _id=“123456”;
};class Teacher :public person
{
};class Student :public person
{
};
现在我们新创建了一个person类,使Teacher和Student都继承person类(至于怎么样继承,以什么方式继承我们在后面说),下面我们写一个main函数来看看现象:
int main()
{Student s;s.Print();return 0;
}
咦? 我们创建的Student类对象s,里面有着person所有成员和数据哎
这就是继承~
二、继承的定义
下面我们来看看怎么使用继承:
1.1 定义格式
每个继承都有自己的父类(基类)、子类(派生类)和继承方式:
1.2 继承方式
继承方式有三种,和类成员的访问限定符是一样的:
使用不同的继承方式,继承基类成员访问方式的变化:
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected 成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
总结:
1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面,都不能去访问它
2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在 派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的
3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private
4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
三、基类和派生类对象赋值转换
派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。
例如:
class person
{
public:void Print(){std::cout << _age << " " << _sex << " " << _name << " " << _id << std::endl;}int _age = 18;char _sex = 'M';std::string _name = "Elon Musk";std::string _id = "123456";
};class Student :public person
{
private:int _grade = 100;
};int main()
{Student s;s._age = 21;s._id = "987456";s._name = "1e-12";person p = s;//派生类对象赋值给基类的对象p.Print();return 0;
}
上述代码,我们将派生类Student所创建的对象s赋给基类的对象p
运行结果:
可以看到基类对象中所有的成员的值都和派生类对象的值一样
这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。 基类对象不能赋值给派生类对象。 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。(这个我们后面再讲解,这里先了解一下)
再来看到下面的代码:
class person
{
public:void Print(){std::cout << _age << " " << _sex << " " << _name << " " << _id << std::endl;}int _age = 18;char _sex = 'M';std::string _name = "Elon Musk";std::string _id = "123456";
};class Student :public person
{
private:int _grade = 100;
};int main()
{Student s;s._age = 21;s._id = "987456";s._name = "1e-12";person& p = s;//派生类对象赋值给基类的引用p._age = 25;s.Print();return 0;
}
运行结果:
我们可以看到派生类对象赋值给基类的引用的话,相当于给派生类中的含有的基类成员取别名:
最后再看到下面:
class person
{
public:void Print(){std::cout << _age << " " << _sex << " " << _name << " " << _id << std::endl;}int _age = 18;char _sex = 'M';std::string _name = "Elon Musk";std::string _id = "123456";
};class Student :public person
{
private:int _grade = 100;
};int main()
{Student s;s._age = 21;s._id = "987456";s._name = "1e-12";person* p = &s;//派生类对象赋值给基类的指针p->_age = 25;s.Print();return 0;
}
运行结果:
派生类对象赋值给基类的指针的话,相当于提取了派生类中的含有的基类成员的地址:
四、继承中的作用域
在继承体系中基类和派生类都有独立的作用域,互不影响
我们来看到下面的代码:
class person
{protected:int _age = 18;char _sex = 'M';std::string _name = "Elon Musk";std::string _id = "123456";
};class Student :public person
{
public:void print(){std::cout << _age << " " << _sex << " " << _name << " " << _id << std::endl;}
private:std::string _id = "000000";
};int main()
{Student s;s.print();return 0;
}
该代码的运行结果为:
我们可以看到派生类中定义了一个和基类成员名字一样_id,接着在打印派生类对象数据时,_id默认是派生类中的成员
这类情况是子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏, 也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
例如:
class person
{protected:int _age = 18;char _sex = 'M';std::string _name = "Elon Musk";std::string _id = "123456";
};class Student :public person
{
public:void print(){std::cout << _age << " " << _sex << " " << _name << " " << _id << " " << person::_id << std::endl;//基类::基类成员 显示访问}
private:std::string _id = "000000";//该成员与基类中的成员命名相同
};int main()
{Student s;s.print();return 0;
}
代码的运行结果:
下面来看到另一段代码:
class A
{
public:void fun(){std::cout << "func()" << std::endl;}
};
class B : public A
{
public:void fun(int i){A::fun();std::cout << "func(int i)->" << i << std::endl;}
};int main()
{B b;b.fun(10);return 0;
}
运行结果:
在上述代码中,子类和父类中都有相同名字的函数fun,但是它们的形参不同,那它们是否构成函数重载呢?
我们仔细想想,构成函数重载的必要条件是什么?
那一定要在同一个作用域内啊,但是上面的代码子类和父类是两个作用域,所以两个fun函数并不能构成函数重载
所以需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏,并不构成函数重载
那我们想在子类对象的外部访问父类中的成员可以这样子(对象名.基类::基类成员):
int main()
{B b;b.A::fun();return 0;
}
还要注意的是:在实际中在继承体系里面最好不要定义同名的成员,不然非常容易混淆
五、派生类的默认成员函数
我们来分析分析派生类中的默认成员函数与基类的默认成员函数有什么样的关系:
5.1 派生类的构造函数和基类构造函数的关系
看代码:
using namespace std;
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 s;return 0;
}
运行效果:
可以看到我们上面也没做,只是创建了一个派生类的对象,就调用了基类中的构造和析构函数,这个也好理解,派生类的对象中有基类的成员,自然要调用基类中的构造和析构函数
那我们不想使用基类中构造函数,想直接调用派生类中构造函数来初始化基类中的成员行不行呢?
接着看下面的代码:
class Student : public Person
{
public:Student(const char* name, int num):_name(name),//先要直接初始化基类中的_name成员_num(num){}
protected:int _num; //学号
};
但是编译时过不去:
有的同学会说,是不是上面的_name前没有作用域限定符,所以访问不到父类中的成员呢?
那我们就更改代码再来试试
但编译器还是没给我们过,所以想要在子类中初始化父类成员是行不通的
那我们想要初始化父类中的成员该怎么办呢?
答案当然是调父类的构造函数:
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
{
public:Student(const char* name, int num):Person(name),//使用父类的构造函数来初始化父类中的成员_num(num){}void print(){cout << _name << " " << _num << endl;}
protected:int _num; //学号
};int main()
{Student s("张三",2100);s.print();return 0;
}
运行效果:
5.2 派生类的拷贝函数和基类拷贝函数的关系
下面我们来在派生类中使用拷贝构造函数:
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
{
public:Student(const char* name, int num):Person(name),//使用父类的构造函数来初始化父类中的成员_num(num){}Student(const Student& s){_num = s._num;}void print(){cout << _name << " " << _num << endl;}
protected:int _num; //学号
};int main()
{Student s1("张三",2100);Student s2(s1);//拷贝构造s2.print();return 0;
}
运行效果:
可以看到派生类对象在拷贝构造另一个对象时并不会自动去调用父类中的拷贝构造函数,这样就导致了派生类中的基类成员并不会被赋值,所以我们在写派生类的拷贝构造函数时,如果想要将基类成员一起赋值就需要手动调用基类中的拷贝构造函数:
class Student : public Person
{
public:Student(const char* name, int num):Person(name),//使用基类的构造函数来初始化基类中的成员_num(num){}Student(const Student& s):Person(s),//手动调用基类中的拷贝构造_num(s._num){}void print(){cout << _name << " " << _num << endl;}
protected:int _num; //学号
};
但是我们仔细分析一下为什么基类的拷贝构造函数中形参是const Person& p但是我们传入的却是const Student& s对象,但是也可以成功调用呢?
这就回到了我们上面讲的基类和派生类对象赋值转换了,在这里发生了派生类的切片转换
5.3 派生类的析构函数和基类析构函数的关系
我们接着来看代码,看看派生类和基类之前的析构有什么关系:
using namespace std;
class Person
{
public:Person(const char* name = "peter"): _name(name){cout << "Person()" << endl;}~Person(){cout << "~Person()" << endl;}
protected:string _name; // 姓名
};class Student : public Person
{
public:Student(const char* name, int num):Person(name),//使用基类的构造函数来初始化基类中的成员_num(num){cout << "Student()" << endl;}~Student(){cout << "~Student()" << endl;}protected:int _num; //学号
};int main()
{Student s1("张三", 0);return 0;
}
运行效果:
我们可以看到在派生类析构之后,基类也随之析构了
所以我们可以看到:每当一个派生类对象析构之后,系统会自动调用基类中的析构函数来析构刚刚被析构派生类对象中的基类成员,所以我们在派生类的析构函数中不必手动调用基类中的析构函数
那为什么系统先要析构派生类的成员再析构其中的基类成员呢?
我们可以从两个角度来理解:
1.程序所有的栈帧都是由栈来存储的,在创建派生类对象时先要创建其中的基类对象,所以根据先进后出的原则,系统先要析构派生类的成员,再析构其中的基类成员
2.在派生类中是可以调用基类中的成员的,如果先对基类成员进行析构,假如在派生类中要对基类成员进行访问,这会造成非法访问,问题很严重
六、继承与友元
友元关系不能继承,也就是说基类友元不能访问派生类私有和保护成员:
class Student;
class Person
{
public:friend void Display(const Person& p, const Student& s);//Display作为基类的友元函数
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;//访问不了派生类的保护/私有成员
}
另外派生类友元不能访问基类私有和保护成员:
class Student;
class Person
{
protected:string _name; // 姓名
};
class Student : public Person
{
public:friend void Display(const Person& p, const Student& s);//Display作为派生类的友元函数
protected:int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{cout << p._name << endl; // 访问不了基类的保护/私有成员cout << s._stuNum << endl;
}
七、继承与静态成员
基类定义了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 p;Student s1;cout << &p._count << endl;cout << &s1._count << endl;Student s2;Student s3;cout << " 人数 :" << Person::_count << endl;Student::_count = 0;cout << " 人数 :" << Person::_count << endl;return 0;
}
运行效果:
我们可以看到在上述代码中, 派生类中的_cout和基类中的_cout成员地址是一样的,这足以证明派生类中继承的静态成员和基类中的静态成员是同一个
八、无法被继承的类
我们如果想让一个类无法被继承,我们只需要对其内部构造或析构函数进行私有化即可:
class A
{
private:A()//构造函数私有化,此类无法被继承{}
};
class B : public A
{
};int main()
{B b;return 0;
}
class A
{
private:~A()//析构函数私有化,此类无法被继承{}
};
class B : public A
{
};int main()
{B b;return 0;
}
这样子虽然让这个类无法被其他类继承,但是我们现在因为在外部无法调用它的构造或析构函数,使得我们无法创建对象啊,这个好解决,我们在其内部加上一个静态函数来调用其构造/析构函数即可:
class A
{
public:static A Create(int count = 0)//使用静态函数来调用其构造函数{return A(count);}void print(){cout << _count << endl;}
private:A(int count)//构造函数私有化,此类无法被继承:_count(count){}int _count;
};int main()
{A a=A::Create(5);//创建对象a.print();return 0;
}
九、复杂的菱形继承及菱形虚拟继承
9.1 多继承
在C++中不仅仅可以让一个类继承另一个类(单继承),还可以让一个类继承其他多个类,这种情况我们将其称为多继承:
9.2 菱形继承
因为多继承的出现,我们会遇到这样一种情况:
我们多继承的两个类,都继承了同一个基类,这叫做菱形继承:
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; // 主修课程
};
这样的菱形继承存在两个问题:
1.在我们创对象去访问基类成员时,编译器无法直接识别我们访问的是哪个基类中的成员(二义性)
2.在Assistant的对象中Person成员会有两份,这造成了数据的冗余
对于二义性我们好解决,可以使用作用域限定符来指定访问的基类成员:
void Test()
{Assistant a;// 需要显示指定访问哪个父类的成员可以解决二义性问题a.Student::_name = "xxx";a.Teacher::_name = "yyy";
}
但是数据的冗余问题还是存在
9.2.1 菱形虚拟继承
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和 Teacher的继承Person时使用虚拟继承,即可解决问题。
使用虚拟继承的方法:在继承方式前加上virtual关键字即可
class Person
{
public:string _name; // 姓名
};
class Student : virtual public Person
{
protected:int _num; //学号
};
class Teacher : virtual public Person
{
protected:int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};
需要注意的是,虚拟继承不要在其他地方去使用
9.2.2 虚拟继承的原理
下面我们来举例,来深入分析虚拟继承的底层原理:
class A
{
public:int _a;
};
class B : public A
{
public:int _b;
};
class C : 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;
}
我们先来一个没有使用虚拟继承的菱形继承,来看看其创建的对象的内存情况:
这在意料之中
下面我们来修改一下代码,看看使用虚拟继承,内存会发生什么样的改变:
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;
}
这里可以看到在内存中,被继承的A基类的成员_a,其空间只有一份,在D对象中将_a放到的了对象组成的最下面,这个_a同时属于B和C这两个基类
但是基类B和C中的空间发生了变化,除了它们所含有的成员_b和_c的数据外,上面还多了一份地址!
仔细看这个多出来的地址,其指向的空间还存储了其他数据!C类中的地址存储了12这个数据,B类中的地址存储了16这个数据,这两个数据意味着什么?
实际上这两个量记录的是,所在基类首地址相对于公共继承的A类中的成员_a的偏移量,可以通过偏移量找到下面的_a数据!
这就是虚拟继承的原理,将基类所继承的公共成员只共同存储一份在内存中,再通过记录偏移量来访问它们
那有的同学会问:为什么要通过存储一个指针来找到偏移量,再通过偏移量找到所存的公共数据呢?我们可以直接将指针换成公共数据部分的地址啊,这样不是更方便吗?
实际上该指针指向的空间是虚基表,该表中不仅仅存的偏移量还有多态的信息(我们后期会详细讲解)
但是这里要注意到所有使用了虚拟继承的类,该内部空间都会发生改变,将继承的基类成员存储在类对象空间的最后,并记录偏移量
十、继承的总结和反思
1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
2. 多继承可以认为是C++的缺陷之一,很多后来的编程语言都没有多继承,如Java。
3. 继承和组合 public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
4.组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
5.优先使用对象组合,而不是类继承 。
6.继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称 为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
7.对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
8.实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有 些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用 继承,可以用组合,就用组合。
下面是代码举例:
// Car和BMW Car和Benz构成is-a的关系
class Car {
protected:string _colour = "白色"; // 颜色string _num = "陕ABIT00"; // 车牌号
};class BMW : public Car {
public:void Drive() { cout << "好开-操控" << endl; }
};class Benz : public Car {
public:void Drive() { cout << "好坐-舒适" << endl; }
};// Tire和Car构成has-a的关系class Tire {
protected:string _brand = "Michelin"; // 品牌size_t _size = 17; // 尺寸};class Car {
protected:string _colour = "白色"; // 颜色string _num = "陕ABIT00"; // 车牌号Tire _t; // 轮胎
};