C++学习笔记-第6单元
注:本部分内容主要来自中国大学MOOC北京邮电大学崔毅东的 《C++程序设计》课程。
第6单元 继承和多态
单元导读
本单元主要介绍如何继承一个类,以及如何实现运行时多态。
单元难点:
- 从基类派生出一个子类后,很关键的是子类构造函数中如何调用基类构造函数,以及构造函数的调用次序。对于这个难点,关键要吃透第1节的【C++11:继承中的构造函数】和【继承中的默认构造函数】这两个知识点。
- 运行时多态是在继承链上出现的。必要条件是必须要使用基类对象的指针或者引用来访问派生类中的同名虚函数。对于这个难点,关键要吃透第3节的【实现运行时多态】和【运行时多态的总结】这两个知识点。
对于上述两个难点,务必亲自动手编写一下程序,或者跟随老师在课件中提供的代码示例视频一起编写。
6.1 继承与构造
6.1.1 继承(Inheritance)
继承是面向对象的第三个特征(面向对象的四个特征是:抽象、封装、继承、多态)。注意到父类是子类的泛化,子类是父类的继承。比如父类是“几何形状”,那么子类就可以是“三角形、正方形、圆……”。
继承链上的类的对应叫法 | |
基类 / Base Class | 派生类 / Derived Class |
父类 / Parent Class | 子类 / Child Class |
超类 / SuperClass | 子类 / SubClass |
继承 vs 泛化 | |
继承/Inherit | 子继承父 |
泛化/Generalize | 父泛化子 |
注意 子类中包含父类的大部分成员。派生类继承基类之后,不仅可以在派生类的函数中直接调用基类的函数(只要不重名),还能再添加属性、添加方法。一般来说,继承的优点是可以提高代码的重用性,减少工作量;提高代码的可拓展性。继承的缺点则是子类必须拥有父类的所有属性与方法,增加了子类的约束;增强了耦合性,当父类被修改时需要考虑子类的修改。
在C++11中,引入final
特殊标识符,可以使得类不能被继承。比如说对于代码:
class B final {};
class D : public B {};
//VS编译后的输出是:error C3246: “D”: 无法从“B”继承,因为它已被声明为“final”这是程序输出。
下面给出继承的示例代码(Shapek类作为基类,让Circle类和rectangle类继承):
基类头文件shape.h
#pragma once
#include<iostream>
#include<string>
#include<array>
using std::string;
using namespace std::string_literals;//使用""s
//枚举颜色
enum class Color {
white, black, red, green, blue, yellow,
};
//定义shape类
class Shape {
private:
Color color{ Color::black };//带有域前缀的枚举常量
bool filled{ false };
public:
Shape() = default;
Shape(Color color_, bool filled_) {
color = color_;
filled = filled_;
}
Color getcolor() { return color; }
void setcolor(Color color_) { color = color_; }
bool isfilled() { return filled; }
void setfilled(bool filled_) { filled = filled_; }
string tostring() {
std::array<string, 6> c{ "white "s,"black "s,"red "s,"green "s,"blue "s,"yellow "s, };
return ("shape: " + c[static_cast<int>(color)] + " " + (filled ? "filled "s : "not filled "s));
}
};
派生类头文件circle.h
#pragma once
#include"shape.h"
//补全circle类,从Shape继承
class circle :public Shape {
double radius { 1.0 };
public:
circle() = default;
circle(double radius_, Color color_, bool filled_) : Shape{ color_,filled_ } {
this->radius = radius_;
};
double getarea() { return(3.14 * radius * radius); };
double getradius() const { return radius; };
void setradius(double radius) { radius = radius; };
};
派生类头文件rectangle.h
#pragma once
#include"shape.h"
//创建rectangle类,从Shape继承
class rectangle :public Shape {
private:
double width{ 1.0 };
double height{ 1.0 };
public:
rectangle() = default;
rectangle(double w, double h, Color c, bool f) :
width{ w }, height{ h }, Shape{ c,f } { };
//以下函数的const表示类的私有数据成员的属性不会被修改
double getwidth() const { return this->width; };
void setwidth(double w) { this->width = w; };
double getheight() const { return this->height; };
void setheight(double h) { this->height = h; };
double getarea() const { return this->height * this->width; };
};
源文件main.cpp
#include<iostream>
#include<string>
#include"shape.h"
#include"circle.h"
#include"rectangle.h"
//创建shape/circle/rectangle对象
//用子类类对象调用基类函数tostring()
int main() {
//定义三个类的对象
Shape s1{ Color::blue,false };
circle c1{ 3.9 ,Color::green,true };
rectangle r1{ 4.0,1.0 ,Color::white,true };
//可以发现子类可以调用父类的函数
std::cout << s1.tostring() << std::endl;
std::cout << c1.tostring() << std::endl;
std::cout << r1.tostring() << std::endl;
//子类也可以直接访问自己的函数
std::cout << "c1 area: " << c1.getarea() << std::endl;
std::cout << "r1 area: " << r1.getarea() << std::endl;
std::cin.get();
return 0;
}
运行结果:
shape: blue not filled
shape: green filled
shape: white filled
c1 area: 47.7594
r1 area: 4
6.1.2 [c++11]继承中的构造函数
本节学习 继承中的构造函数 (Constructors in Inheritance)所具有的一些特征。在C++03中,构造函数不可被继承。而在C++11中,派生类可以继承基类的构造函数,却不能继承的基类的析构函数、友元函数。派生类继承基类构造函数的语法为 using A::A;
表示 继承所有基类的ctor(除了派生类已经定义的同类型构造函数) ,其中前一个A
表示基类,后一个A
表示所有的构造函数。注意 不能仅继承指定的某个基类ctor。下面是 调用从基类继承下来的构造函数 的例子(注意被调用时构造函数的名称也会发生改变,但是编译器还是调用原来的基类的构造函数,而不是重新生成一个新的构造函数):
struct A { // 等价于 class A { public:
A(int i) {}
A(double d, int i) {}
// ...
};
struct B : A { // C++11
using A::A; // 继承基类所有构造函数
int d{0}; // 就地初始化
};
int main() {
B b(1); // 调A(int i)
}
若派生类成员需要初始化,那么就必须要在派生类构造函数中调用基类构造函数,并且也要使用初始化列表的方式 初始化本类成员。也就是,派生类构造函数必须调用基类构造函数。注意到编译器是先调用基类构造函数,再初始化本类成员,这与书写顺序无关。如下所示:
struct A { // 等价于 class A { public:
A(int i) { cout << "A(int i)" << endl; }
A(double d , int i) {}
// ...
};
struct B : A { // C++11
using A::A; // 继承基类ctor,除了A(int i)
int d{ 0 }; // 就地初始化
B(int i) : A{ i } , d{ i } {
std::cout << "B(int i)" << std::endl;
};
int main() {
B b(1); // 调用 B(int i)
std::cin.get();
}
下面是对于上述实验的示例代码:
任务一源文件
#include<iostream>
using std::cout;
using std::endl;
//任务一:继承构造函数
//创建基类B及构造函数B(int),B(char)和派生类D;观察D不继承/E继承B的ctor时的效果
class B {
public:
B() { cout << "B()" << endl; }
B(int i) { cout << "B(int " << i << ")" << endl; }//输出是为了便于观察输出效果
B(char c) { cout << "B(char " << c << ")" << endl; }
};
class D : public B { };
class E : public B {
public:
using B::B;
//E(){ };
//E(int i) : B(int i){ };
//E(char c) : B(char r) { };
};
int main() {
B b;//基类ctor
D d;//D不继承基类的ctor
D d2{ 2 };
E e;//E继承基类的ctor
E e2 { 3 };
std::cin.get();
return 0;
}
运行结果:
B()
B()
B(int 2)
B()
B(int 3)
可以看出,继承和不继承基类构造函数的结果相同,都会调用基类的ctor。区别仅在于继承基类构造函数的时候进行代理/委托构造(如E中的注释所示),而不继承就会直接调用基类ctor。
任务二源文件
#include<iostream>
using std::cout;
using std::endl;
//任务二:派生类先调基类ctor,再构造内嵌对象
//增加E(double)构造函数;创建E对象观察E ctor和B ctor的次序
class B {
public:
B() { cout << "B()" << endl; }
B(int i) { cout << "B(int " << i << ")" << endl; }//输出是为了便于观察输出效果
B(char c) { cout << "B(char " << c << ")" << endl; }
};
class E : public B {
public:
using B::B;
E(double x_) : x{ 1.5 } , B(static_cast<int>(x_)) { //注意别忘了初始化基类构造函数
cout << "E(double " << x << ")" << endl;
}
private:
double x { 0.5 };
};
int main() {
B b;//基类ctor
E e;//E继承基类的ctor
E e2 { 3.5 };
std::cin.get();
return 0;
}
运行结果:
B()
B()
B(int 3)
E(double 1.5)
可以看出,主函数第24行输出了最后两行,证明是先调用基类的代理构造函数,再执行派生类的有参构造函数,且与代理构造的书写顺序无关。
任务三源文件
#include<iostream>
using std::cout;
using std::endl;
//任务三:派生类中调用基类构造函数
//增加类D及D()、D(double),并在E中加入D的两个对象;创建E对象观察D ctor和B ctor的次序
class B {
public:
B() { cout << "B()" << endl; }
B(int i) { cout << "B(int " << i << ")" << endl; }//输出是为了便于观察输出效果
B(char c) { cout << "B(char " << c << ")" << endl; }
};
class D {
public:
D() { cout << "D()" << endl; }
D(double x) { cout << "D(double " << x << ")" << endl; }
};
//注意D类一定要放在上面,E类才能调用
class E : public B {
public:
using B::B;
E(double x_) : d1{ x_ }, d2{ 1.5 }, B(static_cast<int>(x_)) {
cout << "E(double " << x_ << ")" << endl;
}
private:
D d1, d2;
};
int main() {
B b;//基类ctor
E e;//E继承基类B的ctor,并将D作为自己的私有变量
E e2 { 3.5 };
std::cin.get();
return 0;
}
运行结果:
//main()第2行
B()
//main()第3行
B()
D()
D()
//main()第4行
B(int 3)
D(double 3.5)
D(double 1.5)
E(double 3.5)
可以看出,调用派生类的默认构造函数时,优先级从高到低是:基类的默认构造函数、派生类的内嵌对象构造函数,且与书写顺序无关。调用派生类的有参构造函数时,顺序同上。
6.1.3 继承中的默认构造函数
若派生类的构造函数未显式调用基类的构造函数,那么就会默认调用基类的默认构造函数(如下图所示)。如果基类没有默认构造函数就会报错,所以一定要考虑给基类提供默认构造函数。即,派生类构造函数必须调用基类构造函数。
下面给出验证这一现象的代码示例:
#include<iostream>
using std::cout;
using std::endl;
//基类默认构造函数的作用
class A {
public:
A() { cout << "A()" << endl; }
A(int i) { cout << "A(" << i << ")" << endl; }
};
class B :public A {
public:
B() { cout << "B()" << endl; }//没有显式调用基类构造函数
B(int j) :A(j) { cout << "B(" << j << ")" << endl; }//显式调用基类构造函数
};
int main() {
A a1{ };
A a2{ 1 };
B b1{ };//预期会默认调用基类默认构造函数
B b2{ 2 };
std::cin.get();
return 0;
}
运行结果:
//main()第2行
A()
//main()第3行
A(1)
//main()第4行
A()
B()
//main()第5行
A(2)
B(2)
6.1.4 构造链和析构链
构造函数链(constructor chaining)指的是构造类实例会 沿着继承链调用所有的基类ctor,调用次序是父先子后。 析构函数链(destructor chaining)则与构造函数链正好相反,调用次序是子先父后。构造链和析构链的示意图如下:
根据示意图给出代码示例:
源文件main.cpp
#include<iostream>
using std::cout;
using std::endl;
//任务1:创建类结构: Computer -> PC -> Desktop/Laptop以及相应的ctor/dtor
//main中创建Desktop的对象,观察ctor/dtor调用次序
//任务2:增加类Camera作为Laptop的内嵌对象c的类型
//main中创建Laptop对象,观察内嵌对象c的构造与基类构造次序
class Computer { //爷爷类:Computer
public:
Computer() { cout << "Computer()" << endl; }
~Computer() { cout << "~Computer()" << endl; }
};
class PC : Computer { //父类:PC
public:
PC() { cout << "PC()" << endl; }
~PC() { cout << "~PC()" << endl; }
};
class Desktop : PC { //子类1:Desktop
public:
Desktop() { cout << "Desktop()" << endl; }
~Desktop() { cout << "~Desktop()" << endl; }
};
class Camera { //子类2的私有对象:Camera
public:
Camera() { cout << "Camera()" << endl; }
~Camera() { cout << "~Camera()" << endl; }
};
class Laptop : PC { //子类2:Laptop
public:
Laptop() { cout << "Laptop()" << endl; }
~Laptop() { cout << "~Laptop()" << endl; }
private:
Camera c;
};
int main() {
Desktop desk{ };
Laptop lap{ };
std::cin.get();
return 0;
}
运行结果:
//main()第2行
Computer() //爷爷辈
PC() //父辈
Desktop() //自己
//main()第3行
Computer() //爷爷辈
PC() //父辈
Camera() //内嵌对象
Laptop() //自己
//下面是编译器自动进行析构,程序执行完后显示在控制台
//也可以手动加大括号
~Laptop() //自己
~Camera() //内嵌对象
~PC() //父辈
~Computer() //爷爷辈
~Desktop() //自己
~PC() //父辈
~Computer() //爷爷辈
6.2 名字隐藏与重定义
6.2.1 继承中的名字隐藏
本节探讨在继承中,基类同名函数被隐藏的现象。对于下面的代码:
class P { //基类
public:
void f() {}
};
class C :public P { //子类
public:
void f(int x) {}
};
int main() {
C c;
c.f();
}
//Visual C++编译结果:namehiding.cpp(13): error C2660: “C::f”: 函数不接受 0 个参数
//g++编译结果:NameHiding.cpp:13:7: error: no matching function for call to 'C::f()'
也就是说,当基类和派生类含有同名函数时,若此时调用派生类的函数,就只能使用派生类的函数格式,而无法通过继承得到基类的函数格式。此时,派生类视作内部作用域;基类视作外部作用域。即:内部作用域的名字隐藏外部作用域的(同名的)名字。这就是隐藏基类同名函数。
这样做的好处是可以避免某些潜在的危险行为,使得每个类在创建时,它的函数名都是写在一张干净的白纸上面,不会被基类函数名干扰。具体可以参考:“C++继承和名字隐藏”、“为什么派生类的同名函数会隐藏基类的重载函数?”。
如果想取消隐藏基类同名成员,那么使用 using
声明语句就可以将基类成员引入到派生类定义中,如下面代码所示:
#include<iostream>
using std::cout;
using std::endl;
class P { //基类
public:
void f() { cout << "P::f()" << endl; }
};
class C :public P { //子类
public:
using P::f; //此处不带小括号
void f(int x) { cout << "C::f()" << endl; }
};
int main() {
C c;
//通过派生类调用基类的同名函数
c.f(); //方式1:配合using直接调用
static_cast<P>(c).f(); //方式2:强制类型转换
c.P::f(); //方式3:显式的加上基类的名字
std::cin.get();
return 0;
}
输出结果:
P::f()
P::f()
P::f()
6.2.2 重定义函数
由于继承中的名字隐藏,所以完全可以在派生类里面定义一个与基类函数完全一致的函数,此时称为重定义函数(Redefining Functions)。比如前面提到过的继承链如下:
Shape类中的函数Shape::toString()
可以被Circle类继承,因此可以通过Circle对象调用该函数:Circle.toString()
。但是基类的 Shape::toString()
无法输出Circle类对象信息,此时就需要重定义派生类的Circle::toString()
以描述Cilrcle类对象。下面是头文件声明中所对应的源文件函数:
#include <string>
//基类的声明
std::string Shape::toString() {
using namespace std::string_literals;
return "Shape color "s + color + ((filled) ? " filled"s : " not filled"s);
}
//新增的派生类的声明
string Circle::toString() {
return "Circle color " + color + " filled " + ((filled) ? "true" : "false");
}
重定义(Redefine) 与 重载(Overload) 的概念相似,两者的区别为:重载函数的多个函数名字相同,但至少一个函数特征不同(参数类型、参数数量、参数顺序)。重定义函数的函数特征全部相同(包括名字、参数类型、参数数量、参数顺序、返回值类型),但在基类和派生类中分别定义。
注意即使基类的重定义函数可以通过using
被引入到派生类定义中,并且没有语法错误。但派生类重定义的同名函数依旧会覆盖基类成员。
6.3 覆写与运行时多态
6.3.1 多态的概念
多态(Polymorphism) 的概念很多。广义的多态指:不同类型的实体/对象对于同一消息(对同样一个函数的调用)有不同的响应,就是OOP(面向对象编程)中的多态性。截止目前,多态性有 重载多态 和 子类型多态 两种表现方式:
//重载多态:
class C {
public:
int f(int x);
int f( );
};
//子类型多态:不同的对象调用同名重定义函数,表现出不同的行为
class A { virtual int f() {return 1;} };
class B: public A { virtual int f() {return 8;} };
//下面演示子类型多态
A a;
B b;
A* p = &b;//A类型指针,却指向了B类型的对象地址
a.f() // 调用 A::f(),正常
b.f() // 调用 B::f(),正常
p->f(); // 调用 B::f(),多态(虽然a和p同类型)
想要实现多态,就要通过联编(Binding)。联编指确定具有多态性的语句调用哪个函数的过程。比如下图所示情况,虽然在类C中出现了函数f的重载,但是编译器根据输入的参数类型选择应该调用的函数,这就是联编。
联编分为 静态联编(Static Binding) 和 动态联编(Dynamic Binding) 两种。静态联编是在程序编译时(Compile-time)确定调用哪个函数(例如上面示意的函数重载)。动态联编则是在程序运行时(Run-time),才能够确定调用哪个函数。用动态联编实现的多态,也称为 运行时多态(Run-time Polymorphism),也是平常在C++中所说的多态。
6.3.2 实现运行时多态
首先来看一下为什么要使用运行时多态?假如现在有一个继承链(如下面的类图所示,A是基类、C是派生类),这三个对象都定义了toString()
函数来输出信息。若输入x
为继承链上的某个类,就需要根据输入的类来判断调用哪个toString()
函数。如果采用普通的方法,就需要针对这三个不同的类,写三个重载函数(如果就只写一个,那就默认调用写了的那个):
void print( A obj );
void print( B obj );
void print( C obj );
假如继承链变长,这样定义的方法就显得很麻烦,而 运行时多态(run-time polymorphism) 就很轻易的解决了这个问题(利用了继承链的关系),减少了程序员的工作量。
实现运行时多态有两个要素:虚函数(virtual function)、覆写(Override)。虚函数使用关键字virtual
声明,而覆写就是指在派生类中重定义一个虚函数。如果在基类中定义了虚函数,那么整个继承链的同名函数也都是虚函数(不需要在派生类里面再声明virtual
),告知编译器在执行此函数时执行“运行时多态”。另外,如果该函数为纯虚函数(见6.4.2节),所有类都必须定义该同名函数,否则报错。如下图所示:
#include<iostream>
#include<string>
//基类
class A {
public:
virtual std::string toString() { return "A"; } //只需定义一次虚函数
};
//派生类
class B : public A {
public:
std::string toString() override { return "B"; }
//override保证子本派生类和基类是有所定义的同名函数的,可以防止bug
};
//派生类的派生类
class C : public B {
public:
std::string toString() override { return "C"; }
};
//对象指针
void print(A* p) {
std::cout << p->toString() << std::endl;
}
//对象引用
void print(A& p) {
std::cout << p.toString() << std::endl;
}
int main() {
A a1{ };
B b1{ };
C c1{ };
print( &a1 ); // 调用 A::toString()
print( &b1 ); // 调用 B::toString()
print( c1 ); // 调用 C::toString()
return 0;
}
注意到上图中print(A*)
函数的输入已经由继承链上的各个类变成了基类对象的指针/引用,只需要声明一次就行。所以print(A*)
调用哪个同名虚函数,不由指针类型决定,而是由指针所指的【实际对象】的类型决定。运行时,编译器会检查指针所指对象类型。假如在当前派生类中没有找到所调用的函数,就会沿着继承链向上查找最近的同名函数。为了避免这个bug,可以再派生类的同名函数后面写上override
关键字,表明这是对基类同名函数的覆写,在本类中找不到基类的同名函数就会报错。以上,就实现了用父类指针访问子类对象成员。也就是说,定义一个父类类型的指针,指向子类,在调用函数时也可以调用子类的函数。
上面所提到的基类定义了虚同名函数,那么派生类中的同名函数不需要再定义,而是自动变为虚函数的现象,称为“虚函数的传递性”。最后有关 虚函数的特性 ,虚函数保存着一个虚函数表(这与普通函数不同),且调用虚函数时需要执行运行时联编/动态联编(需要额外的开销),所以比非虚函数开销大(一般说C++比C语言性能差,也就体现在了这里)。
6.3.3 运行时多态的总结
本节主要区分静态联编和动态联编,若基类与派生类中有同名函数:
(1) 通过派生类对象访问同名函数,是静态联编。
(2) 通过基类对象的指针访问同名函数,是静态联编。
(3) 通过基类对象的指针访问同名虚函数,是动态联编。
(4) 通过基类对象的引用访问同名虚函数,是动态联编。
/************************
****静态联编的简单示例
*************************/
class P { public: f(){…} }; //父类
class C: public P { public: f(){…} }; //子类
main () {
P p; C c; P* ptr;
//1.通过派生类对象访问同名函数,对象是什么类型,就调什么类型
p.f(); //调用P::f()
c.f(); //调用C::f()
//2.通过基类指针访问同名函数,指针是什么类型,就调什么类型
ptr = &p;
ptr->f(); // 调用P::f()
ptr = &c;
ptr->f(); // 调用P::f(),因为指针类型是基类的
}
/************************
****动态联编的简单示例
*************************/
class P { public: virtual f(){…} }; //父类
class C: public P { public: f(){…} }; //子类,f自动virtual
main () {
P p; C c; P* ptr;
//1.函数虚,不看指针看真对象
ptr = &p;
ptr->f(); //调用P::f()
ptr = &c;
ptr->f(); //调用C::f()
//2.函数虚,不看引用看真对象
P p; C c;
P& ref1 = p;
pr1.f(); //调用P::f()
P& ref2 = c;
pr2.f(); //调用C::f()
}
6.3.4 [C++11]使用override和final
使用 override
显式声明覆写 和 final
显式声明禁止覆写 可以纠错。C++11引入override标识符,指定一个虚函数覆写另一个虚函数(写上override
的函数自动会变成虚函数)。override的价值在于,避免程序员在覆写时错命名或无虚函数导致隐藏bug(可以进一查看override 说明符)。C++11引入final特殊标识符,指定派生类不能覆写虚函数。
/********override的一些代码示例*******/
class A { //父类
public:
virtual void foo() {}
void bar() {}
};
class B : public A { //子类
public:
void foo() const override { } // 错误: B::foo 不覆写 A::foo(签名不匹配)
void foo() override; // 正确: B::foo 覆写 A::foo
void bar() override { } // 错误: A::bar 非虚
};
void B::foo() override { } // 错误: override只能放到类内使用
/*********final的一些代码示例********/
struct Base { //父类
virtual void foo();
};
struct A : Base { //子类
void foo() final; // 正确:A::foo 被覆写且是最终覆写
void bar() final; // 错误:非虚函数不能被覆写或是 final
};
struct B final : A { // 正确:表明 struct B 为 final,不能被继承
void foo() override; // 错误:foo 不能被覆写,因为它在 A 中是 final
};
注意:struct可与class互换,差别在于struct的默认访问属性是public。
6.4 抽象类与动态类型转换
6.4.1 访问控制(可见性控制)
本节介绍 继承中的访问控制属性。关键字protected
、private
、public
都属于访问控制类关键字,都用来说明类里面的数据及函数能否从类外面访问。使用 public
声明的公有成员 可被任何其他类访问;使用 private
声明的私有成员 只能在类内的函数访问;基类里面的私有数据成员不能被派生类访问,而 使用protected
声明的保护属性 的数据或函数可被派生类成员访问。下面是三个关键字所声明的 访问属性 与 派生方式 :
#include <iostream>
using std::cout;
using std::endl;
//三种关键字出现在类里面表示访问属性
class A {
public: // 访问属性
int i;
protected: // 访问属性
int j;
private: // 访问属性
int k;
};
//三种关键字出现在类名字的后面表示派生方式
class B: public A {// 派生方式
public: // 访问属性
void display() {
cout << i << endl; // 正确: can access i
cout << j << endl; // 正确: can access j
cout << k << endl; // 错误: cannot access k
}
};
int main() {
A a;
cout << a.i << endl; // 正确: can access a.i
cout << a.j << endl; // 错误: cannot access a.j
cout << a.k << endl; // 错误: cannot access a.k
}
下面依次介绍公有继承、私有继承、保护继承三种派生方式。这三种派生方式的主要作用,是导致派生类从基类继承的成员属性发生改变。代码示例如下:
class Derived:public Base{}; //公有继承的派生类定义形式
class Derived:private Base{}; //私有继承的派生类定义形式
class Derived:protected Base{};//保护继承的派生类定义形式
公有继承:
(1) 基类成员:在派生类中的访问属性不变。
(2) 派生类的成员函数:可以访问基类的公有成员和保护成员,不能访问基类的私有成员;
(3) 派生类以外的其它函数:可以通过派生类的对象,访问从基类继承的公有成员,但不能访问从基类继承的保护成员和私有成员。
私有继承:
(1) 基类成员:在派生类中都变成 private。
(2) 派生类的成员函数:可以访问基类的公有成员和保护成员,不能访问基类的私有成员。
(3) 派生类以外的其它函数:不能通过派生类的对象,访问从基类继承的任何成员。
保护继承:
(1) 基类成员:公有成员和保护成员变成protected,私有成员不变。
(2) 派生类的成员函数:可以访问基类的公有成员和保护成员,不能访问基类的私有成员。
(3) 派生类以外的其它函数:不能通过派生类的对象,访问从基类继承的任何成员。
通过上述可以看出 公有继承 就只是声明了当前类是继承基类的,而 私有继承 与 保护继承 的唯一区别就是能否使孙类访问子类的成员。
下面是对继承中的访问控制属性的代码示例:
#include<iostream>
//基类
class A {
public:
int i{ 0 };
protected:
int j{ 0 };
private:
int k{ 0 };
};
/****************派生类访问*****************/
//派生类:公有继承
class Pub :public A {
public:
void foo() { i++, j++, k++; }//只有k++报错
};
//派生类:保护继承
class Pro :protected A {
public:
void foo() { i++, j++, k++; }//只有k++报错
};
//派生类:私有继承
class Pri :private A {
public:
void foo() { i++, j++, k++; }//只有k++报错
};
/*****************类外访问******************/
int main() {
Pub pub;//public protected private
Pro pro;//protected protected private
Pri pri;//private private private
//只有pub.i++;可以通过,其他都报错
pub.i++; pub.j++; pub.k++;
pro.i++; pro.j++; pro.k++;
pri.i++; pro.j++; pri.k++;
std::cin.get();
return 0;
}
6.4.2 抽象类与纯虚函数
在继承链中可以发现,越是沿着继承链向下从基类向派生类移动,新类就会越来越 明确和具体;而越是沿着继承链向上从派生类向父类移动,类会越来越 一般化和抽象。若沿着继承链向上移动的过程中,出现一个类太抽象以至于 无法实例化 就叫做抽象类(Abstract Classes)。
好的,上面这个概念确实本身就很抽象,那么现在来看看 抽象函数 的使用场景。假如还是拿上面这个继承链来举例子,现在只有一个Sahpe
类,要求后来的程序员在编写circle
类和Rectangle
类等派生类时,每一个派生类都必须编写getArea()
函数。由于每一个派生类的getArea()
函数的计算公式都不一样,显然不可能在基类编写,所以只能在派生类中各自定义,那怎么能强制要求后来的程序员都知道要在派生类写这个函数呢(不写就报错给你看)?这就需要用到 抽象函数/纯虚函数 (Abstract Functions / Pure Virtual Function)。调用代码格式如下:
virtual double getArea() = 0; // 在基类中声明
//所有的派生类必须实现getArea()纯虚函数才能实例化
于是综上所述,包含 抽象函数 的类被称为 抽象类 。抽象类不能实例化(创建对象);对应的,派生类必须实现基类的纯虚函数,才能创建派生类的对象。
下面展示抽象类的代码示例:
头文件:基类shape.h
#pragma once
#include<iostream>
#include<string>
#include<array>
using std::string;
using namespace std::string_literals;//使用""s
//枚举颜色
enum class Color {
white, black, red, green, blue, yellow,
};
//定义shape类
class Shape {
private:
Color color{ Color::black };//带有域前缀的枚举常量
bool filled{ false };
public:
Shape() = default;
Shape(Color color_, bool filled_) {
color = color_;
filled = filled_;
}
Color getcolor() { return color; }
void setcolor(Color color_) { color = color_; }
bool isfilled() { return filled; }
void setfilled(bool filled_) { filled = filled_; }
virtual double getArea() = 0; //虚函数,继承链上所有函数都必须定义
string tostring() {
std::array<string, 6> c{ "white "s,"black "s,"red "s,"green "s,"blue "s,"yellow "s, };
return ("shape: " + c[static_cast<int>(color)] + " " + (filled ? "filled "s : "not filled "s));
}
};
头文件:一级派生类rectangle.h
#pragma once
#include"shape.h"
//创建rectangle类,从Shape继承
class rectangle :public Shape {
private:
double width{ 1.0 };
double height{ 1.0 };
public:
rectangle() = default;
rectangle(double w, double h, Color c, bool f) :
width{ w }, height{ h }, Shape{ c,f } { };
//get函数的const表示类的私有数据成员的属性不会被修改,确保万无一失
double getwidth() const { return this->width; };
void setwidth(double w) { this->width = w; };
double getheight() const { return this->height; };
void setheight(double h) { this->height = h; };
//double getArea() const override { return this->height * this->width; };
//上一行报错!override和const不能同时用
double getArea() override { return this->height * this->width; };
};
头文件:一级派生类circle.h
#pragma once
#include"shape.h"
//补全circle类,从Shape继承
class circle :public Shape {
double radius { 1.0 };
public:
circle() = default;
circle(double radius_, Color color_, bool filled_) : Shape{ color_,filled_ } {
this->radius = radius_;
};
double getradius() const { return radius; };
void setradius(double radius) { radius = radius; };
double getArea() override { return(3.14 * radius * radius); };
};
源文件main.cpp
#include<iostream>
#include"circle.h"
#include"rectangle.h"
#include"shape.h"
int main() {
//Shape s{ Color::black,true };//错误:无法实例化抽象类
//因为Shape包含纯虚函数,所以是抽象类函数
rectangle r{ 2.5, 2.5, Color::yellow, true };
std::cout << r.getArea() << std::endl;
circle c{ 1.0, Color::green,false };
std::cout << c.getArea() << std::endl;
//用基类的指针来访问派生类的同名对象
Shape* c_p = &c;
std::cout << c_p->getArea() << std::endl;
//用基类的引用来访问派生类的同名对象
Shape& r_r = r;
std::cout << r_r.getArea() << std::endl;
std::cin.get();
return 0;
}
运行结果
6.25
3.14
3.14
6.25
6.4.3 动态类型转换
首先来看看动态类型转换的应用场景。假如还是有之前提到的类图(如上图),此时额外写了一个printObject
函数,期望这个函数拿到Circle
类/Rectangle
类的数据成员后,做一些没有定义过的输出方式,如下面的代码注释所示:
//输出 Shape对象 信息的函数
void printObject(Shape& shape) {// shape是派生类对象的引用
//1.首先输出面积
cout << "The area is " << shape.getArea() << endl;
//2.如果shape是Circle对象,就输出半径
//2.如果shape是Rectangle对象,就输出宽高
}
当然,我们可以通过 基类指针/引用 访问对应的派生类的所有public
和protected
属性的函数,那我们该如何确定这个作为输入参数的对象引用shape
到底是Circle
派生类还是Rectangle
派生类呢?换句话说,我们该怎么避免对象引用shape
其实给定的是Rectangle
派生类,但是却调用了shape.getradius()
函数?这时候就需要 动态类型转换(Dynamic Casting)。
dynamic_cast
运算符 有以下几个特性:
(1) 沿继承层级向上、向下及侧向转换到类的指针和引用。
(2) 转指针:失败返回nullptr。
(3) 转引用:失败抛异常。
使用动态类型转换,就可以得到以下的改进代码:
//输出 Shape对象 信息的函数
void printObject(Shape &shape){
//1.首先输出面积。
cout << "The area is " << shape.getArea() << endl;
//2.如果shape是Circle对象,就输出半径。Rectangle对象未演示。
/**********动态类型转换法一:转引用**********/
Circle& c = dynamic_cast<Circle&>(shape);
// 引用转换失败则抛出一个异常 std::bad_cast
cout << "The radius is " << c.getRadius() << endl;
/**********动态类型转换法二:转指针**********/
Shape *p = &shape;
Circle *c = dynamic_cast<Circle*>(p);//这一步可以帮助判断是否为Circle类型
if (c != nullptr) {// 转换失败则指针为空
cout << "The radius is " << c->getRadius() << endl;
}
}
6.4.4 向上转换和向下转换
之前反复说过通过基类对象的对象指针/对象引用可以访问派生类的函数。那么基类和派生类可以直接相互转换吗?本节就讨论这个直接转换的问题(也就是上一节的 动态类型转换 过程)。继承链上的转换分为向上转换和向下转换。 向上转换(Upcasting) 是将派生类类型指针赋值给基类类型指针。 向下转换(Downcasting) 是将基类类型指针赋值给派生类类型指针。根据本节开头的继承链示意图,给出了向上/向下转换的示例:
Upcast | Downcast |
---|---|
computer = pc | pc = computer |
pc = desktop | desktop = pc |
computer = desktop | desktop = computer |
(指针)转换规则:
(1) 上转可不使用dynamic_cast而隐式转换。
(2) 下转必须显式执行。
总结:父上,子下;上转隐,下转显。
/**********隐式上转**********/
Shape* s = nullptr;
Circle *c = new Circle(2);
s = c; //将派生类赋值给基类,隐式转换
/**********显式下转**********/
Shape* s = new Circle(1);
Circle* c = nullptr;
c = dynamic_cast <Circle*> (s); //将基类赋值给派生类,必须显式转换
上述向上/向下转换 是针对指针的操作,现在来看对于对象的操作(基类对象和派生类对象的互操作)。上图是Shape类和Circle类的内存布局,可以看出Circle类作为派生类,要比Shape类基类多一个数据成员。那么再进行对象操作时,就有如下规则及代码示例:
对象操作规则:
(1) 派生类赋值给基类,可将派生类对象截断,只使用赋值来的信息。
(2) 基类赋值给派生类,不能将基类对象加长,无中生有变出派生类对象。
S = C; //正确
C = S; //错误
Shape& rS = C; //正确
Circle& rC = S; //错误
综上所述,向上/向下转换 是针对指针的操作,让指针指向不同的对象,不会对原来的对象产生改变,通常是双向的。对象操作 (不同类对象之间赋值)会对原来的对象发生改变(如截断等),通常是单向的。
6.4.5 运行时查询类型的信息
上面在介绍动态类型转换的时候,(若采用指针的方式)转换成功了就返回有效指针,转换失败就返回空指针。这听着挺好,但实际上我要是提前知道了对象的类型,肯定能转换成功;而假如我不知道对象的类型,这个动态类型转换就有点像赌博(我赌你应该能转成功),所以显得有点鸡肋。那怎么才能在转换之前就知道当前对象是什么类型呢(运行时查询类型的信息)?这时候就需要 typeid
运算符。
typeid
运算符用于获取对象所属的类的信息,返回一个type_info对象的 引用 。typeid(AType).name()
返回实现定义的、含有类型名称的C风格字符串(char *指针),但并没有标准规定,而是由各个编译器自己定义。以下代码就实现了在运行的时候检测对象的类型信息:
#include<iostream>
#include <typeinfo> //使用typeid,需要包含此头文件
class A { };
int main() {
A a1{ };//定义A类型的对象a
auto& t1 = typeid(a1);
if (typeid(A) == t1) {
std::cout << "a1 has type: " << t1.name() << std::endl;//输出与编译器有关
}
return 0;
}
//VS可能输出 :a1 has type: class A
//g++可能输出:a1 has type: 1A
//g++的输出是经过混淆的,但g++也提供了一些函数可以还原这个经过混淆的字符串
注:RTTI (Run Time Type Identification)即运行时类型识别,是C++语言的一种机制的概念名称,来判断由于多态引起的指针引用类型与所指的类型不一致的情况。关键字typeid
是实现该机制的一种手段,可以查询所指类型的信息。