15.1 OOP:概述
面向对象程序设计(object-oriented programming)的核心思想是数据抽象、继承和动态绑定。
15.1.1 继承
通过继承(inheritance)联系在一起的类构成一种层次关系。在 C++ 语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)。
class Quote {
public:
std::string isbn() const;
virtual double net_price(std::size_t n) const;
};
派生类必须通过使用派生列表(class derivation list)明确指出它是从哪个(哪些)基类继承而来的:
class Bulk_quote : public Quote {
public:
double net_price(std::size_t) const override;
};
派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上virtual
关键字,但是并不是非得这么做。并且,C++11 新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在改函数的形参列表之后增加一个override
关键字。
15.1.2 动态绑定
函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时又称为运行时绑定(run-time binding)。
在 C++ 语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
15.2 定义基类和派生类
15.2.1 定义基类
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
15.2.1.1 成员函数与继承
基类必须将它的两种成员函数区分开来:一种是基类希望其派生类进行覆盖的函数,基类通常将其定义为虚函数;另一种是基类希望派生类直接继承而不要改变的函数。
任何构造函数之外的非静态函数都可以是虚函数,关键字virtual
只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
如果基类把一个函数声明为虚函数,则该函数在派生类中隐式地也是虚函数。
成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。
15.2.1.2 访问控制与继承
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。派生类能访问公有成员,而不能访问私有成员。
不过在某些时候,基类中还有一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护的(protected)访问运算符说明这样的成员。
15.2.2 定义派生类
15.2.2.1 派生类中的虚函数
派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
C++ 新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在函数后面加上关键字override
。
15.2.2.2 派生类对象及派生类向基类的类型转换
在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。
这种转换通常称为派生类到基类的(derived-to-base)类型转换。和其他类型转换一样,编译器会隐式地执行这种转换。
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。
15.2.2.3 派生类构造函数
派生类并不能直接初始化这些从基类继承来的成员,派生类必须使用基类的构造函数来初始化它的基类部分。
每个类控制它自己的成员初始化过程。
除非我们特别指出,否则派生类对象的基类部分会向数据成员一样执行默认初始化。如果想使用其他的基类构造函数,就需要以类名加圆括号内的实参列表的形式为构造函数提供初始值。
首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。
15.2.2.4 派生类使用基类的成员
派生类可以访问基类的共有成员和受保护的成员。
15.2.2.5 继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。
静态成员遵循通用的访问控制规则,如果基类中的成员是private
的,则派生类无权访问它。假设某静态成员是可访问的,则我们既能通过基类也能通过派生类使用它。
15.2.2.6 派生类的声明
派生类的声明与其他类型差别不大,声明中包含类名但不包含它的派生列表:
class Bulk_quote : public Quote; //错误
class Bulk_quote; //正确
15.2.2.7 被用作基类的类
如果我们想要将某个类用作基类,则该类必须已经定义而非仅仅声明。这一规定的原因显而易见:派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它们是什么。因此这一规定还有一层隐含的含义,即一个类不能派生它本身。
15.2.2.8 防止继承的发生(final)
C++11 新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字final
:
class NoDerived final { /* */}; //NoDerived 不能作为基类
15.2.3 类型转换与继承
理解基类和派生类之间的类型转换是理解 C++ 语言面向对象编程的关键所在。
我们可以将基类的指针或引用绑定到派生类对象上。和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。
静态类型与动态类型
静态类型在编译时总是已知的,动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才可知。
基类的指针或引用的静态类型可能与其动态类型不一致。
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。
不存在从基类向派生类的隐式类型转换。
在对象之间不存在类型转换:派生类向基类的自动类型转换只能对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。
15.3 虚函数
我们必须为每一个虚函数都提供定义,而不管它是否被用到,这是因为连编译器也无法确定到底会使用哪个虚函数。
15.3.1 对虚函数的调用可能在运行时才被解析
必须搞清楚的一点是,动态绑定只有当我们通过指针或者引用调用虚函数才会发生。
OOP 的核心思想是多态性(polymorphism)。我们把具有继承关系的多个类型称为多态类型。引用或指针的静态类型与动态类型不同这一事实正是 C++ 语言支持多态性的根本所在。
15.3.2 派生类中的虚函数
一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。
一个派生类的函数如果覆盖了某个继承来的虚函数,则它的形参类型必须与被覆盖的基类函数完全一致。该规则有一个例外,当类的虚函数返回类型是类本身的指针或引用时,派生类的虚函数可以返回派生类的指针或引用,只要求从派生类到基类的类型转换是可访问的。
15.3.3 final 和 override 说明符
C++11 新标准中我们可以使用override
关键字来说明派生类中的虚函数,这么做的好处是使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误。
我们还能把某个函数指定为final
,如果我们已经把函数定义成final
了,则之后任何尝试覆盖该函数的操作都将引发错误。
final
和override
说明符出现在形参列表(包括任何const
或引用修饰符)以及尾置返回类型之后。
15.3.4 虚函数与默认实参
如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
15.3.5 回避虚函数机制
某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现:
//强制调用基类中定义的函数版本而不管 baseP 的动态类型是什么
double undiscounted = baseP->Quote::net_price(42);
该调用将在编译时完成解析。
15.4 抽象基类
15.4.1 纯虚函数
我们可以将函数定义成纯虚(pure virtual)函数,这样做可以清晰明了地告诉用户这个函数是没有实际意义的。
一个纯虚函数无需定义。通过在函数体的位置(声明语句的分号之前)书写= 0
就可以将一个虚函数说明为纯虚函数。其中= 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; //表示折扣的小数值
};
我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。
15.4.2 含有纯虚函数的类是抽象基类
含有(或未经覆盖直接继承)纯虚函数的类是抽象基类(abstruct base class)。
抽象基类负责定义接口,而后续的其他类可以覆盖该接口。
我们不能创建抽象基类的对象。
派生类构造函数只初始化它的直接基类
15.4.3 重构
重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。
15.5 访问控制与继承
15.5.1 受保护的成员
protected
说明符可以看做是public
和private
中和后的产物:
- 和私有成员类似,受保护的成员对于类的用户来说是不可访问的
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权
为了便于理解第(3)条,请看如下示例:
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; }
理解下面两点很重要:
- 在类内部,类成员函数可访问类的任何一个成员(
public
,pirvate
,protected
)。 - 但是,在类的外部(比如 main 函数中),类的
pirvate
成员不管是对该类的对象还是该类派生类的对象,都是无访问权限的
15.5.2 公有、私有和受保护继承
某个类对其继承而来的成员的访问权限受到两个因素影响:一是在基类中该成员的访问说明符,二是在派生类的派生列表中的访问说明符。三种不同的继承方式下的基类特性和派生类特性:
继承方式 | public | public | private |
---|---|---|---|
公有继承 | public | protected | 不可见 |
私有继承 | private | private | 不可见 |
保护继承 | protected | protected | 不可见 |
15.5.3 派生类向基类转换的可访问性
假定 D 继承自 B:
- 只有当 D 公有地继承 B 时,用户代码才能使用派生类向基类的转换;如果 D 继承 B 的方式是受保护的或者私有的,则用户代码不能使用该转换
- 不论 D 以什么方式继承 B,D 的成员函数和友元都是使用派生类向基类的转换:派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的
- 如果 D 继承 B 的方式是公有或者受保护的,则 D 的派生类的成员和友元可以使用 D 向 B 的类型转换;反之,如果 D 继承 B 的方式是私有的,则不能使用
15.5.4 友元与继承
就像友元关系不能传递一样,友元关系也不能继承。每个类负责控制各自成员的访问权限。
15.5.5 改变个别成员的可访问性
有时我们需要改变派生类继承的某个名字的访问级别,通过使用using
声明可以达到这一目的:
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
声明。
15.6 继承中的类作用域
每个类定义自己的作用域,在这个作用域内我们定义类的成员。当存在继承关系时,派生类的作用域在其基类的作用域之内。如果一个名字在派生类作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
在编译时进行名字查找。
15.6.1 名字冲突与继承
派生类能重用定义在其基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。
15.6.2 通过作用域运算符来使用隐藏的成员
我们可以通过作用域运算符来使用一个被隐藏的基类成员。
15.6.3 名字查找先于类型检查
如前所述,声明在内层作用域的函数并不会重载声明在外层作用域的函数。同理,定义在派生类中的函数也不会重载其基类中的成员。如果派生类的成员与基类的某个成员同名,则派生类将在其作用域内隐藏(非重载)该基类成员。即使它们的形参列表不一致,基类成员也会被隐藏掉。
15.6.4 虚函数与作用域
假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。
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
};
15.6.5 通过基类调用隐藏的虚函数
基于上述定义的类,我们看下面的代码:
Base bobj;
D1 d1obj;
D2 d2obj;
Base *bp1 = &bobj, *bp2 = d1obj, *bp3 = d2obj;
bp1->fcn(); //虚调用,将在运行时调用 Base::fcn
bp2->fcn(); //虚调用,将在运行时调用 Base::fcn
bp3->fcn(); //虚调用,将在运行时调用 D2::fcn
D1 *d1p = &d1obj;
D2 *d2p = &d2obj;
bp2->f2(); //错误:Base 没有名为 f2 的成员
d1p->f2(); //虚调用,将在运行时调用 D1::f2()
d2p->f2(); //虚调用,将在运行时调用 D2::f2()
Base *p1 = &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)
基类指针指向派生类对象,则调用基类中的成员函数(该类中无虚函数,实现静态绑定)。
若想让基类指针调用派生类中的成员,则需将该成员函数声明为虚函数(实现动态绑定)。
15.7 构造函数与拷贝控制
15.7.1 虚析构函数
继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样我们就能动态分配继承体系中的对象了。
如前所述,当我们delete
一个动态分配对象的指针时将执行析构函数。如果该指针指向继承体系中的某个类型,则可能出现指针的静态类型与被删除对象的动态类型不符的情况。通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本:
class Quote {
public:
//如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数
virtual ~Quote() = default; //动态绑定析构函数
};
和其他虚函数一样,析构函数的虚属性也会被继承。只要基类的析构函数是虚函数,就能确保当我们delete
基类指针时将运行正确的析构函数版本:
Quote *itemP = new Quote; //静态类型与动态类型一致
delete itemP; //调用 Quote 的析构函数
itemP = new Bulk_quote; //静态类型与动态类型不一致
delete itemP; //调用 Bulk_quote 的析构函数
如果基类的析构函数不是虚函数,则delete
一个指向派生类对象的基类指针将产生未定义的行为。
虚析构函数将阻止合成移动操作
如果一个类定义了析构函数,即使它通过= default
的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
15.7.2 合成拷贝控制与继承
15.7.2.1 派生类中删除的拷贝控制与基类的关系
- 如果基类中的默认构造函数、拷贝控制函数、拷贝赋值运算符或析构函数是被删除的或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作
- 如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分
- 编译器将不会合成一个删除掉的析构函数。当我们使用
= default
请求一个移动操作时,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是删除的
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 的被删除的拷贝构造函数
因为我们定义了拷贝构造函数,所以编译器不会为 B 合成一个移动构造函数。因此,我们既不能移动也不能拷贝 B 的对象。在实际编程过程中,如果在基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。
15.7.2.2 移动操作与继承
如前所述,大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。
因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。
15.7.3 派生类的拷贝控制成员
当派生类定义了拷贝或移动构造函数时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
15.7.3.1 定义派生类的拷贝或移动构造函数
当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分。在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显示地使用基类的拷贝(或移动)构造函数。
class Base { /*...*/ };
class D : public Base {
public:
//默认情况下,基类的默认构造函数初始化对象的基类部分
//要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中
//显示地调用该构造函数
D(const D& d):Base(d) //拷贝基类成员
/* D的成员的初始值*/ { /*...*/ }
D(D&& d):Base(std::move(d)) //移动基类成员
/* D的成员的初始值*/ { /*...*/ }
};
15.7.3.2 派生类赋值运算符
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显示地为其基类部分赋值:
//Base::operator=(const Base&)不会被自动调用
D &D::operator=(const D &rhs)
{
Base::operator=(rhs); //为基类部分赋值
//按照过去的方式为派生类的成员赋值
//酌情处理自赋值及释放已有资源等情况
return *this;
}
15.7.3.3 派生类析构函数
对象销毁的顺序正好与其创建顺序相反:派生类析构函数首先执行,然后是基类的析构函数。
15.7.3.4 在构造函数和析构函数中调用虚函数
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。
15.7.4 继承的构造函数
在 C++11 新标准中,派生类能够重用其直接基类定义的构造函数。一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。
我们重新定义 Bulk_quote,令其继承 Disc_quote 类的构造函数:
class Bulk_quote : public Disc_quote {
public:
using Disc_quote::Disc_quote; //继承 Disc_quote 的构造函数
double net_price(std::size_t) const;
};
继承的构造函数的特点
通常情况下,using
声明语句只是令某个名字在当前作用域可见。而当作用域构造函数时,using
声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。
一个构造函数的using
声明不会改变构造函数的访问级别。
一个using
声明语句不能指定explicit
或constexpr
。继承的构造函数的这两个性质与基类构造函数相同。
当一个基类构造函数含有默认实参时,这些实参不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。
大部分派生类会继承所有的基类构造函数:第一个例外,派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本;第二个例外,默认、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被合成。
15.8 容器和继承
当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式。因为不运行在容器中保存不同类型的元素,所以我们 不能把具有继承关系的多种类型的对象直接存放在容器当中。
我们可以再容器中放置(智能)指针而非对象:当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。