15.1 oop概述
面向对象程序设计的核心思想是数据抽象 继承 和 动态绑定
继承
通过继承联系在一起的类构成一种层次关系
在层次关系的根部有一个基类,其他类直接或者间接的从基类继承而来,称为派生类
基类负责定义这些类共同拥有的成员,而每个派生类定义各自特有的成员
在c++中 基类将类型相关的函数于派生类不做改变直接继承的函数区分对待
一些函数,基类不做定义,由派生类各自定义适合自生的版本,可以在基类中将这些函数声明成虚函数
派生类通过使用类派生列表指出自生从哪些基类继承而来
cpp
class Bulk_quote : public Quote
{
public:
double net_price(size_t) const override;
}
动态绑定
当使用基类的引用或者指针调用一个虚函数时将发生动态绑定
15.2 定义基类和派生类
15.2.1 定义基类
cpp
#ifndef APP_QUOTE_HPP
#define APP_QUOTE_HPP
#include <string>
class Quote {
public:
Quote() = default;
Quote(const std::string &book, double sales_price)
: bookNo(book), price(sales_price)
{
//
}
std::string isbn() const
{
return bookNo;
}
// 返回给定数量的销售总额
// 派生类负责改写并应用不同的折扣算法
virtual double net_price(std::size_t n) const
{
return n * price;
}
virtual ~Quote() = default; //基类都应该定义虚析构函数,即使不做任何事情
private:
std::string bookNo;
protected:
double price = 0.0;
};
#endif //APP_QUOTE_HPP
成员函数与继承
派生类可以继承基类的成员
当遇到派生类需要自定义自己的成员时可以重新定义覆盖
基类的成员函数可以分为两种
- 希望派生类直接继承而不要改变的函数
- 以及希望派生类进行覆盖的函数
对于希望派生类进行覆盖的函数,基类通常使用virtual关键字进行修饰表明其是虚函数
当使用指针或者引用调用虚函数时,该调用将被动态绑定,根据引用或者指针指向的对象从基类对象或者派生类对象中选择函数
关键字virtual只能出现在类内部的声明语句
没有被声明为虚函数的成员在编译期确定调用路径,不会动态绑定
访问控制与继承
派生类可以继承定义在基类中的成员,但是派生类成员函数不一定有权限访问从基类继承而来的成员
派生类无法访问基类的私有成员,可以将部分数据设计成protected(受保护的)成员,这样,派生类可以访问的同时避免使用了pubulic权限
15.2.2 定义派生类
派生类通过使用类派生列表指出该类从哪些基类继承
每个基类都可以指定继承权限
基类中用virtual声明的函数,派生类有两种选择:
- 不重写:此时调用该函数时,会直接执行基类的版本;
- 重写:派生类提供自己的实现,此时通过基类指针 / 引用调用时,会根据对象的实际类型(多态特性)执行派生类的版本。
补充细节:派生类重写虚函数时,不需要再次写virtual关键字(编译器会自动继承虚属性),但 C++11 后强烈推荐加override关键字 ------ 它能让编译器帮你检查函数签名是否写错(比如参数类型 / 个数不一致),避免隐性错误。
"覆盖(override)" 是专属于虚函数的概念 ------ 只有虚函数才能通过基类指针 / 引用,调用到派生类的实现(多态)。
而非虚函数的同名函数,本质是隐藏(hide) :派生类的同名函数会 "隐藏" 基类的版本,但不会触发多态,调用哪个版本完全由指针 / 引用的类型决定,而非对象的实际类型。
派生类对象及派生类向基类的类型转换
一个派生类对象包含多个组成部分,包含本身声明的非静态成员以及各个父类声明的成员
标准没有规定这些成员在内存中如何分布
因为父类有的成员子类都有,所以能把子类实例当成父类对象进行使用,将基类的指针类型或者引用类型绑定到子类对象上
编译器会隐性的执行派生类到基类的转换
派生类构造函数
派生类中属于基类的成员由基类的构造函数进行初始化
派生类初始化过程先执行基类成员的初始化,再执行基类构造函数的函数体,再进行本类独有成员的初始化,最后才是本类构造函数体的执行
在派生类中修改覆盖基类的成员值在语法上是允许的,但是不推荐这样做
派生类使用基类的成员
派生了可以访问基类的公有成员以及受保护成员
继承与静态成员
基类定义的静态成员也是全局唯一的
派生类的声明
派生类的声明不需要派生列表,派生列表只有在实现类定义的 时候才需要
被用作基类的类
当定义派生类时,所用基类必须已经定义
一个类既可以是某类的派生类也可以是另一个类的基类
防止继承的发生
某些类希望其是类继承体系下的最终类不再允许被继承,可以在类名后加以final关键字加以限定
15.2.3 类型转换与继承
可以将子类对象与基类指针或者引用所绑定
静态类型与动态类型
在使用存在继承关系的类型时,需要区分一个变量或者表达式的静态类型于动态类型区分开来
静态类型在编译期就已知,动态类型则需要再运行时根据父类类型名指针指向的实际内存确定具体的子类型
不能从基类向派生类隐式转换
运行时派生类指针可以像基类指针转换,原因时基类所有用的操作派生类可以使用,(一个派生类对象隐含的包括了一个基类对象外加其自定义的部分)
转换只是对引用或者指针起作用
派生类向基类的自动类型转换只对指针或者引用类型起作用
对于直接表示该对象的变量则不存在这样的规则
当使用一个派生类去调用一个基类的拷贝构造或者赋值构造之类的函数时,只会得到一个基类对象,该派生类对象中额外自定义的部分将被舍去
15.3 虚函数
对虚函数的调用可能在运行时才被解析
使用基类的引用或者指针调用一个虚函数时会进行动态绑定(通常只有在运行时才能确定具体类型)
普通成员函数如果没有调用,可以只声明不实现,而虚函数由于只有在运行时才能确定调用对象的类型,所以必须要定义,因为编译器不知道哪个派生类的该函数会被调用,无法通过语法检查
派生类中的虚函数
当在派生类中覆盖了某个虚函数,可以再次使用virtual关键字进行标记(非必须行为)
某个函数一旦被声明称虚函数,则其所有派生类中的该函数都是虚函数
一个派生类如果覆盖了某个继承而来的虚函数,则其形参,返回类型需要与被覆盖的基类函数完全一致
final 和 override 说明符
派生类中可以声明定义和基类同名但是形参不同的函数,这属于完全独立的一个成员函数
实践中想要覆盖基类中的虚函数,可以在派生类中函数使用override关键字表示用于覆盖基类版本,
同时编译器将做额外的检查,确保形参信息和基类中完全一致
当一个函数不希望被覆盖时,可以使用final关键字
虚函数与默认实参
和普通函数一样,虚函数也可以有默认实参
基类和派生类语法上可以有不一样的默认实参,但是最好是使用一致的版本
避免虚函数的机制
可以通过域作用域解析符选择指定版本的函数
15.4 抽象基类
纯虚函数
纯虚函数是 C++ 中一种没有具体实现的虚函数,它的核心作用是为派生类定义一个必须实现的 "接口规范",强制派生类重写该函数
一个纯虚函数无需定义,通过 virtual func() = 0; 声明一个纯虚函数
含有纯虚函数的类是抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类
抽象基类负责定义接口,其后续类负责实现接口,抽象基类不能创建具体对象
15.5 访问控制与继承
含有继承关系的类体系中,每个类负责控制自己的成员的初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问
受保护的成员
一个类使用protected关键字控制哪些成员可以分享给类体系而不公开
protected权限是介于public权限和private之间的权限
类似于private成员,公共代码不可访问protected成员
类似public成员,protected成员可在派生类和友元中访问
派生类的成员或者友元只能通过派生类本身的对象访问基类的受保护成员,也就是说不能通过派生类的友元去访问基类的受保护成员
公有私有和受保护继承
类对其继承而来的成员的访问权限受两个因素的影响,一是类定义中该成员本身的访问说明符,二是派生类在派生列表的访问说明符
派生列表访问说明符本质是一个降权访问限定
- 比如基类 public 成员 + private 继承 → 降级为 private;
- 基类 protected 成员 + public 继承 → 保持 protected(不升级);
- 基类 public 成员 + protected 继承 → 降级为 protected。
对于private来说 派生类始终不可见其父类
派生类向基类转换的可访问性
cpp
//public 继承
class Base {};
class Derived : class Base {};
class Derived : public Base {};
int main() {
Derived d;
Base* pb = &d; // 合法:外部可转换(指针)
Base& rb = d; // 合法:外部可转换(引用)
Base b = d; // 合法:对象切片(值拷贝)
return 0;
}
cpp
// protected 继承
/*
可访问范围:仅在派生类自身及其直接 / 间接子类的内部 / 友元 中可转换;
外部限制:普通函数、外部代码无法进行该转换;
*/
class Base {};
class Derived : protected Base {
public:
void func() {
Base* pb = this; // 合法:派生类内部可转换
}
};
// 子类
class Derived2 : public Derived {
public:
void func2() {
Base* pb = this; // 合法:子类内部可转换
}
};
int main() {
Derived d;
// Base* pb = &d; // 编译报错:protected继承,外部不可转换
return 0;
}
cpp
/*
private 继承
可访问范围:仅在当前派生类的内部 / 友元 中可转换;
限制:其子类、外部代码都无法进行该转换(基类部分对后代完全隐藏);
*/
class Base {};
class Derived : private Base {
public:
void func() {
Base* pb = this; // 合法:当前派生类内部可转换
}
};
// 子类
class Derived2 : public Derived {
public:
void func2() {
// Base* pb = this; // 编译报错:private继承,子类不可转换
}
};
int main() {
Derived d;
// Base* pb = &d; // 编译报错:private继承,外部不可转换
return 0;
}
友元与继承
友元不能传递,每个友元声明只在对声明处的类生效
临时改变个别成员的可访问性
- 作用范围 :仅能调整派生类原本有权访问 的基类成员(即基类的 public/protected 成员),基类 private 成员无法通过
using调整(因为派生类根本访问不到); - 调整方式 :在派生类的
public/protected/private访问块中写入using 基类::成员名;,该成员的最终访问权限就等于using语句所在访问块的权限; - 核心优势:精准调整 "个别成员",而非像派生列表那样批量修改所有基类成员的权限。
默认的继承保护级别
struct 默认权限是public
class 默认权限是private
包括成员本身的访问说明和集成权限
15.6 继承中的类作用域
每个类定义自己的作用域,在这个作用域内定义定义类的成员
当存在继承关系的时候,派生类的作用域嵌套在其基类的作用域之内,如果一个名字在当前作用域内无法正确解析,编译器会在外层作用域继续寻找该名字
在编译时才进行名字查找
一个对象,引用或指针的静态类型决定该对象的哪些成员是可见的,即使静态类型和动态类型不一致
常见情况下是当已经用了基类指针来指向派生类的时候,就无法解析派生类中独有的成员了
名字冲突和继承
和普通作用域一致,派生类可以重用定义在外部类作用域中的名字,一旦派生类和外部类作用域有名称冲突,会隐藏外部名字
名字查找会优先类型检查
内层作用域的重名变量类型可以不同于外部
虚函数和作用域
由于名字查找优于类型检查,所以虚函数要想正确覆盖基类版本必须形参相同,否则派生类该函数只是隐藏了外部基类函数的名字而已
15.7 构造函数与拷贝控制
15.7.1 虚析构函数
继承关系中的类,基类通常应该定义一个虚析构函数
delete一个动态分配的对象将执行对象的析构函数,如果该指针指向一个基类,而动态类型是某个派生类,正确的操作应该是调用派生类的析构而不是基类的析构
cpp
class Base{
// code ...
public:
virtual ~Base() = default;
}
虚析构将阻止合成移动操作
由于编译器生成合成移动操作的规则
基类定义了析构函数,将阻止合成移动操作
15.7.2 合成拷贝控制与继承
基类或者派生类的合成拷贝控制成员的行为与其他合成的构造函数,赋值运算符或析构函数类似
对类本身的成员依次进行初始化,赋值或销毁操作
此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化赋值或者销毁
派生类中删除的拷贝控制与基类的关系

移动操作与继承
由于基类会定义虚析构,所以不含合成移动,其派生类中也没有合成移动
所以当派生类需要移动操作时,需要先在基类中定义这些版本
根据拷贝控制原则,选择手动定义移动操作的类还需要定义相关拷贝控制操作
15.7.3 派生类的拷贝控制成员
派生类构造函数在其初始化阶段不但要初始化本类自己的成员,还负责初始化派生类对象的基类部分
因此在拷贝和移动操作的时候,也需要正确处理基类的成员
但是析构函数不同,析构函数只负责销毁派生类自己的资源
定义派生类的拷贝或者移动构造
由于基类成员的初始化在派生类初始化之前,因此一般需要再构造函数之前显式调用基类的构造函数
移动构造将参数使用std::move转为临时右值引用
派生类赋值运算符
与拷贝和移动构造一样,派生类的赋值运算符也必须显式的为其基类部分赋值
派生类析构函数
在析构函数体执行完成后,对象的成员会被隐式的销毁
对象的基类部分也是被隐性的销毁的,派生类的析构函数只需要负责派生类自己分配的资源
在构造函数和析构函数中调用虚函数
构造 / 析构过程中,对象处于 "半成品" 状态:基类构造时派生类未初始化,基类析构时派生类已销毁。
编译器为了安全,会将此时的对象 "绑定" 到当前构造 / 析构函数所属的类(基类),虚函数调用是静态绑定(只调当前类版本),而非常规的动态绑定。
该规则对 "直接调用虚函数" 和 "间接调用虚函数" 都生效,核心是避免访问无效的派生类成员
15.7.4 继承的构造函数
派生类不能继承默认,拷贝和移动构造函数,如果派生类没有定义,将生成编译器合成的版本
但是可以直接重用其直接基类定义的构造函数
最典型的场景是:基类包含通用属性和完善的构造逻辑,派生类仅继承基类属性、无新增需要初始化的成员变量。此时重用基类构造函数可以避免重复编写相同的初始化代码
派生类重用基类构造函数的方式是使用using语句
通常情况下,using语句只是令某个名字在当前作用域内可见,而当用于构造函数时,using语句不仅仅是使名字可见,而是会令编译器产生代码,生成一个与之对应的派生类构造函数
生成的函数版本如下 Son(args):Person(args){}
继承的构造函数的特点
和普通的using声明不一样,对于构造函数的using声明不会改变构造函数的访问级别
不能在using语句时指定,也不会改变原有的explicit和constexpr属性
基类含有默认实参的构造函数其默认参数不会被继承,反而会生成多个版本,每个版本各自省略掉一个默认参数
cpp
Base(int a, double b= 0.0){}
// 被继承后将生成如下版本
Son(int a, double b){}
Son(int a){}
15.8 容器与继承
使用容器存放继承体系中的对象时,通常需要采取间接存储的方式
在容器中不可以保存不同类型的对象,所以不能直接存储具有继承关系的不同类型的对象
在容器中放置(智能)指针而非对象
由于不能直接存放类型对象,实际中通常选择使用基类指针来进行存储
这些指针所指的动态类型既可以是基类型也可以是派生类型