跳到主要内容C++ 继承详解:派生类函数实现、虚继承原理与菱形继承案例 | 极客日志C++算法
C++ 继承详解:派生类函数实现、虚继承原理与菱形继承案例
C++ 继承机制是面向对象代码复用的核心手段。本文涵盖继承的概念定义、访问权限控制规则、派生类默认成员函数生成逻辑及作用域隐藏问题。重点解析多继承中的菱形继承数据冗余与二义性,阐述虚继承解决方案及 IO 库应用实例。最后对比继承与组合的耦合度差异,强调优先使用组合的设计原则。
活在当下1 浏览 1. 继承的概念和定义
概念:
继承 (inheritance) 机制是面向对象程序设计使代码可以复用的最重要手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法 (成员函数) 和属性 (成员变量),这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。
class Student {
public:
void Identify() {
}
void study() {
}
protected:
string _name;
string _tel;
string _address;
int _age = 18;
int _stuid;
};
class Teacher {
public:
void identity() {
}
void teaching() {
}
protected:
string _name;
string _tel;
string _address;
int _age = 18;
string _title;
};
可以看到 Student 和 Teacher 都有公共成员比如名字,电话,地址,年龄... 都有 identity 身份认证的成员函数,设计到两个类里面就是冗余的。当然他们还有不同的属性特征比如 Student 有学生 id Teacher 有职称或者相关函数等等。那么我们是否可以将公共的属性封装到一个公共类里而我们通过继承的方式获取这些属性呢?
下面我们将公共的成员都放到 Person 类中,Student 和 Teacher 都继承 Person,就可以复用这些成员,就不需要重复定义了,省去了很多麻烦。
class Person {
public:
void Identify() {
cout << "void Identify()" << _name << endl;
}
protected:
string _name;
string _tel;
string _address;
int _age;
};
class Student : public Person {
public:
void study() {
}
protected:
int _stuid;
};
class Teacher : public Person {
public:
void teaching() {
}
protected:
string _title;
};
定义:
我们看到 Person 是基类,也称作父类。Student 是派生类,也称作子类。(因为翻译的原因,所以既叫基类/派生类,也叫父类/子类)
C++ 中的继承方式有三种:public, protected, private。
针对某种父类成员来说,子类继承后是否可以访问继承的这个成员取决于父类的访问限定符和子类的继承方式。
| 基类成员 | public 继承 | protected 继承 | private 继承 |
|---|
| public | public | protected | private |
| protected | protected | protected | private |
| private | 不可见 | 不可见 | 不可见 |
基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出现的。
实际上上面的表格我们进行一下总结会发现,基类的私有成员在派生类都是不可见。基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显式的写出继承方式。
在实际运用中一般使用都是 public 继承,几乎很少使用 protected/private 继承,也不提倡使用 protected/private 继承,因为 protected/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
实例演示三种继承关系下基类成员的各类型成员访问关系的变化
class Person {
public:
void Print() {
cout << _name << endl;
}
protected:
string _name = "1";
private:
int _age;
};
class Student : public Person {
protected:
int _stunum;
};
int main() {
Student s;
s.Print();
return 0;
}
对于_name 最终的继承方式为 protected 可以访问。
protected:
该种继承模式下对于 print 成员函数 最终的继承方式为 protected,在子类中该成员函数的访问权限为 protected 类外不可访问。
class Person {
public:
void Print() {
cout << _name << endl;
}
protected:
string _name = "1";
private:
int _age;
};
class Student : protected Person {
protected:
int _stunum;
};
int main() {
Student s;
s.Print();
return 0;
}
private:
子类中关于父类的成员的全部访问权限为 private 无论是子类内还是子类外全不可访问 这些成员。
class Person {
public:
void Print() {
cout << _name << endl;
}
protected:
string _name = "1";
private:
int _age;
};
class Student : private Person {
protected:
int _stunum;
};
int main() {
Student s;
s.Print();
s._name();
s._age;
return 0;
}
继承类模板
template<class T>
class Stack : public vector<T> {
void push(const T& x) {
vector<T>::push_back(x);
}
void pop() {
vector<T>::pop_back();
}
};
2. 基类和派生类间的转换
- public 继承的派生类对象 可以赋值给 基类的指针 / 基类的引用。这里有个形象的说法叫
切片或者切割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。基类对象不能赋值给派生类对象。基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Information) 的 dynamic_cast 来进行识别后进行安全转换。
切片的图例化:Student 中的_No 是 Student 的独有成员 将其切掉 保留 Student 中的父类成员 整体给父类的指针或者引用。
class Person {
protected:
string _name;
string _sex;
int _age;
};
class Student : public Person {
public:
int _No;
};
int main() {
Student s;
Person* ptr = &s;
Person& a = s;
Person per = s;
s = per;
return 0;
}
3. 继承中的作用域
隐藏规则:
在继承体系中基类和派生类都有独立的作用域。子类继承的父类可以把父类的所有成员看成一个整体本质就是一个自定义类对象(参考 string)只不过继承的父类对象在子类的声明头部 -> 初始化列表中优先调用父类的构造函数初始化父类对象。派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。(在派生类成员函数中,可以使用 基类::基类成员 显示访问)。子类继承父类后,在子类外面调用子类的函数时优先扫描子类域如果没有才会扫描子类中父类对象的域。需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。注意在实际中在继承体系里面最好不要定义同名的成员。(如果做了这件事难道不是违背祖师爷设计继承出来的意义么?)
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;
};
4. 派生类的默认成员函数
4 个常见默认成员函数
6 个默认成员函数,默认的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。派生类的 operator=必须要调用基类的 operator=完成基类的复制。需要注意的是派生类的 operator=隐藏了基类的 operator=,所以需要显示调用基类的 operator=,需要指定基类作用域。派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序 满足先定义的类后析构这个设计要求派生类对象初始化先调用基类构造再调派生类构造。 构造函数会先走初始化列表。而基类对象在子类成员变脸声明的头部。派生类对象析构清理先调用派生类析构再调基类的析构。*** (满足先定义的类后析构这个设计要求) 因为多态中一些场景析构函数需要构成重写,重写的条件之一是函数名相同 (这个我们多态章节会讲解)。那么编译器会对析构函数名进行特殊处理,处理成 destructor(),所以基类析构函数不加 virtual 的情况下,派生类析构函数和基类析构函数构成隐藏关系该点在下篇博客多态中详细阐述。
[参考代码]:
测试父子类 构造函数 和析构函数的调用规则
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;
}
protected:
int _num;
};
int main() {
Student s1("jack", 18);
Student s2(s1);
Student s3("rose", 17);
s1 = s3;
return 0;
}
实现一个不能被继承的类
方法 1:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。
方法 2:C++11 新增了一个 final 关键字,final 修改基类,派生类就不能继承了。
class Base final {
public:
void func5() {
cout << "Base::func5" << endl;
}
protected:
int a = 1;
private:
};
class Derive : public Base {
void func4() {
cout << "Derive::func4" << endl;
}
protected:
int b = 2;
};
int main() {
Base b;
Derive d;
return 0;
}
5. 继承与友元
友元关系不能继承,也就是说基类友元不能访问派生类私有和保护成员。
但是该友元依旧可以访问基类的私有和保护成员。
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;
}
6. 继承与静态成员
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类,都只有一个 static 成员实例。
即基类实例化的对象 和 派生类实例化的对象 调用 static 成员时 调用的是同一个。
这里不做代码演示了 各位可以自己私下验证。
思路:验证基类和派生类中该 static 成员的地址是否相同。
7. 多继承及其菱形继承问题
继承模型:
单继承:一个派生类只有一个直接基类时称这个继承关系为单继承。
多继承:一个派生类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员在放到最后面。
菱形继承:菱形继承是多继承的一种特殊情况。菱形继承的问题,从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题,在 Assistant 的对象中 Person 成员会有两份。支持多继承就一定会有菱形继承,像 Java 就直接不支持多继承,规避掉了这里的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。
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;
};
int main() {
Assistant a;
a._name = "peter";
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
return 0;
}
看编译器的报错

虚继承
使用虚继承可以解决菱形继承的数据冗余和访问时的二义性。
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;
};
int main() {
Assistant a;
a._name = "peter";
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
return 0;
}
菱形继承足够复杂时 会出现各种问题 所以在实际开发中 我们可以实现多继承(符合语法设计) 但是尽量避免菱形继承,或者说就不要设计菱形继承。我查询了下菱形继承的设计场景,结果是很少。
以下是 io 库中的菱形继承
C++ 文档中 库中关于此时设计的部分源代码
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits> {};
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits> {};
8. 继承和组合
public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象 (比如适配器)。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为*
白箱复用(white-box reuse)。术语'白箱'是相对可视性而言:在继承方式中,基类的内部细节对派生类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为*
黑箱复用 (black-box reuse)*,因为对象的内部细节是不可见的。对象只以'黑箱'的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
程序的设计要求高内聚,内耦合,这样可以保证每个模块的稳定 与高效。
因此优先使用组合,而不是继承。实际尽量多用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承 (is-a) 那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承 (is-a) 也适合组合 (has-a),就用组合。
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online