C++面向对象程序设计
本篇隶属C++ Primer中类设计工具专题,当前集中于面向对象程序设计。 # 什么是面向对象编程? 在面向对象编程(缩写为OOP)中,重点是创建程序定义的数据类型,这些数据类型既包含属性,也包含一组定义明确的行为。OOP中的“对象”一词,是指我们可以从此类类型中实例化的对象。 面向对象编程的核心理念是:数据抽象,继承和动态绑定。
继承
概述
基类:位于层次关系中的根部,定义所有类共同的成员变量和成员函数; 派生类:通过继承自动接收基类的成员函数和成员变量;可添加想要的其他函数或成员变量。
- 类派生列表:明确指出当前派生类是从哪个基类继承而来,示例:
1 | class Bulk_Quote : public Quote{ // Bulk_Quote继承Quote |
定义基类和派生类
定义基类
1 | class Quote{ |
为什么要将基类的析构函数定义为虚函数? 解答:当派生类中需要回收内存时,如果析构函数不是virtual,则不会触发动态绑定,只会调用基类的析构函数(而非子类的析构函数)那么:子类资源无法正确释放,导致内存泄露
成员函数
基类定义成员函数时,需区分以下两种情况: 1. 派生类直接继承、不要改变的函数;(编译时解析,执行与派生类细节无关)
- 派生类进行覆盖的函数:定义为虚函数
- 运行时解析:使用基类指针/引用调用虚函数时,根据其绑定类型对象类型的不同,确定执行的版本。
访问权限
C++通过 public、protected、private 三个关键字来控制成员变ᰁ和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。
类的内部:无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。
类的外部:只能通过对象访问public属性的成员,不能访问private, protected属性的成员。
派生类:派生类可以访问基类的public, protected成员,不能访问private成员。
定义派生类
派生类构造函数
派生类首先使用基类的构造函数,初始化其从基类继承的部分;然后按照声明的顺序,依次初始化派生类的成员。
1 | Bulk_quote(const std::string& book, double p, std::size_t qty, double disc):Quote(book, p),min_qty(qty), discount(disc){} |
如上例,将前两个参数传递给Quote
的构造函数,由其初始化Bulk_quote
的基类部分(bookNo
和price
两个成员);再初始化由派生类直接定义的min_qty
和discount
成员。
静态成员
如果基类定义了一个静态成员,则:整个继承体系中,只存在该成员的唯一定义;每个静态成员变量,只存在唯一实例。
派生类的声明
声明包含类名,不包括派生列表(派生列表应该在定义中出现):
1 | class Buik_quote:public Quote; // 错误 |
防止继承的发生:final
1 | class NoDerived final {} // NoDerived不能作为基类 |
类型转换与继承
不存在下行的隐式类型转换(但存在上行的类型转换)
- 上行转换:把派⽣类的指针或引⽤,转换成基类表示
- 下行转换:把基类的指针或引⽤,转换为派⽣类表示
1 | Quote base; |
即使基类指针绑定的是派生类对象,也不能执行从基类向派生类的转换:
1 | Bulk_quote bulk; |
对比两种C++强制类型转换:
static_cast
:没有运行时类型检查
- 上行转换(派生类转基类)安全
- 下行转换(基类转派生类)不安全:没有动态检查
dynamic_cast
:下行转换时,具有类型检查(信息在虚函数中)的功能,⽐static_cast
更安全。
上行的自动类型转换,只对指针或引用有效(对对象无效)
虚函数
虚函数性质
基类声明的虚函数,在所有派生类中均为虚函数;
派生类中重写虚函数时:派生类中形参类型与该函数在基类中形参必须严格匹配。
加了
override
说明符但形参列表不同:编译器报错1
2
3
4
5
6
7
8
9
10struct B{
virtual void f1(int) const;
virtual void f2();
void f3();
}
struct D1:B{
void f1(int) const override; //正确:与B中f1匹配
void f2(int) override; // 错误:形参不匹配
void f3() override; // 错误:f3不是虚函数
}不加
override
说明符,函数名相同但形参列表不同:合法,不会覆盖基类中版本,视作两个独立的函数基类中声明为
final
的函数,派生类中不能重写:1
2
3
4
5
6
7struct D2:B{
void f1(int) const final;
}
struct D3:D2{
void f2(); // 正确:覆盖从B中继承的虚函数
void f1(int) const; // 错误:D2中f1已声明为final
}派生类强行调用基类中的虚函数版本:使用作用域运算符
1
2double undiscounted=baseP->Quote::net_price(42);
// 在编译时解析:无论baseP绑定的对象类型是什么,强行调用Quote的net_price版本
虚函数的实现:虚函数表
虚函数表:每个类(包括抽象类)都有⼀个虚表,其中包含了该类的虚函数的地址。
虚指针:每个对象都包含⼀个指向其类的虚表的指针,这个指针被称为虚指针(vptr)。
当调⽤⼀个虚函数时,编译器会使⽤对象的虚指针查找虚表,并通过虚表中的函数地址来执⾏相应的虚函数。这就是为什么在运⾏时,可以根据实际对象类型来确定调⽤哪个函数的原因。
多态的实现
C++中的多态性通过虚函数(virtual function)和虚函数表(vtable)实现。多态性允许在基类类型的指针或引⽤上调⽤派⽣类对象的函数:调用虚函数时执行动态绑定:运行时解析,执行的函数版本与绑定对象匹配。
基类声明虚函数
使⽤ virtual
关键字,以便派⽣类重写这些函数。
1 | class Shape { |
派生类重写虚函数
在派⽣类中重写基类中声明的虚函数,使⽤ override
关键字。
1 | class Circle : public Shape { |
使用基类指针或引用,指向派生类对象
1 | Shape* shapePtr = new Circle(); |
调用虚函数:运行时解析
1 | shapePtr->draw(); |
虚函数表:编译器在对象的内存布局中维护了⼀个虚函数表,其中存储了指向实际函数的指针。这个表在运⾏时⽤于动态查找调⽤的函数。
抽象基类:不能被实例化的类
类比Java中的abstrct类;纯虚函数类比Java中的abstract方法
抽象类是不能被实例化的类,其主要⽬的是:提供⼀个接⼝,供派⽣类继承和实现。
抽象类中可以包含普通的成员函数、数据成员和构造函数,但它必须包含⾄少⼀个纯虚函数:即在声明中使⽤
virtual
关键字,并赋予函数⼀个 = 0
的纯虚函数。
派⽣类必须实现抽象类中的纯虚函数,否则它们也会成为抽象类。
纯虚函数
通过与虚函数的对比来说明。
- 没有实现:纯虚函数没有函数体,只有函数声明,无默认实现。
- 虚函数有实现:有函数声明和实现,即在基类中可以提供默认实现。
- 强制覆盖:派⽣类必须提供纯虚函数的具体实现,否则它们也会成为抽象类。
- 虚函数可选实现: 派⽣类可以选择是否覆盖虚函数。如果派⽣类没有提供实现,将使⽤基类的默认实现。
- 禁止实例化:包含虚函数的类(即抽象基类)无法实例化。
- 虚函数的类允许实例化
- 用
=0
声明:- 虚函数用virtual声明
- 为接口提供规范,供派生类具体实现。
1 | class AbstractBase { |
访问控制
protected成员
protected
关键字,是介于public
和private
之间的产物。
和
private
成员的相似点:protected
和private
成员,对于类的用户均为不可访问;和
public
成员的相似点:(基类的)protected
和public
成员,对于派生类的成员和友元可访问派生类的成员和友元,只能通过派生类对象(不能通过基类对象),访问基类的
protected
成员。1
2
3
4
5
6
7
8
9
10class Base{
protected:
int prot_mem;
}
class Sneaky:public Base{
friend void clobber(Sneaky&); // 能访问Sneaky::prot_mem
friend void clobber(Base&); // 不能访问Base::prot_mem
}
void clobber(Sneaky &s){s.j=s.prot_mem=0;} // 正确
void clobber(Base &b){b.prot_mem=0;} // 错误
public, private, protected继承
派生类对继承而得的成员的访问权限受两个因素影响:
基类中该成员的访问说明符:影响派生类的成员和友元的访问权限
派生类的成员和友元的访问权限,只与基类中访问说明符有关,不受派生列表中访问说明符的影响:如下例中:
Priv_Derv
中可访问prot_mem
(尽管派生列表中为private
)派生列表中的访问说明符:影响派生类用户(包括派生类的派生类)的访问权限
1 | class Base{ // 基类 |
可通过
using
改变派生类继承的个别成员的可访问性:using
出现在类的private
部分:能被类的成员、友元访问;using
出现在类的protected
部分:能被类的成员、友元和派生类访问;using
出现在类的public
部分:能被类的成员、友元、派生类和用户访问;
1
2
3
4
5
6
7
8class Derived : private Base{
// 使用private继承,所以默认情况下,继承来的pub_mem, prot_mem是Derived的private成员
public:
using Base::pub_mem();
// 使用using后:Derived的用户、派生类均可访问pub_mem
protected:
using Base::prot_mem; // 使用using后:Derived的
}
上行转换(派生类向基类)的可访问性
- 派生类的用户:当且仅当D对B
public
继承,才能进行上行转换; - 派生类D的成员和友元:无论D对B的继承方式,均可上行转换;
- 派生类D的派生类E,其成员和友元:当且仅当D对B
public
或protected
继承,才能上行转换。
友元与继承
友元关系不能传递和继承:对于友元的基类和派生类,不具备特殊访问能力。
每个类控制各自成员的访问权限:对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此。
1 | class Base{ |
struct
和class
默认继承保护级别
默认情况下,class
定义的派生类采取private
继承;struct
定义的派生类采取public
继承
继承中的类作用域
派生类的作用域,嵌套在其基类的作用域之内:
如果一个名字在派生类的作用域内无法解析,则编译器向其外层基类中,不断递归寻找该名字定义,直到继承链的顶端。 > 逻辑类似于:编译器在符号表中查找标识符。
名字冲突:若出现同名,定义在派生类的名字,将隐藏定义在基类中的名字(即使参数列表不同,也会隐藏掉)
可通过作用域运算符,使用隐藏的基类成员,例如:
1
2
3struct Derived:Base{
int get_base_num() {return Base::mem;}
}
构造函数与拷贝控制
虚析构函数
基类的析构函数必须定义为虚函数。
当delete
一个动态分配对象的指针时,可能会出现:指针的静态类型与被删除对象的动态类型不符的情况(运行时多态)。因此需要定义为虚函数,实现虚构函数的动态绑定。
如果基类的析构函数不是虚函数,则
delete
一个指向派生类对象的基类指针时,将产生未定义的行为。
1 | class Quote{ |
合成拷贝控制
未完待续...
容器与继承
当希望在容器中存放具有继承关系的对象时,可采用智能指针:
1 | vector<shared_ptr<Quote>> basket; |
如果定义容器中直接存放基类对象,那么添加派生类时,其派生类部分将会被忽略掉。