C++ Primer 5th 第15章 面向对象程序设计

时间:2023-11-28 15:02:08

面向对象程序设计的核心思想是:数据抽象继承动态绑定

数据抽象:将类的接口与实现分离;

继承:定义相似类型并对相似关系建模;

动态绑定:一定程度上上忽略相似类型间的区别,用统一方式使用它们。

继承

通过继承联系在一起的类构成层次关系。层次关系的最底层或者说根部的类叫做基类,直接或者间接从基类继承而来得到的类叫做派生类。

基类负责定义整个层次共有的特性,派生类在基类的基础上根据自身需求进行扩展。

在面向对象程序设计的继承中,基类将需要进行扩展的函数与原封不动直接继承的函数进行区分对待。需要进行自定义扩展的函数,基类将其声明为虚函数

派生类通过类派生列表来指出从哪个基类进行继承。类派生列表的形式是:类名后一个分号,后面紧跟以逗号分割的基类列表。每个基类前可以有访问说明符,对class来说,如果省略,则默认private继承。

class derived: public base  // 以public方式继承base
{
public:
...
};

对于public继承得到的派生类,完全可以将其视为基类对象来使用。

对于在基类中被声明为虚函数的成员函数,在派生类中,若需要根据自身需要对该成员函数进行重新定义,则必须在派生类内部重新声明一次该虚函数。重新声明时,可以省略关键词virtual

动态绑定

通过动态绑定的特性,可以用一段相同代码同时处理基类和派生类的对象。要使用动态绑定,则处理代码的形参必须是对基类对象的引用类型指针类型

例如,有如下代码:

 #include <iostream>

 using std::cout;
using std::endl;
using std::string; class Quote
{
public:
string isbn() const;
virtual double net_price(size_t) const;
}; string Quote::isbn() const
{
return "abc";
} double Quote::net_price(size_t n) const
{
return 3.14;
} class Bulk_quote:public Quote
{
public:
double net_price(size_t ) const;
}; double Bulk_quote::net_price(size_t n) const
{
return 6.28;
} double print_total(std::ostream &os, Quote &item, size_t n)
{
double ret = item.net_price(n);
os << "ISBN: " << item.isbn() << " # sold: " << n << " total due: " << ret << endl;
return ret;
} int main(int argc, char const *argv[])
{
Quote basic;
Bulk_quote bulk;
print_total(cout, basic, );
print_total(cout, bulk, );
return ;
}

如果print_total的形参item是非引用类型的,则无法使用动态绑定特性。

在C++语言中,当使用基类的引用或者指针时调用虚函数时才会发生动态绑定。

对于动态绑定的解析,其过程是发生在运行时,而不是编译时。

虚函数是通过在成员函数的声明语句前加上virtual关键词以使得该函数能被执行动态绑定。构造函数和非静态函数不允许是虚函数。

一旦在基类中,一个成员函数被声明为虚函数,则其派生类中该继承而来的函数也是虚函数。由于virtual用于声明语句,因此在类外定义成员函数时,不可以使用virtual关键词。

继承与访问控制

尽管派生类是从基类继承而来,但是依旧改变不了每个类自身原有的访问权限。基类的公开和私有访问权限,对于其派生类依然有效,派生类只可以访问基类的public部分,不可以访问private部分。

如果基类想杜绝不想干的外部代码访问自身数据或者接口,但又想允许有继承关系的子类们能够访问到这些数据或者接口,则可以使用protected来实现。

类派生访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见,而不是对派生类自身是否可见。可以将基类视作派生类的一个"成员",而访问说明符则是控制该"基类成员"的访问权限。

派生类与基类

对于public继承得到的派生类可以转换为基类。一个派生类对象包含多个组成部分,一部分是自己定义的子对象,一部分是继承而来的基类子对象。因为派生类对象中含有继承的对应部分,因此可以把派生类当做基类来使用。也可以将基类的指针或引用绑定到派生类对象的基类部分上。

我们在前面的第2章中知道,引用绑定时,必须在相同的类型间进行绑定。在对基类的引用进行绑定时,可以使用派生类,这两种类从类型上来说,并不是同一种,然而派生类内部包含一个基类,因此可以使用派生类的基类部分来进行绑定。实际上,在使用基类引用绑定派生类时,会进行派生类到基类的转换,该行为由编译器隐式的执行。

尽管派生类中含有从基类继承而来的成员,但派生类并不能直接初始化这些成员。派生类必须使用基类的构造函数来初始化它的基类部分。

无论继承与否,每个类负责做好自己的初始化过程。虽然我们在派生类中可以直接访问基类的public和protected成员,但是最好是使用基类的构造函数来初始化其成员。

派生类构造函数通过构造函数初始化列表来传递实参给基类构造函数。

除非我们显式在派生类构造函数初始化列表中显式的调用了基类的构造函数,否则基类将默认初始化,也即调用基类的默认构造函数。

初始化的顺序与之前一样,不是由初始化列表的顺序来决定,而是先初始化基类,然后再按派生类数据声明的次序来依次初始化派生类的数据成员。

继承与静态成员

如果基类定义了一个静态成员,则整个继承体系中只存在唯一一个静态成员的定义,不论从基类如何派生,对于静态成员来说,始终只有一个静态实例。

如果静态成员是可访问的,则我们既能通过基类来访问,也能通过派生类来访问。

被用作基类的类

如果在派生类中要使用某个基类,那么该基类必须已经被完整定义。对于不会用到的成员函数,可以只有声明而没有定义。该规定暗示:一个类不能派生自身。

在C++11新标准中,提供了阻止继承的方法,方法是在定义类时,在类名后跟关键词final。形如 class NoDerived final { /*  */ };

类型转换与继承

前面我们已经提到,引用绑定时,必须在相同的类型间进行绑定。在继承关系中,此规则存在一个例外:我们可以将基类的指针或者引用绑定到派生类对象上。其原因我们已经解释过:因为派生类会隐式的转换到基类。

由于基类的指针或者引用能绑定到派生类,这会导致一个结果:使用基类的指针或者引用的时候,该指针或引用所绑定的对象实际类型可能是基类,也可能是派生类。

动态类型与静态类型

在使用具有继承关系的类型时,我们要知道对于这一特殊类型存在着一个普通类型不具有的概念:动态类型和静态类型。

因为C++是一种静态类型的编程语言,所以C++代码中所有的名字或者说对象都具有一种确定的类型,在编译时期一定会进行类型检查,而很多脚本语言都是动态语言,在代码编写时,不需要指定变量或对象的类型,对象的类型是在程序运行时进行检查。

我们在此处提到的C++语言是静态类型的编程语言,和前面提到的具有继承关系的类型中的动态类型和静态类型并不是一回事,一个是编程语言性质层面的,一个是代码中对象类型层面的。

对于一个变量、表达式等来说,它们的静态类型在编译时就已经知道,比如:

int i;
i+;

变量i的静态类型是int,表达式 i+5 的静态类型是其结果的类型,也是int类型。

上面变量或表达式的静态类型是一定的,也是在编译时已知的,最终代码编译成二进制程序并执行起来之后,该变量名在内存中所引用的实际对象类型和变量名在声明时类型一致。这是静态类型和动态类型一致。

然而对于指针或者引用,则需要认识到在含有继承体系的类体系中,可能静态类型和动态类型不一致。在代码编写期间,我们使用了基类引用来绑定一个对象,或者用基类指针指向了一个对象。编译时,基类对象的类型确定,即使我们使用了派生类去进行绑定或者指向,编译器进行一些处理后,代码编译通过,但编译器处理的方式可能只是在引用或指针那里写入了一个相对内存地址,在程序实际运行之后,所指向的那块地址中的对象的类型并不是编译之前代码中对象的类型。此时指针或者引用的静态类型和动态类型不一致。

需要说明的时,动态绑定这是编程语言提供的功能。所以编译器在基于继承关系的类体系中,基类引用派生类对象时能被语法编译通过。

我们之所以能从派生类转换到基类,是因为派生类中包含了基类,在使用派生类进行绑定时,可以使用派生类中的基类部分。底层实现时,基类可以作为一个独立的对象存在,也可以作为派生类对象的一部分存在,这取决于实现。对于派生类中的基类来说,基类只含有它自身的成员,不含任何派生类的成员。

因为基类完全属于派生类,因此派生类可以向基类转换,而基类不包含派生类,所以基类没办法向派生类转换。派生类和基类关系概念可以如下图所示:

C++ Primer 5th 第15章 面向对象程序设计

其中A是基类,B是派生类,B包含A的全部,B可以很容易转换到A。而A只是B的部分,完全无法做到从A转换到B。

由于编译器执行的是静态类型检查,因此有时某些基类和派生类直接的转换即使合法,但是仍然无法编译通过,例如:

derived d;
base *b = &d; //b指向base类型,b的静态类型是base,动态类型是derived
derived *pd = b; //b指向base类型,b的静态类型和pd静态类型不一致,无法转换

虽然我们知道上述代码中b向pd的转换实际上是合法的,但是由于编译器只检查静态类型,而b和pd的静态类型不一致,所以编译不能通过。如果基类中含有至少一个定义实现了的虚函数,那么我们可以使用dynamic_cast进行类型转换,该转换的安全检查会在程序实际执行起来之后,而不是代码编译期间,当然,如果转换失败,则会抛出异常,我们需要进行异常捕获并处理。或者,我们知道它实际是合法的,则可以使用static_cast来强制转换。

我们还需悉知的一点是:派生类向基类的合法转换仅存在于使用指针或引用进行对象绑定时。在派生类对象和基类对象之间无法直接转换。使用派生类对象对基类对象(非指针/引用)进行初始化或赋值时,会导致派生类对象被切割,切掉后的派生类只剩下其中包含的基类对象,并不会进行动态绑定之类的操作。

之所以我们能将派生类对象切割之后去初始化或赋值基类对象,是因为基类拷贝构造函数形参是引用类型,而基类引用类型能够被派生类对象初始化。如果我们需要阻止这种行为,那么我们可以对拷贝构造函数使用explicit关键词进行说明,禁止派生类对象直接向基类对象切割转换。

虚函数

当我们使用基类的指针或引用调用一个虚成员函数时,会执行动态绑定。因为我们在程序运行时才会找到具体的函数,所以所有同一接口的虚函数都有可能被调用,因此和普通成员函数(可以只声明成员函数而不定义)不同,虚函数必须定义实现。

练习15.1:什么是虚成员?

虚成员是是类中使用了关键词virtual修饰的成员函数,该成员函数不能是构造函数和静态函数。

练习15.2:protected访问说明符与private有何区别?

访问说明符的作用是控制派生类从基类继承而来的成员是否对派生类的用户可见,而不是对派生类自身是否可见。可以将基类视作派生类的一个成员,而访问说明符则是控制该基类成员的权限。对于protected来说,允许基类和派生类访问,但不允许基类和派生类的用户访问。对于private来说,只允许基类自身访问,而不允许基类用户和派生类及派生类用户访问。

练习15.3:定义你自己的Quote类和print_total函数。

#include <string>

class Quote
{
public:
Quote() = default;
Quote(const std::string &b, double p) : bookNo(b), price(p)
{
//constructor
} 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; }; double print_total(std::ostream &os, const Quote &item, size_t n)
{
double ret = item.net_price(n); os << "ISBN:" << item.isbn()
<< "# sold: " << n << " total due: " << ret << std::endl; return ret;
}

练习15.4:下面哪条声明语句是不正确的?请解释原因。
class Base { ... };
(a) class Derived : public Derived { ... };
(b) class Derived : private Base { ... };
(c) class Derived : public Base;
(a) 不正确,类不能派生自身。
(b) 正确。
(c) 不正确。派生列表不能出现在类类型的声明中。