参考资料:
- 《C++ Primer》第5版
- 《C++ Primer 习题集》第5版
15.1 OOP:概述(P526)
**面向对象程序设计(object-oriented programming)**的核心思想是数据抽象、继承和动态绑定。
继承
通过继承(inheritance)联系在一起的类构成一种层次关系,在层次关系根部有一个基类(base class),从基类继承而来的类称为派生类(derived class)。基类负责定义在层次关系中所有类的共同成员,每个派生类定义各自独有的成员。
我们定义一个名为 Quote
的类,表示按原价销售的数据,并将它作为层次关系的基类。Quote
派生出另一个名为 Bulk_quote
的类,表示可以打折销售的书籍:
class Quote {
public:
string isbn() const;
virtual double net_price(size_t n) const;
};
class Bulk_quote :public Quote {
public:
double net_price(size_t n) const override;
};
在 C++ 语言中,基类将类型相关的函数(如 isbn
)与派生类不做改变直接继承的函数(如 net_price
)区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数(virtual function)
派生类必须通过使用类派生列表(class derivation list)明确指出它从哪些类继承而来。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,每个基类前面可以有访问说明符。派生类必须对所有重新定义的虚函数进行声明,派生类可以选择在这样的函数之前加上 virtual
关键字,但不是必须的。C++11 新标准允许派生类显式注明使用哪个成员函数改写基类的虚函数,方式是在函数的形参列表增加 override
关键字。
动态绑定
通过动态绑定(dynamic binding),我们能用同一段代码分别处理 Quote
和 Bulk_quote
的对象:
double print_total(ostream &os,
const Quote &item, size_t n) {
// 调用Quote::net_price或者Bulk_quote::net_price
double ret = item.net_price(n);
os << "ISBN: " << item.isbn()
<< " # sold: " << n << "total due: " << ret << endl;
return ret;
}
对于上面的函数,由于其 item
形参是基类 Quote
的一个引用,所以我们既能使用 Quote
对象,也能使用 Bulk_quote
对象调用该函数;因为 print_total
使用引用类型调用 net_price
,所以实际传入 print_total
的对象类型将决定执行 net_price
的哪个版本。
在上述过程中,函数的运行版本由实参决定,即在运行时选择函数版本,所以动态绑定有时也称为运行时绑定。
15.2 定义基类和派生类(P527)
15.2.1 定义基类(P528)
我们首先完成 Quote
类的定义:
class Quote {
public:
Quote() = default;
Quote(string &book, double sales_price):
bookNo(book), price(sales_price) { }
string isbn() const { return bookNo; }
virtual double net_price(size_t n) const
{ return n * price; }
virtual ~Quote() = default;
private:
string bookNo; // 书籍的ISBN编号
protected:
double price = 0.0; // 书籍的原价
};
基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作。
成员函数与继承
当我们使用引用或指针调用虚函数时,该调用将被动态绑定。除构造函数外的任何非静态成员函数都可以是虚函数,关键字 virtual
只能出现在类内部的声明语句之前。如果基类将一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
成员函数如果没有被声明成虚函数,则解析过程发生在编译时而非运行时。
访问控制和继承
派生类继承定义在基类中的成员,但派生类的成员函数不一定有权访问从基类继承而来的成员。有些时候,我们希望基类中的某些成员可以被派生类访问,而不能被其他用户访问,用 protected
访问说明符可以达到这个效果。
我们希望 Quote
的派生类定义各自的 net_price
函数,因此派生类需要访问 Quote
的 price
成员,所以我们将 price
定义成 protected
的。
15.2.2 定义派生类(P529)
class Bulk_quote :public Quote {
public:
Bulk_quote() = default;
Bulk_quote(const string &book, double p,
size_t qty, double disc):
Quote(book, p), min_qty(qty), discount(disc) { }
double net_price(size_t n) const override;
private:
size_t min_qty = 0; // 适用折扣的最小购买量
double discount = 0.0; // 折扣额
};
double Bulk_quote::net_price(size_t n) const {
if (n >= min_qty) {
return n * (1 - discount) * price;
}
else {
return n * price;
}
}
Bulk_quote
从 Quote
继承了 isbn
函数和 bookNo
、price
等数据成员,还定义了自己版本 net_price
,同时增加了两个新的数据成员 min_qty
和 discount
。
我们可以将公有派生类型的对象绑定到基类的引用或指针上。
派生类中的虚函数
如果派生类没有覆盖基类中的虚函数,则派生类会直接继承其在基类中的版本。
派生类对象及派生类向基类的类型转换
一个派生类对象包含多个组成部分:一个含有派生类自己定义的(非静态)成员的子对象,以及一个与基类对应的子对象,如果有多个基类,则这样的子对象也有多个。C++ 标准没有规定派生类的对象在内存中如何分布,但我们这样认为:
因为派生类对象中含有基类的部分,所以我们可以把派生类的对当成基类对象来使用:
Quote item;
Bulk_quote bulk;
Quote *p = &item;
p = &bulk;
Quote &r = bulk;
这种转换称为**派生类向基类(derived-to-base)**的转换,编译器会隐式地执行这种转换。
派生类构造函数
尽管派生类对象中含有从基类继承而来的成员,但派生类并不能直接初始化这些成员,而是必须使用基类的构造函数来初始化它的基类部分。
每个类控制自己的成员初始化过程
Bulk_quote
的构造函数先执行 Quote
的构造函数,然后再初始化自己定义的 min_qty
和 discount
成员。
派生类使用基类的成员
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。假设某静态成员是可访问的,则我们既能通过基类使用它,也能通过派生类使用它:
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 Base { /* ... */ };
class D1: public Base { /* ... */ };
class D2: public D1 { /* ... */ };
在这个继承关系中,Base
是 D1
的直接基类(direct base),同时是 D2
的间接基类(indirect base)。
防止继承的发生
有时我们会定义这样一种类,不希望其他类继承它。C++11 新标准提供了一种防止继承的方法,即在类名后面跟一个关键字 final
:
class NoDerived final { /* ... */ }; // NoDerived不能作为基类
class Bad : NoDerived { /* ... */ }; // 错误,NoDerived是final的
15.2.3 类型转换与继承(P534)
理解基类和派生类之间的类型转换是理解 C++ 语言面向对象编程的关键所在。
可以将积累的指针或引用绑定到派生类对象有一层极为重要的意义:当时使用基类的指针或引用时,实际上我们并不清楚这个指针或引用所绑定对象的真实类型。
静态类型和动态类型
当我们使用存在继承关系的类型时,必须将一个变量或表达式的静态类型(static type)和动态类型(dynamic type)区分开来。静态类型在编译时总是已知的,而动态类型直到运行时才可知。
例如,当 print_total
调用 net_price
时:
double ret = item.net_price(n);
item
的静态类型是 Quote&
,它的动态类型依赖于 item
绑定的实参。如果我们传递给 print_total
一个 Bulk_quote
对象,那么 item
的静态类型将与它的动态类型不一致。
不存在基类向派生类的隐式类型转换
不存在基类向派生类的自动类型转换:
Quote base;
Bulk_quote *bulkP = &base; // 错误
Bulk_quote &bulkR = base; // 错误
即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换:
Bulk_quote bulk;
Quote *itemP = &bulk;
Bulk_quote *bulkP = itemP; // 错误
编译器只能通过检查指针或引用的静态类型来推断转换是否合法。如果基类中至少包含一个虚函数,我们可以使用 dynamic_cast
请求一个类型转换,该转换的安全性检查将在运行时执行:
Bulk_quote *bulkP = dynamic_cast<Bulk_quote*>(itemP); // 正确
如果已知某个基类向派生类的转换是安全的,则我们可以使用 static_cast
来强制覆盖编译器的检查工作。
对象之间不存在类型转换
派生类向基类的自动类型转换只对指针或引用有效,在派生类类型和基类类型之间不存在这样的转换。
Bulk_quote bulk;
Quote item(bulk); // 使用Quote::Quote(const Quote&)
item = bulk; // 使用Quote::operator=(const Quote&)
当我们用一个派生类对象为一个基类对象初始化或赋值时,派生类对象中只有基类部分会被拷贝、移动、赋值,它的派生类部分会被忽略掉。
15.3 虚函数(P536)
我们知道,使用基类的引用或指针调用一个虚函数成员时,会执行动态绑定。由于我们直到运行时在确定到底调用哪个版本的虚函数,所以在调用前所有虚函数都必须有定义。
对虚函数的调用可能在运行时才被解析
需要强调的是,动态绑定只有当我们通过指针或引用调用虚函数才会发生。如果我们用一个普通对象调用虚函数,在编译时就会将调用的版本确定下来。
派生类中的虚函数
一旦某个函数被声明称虚函数,则在所有派生类中它都是虚函数。
一个派生类的函数如果覆盖了某个继承而来的虚函数,则它的形参类型必须与被它覆盖的基类函数完全一致。同样,派生类中虚函数的返回类型也必须与基类函数相同。该规则存在一个例外,当类的虚函数返回类型是类本身的指针或引用时,上述规则无效。
final
和override
说明符
如果派生类中定义了一个函数,该函数与基类中虚函数的名字相同但形参列表不同,这仍然是合法行为,编译器认为这个新函数与基类中的函数是相互独立的。在 C++11 新标准中,我们可以使用 override
关键字说明派生类中的虚函数,如果我们用 override
标记了某个函数,但该函数没有覆盖已存在的虚函数,此时编译器将报错:
class B {
virtual void f1(int) const;
virtual void f2();
void f3();
};
class D1 : public B {
void f1(int) const override;
void f2(int) override; // B没有形如f2(int)的虚函数
void f3() override; // f3不是虚函数
void f4() override; // B中没有名为f3的函数
};
我们还能把某个函数指定为 final
,一旦某个函数被标记为 final
,则任何覆盖该函数的行为都将引发错误:
class B {
virtual void f1(int) const final;
};
class D1 : public B {
void f1(int) const override; // 无法覆盖final函数
};
override
和 final
都出现在形参列表、const
、引用修饰符、尾置返回类型之后。
虚函数与默认实参
虚函数也可以有默认实参,但在函数调用中,默认实参的值由静态类型决定。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
回避虚函数的机制
在某些情况下,我们不希望对虚函数的调用进行动态绑定,而是强制执行某一特定版本。使用作用域运算符可以达成这一目的:
double undiscounted = baseP->Quote::net_price(42);
上面的调用将在编译时完成解析。
15.4 抽象基类(P540)
假设我们希望扩展前面书店程序的定义,令其支持多种打折策略。每种打折策略都需要一个购买量和一个折扣值,我们可以定义的一个新类 Disc_quote
来支持不同的折扣策略,表示特定打折策略的类将继承自 Disc_quote
并定义自己的 net_price
函数。
Disc_quote
类的 net_price
是没有任何意义的,所以直接继承 Quote
中的 net_price
即可。
由于 Disc_quote
不代表任何一种具体的打折策略,所以我们不希望用户创建 Disc_quote
类型的对象。
纯虚函数
我们可以将 Disc_quote
的 net_price
函数定义成纯虚(pure virtual)函数,明确告诉用户这个函数没有实际意义。和普通虚函数不同,纯虚函数无需定义,在函数体的位置书写 =0
就能将一个虚函数声明成纯虚函数,其中,=0
只能出现在类内部的虚函数声明处:
class Disc_quote :public Quote {
public:
Disc_quote() = default;
Disc_quote(const string &book, double price,
size_t qty, double disc) :
Quote(book, price), quantity(qty),
discount(disc) { }
double net_price(size_t) const = 0;
protected:
size_t quantity = 0; // 折扣适用的购买量
double discount = 0.0; // 表示折扣的小数值
};
我们也可以为纯虚函数提供定义,但函数体必须定义在类的外部。
含有纯虚函数的类是抽象基类
含有(或未经覆盖直接继承)纯虚函数的类是抽象基类(abstract base class)。抽象基类负责定义接口,后续的派生类可以覆盖该接口。
我们不能直接创建一个抽象基类的对象:
Disc_quote discounted; // 错误
派生类构造函数值初始化它的直接基类
我们重新实现 Bulk_quote
:
class Bulk_quote :public Disc_quote {
public:
Bulk_quote() = default;
Bulk_quote(const string &book, double p,
size_t qty, double disc):
Disc_quote(book, p, qty, disc) { }
double net_price(size_t n) const override;
};
这个版本的 Bulk_quote
的直接基类是 Disc_quote
,间接基类是 Quote
。