本 Wiki 开启了 HTTPS。但由于同 IP 的 Blog 也开启了 HTTPS,因此本站必须要支持 SNI 的浏览器才能浏览。为了兼容一部分浏览器,本站保留了 HTTP 作为兼容。如果您的浏览器支持 SNI,请尽量通过 HTTPS 访问本站,谢谢!
C++ Primer 笔记 第十五章
面向对象编程的核心点有三个:
本章主要会介绍继承与动态绑定。这两个特性会让定义功能类似的类变得更简单,也会让用户在使用这些类时不用考虑一些细小的区别。
当类中存在着继承关系时,这些类实际上处于层级关系。这种层级关系通常:
在基类中,函数被分为两类:
对于第一类函数,C++ 允许我们在不改变该函数名的同时,在派生类中对其进行重新定义。这一类的函数被称为虚函数(Virtual functions)。
假设我们希望建立一个类 Quote,用于表示书籍的贩卖信息。Quote 的成员有:
isbn()
,用获取书的 ISBNnet_price()
,根据书的购买数量决定价格同时,我们希望指定不同的售卖书籍的策略。这里使用 Quote 的派生类 Bulk_quote 表示批发书籍的贩售信息策略。根据上述信息:
isbn()
成员并不依赖于特定的类,因此只需在基类 Quote 中定义net_price(size_t)
成员需要根据不同的策略实现不同的版本,因此需要声明为虚函数因此,基类 Quote 的声明如下:
class Quote {
public:
std::string isbn() const;
virtual double net_price(std::size_t n) const;
};
派生类的定义中必须指明该类继承自哪些基类。通常我们通过 :
加类名列表的组合方式来表达该信息。这样的列表被称为类派生列表(Class derivation list)。上例中,Bulk_quote 的定义可以写作:
class Bulk_quote : public Quote { // Bulk_quote inherits from Quote
public:
double net_price(std::size_t) const override;
};
需要注意的几个点:
public
,其意义在于允许我们将 Bulk_quote 类型的对象像 Quote 类型的对象一样使用。virtual
关键字。override
关键字标记派生类中的虚函数(需要重写基类的函数)
动态绑定允许用户使用同样的代码处理不同的对象。比如我们可以使用 print_total
函数处理 Quote 与 Bulk_quote:
// calculate and print the price for the given number of copies, applying any discounts
double print_total(ostream &os,
const Quote &item, size_t n)
{
// depending on the type of the object bound to the item parameter
// calls either Quote::net_price or Bulk_quote::net_price
double ret = item.net_price(n);
os << "ISBN: " << item.isbn() // calls Quote::isbn
<< " # sold: " << n << " total due: " << ret << endl;
return ret;
}
几个需要注意的地方:
item
参数接收的是 Qoute 的引用;这样做可以不但能传递 Quote 对象,也能传递 Bulk_quote 对象net_pirce()
为虚函数。由于调用对象的类型为引用,因此 net_price()
的具体执行版本与调用对象的类型有关。调用的结果如下:
// basic has type Quote; bulk has type Bulk_quote
print_total(cout, basic, 20); // calls Quote version of net_price
print_total(cout, bulk, 20); // calls Bulk_quote version of net_price
上述的调用过程中,选择 net_pirce()
的决定需要得知对应的对象才能完成。由于对象创建于运行期,因此该选择(绑定)也只能在运行期才能确定。因此,这种选择的操作被称为动态绑定,也被称为运行时绑定(run-time binding)。
C++ 中,当使用指向基类的引用或者指针调用虚函数时,会发生动态绑定。
接着 Quote 的例子。Quote 的定义如下:
class Quote {
public:
Quote() = default; // = default see § 7.1.4 (p. 264)
Quote(const std::string &book, double sales_price):
bookNo(book), price(sales_price) { }
std::string isbn() const { return bookNo; }
// returns the total sales price for the specified number of items
// derived classes will override and apply different discount algorithms
virtual double net_price(std::size_t n) const
{ return n * price; }
virtual ~Quote() = default; // dynamic binding for the destructor
private:
std::string bookNo; // ISBN number of this item
protected:
double price = 0.0; // normal, undiscounted price
};
该实现中出现了三个新的知识点:
virtual
关键字的使用virual
(之后会提到)protected
成员基类需要定义虚析构函数,即便该析构函数不会投入使用。
派生类往往需要对某些成员进行重新定义。比如 Quote 实例中的 net_pirce
,该成员根据书籍购买的数量会得出不同的结果,是一个依赖类型的成员。对于这样的成员,派生类需要通过对其进行重写(override),来实现该成员在派生类中的定义。这样的成员具有如下的特点:
定义该类函数时,只需要在基类的声明前面加上 virtual
关键字即可。派生类中,该成员不要求被关键字 virtual
;而该成员的类外定义中不能加上 virtual
。
<html>
<img src=“/_media/programming/cpp/cpp_primer/dyn_bind.svg” width=“300”>
</html>
成员被直接继承意味着派生类对其的要求与基类相同。这种成员的特点是:
isbn()
,无论是 Quote 对象与 Bulk_quote 对象,调用该函数均会返回书的 ISBN。根据基类成员中的访问说明符:
可以看出在 Quote 的实现中,存在重写的函数(虚函数)往往会用到 protected
的成员;而直接继承的成员往往只会用到 private
的成员。
派生类的定义有几个特点:
override
关键字显式的标记来看一下 Bulk_quote 的实现:
class Bulk_quote : public Quote { // Bulk_quote inherits from Quote
Bulk_quote() = default;
Bulk_quote(const std::string&, double, std::size_t, double);
// overrides the base version in order to implement the bulk purchase discount policy
double net_price(std::size_t) const override;
private:
std::size_t min_qty = 0; // minimum purchase for the discount to apply
double discount = 0.0; // fractional discount to apply
};
如果虚函数没有在派生类中进行重写,那么派生类会直接继承基类中该函数的定义。
派生列表中的说明符也分为三种:public
,protected
,private
。总的来说,该说明符决定了基类中的成员是否对派生类成员可见。当派生是公有的时候:
以上面的实现举例,由于 Bulk_quote 的派生列表是公有类型的,因此 isbn()
成员则成为了 Bulk_quote 接口的一部分,而我们也可以使用 Bulk_quote 对象作为 Quote&
或 Quote*
参数的 argument。
像 Bulk_quote 这样只继承了一个基类的情况被称为单继承(single inheritance)。
实际上,一个派生类的对象可以大致被分为两个部分:
比如 Bulk_quote 的对象,实际上的组成是下面这样的:
因为派生类对象包含了对应的基类成员,因此我们可以像使用基类对象一样使用派生类对象。具体的来说,我们可以将基类的引用或指针绑定到派生类对象中的基类部分上:
Quote item; // object of base type
Bulk_quote bulk; // object of derived type
Quote *p = &item; // p points to a Quote object
p = &bulk; // p points to the Quote part of bulk
Quote &r = bulk; // r bound to the Quote part of bulk
这种转换通常被称为派生类到基类的转换(derived-tobase conversion)。这类转换是被隐式的执行的。这也意味着,当需要使用基类的引用或指针时,我们可以使用派生类对象的引用或指针来代替。
派生类的子对象在内存中并不一定是连续存储的。
尽管派生类对象包含了继承自基类的成员,但这些成员并不能被派生类直接初始化,而是需要使用基类的构造函数进行初始化。派生类中的成员初始化顺序如下:
以 Bulk_quote 为例,其构造函数实现如下:
Bulk_quote(const std::string& book, double p,
std::size_t qty, double disc) :
Quote(book, p), min_qty(qty), discount(disc) { }
// as before
};
当 Quote 的构造函数执行完毕时(初始化列表以及函数体执行完毕以后),Bulk_quote 才会对 min_qty
与 discount
成员进行初始化;最后执行的是 Bulk_quote 构造函数的函数体。成员的初始化由其对应的类控制。
基类成员可以以 protected
的方式被派生类使用:
// if the specified number of items are purchased, use the discounted price
double Bulk_quote::net_price(size_t cnt) const
{
if (cnt >= min_qty)
return cnt * (1 - discount) * price;
else
return cnt * price;
}
该实现需要访问基类中的 price
成员,因此 price
需要被声明为 protected
。
关键概念:遵循基类的接口
需要明确的是,每个类都定义了自己的接口;因此即便是基类与派生类,类与类之间的交互应该通过接口来实现。这也是为什么我们需要使用基类的构造函数来初始化派生类中的基类部分的原因。
如果基类中包含了静态成员,那么该成员在整个继承体系(层级)中有且只有唯一的一个实例:
class Base {
public:
static void statmem();
};
class Derived : public Base {
void f(const Derived&);
};
静态成员遵循一般的访问控制:
void Derived::f(const Derived &derived_obj)
{
Base::statmem(); // ok: Base defines statmem
Derived::statmem(); // ok: Derived inherits statmem
// ok: derived objects can be used to access static from base
derived_obj.statmem(); // accessed through a Derived object
statmem(); // accessed through this object
}
与定义不同,派生类的声明不包括派生列表。派生列表必须与派生类的定义同时出现:
class Bulk_quote : public Quote; // error: derivation list can't appear here
class Bulk_quote; // ok: right way to declare a derived class
基类在被派生类使用之前,必须拥有完整定义:
class Quote; // declared but not defined
// error: Quote must be defined
class Bulk_quote : public Quote { ... };
这是因为派生类中包含了基类部分。如果希望使用这个部分中的成员,必须要有成员的定义。这也暗示了基类的派生类不能是自己。
class Base { /* ... */ } ;
class D1: public Base { /* ... */ };
class D2: public D1 { /* ... */ };
如果一个派生类继承自间接基类,那么该类的基类部分将继承层级中之前所有间接基类的基类部分。
C++ 11 中通过在类声明后面添加 final
关键字,阻止该类被其他类继承:
class NoDerived final { /* */ }; // NoDerived can't be a base class
class Base { /* */ };
// Last is final; we cannot inherit from Last
class Last final : Base { /* */ }; // Last can't be a base class
class Bad : NoDerived { /* */ }; // error: NoDerived is final
class Bad2 : Last { /* */ }; // error: Last is final
之前提到过,基类的引用 / 指针可以绑定 / 指向其派生类对象:
double print_total(ostream &os, const Quote &item, size_t n) {....};
Bulk_quote bulk; // object of derived type
Quote &r = bulk; // r bound to the Quote part of bulk
Quote *p = &bulk; // p points to the Quote part of bulk
当使用基类的引用或者指针类型的对象时,我们不能确定该对象绑定的实际类型;该类型可能是基类对象,也可能是派生类对象。因此,使用此类型对象时,需要考虑两种类型:静态类型(static type)与动态类型(dynamic type)的区别。
智能指针也支持派生类到基类的转换。
静态类型与动态类型存在如下的区别:
以 print_total
函数为例,其接受一个 Quote&
类型的参数,因此该参数的静态类型是 Quote&
。当我们提供一个 Bulk_quote
对象给该参数时,该参数的动态类型就是 Bulk_quote&
。
需要注意的是,如果表达式的类型(接受参数的类型)不是指针或者引用类型,那么其静态类型与动态类型是一致的。
之所以存在派生类到基类的转换,是因为派生类中的存在基类部分,且该部分可以被基类的指针或者引用绑定。而基类对象不一定是派生类的一部分(绝大部分情况下,派生类都会定义基类中没有的成员)。因此,从基类到派生类的转换是不允许的,因为该转换很可能导致访问基类中不存在的成员:
Quote base;
Bulk_quote* bulkP = &base; // error: can't convert base to derived
Bulk_quote& bulkRef = base; // error: can't convert base to derived
由于存在这种限制,即便是基类的指针 / 引用与派生类对象已经绑定,该类转换也不被允许发生:
Bulk_quote bulk;
Quote *itemP = &bulk; // ok: dynamic type is Bulk_quote
Bulk_quote *bulkP = itemP; // error: can't convert base to derived
这是因为编译器没有办法在编译期确定对应的转换是否在运行期是安全的。具体的来说,编译期只能通过检查静态类型来判断该转换是否合法。dynamic_cast
将类型检查放到运行期执行static_cast
覆盖编译器的检查工作派生类到基类的隐式转换只能应用于引用 / 指针类型上:
由于派生类到基类的转换应用于引用类型,因此拷贝 / 移动 / 赋值操作都可以使用派生类的对象作为参数。但需要注意的是,当传递派生类对象到这些操作中时,会调用基类的拷贝构造/移动构造/赋值函数。因此,只有基类部分会被 拷贝 / 移动 / 赋值,而派生类部分将被忽略(Recall 一下,类只负责对自身成员的初始化)。这种情况下,我们称派生类部分被切掉(Sliced down)了:
Bulk_quote bulk; // object of derived type
Quote item(bulk); // uses the Quote::Quote(const Quote&) constructor
item = bulk; // calls Quote::operator=(const Quote&)
上面的例子中,bulk
中属于派生类部分的成员会在 item
初始化 / 赋值的过程中被忽略。
几个关键的概念:
当某个继承层级中定义了一个虚函数,该层级中的所有同名函数都被隐式的定义为虚函数。由于虚函数的调用直到运行期才可以确定具体的版本,因此层级中所有的虚函数都必须被定义,无论该函数是否会被调用。
虚函数的解析时机取决于调用虚函数的方式。如果通过引用或者指针的方式调用某个虚函数,那么该虚函数将直到运行期才能确定被调用的具体版本。也就是说,被调用的版本取决于调用者的动态类型。比如之前的 print_total
例子:
Quote base("0-201-82470-1", 50);
print_total(cout, base, 10); // calls Quote::net_price
Bulk_quote derived("0-201-82470-1", 50, 5, .19);
print_total(cout, derived, 10); // calls Bulk_quote::net_price
如果调用的方式是通过非引用或者指针的方式,那么调用的虚函数版本有调用者的静态类型决定:
base = derived; // copies the Quote part of derived into base
base.net_price(20); // calls Quote::net_price
上例中,对 net_price()
的调用在编译期就已经确定了。
面向对象编程的关键概念是多态(polymorphism)。我们将具有继承关系的多种类型称为多态类型,因为通过使用多态类型,我们可以使用该类型的多种形式,而无需在意其差异。
当使用引用或者指针调用基类中的函数时,调用对象的类型是不确定的。如果被调用的函数时虚函数,那么该函数的版本需要等到运行期决定,因为只有到了运行期才能决定引用或指针绑定对象的真正类型。
另外,以下两种调用都会在编译期绑定:
这种情况下,对象的静态类型与动态类型不做区分,也无法区分。
只有在使用引用或者指针调用虚函数的时候,才会在运行期解析该调用。也只有在这种情况下,调用者的静态类型与动态类型才会不同。
派生类中的虚函数有两个重要的特点:
virtual
关键字。
“返回值与基类中版本一致”这个论断有一个例外。当虚函数的返回类型是调用者自身的引用或是指针时,该规则无效。比如,如果 D 继承自 B,则基类的虚函数会返回 B*,而派生类的函数会返回 D*。
该例外受继承访问控制的影响(需要可访问)。
C++ 中允许对派生类中的虚函数赋予与基类同名函数不同的参数列表。但在这种情况下,编译器会将其视作不同的函数,而非派生类对基类的重写。实践中,这样的写法通常是错误的;这可能是因为作者原本希望重写,但弄错了参数列表。
C++ 11 中提供了关键字 override
来确保此类错误不会发生。如果函数被 override
关键字修饰,当派生类中的虚函数无法对基类进行重写的时候,编译器会报错:
struct B {
virtual void f1(int) const;
virtual void f2();
void f3();
};
struct D1 : B {
void f1(int) const override; // ok: f1 matches f1 in the base
void f2(int) override; // error: B has no f2(int) function
void f3() override; // error: f3 not virtual
void f4() override; // error: B doesn't have a function named f4
};
C++ 11 中还提供了另外一个关键字 final
。被 final
修饰的函数无法被之后的派生类再次重写:
struct D2 : B {
// inherits f2() and f3() from B and overrides f1(int)
void f1(int) const final; // subsequent classes can't override f1 (int)
};
struct D3 : D2 {
void f2(); // ok: overrides f2 inherited from the indirect base, B
void f1(int) const; // error: D2 declared f2 as final
};
虚函数也可以拥有默认参数。当虚函数被调用时,如果需要使用默认参数,则默认参数的值以调用者的静态类型中的默认值为准。举例来说,如果基类中与派生类中的虚函数拥有不同值的默认参数,当使用基类的引用或指针调用该虚函数时,即便我们传递的是派生类的对象,最后该虚函数也会使用基类中的默认参数值。
如果虚函数使用默认参数,应该基类和派生类中默认参数一致。
某些情况下,我们不希望使用虚函数的机制来调用虚函数,而是希望调用某个类中指定的版本,此时需要通过指定该版本的作用域来调用指定的虚函数版本。比如强制调用 Quote
类中 net_price()
:
// calls the version from the base class regardless of the dynamic type of baseP
double undiscounted = baseP->Quote::net_price(42);
这样调用的话,无论 baseP
绑定了哪种类型,最终都会调用 Quote
的 net_price()
函数。此类调用会在编译期完成绑定。当使用派生类虚函数调用其基类版本时,省略作用域运算符将导致该虚函数调用其自身,从而导致无限递归。
之前的 Quote
例子中,几个派生类中的价格策略各有不同,但其策略的实现(net_price()
)都围绕着两个成员 quantity
和 discount
来实现的。针对这种情况,我们可以提供一个类 Disc_quote
用于存储这两个成员,而其他的策略类则作为该类的派生类来使用这些成员。
但有一个问题是, Disc_quote
只用于存储成员。比起具体的价格策略,该类更像是体现书籍概念的类。因此, Disc_quote
类中不应该涉及 net_price()
的实现。
一个办法是不在 Disc_quote
中定义 net_price()
。但这样做问题在于,由于 Disc_quote
继承自 Quote
,当使用 Disc_quote
的对象作为调用者时,会调用 Quote
版本的 net_price()
。这将导致结果不会带有任何折扣。
实际上,像 Disc_quote
这样单纯的表示概念的类,是不应该实体化的。也就是说:
C++ 中通过将对应的虚函数定义为纯虚函数(Pure Virtual Functions)来阻止用户对这样的类进行实体化。纯虚函数通过在函数添加 =0
实现。纯虚函数拥有下面的特点:
=0
只需要出现在虚函数声明的地方
下面是 Disc_quote
的设计:
// class to hold the discount rate and quantity
// derived classes will implement pricing strategies using these data
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; // purchase size for the discount to apply
double discount = 0.0; // fractional discount to apply
};
需要注意的是, Disc_quote
类中依然保留了构造函数。即便 Disc_quote
本身无法实例化,其构造函数依然可以被用于之后的派生类。
像 Disc_quote
这样定义了纯虚函数的类,我们将其称为抽象基类(Abstract base class)。抽象基类用于为之后的派生类定义需要被重写的接口。继承自抽象基类的派生类必须对纯虚函数进行重写,否则也会被视作是为抽象类。抽象类不能被实例化:
// Disc_quote declares pure virtual functions, which Bulk_quote will override
Disc_quote discounted; // error: can't define a Disc_quote object
Bulk_quote bulk; // ok: Bulk_quote has no pure virtual functions
向层级中添加 Disc_quote
属于重构(Refactoring)的一个例子。重构意味着重新设计层级的关系,是一种面向对象中常见的应用。本例中,重构并不会对使用 Bulk_quote
与 Quote
内容的代码造成影响。
接着之前的例子。定义了 Disc_quote
之后,我们就可以将 Bulk_quote
作为其派生类,使用其内部成员了:
// the discount kicks in when a specified number of copies of the same book are sold
// the discount is expressed as a fraction to use to reduce the normal price
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) { }
// overrides the base version to implement the bulk purchase discount policy
double net_price(std::size_t) const override;
};
需要注意的是,派生类中的构造函数只会对直接基类的基类部分初始化,比如上面的
Bulk_quote(const std::string& book, double price,std::size_t qty, double disc):
Disc_quote(book, price, qty, disc) { }
实际上进行了三个部分的初始化:
Bulk_quote
的构造函数初始化了一个空的 Bulk_quote
部分,并传递参数给 Disc_quote
Disc_quote
的构造函数初始化了 quantity
和 discount
,并将其余两个参数传递给 Quote
Quote
的构造函数负责初始化余下的部分可以看出来的是,每个类控制着自身部分的初始化。而派生类传递的参数只能传递给直接基类;如果是间接基类,需要由派生类的直接基类再进行一次传递。
在类继承的同时,被继承的类还可以指定派生类对自身成员的访问方式。
protected
的成员表达的意愿是,希望与自身的派生类分享,但不希望接受一般的调用。被 protected
修饰的成员有如下的几个特性:
protected
的成员protected
的成员protected
成员下面是详细的例子:
class Base {
protected:
int prot_mem; // protected member
};
class Sneaky : public Base {
friend void clobber(Sneaky&); // can access Sneaky::prot_mem
friend void clobber(Base&); // can't access Base::prot_mem
int j; // j is private by default
};
// ok: clobber can access the private and protected members in Sneaky objects
void clobber(Sneaky &s) { s.j = s.prot_mem = 0; }
// error: clobber can't access the protected members in Base
void clobber(Base &b) { b.prot_mem = 0; }
假设上述的程序中可以通过基类的对象来访问基类中的受保护成员,那么:
void clobber(Base &b) { b.prot_mem = 0; }
这个版本的 clobber
将可以修改基类中的 protected
成员。也就是说,类的使用者可以修改 protected
成员。该结果显然与 protected
的设计理念不符。因此,派生类的实现者只能通过派生类的对象去访问 protected
成员,换句话说,只能访问派生类中内嵌的基类部分中的 protected
成员。
继承的成员是否能访问取决于以下两个因素的组合:
派生列表用于指定派生类的使用者 对基类成员的访问方式。继承的访问控制(方式)有三种:
public
公有继承:继承的成员维持其可访问性不变private
私有继承:继承的所有成员的访问性都将变为 private
protected
保护继承:继承的公有成员的访问性将转变为 protected
派生类的访问控制符不影响派生类成员(友元)对基类成员的访问。基类成员是否能被派生类成员访问只取决于该基类成员在基类中的权限。比如下面的例子:
Base
中的 priv_mem
成员,该成员都无法被派生类访问pub_mem
无法通过私有继承的派生类对象访问,因为私有继承将其的访问权限从公有改为了私有
class Base {
public:
void pub_mem(); // public member
protected:
int prot_mem; // protected member
private:
char priv_mem; // private member
};
struct Pub_Derv : public Base {
// ok: derived classes can access protected members
int f() { return prot_mem; }
// error: private members are inaccessible to derived classes
char g() { return priv_mem; }
};
struct Priv_Derv : private Base {
// private derivation doesn't affect access in the derived class
int f1() const { return prot_mem; }
};
Pub_Derv d1; // members inherited from Base are public
Priv_Derv d2; // members inherited from Base are private
d1.pub_mem(); // ok: pub_mem is public in the derived class
d2.pub_mem(); // error: pub_mem is private in the derived class
需要注意的是,继承列表的访问控制也会影响派生类的派生类,比如以 private
方式继承自基类的派生类,即便其自身的派生类以 public
的方式继承,也无法访问基类中的 protected
成员:
struct Derived_from_Public : public Pub_Derv {
// ok: Base::prot_mem remains protected in Pub_Derv
int use_base() { return prot_mem; }
};
struct Derived_from_Private : public Priv_Derv {
// error: Base::prot_mem is private in Priv_Derv
int use_base() { return prot_mem; }
};
派生类到基类的转换是否可以执行,取决于两点:
假设 D
继承自 B
:
D
类的使用者只有在 D
公有的继承自 B
的时候,才能进行派生类到基类的转换D
类的实现者(类成员 / 友元)可以进行派生类到基类的转换,无需考虑继承的方式D
类的派生类实现者 只有在继承方式是公有或受保护的前提下,才能进行派生类到基类的转换如果我们可以访问基类中的公有成员,那么我们就可以进行派生类到基类的转换。
当不考虑的继承的时候,类的用户被分为两种:
当考虑有继承情况的时候,类的用户需要被分为三种:
protected
成员)鉴于这样的分类,基类的设计应该作如下分割:
友元关系是无法继承的。友元关系与所在的类绑定,因此通过派生类的友元访问基类成员,或是通过基类的友元访问派生类的成员都是不可行的。来看下面一个例子:
class Base {
// added friend declaration; other members as before
friend class Pal; // Pal has no access to classes derived from Base
};
class Pal {
public:
int f(Base b) { return b.prot_mem; } // ok: Pal is a friend of Base
int f2(Sneaky s) { return s.j; } // error: Pal not friend of Sneaky
// access to a base class is controlled by the base class, even inside a derived object
int f3(Sneaky s) { return s.prot_mem; } // ok: Pal is a friend
};
需要注意的是,我们可以通过基类的友元函数访问派生类中基类部分的成员。注意这里的 f3
成员,尽管 Pal
是基类 Base
的成员,但其依然可以访问基类中的成员 prot_mem
。严格意义上来说,这里的 prot_mem
属于派生类 Sneaky
中的基类部分。// D2 has no access to protected or private members in Base
class D2 : public Pal {
public:
int mem(Base b)
{ return b.prot_mem; } // error: friendship doesn't inherit
};
如果希望对某个指定的成员进行访问级别的变更,我们可以使用 using
声明:
class Base {
public:
std::size_t size() const { return n; }
protected:
std::size_t n;
};
class Derived : private Base { // note: private inheritance
public:
// maintain access levels for members related to the size of the object
using Base::size;
protected:
using Base::n;
};
上例中,由于 Derived
通过私有的方式进行继承,因此默认情况下,继承的 size
、n
两个成员都转变为了派生类的私有成员。当使用 using
指定这两个成员的位置时(此处为 Base
类),这两个成员在派生类中的访问控制将被基类中原有的访问控制覆盖。此时,派生类中的 size
是公有的,而 n
是受保护的。using
修饰的成员,其可访问性由其声明名字所在的访问说明符决定。 比如 Base
中的 size
被声明为 public
的成员,通过 using
声明,则可以被所有的用户使用。
using 声明应该只提供给那些被允许访问的成员。
由于类可以定义为 class
与 struct
两种类型,当类成员没有被显示的访问说明符修饰时,根据类型的不同,默认的继承方式也不同:
class Base { /* ... */ };
struct D1 : Base { /* ... */ }; // public inheritance by default
class D2 : Base { /* ... */ }; // private inheritance by default
需要进行私有继承的成员应该显式的提供私有关键字,而不是使用默认的方式来继承。这样做可以令私有关系明确。
在继承中,派生类的作用域嵌套在基类的作用域中。如果名字在派生类中不能解析,编译器会到其直接基类中查找改名字。这种特性使得我们可以用派生类对象调用基类中的成员,比如之前 Qoute
的例子中:
Bulk_quote bulk;
cout << bulk.isbn();
isbn()
成员在派生类 Bulk_quote
中并不存在。此时编译器跳转到其直接基类 Quote
中 找到了该成员,因此 isbn()
调用被成功解析。
对象(或者引用,指针)的静态类型决定了该对象的哪些成员是可见的。也就是说,无论对象的动态类型最终是什么,也不会影响某个成员的可见性。一个常见的例子是,使用基类访问派生类中的成员。此时有两种方式访问:
但无论哪种方式,都无法访问派生类中的成员。比如下面的例子:
class Disc_quote : public Quote {
public:
std::pair<size_t, double> discount_policy() const
{ return {quantity, discount}; }
// other members as before
};
Bulk_quote bulk;
Bulk_quote *bulkP = &bulk; // static and dynamic types are the same
Quote *itemP = &bulk; // static and dynamic types differ
bulkP->discount_policy(); // ok: bulkP has type Bulk_quote*
itemP->discount_policy(); // error: itemP has type Quote*
上面的例子中, discount_policy()
成员只对派生类的对象(引用,指针)可见;即便基类的动态类型是派生类 Bulk_quote
,也无法访问该派生类中的成员。这是由于 itemP
的类型是 Quote*
,其名字查找的作用域从 Quote
开始,而并不包括子域 Bulk_quote
。换句话说:
由于派生类的作用域属于嵌套作用域,因此派生类中定义的,任意与基类中相同的名字,都会隐藏基类中的定义:
struct Base {
Base(): mem(0) { }
protected:
int mem;
};
struct Derived : Base {
Derived(int i): mem(i) { } // initializes Derived::mem to i
// Base::mem is default initialized
int get_mem() { return mem; } // returns Derived::mem
protected:
int mem; // hides mem in the base
};
Derived d(42);
cout << d.get_mem() << endl; // prints 42
与一般情况类似,如果想使用基类中的同名成员,可以通过指定作用域来实现:
struct Derived : Base {
int get_base_mem() { return Base::mem; }
// ...
};
派生类中的重名成员会隐藏基类中的成员。因此,不推荐在派生类中使用基类中已存在的名字定义成员。
名字查找在存在继承关系的类中会有如下的查找顺序: 假设有如下调用:
p->mem();
p
的静态类型(显然对应 p
的静态类型应该是类类型)mem
所在类的静态类型:mem
没有找到,持续向上(基类中)查找,直到找到或者到达最上层的基类为止mem
还是没有找到,编译器报错mem
以后,进行类型检查,查看该调用是否合法。如果合法,则编译器生成代码。此时:mem
是虚函数,且调用者的类型是引用或是指针,则编译器生成的代码用于确定调用者的动态类型与一般名字查找规则相同:
这是因为编译器会首先查找名字。假设派生类中存在同名函数,当编译器找到该函数时,名字查找也就结束了。之后,编译器才会进行类型的匹配。比如下面的例子中,被调用的 memfcn()
版本属于派生类。当调用 d.memfcn()
时,首先查找派生类中 memfcn()
的定义。由于编译器在当前作用域已经找到找到 memfcn
,搜寻停止。此时,由于该调用参数不匹配,编译器报错:
struct Base {
int memfcn();
};
struct Derived : Base {
int memfcn(int); // hides memfcn in the base
};
Derived d; Base b;
b.memfcn(); // calls Base::memfcn
d.memfcn(10); // calls Derived::memfcn
d.memfcn(); // error: memfcn with no arguments is hidden
d.Base::memfcn(); // ok: calls Base::memfcn
可见,虚函数为什么需要保证一致的参数列表。如果参数列表不一致,则重写不会发生;取而代之的是,派生类会隐藏基类中的同名函数。在没有重写的情况下,我们无法通过基类指针或应用的方式调用派生类中的重写版本:
class Base {
public:
virtual int fcn();
};
class D1 : public Base {
public:
// hides fcn in the base; this fcn is not virtual
// D1 inherits the definition of Base::fcn()
int fcn(int); // parameter list differs from fcn in Base
virtual void f2(); // new virtual function that does not exist in Base
};
class D2 : public D1 {
public:
int fcn(int); // nonvirtual function hides D1::fcn(int)
int fcn(); // overrides virtual fcn from Base
void f2(); // overrides virtual f2 from D1
};
上例中,D1
中的 fcn(int)
由于参数列表与基类不配,因此没有进行重写。D1::fcn(int)
被视作 D1
自己定义的成员函数。在 D1
中存在两个 fcn
的函数:
fcn()
继承自基类fcn(int)
为派生类自身的成员函数。如果对上述的类进行如下的调用:
Base bobj; D1 d1obj; D2 d2obj;
Base *bp1 = &bobj, *bp2 = &d1obj, *bp3 = &d2obj;
bp1->fcn(); // virtual call, will call Base::fcn at run time
bp2->fcn(); // virtual call, will call Base::fcn at run time
bp3->fcn(); // virtual call, will call D2::fcn at run time
当使用基类的指针对 fcn()
进行调用时,由于 fcn()
是基类中定义的虚函数,因此 bp1
, bp2
, bp3
都是虚调用。具体的来说:
Base
对象调用的版本是 Base::fcn()
D1
对象调用的版本也是 Base::fcn()
,因为 D1
中 fcn()
继承自 Base::fcn()
,且没有重写D2
对象调用的版本是 D2::fcn()
, 因为 D2
中对 Base::fcn()
进行了重写
当调用 f2()
时:
D1 *d1p = &d1obj; D2 *d2p = &d2obj;
bp2->f2(); // error: Base has no member named f2
d1p->f2(); // virtual call, will call D1::f2() at run time
d2p->f2(); // virtual call, will call D2::f2() at run time
bp2
无法完成调用,因为 Base
中不存在 f2()
d1p
调用的是 D1::f2()
d2p
由于定义了 f2()
重写,调用的也是 D1::f2()
如果调用 fcn(int)
的话:
Base *p1 = &d2obj; D1 *p2 = &d2obj; D2 *p3 = &d2obj;
p1->fcn(42); // error: Base has no version of fcn that takes an int
p2->fcn(42); // statically bound, calls D1::fcn(int)
p3->fcn(42); // statically bound, calls D2::fcn(int)
因为 fcn(int)
是 D1
自身的成员函数,因此不存在重写。因此:
Base
中无法调用 fcn(int)
,该函数在基类中并未定义D1
与 D2
均调用的是自身定义的版本。所有的绑定都是静态绑定,均在编译期完成。在 C++ 中,虚函数也可以被重载。而在派生类中,我们可以对虚函数的重载版本进行重写。重写可以针对不同的重载版本,但需要注意的是,如果希望所有重载版本的定义对派生类可见,我们有两个选择:
这样会带来一个问题,当只需要重写一部分重载版本时,这个过程会变得很麻烦。
一种解决方案是使用 using
在需要重载的地方进行该函数的声明。由于 using
在声明的时候只针对函数的名字,而不考虑参数列表,因此 using
声明默认会将基类中该函数的所有版本“添加到”派生类中(也就是将该系列函数的作用域并入了派生类中)。这样的方式导致继承的重载函数的定义对派生类可见,因此只需要对需要重写的函数进行操作即可。
需要注意的是,使用 using
声明基类中的函数,其前提是该函数机器重载版本必须可以被派生类访问。否则,如果访问没有被派生类重新定义的重载版本,都是对 using
声明的访问。
处于继承层级中的类同样存在着拷贝控制。
继承对拷贝控制最大的一个影响是,基类中的析构函数通常应该被定义为虚函数。这样做是因为需要对拥有动态资源的派生类进行准确的析构。也就是说,虚析构函数允许我们使用动态绑定的方式来释放资源。比如之前的例子 Quote
,其析构函数需要声明为:
class Quote {
public:
// virtual destructor needed if a base pointer pointing to a derived object is deleted
virtual ~Quote() = default; // dynamic binding for the destructor
};
如果此时 Quote*
绑定的是 Bluk_quote
,那么对 Quote*
申请的资源进行 delete
就可以正确释放 Bluk_quote
对象申请的资源。此时,Bluk_quote
也拥有析构函数,该函数继承自基类;而通过对该析构函数的重写则可以正确的释放 Bluk_quote
对象申请的资源:
Quote *itemP = new Quote; // same static and dynamic type
delete itemP; // destructor for Quote called
itemP = new Bulk_quote; // static and dynamic types differ
delete itemP; // destructor for Bulk_quote called
如果基类中,析构函数不被声明为虚函数,那么通过基类指针 对派生类资源进行 delete 操作是未定义行为。
需要注意的是,基类的析构函数是三五原则一个重要的例外。基类需要一个内容为空的虚析构函数,而并不意味着需要添加其他拷贝控制的部分。
这也带来一个影响:编译器不会为定义了析构函数的类合成移动操作。
C++ 中,派生类中的合成拷贝控制成员,在处理基类部分的资源时,会调用基类中对应的拷贝控制成员来处理。比如之前的 Quote
实例:
Bluk_quote
的合成构造函数会调用 Disc_quote
中的构造函数,也就是 Quote
的构造函数Quote
的默认构造函数对 Disc_quote
直接基类部分 bookNo
和 price
进行初始化,之后由 Disc_quote
对 Bluk_quote
的直接基类部分 qty
和 discount
进行初始化。Quote
中的成员 bookNo
会被首先初始化该规则同样适用于拷贝成员。比如拷贝构造函数,析构函数等。需要注意的是,该规则被应用的前提是基类中对应的拷贝控制成员是可访问的(没有被删除的),而与成员是否是合成的并没有关系。
C++ 11 中允许拷贝成员被删除。鉴于派生类拷贝控制成员会使用基类的拷贝控制成员,基类中的拷贝控制成员会影响派生类中对应成员的可删除性。总的来说:
= default
在派生类中显式的要求合成移动成员时,如果基类中无法提供对应的,未删除(可访问的)移动成员,则该移动成员是被删除的,因为基类部分无法移动。如果基类的析构函数也是被删除的(无法访问的),则该移动成员也将是被删除的。
下面的例子中,B
中的拷贝构造函数被删除,导致了派生类中 D
中的拷贝构造函数,以及使用该拷贝构造函数的移动成员都被删除:
class B {
public:
B();
B(const B&) = delete;
// other members, not including a move constructor
};
class D : public B {
// no constructors
};
D d; // ok: D's synthesized default constructor uses B's default constructor
D d2(d); // error: D's synthesized copy constructor is deleted
D d3(std::move(d)); // error: implicitly uses D's deleted copy constructor
这种情况下,由于 B
定义了拷贝构造函数,B
不会合成移动构造函数。因此 B
不能被拷贝和移动,这也导致 D
的对象也是无法被拷贝和移动的。D
需要通过自定义拷贝 / 移动构造函数来完成这些操作(函数需要处理基类部分)
通常情况下,如果基类中没有可用的拷贝控制成员,则派生类中也不会有。
由于多数基类都会定义虚析构函数, 基类中通常不会有合成移动操作;这也同时会影响到派生类。为此,基类中通常会定义移动操作(如果移动操作有意义的话),来为其派生类提供移动操作。比如我们的 Quote
类中,就可以进行显式的移动成员定义:
class Quote {
public:
Quote() = default; // memberwise default initialize
Quote(const Quote&) = default; // memberwise copy
Quote(Quote&&) = default; // memberwise move
Quote& operator=(const Quote&) = default; // copy assign
Quote& operator=(Quote&&) = default; // move assign
virtual ~Quote() = default;
// other members as before
};
由于基类中定义了拷贝/移动控制成员,该基类以及其派生类都会支持这些操作。
派生类中的拷贝控制成员分为两类:
定义派生类的拷贝 / 移动构造函数时,需要显式的委托基类中对应的拷贝 / 移动构造函数,以此来初始化派生类对象中的基类部分:
class Base { /* ... */ } ;
class D: public Base {
public:
// by default, the base class default constructor initializes the base part of an object
// to use the copy or move constructor, we must explicitly call that
// constructor in the constructor initializer list
D(const D& d): Base(d) // copy the base members
/* initializers for members of D */ { /* ... */ }
D(D&& d): Base(std::move(d)) // move the base members
/* initializers for members of D */ { /* ... */ }
};
被用于初始化的派生类对象 d
,其内部的基类部分被用于初始化 D
类型的新对象中的基础部分(传递给 Base&
)。基类的拷贝 / 移动构造函数会拷贝 / 移动这部分内容用于初始化。// probably incorrect definition of the D copy constructor
// base-class part is default initialized, not copied
D(const D& d) /* member initializers, but no base-class initializer */
{ /* ... */ }
这种情况下,D
类型的新对象中的两个部分:
这样得到的结果是一个只拷贝了一半的对象,显然是不合理的。
派生类拷贝 / 移动构造函数必须显式的委托基类中对应的构造函数进行基类部分的构造。
派生类的赋值过程分为两个份:
// Base::operator=(const Base&) is not invoked automatically
D &D::operator=(const D &rhs)
{
Base::operator=(rhs); // assigns the base part
// assign the members in the derived class, as usual,
// handling self-assignment and freeing existing resources as appropriate
return *this;
}
无论基类中的赋值运算符是合成的还是自定义的,都可以通过以 Base::operator=(rhs);
的方式调用。
当析构函数的函数体执行完毕后,类成员会被隐式的自动销毁。因此,派生类的析构函数不需要对基类部分进行额外的销毁工作;也就是说派生类的析构函数只负责释放被派生类申请的资源(成员):
class D: public Base {
public:
// Base::~Base invoked automatically
~D() { /* do what it takes to clean up derived members */ }
};
与一般析构函数的销毁顺序类似,派生类对象的销毁过程也是反向的,会先运行派生类的析构函数,再运行基类的析构函数。
在对派生类对象进行构建与销毁中,基类部分与派生类部分是分别进行的;而在此期间,被构建的对象属于一种不完全的状态。以构造函数为例,其基类初始化完毕,但派生类还没有初始化。
由于这种不完全的状态并不满足动态绑定的要求,因此编译器将该处于不完全状态的对象的类型,视作与其构造函数一个类型。也就是说,派生类对象在构造过程中,如果没有完成派生类成员的初始化,则此时该对象会被视作基类类型的对象。
而当我们在派生类成员被构造之前尝试调用虚函数;具体的来说,在基类的构造函数中调用虚函数,会导致该调用的绑定不再是动态绑定,而直接会绑定到与构造函数类型相同的对象上(基类类型上)。此时对虚函数的调用,实际上是在调用基类中的虚函数版本。来看下面的例子:
class Dog
{
public:
Dog() {
cout<< "Constructor called" <<endl;
bark() ;
}
~Dog() { bark(); }
virtual void bark() { cout<< "Virtual method called" <<endl; }
void seeCat() { bark(); }
};
class Yellowdog : public Dog
{
public:
Yellowdog() { cout<< "Derived class Constructor called" <<endl; }
void bark() { cout<< "Derived class Virtual method called" <<endl; }
};
int main()
{
Yellowdog d;
d.seeCat();
}
基类的构造函数 Dog()
调用了虚函数 bark()
,但由于此时派生类对象并没有完成构造,因此调用的是 Dog::bark()
。而当派生类对象 d
构造完毕之后,再对 bark()
进行调用(使用 seeCat()
),此时则进行了动态绑定,d.seeCat()
调用了 Yellowdog::bark()
。因此,整个程序的输出应该为:
Constructor called //Dog::cstr
Virtual method called //Dog::bark() in Dog::cstr
Derived class Constructor called // Yellowdog::cstr
Derived class Virtual method called //Yellowdog::bark()
Virtual method called //Dog::bark() in Dog::dstr
这种行为也很好解释:如果允许未完成对象进行动态绑定,则实际上是允许对未初始化成员的访问;这种访问很可能会导致程序的崩溃。
为什么要在派生类移动成员中使用 std::move()
比如下面的:
Disc_quote(Disc_quote&& rhs) : Quote(std::move(rhs))
第一眼看上去很奇怪,为什么参数命名是右值,却仍然要使用 std::move()
再转换一遍?这是因为所有有名字的右值都被当做左值处理。因此 rhs
在此处实际上是左值。virtual ~Disc_quote() = 0;
而不进行类外定义的话,会出现如下错误:
undefined reference to `Disc_quote::~Disc_quote()'
这是因为,即便抽象基类不需要实体化,也需要提供一个析构函数供派生类销毁属于该抽象基类中的资源。C++11 中提供了让派生类继承直接基类的构造函数的方法,格式如下:
using derived(parms) : base(args) { }
该方法使用 using
关键字。普通情况下,using
的使用是为了让某个名字在当前作用域可见;但当 using
应用到构造函数上时,则代表了当前派生类会继承基类中的构造函数进行使用。比如下面的例子:
class Bulk_quote : public Disc_quote {
public:
using Disc_quote::Disc_quote; // inherit Disc_quote's constructors
double net_price(std::size_t) const;
};
如果派生类拥有属于自己的成员,那么这些成员会进行默认初始化。
通过作业中的实际测试,如果只是单纯的使用继承的构造函数,是无法对这些派生类的成员进行初始值设定的。
该方法实际等同于:
Bulk_quote(const std::string& book, double price,
std::size_t qty, double disc):
Disc_quote(book, price, qty, disc) { }
在继承的过程中:
explicit
和 constexpr
的修饰默认情况下,构造函数的继承会将基类中所有的构造函数继承到派生类中。但有几个例外:
基类构造函数中的默认参数不会被继承。这种情况下,该基类构造函数可能会被分为多个构造函数分别继承。总的来说可以分为两部分:
下面是一个测试的例子:
struct Base
{
Base(int a, int b = 1, int c = 2):x(a), y(b), z(c) {}
int x {0};
int y {0};
int z {0};
};
struct D :public Base
{
using Base::Base;
void print() {std::cout << x << " " << y << " " << z << std::endl; }
};
int main(int argc, char const *argv[])
{
D d1(1, 2, 3);
d1.print();
D d2(4,5);
d2.print();
D d3(6);
d3.print();
输出为:
1 2 3
4 5 2
6 1 2
当继承的构造函数中参数数量小于基类构造函数时,就会有带默认值的参数被省略。而该默认值会作为被构造对象的初始值。被省略的参数都是带有默认值的;被省略的方向是从右到左。
基类的类型与派生类的类型不同,而容器只允许指定单个类型。因此,如果希望将存在继承关系的对象同时存入某个容器时,必须采用间接访问的办法。如果直接进行存储,那么将会导致派生类对象被转换为基类对象,且派生类部分的成员会丢失。比如下面的例子:
vector<Quote> basket;
basket.push_back(Quote("0-201-82470-1", 50));
// ok, but copies only the Quote part of the object into basket
basket.push_back(Bulk_quote("0-201-54848-8", 50, 10, .25));
// calls version defined by Quote, prints 750, i.e., 15 * $50
cout << basket.back().net_price(15) << endl;
当 push_back()
的操作完成之后,被操作的对象被转换为了 Qoute
类型,并丢失了折扣计件数量以及折扣率两个派生类中的成员。
我们可以通过间接的方式(指针),也就是定义一个存储智能指针的容器(通常)来解决与继承相关的对象的存储问题。由于多态的存在,这些指针指向对象的动态类型可以是基类,也可以是派生类:
vector<shared_ptr<Quote>> basket;
basket.push_back(make_shared<Quote>("0-201-82470-1", 50));
basket.push_back(
make_shared<Bulk_quote>("0-201-54848-8", 50, 10, .25));
// calls the version defined by Quote; prints 562.5, i.e., 15 * $50 less the discount
cout << basket.back()->net_price(15) << endl;
当 push_back()
的操作完成之后,智能指针的类型从 shared_ptr<Bulk_qoute>
转变为了 shared_ptr<Quote>
;但由于此时该智能指针指向的依然是 Bulk_quote
类型的对象,因此当使用该指针访问 net_pirce()
成员的时,对象的动态类型是 Bulk_quote
,因此访问的成员是 Bulk_quote::net_price()
。
由上一节的内容得知,使用指针作为管理容器的中对象的工具是非常方便的事情。本节中,我们可以通过利用 multiset 和 shared_ptr 的组合,来达到管理,并能够对所有书籍的出售情况进行统计的功能。
在这个实例中,我们选择 multiset 来作为书籍管理的容器。这是因为:
Quote
对象,也就是多个销售实例该容器的实现如下:
// function to compare shared_ptrs needed by the multiset member
static bool compare(const std::shared_ptr<Quote> &lhs,
const std::shared_ptr<Quote> &rhs)
{ return lhs->isbn() < rhs->isbn(); }
// multiset to hold multiple quotes, ordered by the compare member
std::multiset<std::shared_ptr<Quote>, decltype(compare)*>
items{compare};
multiset items
的 key 类型是我们用于管理销售实例 Quote
对象的智能指针。之后的类型 decltype(compare)*
意味着我们指定了函数 compare()
作为 items
元素的自定义排序规则。该函数按照实例中书籍的 ISBN 进行排序。需要注意的是,该函数被定义为静态成员函数,以便所有的容器 set 共享该规则。
由于静态成员函数的可见范围是 file scope, 因此其定义必须处于其声明所在文件中。
ref: Static functions outside classes
再回过来看 items
的定义,该 set 采用了如下的构造函数来定义:
explicit multiset( const Compare& comp,
const Allocator& alloc = Allocator() );
很显然,{ compare }
是为 item
的规则提供 argument。一些参考:
基本容器选择好之后,我们需要为 Basket
类提供两个功能:
Quote
对象到 items
中的功能 add_item()
total_receipt()
由于 items
通过智能指针来管理 Quote
对象,因此 add_item()
需要接受类型为 Quote
的智能指针,并使用成员 insert()
将其添加到 items
中:
void add_item(const std::shared_ptr<Quote> &sale)
{ items.insert(sale); }
在使用的时候,只需要使用对象调用该函数即可。该函数的初始值可以通过 make_shared
生成:
Basket bsk;
bsk.add_item(make_shared<Quote>("123", 45));
bsk.add_item(make_shared<Bulk_quote>("345", 45, 3, .15));
另外一个小关注点,作者在传递 shared_ptr 的时候使用了引用的方式。可以看看相关的讨论:
C++ - passing references to std::shared_ptr or boost::shared_ptr
total_receipt()
成员需要做两件事:
items
,查询所有的书籍实现如下:
double Basket::total_receipt(ostream &os) const
{
double sum = 0.0; // holds the running total
// iter refers to the first element in a batch of elements with the same ISBN
// upper_bound returns an iterator to the element just past the end of that batch
for (auto iter = items.cbegin();
iter != items.cend();
iter = items.upper_bound(*iter)) {
// we know there's at least one element with this key in the Basket
// print the line item for this book
sum += print_total(os, **iter, items.count(*iter));
}
os << "Total Sale: " << sum << endl; // print the final overall total
return sum;
}
这里使用了成员 upper_bound()
来设定循环条件:
iter = items.upper_bound(*iter)
当当前循环结束之后,upper_bound()
会自动跳到下个 key 的第一个位置(如果存在的话),也就是下一本书的第一个销售记录。由此可以看出,该循环是以所有相同 key 为单位的;也就是说,一次循环包括了所有 ISBN 相同的销售记录,也就是所有同一本书的销售记录。
print_total()
函数对当前书的所有销售记录进行累加:
sum += print_total(os, **iter, items.count(*iter));
有两点要注意:
print_total
的参数要求是 Quote&
,iter
代表的是当前迭代器,指向的是智能指针,因此需要解引用两次才能得到 Quote
对象count(*iter)
计算的是当前的 key 有几个元素,也就是当前的书总共卖掉了多少本。将该值传递进 print_total()
中,即可计算出当前书的销售总额(调用了 net_price()
计算)
之前的实现中,add_item()
接受智能指针参数。这样做并不是很方便,因为针对不同的售书实体需要指定不同类型的智能指针:
sk.add_item(make_shared<Quote>("123", 45));
bsk.add_item(make_shared<Bulk_quote>("345", 45, 3, .15));
我们希望将其改造为接受 Quote
对象,也就是类似拷贝构造函数的方式来添加实体到 items
中:
void add_item(const Quote& sale); // copy the given object
void add_item(Quote&& sale); // move the given object
但这样做会带来一个问题。由于 new
只能为我们指定的类型 Quote
分配空间,因此,当以 Bulk_quote
类型对象作为参数时,我们只能得到一个以该对象基类部分为内容的 Quote
对象。
由于 new
不能直接对派生类对象进行正确的空间分配,我们采用虚函数接口的方式来实现该功能。也就是说,创建一个虚函数,该虚函数在不同的类下有不同的空间分配方法。我们将该函数命名为 clone
。该函数需要在 Quote
与 Bluk_Quote
中有分别的实现:
class Quote {
public:
// virtual function to return a dynamically allocated copy of itself
// these members use reference qualifiers; see §13.6.3 (p. 546)
virtual Quote* clone() const & {return new Quote(*this);}
virtual Quote* clone() &&
{return new Quote(std::move(*this));}
// other members as before
};
class Bulk_quote : public Quote {
Bulk_quote* clone() const & {return new Bulk_quote(*this);}
Bulk_quote* clone() &&
{return new Bulk_quote(std::move(*this));}
// other members as before
};
而通过重写 clone()
,我们就能使用同一个 add_item()
来对不同的类分配空间了:
class Basket {
public:
void add_item(const Quote& sale) // copy the given object
{ items.insert(std::shared_ptr<Quote>(sale.clone())); }
void add_item(Quote&& sale) // move the given object
{ items.insert(
//named rvalue is treated as lvaule, so std::move here.
std::shared_ptr<Quote>(std::move(sale).clone())); }
// other members as before
};
clone()
会返回一个指向当前类型对象的指针,而该指针被用于初始化对应的智能指针。由于该指针是临时变量,因此也不用担心野指针的问题。同时,也因为智能指针支持派生类到基类的转换,因此将 Bulk_qoite*
类型的指针绑定到 shared_ptr<Quote>
类型智能指针也是合理的。add_item()
的不同版本适配, clone()
使用了引用限定符 &
和 &&
,这将强制 add_item()
根据参数类型的不同选择拷贝或者移动的方式来完成实例的添加。
相较于之前使用单个关键词进行查询的 TextQueries 实例,本章的实例要求提供使用关键词的逻辑表达式来作为查询的关键词。比如如下的查询方式,且支持这些查询方式的组合使用:
# ~Negation operator, yield lines that don’t match the query
~(Alice)
# | Or operator, return lines matching either of two queries
hair | Alice
# & and operator, return lines matching both queries
hair & Alice
输出与之前的实例相同,输出结果为关键词出现的次数,以及所在行的打印:
Executing Query for: ((fiery & bird) | wind)
((fiery & bird) | wind) occurs 3 times
(line 2) Her Daddy says when the wind blows
(line 4) like a fiery bird in flight.
(line 5) A beautiful fiery bird, he tells her,
通过仔细观察,我们发现这些运算实际上都可以以对象的形式来呈现(感觉有点像 functor)。具体如下:
WordQuery // Daddy
NotQuery // ~Alice
OrQuery // hair | Alice
AndQuery // hair & Alice
以对象的方式来表示运算的过程意味着:
从实现上来讲,有两个函数需要动态绑定:
eval()
,接收的 TextQuery 对象,返回 QueryResult 对象,也就是根据已有字典查询,查询的结果经过处理,输出为 QueryResult 对象做最后处理rep()
,将返回一个 string 用于表示输出结果中的关键词(也就是上例子中 occur 后的那一行表达式)recover: TextQuery 对象是从导入的数据中的词为单位,并存储这些词所在对应行的数据对象;同时,TextQuery 带有成员函数 query() 用于对单个关键词进行输出。 QueryResult 存储的是指定的关键词,以及其在 TextQuery 中对应的行信息。
而最后的打印结果,将通过调用 QueryResult 对象配套的 print()
( 作业使用了 «
重载实现),通过读取 QueryResult 中的行信息,来打印出对应的行内容。
~
运算与其他运算不太一样。~
运算是打印出没有关键词的行;如果按照正常的逻辑,存储关键词所在的行号的话,该运算是无法得出结果的,因为结果并不存在。解决方案是,修改 NotQuery
的 eval()
执行方式,将正常查询结果的内容从总行数中剔除;剩下的结果就是对该关键词查询求反的结果了。
通过上面的信息可以发现,这些用于表示关键词组合的表达式对象可以完全表现为,以单个关键词为基础的查询输出结果(行号 set)的组合关系。具体的来说:
eval()
进行组合,得到最终的行号
根据这样的思路,我们定义一个抽象类 Query_base
;其他所有的具体类都将基于该抽象类实现。
需要注意的是,逻辑运算的算子数量要求存在不同。对于要求双算子的两种运算 &
和 |
,给予一个额外的抽象类 BinaryQuery
有助于简化这两个类的实现:
<html>
<img src=“/_media/programming/cpp/cpp_primer/query_hierarchy.svg” width=“400”>
</html>
关键概念:组合与继承
在类设计中有两个关键的关系:是(Is-A)和拥有 (Has-A)。派生类与基类应该体现出派生类是 (Is-A) 一个基类的关系;而 Has-A 更加体现了成员(member)的概念,比如 Bulk_quote
的成员拥有(Has-A) ISBN()
.
接口类是一个很重要的概念(也就是我们经常提到的句柄类(handle class))。
在应用中,查询是以表达式的形式出现的。如果在表达式中直接使用已有类类型的对象作为算子,显然是很不方便的。为解决这个问题,我们希望用一个接口类(Interface class)Query
来管理层级中所有会用到的类。实现上来讲,Query
需要有三个成员:
Query_base
的指针,用于绑定派生类对象(推荐使用智能指针,方便内存管理)eval()
的定义,通过指针成员访问该函数可以达到动态绑定的效果rep()
的定义,同上
由于我们希望使用者只基于 Query
来描述整个表达式,因此 Query
还需要有能够创建不同派生类对象的手段。根据逻辑运算的定义,我们可以重载三种不同的逻辑运算,这些运算会创建并返回一个绑定了派生类对象的 Query
对象:
&
会绑定 AndQuery
至 Query
|
会绑定 OrQuery
至 Query
~
会绑定 NotQuery
至 Query
除此之外,单个关键词的对象类型 WordQuery
作为表达式的基础部分,需要可以使用 Query
的构造函数直接创建。
根据之前的内容,我们的表达式的最终结果,实质上是不同单个关键词查询结果的逻辑运算。因此,一个查询表达式的执行,实际上是在以 WordQuery
为单位构建执行的过程:
而具体执行的过程,也是按照这个树的顺序进行调用,直到达到最基本的单位时得出查询结果后,再依次返回上级,进行最后的结果的运算(类似递归的概念)。
几个要点:
Query_base
作为抽象类,其实现需要定义两个纯虚函数:eval()
和 rep()
。Query
(虚函数的要求)。protected
下Query
对其访问的权限
// abstract class acts as a base class for concrete query types; all members are private
class Query_base {
friend class Query;
protected:
using line_no = TextQuery::line_no; // used in the eval functions
virtual ~Query_base() = default;
private:
// eval returns the QueryResult that matches this Query
virtual QueryResult eval(const TextQuery&) const = 0;
// rep is a string representation of the query
virtual std::string rep() const = 0;
};
首先,由于 Query
使用指针惯例 Query_base
以及其派生类,Query
需要拥有指针数据成员,以及对其初始化的构造函数。该构造函数会初始化一个指向抽象类类型的指针,因此被定义为私有成员。
private:
Query(std::shared_ptr<Query_base> query): q(query) { }
std::shared_ptr<Query_base> q;
其次,由于逻辑运算符需要借助 Query
创建并绑定对应对象,那么实际上返回的是一个已经绑定好对应对象的,但类型是 Query_base
的指针。该指针需要通过上述的私有构造函数完成对应 Query
的构造,因此需要在 Query
内部提供访问权限。Query
内部也需要通过重写 eval()
与 rep
来调用不同的派生类版本,具体是那个取决于 q
指向的对象:
QueryResult eval(const TextQuery &t) const { return q->eval(t); }
std::string rep() const { return q->rep(); }
大致的实现如下:
// interface class to manage the Query_base inheritance hierarchy
class Query {
// these operators need access to the shared_ptr constructor
friend Query operator~(const Query &);
friend Query operator|(const Query&, const Query&);
friend Query operator&(const Query&, const Query&);
public:
Query(const std::string&); // builds a new WordQuery
// interface functions: call the corresponding Query_base operations
QueryResult eval(const TextQuery &t) const
{ return q->eval(t); }
std::string rep() const { return q->rep(); }
private:
Query(std::shared_ptr<Query_base> query): q(query) { }
std::shared_ptr<Query_base> q;
};
需要注意的是,任何需要用到之后类类型具体定义的函数,都只能在次做出声明,比如构造 WordQuery
的函数。
Query
的输出通过指针访问对应的 rep()
版本即可。实际上,Query
通过了 Query_base
进行了动态绑定:
std::ostream &
operator<<(std::ostream &os, const Query &query)
{
// Query::rep makes a virtual call through its Query_base pointer to rep()
return os << query.rep();
}
之前提到,派生类对象是通过 Query
指向 Query_base
的指针来管理的。除开基本单位 WordQuery
可以使用 string 直接初始化,其他的类对象都需要使用指针初始化,从而来保证逻辑运算可以在任意的两个派生类之间执行(或是单个派生类,比如 ~
)。
除此之外,派生类也需要自身的 eval()
和 rep()
版本来输出最终的查询结果。
WordQuery
作为基本单位,需要实现几个功能:
TextQuery
的成员 query
,因此该类 eval()
只需要做查询,而 rep()
只需要打印关键词即可。
class WordQuery: public Query_base {
friend class Query; // Query uses the WordQuery constructor
WordQuery(const std::string &s): query_word(s) { }
// concrete class: WordQuery defines all inherited pure virtual functions
QueryResult eval(const TextQuery &t) const
{ return t.query(query_word); }
std::string rep() const { return query_word; }
std::string query_word; // word for which to search
};
当 WordQuery
定义完毕之后,Query
中对应管理 wordQuery
初始化的构造函数也可以实现了:
inline Query::Query(const std::string &s): q(new WordQuery(s)) { }
NoQuery
的设计要点与 WordQuery 类似,最终也需要通过来自 Query_base
的指针进行初始化。因此,主要的设计点有:
Query_base
的指针;但由于我们使用 Query
代管,因此此处的数据成员类型为 Query
,之后会使用 Query
使用指针的方式间接的进行冬天胖丁。eval()
的实现:该实现牵涉到求单个查询结果的差集,放到后面再说rep()
的实现:这里就可以看出使用 Query
代管的思路了:Query
类型的成员对其成员 Query::rep()
进行访问。需要注意的是,这一步是对象访问成员函数,不是 virtual call。 Query
中的实现是 q→rep()
,此时通过 Query_base
虚调用了对应的 rep()
。(一连串的调用,最终实现会使用 WordQuery
)
class NotQuery: public Query_base {
friend Query operator~(const Query &);
NotQuery(const Query &q): query(q) { }
// concrete class: NotQuery defines all inherited pure virtual functions
std::string rep() const {return "~(" + query.rep() + ")";}
QueryResult eval(const TextQuery&) const;
Query query;
};
其对应的运算符重载的实现如下:
inline Query operator~(const Query &operand)
{
return std::shared_ptr<Query_base>(new NotQuery(operand));
}
NotQuery
以外,剩余的 AndQuery
和 OrQuery
都需要两个算子进行运算(初始化)。因此需要有 2 个 Query
对象作为数据成员AndQuery
与 OrQuery
的运算符号不同,需要一个 string 来存放对应符号AndQuery
与 OrQuery
的 rep()
可以公用一套 rep()
,只需要指定不同的运算符号即可。eval()
并不能用一套方案,因此 BinaryQuery
不做实现;而 BinaryQuery
需要被设计为抽象类,继承 Query_base::eval()
可以保证这一点。
class BinaryQuery: public Query_base {
protected:
BinaryQuery(const Query &l, const Query &r, std::string s):
lhs(l), rhs(r), opSym(s) { }
// abstract class: BinaryQuery doesn't define eval
std::string rep() const { return "(" + lhs.rep() + " "
+ opSym + " "
+ rhs.rep() + ")"; }
Query lhs, rhs; // right- and left-hand operands
std::string opSym; // name of the operator
};
BinaryQuery
,其构造函数均使用 BinaryQuery()
的构造函数,只是在调用的时候会指定不同的运算符号eval()
和对应的运算符重载rep()
直接继承自 BinaryQuery
,而通过 BinaryQuery
中的调用,会再次回到 Query
中的虚调用。
class AndQuery: public BinaryQuery {
friend Query operator& (const Query&, const Query&);
AndQuery(const Query &left, const Query &right):
BinaryQuery(left, right, "&") { }
// concrete class: AndQuery inherits rep and defines the remaining pure virtual
QueryResult eval(const TextQuery&) const;
};
inline Query operator&(const Query &lhs, const Query &rhs)
{
return std::shared_ptr<Query_base>(new AndQuery(lhs, rhs));
}
class OrQuery: public BinaryQuery {
friend Query operator|(const Query&, const Query&);
OrQuery(const Query &left, const Query &right):
BinaryQuery(left, right, "|") { }
QueryResult eval(const TextQuery&) const;
};
inline Query operator|(const Query &lhs, const Query &rhs)
{
return std::shared_ptr<Query_base>(new OrQuery(lhs, rhs));
}
eval()
函数的主要功能是获取查询表达式在关键词 TextQuery
中的位置。根据表达式的不同,eval()
也需要被重写成几个版本。最简单的 WordQuery
已经实现,其功能就是返回当前关键词的查询结果(行 set)。而之后的几个派生类的查询结果,都是基于该结果进行的求反 / 求并 / 求交 等逻辑操作。
OrQuery
代表查询两个关键词任意一个存在的行 set,因此只需要对两个关键词分别查询,之后将其结果求并集即可。实现中采用了将 lhs.set 插入(insert()
)到 rhs.set 中来完成该运算。运算的结果被存放到 ret_lines
中并返回;返回的是一个 QueryResult
的结果,可以使用之前配套的 Print
函数打印出结果:
// returns the union of its operands' result sets
QueryResult
OrQuery::eval(const TextQuery& text) const
{
// virtual calls through the Query members, lhs and rhs
// the calls to eval return the QueryResult for each operand
auto right = rhs.eval(text), left = lhs.eval(text);
// copy the line numbers from the left-hand operand into the result set
auto ret_lines =
make_shared<set<line_no>>(left.begin(), left.end());
// insert lines from the right-hand operand
ret_lines->insert(right.begin(), right.end());
// return the new QueryResult representing the union of lhs and rhs
return QueryResult(rep(), ret_lines, left.get_file());
AndQuery
流程与 OrQuery
类似。唯一不同的地方在于 AndQuery
代表了 &
的运算,因此求的是两个行 set 的交集。这里使用了算法 set::set_intersection
进行运算:
// returns the intersection of its operands' result sets
QueryResult
AndQuery::eval(const TextQuery& text) const
{
// virtual calls through the Query operands to get result sets for the operands
auto left = lhs.eval(text), right = rhs.eval(text);
// set to hold the intersection of left and right
auto ret_lines = make_shared<set<line_no>>();
// writes the intersection of two ranges to a destination iterator
// destination iterator in this call adds elements to ret
set_intersection(left.begin(), left.end(),
right.begin(), right.end(),
inserter(*ret_lines, ret_lines->begin()));
return QueryResult(rep(), ret_lines, left.get_file());
}
NotQuery
代表的是求反操作 ~
。实现上来说分几步:
ret_lines
) 中书上的算法应该是某种字符串比较的算法,待研究。
// returns the lines not in its operand's result set
QueryResult
NotQuery::eval(const TextQuery& text) const
{
// virtual call to eval through the Query operand
auto result = query.eval(text);
// start out with an empty result set
auto ret_lines = make_shared<set<line_no>>();
// we have to iterate through the lines on which our operand appears
auto beg = result.begin(), end = result.end();
// for each line in the input file, if that line is not in result,
// add that line number to ret_lines
auto sz = result.get_file()->size();
for (size_t n = 0; n != sz; ++n) {
// if we haven't processed all the lines in result
// check whether this line is present
if (beg == end || *beg != n)
ret_lines->insert(n); // if not in result, add this line
else if (beg != end)
++beg; // otherwise get the next line number in result if there is one
}
return QueryResult(rep(), ret_lines, result.get_file());
}