0%

C++ Primer 第十五章

面向对象程序设计

OOP:概述

面向对象程序设计(object-oriented programming)的核心思想是数据抽象继承动态绑定

  • 使用数据抽象,我们可以将类的接口与实现分离;
  • 使用继承,可以定义相似的类型并对其相似关系建模;
  • 使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

继承

通过继承(inheritance)联系在一起的类构成一种层次关系。通常在层次关系的根部有一个**基类(base class),其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类( derived class)**。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。

C++中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数( virtual function):

1
2
3
4
5
class Quote {
public:
std::string isbn() const;
virtual double net_price(std: :size_t n) const;
};

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

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

因为使用public派生,所以可以使用基类成员,可以把Bulk quote的对象当成Quote的对象来使用。

动态绑定

用它可以分别处理Quote和Bulk_quote的对象。例如,当要购买的书籍和购买的数量都已知时,下面的函数负责打印总的费用:

1
2
3
4
5
6
7
//计算并打印销售给定数量的某种书籍所得的费用
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;

函数形参中的item是基类的引用,我们既可以使用基类的该函数,又可以使用派生类中的该函数,这回根据对象的类型决定执行哪一个版本:

1
2
3
// basic的类型是Quote; bulk的类型是Bulk_quote
print_total (cout, basic,20); //调用Quote的net_price
print_total (cout, bulk,20); //调用Bulk quote的net price

函数的运行版本由实参决定,所以动态绑定又被称为运行时绑定。

在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。

定义基类和派生类

定义基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Quote {
public:
Quote() = default; //关于=default请参见7.1.4节(第 237页)
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)访问运算符说明这样的成员。

定义派生类

派生类必须使用类派生列表指出从哪继承而来。派生类必须将继承类的虚函数重新声明:

1
2
3
4
5
6
7
8
9
10
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 ; //以小数表示的折扣额
};

再派生类中使用public继承,所以回隐式包含isbn函数

派生类中的虚函数

派生类可以不覆盖它继承的虚函数,但必须重新声明:如果没有覆写,其行为类似其他的普通成员。

派生类可以再它覆盖的函数前使用virtual,可以在函数const关键字后添加override。

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

派生类大致可以认为是这样分布:

image.png

因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。

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

这种派生类到基类的转换回隐式的进行,所以可以将派生类的引用或指针用在基类引用或者指针上。

派生类构造函数

派生类构造函数可以调用基类的构造函数帮助建立自己的构造函数:

1
2
3
4
5
Bulk quote(const std::string& book,double p,
std::size_t qty,double disc):
Quote(book, p), min_qty(qty), discount (disc){ }
//与之前一致
};

除非特别指出,否则派生类的所有成员都会执行默认初始化。编译器会首先初始化基类的部分,然后按顺序声明派生类的成员。

派生类使用基类成员

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

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

派生类的作用域嵌套在基类的作用域之内。因此,对于派生类的一个成员来说,它使用派生类成员(例如min_qty和discount)的方式与使用基类成员(例如price)的方式没什么不同。

关键概念:遵循基类的接口
必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的公有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。

继承与静态成员

如果基类定义了静态成员,则它在整个继承体系中只存在该成员的唯一定义。不论有几个派生,每个静态成员都只存在唯一实例:

1
2
3
4
5
6
7
class Base {
public:
static void statmem () ;
};
class Derived : public Base {
void f (const Derived&) ;
};

静态成员遵循通用的访问控制规则,如果基类中的成员是private的,则派生类无权访问它。假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它

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

派生类的声明

派生类声明方式和一般类一样:

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

被用作基类的类

如果想将派生类作为基类,则必须已经被定义:

1
2
3
class Quote;				//声明但未定义
//错误:Quote必须被定义
class Bulk_quote : public Quote { ... };

派生类包含从基类中来的成员,为了使用它们,必须先知道它们。也表明了一个类不能派生它本身。

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

1
2
3
class Base {/* ...*/ };
class D1: public Base {/* ...*/ };
class D2: public D1 {/* ...*/ };

在这个继承关系中,Base是D1的**直接基类( direct base),同时是D2的间接基类( indirectbase)**。直接基类出现在派生列表中,而间接基类由派生类通过其直接基类继承而来。

每个类都会继承直接基类的所有成员。对于一个最终的派生类来说,它会继承其直接基类的成员;该直接基类的成员又含有其基类的成员;依此类推直至继承链的顶端。

防止继承的发生

类名后加上final可防止类被继承:

1
2
3
4
5
6
class NoDerived final { /** / };			//NoDerived不能作为基类
class Base {/* */ };
// Last是final的;我们不能继承
Lastclass Last final : Base {/**/ }; // Last不能作为基类
class Bad : NoDerived{/**/}; //错误:NoDerived是final的
class Bad2 : Last {/* */ }; //错误: Last是final的

类型转换与继承

在继承关系的类中,基类的指针和引用可以绑定到派生类对象上,所以使用基类指针或者引用时,并不清楚绑定对象的真实类型。

和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。

静态类型与动态类型

在继承关系中必须区分两种类型,表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型,动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。

1
2
//当print_total调用net_price 时(参见15.1节,第527页):
double ret = item.net_price (n) ;

item在前面时用Quote&去定义的,那么Quote&就是item的静态类型,动态类型知道在运行时调用该函数才会知道。如果传递一个Bulk_quote对象给print_total,则item的动态类型于静态类型不一致。只有引用和指针动态和静态类型才会不一致。

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

派生类可以向基类转换是因为每一个派生类对象都包含一个基类的部分。所以一个基类对象既可以独立存在可以作为派生类的一部分存在。但任何派生类对象都不可以向基类隐式的转换,包括引用和指针。

总结:要想理解在具有继承关系的类之间发生的类型转换,有三点非常重要

  • 从派生类向基类的类型转换只对指针或引用类型有效。
  • 基类向派生类不存在隐式类型转换。
  • 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行。我们将在15.5节(第544页)详细介绍可访问性的问题。

尽管自动类型转换只对指针或引用类型有效,但是继承体系中的大多数类仍然(显式或隐式地)定义了拷贝控制成员。因此,我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象。不过需要注意的是,这种操作只处理派生类对象的基类部分。

虚函数

在我们使用基类的引用或指针调用虚函数时会执行动态绑定。因为知道运行时才知道调用了哪个版本,所以所有的虚函数都必须有定义,不论它是否被用到。

对虚函数的调用可能在运行时才被解析

虚函数执行哪个版本完全依赖于运行时绑定到其对象的实际动态类型:

1
2
3
4
Quote base("0-201-82470-1", 50);
print_total(cout, base, 10); //调用Quote::net price
Bulk_quote derived("0-201-82470-1", 50, 5, .19);
print_total(cout, derived, 10); //调用Bulk_quote::net_price

依据调用对象的不同,会调用两种不同的函数。此外动态绑定也只有当通过指针或引用调用虚函数才会发生:

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

关键概念:C++的多态性
OOP的核心思想是多态性(polymorphism)。多态性这个词源自希腊语,其含义是“多种形式”。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是C++语言支持多态性的根本所在。

当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。

另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论如何都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。

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

派生类中的虚函数

当我们在派生类中可以再一次使用virtual关键字指出函数的性质。一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参、返回类型必须与被覆盖的基类函数完全一致。有一个例外是当类的虚函数返回类型是类本身的指针和引用时,规则无效:也就是说,如果D由B派生得到,则基类的虚函数可以返回B*而派生类的对应函数可以返回D*,只不过这样的返回类型要求从D到B的类型转换是可访问的。

final和override

我们使用override来覆盖继承而来的虚函数,但该函数并没有覆盖已存在的虚函数,此时编译器将会报错:

1
2
3
4
5
6
7
8
9
10
11
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没有形如2 (int)的函数
void f3() override; //错误:f3不是虚函数
void f4() override; //错误:B没有名为f4的函数
};

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

1
2
3
4
5
6
7
8
9
10
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
};

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

虚函数与默认实参

虚函数可以有默认实参,如果某次函数调用使用默认实参,则实参值由静态类型决定:

换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。

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

回避虚函数的机制

如果希望虚函数调用不要进行动态绑定,而是强迫其执行某个版本,可以使用作用域运算符:

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

此代码在编译时就可以完成解析

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

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

抽象基类

我们可以将一个函数定义为纯虚函数,这样做的目的时为了告诉用户,当前此函数没有任何意义,所以它无需定义,通过在函数体位置添加=0就可以声明一个纯虚函数,它只能出现在类内部虚函数声明语句处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//用于保存折扣值和购买量的类,派生类使用这些数据可以实现不同的价格策略
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;
//表示折扣的小数值
};

我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个=0的函数提供函数体。

含有纯虚函数的类是抽象基类

含有纯虚函数的类是抽象基类,这种类只负责定义接口,后续的其他类可以覆盖接口,我们不可以直接创建一个抽象基类的对象,因为此时的其中的纯虚函数并没有被定义,我们可以在派生类中覆盖此函数,然后就可以创建派生类的对象。

1
2
3
// Disc_quote声明了纯虚函数,而Bulk_quote将覆盖该函数
Disc_quote discounted; //错误:不能定义 Disc quote的对象
Bulk_quote bulk; //正确:Bulk quote中没有纯虚函数

如果不给出纯虚函数的定义,它任然是抽象基类。

派生类构造函数只初始化它的直接基类

重新实现Bulk_quote,让它继承Disc_quote:

1
2
3
4
5
6
7
8
9
10
11

//当同一书籍的销售量超过某个值时启用折扣
//折扣的值是一个小于1的正的小数值,以此来降低正常销售价格
class Bulk_quote : public Disc_quote {
public:
Bulk_quote() = default;
Bulk_quote(const std : : string & book, double price,
std : : size_t qty, double disc) :
Disc_quote(book, price, qty, disc) { };//覆盖基类中的函数版本以实现一种新的折扣策略
double net_price(std : : size_t) const override;
};

这个版本的 Bulk_quote 的直接基类是 Disc_quote,间接基类是 Quote。每个Bulk quote对象包含三个子对象:一个(空的)Bulk_quote部分、一个 Disc_quote子对象和一个Quote子对象。

每个类各自控制其对象的初始化过程。因此,即使Bulk_quote没有自己的数据成员,也必须提供构造函数,在构造函数中调用直接基类的构造函数,进而继续调用间接基类 的构造函数。

关键概念:重构
在Quote的继承体系中增加Disc_quote类是重构(refactoring)的一个典型示例。

重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。

值得注意的是,即使我们改变了整个继承体系,那些使用了Bulk_quote或Quote的代码也无须进行任何改动。不过一旦类被重构(或以其他方式被改变),就意味着我们必须重新编译含有这些类的代码了。

访问控制与继承

每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问( accessible)。

受保护的成员

如前所述,一个类使用protected关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。protected说明符可以看做是 public和 private 中和后的产物:

  • 和私有成员类似,受保护的成员对于类的用户来说是不可访问的。
  • 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
  • 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
1
2
3
4
5
6
7
8
9
10
11
12
13
class Base {
protected:
int prot_mem; // protected成员
};
class Sneaky : public Base {
friend void clobber(Sneaky&); //能访问Sneaky: :prot_mem
friend void clobber(Base&); //不能访问Base: :prot_mem
int j; // j默认是private
};
//正确:clobber能访问Sneaky对象的private和protected成员
void clobber(Sneaky &s) { s.j = s.prot_mem = 0; }
//错误:clobber不能访问Base的protected成员
void clobber(Base& b) { b.prot_mem = 0; }

例子中派生类成员函数使用基类对象访问受保护的成员是不可行的。

公有、私有、受保护继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base {
public:
void pub_mem();
// public成员
protected:
int prot_mem;
// protected成员
private:
char priv_mem;
// private成员
};
struct Pub_Derv : public Base {
// 正确:派生类能访问protected成员int f() { return prot_mem; }
//错误:private成员对于派生类来说是不可访问的char g() { return priv_mem;}
};
struct Priv_Derv : private Base {
// 依旧不能访问
int f1() const { return prot_mem; }
};

派生访问说明符对派生类的成员(及友元)能否访问直接基类成员没有影响。访问权限只与基类中的访问说明符有关。派生类只能访问直接基类的受保护的与共有的成员。

派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限,概括来说:

  • 如果是共有继承,那么继承而来的成员访问等级不变。
  • 受保护继承,继承而来的访问等级上升一级,public变为protect。
  • 私有继承,所有继承成员访问等级上升到最高,及全部为private

派生类向基类转换的可访问性

派生类向基类的转换(参见15.2.2 节,第530页)是否可访问由使用该转换的代码决定,同时派生类的派生访问说明符也会有影响。假定D继承自B:

  • 只有当D 公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换。
  • 不论D以什么方式继承B,D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
  • 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,如果D继承B的方式是私有的,则不能使用。

对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。

关键概念:类的设计与受保护的成员
不考虑继承的话,我们可以认为一个类有两种不同的用户:普通用户和类的实现者。

其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。

如果进一步考虑继承的话就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员。

和其他类一样,基类应该将其接口成员声明为公有的;同时将属于其实现的部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据;对于后者应该声明为私有的。

友元与继承

就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员:

1
2
3
4
5
6
7
8
9
10
11
class Base {
//添加friend声明,其他成员与之前的版本一致
friend class Pal; // Pal在访问Base的派生类时不具有特殊性
};
class Pal {
public:
int f(Base b) { return b.prot_mem; } //正确: Pal是 Base的友元
int f2(Sneaky s) { return s.j; } //错误: Pal不是Sneaky的友元
//对基类的访问权限由基类本身控制,即使对于派生类的基类部分也是如此
int f3(Sneaky s) { return s.prot_mem; } //正确: Pal是Base的友元
};

在f2中j是Sneaky成员而不是Base成员,由于pal只是Base的友元所以不能访问其派生类成员。而f3访问的是Sneaky中的Base成员所以可以访问。

当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效。对于原来那个类来说,其友元的基类或者派生类不具有特殊的访问能力:

1
2
3
4
5
6
7
8
// D2对Base 的 protected和private成员不具有特殊的访问能力class D2 : public Pal {
public:
int mem(Base b)
{
return b.prot_mem;
}
//错误:友元关系不能继承
};

改变个别成员的可访问性

使用using声明可以改变和继承某个名字的访问级别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
std : : size_t size() const { return n; }
protected:
std : : size_t n;
};
class Derived : private Base {
//注意:private继承
public:
//保持对象尺寸相关的成员的访问级别
using Base : : size;
protected:
using Base : : n;
};

using使得不论如何继承而来的成员在保持using前一个访问说明符的访问等级,如size是public,n是protect。但是派生的类只能为可以访问的名字提供using声明

默认的继承保护级别

默认情况下class定义派生类是私有继承,而struct是共有继承。但更建议显示的声明出来。

继承中的类作用域

派生类的作用域嵌套在基类内,例如:

1
2
Bulk_quote bulk;
cout <<bulk.isbn () ;

名字isbn的解析将按照下述过程所示:

  • 因为我们是通过Bulk_quote的对象调用isbn的,所以首先在Bulk_quote中查找,这一步没有找到名字isbn
  • 因为Bulk quote是 Disc quote 的派生类,所以接下来在 Disc_quote 中查找,仍然找不到。
  • 因为 Disc_quote是 Quote的派生类,所以接着查找Quote;此时找到了名字isbn,所以我们使用的isbn最终被解析为Quote中的isbn。

编译时进行名字查找

一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是由静态类型决定的。

这是因为当使用基类指针时,可使用的成员已经确定,如果使用它绑定一个派生类对象,且派生类对象又新增了几个成员,此时对于这个指针来说,它并不知道这些多出来的成员,自然也无法使用它们。

名字冲突与继承

如果派生类中定义了其基类同名的成员,那么此成员将会隐藏基类中的同名成员,就像局部变量被优先使用。主要还是因为派生类的作用域嵌套在基类内。

通过作用域运算符使用隐藏成员

通过在同名成员前加上作用域就可以调用它:

1
2
3
struct Derived : Base {
int get_base_mem() { return Base : :mem;} //...
};

建议还是不要同名。

*关键概念:名字查找与继承
理解函数调用的解析过程对于理解C++的继承至关重要,假定我们调用p->mem ()(或者obj .mem()),则依次执行以下4个步骤:

  • 首先确定p(或obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
  • 在p(或obj)的静态类型对应的类中查找mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
  • 一旦找到了mem,就进行常规的类型检查(参见6.1节,第183页)以确认对于当前找到的 mem,本次调用是否合法。
  • 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
    • 如果 mem是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
    • 反之,如果mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。

一如既往,名字查找先于类型检查

如前所述,声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致。

虚函数与作用域

现在可以理解为什么基类与派生类的虚函数必须有相同的形参列表了,如果不相同就会隐藏同名成员,而不是覆写。不然就无法通过基类引用或指针访问派生类的对象了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Base {
public:
virtual int fcn();
};
class D1 : public Base {
public:
//隐藏基类的fcn,这个fcn不是虚函数
// D1继承了Base : :fcn ()的定义
int fcn(int); //形参列表与Base 中的fcn不一致
virtual void f2(); //是一个新的虚函数,在 Base中不存在
};
class D2 : public D1 {
public:
int fcn(int); //是一个非虚函数,隐藏了D1 : : fcn (int)
int fcn(); //覆盖了Base的虚函数fcn
void f2(); //覆盖了D1的虚函数f2
}

通过基类调用隐藏的虚函数

1
2
3
4
5
6
7
8
9
Base bobj; D1 d1obj; D2 d2obj;
Base* bp1l = &bobj, * bp2 = &dlobj, * bp3 = &d2obj;
bp1->fcn(); //虚调用,将在运行时调用 Base : : fcn
bp2->fcn(); //虚调用,将在运行时调用 Base : : fcn
bp3->fcn(); //虚调用,将在运行时调用D2 : :fcn
D1* dlp = &dlobj; D2* d2p = &d2obj;
bp2->f2(); //错误:Base没有名为f2的成员
dlp->f2(); //虚调用,将在运行时调用D1 : :f2()
d2p->f2(); // 虚调用,将在运行时调用D2: : f2()

对于调用非虚函数,并不会发生动态绑定,由指针的类型决定。

1
2
3
4
Base *pl = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn (42); //错误:Base中没有接受一个int的fcn
p2->fcn (42); //静态绑定,调用D1 : :fcn(int)
p3->fcn ( 42); //静态绑定,调用D2 : :fcn (int)

覆盖重载函数

和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。

有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将极其烦琐。

一种好的解决方案是为重载的成员提供一条using声明语句(参见15.5节,第546页),这样我们就无须覆盖基类中的每一个重载版本了。using声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。

构造函数与拷贝控制

虚析构函数

继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数(参见15.2.1节,第528页),这样我们就能动态分配继承体系中的对象了。

当delete一个动态分配的对象的指针时执行析构函数,但如果指针指向了其“子孙”,则有可能出现指针静态类型与被删除对象的动态类型不符。所以必须在基类中将析构函数定义为析构函数保证函数的执行:

1
2
3
4
5
class Quote {
public:
//如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数
virtual ~Quote() = default; // 动态绑定析构函数
};

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。

在之前的准则中:如果一个类需要一个析构函数,那么它同样也需要拷贝和赋值操作。但基类的析构是一个重要的例外。它的虚函数并没有内容所以可以没有其他操作。

虚析构函数阻止合成移动

基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。

合成拷贝控制与继承

这里的合成的拷贝,赋值或析构与普通类似,

基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构
函数类似:它们对类本身的成贝依城优对一个对象的直接基类部分进行初始化、赋值或销成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。例如,

  • 合成的Bulk quote默认构造函数运行Disc_quote 的默认构造函数,后者又运行Quote的默认构造函数。
  • Quote 的默认构造函数将bookNo成员默认初始化为空字符串,同时使用类内初始值将price初始化为0。
  • Quote的构造函数完成后,继续执行Disc_quote的构造函数,它使用类内初始值初始化 qty和discount。
  • Disc_quote的构造函数完成后,继续执行Bulk_quote的构造函数,但是它什么具体工作也不做。

拷贝构造也是类似,此外基类成员是合成或自定义都没有影响,但唯一的要求是成员应该可以访问比关切不是被删除的。

如前所述,Quote因为定义了析构函数而不能拥有合成的移动操作,因此当我们移动Quote对象时实际使用的是合成的拷贝操作。如我们即将看到的那样,Quote没有移动操作意味着它的派生类也没有。

派生类中删除的拷贝控制与基类的关系

  • 如果基类中基础操作(构造、拷贝。。。)是删除的函数或不可访问,那么派生类对应的成员也是被删除的,因为派生类不能通过这些基类成员来为基类执行这些操作。
  • 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
  • 和过去一样,编译器将不会合成一个删除掉的移动操作。当我们使用=default请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
1
2
3
4
5
6
7
8
9
10
11
12
class B {
public:
B();
B(const B&) = delete;
//其他成员,不含有移动构造函数
};
class D : public B {
//没有声明任何构造函数
};
D d; //正确:D的合成默认构造函数使用B的默认构造函数
D d2(d); //错误:D的合成拷贝构造函数是被删除的
D d3(std: : move(d)); //错误:隐式地使用D的被删除的拷贝构造函数

移动操作与继承

如前所述,大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。

因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。我们的Quote可以使用合成的版本,不过前提是Quote必须显式地定义这些成员。一旦 Quote定义了自己的移动操作,那么它必须同时显式地定义铂贝操作(参见13.6.2节,第476页):

1
2
3
4
5
6
7
8
9
10
11
12
class Quote {
public:
Quote() = default;
//对成员依次进行默认初始化
Quote(const Quote&) = default;
//对成员依次拷贝
Quote(Quote&&) = default;
//对成员依次拷贝
Quote& operator=(const Quote&) = default;//拷贝赋值
Quote& operator=(Quote&&) = default;//移动赋值virtual ~Quote() = default;
//其他成员与之前的版本一致
};

派生类的拷贝控制成员

派生类的拷贝和移动构造函数不仅仅要负责自己的成员初始化还有基类成员。而析构函数只负责销毁派生类自己分配的资源

当派生类定义了拷贝或移动操作时,该操作负责铂贝或移动包括基类部分成员在内的整个对象。

定义派生类的拷贝或移动构造函数

派生类拷贝或移动构造通常是应用对应的基类构造来初始化对象的基类部分:

1
2
3
4
5
6
7
8
9
10
11
12
class Base {/* ...*/ };
class D : public Base {
public:
//默认情况下,基类的默认构造函数初始化对象的基类部分
//要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中
//显式地调用该构造函数
D(const D& d) : Base(d)
//拷贝基类成员
/* D的成员的初始值*/ {/* ...*/ }
D(D&& d) : Base(std : : move(d))//移动基类成员
/* D的成员的初始值*/ {/* ... */}
};

将D类型的对象d传递给基类的拷贝构造函数。这个构造负责将d的基类部分拷贝给要创建的对象。如果d并没有基类初始值,则默认初始化。

在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数。

派生类赋值运算符

派生类的赋值运算符也必须显示地为其基类部分赋值。

1
2
3
4
5
6
7
// Base: : operator=(const Base&)不会被自动调用
D D: :operator=(const D &rhs)
{
Base: :operator=(rhs); //为基类部分赋值
//按照过去的方式为派生类的成员赋值
//酌情处理自赋值及释放已有资源等情况return *this;
}

调用基类的赋值操作无论基类的构造函数或赋值运算符是合成还是自定义,派生类的对应操作都能够使用它们。

派生类析构函数

派生类析构函数只负责销毁由派生类自己分配的资源:

1
2
3
4
5
class D : public Base {
public:
//Base : : ~Base被自动调用执行
~D() {/*该处由用户定义清除派生类成员的操作*/ }
};

对象销毁的顺序与创建的顺序相反。从子孙到祖先。

在构造和析构函数中调用虚函数

继承的构造函数

一个类只能初始化的直接基类,一个类也只能继承其直接类的构造函数。类不能默认继承默认的拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器会为派生类合成。

派生类继承基类构造函数的方式是提供一条注明了(直接)基类名的using声明语句。举个例子,我们可以重新定义Bulk_quote类,令其继承Disc_quote类的构造函数:

1
2
3
4
5
class Bulk_quote : public Disc_quote {
public:
using Disc_quote : : Disc_quote;//继承Disc_quote的构造函数
double net_price(std: :size_t) const;
};

通常情况下,using声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。

生成的构造函数为:

1
2
3
4
5
derived (parms) : base (args){ }
//在我们的 Bulk_quote类中,继承的构造函数等价于:
Bulk quote(const std : : string & book, double price,
std : : size_t qty, double disc) :
Disc quote(book, price, qty, disc) {}

derived是派生类的名字,base是基类的名字,parms是构造函数的形参列表,args将派生类构造函数的形参传递给基类的构造函数。

继承的构造函数的特点

和普通using不一样的是,构造函数的using声明不会改变构造函数的访问级别,且using声明也不能指定exlicit或者constexpr。继承的构造函数会具有相同的属性。

当基类的构造函数含有默认的实参时,这些实参并不会被继承,相反的是,派生类会获得多个继承的构造函数,每个构造函数分别省略掉由默认实参的形参。

容器与继承

当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。

在容器中放置智能指针而非对象

容器中防止继承关系的对象时,通常存放的是基类的指针(智能指针更好)。

编写Basket类

我们定义一个表示购物篮的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Basket {
public:
//Basket使用合成的默认构造函数和拷贝控制成员
void add_item(const std : : shared_ptr<Quote>& sale) { items.insert(sale); }
//打印每本书的总价和购物篮中所有书的总价
double total_receipt(std : : ostream&)const;
private:
//该函数用于比较shared_ptr,multiset成员会用到它
static bool compare(const std : : shared_ptr<Quote>& lhs,
const std : : shared_ptr<Quote>& rhs)
{
return lhs->isbn() < rhs->isbn();
}
//multiset保存多个报价,按照compare成员排序
std::multiset<std::shared ptr<Quote>, decltype (compare)*> items{ compare };
};

我们的类使用一个multiset(参见11.2.1节,第377页)来存放交易信息,这样我们就能保存同一本书的多条交易记录,而且对于一本给定的书籍,它的所有交易信息都保存在一起(参见11.2.2节,第 378页)。

这个声明看起来不太容易理解,但是从左向右读的话,我们就能明白它其实是定义了一个指向Quote对象的shared ptr的multiset。这个multiset将使用一个与compare成员类型相同的函数来对其中的元素进行排序。multiset成员的名字是 items,我们初始化items并令其使用我们的compare函数。

定义Basket的成员

这个成员的名字是total_receipt,它负责将购物篮的内容逐项打印成清单,然后返回购物篮中所有物品的总价格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
double Basket : : total_receipt(ostream& os) const
{
double sum = 0.0; //保存实时计算出的总价格
// iter指向ISBN相同的一批元素中的第一个
// upper_bound返回一个迭代器,该迭代器指向这批元素的尾后位置
for (auto iter = items.cbegin();
iter != items.cend();
iter = items.upper_bound(*iter)){
//我们知道在当前的Basket中至少有一个该关键字的元素
//打印该书籍对应的项目
sum += print_total(os, **iter, items.count(*iter));
}
os << "Total sale: " << sum << endl; //打印最终的总价格
return sum;
}

这里的for循环中upper函数会跳过相同的书直接指到下一种书。此外在print_total函数中,第二个参数第一次解引用得到一个指向该对象的指针指针,再一次解引用才能得到这个Quto对象(或派生对象)。使用multiset统计有多少个相同元素。

隐藏指针

接下的需要定义add_item成员,先看使用:

1
2
3
Basket bsk;
bsk.add_item (make_shared<Quote> ( "123",45));
bsk,add_item (make_shared<Bulk_quote> ("345"453.15));

下一步重新定义add_item使它接受一个Quote对象而非shared_ptr,新版本的add_item将负责内存的分配。定义两个版本,一个拷贝它给定的对象,另一个采取移动操作。

1
2
void add_item (const Quote& sale) ;			//拷贝给定的对象
void add_item (Quote&& sale) ; //移动给定的对象

此时遇到的问题是,函数不知道分配的类型,若new Quote(sale),则可能不正确,传入其派生类的对象时,会被切掉一部分。

模拟虚拷贝

我们给Quote添加一个虚函数,函数申请一份当前对象的拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Quote {
public:
//该虚函数返回当前对象的一份动态分配的拷贝
//这些成员使用的引用限定符参见13.6.3节(第483页)
virtual Quote* clone () const & {return new Quote (*this);}
virtual Quote* clone() &&
{ return new Quote(std : : move (*this)); }
//其他成员与之前的版本一致
};
class Bulk_quote : public Quote {
Bulk_quote* clone () const & (return new Bulk_quote (*this); }
Bulk_quote* clone () &&
{return new Bulk_quote (std: : move ( *this)) ;}
//其他成员与之前的版本一致
};

因为我们拥有add_item的拷贝和移动版本,所以我们分别定义clone的左值和右值版本。

使用clone写出新版的add_item:

1
2
3
4
5
6
7
8
9
10
11
12
class Basket {
public:
void add_item(const Quote& sale) //拷贝给定的对象
{
items.insert(std: :shared_ptr<Quote>(sale.clone()));
}void add_item(Quote&& sale) //移动给定的对象
{
items.insert(
std: :shared_ptr<Quote>(std: :move(sale).clone()));
}
//其他成员与之前的版本一致
};

clone根据作用与左值右值分不同的版本,add_item也有调用不同版本的重载。Sale的动态类型决定了运行Quote还是Bulk_quote函数。然后为这个对象绑定智能指针放在容器内。