C++ 面向对象程序设计:继承、虚函数和动态绑定到底在解决什么问题
Table of Contents
C++ 面向对象程序设计:继承、虚函数和动态绑定到底在解决什么问题
很多人学 C++ 面向对象时,会背下数据抽象、继承、动态绑定这些术语,但一旦落到代码里,还是容易停留在“语法知道,场景不清楚”的阶段。
这篇文章的目标不是只把概念列一遍,而是把几个最核心的问题串起来:为什么要区分类的接口和实现,为什么需要继承,为什么虚函数和动态绑定会成为 C++ 面向对象设计里的关键机制。
面向对象程序设计基于三个基本概念:数据抽象,继承和动态绑定。
OOP 概述
面向对象程序设计 (object-oriented programming) 的核心思想是数据抽象、继承和动态绑定。通过使用数据抽象,我们可以将类的接口与实现分离;使用继承,可以定义相似的类型并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。
继承
通过继承 (inheritance) 联系在一起的类构成一种层次关系。通常在层次关系的根部有一个基类 (basic class),其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类 (derived class)。基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。
在 C++ 语言中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待。对于某些函数,基类希望它的派生类各自定义适合自身的版本,此时基类就将这些函数声明成虚函数 (virtual function)。因此,我们可以将 Quote 类编写成
class Quote {
public:
std::string isbn() const;
virtual double net_price(std::size_t n) const;
};
派生类必须通过使用类派生列表 (class derivation list) 明确指出它是从哪个(哪些)基类继承而来的。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有访问说明符:
class Bulk_quote : public Quote {
public:
double net_price(std::size_t) const override;
};
因为 Bulk_quote 在它的类派生列表中使用了 public 关键字,因此我们完全可以把 Bulk_quote 的对象当成 Quote 的对象来使用。
派生类必须在其内部对所有重新定义的虚函数进行声明。派生类可以在这样的函数之前加上 virtual 关键字,但是并不是非得这么做。C++ 11 新标准允许派生类显式地注明它将使用哪个成员函数改写基类的虚函数,具体措施是在该函数的形参列表之后加一个 override 关键字。
动态绑定
通过动态绑定 (dynamic binding),我们能用同一段代码分别处理 Quote 和 Bulk_quote 的对象。例如,当要购买的书籍和购买的数量都已知时,下面的函数负责打印总的费用
double print_total(ostream& os, const Quote& item, size_t n) {
double ret = item.net_price(n);
os << "ISBN: " << item.isbn()
<< "# sold: " << n << " total due: " << ret << endl;
}
关于上面的函数有两个有意思的结论:因为函数 print_total 的 item 形参是基类 Quote 的一个引用,所以我们既能使用基类 Quote 的对象调用该函数,也能使用派生类 Bulk_quote 的对象调用它;又因为 print_total 是使用引用类型调用 net_price 函数的,所以实际传入 price_total 的对象类型将决定到底执行 net_price 的哪个版本:
print_total(cout, basic, 20); // 调用 Quote 的 net_price
print_total(cout, bulk, 20); // 调用 Bulk_quote 的 net_price
上述过程中函数的运行版本由实参决定,即在运行时选择函数的版本,所以动态绑定有时又被称为**运行时绑定 **(run-time binding)。
在 C++ 语言中,当我们使用基类的引用(或指针)调用一个虚函数时将发生动态绑定。
定义基类和派生类
定义基类
我们首先完成对 Quote 类的定义
class Quote {
public:
Quote() = default;
Quote(string& book, double sales_price) :
book_no(book), price(sales_price) {};
std::string isbn() const { return book_no; };
virtual double net_price(std::size_t n) const { return n * price; };
virtual ~Quote() = default;
private:
string book_no;
protected:
double price = .0L;
};
作为继承关系的基类都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。
将基类的析构函数定义为虚函数的原因是为了确保当我们 delete 一个基类指针时,而该指针实际指向一个派生类对象时,程序也能正确运行。
成员函数与继承
派生类可以继承其基类的成员,派生类需要对这些操作提供自己的新定义以覆盖 (override) 从基类继承而来的旧定义。
基类必须将它的两种成员函数区分开来:一种是基类希望其派生类进行覆盖的函数,另一类是基类希望派生类直接继承而不要改变的函数。对于前者,基类通常将其定义为虚函数 (virtual)。当我们使用指针或引用调用虚函数时,该调用将被动态绑定。根据引用或指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。
基类通过在其成员函数的声明语句之前加上关键字 virtual 使得该函数执行动态绑定。任何构造函数之外的非静态函数都可以是虚函数。关键字 virtual 只能出现在类内部的声明语句之前而不能用于类外部的函数定义。如果基类把一个函数声明成虚函数,则该函数在派生类中隐式地也是虚函数。
成员函数如果没被声明为虚函数,则其解析过程发生在编译时而非运行时。对于 isbn 成员来说这正是我们希望的结果。isbn 函数的执行与派生类的细节无关,不管作用于 Quote 对象还是 Bulk_quote 对象,isbn 函数的行为都一样。在我们的继承层次关系中只有一个 isbn 函数,因此也就不存在调用 isbn() 时到底执行哪个版本的疑问。
访问控制与继承
派生类可以继承定义在基类的成员,但是派生类不一定有权访问从基类继承而来的成员。和其他使用基类的代码一样,派生类能访问公有成员,而不能访问私有成员。不过在某些时候基类中还有这样一种成员,基类希望它的派生类有权访问该成员,同时禁止其他用户访问。我们用受保护的 (protected) 访问运算符说明这样的成员。而基类中的私有成员即使是派生类也无法访问。
定义派生类
派生类必须通过使用类派生列表 (class derivation list) 明确指出它是从哪个(哪些)基类继承而来。类派生列表的形式是:首先是一个冒号,后面紧跟以逗号分隔的基类列表,其中每个基类前面可以有以下三个访问说明符中的一个:public、protected 或者 private
派生类必须将其继承而来的成员函数中需要覆盖的那些重新声明,例如 Bulk_quote 类需要包含一个 net_price 成员
class Bulk_quote : public Quote {
public:
Bulk_quote() = default;
Bulk_quote(string&, double, size_t, double);
virtual double net_price(size_t n) const override;
private:
size_t min_qty = 0;
double discount = .0L;
};
派生列表中的访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见。如果一个派生关系是公有的,则基类的公有成员也是派生类接口的组成部分。
派生类中的虚函数
派生类经常(但不总是)覆盖它继承的虚函数。如果派生类没有覆盖其基类中的某个虚函数,则该虚函数的行为类似于其他的普通成员,派生类会直接继承其在基类中的版本。
派生类可以在它覆盖的函数前使用 virtual 关键字,但不是非得这么做。C++ 11 新标准允许派生类显式地注明它使用某个成员函数覆盖了它继承的虚函数。具体做法是在形参列表后面、或者在 const 成员函数的 const 关键字后面、或者在引用成员函数的引用限定符后面添加一个关键字 override。
派生类对象及派生类向基类的类型转换
因为在派生类对象中含有与其基类对应的组成部分,所以我们能把派生类的对象当成基类对象来使用,而且我们也能将基类的指针或者引用绑定到派生类对象中的基类部分上。
Quote item;
Bulk_quote bulk;
Quote* p = &item;
p = &bulk; // 基类指针可以指向派生类对象
Quote& r = bulk; // 基类引用可以绑定到派生类对象
这种转换通常称为 **派生类到基类的 **(derived-to-base) 类型转换。
这种隐式特性意味着我们可以把派生类对象或者派生类对象的引用用在需要基类引用的地方;同样的,我们也可以把派生类对象的指针用在需要基类指针的地方。
在派生类对象中含有与其基类对应的组成部分,这一事实是继承的关键所在。
派生类构造函数
派生类必须使用基类的构造函数来初始化它的基类部分。
每个类控制它自己的成员初始化过程。
示例
Bulk_quote::Bulk_quote(string& book, double price, size_t qty, double discount)
:Quote(book, price), min_qty(qty), discount(discount) { }
如果想使用其他的基类构造函数,我们需要以类名加圆括号内的实参列表的形式为构造函数提供初始值。这些实参将帮助编译器决定到底应该选用哪个构造函数来初始化派生类对象的基类部分。
首先应该初始化基类部分,然后按照声明的顺序依次初始化派生类的成员。
double Bulk_quote::net_price(std::size_t n) const {
if (n > min_qty) {
return n * (1 - discount) * price;
}
else {
return n * price;
}
}
关键概念:遵循基类的接口
必须明确一点:每个类负责定义各自的接口。要想与类的对象交互必须使用该类的接口,即使这个对象是派生类的基类部分也是如此。
因此派生类对象不能直接初始化基类的成员。尽管从语法上来说我们可以在派生类构造函数体内给它的共有或受保护的基类成员赋值,但是最好不要这么做。和使用基类的其他场合一样,派生类应该遵循基类的接口,并且通过调用基类的构造函数来初始化那些从基类中继承而来的成员。
继承与静态成员
如果基类定义了一个静态成员,则在整个继承体系中只存在该成员的唯一定义。不论从基类中派生出来多少个派生类,对于每个静态成员来说都只存在唯一的实例。
派生类的声明
派生类的声明与其他类差别不大,声明中包含类名但是不包含它的派生列表:
class Bulk_quote : public Quote; // compile error
class Bulk_quote; // ok
一条声明语句的目的是令程序知晓某个名字的存在以及该名字表示一个什么样的实体,如一个类、一个函数或一个变量等。派生列表以及与定义有关的其他细节必须与主体一起出现。
被用作基类的类
如果我们想将某个类用作基类,则该类必须已经定义而非仅仅声明。
这一规则的原因显而易见:派生类中包含并且可以使用使用它从基类继承而来的成员,为了使用这些成员,派生类当然要知道它们是什么。因此该规定还有一层隐含的意思,即一个类不能派生它本身。
一个类是基类,同时它也可以是一个派生类。
class Base {};
class D1 :public Base {};
class D2 :D1 {};
在这个继承关系中,Base 是 D1 的直接基类 (direct base),同时也是 D2 的间接基类 (indirect base)。直接基类出现在派生列表中,而间接基类由派生类通过其直接基类继承而来。
防止继承的发生
有时我们会定义这样一种类,我们不希望其他类继承它,或者不想考虑它是否是和作为一个基类。为了实现这一目的, C++ 11 新标准提供了一种防止继承发生的方法,即在类名后跟一个关键字 final:
class no_derived final {};
class base{};
class last final : base{};
class bad : no_derived {}; // compile error
class bad2 : last{}; // compile error
在 C# 中这个关键字是 sealed,而且 C# 中的 sealed 放在 class 之前,C++ 中的 final 放在类名之后。
类型转换与继承
理解基类和派生类之间的类型转换是理解 C++语言面向对象编程的关键所在。
通常情况下,如果我们想把引用或指针绑定到一个对象上,则引用或指针的类型应与对象的类型一致,或者对象的类型含有一个可接受 const 类型转换规则。存在继承关系的类是一个重要的例外:我们可以将基类的指针或引用绑定到派生类对象上。例如我们可以用 Quote& 指向一个 Bulk_quote 对象,也可以把一个 Bulk_quote 对象的地址赋给一个 Quote*
可以将基类的指针或应用绑定到派生类对象上有一层极为重要的含义:当使用基类的引用(或指针)时,实际上我们并不清楚该引用(或指针)所绑定对象的真实类型。该对象可能是基类的对象,也可能是派生类的对象。
和内置指针一样,智能指针类也支持派生类向基类的类型转换,这意味着我们可以将一个派生类对象的指针存储在一个基类的智能指针内。
静态类型与动态类型
当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型 (static type) 与该表达式表示对象的动态类型 (dynamic type) 区分开来。表达式的静态类型在编译时总是已知的,它是变量声明时的类型或表达式生成的类型;动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。
例如,当 print_total 调用 net_price 时
double ret = item.net_price(n);
我们知道 item 的静态类型是 Quote&,它的动态类型依赖于 item 绑定的实参,动态类型直到在运行时调用该函数时才会知道。如果我们传递一个 Bulk_quote 对象给 print_total,则 item 的静态类型将与它的动态类型不一致。如前所述, item 的静态类型是 Quote&,而在此例中它的动态类型是 Bulk_quote
如果表达式既不是引用也不是指针,则它的动态类型永远与静态类型一致。例如,Quote 类型的变量永远是一个 Quote 对象,我们无论如何都不能改变该变量对应的对象的类型。
基类的指针或引用的静态类型可能与其动态类型不一致,一定要理解其中的原因。
不存在从基类向派生类的隐式类型转换 …
之所以存在派生类向基类的类型转换是因为每个派生类对象都包含一个基类部分,而基类的引用或指针可以绑定到该基类部分上。
因为一个基类的对象可能是派生类对象的一部分,也可能不是,所以不存在从基类向派生类的自动类型转换:
Quote base;
Bulk_quote* bulkp = &base; // compile error
Bulk_quote& bulkr = base; // compile error
如果上述赋值是合法的,则我们有可能会使用 bulkp 或者 bulkr 访问 base 中根本不存在的成员。
除此之外还有一种情况显得有点特别,即使一个基类指针或引用绑定在一个派生类对象上,我们也不能执行从基类向派生类的转换:
Bulk_quote bulk;
Quote* item = &bulk;
Bulk_quote* bulkp = item; // compile error
编译器在编译时无法确定某个特定的转换在运行时是否安全,这是因为编译器只能通过检查指针或引用的静态类型来推断该转换是否合法。如果在基类中含有一个或多个虚函数,我们可以使用 dynamic_cast 请求一个类型转换,该转换的安全检查将在运行时执行。同样,如果我们已知某个基类向派生类转换是安全的,则我们可以使用 static_cast 来强制覆盖掉编译器的检查工作。
… 在对象之间不存在类型转换
派生类向基类的自动类型转换只对指针或引用类型有效,在派生类类型和基类类型之间不存在这样的转换。很多时候,我们确实希望将派生类对象转换成它的基类类型,但是这种转换的实际发生过程往往与我们期望的有所差别。
请注意,当我们初始化或赋值一个类类型的对象时,实际上是在调用某个函数。当执行初始化时,我们调用构造函数;而当执行赋值操作时,我们调用赋值运算符。这些成员通常都包含一个参数,该参数的类型是类类型的 const 版本的引用。
因为这些成员接受引用作为参数,所以派生类向基类的转换允许我们给基类的拷贝/移动操作传递一个派生类的对象。这些操作不是虚函数。当我们给基类的构造函数传递一个派生类对象时,实际运行的构造函数是基类中定义的那个,显然该构造函数只能处理基类自己的成员。类似的,如果我们将一个派生类对象赋值给一个基类对象,则实际运行的赋值运算符也是基类中定义的那个,该运算符同样只能处理基类自己的成员。
Bulk_quote bulk;
Quote item(bulk); // Quote::Quote(const Quote&)
item = bulk; // Quote& Quote::operator=(const Quote&);
当构造 item 时,运行 Quote 的拷贝构造函数,该函数只能处理 book_no 和 price 两个成员,它负责拷贝 bulk 中 Quote 部分的成员,同时忽略掉 bulk 中 Bulk_quote 部分的成员。类似的,对于将 bulk 赋值给 item 的操作来说,只有 bulk 中 Quote 部分的成员被赋值给 item。
因为在上述过程中会忽略 Bulk_quote 部分,所以我们可以说 bulk 的 Bulk_quote 部分被切掉 (sliced down) 了。
当我们用一个派生类对象为一个基类对象初始化或赋值时,只有该派生类对象中的基类部分会被拷贝、移动或赋值,它的派生类部分将被忽略掉。
关键概念:存在继承关系的类型之间的转换规则:
要想理解在具有继承关系的类之间发生的类型转换,有三点非常重要:
- 从派生类向基类的类型转换只对指针或引用类型有效。
- 基类向派生类不存在隐式类型转换。
- 和任何其他成员一样,派生类向基类的类型转换也可能会由于访问受限而变得不可行。
尽管自动类型转换只对指针或引用有效,但是继承体系中的大多数类仍然(显式或隐式地)定义了拷贝控制成员。因此,我们通常能够将一个派生类对象拷贝、移动或赋值给一个基类对象。不过需要注意的是,这种操作只处理派生类对象的基类部分。
虚函数
当我们使用基类的引用或指针调用一个虚成员函数是会执行动态绑定。
因为我们直到运行时才能知道到底调用了哪个版本的虚函数,所以所有的虚函数都必须有定义。通常情况下,如果我们不使用某个函数,则无须为该函数提供定义。但是我们必须为每个虚函数都提供定义,而不管它是否被用到了,这是因为连编译器也无法确定到底会使用哪个虚函数。
对虚函数的调用可能在运行时才被解析
当某个虚函数通过指针或引用调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本的函数。被调用的函数是与绑定到指针或引用上的对象的动态类型相匹配的那一个。
必须要搞清楚的一点是,动态绑定只有当我们通过指针或引用调用虚函数时才会发生。而当我们通过一个具有普通类型(非引用非指针)的表达式调用虚函数时,在编译时就会将调用的版本确定下来。
关键概念:C++ 的多态性
OOP 的核心思想是多态性 (polymorphism)。多态性这个词源自希腊语,其含义是“多种形式”。我们把具有继承关系的多个类型称为多态类型,因为我们能使用这些类型的“多种形式”而无须在意它们的差异。引用或指针的静态类型与动态类型不同这一事实正是 C++ 语言支持多态性的根本所在。
当我们使用基类的引用或指针调用基类中定义的一个函数时,我们并不知道该函数真正作用的对象是什么类型,因为它可能是一个基类的对象也可能是一个派生类的对象。如果该函数是虚函数,则直到运行时才会决定到底执行哪个版本,判断的依据是引用或指针所绑定的对象的真实类型。
另一方面,对非虚函数的调用在编译时进行绑定。类似的,通过对象进行的函数(虚函数或非虚函数)调用也在编译时绑定。对象的类型是确定不变的,我们无论如何都不可能令对象的动态类型与静态类型不一致。因此,通过对象进行的函数调用将在编译时绑定到该对象所属类中的函数版本上。
当且仅当对通过指针或引用调用虚函数时,才会在运行时解析该调用,也只有在这种情况下对象的动态类型才有可能与静态类型不同。
派生类中的虚函数
当我们在派生类中覆盖了某个虚函数时,可以再一次使用 virtual 关键字指出该函数的性质。然而这么做并非必须,因为一旦某个函数被声明成虚函数,则在所有派生类中它都是虚函数。
派生类中虚函数的返回类型必须与基类的函数匹配。当类的虚函数返回类型是类本身的指针或引用时,上数规则无效。也就是说,如果 D 由 B 派生得到,则基类的虚函数可以返回 B* 而派生类的对应函数可以返回 D*,只不过这样的返回类型要求从 D 到 B 的类型转换是可访问的。
基类中的虚函数在派生类中隐含地也是一个虚函数。当派生类覆盖了某个虚函数时,该函数在基类中的形参必须与派生类中的形参严格匹配。
final 和 override 说明符
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法的行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。就实际的编程习惯而言,这种声明往往意味着发生了错误,因为我们可能原本希望派生类能覆盖掉基类中的虚函数,但是一不小心把形参列表弄错了。
要想调试并发现这样的错误显然非常困难。在 C++ 11 新标准中我们可以使用 override 关键字来说明派生类中的虚函数。这么做的好处是在使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误,后者在编程实践中显得更加重要。如果我们使用 override 标记了某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错。
struct B
{
virtual void f1(int) const;
virtual void f2();
void f3();
};
struct D1 : public B {
void f1(int) const override;// ok
void f2(int) override; // compile error 形参列表不同
void f3() override; // compile error 基类的 f3 不是虚函数
void f4() override; // compile error 基类没有 f4 这个虚函数
};
在上述代码中,只有 f1 的 override 是正确的。只有虚函数才能被覆盖,所以 f3 f4 的 override 都是错误的。
我们也可以给某个函数指定为 final,如果我们已经把函数定义为 final,则之后任何尝试覆盖该函数的操作都将引发错误。
struct D2 : public B {
// 从 B 继承了 f2 f3,重写了 f1
void f1(int) const final override ;
};
struct D3 : public D2 {
void f2() override; // ok
void f1(int) const; // compile error
};
虚函数与默认实参
和其他函数一样,虚函数也可以拥有默认实参。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
struct Base {
virtual void f(int i = 10) const { cout << "Base: " << i << endl; }
};
struct Derived : public Base {
void f(int i = 20) const override { cout << "Derived: " << i << endl; }
};
...
Derived d;
Base& b = d;
b.f(); // 打印输出:Derived: 10
回避虚函数的机制
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的,例如
double undisaccounted = basep->Quote::net_price(42);
该代码强行调用 Quote 的 net_price 函数,而不管 basep 实际指向的对象类型到底是什么。该调用将在编译时完成解析。
通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
什么时候我们需要回避虚函数的机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层级中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作。
如果一个派生类虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。
抽象基类
纯虚函数
纯虚 (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;
private:
size_t quantity = 0;
double discount = .0f;
};
和之前定义的 Bulk_quote 类一样,Disc_quote 也分别定义了一个默认构造函数和一个接受四个参数的构造函数。尽管我们不能直接定义这个类的对象,但是 Dis_quote 的派生类构造函数将会使用 Disc_quote 的构造函数来构建各个派生类对象的 Disc_quote 部分。其中,接受四个参数的构造函数将前两个参数传递给 Quote 的构造函数,然后直接初始化自己的成员 discount 和 quantity。 默认构造函数则对这些成员进行默认初始化。
值得注意的是,我们也可以为纯虚函数提供定义,不过函数体必须定义在类的外部。也就是说,我们不能在类的内部为一个 =0 的函数提供函数体。
含有纯虚函数的类是抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类 (abstract base class)。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。
抽象基类的派生类必须给出自己的函数实现,例如 Disc_quote 的派生类必须给出 net_price 的实现,否则它们仍将是抽象基类。
如果一个抽象基类的纯虚函数是 private 的(一般不会设置私有纯虚函数),子类不能通过作用域运算符访问私有函数,但是能重写
class Base {
private:
virtual void vir_func() = 0;
};
class Child : private Base {
public:
void vir_func() override {}
};
派生类构造函数值初始化它的直接基类
接下来实现 Bulk_quote 派生于 Disc_quote
class Bulk_quote : public Disc_quote {
public:
Bulk_quote() = default;
Bulk_quote(const string& book, double price, size_t qty, double disc) :
Disc_quote(book, price, qty, disc) {}
double net_price(size_t t) const override;
};
Bulk_quote 的直接基类是 Disc_quote,间接基类是 Quote
每个类各自控制器对象的初始化过程。即使 Bulk_quote 没有自己的数据成员,也需要一个四个参数的构造函数,并且这个构造函数将参数传递给 Disc_quote 的构造函数,随后 Disc_quote 的构造函数继续调用 Quote 的构造函数,由 Quote 首先初始化 book_no 和 price,再由 Disc_quote 初始化 quantity 和 discount ,最后执行 Bulk_quote 构造函数。
访问控制与继承
每个类分别控制自己的成员初始化过程,与之类似,每个类还分别控制着其成员对于派生类来说是否可访问。
受保护的成员
一个类使用 protected 关键字来声明那些它希望与派生类分享但是不想被其他公共访问使用的成员。protected 说明符可以看做是 public 和 private 中和后的产物:
- 和私有成员类似,受保护的成员对于类的用户来说是不可访问的。
- 和公有成员类似,受保护的成员对于派生类的成员和友元来说是可访问的。
此外,protected 还有另外一条重要的性质。
- 派生类的成员或友元只能通过派生类对象来访问基类的受保护成员。派生类对于一个基类对象中的受保护成员没有任何访问特权。
class Base {
protected:
int prot_mem;
};
class Sneaky :public Base {
friend void clobber(Sneaky&);
friend void clobber(Base&);
int j;
};
void clobber(Sneaky& s) {
s.j;
s.prot_mem; // ok
}
void clobber(Base& b) {
b.prot_mem; // compile error
}
如果派生类(及其友元)能访问基类对象的受保护成员,则上面的第二个 clobber (接受 Base& )就是合法的。该函数不是 Base 类的友元,但是它仍然能够改变一个 Base 对象的内容。如果按照这样的思路,则我们只要定义一个形如 Sneaky 的新类就能非常简单的规避掉 protected 提供的访问保护了。
要想阻止以上用法,我们就要做出如下的规定,即派生类的成员和友元只能访问派生类对象中的基类部分的受保护成员:对于普通的基类对象中的成员不具有特殊的访问权限。
公有、私有和受保护的继承
某个类对其继承而来的成员的访问权限受到两个因素影响:
一是在基类中该成员的访问说明符;
二是在派生类的派生列表中的访问说明符。
class Base
{
public:
void pub_mem();
protected:
int prot_mem;
private:
char pri_mem;
};
struct Pub_Derv :public Base {
int f() { return prot_mem; } // ok
char g() { return pri_mem; } // compile error 无法访问私有成员
};
struct Pri_Derv : private Base {
// private 派生不影响派生类的访问权限
int f1() const { return prot_mem; } // ok
};
派生访问说明符对于派生类的成员(及友元)能否访问其直接基类的成员没什么影响。对基类成员的访问权限只与基类中的访问说明符有关。
Pub_Derv 与 Pri_Derv 都能访问受保护的成员 prot_mem 同时她们都不能访问私有成员 priv_mem
派生访问说明符的目的是控制派生类用户(包括派生类的派生类在内)对于基类成员的访问权限:
Pub_Derv d1;
Pri_Derv d2;
d1.pub_mem();
d2.pub_mem(); // compile error
Pub_Derv 和 Priv_Derv 都继承了 pub_mem 函数,如果继承是公有的,则成员将遵循其原有的访问说明符,此时 d1 可以调用 pub_mem。在 Pri_Derv 中,Base 的成员是私有的,因此类的用户不能调用 pub_mem
派生访问说明符还可以控制继承自派生类的新类的访问权限:
struct Derived_from_Public : public Pub_Derv {
int use_base() { return prot_mem; }
};
struct Derived_from_Private : public Pri_Derv {
int use_base() { return prot_mem; } // compile error
};
派生类的派生类也是派生类的用户。
假设我们之前还定义了一个名为 Prot_Derv 的新类,它采用受保护继承,则 Base 的所有公有成员在新定义的类中都是受保护的。Prot_Derv 的用户不能访问 pub_mem,但是 Prot_Derv 的成员和友元可以访问那些继承而来的成员。
派生类向基类转换的可访问性
派生类向基类的转换,是否可访问由使用该转换代码决定,同时派生类的派生访问说明符也会有影响。假定 D 继承自 B:
- 只有当 D 公有地继承 B 时,D 的用户代码才能使用派生类向基类的转换;如果 D 继承 B 的方式是受保护的或者私有的,则用户代码不能使用该转换。
- 不论 D 以什么方式继承 B,D 的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元来说永远是可访问的。
- 如果 D 继承 B 的方式是公有的或者受保护的,则 D 的派生类的成员和友元可以使用 D 向 B 的类型转换;反之,如果 D 继承 B 的方式是私有的,则不能使用。
对于代码中的某个给定节点来说,如果基类的公有成员是可访问的,则派生类向基类的类型转换也是可访问的;反之则不行。
class Base { };
class Public_Derv : public Base {
void memfcn(Base& b) { b = *this; }
};
class Protected_Derv : protected Base {
void memfcn(Base& b) { b = *this; }
};
class Private_Derv : private Base {
void memfcn(Base& b) { b = *this; }
};
class Derived_from_Public : public Public_Derv {
void memfcn(Base& b) { b = *this; }
};
class Derived_from_Protected : protected Protected_Derv {
void memfcn(Base& b) { b = *this; }
};
class Derived_from_Private : private Private_Derv {
void memfcn(Base& b) { b = *this; } // compile error
};
void func() {
Public_Derv d1;
Private_Derv d2;
Protected_Derv d3;
Derived_from_Public dd1;
Derived_from_Private dd2;
Derived_from_Protected dd3;
Base* p = &d1;
p = &d2; // compile error
p = &d3; // compile error
p = &dd1;
p = &dd2; // compile error
p = &dd3; // compile error
}
关键概念:类的设计与受保护的成员
不考虑继承的话,我们可以认为一个类有两种不同的用户:普通用户和类的实现者。
其中,普通用户编写的代码使用类的对象,这部分代码只能访问类的公有(接口)成员;实现者则负责编写类的成员和友元的代码,成员和友元既能访问类的公有部分,也能访问类的私有(实现)部分。
如果进一步考虑继承的话,就会出现第三种用户,即派生类。基类把它希望派生类能够使用的部分声明成受保护的。普通用户不能访问受保护的成员,而派生类及其友元仍旧不能访问私有成员。
和其他类一样,基类应该将其接口成员声明为公有的;同时将属于其实现部分分成两组:一组可供派生类访问,另一组只能由基类及基类的友元访问。对于前者应该声明为受保护的,这样派生类就能在实现自己的功能时使用基类的这些操作和数据;对于后者应该声明为私有的。
友元和继承
就像友元关系不能传递一样,友元关系同样也不能继承。基类的友元在访问派生类成员时不具有特殊性,类似的,派生类的友元也不能随意访问基类的成员。
基类的友元可以访问派生类的基类部分数据,但是其他的数据不可以访问。
class Base {
friend class Pal;
protected:
int prot_mem;
};
class Sneaky :public Base {
int j;
};
class Pal {
public:
int f(Base b) { return b.prot_mem; }
int f2(Sneaky s) { return s.j; } // compile error
int f3(Sneaky s) { return s.prot_mem; } // compile ok
};
每个类负责控制自己的成员的访问权限。
class D2 :public Pal {
public:
int mem(Base b) { return b.prot_mem; } // compile error
};
改变个别成员的可访问性
有时我们需要改变派生类继承的某个名字的访问级别,通过使用 using 声明,可以达到这一目的:
class Base {
public:
std::size_t size() const { return n; }
protected:
std::size_t n;
};
// 此处使用 private 继承
class Derived : private Base {
public:
using Base::size;
protected:
using Base::n;
};
因为 Derived 使用了私有继承,所以继承而来的成员 size 和 n (在默认情况下)是 Derived 的私有成员。然而,我们使用 using 声明语句改变了这些成员的可访问性。改变之后,Derived 的用户将可以使用 size 成员,而 Derived 的派生类将能使用 n
通过在类的内部使用 using 声明语句,我们可以将该类的直接或间接基类中的任何可访问成员(例如,非私有成员)标记出来。using 声明语句中名字的访问权限由该 using 声明语句之前的访问说明符来决定。也就是说,如果一条 using 声明语句出现在类的 private 部分,则该名字只能被类的成员和友元访问;如果 using 声明语句位于 public 部分,则类的所有用户都能访问它;如果 using 声明语句位于 protected 部分,则该名字对于成员、友元和派生类都是可访问的。
派生类只能为那些它可以访问的名字提供 using 声明。
默认的继承保护级别
派生访问说明符可以省略。
使用 class 关键字定义的类是私有继承的,而使用 struct 关键字定义的派生类是公有继承的:
class Base {
public: int pub_mem;
};
class D1 : Base {};
struct D2 : Base {};
int func() {
D1 d1;
D2 d2;
d1.pub_mem; // compile error
d2.pub_mem;
}
人们常常有一种错觉,认为在使用 struct 关键字和 class 关键字定义的类之间还有更深层次的差别。事实上,唯一的差别就是默认成员访问说明符及默认派生访问说明符;除此之外,再无其他不同之处。
一个私有派生的类最好显式地将 private 声明出来,而不要仅仅依赖于默认的设置。显示声明的好处是可以令私有继承关系清晰明了,不至于产生误会。
继承中的类作用域
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。即使静态类型与动态类型可能不一致(当使用基类的引用或指针时会发生这种情况),但是我们能使用哪些成员仍然是静态类型决定的。
名字冲突与继承
和其他作用域一样,派生类也能重用定义在其直接基类或间接基类的名字,此时定义在派生类中的名字将隐藏基类的名字:
struct Base {
Base() :mem(0) {}
protected:
int mem;
};
struct Derived :Base {
// Derived 中的 mem 将隐藏 Base 中的 mem
// Base::mem 将被默认初始化
Derived(int i) :mem(i) {}
// 返回 Derived::mem
int get_mem() { return mem; }
protected:
int mem;
};
Derived d(42);
cout << d.get_mem() << endl; // 输出 42
通过作用域说明符来使用隐藏的成员
我们可以通过作用域运算符来使用一个被隐藏的基类成员:
struct Derived :Base {
int get_mem() { return Base::mem; }
// ...
};
作用域运算符将覆盖掉原有的查找规则,并指示编译器从 Base 类的作用域开始查找 mem
如果使用最新的 Derived 版本运行上面的代码,则 d.get_mem() 输出的结果将是 0
除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。
关键概念:名字查找与继承
理解函数调用的解析过程对于理解 C++ 的继承至关重要,假定我们调用 p->mem() 或者 obj.mem() 则依次执行以下 4 个步骤:
- 首选确定 p(或 obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
- 在 p (或 obj )的静态类型对应的类中查找 mem 。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
- 一旦找到了 mem,就进行常规的类型检查,以确定对于当前找到的 mem 本次调用是否合法。
- 假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码: * —— 如果 mem 是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。 * ——反之,如果 mem 不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。
名字查找先于类型检查
声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义在派生类中的函数也不会重载其基类中的成员。和其他作用域一样,如果派生类的成员与基类的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉:
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(); // compile error
d.Base::memfcn(); // ok
Derived 中的 memfcn 声明隐藏了 Base 中的 memfcn 声明。在上面的代码中前两条调用语句容易理解,第一个通过 Base 对象 b 进行的调用执行基类的版本;类似的,第二个通过 d 进行的调用执行 Derived 的版本;第三条调用语句有点特殊,d.memfcn() 是非法的。
为了解析这条调用语句,编译器首先在 Derived 中查找名字 memfcn;因为 Derived 确实定义了一个 memfcn 的成员,所以查找过程终止。**一旦名字找到,编译器就不再继续查找了。**Derived 中的 memfcn 版本需要一个 int 实参,而当前的调用语句无法提供任何实参,所以该调用语句是错误的。
虚函数与作用域
我们现在可以理解为什么基类与派生类中的虚函数必须有相同类型的形参列表了。假如基类与派生类的虚函数接受的实参不同,则我们就无法通过基类的引用或指针调用派生类的虚函数了。例如:
class Base {
public:
virtual int fcn();
};
class D1 : public Base {
public:
int fcn(int); // 形参列表与 Base 中的 fcn 不一样
virtual void f2(); // 是一个新的虚函数,在 Base 中不存在
};
class D2 : public D1 {
public:
int fcn(int); // 非虚函数,隐藏了 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。
通过基类调用隐藏的虚函数
给定上面定义的这些类后,下面是使用的示例:
Base bobj; D1 d1obj; D2 d2obj;
Base* bp = &bobj, * bd1p = &d1obj, * bd2p = &d2obj;
bp->fcn(); // Base::fcn()
bd1p->fcn(); // Base::fcn()
bd2p->fcn(); // D2::fcn()
D1* d1p = &d1obj; D2* d2p = &d2obj;
bp->f2(); // compile error
d1p->f2(); // D1::f2()
d2p->f2(); // D2::f2()
前三条调用语句是通过基类的指针进行的,因为 fcn 是虚函数,所以编译器产生的代码将在运行时确定使用虚函数的哪个版本。判断的依据是该指针所绑定对象的真实类型。在 bd1p 的例子中,实际绑定的对象是 D1 类型,而 D1 并没有覆盖那个不接受实参的 fcn,所以通过 bd1p 进行的调用将在运行时解析为 Base 定义的版本。
接下来三条调用语句是通过不同类型的指针进行的,每个指针分别指向继承体系中的一个类型。因为 Base 类中没有 f2() 所以第一条语句是非法的,即使当前的指针碰巧指向了一个派生类对象也无济于事。
为了完整的阐明上述问题,下面示例调用过程中:
Base* p1 = &d2obj; D1* p2 = &d2obj; D2* p3 = &d2obj;
p1->fcn(42); // compile error
p2->fcn(42); // 静态绑定 D1::fcn(int)
p3->fcn(42); // 静态绑定 D2::fcn(int)
在上面的每条调用语句中,指针都指向了 D2 类型的对象,但是由于我们调用的是非虚函数,所以不会发生动态绑定。实际调用的函数版本由指针的静态类型决定。
覆盖重载的函数
和其他函数一样,成员函数无论是否是虚函数都能被重载。派生类可以覆盖重载函数的 0 个或多个实例。如果派生类希望所有的重载版本对于它来说都是可见的,那么它就需要覆盖所有的版本,或者一个也不覆盖。
有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将极其烦琐。
一种好的解决方案是为重载的成员提供一条 using 声明语句,这样我们就无须覆盖基类中的每一个重载版本了。using 声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using 声明就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了,而无须为继承而来的其他函数重新定义。
类内 using 声明的一般规则同样适用于重载函数的名字,基类函数的每个实例在派生类中都必须是可访问的。对派生类没有重新定义的重载版本的访问实际上是对 using 声明点的访问。
class Base {
public:
void func() { cout << "Base::func()" << endl; }
void func(int) { cout << "Base::func(int)" << endl; }
void func(int, int) { cout << "Base::func(int, int)" << endl; }
};
class Derived : public Base {
public:
using Base::func;
void func(int) { cout << "Derived::func(int)" << endl; }
};
...
Derived d;
d.func(); // Base::func()
d.func(1); // Derived::func(int)
d.func(1, 2); // Base::func(int, int)
构造函数与拷贝控制
虚析构函数
继承关系对基类拷贝控制最直接的影响是基类通常应该定义一个虚析构函数。
当我们 delete 一个动态分配的对象的指针时将执行析构函数,如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。
和其他虚函数一样,析构函数的虚属性也会被继承,无论派生类使用合成的析构还是定义自己的析构函数,都将是虚析构函数。只要基类的析构函数是虚函数,就能确保当我们 delete 基类指针时将运行正确的析构函数版本:
class Quote {
public:
// 如果我们删除的是一个指向派生类的对象的基类指针,则需要虚析构函数
virtual ~Quote() = default;
};
...
Quote* p = new Bulk_quote;
delete p;
如果基类的析构函数不是虚函数,则 delete 一个指向派生类对象的基类指针将产生未定义的行为。
一条经验准则:如果一个类需要析构函数,那么它也同样需要拷贝和赋值操作。基类的析构是一个重要的例外,因为一个基类总是需要析构函数,而且需要将析构函数定义为虚函数。
虚析构函数将阻止合成移动操作
基类需要一个虚析构函数这一事实还会对基类和派生类的定义产生另外一个间接的影响:如果一个类定义了析构函数,即使是通过 =default 的形式使用了合成的版本,编译器也不会为这个类合成移动操作。
合成拷贝控制与继承
基类或派生类的合成拷贝控制成员的行为与其他合成的构造函数、赋值运算符或析构函数类似:它们对类本身的成员依次进行初始化、赋值或销毁操作。此外,这些合成的成员还负责使用直接基类中对应的操作对一个对象的直接基类部分进行初始化、赋值或销毁的操作。
无论基类成员是合成的版本,还是自定义的版本都没有太大的影响,唯一的要求是相应的成员应该可访问,并且不是一个被删除的函数。
基类通常都应该定义一个虚析构函数,即使基类根本不需要析构函数也最好这么做。将基类的析构函数定义成虚函数的原因是为了确保当我们删除一个基类指针,而该指针实际指向一个派生类对象时,程序也能正确运行。
派生类中删除的拷贝控制与基类的关系
就像其他类的情况一样,基类和派生类也有可能将默认的构造函数或者任何一个拷贝控制成员定义成被删除的函数。
- 如果基类中的默认构造函数、拷贝构造函数、拷贝运算符或析构函数是被删除的函数或者不可访问的,则派生类中对应的成员将是被删除的。
- 如果基类中有一个不可访问或删除掉的析构函数,则派生类中合成的默认和拷贝构造函数将是被删除的。
- 编译器将不会合成一个删除掉的移动操作。如果基类的析构函数是删除的或不可访问的,则派生类的移动构造函数也将是被删除的。
class B {
public:
B();
B(const B&) = delete;
};
class D : public B {
// no construct
};
...
D d; // ok, D 的默认构造使用 B 的默认构造
D d2(d); // compile error, D 的合成拷贝构造函数是被删除的
D d3(std::move(d)); // compile error, 隐式地使用 D 的被删除的拷贝构造函数
基类 B 含有一个可访问的默认构造函数和一个显式删除的拷贝构造函数。因为我们定义了拷贝构造函数,所以编译器不会为 B 合成一个移动构造函数。因此,我们既不能移动也不能拷贝 B 的对象。如果 B 的派生类希望它自己的对象能被移动和拷贝,则派生类需要自定义相应版本的函数。当然,在这一过程中派生类还必须考虑如何移动或拷贝其基类部分的成员。在实际编程过程中,如果在基类没有默认、拷贝或移动构造函数,则一般情况下,派生类也不会定义相应的操作。
移动操作与继承
大多数基类都会定义一个虚析构函数。因此在默认情况下,基类通常不含有合成的移动操作,而且在它的派生类中也没有合成的移动操作。
因为基类缺少移动操作会阻止派生类拥有自己的合成移动操作,所以当我们确实需要执行移动操作是,应该首先在基类中进行定义。我们的 Quote 可以使用合成的版本,不过前提是 Quote 必须显式地定义这些成员。一旦 Quote 定义了自己的移动操作,那么它必须同时显式地定义拷贝操作
class Quote {
public:
Quote() = default;
virtual ~Quote() = default;
Quote(const Quote& other) = default;
Quote(Quote&& other) noexcept = default;
Quote& operator=(const Quote& other)= default;
Quote& operator=(Quote&& other) noexcept = default;
// ... 其他成员与之前的一致
private:
string book_no;
double price = .0L;
};
通过上面的定义,我们就能对 Quote 的对象逐成员地分别进行拷贝、移动、赋值和销毁操作了。而且除非 Quote 的派生类中含有排斥移动的成员,否则它将自动获得合成的移动操作。
当然也可以手动实现,手动实现需要注意运算符操作时需要返回引用以及 noexpect
Quote(const Quote& other) :book_no(other.book_no), price(other.price) {}
Quote(Quote&& other) noexcept :book_no(other.book_no), price(other.price) {}
Quote& operator=(const Quote& other) {
book_no = other.book_no;
price = other.price;
return *this;
}
Quote& operator=(Quote&& other) noexcept {
book_no = other.book_no;
price = other.price;
return *this;
}
为了代码可读性,为了减少复杂度,看起来使用 =default 有时候也是很好的选择
派生类的拷贝控制成员
派生类构造函数在其初始化阶段中不但要初始化派生类自己的成员,还负责初始化派生类对象的基类部分。因此,派生类的拷贝和移动构造函数在拷贝和移动自有成员的同时,也要拷贝和移动基类部分的成员。类似的,派生类赋值运算符也必须为其基类部分的成员赋值。
和构造函数及赋值运算符不同的是,析构函数只负责销毁派生类自己分配的资源。
当派生类定义了拷贝或移动操作时,该操作负责拷贝或移动包括基类部分成员在内的整个对象。
定义派生类的拷贝或移动构造函数
当为派生类定义拷贝或移动构造函数时,我们通常使用对应的基类构造函数初始化对象的基类部分:
class Base {};
class D :public Base {
public:
D(const D& d) :Base(d) { }
D(D&& d) noexcept :Base(std::move(d)) {}
};
初始值 Base(d) 将一个 D 对象传递给基类构造函数。尽管从道理上来说,Base 可以包含一个参数为 D 的构造函数,但是在实际编程过程中通常不会这么做。相反,Base(d) 一般会匹配 Base 的拷贝构造函数。D 类型的对象 d 将被绑定到该构造函数的 Base& 形参上。Base 的拷贝构造函数负责将 d 的基类部分拷贝给要创建的对象。假如我们没有提供基类的初始值的话:
// D 的这个拷贝构造函数很可能是不正确的定义
// 基类部分被默认初始化,而非拷贝
D(const D& d) // 成员初始化,但是没有提供基类初始值
{/* ... */ }
在上面的例子中,Base 的默认构造函数将被用来初始化 D 对象的基类部分。假定 D 的构造函数从 d 中拷贝了派生类成员,则这个新构建的对象的配置将非常奇怪:它的 Base 成员被赋予了默认值,而 D 成员的值则是从其他对象拷贝得来的。
在前面提到:派生类必须使用基类的构造函数来初始化它的基类部分。每个类控制它自己的成员初始化过程。
在默认情况下,基类默认构造函数初始化派生类对象的基类部分。如果我们想拷贝(或移动)基类部分,则必须在派生类的构造函数初始值列表中显式地使用基类的拷贝(或移动)构造函数。
派生类赋值运算符
与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值:
// Base::operator=(const Base&) 不会自动调用
D& operator=(const D& rhs) {
Base::operator=(rhs); // 为基类部分赋值
// 按照过去的方式为派生类的成员赋值
// 酌情处理自赋值,以及释放已有资源等情况
return *this;
}
上面的运算符首先显式地调用基类赋值运算符,令其为派生类对象的基类部分赋值。基类的运算符(应该可以)正确地处理自赋值的情况,如果赋值命令是正确的,则基类运算符将释放掉其左侧运算对象的基类部分的旧值,然后利用 rhs 为其赋一个新值。随后,我们继续进行其他为派生类成员赋值的工作。
值得注意的是,无论基类的构造函数或赋值运算符是自定义的版本还是合成的版本,派生类的对应操作都能使用它们。例如,对于 Base::opertaor= 的调用将执行 Base 的拷贝赋值运算符,至于该运算符是由 Base 显式定义的还是由编译器合成的无关紧要。
派生类析构函数
在析构函数体执行完成后,对象的成员会被隐式销毁。类似的,对象的基类部分也是隐式销毁的。
因此,和构造函数及赋值运算符不同的是,派生类析构函数只负责销毁由派生类自己分解的资源。
class D :public Base {
public:
// Base::~Base() 被自动调用执行
~D(){ /* 该处由用户定义清除派生类成员的操作 */}
};
对象销毁的顺序正好与其创建的顺序相反:派生类析构函数首先执行,然后是基类的析构函数,以此类推,沿着继承体系的反方向直至最后。
不要在构造函数和析构函数中调用虚函数
派生类对象的基类部分将首先被构建。当执行基类的构造函数时,该对象的派生类部分是未被初始化的状态。类似的,销毁派生类对象的次序正好相反,因此当执行基类的析构函数时,派生类部分已经被销毁了。由此可知,当我们执行上述基类成员的时候,该对象处于未完成的状态。
为了能够正确的处理这种未完成的状态,编译器认为对象的类型在构造或析构的过程中仿佛发生了改变一样。也就是说,当我们构建一个对象时,需要把对象的类和构造函数的类看作是同一个;对虚函数的调用的绑定正好符合这种把对象的类和构造函数的类看作是同一个的要求;对于析构函数也是同样的道理。上述的绑定不但对直接调用虚函数有效,对间接调用也是有效的,这里的间接调用是指通过构造函数(或析构函数)调用另一个函数。
为了理解上述行为,不妨考虑当基类构造函数调用虚函数的派生类版本时会发生什么情况。这个虚函数可能会访问派生类的成员,毕竟,如果它不需要访问派生类成员的话,则派生类直接使用基类的虚函数版本就可以了。然而,当执行基类构造函数时,它要用到的派生类成员尚未初始化,如果我们允许这样的访问,则程序很可能会崩溃。
如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所述类型相对应的虚函数版本。使用作用域运算符执行特定版本。
继承的构造函数
在 C++ 11 新标准中,派生类能够重用其直接基类定义的构造函数。尽管如我们所知,这些构造函数并非以常规的方式继承而来,但是为了方便,我们不妨姑且称其为“继承”的。一个类只初始化它的直接基类,出于同样的原因,一个类也只继承其直接基类的构造函数。类不能继承默认、拷贝和移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。
派生类继承基类构造函数的方式是提供了一条注明了(直接)基类名的 using 声明语句。
举个例子,我们可以重新定义 Bulk_quote 类,令其继承 Disc_quote 类的构造函数
class Bulk_quote : public Disc_quote {
public:
using Disc_quote::Disc_quote; // 继承 Disc_quote 的构造函数
double net_price(size_t t) const;
};
通常情况下,using 声明语句只是令某个名字在当前作用域内可见。而当作用于构造函数时,using 声明语句将令编译器产生代码。对于基类的每个构造函数,编译器都生成一个与之对应的派生类构造函数。换句话说,对于基类的每个构造函数,编译器都在派生类中生成一个形参列表完全相同的构造函数。
这些编译器生成的构造函数形如:
derived(param) : base(args) { }
其中 derived 是派生类的名字,base 是基类的名字,param 是构造函数的形参列表,args 将派生类构造函数的形参传递给基类的构造函数。在我们的 Bulk_quote 类中,继承构造函数定价为
Bulk_quote(const string& book, double price, size_t qty, double disc) :
Disc_quote(book, price, qty, disc) {}
如果派生类含有自己的数据成员,则这些成员将被默认初始化。
继承的构造函数的特点
和普通成员的 using 声明不一样,一个构造函数的 using 声明不会改变该构造函数的访问级别。
例如,不管 using 声明出现在哪儿,基类的私有构造函数在派生类中还是一个私有构造函数;受保护的构造函数和公有构造函数也是同样的规则。
而且,一个 using 声明语句不能指定 explicit 或 constexpr 如果基类的构造函数是 explicit 或者 constexpr ,则继承的构造函数也拥有相同的属性。
当一个基类构造函数含有默认实参时,这些实参并不会被继承。相反,派生类将获得多个继承的构造函数,其中每个构造函数分别省略掉一个含有默认实参的形参。例如,如果基类有一个接受两个形参的构造函数,其中第二个形参含有默认实参,则派生类将获得两个构造函数:一个构造函数接受两个形参(没有默认实参),另一个构造函数只接受一个形参,它对应于基类中最左侧的没有默认值的那个形参。
如果基类含有几个构造函数,则除了两个例外情况,大多数时候派生类会继承所有这些构造函数。第一个例外是派生类可以继承一部分构造函数,而为其他构造函数定义自己的版本。如果派生类定义的构造函数与基类的构造函数具有相同的参数列表,则该构造函数不会被继承。定义在派生类中的构造函数将替换继承而来的构造函数。
第二个例外是,默认构造、拷贝和移动构造函数不会被继承。这些构造函数按照正常规则被合成。继承的构造函数不会被作为用户定义的构造函数来使用,因此,如果一个类只含有继承的构造函数,则它也将拥有一个合成的默认构造函数。派生类合成的默认构造函数会调用基类的默认构造函数去初始化基类部分的数据成员。
容器与继承
当我们使用容器存放继承体系中的对象时,通常必须采取间接存储的方式。因为不允许在容器中保存不同类型的元素,所以我们不能把具有继承关系的多种类型的对象直接存放在容器中。
当派生类对象被赋值给基类对象时,其中的派生类部分将被“切掉”,因此容器和存在继承关系的类型无法兼容。
在容器中放置(智能)指针而非对象
当我们希望在容器中存放具有继承关系的对象时,我们实际上存放的通常是基类的指针(更好的选择是智能指针)。和往常一样,这些指针所指对象的动态类型可能是基类类型,也可能是派生类类型。
继承与容器示例
class Basket
{
public:
void add_item(const Quote &sale)
{
items.insert(shared_ptr<Quote>(sale.clone()));
}
void add_item(const Quote &&sale)
{
items.insert(shared_ptr<Quote>((move(sale)).clone()));
}
double total_receipt(ostream &) const;
private:
static bool compare(const shared_ptr<Quote> &lhs, const shared_ptr<Quote> &rhs)
{
return lhs->isbn() < rhs->isbn();
};
multiset<shared_ptr<Quote>, decltype(compare) *> items{compare};
};
模拟虚拷贝
为了让容器可以接受不同对象的拷贝,因此需要在基类中增加虚拷贝实现,虚拷贝应该返回指针或者智能指针。
虚拷贝函数 clone 应当根据作用于左值还是右值而分为不同的重载版本。
class Quote
{
...
virtual Quote *clone() const & { return new Quote(*this); };
virtual Quote *clone() &&
{
return new Quote(move(*this));
};
...
};
这个容器可以接收 Quote 和 Quote 的派生类,基类也可以直接返回一个 shared_ptr 更加直观
像这样的语句 shared_ptr<Quote> p = make_shared<Bulk_Quote>(args); 是完美支持的
class Quote {
public:
// ...
virtual shared_ptr<Quote> clone() const& {
return make_shared<Quote>(*this);
}
virtual shared_ptr<Quote> clone() && {
return make_shared<Quote>(std::move(*this));
}
};
class Bulk_quote : public Disc_quote {
public:
// ...
virtual shared_ptr<Quote> clone() const& override {
return make_shared<Bulk_quote>(*this);
}
virtual shared_ptr<Quote> clone() && override {
return make_shared<Bulk_quote>(std::move(*this));
}
}
可能有的编译器版本不支持直接 return make_share
文本查询程序示例
class query_res;
class text_query
{
public:
using line_no = vector<string>::size_type;
text_query(ifstream &);
query_res query(const string &) const;
private:
shared_ptr<vector<string>> file;
map<string, shared_ptr<set<line_no>>> wm;
};
text_query::text_query(ifstream &ifs) : file(make_shared<vector<string>>())
{
string text;
while (getline(ifs, text))
{
file->push_back(text);
int n = file->size() - 1;
istringstream line(text);
string word;
while (line >> word)
{
auto &lines = wm[word];
if (!lines)
{
lines.reset(new set<line_no>);
}
lines->insert(n);
}
}
}
class query_res
{
public:
using line_no = vector<string>::size_type;
query_res(string s, shared_ptr<set<line_no>> p, shared_ptr<vector<string>> f)
: sought(s), lines(p), file(f) {}
set<line_no>::iterator begin() { return lines->begin(); }
set<line_no>::iterator end() { return lines->end(); }
shared_ptr<vector<string>> get_file() { return file; }
int res_size() const { return lines->size(); }
private:
string sought;
shared_ptr<set<line_no>> lines;
shared_ptr<vector<string>> file;
};
query_res text_query::query(const string &sought) const
{
static shared_ptr<set<line_no>> nodata = make_shared<set<line_no>>();
auto loc = wm.find(sought);
if (loc == wm.end())
{
return query_res(sought, nodata, file);
}
else
{
return query_res(sought, loc->second, file);
}
}
class query_base
{
friend class query;
protected:
using line_no = text_query::line_no;
virtual ~query_base() = default;
private:
virtual query_res eval(const text_query &) const = 0;
virtual string rep() const = 0;
};
class query
{
friend query operator~(const query &);
friend query operator|(const query &, const query &);
friend query operator&(const query &, const query &);
public:
query(const string &);
query_res eval(const text_query &t) const { return q->eval(t); };
string rep() const { return q->rep(); };
private:
query(shared_ptr<query_base> query) : q(query) {}
shared_ptr<query_base> q;
};
class word_query : public query_base
{
friend query;
word_query(const string &s) : query_word(s){};
query_res eval(const text_query &t) const { return t.query(query_word); }
string rep() const { return query_word; }
private:
string query_word;
};
query::query(const string &s) { q = make_shared<word_query>(word_query(s)); }
class not_query : public query_base
{
friend query operator~(const query &);
not_query(const query &q) : inner_query(q) {}
string rep() const { return "~(" + inner_query.rep() + ")"; }
query_res eval(const text_query &) const;
query inner_query;
};
inline query operator~(const query &operand)
{
shared_ptr<query_base> qb = make_shared<not_query>(not_query(operand));
return qb;
}
class binary_query : public query_base
{
protected:
binary_query(const query &l, const query &r, string s) : l(l), r(r), op_sym(s){};
string rep() const { return "(" + l.rep() + " " + op_sym + " " + r.rep() + ")"; }
query l, r;
string op_sym;
};
class and_query : public binary_query
{
friend query operator&(const query &, const query &);
and_query(const query &l, const query &r) : binary_query(l, r, "&") {}
query_res eval(const text_query &) const;
};
inline query operator&(const query &l, const query &r)
{
shared_ptr<query_base> qb = make_shared<and_query>(and_query(l, r));
return qb;
}
class or_query : public binary_query
{
friend query operator|(const query &, const query &);
or_query(const query &l, const query &r) : binary_query(l, r, "|") {}
query_res eval(const text_query &) const;
};
inline query operator|(const query &l, const query &r)
{
shared_ptr<query_base> qb = make_shared<or_query>(or_query(l, r));
return qb;
}
query_res or_query::eval(const text_query &tq) const
{
auto left = l.eval(tq);
auto right = r.eval(tq);
auto ret_line = make_shared<set<line_no>>(left.begin(), left.end());
ret_line->insert(right.begin(), right.end());
return query_res(rep(), ret_line, left.get_file());
}
query_res and_query::eval(const text_query &tq) const
{
auto left = l.eval(tq);
auto right = r.eval(tq);
auto ret_line = make_shared<set<line_no>>();
set_intersection(left.begin(), left.end(), right.begin(), right.end(),
inserter(*ret_line, ret_line->begin()));
return query_res(rep(), ret_line, left.get_file());
}
query_res not_query::eval(const text_query &tq) const
{
auto result = inner_query.eval(tq);
auto ret_lines = make_shared<set<line_no>>();
auto beg = result.begin();
auto end = result.end();
auto sz = result.get_file()->size();
for (size_t i = 0; i < sz; i++)
{
if (beg != end)
{
if (*beg == i)
{
beg++;
}
else
{
ret_lines->insert(i);
}
}
}
return query_res(rep(), ret_lines, result.get_file());
}
实际开发里怎么理解 C++ 面向对象
如果只看语法,继承、虚函数、动态绑定很容易被学成一堆分散知识点;但放到真实工程里,它们本质上都在解决同一个问题:如何在保证接口稳定的前提下,允许不同类型提供各自实现。
这也是为什么 C++ 面向对象设计里,接口边界和继承关系必须非常谨慎。不是所有相似类都值得继承,也不是所有函数都应该做成虚函数。真正重要的是,你是否真的需要“以统一接口处理不同对象”,以及这种抽象是否能让系统更容易扩展,而不是更难理解。
总结
C++ 面向对象的关键,不是会不会写类继承语法,而是能不能正确理解抽象、继承和动态绑定分别承担什么角色。
数据抽象负责隔离接口和实现,继承负责表达类型关系,动态绑定负责让统一接口在运行时落到不同实现。把这三个点连起来理解,面向对象在 C++ 里就不再只是术语,而会变成可以真正用于设计代码结构的工具。