C++之继承详解
目录
前言
一、继承的概念及定义
1.1 继承的概念
1.2 继承的定义
1.2.1 继承格式
1.2.2 继承基类成员访问方式的变化
二、赋值兼容规则
三、继承中的作用域
四、默认成员函数
final关键字
五、继承与友元
六、继承与静态成员
七、多继承及其菱形继承问题
7.1 继承模型
7.2 虚继承
八、继承和组合
总结
前言
之前的文章我们讲解了STL中一些基础容器的实现,然后把内存管理、模版这些也一直在用,相信大家对于他们的使用肯定是没有问题了,面向对象三大特性:封装、继承、多态,那本篇文章要来讲解的是继承,接下来就开始正题!!!
一、继承的概念及定义
1.1 继承的概念
继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类,继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程,以前我们接触的函数层次的复用,继承是类设计层次的复用
就比如,有老师和学生两个类,老师和学生都有姓名、年龄、电话、家庭地址、身份证号这些成员变量,那设计到两个类里就太冗余了,虽然他们也可以有不同的成员变量和成员函数,比如学生可以有学号,老师可以有工号等等这些
class Teacher
{
public:
void Teach()
{
}
protected:
//都有的
string name;
size_t age;
string address;
string tele;
string Identification;
//不同的
string jobid;
};
class Student
{
public:
void Study()
{
}
protected:
//都有的
string name;
size_t age;
string address;
string tele;
string Identification;
//不同的
string studyid;
};
那如果有10个类,这些信息要在10个类里面都写一份,那也太麻烦了,而且也不方便管理,那我们可以把这些信息单独放在一个类中,其它类来继承它,下面我们公共的成员都放到Person类中,Student和Teacher都继承Person,就可以复用这些成员,就不需要重复定义了,省去了很多麻烦
1.2 继承的定义
1.2.1 继承格式
1.2.2 继承基类成员访问方式的变化
类成员/访问方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
根据上面的表格我们可以总结出以下几点
1. 基类private成员在派生类中无论以什么方式继承都是不可见的,这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它,跟private不一样,private是类里面可以使用,类外面不能使用2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected,可以看出保护成员限定符是因继承才出现的3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在派生类都是不可见,基类的其他成员在派生类的访问方式是去成员在基类的访问限定符和继承方式中小的那个,大小是这样排序的public > protected > private4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式5. 在实际运用中⼀般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
class Person
{
protected:
string name;
size_t age;
string address;
string tele;
string Identification;
private:
int _a;
};
class Teacher : public Person
{
public:
void Teach()
{
// 不可见
_a++
}
protected:
string jobid;
};
class Student : public Person
{
public:
void Study()
{
}
protected:
string studyid;
};
int main()
{
Teacher t;
Student s;
//可以访问
t.Teach();
s.Study();
//不可访问
cout << t.age << endl;
cout << s.age << endl;
return 0;
}
我们看到Person是基类,也叫做父类,Student是派生类,也叫做子类。_a是Person类的私有成员,在Teacher类中不可见,把其它这些成员在Person中放成保护,那公有继承,在两个派生类中就是保护成员,派生类可以用,但是类外就访问不到了,如果定义成私有成员,不仅在类外访问不到,在派生类中也访问不到了,那继承就没有意义了,所以要继承的类一般都不会把成员设计成私有,也不会用私有继承
二、赋值兼容规则
public继承的派生类对象可以赋值给基类的指针/基类的引用/基类的对象,这里有个形象的说法叫切片或者切割,意思就是把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
Student s;
// 派生类对象赋值给基类对象
Person p = s;
// 派生类对象赋值给基类的指针/引用
Person* pp = &s;
Person& rp = s;
return 0;
}
这就是赋值兼容规则,基类的对象或者指针/引用看到的就是基类的大小
但是需要注意的是,对象之间的赋值,是有拷贝的,而指针和引用的赋值是没有拷贝的。对象之间的赋值是先去调用了基类的拷贝构造,然后再把派生类中的基类切出去,这个在下面讲派生类的默认成员函数时会验证。那怎么证明指针和引用的赋值没有拷贝呢?
int main()
{
int i = 0;
// 权限放大
double& d = i;
// 权限平移
const double& d = i;
return 0;
}
当两个不相同的类型赋值的时候,中间会产生一个临时变量,临时变量具有常性,所以d引用的其实是中间的临时变量,也就是权限放大了,而上面的Person& rp = s,是没有报错的,也就是没有权限放大,那rp就没有引用临时变量,就引用的是s,所以没有拷贝
要注意的是,基类的指针或引用可以通过强制类型转换赋值给派生类的指针或引用,但是基类的指针必须是指向派生类对象时才是安全的,指向基类对象时是不安全的,如果基类是多态类型,可以用dynamic_cast识别后进行安全转换,这个涉及到了多态和类型转换,我们就到后面再讲,还有一个就是父类对象无论如何也不能赋值给子类对象,隐式类型转换/强制类型转换都不可以
三、继承中的作用域
class Person
{
protected:
string _name = "张三";
int _num = 1;
};
class Student : public Person
{
public:
void Print()
{
cout << _num << endl; // 10
}
protected:
int _num = 10;
};
int main()
{
Student s;
s.Print();
return 0;
}
我们看到的是这里父类和子类都有名为_num的成员,那在Print函数中打印出来的是谁的_num呢?这里就要引入一个隐藏的概念,隐藏也叫重定义,首先我们先说基类和派生类都有各自独立的作用域的,而在父类域和子类域中存在同名成员,子类的成员就会隐藏父类的成员
class Student : public Person
{
public:
void Print()
{
cout << _num << endl; // 10
cout << Person::_num << endl; // 1
}
protected:
int _num = 10; // 学号
};
只需要指定一下类域就可以访问到父类的成员
class Person
{
public:
void func()
{
cout << "Person::func()" << endl;
}
protected:
string _name = "张三"; // 姓名
int _num = 1; // ⾝份证号
};
class Student : public Person
{
public:
void func()
{
cout << "Student::func()" << endl;
}
protected:
int _num = 10; // 学号
};
int main()
{
Student s;
s.func(); // Student::func()
return 0;
}
父类和子类都有func函数,子类对象调用的时候,因为把父类的也继承下来了,那调用的是继承下来的父类中的func还是子类自己的func呢?定义是这样的,在父类域和子类域中,只要成员函数的函数名相同就构成隐藏,所以访问到的是子类的func函数
int main()
{
Student s;
s.func(); // Student::func()
s.Person::func(); // Person::func()
return 0;
}
同样是在调用时指定类域就可以,但是这里的指定是有些怪的,s.Person::func(),大家要注意一下。虽然可以存在同名的成员,但在实际中的继承体系里最好还是不要定义同名成员,容易造成混淆
四、默认成员函数
四个常见的默认成员函数我们之前已经讲的很透彻了,那我们现在来看看派生类的默认成员函数
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员,如果基类没有默认构造 函数,则必须在派生类构造函数的初始化列表阶段显示调用
class Person
{
public:
Person(const char* name = "peter")
: _name(name)
{
cout << "Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
Student(const char* name, int num)
//: _name(name) // 不可以
: _num(num)
{
cout << "Student()" << endl;
}
protected:
int _num;
};
像上面这样_name在这里初始化是不可以的,不能在派生类的初始化列表中初始化父类的成员,应该把父类当做一个整体。现在父类是有默认构造的,我们可以不显示调用
int main()
{
Student s1("jack", 18);
return 0;
}
通过打印和调试,我们是可以看到结果的,而且是先调用父类的构造函数初始化父类成员,再初始化子类自己的成员
那如果父类没有默认构造,这时需要在子类的初始化列表中显示调用
class Person
{
public:
//Person(const char* name = "peter")
Person(const char* name) // 不是默认构造
: _name(name)
{
cout << "Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
Student(const char* name, int num)
:Person(name) // 显示调用
, _num(num)
{
cout << "Student()" << endl;
}
protected:
int _num;
};
如果在子类的初始化列表中调换了顺序,先写_num,再去初始化父类的,也不会按照我们写的顺序去初始化,就算换了顺序,依然会先初始化父类成员,再初始化自己的成员,这是一个需要注意的点
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
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)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s)
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
protected:
int _num;
};
拷贝构造也是先调用父类的,拷贝父类那一部分成员,再拷贝自己的成员,这里如果父类没有写拷贝构造的话,现在父类有构造,编译器就不会生成拷贝构造了,因为拷贝构造是构造函数的一个重载,那子类的拷贝构造就去调用到了父类的默认构造,可能会出现结果的错误,所以这里父类的拷贝构造还是显示写比较好
派生类的operator=必须要调用基类的operator=完成基类的赋值,需要注意的是派生类的operator=隐藏了基类的operator=,所以显示调用基类的operator=,需要指定基类作用域
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;
}
protected:
string _name;
};
class Student : public Person
{
public:
Student(const char* name, int num)
: Person(name)
, _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;
}
protected:
int _num;
};
如果不指定调用父类的,那就一直是自己调用自己,自己调用自己,就死递归了
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员,因为这样才能保证派 生类对象先清理派生类成员再清理基类成员的顺序
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)
{
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;
//~Person();
//Person::~Person();
}
protected:
int _num;
};
一定要记得这里是自动调用,因为构造时顺序是先调用父类,再调用子类的,所以析构函数就是先析构子类,然后在子类的析构函数结束后自动调用父类的析构函数,这里如果写~Person去调用父类析构是调不到的,因为他们构成了隐藏,但是成员函数不是函数名相同才构成隐藏吗?那父类和子类的析构函数不同名啊,这是因为多态的原因,析构函数的函数名被统一处理成了destructor,这个我们下一篇将多态大家就可以理解了。而且像下面那样写也是不可以的,这样写这里会调一次,子类的析构结束后又会调一次,相当于调了两次,所以我们直接不写就可以了
总结一下,派生类的默认成员函数都要去调用基类的对应的默认成员函数,要把基类当成一个整体,当成派生类的自定义类型成员来看待
那我们把赋值兼容的子类对象赋值给父类对象的拷贝问题验证一下
public:
Person()
{}
Person(const Person& s)
{
cout << "Person(const Person& s)" << endl;
}
protected:
string name;
size_t age;
string address;
string tele;
string Identification;
};
class Student : public Person
{
public:
void Study()
{}
protected:
string studyid;
};
int main()
{
Student s;
Person p = s;
return 0;
}
通过打印,我们可以看到是调用了基类的拷贝构造,然后再把父类的这部分切下来,所以说对象的赋值是有拷贝的,而指针和引用的赋值是没有拷贝的
final关键字
一个类不能被继承,其中一个设计方法是,把基类的构造函数私有,因为派生类要去调用基类的构造函数,而基类的构造函数是私有的话,那在派生类中就不可见,就调不到了
class A
{
private:
A()
{}
};
class B : public A
{
public:
B()
{}
};
int main()
{
A aa;
B bb;
return 0;
}
但是这种方法的问题就是不仅B类不能实例化出对象,A这个类也不能实例化对象了。所以在C++11中引入了一个关键字,叫final,final修饰一个类,这个类不能被继承
class A final
{
public:
A()
{}
};
class B : public A // 编译报错
{
public:
B()
{}
};
int main()
{
A aa;
B bb;
return 0;
}
五、继承与友元
友元关系不能继承,基类友元不能访问派生类的私有和保护成员
class Student;
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;
// 编译报错,无法访问Student的私有
cout << s._stuNum << endl;
}
int main()
{
Person p;
Student s;
Display(p, s);
return 0;
}
这里要前置声明一下Student,因为编译器只会向上查找,那编译器看到基类中的友元声明
friend void Display(const Person& p, const Student& s)时向上去查,找不到Student是什么,就会报错,所以要前置声明,告诉编译器这是一个类,定义在下面。这里Display虽然是基类的友元,可以访问基类的私有,但并不是派生类的友元,要在派生类中也声明一下才可以,友元关系是不能继承的
class Student : public Person
{
friend void Display(const Person& p, const Student& s);
protected:
int _stuNum;
};
六、继承与静态成员
class Person
{
public:
string _name;
static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum;
};
int main()
{
Person p;
Student s;
// 这里的运行结果可以看到非静态成员_name的地址是不一样的
// 说明派生类继承下来了,基类和派生类对象各有⼀份
cout << &p._name << endl;
cout << &s._name << endl;
// 这⾥的运行结果可以看到静态成员_count的地址是⼀样的
// 说明派生类和基类共用同一份静态成员
cout << &p._count << endl;
cout << &s._count << endl;
// 公有的情况下,父类和派生类指定类域都可以访问静态成员
cout << Person::_count << endl;
cout << Student::_count << endl;
return 0;
}
静态成员变量就可以理解为是继承了使用权,但是使用的和基类的还是同一个,并不是有两份
七、多继承及其菱形继承问题
7.1 继承模型


下面我们用ABCD四个类来演示,比较直观一些
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;
};
它的对象模型为
这时候A类在B类和C类中都有一份,访问会出现不明确的情况,解决方法是指定类域,指定一下是在B类中的还是在C类中的

那我们可以通过内存看到,现在这两份_a互相不打扰了,自己有自己的数据,但是指定类域只能解决二义性的问题,无法解决数据冗余,现在只想让_a有一份应该怎么做呢,可以用虚继承来解决
7.2 虚继承
虚继承是解决菱形继承的方式,只需要在腰线的位置加上virtual关键字就可以了,在这里也就是B和C的位置加上virtual
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;
};
现在我们可以看到,_a只会被存储一份,从B和C中拿出来了,存储这个位置是没有规定的,可能其他编译器会单独存在最上面,但是vs是存在最下面的,不管指不指定类域,现在访问到的都是这份_a,那数据冗余和二义性就解决了,但是我们会发现,原来B和C中A的位置,现在被替换成了个两个指针,这两个指针指向的是什么呢?我们再运行一次看看,再运行一次的话地址就不一样了,这个无所谓,我们只需要看一下指向的内容是什么
原来B和C中的A的位置被替换成了两个指针,这两个指针叫虚基表指针,指向虚基表,虚基表中存的是到A的偏移量,为什么要这么设计呢?存偏移量有什么好处呢?我们看下面的代码
int main()
{
B bb;
C cc;
return 0;
}
现在在ABCD四个类中把成员给上缺省值,分别是1-4, 方便我们更好的观察
我们不仅可以定义D类的对象,B和C也可以去定义对象
这里发现,当我们使用虚继承后,不仅D的对象模型变了,B和C的对象模型也变了,A不存在它们当中了,也是拿下来单独存,原来存A的位置现在被替换成了虚基表指针,里面存的是到A的偏移量
int main()
{
D d;
B* pb = &d;
C* pc = &d;
return 0;
}
那当切片的时候,父类指针指向子类对象的时候,切片要把子类中父类的那一部分切出来,因为现在用了虚继承,A就拿出去存了,所以这个时候就要通过虚基表里存的偏移量,来找到A,完成切片
以上就是菱形继承会导致的问题,就需要用虚拟菱形继承来解决,以及虚继承的原理。我们可以设计出多继承,但是不建议设计出菱形继承,因为菱形虚拟继承以后,无论是使用还是底层都会复杂很多。
八、继承和组合
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用,术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对派生类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。对象组合是类继承之外的另⼀种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用,因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承(is-a)也适合组合(has-a),就用组合。
// Tire(轮胎)和Car(车)更符合has-a的关系
class Tire
{
protected:
string _brand = "Pirelli"; // 品牌
size_t _size = 15; // 尺寸
};
class Car
{
protected:
string _colour = "黑色"; // 颜色
string _num = "津BHY529"; // 车牌号
Tire _t1; // 轮胎
Tire _t2; // 轮胎
Tire _t3; // 轮胎
Tire _t4; // 轮胎
};
我们可以看到,车和轮胎明显更加符合has-a的关系,那就优先组合
// Person和Student更符合is-a的关系
class Person
{
public:
void func()
{}
};
class Student : public Person
{
public:
void func()
{}
};
人和学生更加符合is-a的关系,优先使用继承
如果都满足那就尽量组合,因为组合的关联性弱,耦合度低
总结
本篇文章我们讲了继承的知识点,继承是C++中非常重要的知识,也有很多细节的小点,而且C++还有比较复杂的菱形继承,总体来说大家肯定是要多看两遍才能更好的吸收这里,而且等到真正的使用起来的时候大家的感受肯定会再好一点,理解也肯定能加深,那我们本篇文章就到这里了,如果大家觉得小编写的不错的,可以给小编一个三连表示支持,谢谢大家!!!