第十五章 面向对象程序设计
概述
面向对象程序设计object-oriented programming
的核心思想是数据抽象、继承和动态绑定:
- 使用数据抽象,我们可以将类的接口与实现分离
- 使用继承可以定义相似的类型并对其相似关系建模
- 使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象
1. 继承
在层次关系根据的类被称为基类bsae class
,其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类derived class
。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自持有的成员。
在
C++
中基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类个自定义适合自身的版本,此时基类就将这些函数声明成虚函数
举例而言,Quote
的对象表示按原价销售的书籍,派生出一个名为Bulk_quote
的类来表示打折销售的书籍。这些类包含两个成员函数:
-
isbn()
:返回书籍的ISBN
编号,不涉及派生类的特殊性 -
net_price(size_t)
:返回书籍的实际价格,是类型相关的,基类希望每个派生类各自定义适合自身的版本,基类就会把这些函数声明成虚函数virtual function
class Quote {
public:
std::string isbn() const;
virtual double net_price(std::size_t n) const;
};
class Bulk_quote : public Quote {
public:
double net_price(std::size_t) const override;
}
派生类必须在其内部对所有重新定义的虚函数进行声明,在这样的函数之前可加可不加
virtual
关键字。
2. 动态绑定
通过动态绑定dynamic binding
我们能用同一段代码分别处理Quote
和Bulk_quote
对象。比如我们根据购买的书籍和购买的数量,打印总的费用:
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() << " # sold: " << n << " total due: " << ret << endl;
}
上述代码中item
形参是基类Quote
的一个引用,我们既可以使用基类Quote
的对象调用该函数,也可以使用派生类Bulk_quote
的对象调用它:
print_total(cout, basic, 20); // basic是Quote类型
print_total(cout, bulk, 20); // bulk是Bulk_Quote类型
在
C++
中,我们使用基类的引用或者指针调用一个虚函数时会发生动态绑定。
定义基类和派生类
1. 定义基类
先完成基类Quote
的定义:
class Quote {
public:
Quote() = dufault;
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; // 代表普通状态下不打折的价格
};
基类通常应该定义一个虚析构函数,即使该函数不执行任何实际操作。
- 派生类可以继承基类的成员,但是对于
net_price
这种与类型相关的操作必须对其重新定义,即派生类需要对这些操作提供自己的新定义以覆盖override
从基类继承而来的旧定义 - 在
C++
中,基类必须把两种成员函数区分开:一种是基类希望其派生函类进行覆盖的函数,另一种是基类希望派生类直接继承而不要改变的函数。前者基类通常将其定义为虚函数virtual
,当我们使用指针或者引用调用虚函数时,该调用将被动态绑定 - 任何构造函数之外的非静态函数都可以是虚函数
- 如果基类把一个函数声明成虚函数,那么该函数在派生类中隐式地也是虚函数
- 成员函数如果没被声明成虚函数,那么其解析过程发生在编译时而不是运行时
派生类可以继承定义在基类中的成员,但是派生类的成员函数不一定有权访问从基类继承而来的成员(派生类可以访问公有成员,但是不能访问私有成员),如果基类希望它的派生类有权访问某成员同时禁止其他用户访问,那么应该用protected
关键字。
2. 定义派生类
class Bulk_quote : public Quote{
public:
Bulk_quote() = default;
Bulk_quote(const std::string&, doubl, std:::size_t, double);
// 对重新定义的虚函数进行声明
double net_price(std::size_t) const override;
private:
std::size_t min_qty = 0; // 适用折扣的最低购买量
double discount = 0.0; // 以小数表示的折扣额
};
- 派生类经常(但不总是)覆盖它集成的虚函数,如果没有覆盖的话,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本
- 派生类可以在它覆盖的函数前适用
virtual
关键字,但不是非得这么做 -
C++11
新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数,做法是添加一个关键字override
2.1 派生类构造函数
尽管在派生类对象中含有从基类继承而来的成员,但是派生类并不能直接初始化这些成员,派生类必须使用基类的个构造函数来初始化它的基类部分。
Bulk_quote(const std::string& book, double p, std::size_t qty, double disc) :
Quote(book, p), min_qty(qty), discount(disc) { }
除非我们特别指出,否则派生类对象的基类部分会像其他数据成员一样执行默认初始化。如果想使用其他的基类构造函数,我们需要以类名加圆括号内的实参列表的形式为构造函数提供初始值。这些实参用于帮助编译器到底应该选择哪个构造函数来初始化派生类对象的基类部分。
首先初始化基类的部分,然后按照声明顺序依次初始化派生类的成员。
2.2 派生类使用基类的成员
派生类可以直接访问基类的公有成员和受保护成员。
每个类负责定义格子的接口,要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。因此派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的共有或受保护的基类成员赋值,但最好不要这么做。
2.3 继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。无论派生出多少个派生类,对于每个静态成员来说都只存在一个唯一的实例。
2.4 防止继承的发生
有时我们会定义这样一个类,不希望其他类继承它,或者不想考虑它是否适合作为一个基类。C++11
新标准允许在类名后跟一个final
来实现防止继承发生的功能:
class NoDerived final { /* */ }; // NoDerived不能作为基类
class Last final : Base { /* */ }; // Last不能作为基类
3. 类型转换与继承
通常情况下,如果我们想把引用或者指针绑定到一个对象上,那么引用或者指针应与对象的类型一致,或者对象的类型含有一个可接受的const
类型转换规则。但是存在继承关系的类是一个重要的例外:我们可以把基类的指针或者引用绑定到派生类对象上。
这意味着当使用基类的引用(或指针)时,实际上我们并不清楚绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。
3.1 静态类型与动态类型
当我们在使用存在继承关系的关系时,必须将一个变量或其他表达式的静态类型与该表达式表示对象的动态类型区分开来。
表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。
如果表达式既不是引用也不是指针,那么它的动态类型永远与静态类型一致。例如Quote
类型的变量
3.2 不存在从基类向派生类的隐式类型转换
之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或者指针可以绑定到该基类部分上,反之不存在从基类向派生类的隐式类型转换。
有一点需要注意的是,即使一个基类指针或者引用绑定在一个派生类对象上,我们也不能执行从基类到派生类的转换:
Bulk_quote bulk;
Quote *itemP = &bulk; // 正确:动态类型是Bulk_quote
Bulk_quote *bulkP = itemP; // 错误:不能将基类转换成派生类
编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。如果基类中含有一个或者多个虚函数,我们可以使用dynamic_cast
请求一个类型转换,该转换的安全检查将在运行时执行。如果我们已知某个基类向派生类的转换是安全的,那么我们可以使用static_cast
来强制覆盖掉编译器的检查工作。
3.3 在对象之间不存在类型转换
派生类向基类的自动类型转换只对指针或者引用类型有效,在派生类类型和基类类型之间不存在这样的转换。
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将会被忽略掉。
3.4 存在继承关系的类型之间的转换规则
要想理解在具有继承关系的类之间发生的类型转换,有三点特别重要:
- 从派生类向基类的类型转换只对指针或者引用类型有效
- 基类向派生类不存在隐式类型转换
- 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变男的不可能
尽管自动类型转换只对指针或者引用生效,但是继承体系中的大多数仍然(显式或者隐式)定义了拷贝控制成员。因此我们通常能够将一个派生类对象拷贝、移动、赋值给一个基类对象,不过这些操作只会处理派生类对象的基类部分。
虚函数
当我们使用基类的引用或者指针调用一个虚成员函数的时候会发生动态绑定,直到运行时我们才能知道到底调用了哪个版本,所以所有的虚函数都必须有定义。
通常情况下如果我们不使用某个函数,那么我们无须为该函数提供定义。但是我们必须为每一个虚函数都提供定义而不管它是否被用到了,这是因为连编译器也无法确定是否会使用到哪个虚函数。
1. 对虚函数的调用可能在运行时才被解析
当某个虚函数通过指针或者引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本呢的函数。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。
必须要搞清楚的是,动态绑定只有当我们通过指针或者调用虚函数时才会发生,也只有在这种情况下对象的动态类型才能可能与静态类型不同。
2. 派生类中的虚函数
一旦某个函数被声明成虚函数,那么在所有派生类中它都是虚函数。一个派生类的函数如果覆盖了某个继承而来的虚函数,那么它的形参类型必须与基类函数完全一致。一般情况下派生类中虚函数返回类型是类本身的指针或引用时,上述规则无效。比如D
由B
派生而来,则基类的虚函数可以返回B*
而派生类的对应函数可以返回D*
。
3. final和override说明符
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器认为新定义的这个函数与基类中原有的函数是相互独立的。
这会带来一个问题:我们原本希望派生类可以覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了就可能带来问题。
C++11
新标准中我们可以使用override
关键字来说明派生类中的虚函数,这样做的好处是使得程序员的意图更加清晰的同时让编译器为我们发现错误。如果我们使用override
标记了某个函数但是该函数没有覆盖已存在的虚函数,此时编译器将报错。
我们还可以把某个函数指定为final
,之后任何尝试覆盖该函数的操作都将引发错误。
4. 虚函数与默认实参
如果某次函数调用使用了默认实参,那么该实参值由本地调用的静态类型决定。即如果我们通过基类的引用或者指针调用函数,则使用基类中定义的默认是残,即使实际运行的是派生类中的函数版本也是如此。
如果虚函数使用哪个默认实参,那么基类和派生类中定义的默认实参最好一致。
5. 回避虚函数的机制
在某些情况下,我们希望对虚函数的调用不要执行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的:
// 调用基类中定义的函数版本而不管baseP的动态类型是什么
double undiscounted = baseP->Quote::net_price(42);
通常情况下只有成员函数(或者友元)中的代码才需要使用作用域运算符来回避虚函数的机制。如果一个虚函数需要调用它的基类版本,但是没有使用作用域运算符,那么会导致无限递归。
抽象基类
1. 纯虚函数
我们可以将net_price
定义成纯虚函数从而告诉用于当前这个net_price
函数是没有意义的。一个纯虚函数无须定义,我们通过在函数体的位置书写=0
就可以将一个虚函数说明为纯虚函数。
// 用于保存折扣值和购买量的类,派生类使用这些数据可以实现不同的价格策略
class Disc_quote : public Quote {
public:
Disc_quote() = default;
Dsic_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;
}
虽然我们不能直接定义这个类的对象,但是Disc_quote
的派生类构造函数会使用Disc_quote
的构造函数来构建各个派生类对象的Disc_quote
部分。
2. 含有纯虚函数的类是抽象基类
抽象基类负责定义接口,而后续的其他类可以覆盖接口。我们不能(直接)创建一个抽象基类的对象。
Disc_quote
的派生类必须给出自己的net_price
定义,否则它们仍然将是抽象基类。
3. 派生类构造函数只初始化它的直接基类
如果一个派生类同时包含直接基类和间接基类,那么该派生类的构造函数只需要初始化它的直接基类。
访问控制和继承
1. 受保护成员
一个protected
关键字声明它希望与派生类分享但是不想被其他公共访问使用的成员:
- 和私有成员类似,受保护的成员对于类的用户来说不可访问
- 和公有成员类似,受保护的成员对派生类的成员和友元来说是可访问的
- 派生类的成员或者友元只能通过派生类对象来访问基类的受保护成员,派生类对于一个基类对象中的受保护成员没有任何访问特权
理解最后一条规则可以参考如下例子:
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.port_mem = 0; }
// 错误:clobber不能访问Base的protected成员
void clobber(Base &b) { b.port_mem = 0; }
派生类的成员和友元函数只能访问派生类对象中基类部分的受保护成员,对于普通的基类对象中的成员不具有特殊的访问权限。
2. 公有、私有和受保护继承
某个类对继承而来的成员呢的访问权限收到两个因素影响:一个是基类中该成员的访问说明符,第二个是在派生类的派生列表中的访问说明符。
class Base {
public:
void pub_mem(); // public成员
protected:
int prot_mem; // protected成员
private:
char priv_mem; // private成员
};
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的
3. 派生类向基类转换的可访问性
假定D
继承B
:
- 只有当
D
公有地继承B
时,用户代码才能使用派生类向基类的转换;如果D
继承B
的方式是受保护的或者私有的,则用户代码不能使用该转换 - 无论
D
以什么方式继承B
,D
的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的 - 如果
D
继承B
的方式是公有的或者受保护的,则D
的派生类的成员和友元可以使用D
向B
的类型转换,反之如果D
继承B
的方式是私有的,则不能使用
和其他类一样,基类应该将其接口成员声明为公有的;同时将属于其实现的部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据;对于后者应该声明为私有的。
4. 友元与继承
就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,同样的派生类的友元也不能随便访问基类的成员。
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;
}
由于Derived
使用了私有继承,所以size()
和n
默认情况下是Derived
的私有成员。我们使用using
声明语句改变了这些成员的可访问性。改变之后Derived
的用户将可以使用size
成员,而Derived
的派生类将能使用n
。
派生类只能为那些它可以访问的名字提供
using
声明。
6. 默认的继承保护级别
使用class
关键字定义的派生类是私有继承的,使用struct
关键字定义的派生类是公有继承的。
class Base { /*...*/ };
struct D1 : Base { /*...*/ }; // 默认public继承
class D2 : Base { /*...*/ }; // 默认private继承
class
和struct
除了默认成员访问说明符及默认派生访问说明符不同外其他完全相同。
继承中的类作用域
每个类定义自己的作用域,在这个作用域里面我们定义类的成员。当存在继承关系时,派生类的作用域嵌套在其基类的作用域之内。
如果一个名字在派生类的作用域内无法正确解析,那么编译器将继续在外层的基类作用域中寻找该名字的定义。
假设Disc_quote
是Quote
的派生类,Bulk_quote
是Disc_quote
的派生类,那么当我们执行:
Bulk_quote bulk;
cout << bulk.isbn();
首先在Bulk_quote
中查找不到isbn()
,然后在Disc_quote
中查找不到isbn()
,最终会被解析成Quote
中的isbn()
。
1. 在编译时进行名字查找
一个对象、引用或者指针的静态类型决定了该对象的那些成员是可见的。即使静态类型和动态类型可能不一致(当使用基类的引用或者指针时可能发生这种情况),但是我们能使用哪些成员仍然是静态类型决定的。
举个例子,我们在Disc_quote
中添加一个新成员:
class Disc_quote : public Quote {
public:
std::pair<size_t, double> discount_policy() const
{ return {quantity, discount}; }
// 其他成员与之前的版本保持一致
};
// 我们只能通过Disc_quote及其派生类对象、引用或指针来使用discount_policy
Bulk_quote bulk;
Bulk_quote *bulkP = &bulk; // 静态类型与动态类型一致
Quote *itemP = &bulk; // 静态类型与动态类型不一致
bulk->discount_policy(); // 正确:bulkP的类型是Bulk_quote*
itemP->discount_policy(); // 错误:itemP的类型是Quote*
itemP
的类型是Quote
的指针,意味着对discount_policy
的搜索将从Quote
开始,显然Quote
不包含discount_policy
的成员,因此我们无法通过Quote
的对象、引用或者指针来调用discount_policy
。
2. 名字冲突和继承
与其他作用域一样,派生类也可以重用定义在其直接基类或者间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。
struct Base {
Base() : mem(0) { }
protected:
int mem;
};
struct Derived : Base {
Derived(int i) : mem(i) { } // 用i初始化Derived::mem,Base::mem进行默认初始化
int get_mem() { return mem; } // 返回Derived::mem
protected:
int mem; // 隐藏基类中的
};
// 返回42而非0,即返回定义在Derived中的名字
Derived d(42);
cout << d.get_mem() << endl;
3. 通过作用域运算符来使用隐藏的成员
struct Derived : Base {
int get_base_mem() { return Base::mem; }
// ...
}
作用域运算符将覆盖原有的查找规则,并指示编译器从Base
类的作用域开始查找mem
。如果使用上述这个Derived
版本运行上面的代码,则d.get_mem()
的输出结果将是0
。
除了覆盖继承而来的虚函数外,派生类最好不要重用其他定义在基类中的名字。
4. 名字查找与继承
理解函数调用的解析对于理解C++
的继承至关重要,假定我们调用p->mem()
或者obj.mem()
,则依次执行如下四个步骤:
- 首先确定
p
或者obj
的静态类型,因为我们调用的是一个成员,所以该类型必然是类类型 - 在
p
或者obj
的静态类型对应的类中查找mem
,如果找不到则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类都没找到,则编译器报错 - 一旦找到了
mem
,就进行常规的类型检查,以确定本地调用是否合法 - 假设调用合法,则编译器根据调用的是否是虚函数产生不同的代码:
- 如果
mem
是虚函数且我们是通过指针或者引用进行调用,则编译器产生的代码将在运行时确定到底运行虚函数的哪个版本,依据是对象的动态类型 - 如果
mem
不是虚函数或者我们是通过对象(非引用或者指针)进行调用,则编译器将产生一个常规函数调用
- 如果
5. 一如往常,名字查找先于类型检查
声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此定义派生类中的函数也不会重载其基类的成员。
和其他作用域一样,如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也依然会被隐藏掉。
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
// ps: 但是除了虚函数外,最好还是不要重用其他定义在基类中的名字
6. 虚函数与作用域
从名字查找先于类型检查我们可以得知为什么基类和派生类中的虚函数为什么必须有相同的形参列表了。假如基类和派生类的虚函数接收的实参不同,那么我们就无法通过基类的引用或者指针调用派生类的虚函数了。
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
};
D1
的fcn
函数并没有覆盖Base
的虚函数fcn
,原因是它们的形参列表不同。实际上D1
的fcn
将隐藏Base
的fcn
。此时拥有了两个名为fcn
的函数:一个是从D1
从Base
继承而来的虚函数fcn
,另一个是D1
自己定义的接受一个int
参数的非虚函数fcn
。
7. 覆盖重载的函数
和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的0
个或多个实例。前面提到名字查找先于类型检查,因此如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖掉所有版本,或者一个都不覆盖。
有一些情况下一个类仅仅需要覆盖重载集合中的一些而非全部函数,一种好的方法是为重载的成员提供一个using
声明语句,这样我们就无须覆盖基类中的每一个重载版本了。using
声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的using
声明可以把该函数的所有重载实例添加到派生类作用域中。这时候派生类只需要定义其特有的函数即可,不会隐藏掉其他没有重载的实例。
using
可以改变个别成员的可访问性,这样基类函数的每个实例在派生类中都必须是可访问的,对派生类没有重新定义的重载版本访问实际上是对using
声明点的访问。
构造函数与拷贝控制
1. 虚析构函数
继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数,这样我们就可以动态分配继承体系中的对象了。当我们delete
一个动态分配的对象的指针时将执行析构函数,如果该指针指向继承体系中的某个类型,那么可能出现指针的静态类型与被删除对象的动态类型不符合的情况。
我们通过在基类中将析构函数定义为虚函数以确保执行正确的析构函数版本。
class Quote {
public:
// 如果我们删除的是一个指向派生类对象的基类指针,则需要虚析构函数
virtual ~Quote() = default; // 动态绑定析构函数
}
和其他虚函数一样,析构函数的虚属性也会被继承,因此无论您
Quote
的派生类使用合成的析构函数还是定义自己的析构函数都将是虚析构函数。只要基类的析构函数是虚函数,就能确保当我们delete
基类指针时将运行正确的析构函数版本。
我们之前介绍过一条经验准则:如果一个类需要析构函数,那么它同样也需要拷贝和赋值操作。注意基类的析构不需要遵循上述准则,它是一个重要的例外。一个基类总是需要析构函数,而且它能将析构函数设定为虚函数。此时该析构函数为了成为虚函数而令内容为空,我们显然无法由此推断该基类是不是还需要赋值运算符或者拷贝构造函数。
2. 虚析构函数将阻止合成移动操作
如果一个类定义了虚析构函数,即使它通过=default
的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,那么编译器就不会为它合成移动构造函数和移动赋值运算符了。只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个
static
数据成员都可以移动时,编译器才会为它合成移动构造函数或者移动赋值运算符。
3. 合成拷贝控制与继承
基类或派生类的合成拷贝控制成员与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁的操作。此外这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。例如:
- 合成的
Bulk_quote
默认构造函数运行Disc_quote
的默认构造函数,后者又运行Quote
的默认构造函数 - 合成的
Bulk_quote
拷贝构造函数使用(合成的)Disc_quote
拷贝构造函数,后者又使用(合成的)Quote
拷贝构造函数 - 在我们的
Quote
继承体系中,所有类都使用合成的析构函数,其中派生类隐式地使用而基类通过将其虚析构函数定义成=default
而显式地使用 - 由于
Quote
因为定义了析构函数而不能拥有合成的移动操作,因此当我们移动Quote
对象时实际使用的是合成的拷贝操作,另外Quote
没有移动操作意味着它的派生类也没有
4. 派生类中删除的拷贝控制与继承
基类或者派生类可以出于同样的原因将其合成的默认构造函数或者任何一个拷贝控制成员定义为被删除的函数。另外某些定义基类的方式也可能导致有的派生类成员称为被删除的函数:
- 如果基类中的默认构造函数、拷贝构造函数、拷贝赋值运算或析构函数是被删除的函数或者不可访问,则派生类中对应的成员将是被删除的,原因是编译器不能使用基类成员来执行派生类对象基类部分的构造、赋值或者销毁操作
- 如果在基类中有一个不可访问或者删掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的,原因是编译器无法销毁掉派生类的基类部分
- 当我们使用
=default
请求一个移动操作时,如果基类中的对应操作是删除或者不可访问的,那么派生类中该函数将是被删除的,原因是派生类对象的基类部分不可移动。同样如果基类的析构函数是删除的或者不可访问的,那么派生类的移动构造函数也将是被删除的
class B {
public:
B(); // 可访问的构造函数
B(const B&) = delete; // 显式删除的拷贝构造函数
// 因为我们定义了拷贝构造函数,所以编译器不会为B合成一个移动构造函数
};
class D : public B {
// 没声明任何构造函数
};
我们既不能移动也不能拷贝B
的对象,如果B
的派生类希望它自己的对象能被移动和拷贝,则派生类需要自定义相应版本的构造函数,在这一过程中派生类需要考虑如何移动或者拷贝其基类部分的成员。
在实际编程中,如果基类中没有默认、拷贝或移动构造函数,那么一般情况下派生类也不会定义相应的操作。
5. 移动操作与继承
前面提到,大多数基类都会定义一个虚析构函数,因此在默认情况下基类通常不包含合成的移动操作,而且在派生类中也没有合成的移动操作。因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作时应该首先在基类中进行定义。一旦Quote
定义了自己的移动操作,那么它必须同时显式地定义拷贝操作:
class Quote {
public:
Quote() = default; // 对成员依次进行默认初始化
Quote()(const Quote&) = deafult; // 对成员依次拷贝
Quote(Quote&&) = default; // 对成员依次拷贝
Quote& operator=(const Quote&) = default; // 拷贝赋值
Quote& operator=(Quote&&) = default; // 移动赋值
virtual ~Quote() = default;
// 其他成员与之前版本一致
};
通过上面定义,我们可以对Quote
的对象逐个成员分别进行拷贝、移动、赋值和销毁操作,而且除非Quote
的派生类中含有排斥移动的成员,否则它将自动获得合成的移动操作。
6. 派生类的拷贝控制成员
派生类构造函数在其初始化阶段中不但要初始化派生类自己的成员,还要负责初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。
和构造函数及赋值运算符不同的是,析构函数只负责销毁派生类自己分配的资源。如前所述,对象的成员是被隐式销毁的,类似的派生类对象的基类部分也是自动销毁的。当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
当我们为派生类定义拷贝或者移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分:
// 初始值Base(d)将一个D对象传递给基类构造函数,Base(d)会去匹配Base的拷贝构造函数,将d的基类部分拷贝给要创建的对象
class Base { /* ... */ };
class D : public Base {
public:
// 默认情况下,基类的默认构造函数初始化对象的基类部分
// 要想使用拷贝或移动构造函数,我们必须在构造函数初始值列表中显式地调用该构造函数
D(const D& d) : Base(d) // 拷贝基类成员
/* D的成员的初始值 */ { /* ... */ }
D(D&& d): Base(std::move(d)) // 移动基类成员
/* D的成员的初始值 */ { /* ... */ }
}
与拷贝或者移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值:
// Base::operator=(const Base&)不会被自动调用
D &D::operator=(const D &rhs)
{
Base::operator=(rhs); // 为基类部分赋值
// 为派生类的成员赋值, 考虑自赋值情况以及释放已有资源
return *this;
}
派生类的析构函数只负责销毁由派生类自己分配的资源:
class D : public Base {
public:
// Base::~Base被自动调用执行
~D() { /* 由用户自定义清除派生类成员的操作 */ }
}
7. 在构造函数和析构函数中调用虚函数
派生类对象的基类部分首先被构造,然后再构造派生类部分。对象销毁的顺序正好相反,派生类析构函数首先执行,然后是基类的析构函数。
当执行基类的构造函数时,该对象的派生类部分是未被初始化的状态;当执行基类的析构函数时,派生类部分已经被销毁了。因此如果构造函数或者析构函数调用了某个虚函数,则我们应该执行与构造函数或者析构函数所属类型相对应的虚函数版本。
8. 继承的构造函数
一个类只初始化它的直接基类,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。派生类继承基类构造函数的方式是提供一条注明(直接)基类名的using
声明语句:
class Bulk_quote : public Disc_quote {
public:
using Disc_quote::Disc_quote; // 继承Disc_quote的构造函数
double net_price(std::size_t) const;
}
通常情况下using
声明只是令某个名字在当前作用域内可见。但如果作用于构造函数,那么using
语句将令编译器产生代码。对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数,形如:
derived(params) : base(args) { }
// 例如
Bulk_quote(const std::string& book, double price,
std::size_t qty, double disc) :
Disc_quote(book, price, qty, disc) { }
当一个基类构造函数含有默认实参,那么这些实参并不会被继承,相反派生类将会获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。
如果基类含有几个构造函数,则除了两个例外情况外大多数派生类会继承所有的构造函数:
- 如果派生类定义的构造函数与基类的构造函数有相同的参数列表,则该构造函数不会被继承,定义在派生类中的构造函数会替换继承而来的构造函数
- 默认、拷贝和移动构造函数不会被继承,这些构造函数会按照正常规则被合成,因此如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数
容器与继承
当我们使用容器存放继承体系中的对象时,必须采用间接存储的方式。比如我们想定义一个vector
保存用户准备购买的几种书籍,显然我们不应该用vector
保存Bulk_quote
对象,因为我们无法将Quote
转化成Bulk_quote
对象。我们也不应该用vector
保存Quote
对象,虽然我们可以把Bulk_quote
对象放置在容器中,但是这些对象再也不是Bulk_quote
对象了。
vector<Quote> basket;
basket.push_back(Quote("0-201-82470-1", 50));
// 正确:但是只能把对象的Quote部分拷贝给basket, 派生类部分会被舍弃掉
basket.push_bask(Bulk_quote("0-201-54848-8", 50, 10, .25));
// 调用Quote定义的版本
cout << basket.back().net_price(15) << endl;
当我们希望在容器中存放具有继承关系的对象时,实际上存放的通常是基类的指针(更好的选择是智能指针):
Q:这里为什么要使用智能指针,涉及到动态内存申请了?是因为如果vector没被销毁,我们也希望在对象未被使用时及时释放内存你
vector<shared_ptr<Quote>> basket;
// 正如我们可以将一个派生类的普通指针转换基类指针一样,我们也能把一个派生类的智能指针转换为基类的指针指针
basket.push_back(mask_shared<Quote>("0-201-82470-1", 50));
basket.push_back(make_shared<Bulk_quote>("0-201-54848-8", 50, 10, .25));
make_shared<Bulk_quote>
返回一个shared_ptr<Bulk_quote>
对象,当我们调用push_back
时该对象被转换成shared_ptr<Quote>
。
网友评论