现代C++(C++11 以上)多范式编程语言,主要支持以下 5 种编程范式:

  • 面向过程
  • 面向对象
  • 泛型
  • 模板元
  • 函数式

这些范式在前面的章节中大部分都有涉及到,本章着重讲解面向对象编程(OOP),下一章是模板和泛型。

1. OOP:概述

面向对象程序设计(object-oriented programming)的核心思想是数据抽象继承动态绑定 (多态)。通过使用数据抽象,我们可以将类的接口与实现分离(见第7章);使用继承,可以定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

继承

通过继承(inheritance)联系在一起的类构成一种层次关系,是 is-a 的关系,例如 Cat 继承自 Animal

image-20230412134103900

在这里,Animal 叫做基类,Cat 是派生类。

一个实例,实现不同定价策略,首先定义一个名为 Quote 的类作为基类,派生出一个名为 Bulk_quote 的类,它表是可以打折销售的书籍。

这些类将包含下面的两个成员函数:

  • isbn(),返回书籍的ISBN编号。该操作不涉及派生类的特殊性,因此只定义在Quote类中。
  • net_price(size_t),返回书籍的实际销售价格,前提是用户购买该书的数量达到一定标准。这个操作显然是类型相关的,QuoteBulk_quote都应该包含该函数。

对于基类和派生类都有的行为,但是他们的行为具体实现不同,我们需要把这个方法声名为虚函数Quote 类:

class Quote {
public:
    std::string isbn() const;
    virtual double net_price(std::size_t n) const;
};

派生类必须通过使用类派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符:

class Bulk_quote:public Quote{		//Bulk_quote继承了Quote
public:
double net_price(std::size_t)const override;
};

派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上virtual关键字,也可以不加,基类中的虚函数在派生类中默认为虚函数。C+11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后增加 一个override关键字。只有函数签名完全一致的才能被认为是继承自基类的虚函数,override 会在编译是对这一规则进行检查。

动态绑定

动态绑定 又称作 运行时绑定 ,即在运行时才能确定对象的具体类型。在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。

//计算并打印销售给定数量的某种书籍所得的费用
double print_total(ostream &os,
					const Quote &item, size_t n)
{
    //根据传入item形参的对象类型调用Quote::net_price
    //或者Bulk_quote::net_price
    double ret = item.net_price ( n ) ;
    os << "ISBN:"<< item.isbn()		//调用Quote::isbn
    <<" # sold:"<< n << " total due:" << ret << endl;
    return ret;
}

//basic的类型是Quote;bulk的类型是Bulk_quote
print_total(cout,basic,20);		//调用Quote的net_price
print_total(cout,bulk,20);		//调用Bulk_quote的net_price

2. 定义基类和派生类

2.1 定义基类

Quote 类的定义:

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;     // 代表普通状态下不打折的价格
};

如果一个类需要被继承,虚构函数通常都应该定义为虚函数,即使该函数不执行任何实际操作也是如此。

具体原因在后续小节会给出

成员函数与继承

在C++语言中,基类必须将它的两种成员函数区分开来:一种是基类希望其派生类进行覆盖的函数;另一种是基类希望派生类直接继承而不要改变的函数。对于前者,基类通常将其定义为虚函数(virtual)。当我们使用指针或引用调用虚函数时,该调用将被动态绑定。根据引用或指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。

  • 基类通过在其成员函数的声明语句之前加上关键字virtual使得该函数执行动态绑定。
  • 任何构造函数之外的非静态函数都可以是虚函数。
  • 关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
  • 如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。

访问控制与继承

关于不同的继承方式的访问权限可参考下图理解

img

2.2 定义派生类

派生类必须重新定义基类中的虚函数,以下时 Bulk_quote 的定义

class Bulk_quote : public Quote // Bulk quote 继承自 Quote
{ 
public:
    Bulk_quote() = default;
    Bulk_quote(const std::string &, double, std::size_t, double);
    // 覆盖基类的函数版本以实现基于大量购买的折扣政策
    double net_price(std::size_t) const override;

private:
    std::size_t min_qty = 0;        // 适用折扣政策的最低购买量
    double discount = 0.0;          // 以小数表示的折扣额
};

派生类中的虚函数

派生类对于基类中的虚函数不一定要重新定义,如果不定义的话,派生类会直接继承基类的行为。

派生类对象及派生类向基类的类型转换

一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与该派生类继承的基类对应的子对象,如果有多个基类,那么这样的子对象也有多个。

因此,一个Bulk_quote对象将包含四个数据元素:它从Quote继承而来的bookNoprice数据成员,以及Bulk_quote自己定义的min_qtydiscount成员。

image-20230407194548987

我们可以将基类的指针或引用绑定到派生类对象中的基类部分上

Quote item;			//基类对象
Bulk_quote bulk;	//派生类对象
Quote *p= &item;	//p指向Quote对象
p= &bulk;			//p指向bulk的Quote部分
Quote &r = bulk;	//r绑定到bulk的Quote部分

这种转换通常称为派生类到基类的(derived-to-base)类型转换。和其他类型转换一样,编译器会隐式地执行派生类到基类的转换。

在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。

派生类构造函数

派生类含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。每个类控制它自己的成员初始化过程。

Bulk_quote(const std::string&book,double p,
			std::size_t qty,double disc):
			Quote(book,p),min_qty(qty),discount(disc) { }

首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。

派生类使用基类的成员

派生类可以访问基类的公有成员受保护成员

//如果达到了购买书籍的某个最低限量值,就可以享受折扣价格了
double Bulk_quote::net_price(size_t cnt) const
{
    if (cnt > = min _ qty )
    	return cnt*(1-discount)*price;
    else
    	return cnt * price;
}

继承与静态成员

如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。

class Base {
public:
	static void statmem();
};
class Derived : public Base {
	void f(const Derived&);
};

void Derived::f(const Derived &derived_obj)
{
    Base::statmem();	//正确:Base定义了statmem
    Derived::statmem();	//正确:Derived继承了statmem
    //正确:派生类的对象能访问基类的静态成员
    derived_obj.statmem();//通过Derived对象访问
    statmem();			//通过this对象访问
}

派生类的声明

class Bulk_quote:public Quote;//错误:派生列表不能出现在这里
class Bulk_quote;			//正确:声明派生类的正确方式

被用作基类的类

如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明:

class Quote;		//声明但未定义
//错误:Quote必须被定义
class Bulk quote : public Quote { ... };

继承一个类,派生类是继承它的成员,显然需要像定义才能确定成员。

一个类是基类,同时它也可以是一个派生类:

class Base {/*...*/};
class D1: public Base ( /* ... */ };
class D2:public D1 {/*...*/};

BaseD1直接基类 ,同时是 D2间接基类

防止继承的发生

使用 final 关键字可以使类不可以被继承

class NoDerived final { /* */ };		//NoDerived不能作为基类
class Base {/* */ };		
//Last是final的;我们不能继承Last
class Last final : Base {/* */ };		//Last不能作为基类
class Bad : NoDerived { /* */ };		//错误:NoDerived是final的
class Bad2 : Last {/* */ };				//错误:Last是final的

2.3 类型转换与继承

指针或引用变量的类型应该与他们所绑定的对象类型一致,但是具有继承关系的类是一个例外,我们可以将基类指针或引用绑定的派生类对象上。和内置指针一样,智能指针也支持派生类向基类的类型转换。

静态类型与动态类型

当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型(statictype)与该表达式表示对象的动态类型(dynamic type)区分开来。表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。

double ret = item.net_price(n);		

如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。例如,Quote类型的变量永远是一个Quote对象,我们无论如何都不能改变该变量对应的对象的类型。

不存在从基类向派生类的隐式类型转换

因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换:

Quote base;
Bulk_quote* bulkP=&base;		//错误:不能将基类转换成派生类
Bulk_quote& bulkRef=base;		//错误:不能将基类转换成派生类

如果上述赋值是合法的,则我们有可能会使用bulkPbulkRef访问base中本不存在的成员。

除此之外还有一种情况显得有点特别,即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换:

Bulk_quote bulk;
Quote* itemP=&bulk;			//正确:动态类型是Bulk_quote
Bulk_quote* bulkP=itemp;	//错误:不能将基类转换成派生类

在对象之间不存在类型转换

当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。

Bulk_quote bulk;		//派生类对象
Quote item(bulk);		//使用Quote::Quote(const Quote&)构造函数
item=bulk;				//调用Quote::operator=(const Quote&)

当构造item时,运行Quote的拷贝构造函数。该函数只能处理bookNoprice两个成员,它负责拷贝bulkQuote部分的成员,同时忽略掉bulkBulk_quote部分的成员。类似的,对于将bulk赋值给item的操作来说,只有bulkQuote部分的成员被赋值给item

3. 虚函数

派生类可以不定义基类中的虚函数(如果使用的话),但是对需要函数的调用是在运行期间才能确定的,编译器是无法确定虚函数是否被调用,所以我们必须为每一个虚函数提供定义。

对虚函数的调用可能在运行是才被解析,当我们用一个引用或指针调用具有继承关系的类中的虚函数时,在运行阶段才能知道调用的哪个类的方法。这种情况只发生在引用或指针调用虚函数时,对于普通类型的变量调用虚函数在编译器期就会将调用的版本确定。

base=derived;			//把derived的Quote部分拷贝给base
base.net_price(20);		//调用Quote::net_price

当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。

派生类中的虚函数

  • 派生类中继承的虚函数virtual 关键字不是必须的,因为一旦某个函数被声明成虚函数,则在所有派生类中 它都是虚函数。
  • 一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。
  • 派生类中虚函数的返回类型也必须与基类函数匹配,类的虚函数返回类型是类本身的指针或引用时例外。

final 和 override 说明符

在派生类中声明一个和基类某个虚函数名称一致,参数列表不一样的函数是合法的,这是一个全新定义的在方法,和基类中的同名虚函数没有任何关系。有时这可能是书写错误导致我们想要重新定义虚函数,但是错把参数列表写错了。为了解决这个问题C++11 引用入了 override 关键字,添加了这个说明符的函数就是继承自基类的虚函数,编译器会检查其参数列表是否基类一致。

struct B
{
    virtual void f1(int) const;
    virtual void f2();
    void f3();
};
struct D1 : B
{
    void f1(int) const override;        // 正确:f1与基类中的f1匹配
    void f2(int) override;              //错误:B没有形如f2(int)  的函数
    void f3() override;                 // 错误:f3不是虚函数
    void f4() override;                 // 错误:B没有名为f4的函数
};

我们还能把某个函数指定为final,如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作都将引发错误:

struct D2 : B
{
    // 从B继承f2()和f3(),覆盖f1(int)
    void f1(int) const final; // 不允许后续的其他类覆盖f1(int)
};
struct D3 : D2
{
    void f2();                  // 正确:覆盖从间接基类B继承而来的f2
    void f1(int) const;         // 错误:D2已经将f2声明成final
};

finaloverride说明符出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型之后。

关于函数的修饰符顺序参考:https://zhuanlan.zhihu.com/p/389757228

inline virtual void f() const volatile & noexcept final override try { 

以上是C++ 函数可用的修饰符顺序参考

虚函数与默认参数

和其他函数一样,虚函数也可以拥有默认实参。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。

如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。

回避虚函数的机制

在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的,例如下面的代码:

//强行调用基类中定义的函数版本而不管baseP的动态类型到底是什么
double undiscounted=baseP->Quote::net_price(42);

通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。

如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。

4. 抽象基类

一个新的需求,假设我们需要定义一个新的名为 Disc_quote 的类来支持不同的折扣需要。在定义Disc_quote类之前,首先要确定它的net_price函数完成什么工作。显然我们的Disc_quote类与任何特定的折扣策略都无关,因此Disc_quote类中的net_price函数是没有实际含义的。这个方法只是一种抽象的概念。

纯虚函数

为了实现上面的需求,我们可以将 net_price定义为纯虚函数 , 纯虚函数的的格式是在方法的参数列表后写 =0

// 用于保存折扣值和购买量的类,派生类使用这些数据可以实现不同的价格策略
class Disc quote : public Quote
{
public:
    Disc_quote() = default;
    Disc_quote(const std::string &book, double price,
               std::size_t qty, double disc) 
               : Quote(book, price),
                 quantity(qty), 
                 discount(disc) {}
    // 纯虚函数声明
    double net_price(std::size_t) const = 0;

protected:
    std::size_t quantity = 0;// 折扣适用的购买量
    double discount = 0.0;// 表示折扣的小数值
};

关于纯虚函数

  • 含有纯虚函数的的类是抽象基类 (类似于Java的抽象类),抽象基类是不能被示例化的
  • 抽象基类的派生类必须重新定义基类所有纯虚函数的具体实现,如果存在没有定义的纯虚函数,派生类依旧是抽象基类
  • 我们可以为抽象基类的纯虚函数提供定义,但是函数体需要在类外定义

5. 访问控制与继承