C++ 学习笔记之(15)-面向对象程序设计
OOP
:概述
面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定。
- 数据抽象:将类的接口与实现分离,详情可在C++ 学习笔记之(7)-类查阅
- 继承:可以定义相似的类型并对其相似关系建模, 继承构建一种层次关系,层次根部为基类,其他类则直接或间接地从基类继承而来,称为派生类
- 动态绑定:在一定程度上忽略相似类型的区别,而以统一的方式使用他们的对象。即函数的运行版本由实参决定,即在运行时选择函数的版本,又被成为 运行时绑定(run-time binding)
- 虚函数(virtual function):某些基类希望派生类重新定义的函数,会被基类声明成虚函数
定义基类和派生类
定义基类
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; // 书籍的ISBN 编号
protected:
double price = 0.0; // 代表普通状态下不打折的价格
};
- 基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此
- 基类通过声明成员函数为
virtual
使得该函数执行动态绑定 - 关键字
virtual
只能出现在类内部的声明语句之前,而不能用于类外部的函数定义 - 任何构造函数之外的非静态函数都可以是虚函数
- 如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数
- 虚函数的解析过程发生在运行时,而非编译时
- 受保护的(protected)访问运算符表示基类成员希望派生类成员有权访问,而禁止其他用户访问
定义派生类
派生类必须通过使用 类派生列表(class derivation list)明确指出从那个基类继承而来
class Bulk_quote : public Quote{ // Bulk_quote 继承自 Quote
public:
Bulk_quote() = default;
Bulk_quote(const std::string& book, double p, std::size_t qty, double disc):
Quote(book, p), min_qty(qty), discount(disc) {}
// 覆盖基类的函数版本以实现基于大量购买的折扣政策
double net_price(std::size_t) const override
{
{
if(cnt >= min_qty)
return cnt * (1 - discount) * price;
else
return cnt * price;
}
}
private:
std::size_t min_qty = 0; // 使用折扣政策的最低购买量
double discount = 0.0; // 以小数表示的折扣额
};
共有派生类型的对象能够绑定到基类的引用或指针,即
Quote
的引用或指针可以使用Bulk_quote
对象若派生类未覆盖基类的虚函数,则直接继承基类版本
override
关键字表明派生类覆盖了基类的虚函数派生类的对象可当成基类对象使用,也可将基类的指针或引用绑定到派生类对象中的基类部分
下列代码为派生类到基类的(derived-to-base)类型转换,编译器会隐式执行
Quote item; // 基类对象 Bulk_quote bulk; // 派生类对象 Quote *p = &item; // p 指向 Quote 对象 p = &bulk; // p 指向 buld 的 Quote 部分 Quote &r = bulk; // r 绑定到 bulk 的 Quote 部分
首先初始化基类部分,将参数传递给
Quote
的构造函数,然后按照声明的顺序依次初始化派生类成员每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类, 因此派生类对象不能直接初始化基类成员
基类定义的静态成员在整个继承体系中只存在唯一定义
某个类用做基类的前提是已经定义,而非仅仅声明
C++11定义了关键字
final
防止被继承class NoDerived final { /* */ };
类型转换与继承
- 静态类型(static type):编译时已知,是变量声明时的类型或表达式生成的类型
- 动态类型(dynamic type):变量或表达式表示的内存中的对象的类型,运行时才可知
- 基类不能隐式转换为派生类,但可使用
static_cast
转换,只要保证安全 - 派生类向基类的自动类型转换只对指针或引用类型有效,而派生类对象与基类对象的转换是通过拷贝/移动构造函数、赋值运算符发生的
虚函数
虚函数必须有定义
当虚函数通过指针或引用调用时,编译器知道运行时才能确定引用或指针绑定的对象类型。而若通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,编译时即可确定
Quote base; Quote *p = &item; p.net_price(10); // 调用 Quote::net_price Bulk_quote derived; p = &derived; p.net_price(20); // 调用 Bulk_quote::net_price
动态绑定只发生在通过指针或引用调用虚函数时
base = derived; // 把 derived 的 Quote 部分拷贝给 base base.net_price(20); // 调用 Quote::net_price
基类中的虚函数在派生类中隐含地也是虚函数,若派生类的函数覆盖了继承而来的虚函数,则它的形参类型必须与被覆盖的基类函数严格匹配,但返回类型只需与基类函数匹配
override
关键字表明派生类覆盖了基类的虚函数。final
可用于函数,表明函数不可覆盖。这两个说明符出现在形参列表(包括任何const
或引用修饰符)以及尾置返回类型之后如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致,否则使用基类中定义的默认实参
可以使用作用域运算符来货币虚函数的动态绑定机制,通常只有成员函数(或友元)才需要
抽象基类
- 纯虚函数(pure virtual function):无需定义,只需要在函数体前书写
=0
即可,=0
只能出现在类内部的虚函数声明语句处 - 抽象基类(abstract base class):含有(或者未经覆盖直接继承)纯虚函数的类。抽象类负责定义接口,不能创建抽象类的对象
class A{
public:
virtual int func() = 0; // 纯虚函数,故 A 为抽象类
};
访问控制与继承
每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类是否可访问
受保护的成员
- 和私有成员类似,受保护的成员对于类的用户来说是不可访问的
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权
class Base{
protected:
int prot_mem; // protected 成员
};
class Sneaky: public Base{
friend void clobber(Sneaky&) { s.j = s.prot_mem = 0; } // 能访问 Sneaky::prot_mem
friend void clobber(Base&) { b.prot_mem = 0; } // 不能访问 Base::prot_mem
int j;
};
公有、私有和受保护继承
某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符
- 派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限
- 派生类无法访问基类中的
private
成员,无论何种方式继承 - 派生类用户无法访问
private
继承的成员
派生类向基类转换的可访问性
- 只有派生类公有继承基类,才能使用派生类向基类的转换
- 派生类的成员函数和友元 ,可以使用派生类向基类的转换
若派生类公有或受保护继承基类,则派生类的成员和友元可以使用派生类向基类的转换。若私有,则不能
友元关系不能传递,不能继承,每个类负责控制各自成员的访问权限
- 通过使用
using
声明可改变派生类继承的某个成员的访问级别 - 默认情况下,使用
class
关键字定义的派生类为私有继承,使用struct
关键字定义的派生类为公有继承
继承中的类作用域
派生类的作用域嵌套在其基类的作用域之内,若一个名字在派生类作用域无法解析,则会在外层的基类作用域寻找
名字查找发生在编译时,即静态类型决定可见的成员,即使静态类型与动态类型可能不一致
派生类能够重用定义在其基类或间接基类中的名字,此时内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字
- 可以通过使用作用域运算符使用被隐藏的成员
using
声明语句可以把基类中某函数的所有重载实例都添加到派生类作用域中
构造函数与拷贝控制
虚析构函数
若静态类型与动态类型不相符的话,编译器期望指向的是动态类型的析构函数,故需要在基类中将析构函数定义为虚函数以确保执行正确的析构函数版本
- 如果基类的析构函数不是虚函数,则
delete
一个指向派生类对象的基类指针将产生未定义的行为 - 基类通常需要析构函数,且将其设定为虚函数
合成拷贝控制与继承
定义基类的方式可能导致派生类成员成为被删除的函数
- 若基类中的默认构造函数、拷贝构造函数、拷贝赋值运算符或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,因为编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作
- 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分
- 当使用
=default
请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,则派生类中该函数将是被删除的,因为派生类对象的基类部分不可移动
派生类的拷贝控制成员
- 当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象
- 默认情况下,基类的默认构造函数初始化对象的基类部分,如果我们想拷贝或移动基类部分,必须在派生类构造函数初始值列表中显示调用基类的拷贝或移动构造函数
class Base { /* ... */ }
class D: public Base{
public:
D(const D& d): Base(d) /* D 成员初始值 */ { /* ... */ } // 拷贝基类成员
D(D&& d): Base(std::move(d)) /* D 成员初始值 */ { /* ... */ } // 移动基类成员
};
- 与构造函数即赋值运算符不同,派生类析构函数只负责销毁由派生类自己分配的资源
- 如果构造函数或虚构函数调用了某个虚函数,则应该执行与构造函数或析构函数所属类型相对应的虚函数版本
继承的构造函数
- 类不能继承默认、拷贝和移动构造函数,若派生类没有直接定义这些构造函数,则编译器会合成
- 通常情况下,
using
声明语句只是令某个名字在当前作用域可见,而当作用于构造函数时,using
声明语句令编译器产生代码,即对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数 - 构造函数的
using
声明不会改变该构造函数的访问级别 - 当基类构造函数含有默认实参时,这些实参并不会被继承
class Bulk_quote: public Disc_quote{
public:
using Disc_quote::Disc_quote; // 继承 A 的构造函数, 等价于
/*
若派生类有自己的数据成员,则会默认初始化
Bulk_quote(const std::string&book, double price, std::size_t qty, double disc):
Disc_quote(book, price, qty, disc) {}
*/
};
容器与继承
当使用容器存放集成体系中的对象时,通常采用简介存储的方式。因为不允许在容器中存放不同类型的元素
- 当派生类对象被赋值给基类对象时,其中的派生类部分将被切掉,因此容器和存在继承关系的类型无法兼容
- 可以采用存放基类指针(更好的选择是智能指针)来存放具有继承关系的对象,因为指针所指对象的动态类型可能是基类类型也可能是派生类型
class B{
public:
virtual void info() { cout << str << endl; }
private:
std::string str = "B";
};
class D: public B{
public:
void info() override { cout << str2 << endl; }
private:
std::string str2 = "D";
};
vector<shared_ptr<B>> vec;
vec.push_back(make_shared<B>());
vec.push_back(make_shared<D>());
vec[0]->info(); // B
vec[1]->info(); // D
vector<B> vec2;
vec2.push_back(B());
vec2.push_back(D());
vec2[0].info(); // B
vec2[1].info(); // B, 派生部分被截断
结语
继承和动态绑定的结合使得我们能够编写具有特定类型行为但又独立于类型的程序
C++中的动态绑定只作用于虚函数,并且通过指针或引用调用
当执行派生类的构造、拷贝、移动和赋值操作时,首先构造、拷贝、移动和赋值其中的基类部分,然后才轮到派生类部分。析构函数的执行顺序正好相反,首先销毁派生类,接下来执行基类子对象的析构函数。
基类通常需要定义一个虚析构函数
派生类为每个基类提供一个保护级别,public
基类的成员也是派生类接口的一部分,private
基类的成员不可访问,protected
基类的成员对于派生类的派生类是可访问的,但是对于派生类的用户不可访问