前言:主要是自己学习过程的积累笔记,所以跳跃性比较强,建议先自学后拿来作为复习用。
文章目录
- 1 定义父类和子类
- 1.1 定义父类
- 访问说明符 protected
- 1.2 定义子类
- 1.3 子类向父类的转换
- 1.4 转换的例外
- 1.5 子类的构造函数
- 1.6 静态成员不能继承
- 1.7 防止继承的发生
- 2 虚函数
- 2.1 静态类型和动态类型
- 2.2 调用虚函数
- 2.3 子类中的虚函数
- 2.3.1 返回类型不一致
- 2.3.2 形参列表不一致
- override 和 final 关键字
- 2.3.3 回避动态绑定
- 3 抽象基类
- 重构
- 4 访问控制与继承
1 定义父类和子类
面向对象程序设计的核心思想是数据抽象、继承和动态绑定。通过使用数据抽象,我们可以将类的接口与实现分离;使用继承,可以定义相似的类并对其相似关系建模;使用动态绑定,可以在一定程度上忽略相似类的区别,而以统一的方式使用它们。
建议读者在学习这一内容之前先将 C++ 类的知识理解透彻,具体可参考:C++ 学习笔记(八)(类篇一);如果想知道更多关于 C++ 类的知识,可以参考:C++ 学习笔记(九)(类篇二)。这两篇文章总结的非常详细。
1.1 定义父类
如果我们希望让一个类拥有另一个类的成员变量和成员函数,同时又能定义它自己的数据成员,就可以用到类的继承。通过继承联系在一起的类构成了一种层次关系。在层次关系根部的被称为基类,继承基类的类被称为派生类。当然,我们更多的会使用父类与子类来描述类之间的继承关系。
定义一个父类来表示某一书籍的销售情况:
读者可以把每一处的代码复制到一个文本中,就不需要重复的往前看了。
// Root 类表示所有书籍的销售情况
class Root
{
public:
// 该语句使编译器为 Root 类生成一个默认构造函数
Root() = default;
// 该函数是 Root 类的构造函数
Root(const string &book, double sales_price) :
bookNo(book), price(sales_price) {}
// 该函数返回书籍的编号
string get_number() { return bookNo; }
// 该函数计算并返回该书籍的销售额
virtual double net_price(int n) const { return n * price; }
// 析构函数
virtual ~Root() = default;
private:
string bookNo; // book number,书籍的编号
protected:
double price = 0.0; // 不打折时书本的售价
}; // 分号不要忘记
子类不仅可以继承父类的成员函数,还能对其进行修改。前提是在父类中,该函数用 virtual 关键字修饰过,这样的成员函数被称为虚函数。任何除了构造函数之外的非静态函数都可以定义成虚函数。virtual 关键字只能出现在类的内部。C++ 把子类重写父类的虚函数这一操作称为覆盖(override)。
访问说明符 protected
尽管子类可以继承父类的数据成员,但是默认情况下,子类只能访问父类中的公有成员,不能访问私有成员。如果父类希望它的子类有权访问某些成员,但又不想让其他的类访问该成员,就可以用受保护的(protected)访问说明符说明这些成员。
1.2 定义子类
子类必须通过类派生列表来说明它继承了哪个父类。类派生列表的形式是:class 子类名 : 访问说明符 父类名。不同的父类之间用逗号隔开。子类必须重新声明一遍父类的虚函数。
定义一个子类来表示某一书籍的打折情况:
// 利用类派生列表说明 Son 类继承自 Root 类
// Son 类表示某一类书籍的销售情况
class Son : public Root
{
public:
Son() = default;
Son(const string&, double, int, double);
// 该函数覆盖了父类的虚函数,用以实现打折后的销售额
double net_price(int) const override;
private:
// 子类也可以定义自己的成员变量
int amount = 0; // 当购买数量达到 amount 时予以折扣优惠
double discount = 0.0; // 折扣值
};
子类 Son 从父类那里继承了 get_number 函数,以及 bookNo 和 price 等成员变量。此外它也定义了自己的 net_price 函数,以及 amount 和 discount 成员变量。
子类可以选择不覆盖父类的虚函数,这样的话它会直接继承父类的虚函数版本。子类可以在覆盖虚函数时也使用 virtual 关键字,但更常用的做法,也是 C++ 新标准下的做法是:在形参列表之后、或者常量成员函数的 const 关键字之后(比如 Son 类中的做法)、或者在引用成员函数的引用限定符之后添加关键字 override。
1.3 子类向父类的转换
一个 Son 类对象包含以下两个部分:
但是要清楚一点,继承自父类的部分和子类自定义的部分,在内存中不一定是连续存储的。一个子类可以继承多个父类,其对象也就包含多个组成部分,这些部分也不一定是连续存储的。同时,类也可以多层次继承,比如 A 继承 B,B 又继承 C,C 又继承 D。
因为在子类中有父类的组成部分,所以可以把子类的对象当做父类的对象来使用。即可以让父类的指针指向子类对象,或者令父类的引用绑定到子类对象上。
Root root_obj; // Root 对象
Son son_obj; // Son 对象
Root *p = &root_obj; // p 指向 Root 对象
p = &son_obj; // p 指向 Son 对象
Root &r = son_obj; // r 绑定到 Son 对象的 Root 部分
这种转换通常称为子类到父类的类型转换,编译器会隐式地执行该转换。还有一种情况,当一个函数的形参是 Root 类的对象时,我们也能向其传递 Son 类的实参对象。
1.4 转换的例外
子类向父类的自动类型转换只对指针或者引用有效,在子类类型和父类类型之间则不存在这样的转换。举个栗子,如果有 Root 类对象 A,Son 类对象 B,那么执行 A = B 时,并不会把 B 强制转换为 Root 类型。这么做,只是将 B 中含有父类的部分拷贝给 了 A,而 B 中属于子类的部分则被切掉了。
要理解在具有继承关系的类之间发生的类型转换,有四点非常重要:
- 从子类向父类的类型转换只对指针或引用类型有效。
- 父类向子类不存在隐式地类型转换。
- 子类向父类的类型转换也可能会由于访问受限而变得不可行。
- 将一个子类对象拷贝、移动或赋值给一个父类对象,只操作子类对象中的父类部分。
1.5 子类的构造函数
尽管子类对象含有从父类继承而来的成员变量,但子类不能直接初始化它们,而必须使用父类的构造函数来初始化父类的部分。换句话说,每个类只负责它自己成员的初始化。定义 Son 类的构造函数如下:
Son(const string &book, double p, int amt, double disc) :
Root(book, p), amount(amt), discount(disc) {}
该构造函数将其前两个参数 book 和 p 传递给 Root 的构造函数,而不是由它自己来执行初始化。如果我们不显示地指出父类成员的初始化方式,则它们会以父类的默认构造函数来执行默认初始化。
总之一点就是:每个类负责定义自己的接口。与类的对象交互必须通过该类的接口,即使是子类,也只能通过接口访问其父类的成员。这有助于我们更好地管理自己的代码,也能使其更加安全。
1.6 静态成员不能继承
如果父类定义了一个静态成员,则在整个继承体系中都只存在该成员的唯一定义。可以通过四种方式访问静态成员:
// 定义一个 Base 类
class Base
{
public:
static void function(); // Base 类的静态成员
};
// 定义一个继承自 Base 的子类
class Derived : public Base
{
void f(const Derived&); // 子类中的一个成员函数
};
// 在子类外部定义 f 函数,接受一个 Derived 对象的引用作为实参
void Derived::f(const Derived& derived_obj)
{
Base::function(); // 通过父类直接访问函数
Derived::function(); // 通过子类直接访问函数
derived_obj.function(); // 通过子类对象访问函数
function(); // 通过 this 对象访问,相当于 this->function();
}
1.7 防止继承的发生
如果不希望某一个类被继承,可以在类名后加上 final 关键字:
class Base final { /* */ }; // Base 类不能作为父类
2 虚函数
2.1 静态类型和动态类型
静态类型指变量声明时的类型或者表达式生成的类型,它在程序编译的时候就确定了;动态类型是变量或表达式表示的内存中的对象的类型。比如 1.3 代码中的指针 p,它的静态类型是 Root,但后面绑定到了一个 Son 类的对象上,所以它的动态类型是 Son。
由此也可知道,如果表达式不是父类的指针或引用,那么它的静态类型和动态类型是永远保持一致的。
注意将静态类型和静态成员区分开来。前者指变量的类型,后者指变量的属性。
2.2 调用虚函数
通常情况下,如果我们不打算使用某个函数,就无须为该函数提供定义。但虚函数不管是否被用到都必须定义。当我们使用父类的引用或指针调用一个成员函数时会执行动态绑定,动态绑定的意思就是编译器会根据传入的实参类型,来决定使用哪个版本的虚函数,因此动态绑定也叫运行时绑定。
由于上述原因,当某个虚函数通过指针或引用被调用时,编译器产生的代码直到运行时才能确定应该调用哪个版本(先编译,后运行)。此处定义一个函数,当购买的书籍和数量都确定时,它负责打印销售额:
// 计算并打印售出指定数量的书籍所得到的销售额
double print_total(ostream &os, const Root &item, int n)
{
// 根据传入 item 形参的对象的类型调用 Root::net_price 或者 Son::net_price
double result = item.net_price(n);
os << "ISBN: " << item.isbn() // 调用 Root::isbn
<< "sold: " << n << "total due: " << result << endl;
return result;
}
该函数接受一个输出流和一个父类对象,以及该对象(书籍)的销量,在函数中通过对象调用 net_price 函数来计算销售额。此时我们定义父类和子类的对象来调用它:
// 调用父类的构造函数创建对象
Root base_obj("I am father.", 10);
// print_total 函数接受父类对象实参,调用的是父类虚函数,即 Root::net_price
print_total(cout, base_obj, 100);
// 调用子类的构造函数创建对象
Son derived_obj("I am son.", 10, 10, 0.2);
// print_total 函数接受子类对象实参,调用的是子类虚函数,即 Son::net_price
print_total(cout, derived_obj, 100);
以上便是动态绑定的的过程。必须要搞清楚,动态绑定只有当我们通过指针或引用调用虚函数时才会发生,也只有在这种情况下对象的动态类型与静态类型才有可能不一致。当我们通过一个非指针非引用的表达式调用虚函数时,程序在编译时就会确定好调用的版本。这也是 C++ 多态性的一个体现:具有继承关系的多个类型称为多态类型,我们能使用这些类型的“多种形式”而无须在意它们的差异。
2.3 子类中的虚函数
若子类想重写父类的虚函数,那么函数的返回类型、名字、参数列表全部都得和父类一样,仅仅是函数的具体实现不同(注意,这不同于函数的重载)。
2.3.1 返回类型不一致
如果返回类型不一样,编译就会出错。但是此处有一个例外,当返回类型是类本身的指针或引用时,父类子类虚函数的返回类型可以不一致。比如子类 A 继承自(派生自)父类 B,则类 A 的虚函数返回 A* 的同时类 B 的虚函数可以返回 B*,前提是从 A 到 B 的类型转换是可访问的。在后面会解释如何确定一个父类的可访问性。
2.3.2 形参列表不一致
override 和 final 关键字
如果参数列表不一致,编译器会认为子类新定义的这个函数和父类中的虚函数是相互独立的。从编程的角度而言,这意味着发生了错误,但实际编译时是可以编译通过的。要想调试发现这种错误很难,解决的办法就是,在重写的虚函数后加上 override 关键字,来告诉编译器该函数对父类中的虚函数进行覆盖。此时如果形参列表不一致就会报错;如果在父类没有 override 标记的虚函数,编译器也会报错。
同时,如果我们不希望某个函数被覆盖,可以在其后加上 final 关键字。之后任何尝试覆盖该函数的操作都将引发错误:
struct A
{
virtual void f1(int) const;
virtual void f2();
void f3();
void f5() final;
};
struct B : A
{
void f1(int) const override; // 正确:f1 与父类 A 中的 f1 匹配
void f2(int) override; // 错误:父类 A 没有 f2(int) 函数
void f3() override; // 错误:f3 不是虚函数
void f4() override; // 错误:父类 A 没有 f4 函数
void f5() override; // 错误:父类 A 已将 f5 声明成 final
};
2.3.3 回避动态绑定
在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫执行虚函数的某个版本。比如当一个子类的虚函数需要调用父类的虚函数版本时,就可以通过使用作用域运算符来实现:
double result = object->Root::net_price(10);
该代码会强行调用 Root 类的 net_price 函数,而不管 object 实际指向的是 Root 对象还是 Son 对象。该调用将在编译时完成解析。通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制。
3 抽象基类
在 1.2 中定义子类时,我们为子类添加了成员变量,一个是 amount,另一个是 discount。代表当购买数量达到一个值时给予折扣的优惠。如果我们希望将折扣策略改成:购买数量未达到一个值时才享受折扣的优惠。这两个策略都要求一个购买量的值 amount 和一个折扣值 discount。因此我们可以将它们两个抽象出来,放到一个类 Disc 中。来令其他类继承自 Disc 类,这样每个子类都能定义自己的 net_price 函数,来制定自己的折扣策略。
但是此处就出现一个问题: Disc 的作用仅仅是提供两个成员变量给子类,并不代表任一类书籍,也不实现特定的折扣策略,更不定义自己的 net_price 函数。为了防止出现不知名的意外,我们可以在 Disc 中将 net_price 函数定义成纯虚函数。纯虚函数无须定义,只需要在虚函数的函数体前加上一个“ = 0 ”即可,该标记只能出现在类的内部虚函数的声明处:
// 用于保存折扣值和购买量的类,子类使用这些数据以实现不同的折扣策略
// Disc 类继承自 Root 类,所以有 net_price 函数
class Disc :: public Root
{
public:
Disc() = default;
Disc(const string &book, double price, int amt, double disc) :
Root(book, p), amount(amt), double disc) {}
double net_price(int) const = 0; // 加上 = 0 声明成纯虚函数
protected:
// 将 Son 类的购买值和折扣值移到 Disc 类中
int amount = 0; // 购买值
double discount = 0.0; // 折扣值
};
含有纯虚函数的类被称为抽象基类,我们不能定义这种类的对象,按理来说是用不上构造韩色儿,但 Disc 类仍然定义了一个默认构造函数和一个接受四个参数的构造函数。这是因为 Disc 类的子类的构造函数会用到 Disc 类的构造函数,来初始化它们的属于 Disc 类的部分。这里要补充一点,尽管没有意义,但我们仍然可以定义纯虚函数,只不过在类内只能声明纯虚函数,定义必须写到类外。
抽象基类只负责定义接口,后续的其他类可以覆盖这个接口。我们不能直接创建一个抽象基类的对象,但是可以创建抽象基类子类的对象,前提是这些子类覆盖了抽象基类的纯虚函数。
既然我们将购买值和折扣值移到了 Disc 类中, 就可以重写 Son 类了。这一次让它继承 Disc 而非 Root:
// Son 类表示某一类书籍的销售情况
class Son : public Disc
{
public:
Son() = default;
// Son 的构造函数会调用 Disc 的构造函数进行初始化
Son(const string &book, double price, int amt, double disc) :
Disc(book, price, amt, disc) {}
// 该函数覆盖了父类的虚函数,用以实现打折后的销售额
double net_price(int) const override;
};
这个时候 Son 的直接基类是 Disc,间接基类是 Root。每个 Son 对象包含三个子对象:一个空的 Son 部分,一个 Disc 子对象和一个 Root 子对象。在 C++ 中,每个类控制其对象的初始化过程。因此,即使 Son 类没有自己的数据成员,它也仍然需要提供一个接受四个参数的构造函数。该构造函数将它的实参传递给 Disc 的构造函数,随后 Disc 的构造函数继续调用 Root 的构造函数。Root 的构造函数首先初始化 bookNo 和 price 成员,够早结束后,运行 Disc 的构造函数并初始化 amount 和 discount 成员,最后运行 Son 的构造函数。
重构
在 Root 的继承体系中增加 Disc 类是重构的一个典型示例。重构负责重新设计类的体系以便将操作或数据从一个类移动到另一个类。对于面向对象的应用程序来说,重构是一种很普遍的现象。不过即使我们改变了整个继承体系,那些使用了 Son 或 Root 的代码也无须进行任何改动,不过一旦类被重构(或以其他方式被改变),就意味着我们必须重新编译含有这些类的代码了。