前言
面向对象有三个核心概念,抽象、继承、多态。
数据抽象就是多类事物的共同特征抽象出来,抽象出一个类,这个类定义了接口。
继承是描述类与类之间的关系,是一种包含关系。比如苹果和梨子都是水果,水果是基类,它包含苹果和梨子的共同特征(数据特征、行为特征),而苹果和梨是派生类,有它们自己独有的特征。
多态,c++主要指的是动态绑定,根据里氏替换原则,使用父类的地方都可以用子类去替换。在运行时,根据实际类型(c++称为动态类型)来调用实际类型的方法,已达到复用的目的。
我们在之前 类 的那一章已经分析过数据抽象的基本知识了。本文主要分析继承和动态绑定(更加严格来说)。
继承和动态绑定对程序有两方面的影响:
- 更容易的定义与其他类相似但不完全相同的新类 (继承)
- 在一定程度上忽略这些彼此相似的类编写程序(动态绑定)
// 继承
class Quote {
public:
std::string isbn() const;
// 希望派生类定义自己的版本
virtual double net_price(int n) const;
};
class Bulk_quote : public Quote {
public:
// 1. virtual 可加可不加;2.override显示的告诉编译器这个方法重写了基类的虚函数
virtual double net_price(int n) const override;
};
// 动态绑定
double print_total(ostream &os, const Quote &item, int n) {
// 使用基类引用编程,运行时根据引用的动态类型选择实际类型的虚函数
double ret = item.net_price(n);
cout << ret << endl;
}
Quote basic;
Bulk_quote bulk;
print_total(cout, basic, 20); // 调用 Quote::net_price
print_total(cout, bulk, 20); // 调用 Bulk_quote::net_price
注意:
c++ 中,使用基类的引用或者指针调用虚函数来发生动态绑定
1. 定义基类和派生类
- 基类
基类集中了派生类共有的特征,通过将函数设置成虚拟函数来说明希望派生类定义自己的版本,而如果派生类没有定义自己的版本,则会继承基类的版本,并且隐式保持着虚拟的状态。
class Quote {
public:
Quote() = default;
virtual ~Quote() = default; // 对析构函数进行动态绑定
};
基类通常需要定义一个虚析构函数,即使该函数不执行任何实际操作。因为,我们知道动态绑定需要基类定义虚函数,如果基类的析构函数不是虚拟的,用基类的引用或者指针来编程,并不会发生动态绑定。
任何构造函数之外的,非静态的函数都可以是虚函数。另外,virtual关键字只能出现在类的内部。
派生类继承基类的成员,但是派生类的成员函数不一定有权访问继承而来的成员。需要看基类定义的成员访问控制符。
- 派生类
派生类通过类派生列表指明从哪个(哪些)基类继承而来。
class Bulk_quote : public Quote {
public:
Bulk_quote() = default;
double net_price() const override;
private:
int min_qty = 0; // 可以定义派生类自己的数据成员
double discount = 0.0;
};
类派生列表包含了派生访问说明符,这个符号说明的是如何继承基类的成员,即基类的成员对于派生类的用户的可见性。和基类的成员访问符的作用没有关系。
派生类可以不覆盖继承的虚函数。另外,派生类中覆盖的函数不必使用virtual关键字。但是一定得记住的是,这个函数一定还是虚的。可以通过override显示告诉编译器,我覆盖了虚方法,请检查。
- 派生类向基类的类型转换
派生类对象组成部分:
- 派生类自己定义的非静态成员子对象。
- 继承基类对应的子对象,如果是多继承,则有多个
正因为派生类对象中含有与其基类对应的组成部分,所以
- 可以把派生类对象当成基类对象来使用
- 能将基类的指针或引用绑定到派生类的对象上来
Quote item; // 基类
Bulk_quote bulk; // 派生类
Quote *p = &item; // p 指向 Quote对象
p = &bulk; // p 指向 bulk 的 Quote 部分
Quote &r = bulk; // r 绑定到 bulk 的 Quote 部分
- 关于构造函数
记住一点:每个类控制它自己的成员初始化过程
因此,我们很容易明白,派生类对象含有从基类继承而来的成员,但是派生类不能直接初始化它们。派生类必须使用基类的构造函数来初始化它们。
Bulk_quote(const string &book, double p, int qty, double disc)
: Quote(book, p), min_qty(qty), discount(disc) {}
// 使用基类构造函数初始化基类部分
另外,初始化的顺序是:
- 首先初始化基类的部分
- 按照声明的顺序依次初始化派生类的数据成员
如果没有指出基类的构造函数,执行基类的默认初始化。
- 关于静态成员
如果基类定义了静态成员,在整个继承体系中只存在该成员的唯一定义。
- 防止继承的发生 (final)
final 关键字有两个作用
- 定义类为final,不允许其他类继承它
- 定义类中的方法为final,不允许其它函数覆盖它
2. 类型转换与继承
理解类型转换非常重要。关键在于理解静态类型和动态类型的关系。
注意:
和内置指针一样,智能指针也支持派生类向基类的转换
变量有静态类型和动态类型。
- 静态类型指变量声明的类型,编译期可以确定的
- 动态类型指变量表示的对象的类型,是内存中对象的类型,运行时才可知
那么,容易知道,如果变量或者表达式不是引用也不是指针,那么它的动态类型与静态类型永远是一致的。只有指针或者引用才有可能静态类型与动态类型不一致。
不存在从基类向派生类的隐式类型转换(判断的关键:编译期来检查静态类型转换是否合法)
Quote base;
Bulk_quote *bulkp = &base; // 错误:不能将基类转换成派生类
Bulk_quote &bulkRef = base; // 错误:不能讲基类转换成派生类
Bulk_quote bulk;
Quote *itemp = &bulk; // 正确
Bulk_quote *bulkp = itemp; // 错误:不能将基类转换成派生类,即使动态类型是派生类
当然,如果转换是安全的,可以使用static_cast或者dynamic_cast强制转换
对象之间不存在类型转换,如果派生类转基类,会被切掉一部分,这些是由拷贝构造函数和赋值操作符来决定的。
Bulk_quote bulk;
Quote item(bulk); // 使用Quote::Quote(const Quote &) 拷贝构造函数
item = bulk; // 使用Quote::operator=(const Quote &) 赋值运算符
3. 虚函数
使用引用或者指针来发生动态绑定
非虚函数的调用发生在编译期,根据静态类型来调用
一旦函数声明成虚函数,它在所有派生类中都是虚函数
注意
1 派生类的函数如果覆盖继承来的虚函数,它的形参必须与被覆盖的基类虚函数完全一致
2 返回类型也必须与基类函数一致,有一个例外,但返回类型是类本身的指针或引用时,这种例外必须要从 D 到 B 的类型转换是可访问的。
关于默认实参
如果虚函数使用默认实参,基类和派生类的定义的默认实参最好一致。
回避虚函数机制
即使用特定版本的虚函数,使用作用域操作符 ::
double undiscounted = baseP -> Quote::net_price(42); // 强制使用基类的版本而不管动态类型
通常的使用场景是,基类的虚函数执行了一些共同的任务,派生类的版本需要直接调用,并执行一些自己的操作。
如果没有使用作用域操作符,派生类虚函数调用基类版本,会在运行时调用自己,从而导致无限递归。
4. 抽象基类
某些情况下,一些类并不需要被创建,它的作用只是定义一个接口,或者提供公共部分的特征。我们称这样的类为抽象类。c++中并没有像java那样可以声明类为抽象类。c++通过将虚函数声明成纯虚函数,来将一个类作为抽象类。
抽象类可以作为引用和指针使用。
class Disc_quote : public Quote { // Disc_quote 为抽象类,不能创建对象
public:
Disc_quote() = default;
double net_price(int) const = 0; // 声明为纯虚函数
protected:
int quantity = 0;
double discount = 0;
};
// 纯虚函数的定义必须在类外
double Disc_quote::net_price(int t) {
return discount;
}
注意 :
派生类必须要覆盖抽象基类的纯虚函数,否则它们仍然是抽象的。
5. 访问控制与继承
关于这个问题,分两部分来看。
一方面,基类使用访问控制符来表达基类的成员的是否可以访问,三个级别
- public: 公有的,所有人可见
- protected: 受保护的,仅派生类和友元可见(用户不可见!!!)
- private: 私有的,仅友元可见
注意
派生类的成员或友元,只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问权(这时派生类仅仅是一个用户)
class Base {
protected:
int prot_mem;
};
class Sneaky : public Base {
friend void clobber(Sneaky &);
friend void clobber(Base &);
int j;
};
// 因为是Sneaky的友元,可以访问Sneaky的private和protected
void clobber(Sneaky &s) {
s.j = s.prot_mem = 0; // 正确,clobber能够访问Sneaky对象的private和protected成员
}
// 虽然是友元
void clobber(Base &b) {
b.prot_mem = 0; // 错误,不能访问一个基类对象的protected成员
}
另一方面,继承有访问控制,分为公有继承、私有继承、受保护继承。这个继承说明的仅仅是基类的成员被派生类继承了之后,它的访问控制权限要如何改变。受到两个因素的影响,其一是在基类中的访问控制符,其二是继承的访问控制符。
- 公有继承,基类中访问控制符是什么样,照样是什么样
- 受保护继承,基类中是public,则变成protected,基类是protected,还是protected
- 私有继承,基类所有都变成private
一定要注意的是,这里仅仅指的基类的成员被继承之后,在派生类的控制访问符变成什么样。
派生访问说明符 对派生类的成员(或友元)能否访问到其直接基类的成员没有什么关系
注意,直接基类!!!
通过以上,我们知道,派生访问符目的是控制派生类用户(包括派生类的派生类)对于基类成员的访问权限。
关于派生类向基类转换的可访问性
记住一点,只有当D公有继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护或者私有继承,则用户代码不能使用该转换
而不论D以什么方式继承B,D的成员函数和友元函数都能够使用派生类向基类的转换。
关于友元和继承
友元关系不传递,不继承
如何改变个别成员的可访问性
使用using声明
class Base {
public:
int size() const {return n;}
protected:
int n;
};
class Derived : private Base { // 私有继承,默认应该是私有的,但。。。
public:
using Base::size; // 基类size方法的访问权限由前面的public决定
protected:
using Base::n; // 基类n数据成员由前面的protected决定
};
默认继承保护级别!!!!
- class 默认是私有继承
- struct 默认是公有继承
6. 继承中的类作用域
记住一点,派生类的作用域嵌套在其基类的作用域之内。
如果一个名字在派生类作用域无法解析,则在外层基类作用域中查找。
注意只会往当前派生类到继承链的顶端找,不会往继承链的末端找
名字查找优先于类型检查
因此,声明在内存作用域的函数并不会重载声明在外层作用域的函数,也就是说派生类的函数不会重载其基类的函数,只会隐藏基类的函数,即使派生类的成员和基类的成员形参列表不一致。(名字查找,只查找函数名)
但是,如前面所说,可以使用作用域操作符调用基类的成员
struct Base {
int func();
};
struct Derived : Base {
int func(int);
};
Derived d;
Base b;
b.func(); // 调用 Base::func()
d.func(10); // 调用 Derived::func()
d.func(); // 错误:基类的func被隐藏了
d.Base::func(); // 正确:通过作用域操作符调用 Base::func()
关于虚析构函数
定义虚析构函数的意义在于,如果delete指向派生类对象的基类指针,根据动态绑定,调用的是派生类的析构函数,这正是我们想要的。
如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针会产生未定义的行为。
我们之前还说过一个三\五法则,如果一个类需要析构函数,那么它同样需要拷贝和赋值。注意,基类的析构函数并不遵循,这是一个重要的例外。
虚拟析构函数将阻止合成移动操作。
析构顺序(和构造顺序相反):
- 派生类的析构函数首先执行
- 然后是基类的析构函数(直到继承链的顶端)