面向对象程序设计基于三个基本概念:数据抽象、继承和动态绑定,第七章介绍了数据抽象的知识,本章将介绍继承和动态绑定。
15.1 OOP概述
面向对象程序设计(object-oriented programming)的核心思想是数据抽象、继承和动态绑定。
(1)继承
通过继承(inheritance)联系在一起的类构成一种层次关系。
在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 |
class Bulk_quote : public Quote { public: double net_price(std::size_t) const override; }; |
注意:派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上virtual关键字,但是并不是非得这么做。并且,C++11新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在改函数的形参列表之后增加一个override关键字。
(2)动态绑定
通过使用动态绑定(dynamic binding),我们能用同一段代码分别处理Quote和Bulk_quote的对象。例如,当要购买的书籍和购买的数量都已知时,下面的函数负责打印总的费用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
//计算并打印销售给定数量的某种书籍所得的费用 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 |
在上述过程中,函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时又称为运行时绑定(run-time binding)。
NOTE:在C++语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
15.2 定义基类和派生类
- 定义基类
12345678910111213141516class 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;protected:double price = 0.0; //代表普通状态下不打折的价格};
NOTE:基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。(1)基类必须将它的两种成员函数区分开来:一种是基类希望其派生类进行覆盖的函数,基类通常将其定义为虚函数;另一种是基类希望派生类直接继承而不要改变的函数。
(2)任何构造函数之外的非静态函数都可以是虚函数,关键字virtual只能出现在类内部的声明语句之前而不能用于类外部的函数定义。
(3)如果基类把一个函数声明为虚函数,则该函数在派生类中隐式地也是虚函数。
(4)成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。
(5)派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员。派生类能访问公有成员,而不能访问私有成员。
(6)不过在某些时候,基类中还有一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护的(protected)访问运算符说明这样的成员。 - 定义派生类
12345678910class Bulk_quote : public 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; //以小数表示的折扣额};
(1)派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
(2)C++新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在函数后面加上关键字override。
(3)在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或引用绑定到派生类对象中的基类部分上。12345Quote item; //基类对象Bulk_quote bulk; //派生类对象Quote *p = &item; //p指向Quote对象p = &bulk; //p指向bulk的Quote部分Quote &r = bulk; //r绑定到bulk的Quote部分这种转换通常称为派生类到基类的(derived-to-base)类型转换。
NOTE:在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。
(4)派生类构造函数
(a)派生类并不能直接初始化这些从基类继承来的成员,派生类必须使用基类的构造函数来初始化它的基类部分。
NOTE:每个类控制它自己的成员初始化过程。
(b)例如接受四个参数的Bulk_quote构造函数如下所示:12Bulk_quote(const std::string &book, double p, std::size_t qty,double disc):Quote(book, p), min_qty(qyt), discount(disc) { }如上,除非我们特别指出,否则派生类对象的基类部分会向数据成员一样执行默认初始化。如果想使用其他的基类构造函数,就需要以类名加圆括号内的实参列表的形式为构造函数提供初始值。
NOTE:首先初始化基类的部分,然后按照声明的顺序依次初始化派生类的成员。(5)派生类使用基类的成员
1234567//如果达到了购买书籍的某个最低限量值,就可以享受折扣价格了double Bulk_quote::net_price(size_t cnt) const{if (cnt >= min_qyt)return cnt * (1-discount) * price;elsereturn cnt * price;}(6)防止继承的发生(final)
C++11新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字final:1class NoDerived final { /* */}; //NoDerived不能作为基类 - 类型转换与继承
WARNING:理解基类和派生类之间的类型转换是理解C++语言面向对象编程的关键所在。
我们可以将基类的指针或引用绑定到派生类对象上!例如,我们可以用Quote&指向一个Bulk_quote对象,也可以把一个Bulk_quote对象的地址赋给一个Quote*。
(1)静态类型与动态类型
静态类型在编译时总是已知的,动态类型则是变量或表达式表示的内存中的对象的类型,动态类型直到运行时才可知。例如,当print_total调用net_price时
double ret = item.net_price(n);
我们知道item的静态类型是Quote&,它的动态类型则依赖于item绑定的实参,动态类型直到在运行时调用该函数时才会知道。
NOTE:基类的指针或引用的静态类型可能与其动态类型不一致。
(2)不存在从基类向派生类的隐式类型转换…
(3)…在对象之间不存在类型转换
派生类向基类的自动类型转换只能对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
WARNING:当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。
15.3 虚函数
- 对虚函数的调用可能在运行时才被解析
- 派生类中的虚函数
(1)一旦某个函数被声明为虚函数,则在所有派生类中它都是虚函数。
(2)一个派生类的函数如果覆盖了某个继承来的虚函数,则它的形参类型必须与被覆盖的基类函数完全一致。 - final和override说明符
(1)C++11新标准中我们可以使用override关键字来说明派生类中的虚函数,这么做的好处是使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误。
(2)我们还能把某个函数指定为final,如果我们已经把函数定义成final了,则之后任何尝试覆盖该函数的操作都将引发错误。 - 虚函数与默认实参
如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
TIP:如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。 - 回避虚函数机制
某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现:12//强制调用基类中定义的函数版本而不管baseP的动态类型是什么double undiscounted = baseP->Quote::net_price(42);
15.4 抽象基类
- 纯虚函数
我们可以将net_price定义成纯虚(pure virtual)函数,这样做可以清晰明了地告诉用户当前这个net_price函数是没有实际意义的。
(1)通过在函数体的位置(声明语句的分号之前)书写=0就可以将一个虚函数说明为纯虚函数。其中=0只能出现在类内部的虚函数声明语句处:123456789101112//用于保存折扣值和购买量的类,派生类使用这些数据可以实现不同的价格策略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; //表示折扣的小数值};(2)尽管我们不能直接定义这个类的对象,但是Disc_quote的派生类构造函数将会使用Disc_quote的构造函数来构建各个派生类对象的Disc_quote部分。
- 含有纯虚函数的类是抽象基类
(1)含有(或未经覆盖直接继承)纯虚函数的类是抽象基类(abstruct base class)。
(2)抽象基类负责定义接口,而后续的其他类可以覆盖该接口。
(3)我们可以定义Disc_quote的派生类对象,前提是这些类覆盖了net_price函数:123//Disc_quote声明了纯虚函数,而Bulk_quote将覆盖该函数Disc_quote discounted; //错误:不能定义Disc_quote的对象Bulk_quote bulk; //正确:Bulk_quote中没有纯虚函数NOTE:我们不能创建抽象基类的对象。
- 派生类构造函数只初始化它的直接基类
重新实现Bulk_quote,这一次让它继承Disc_quote而非直接继承Quote:1234567891011//当同一书籍的销售量超过某个值时启用折扣//折扣的值是一个小于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;};关键概念:重构
在Quote的继承体系中增加Disc_quote类是重构(refactoring)的一个典型示例。重构负责重新设计类的体系以便将操作和/或数据从一个类移动到另一个类中。对于面向对象的应用程序来说,重构是一种很普遍的现象。
15.5 访问控制与继承
- 受保护的成员
protected说明符可以看做是public和private中和后的产物:
(1)和私有成员类似,受保护的成员对于类的用户来说是不可访问的。
(2)和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
(3)派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
为了便于理解第(3)条,请看如下示例:12345678910111213class Base {protected:int prot_mem; //protected成员};class Sneaky : public Base {friend void clobber(Sneaky&); //能访问Sneaky::prot_memfriend void clobber(Base&); //不能访问Base::prot_memint 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)在类内部,类成员函数可访问类的任何一个成员(public,pirvate,protected)。
(2)但是,在类的外部(比如main函数中),类的pirvate成员不管是对该类的对象还是该类派生类的对象,都是无访问权限的。 - 公有、私有和受保护继承
三种不同的继承方式下的基类特性和派生类特性:1234public protected private公有继承 public protected 不可见私有继承 private private 不可见保护继承 protected protected 不可见 - 友元与继承
就像友元关系不能传递一样,友元关系也不能继承。
15.6 继承中的类作用域
(1)每个类定义自己的作用域在这个作用域内我们定义类的成员。
(2)当存在继承关系时,派生类的作用域在其基类的作用域之内。
(3)但如果一个名字在派生类作用域内无法正确解析,则编译器将继续在外层的基类作用域中寻找该名字的定义。
- 名字冲突与继承
(1)派生类能重用定义在其基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。
NOTE:派生类的成员将隐藏同名的基类成员。 - 通过作用域运算符来使用隐藏的成员
我们可以通过作用域运算符来使用一个被隐藏的基类成员。 - 名字查找先于类型检查
(1)如前所述,声明在内层作用域的函数并不会重载声明在外层作用域的函数;
(2)同理,定义在派生类中的函数也不会重载其基类中的成员。
(3)如果派生类的成员与基类的某个成员同名,则派生类将在其作用域内隐藏(非重载)该基类成员。即使它们的形参列表不一致,基类成员也会被隐藏掉。 - 虚函数与作用域
假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。
12345678910111213141516171819class 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的虚函数fcnvoid f2(); //覆盖了D1的虚函数f2}; - 通过基类调用隐藏的虚函数
(1)基于上述定义的类,我们看下面的代码:
12345678Base bobj;D1 d1obj;D2 d2obj;Base *bp1 = &bobj, *bp2 = d1obj, *bp3 = d2obj;bp1->fcn(); //虚调用,将在运行时调用Base::fcnbp2->fcn(); //虚调用,将在运行时调用Base::fcnbp3->fcn(); //虚调用,将在运行时调用D2::fcn
其中,稍有疑惑的可能是第2个调用:在bp2的例子中,实际绑定的对象是D1类型,而D1并没有覆盖那个不接受实参的fcn,所有通过bp2进行的调用将在运行时解析为Base定义的版本。
(2)接着看如下调用:
123456D1 *d1p = &d1obj;D2 *d2p = &d2obj;bp2->f2(); //错误:Base没有名为f2的成员d1p->f2(); //虚调用,将在运行时调用D1::f2()d2p->f2(); //虚调用,将在运行时调用D2::f2()
(3)我们再看观察对非虚函数调用的情况:
1234567Base *p1 = &d2obj;D1 *p2 = &d2obj;D2 *p3 = &d2obj;p1->fcn(42); //错误:Base中没有接受一个int的fcnp2->fcn(42); //静态绑定,调用D1::fcn(int)p3->fcn(42); //静态绑定,调用D2::fcn(int)
上面语句中,指针都指向了D2类型的对象,但是由于调用的是非虚函数,所以不会发生动态绑定。实际调用的函数版本由指针的静态类型决定。
总结上面几种指针调用虚函数和非虚函数的情况:
(1)基类指针指向派生类对象,则调用基类中的成员函数(该类中无虚函数,实现静态绑定)。
(2)若想让基类指针调用派生类中的成员,则需将该成员函数声明为虚函数(实现动态绑定)。
15.7 构造函数与拷贝控制
- 虚析构函数
(1)继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样我们就能动态分配继承体系中的对象了。
(2)通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本:
12345class Quote {public://如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数virtual ~Quote() = default; //动态绑定析构函数};
(3)和其他虚函数一样,析构函数的虚属性也会被继承。只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本:
1234Quote *itemP = new Quote; //静态类型与动态类型一致delete itemP; //调用Quote的析构函数itemP = new Bulk_quote; //静态类型与动态类型不一致delete itemP; //调用Bulk_quote的析构函数
WARNING:如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。 - 虚析构函数将阻止合成移动操作
如果一个类定义了析构函数,即使它通过=default的形式使用了合成的版本,编译器也不会为这个类合成移动操作。 - 合成拷贝控制与继承
(1)如果基类中的默认构造函数、拷贝控制函数、拷贝赋值运算符或析构函数是被删除的或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或销毁操作。
(2)如果在基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,因为编译器无法销毁派生类对象的基类部分。
(3)编译器将不会合成一个删除掉的析构函数。当我们使用=default请求一个移动操作是,如果基类中的对应操作是删除的或不可访问的,那么派生类中该函数将是删除的,原因是派生类对象的基类部分不可移动。同样,如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是删除的。
思考如下示例:
12345678910111213class 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的被删除的拷贝构造函数
分析:
(1)因为我们定义了拷贝构造函数,所以编译器不会为B合成一个移动构造函数。
(2)因此,我们既不能移动也不能拷贝B的对象。
(3)在实际编程过程中,如果在基类中没有默认、拷贝或移动构造函数,则一般情况下派生类也不会定义相应的操作。 - 派生类的拷贝控制成员
WARNING:当派生类定义了拷贝或移动构造函数时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
(1)定义派生类的拷贝或移动构造函数
当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分:
1234567891011class Base { /*...*/ };class D : public Base {public://默认情况下,基类的默认构造函数初始化对象的基类部分//要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中//显示地调用该构造函数D(const D& d):Base(d) //拷贝基类成员/* D的成员的初始值*/ { /*...*/ }D(D&& d):Base(std::move(d)) //移动基类成员/* D的成员的初始值*/ { /*...*/ }};
WARNING:在默认情况下,基类默认构造函数初始化派生类对象的基类部分如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显示地使用基类的拷贝(或移动)构造函数。
(2)派生类赋值运算符
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显示地为其基类部分赋值:
12345678//Base::operator=(const Base&)不会被自动调用D &D::operator=(const D &rhs){Base::operator=(rhs); //为基类部分赋值//按照过去的方式为派生类的成员赋值//酌情处理自赋值及释放已有资源等情况return *this;}
(3)派生类析构函数
对象销毁的顺序正好与其创建顺序相反:派生类析构函数首先执行,然后是基类的析构函数。 - 继承的构造函数
(1)在C++11新标准中,派生类能够重用其直接基类定义的构造函数
(2)一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。
(3)类不能继承默认、拷贝和移动构造函数。
我们重新定义Bulk_quote,令其继承Disc_quote类的构造函数:
12345class Bulk_quote : public Disc_quote {public:using Disc_quote::Disc_quote; //继承Disc_quote的构造函数double net_price(std::size_t) const;};
通常情况,using声明语句只是令某个名字在当前作用域内可见,而当作用于构造函数时,using声明语句将令编译器产生代码。