第十五章 面向对象程序设计
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键
派生类构造函数
尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员。和其他创建了基类对象的代码一样,派生类也必须使用基类的构造函数来初始化它的基类部分。
每个类控制它的成员初始化过程
派生类对象的基类部分与派生类对象自己的数据成员都是在构造函数的初始化阶段执行初始化操作的。类似于我们的初始化成员的过程,派生类构造函数同样是通过构造函数初始化列表来讲实参传递给基类构造函数。例如,接收四个参数的 Bulk_quote
构造函数如下所示:
Bulk_quote(const std::string& book, double p, std::size_t qty, double disc):
Quote(book, p), min_qty(qty), discount(disc) { }
// ...
该函数将它的前两个参数传递给 Quote
的构造函数,由 Quote
的构造函数负责初始化 Bulk_quote
的基类部分。当 Quote
构造函数体结束后,我们构建的对象的基类部分也就完成初始化了。接下来初始化由派生类直接定义的 min_qty
成员和 discount
成员。最后运行 Bulk_quote
的构造函数体
也许这就是为什么不能在构造函数中调用虚函数的原因?会执行两次?
关键概念:遵循基类的接口
必须明确的是:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此
因此,派生类对象不能直接初始化基类的成员。尽管从语法上来说可以在派生类构造函数体内给它的共有或受保护的基类成员赋值,但是最好不要这样做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。
class Base{
public:
static void statemem();
};
class Drived : public Base{
void f(const Derived&);
};
静态成员遵循通用的访问控制规则,如果基类中的成员是 private
的,则派生类无权访问。假设某静态成员是可访问的,则我们既能通过基类使用它也能通过派生类使用它
void Derived::f(const Derived& derived_obj){
Base::statmem(); // 正确:Base定义了 statmem
Derived::statmem(); // 正确:Derived 继承了 statmem
// 正确:派生类对象能访问基类的静态成员
derived_obj.statmem(); // 通过 Derived 对象访问
statmem(); // 通过 this 对象访问
}
防止继承的发生
C++11
新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字 final
class NoDerived final { /* */ }; // NoDerived 不能作为基类
class Base{ /* */ };
// Last 是 final 的; 我们不能继承 Last
class Last final : Base { /* */ }; // Last 不能作为基类
class Bad : NoDerived { /* */ }; // 错误:NoDerived 是 final 的
class Bad2: Last { /* */ }; // 错误:Last 是 final 的
虚函数
动态绑定只有当我们通过指针或引用调用虚函数时才会发生
base = derived; // 把 derived 的 Quote 部分拷贝给 base
base.net_price(20); // 仍调用 Quote::net_price
final 和 override 说明符
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然时合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。就实际的编程习惯而言,这种声明往往意味着发生了错误,因为可能原本希望派生类覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了
我们可以通过 override
标注某个函数,让程序员意图更清晰的同时编译器也可以为我们发现一些错误。如果标记了函数,但该函数没有覆盖已经存在的虚函数,编译器报错
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 没有形如 f2(int) 的函数
void f3() override; // 错误:f3 不是虚函数
void f4() override; // 错误:B 没有名为 f4 的函数
};
除了把类声明为 class NoDerived final { /* */ };
的方式阻止继承外,还可以把某个函数指定为 final
,则之后任何尝试覆盖该函数的操作都将引发错误
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
或引用修饰符)以及尾置返回类型之后
虚函数和默认实参
虚函数也可以拥有默认实参,如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
也就是说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是金磊函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致
抽象基类
纯虚函数
// 用于保存折扣值和购买量的类,派生类使用这些数据可以实现不同的价格策略
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 的函数提供函数体
访问控制与继承
每个类分别控制自己的成员初始化过程,与之类似,每个类害分别控制着其成员对于派生类来说是否 可访问(accessible)
受保护的成员
如前所述,一个类使用 protected
关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。protected
说明符可以看作是 public
和 private
中和的产物
- 和私有成员类似,受保护的成员对于类的用户来说是不可访问的
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的
此外,protected
还有另外一条重要的性质
- 派生类的成员和友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权
class Base{
protected:
int prot_mem;
};
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; }
派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员;对于普通的基类对象中的成员不具有特殊的访问权限
公有、私有和受保护继承
class Base{
public:
void pub_mem():
protected:
int prot_mem;
private:
char priv_mem;
};
struct Pub_Derv : public Base{
// 正确:派生类能访问 protected 成员
int f(){ return prot_mem; }
// 错误:private 成员对于派生类来说是不可访问的
char g() { return priv_mem; }
};
struct Priv_Derv : private Base{
// private 不影响派生类的访问权限
int f1() const { return prot_mem; }
};
派生类访问说明符对于派生类的成员( 及友元 )能否访问其直接基类的成员没有什么影响。对基类成员的访问权限只与基类中的访问说明符有关。Pub_Derv
和 Priv_Derv
都能访问受保护的成员 prot_mem
,同时都不能访问私有成员 priv_mem
派生访问说明符的目的是控制派生类用户( 包括派生类的派生类在内 )对于基类成员的访问权限
Pub_Derv d1; // 继承自 Base 的成员是 public 的
Priv_Derv d2; // 继承自 Base 的成员是 private 的
d1.pub_mem(); // 正确:pub_mem 在派生类中是 public 的
d2.pub_mem(); // 错误:pub_mem 在派生类中是 private 的
派生访问说明符还可以控制继承自派生类的新类的访问权限:
struct Derived_from_Public : public Pub_Derv{
// 正确:Base::prot_mem 在 Pub_Derv 中仍然是 protected
int use_base() { return prot_mem; }
};
struct Derived_from_Private : public Priv_Derv{
// 错误:Base::prot_mem 在 Priv_Derv中是 private 的
int use_base() { return prot_mem; }
};
友元和继承
class Base{
friend class Pal;
protected:
int prot_mem;
};
class Sneaky : public Base{
friend void clobber(Sneaky&); // 能访问 Sneaky::prot_mem
friend void clobber(Base&); // 不能访问 Base::prot_mem
int j; // j 默认为 private
};
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 的友元
};
如前,每个类负责控制自己成员的访问权限,因此尽管看起来有点怪,但是 f3
确实时正确的。Pal
是 Base
的友元,所以 Pal
能访问 Base
对象的成员,这种可访问性包括了 Base
对象内嵌在其派生类对象中的情况
当一个类将另一个类声明为友元时,这种友元关系只对做出声明的类有效。对于原来的类来说,其友元的基类或派生类不具有特殊的访问能力
// D2 对 Base 的 protected 和 private 成员不具有特殊访问能力
class D2 : public Pal{
public:
int mem(Base b)
{ return b.prot_mem; } // 错误:友元关系不能继承
};
不能继承友元关系;每个类负责控制各成员的访问权限
改变个别成员的可访问性
有时会需要改变派生类继承的某个名字的访问级别,通过使用 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;
};
因为 Derived
使用了私有继承,所以继承而来的成员 size
和 n
是 Derived
的私有成员。然后,我们使用 using
声明语句改变这些成员的可访问性。改变之后,Derived
的用户将可以使用 size
成员,而 Derived
的派生类将能使用 n
通过在类的内部使用 using
声明语句,我们可以将该类的直接或简介基类中的任何可访问成员标记出来。using
声明语句中名字的访问权限由该 using
声明语句之前的访问说明负来决定。也就是所,如果一条 using
声明语句出现在类的 private
部分,则改名字只能被类的成员和友元访问;如果出现在 public
部分,则类的所有用户都能访问它;如果位于 protected
部分,则该名字对于成员、友元和派生类是可访问的
默认继承保护级别
class Base { /* ... */ };
struct D1 : Base { /* ... */ }; // 默认 public 继承
class D2 : Base { /* ... */ }; // 默认 private 继承
名字查找先于类型检查
声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义派生类中的而函数也不会重载其基类中的成员。和其他作用域一样,如果派生类的成员与基类的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉
struct Base{
int memfcn();
};
struct Derived : Base{
int memfcn(int); // 隐藏基类的 memfcn
};
Derived d;
Base b;
b.memfcn(); // 调用 Base::memfcn
d.memfcn(10); // 调用 Derived::memfcn
d.memfcn(); // 错误:参数列表为空的 memfcn 被隐藏了
d.Base::memfcn(); // 正确:调用 Base::memfcn
对于派生类派生类的析构函数来说,它除了销毁派生类自己的成员外,还负责销毁派生类的直接基类;该直接基类又销毁它自己的直接基类,以此类推直至继承链的顶端
定义派生类的拷贝或移动构造函数
通常使用对应的基类构造函数初始化对象的基类部分
class Base{ /* */ };
class D : public Base{
// 默认情况下,基类的默认构造函数初始化对象的基类部分
// 要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中
// 显式地调用该构造函数
D(const D& d) : Base(d)
{ /* */ }
D(D&& d) : Base(std::move(d))
{ /* */ }
}:
假如没有提供基类的初始值
// D 的这个拷贝构造函数很可能是不正确的定义
// 基类部分被默认初始化,而非拷贝
D(const D& d) /* 成员初始值,但是没有提供基类初始值 */
{ /*...*/ }
上面的例子中,Base
的默认构造函数将被用来初始化 D
对象的基类部分。假定 D
的构造函数从 d
中拷贝了派生类成员,则这个新构建的对象的配置将非常奇怪:它的 Base
成员被赋予了默认值,而 D
成员的值则是从其他对象拷贝得来的
默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们向拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类地拷贝(或移动)构造函数
派生类赋值运算符
// Base::operator=(const Base&) 不会被自动调用
D& D::operator=(const D& rhs){
Base::operator=(rhs); // 为基类部分赋值
// 按照过去地方式为派生类的成员赋值
// 酌情处理自赋值及释放已有资源等情况
return *this;
}
容器与继承
假定向定义一个 vector
,令其保存用户准备购买的几种书籍。显然不应该用 vector
保存 Bulk_quote
对象。因为我们不能将 Quote
对象转换成 Bulk_quote
,所以我们将无法把 Quote
对象防止在该 vector
中。
其实也不应该使用 vector
保存 Quote
对象。虽然可以把 Bulk_quote
对象防止在容器中,但是这些对象再也不是 Bulk_quote
对象了
vector<Quote> basket;
basket.push_back(Quote("0-201-82470-1", 50));
// 正确:但是只能把对象的 Quote 部分拷贝给 basket
basket.push_back(Bulk_quote("0-201-54848-8", 50, 10, .25));
// 调用 Quote 定义的版本,打印 750,即 15 * $50
cout << basket.back().net_price(15) << endl;
在容器中放置(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,实际上存放的通常时基类的指针。
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));
// 调用 Quote 定义的版本; 打印 562.5,即在 15 * &50 中扣除掉折扣金额
cout << basket.back()->net_price(15) << endl;
编写 Basket 类
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);
}:
定义 Basket 的成员
Basket
类只定义两个操作。第一个成员是我们在类的内部定义的 add_item
成员,该成员接收一个指向动态分配的 Quote
的 shared_ptr
,然后将这个 shared_ptr
放置在 multiset
中。第二个成员的名字是 total_receipt
,它负责将购物篮的内容逐项打印成清单,然后返回购物篮中所有物品的总价格:
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
循环首先定义并初始化 iter
, 令其指向 multiset
的第一个元素。条件部分检查 iter
是否等于 cend()
;如果相等,表明已经处理完了所有的购买记录;否则继续处理下一本书籍
与寻常循环语句以此读取每个元素不同,我们直接令 iter
指向下一个关键字,调用 upper_bound
函数可以令我们跳过与当前关键字相同的所有元素。对于 upper_bound
函数来说,它返回的是一个迭代器,该迭代器指向所有与 iter
关键字相等的元素中最后一个元素的下一个位置。
sum += print_total(os, **iter, items.count(*iter));
printe_total
实参包括一个用户写入数据的 ostream
、一个待处理 Quote
对象和一个计数器。当我们解引用 iter
后将得到一个指向准备打印的对象的 shared_ptr
。使用 multiset
的 count
成员来统计在 multiset
中又多少元素的键值相同。
隐藏指针
Basket
的用户仍然必须处理动态内存,原因是 add_item
需要接收一个 shared_ptr
参数。因此,用户不得不按照如下形式编写代码:
Basket bsk;
bsk.add_item(make_shared<Quote>("123", 45));
bsk.add_item(make_shared<Bulk_quote>("345", 45, 3, .15));
下一步是重新定义 add_item
,使它接收一个 Quote
对象而非 shared_ptr
。新版本的 add_item
将负责处理内存分配,这样他的用户就不必再受困于此。我们将定义两个版本,一个拷贝它给定的对象,另一个采取移动操作
void add_item(const Quote& sale); // 拷贝给定的对象
void add_item(Quote&& sale); // 移动给定的对象
唯一的问题是 add_item
不知道要分配的类型。
new Quote(sale)
这条表达式所作的工作可能不对:new
为我们请求的类型分配内存,因此这条表达式将分配一个 Quote
类型的对象并且拷贝 sale
的 Quote
部分。然而,sale
实际指向的可能是其派生类对象,此时,该对象将被迫切掉一部分
模拟虚拷贝
为了解决上述问题,给 Quote
类添加一个虚函数,该函数i将申请一份当前对象的拷贝
class Quote{
public:
// 该虚函数返回当前对象的一份动态分配的拷贝
virtual Quote* clone() const & { return new Quote(*this); }
virtual Quote* clone() const &&
{ return new Quote(std::move(*this)); }
};
class Bulk_quote : public Quote {
public:
Bulk_quote* clone() const & { return new Bulk_quote(*this); }
Bulk_quote* clone() &&
{ return new Bulk_quote(std::move(*this)); }
// ...
};
因为我们拥有 add_item
的拷贝和移动版本,所以我们分别定义 clone
的左值和右值版本。每个 clone
函数分配当前类型的一个新对象,其中, const
左值引用成员将它自己拷贝给新分配的对象;右值引用成员则将自己移动到新数据中
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())); }
// ...
};