第五章 面向对象的编程风格。

前面其实已经用到了类的概念,但是并没有引入面向对象的编程,因为还没有引入继承和多态两种重要的特性。本章主要介绍这一内容

面向对象编程概念

首先解释继承(inheritance)和多态(polymorphism)

继承定义了类之间的父子关系,父类(parent)定义了所有子类共有的接口(public interface)和私有实现(private implementation)。子类可以增加或者覆盖父类的成员变量或者成员函数。

父类也称为基类(base class),子类称为派生类(derived class)。父类和子类之间的关系称为继承关系(inheritance hierarchy)。

此外,还有一个很重要的技巧,即抽象基类(abstract base class)。在文中的例子里,图书馆中有book,ChildToy,Manazines,Films几个类,而抽象基类LibMat是所有类的基类。它提供了图书馆的对象的基础功能。并且我们可以使用如下的用法:

void load_check_in(LibMat &mat)
{
   mat.check_in();
  
   if(mat.is_late())
      mat.assess_file();

   if(mat.waiting_list())
      mat.notify_available();
}

其中输入参数的类型为LibMat,但是实际上在具体调用时,所有该抽象基类的子类都可以作为输入参数。从而可以避免对所有的子类都实现相同的功能(疑问:这是类中的成员函数特有的功能还是所有的函数均支持?)

而为了解释上面这个程序,就需要引入多态的概念。当编译器编译时就已经知道所执行的函数具体是指哪个子类时,就称为静态绑定(static binding)。反之,如果只有具体调用,运行到这里才知道时,就称为动态绑定(dynamic binding)。而多态实际上是让基类的pointer*或者reference&得以指向任何一个派生类的对象,是一种动态绑定。另外这里强调了,多态和动态绑定的特性只有在使用pointer*或者reference&时才会发挥。

而这种编程思路,会讲基础的实现和具体的物品分离,方便维护。

漫游:面向对象的编程思维

主要介绍了virtual这个关键字的使用方法,另外介绍了一种从泛化的类,然后进行分类的树状编程思路。这里有三种类,分别是LibMatBookAudioBook。三者的成员函数和成员变量有重合的部分,但是其中的print成员函数,三个类的具体实现不同,这是就要使用virtual的关键字,当调用的是更新的类的print时,就以最新的类为标准。这里给出程序代码:

class LibMat{
public:
   LibMat(){cout<<"LibMat::LibMat() default constructor!\n";}

   virtual ~LibMat(){cout<<"LibMat::~LibMat() destructor!\n"}
   virtual void print() const
   { cout<<"LibMat::print()--I am a LibMat object!\n";}
};
class Book:public LibMat{
public:
   Book(const string &title, const string &author)
      :_title(title),_author(author){
      cout<<"Book::Book("<<_title
          <<", "<<_author<<") constructor\n";
   }
   virtual ~Book(){
   ...}
   virtual void print() const{
   ...
   }
   ...
};
class AudioBook:public Book{
public:
   AudioBook(...){...}
   ~AudioBook(){...}
   virtual void print() const{...}
   const string& narrator() const{...}
   ...
};

//一个non-member function
void print(const LibMat &mat)
{
   cout<<"...";
 
   mat.print();
}

//执行
cout<<"\n"<<"Creating a object to print()\n";
AudioBook ab(...);
print(ab);

上述三个类,使用:号和public进行了继承,并且类中同时具有成员函数print,而此外,还给定了一个非成员函数的print函数,输入参数为类对象,具体实现为调用类对象中的成员函数print

这里有几条规则,新的类可以使用原有的类中所有声明为protected的成员函数和成员变量。比如Book中有成员变量_tilte_author,那么类AudioBook就可以调用这个成员变量。但是同时,所有的print函数均声明为了virtual,在执行mat.print时,如果输入参数的为基类LibMat的对象,就调用基类的成员函数。如果输入参数为第一层子类Book的对象,就调用Book中的成员函数。那么如果是第二层子类AudioBook的对象,也以此类推。

而在子类的对象初始化和销毁时,其所有父类的构造函数和析构函数都会依次执行,以当前为例,具体执行顺序如下:

LibMat 的构造函数
Book 的构造函数
AudioBook 的构造函数

执行部分

AudioBook 的析构函数
Book 的析构函数
LibMat 的析构函数

可以看出执行顺序正好是反过来的。

文中也强调了,不必刻意区分继承而来的成员和自身定义的成员。例如

int main()
{
  AudioBook ab(...);
 
   cout<< ab.title()<<'\n'
       << ab.author()<<'\n'
       << ab.narrator()<<'\n';

}

其中titile()author()是父类Book的成员函数,而narrator()AudioBook的成员函数。其使用并无不同。

不带继承的多态

感觉是上面一章的内容,在不使用class以及,继承的技巧的情况下。实现了上一章对多个数组的调用问题。非常华丽的技巧秀。但是没有新知识,这里不细说。见Page142

定义一个抽闲基类

继续前一节的问题,虽然实现多个数组的方便调用是在C框架下可以实现的,但是需要极高的技巧,而且并不方便维护。这一节尝试在C++框架下实现相同的功能。基本思想是设计一个抽象基类,然后用所有数列对应的类继承这个抽象基类,从而实现基础功能的统一。文中将这种设计分成了三个步骤。

其一,找出所有子类共通的操作行为。即这个基类的公有接口(public interface),如下:

class num_sequence{
public:
   int elem(int pos); //返回pos位置上的元素
   void gen_elems(int pos); //产生直到pos位置的所有元素
   const char* what_am_i() const; //返回确切的数列类型
   ostream& print(ostream &os = cout) const;
   bool check_integrity(int pos);
   static int max_elems();
   ///...
};

其二,是找出哪些操作行为与类型相关(type-dependent),即哪些行为必须根据不同的派生类而有不同的实现方式。那么这部分函数要使用virtual的技巧。这里强调一下,static member function无法声明为虚函数

其三,是找出每个操作行为的访问层级(access level),即区别哪些程序使用public或者protected或者private,从而得到更加具体的实现:

class num_sequence{
public:
   virtual ~num_sequence(){};

   virtual int elem(int pos)=0; //返回pos位置上的元素
   virtual const char* what_am_i() const=0; //返回确切的数列类型
   virtual ostream& print(ostream &os = cout) const=0;
   static int max_elems() {return _max_elems};
protected:
   virtual void gen_elems(int pos)=0; //产生直到pos位置的所有元素
   bool check_integrity(int pos);
 
   const static int _max_elems = 1024;
};

我们注意到有将虚函数直接赋值为0的操作,这里称之为纯虚函数pure virtual function,例如virtual int elem(int pos)=0;。本人理解为将函数指针指向一个空地址,表示当前这个成员函数没有任何意义。同时在语法上,任何一个类如果声明有一个或多个纯虚函数,由于接口的不完整性,程序无法为它产生对象,同时包含纯虚函数的类成为抽象类。这种类只能又派生类的子对象(subobject)使用,且前提是当前派生类给了这个成员函数具体的定义。

注意到这里的基类num_sequence并未定义任何成员变量,这就给其派生类的变量类型提供了很大的自由空间。这和前几章逐渐泛化变量类型的目的是一样的。

而关于析构函数,这里强调了一个编程习惯,即尽量使用虚函数。例如:

class num_sequence{
public:
   virtual ~num_sequence();
   //...
};

具体理由是对于如下代码:

num_sequence *ps = new Fibonacci(12);
delete ps;

这里的ps是一个class的指针,语法上可以指向其派生类型的对象。但是在进行delete时,我们希望程序能够调用Fibonacci这个类的析构函数。如果不对析构函数添加virtual的关键字,那么这个析构函数的操作在编译时就已经完成解析。而编译时它的调用类型为基类的类型num_sequence,所以调用的是基类的析构函数。为了能让析构函数在执行过程中动态调用,要引入virtual的关键字。

另外一个编程习惯是尽量不要把析构函数声明为纯虚函数,(我自己觉得,可能是因为析构函数本来就不代表具体的行为)。比较推荐使用的空定义,即:

inline num_sequence::~num_sequence(){}

定义一个派生类

这部分建议有空反复阅读。重点并不是语法,而是编程思维。如何能够借助已有的语法,设计能够自由的在基类和派生类之间的成员函数自由切换的程序。

基础的派生类的实现如下,这里强调派生类声明之前必须已经存在基类。而声明派生除去使用:之外,还使用了关键字public,这代表了继承方式,具体细节书中未提而是给出了参考文献。代码如下:

#include "num_sequence.h"

class Fibonacci : public num_sequence{
public:
   Fibonacci(int len=1, int beg_pos = 1)
     :_length(len), _beg_pos(beg_pos){}

   virtual int    elem(int pos) const;
   virtual const char* what_am_i() const {return "Fobonacci";}
   virtual ostream&   print(ostream &os = cout ) const;
   int  length() const {return _length;}
   int beg_pos() const {return _beg_pos;}

protected:
   virtual void gen_elems(int pos) const;
   int _length;
   int _beg_pos;
   static vector<int> _elems;
}

这里出现了问题,其中的length()beg_pos()为定义的成员函数。如果类似之前使用基类的指针指向派生类的对象,那么函数指针就无法使用:

num_sequence *ps = new Fibonacci;
ps -> length();  //错误

这里给出的解决方法有两个。其一,是在基类中将length()beg_pos()添加为纯虚函数,然后在派生类中添加具体实现。这一方法的问题在于,所有的派生类都必须提供这两个成员函数的具体实现。其二,是直接将这两个成员函数写成基类的inline nonvirtual function。(个人感觉第二种比较合理)

在对虚函数进行实现时,不需要给定virtual的关键字,例如:

int Fibonacci:
elem(int pos) const
{ 
   if(!check_integrity(pos))
      return 0;

   if(pos>_elems.size())
      Fibonacci::gen_elems(pos);

   return _elems[pos-1];
}

除去没有virtual这个关键字之外,还有一个细节,Fibonacci::gen_elems(pos)。这里指定了我们调用的成员函数是派生类的。正常情况下,虚函数的解析是动态的。在具体执行到这里时,解释器才会解析这里要调用的成员函数属于谁,但是如果用户希望直接在编译时就指定,就可以使用这种class scope运算符。(猜测是因为动态会损失程序性能)。

文中为了完整性给出了gen_elems()print()的具体实现,这里并不给出。但是这elem()print()都需要进行一个操作,即检查数组_elem中的元素是否足够,如果不够就用函数gen_elems()增添函数。文中希望将这个操作写成新的函数check_integrity()。而之前在基类中,已经定义了同名的成员函数。此时编译器会优先将成员函数解释为派生类的成员函数,如果希望调用基类中的同名成员函数,必须类似的指明num_sequence::check_integrity(pos)

但同时这种覆盖的语法又会引入新的问题。

num_sequence *ps = new Fibonacci(12,8);
ps->check_integrity(pos);

这里的利用基类的指针调用成员函数时,编译器会解析成基类的成员函数check_integrity(),这是我们不希望的。解决办法文中又提供了不止一种。其一,将基类中所有的函数声明为虚函数。其二,也是本文采用的,是给这个成员函数写成了更加巧妙的形式,如下:

bool num_sequence::
check_integrity(int pos, int size)
{
   if(pos<=0 || pos>_max_elems){
      //和先前相同...
   }

   if (pos>size)
      //gen_elems()系通过虚拟机制调用
      gen_elems(pos);

   return true;
}

//调用
int Fibonacci::
elem(int pos)
{
   if (!check_integrity(pos,_elem.size())
      return 0;
   //...
}

这种写法一个明显的好处,直接在基类中定义了一个非虚的成员函数。而在函数中具体调用gen_elems()函数时,使用了虚函数的机制,从而能够在不同的派生类中调用不同的成员函数。将所有派生类中一致的操作提取出来,然后将不同的操作设计成接口交给派生类具体实现。

运用继承体系

类似第一个派生类,将所有的派生类Pell,Lucas,Square,Triangular,Pentagonal,Fibonacci实现之后。我们就得到了一个和前一章程序功能完全相同的程序。只是这并不需要像之前一样极高的技巧。这里给出了这一程序的调用方式。

其一,是定义了一个display()函数:

inline void display(ostream &os,
            const num_sequence &ns,int pos)
{
   os << "The element at position"
      << pos  << " for the "
      << ns.what_am_i << " sequence is "
      << ns.elem(pos) << endl;
}

//调用
int main()
{
   const int pos = 8;
  
   Fibonacci fib;
   display(cout,fib,pos);
   Pell pell;
   display(cout,pell,pos);
   Lucas lucas;
   display(cout,lucas,pos);
   ...
}

//输出
The element at position 8 for the Fibonacci sequence is 21
The element at position 8 for the Pell sequence is 408
...

没啥好说的,因为使用了虚函数,一个非成员函数可以调用同一个基类下的多个派生类的同名成员函数。

其二,定义了运算符的重载操作,这个还是有点秀的

ostream& operator<<(ostream &os, const num_sequence &ns)
{return ns.print(os);}

int main()
{
   Fibonacci fib(8);
   Pell pell(6,4);
   ...
   cout << "fib: " << fib << '\n'
        << "pell:" << pell << '\n'
        ...
};

//输出
fib: (1,8) 1 2 3 5 8 13 21
pell: ...

其中return np.print(os)的理由前一章提到过,可以让<<连续使用。从而输出变得很方便。

基类应该多么抽象

这里强调前面的程序结构设计是否合理,是根据应用场景的,并给出了另外一种设计模式。

首先介绍这部分里面的一个技巧,在类的成员变量里面声明了vecotr<int> &_elems。这里使用了reference,而不是pointer。理由是reference无法表示空对象(null object),而pointer可以。这就导致pointer在使用时要检查是否为null,而reference不需要。

而成员变量如果是reference,就一定要在构造函数中初始化,并且一旦初始化,就不能指向另一个对象。如果是pointer,则没有这个限制,可以在构造函数内初始化,也可以提供null让用户初始化。两者各有各的优势。

然后再谈这里面提到的程序设计思想,前面的程序设计尽可能的将基类进行泛化,从而方便日后开发者对程序扩容。基类的设计越广泛,今后就可能纳入更多的体系。
然而如果程序准备开源给用户使用,让用户在程序中进行修改。那么这种过于泛化的设计,会使得用户难以理解,需要比较高的编程素养。此外,即使能够理解程序,在派生类中,需要指定的部分过多,也是会增加工作量。

(感受其中应用不同带来的设计需求的不同。前者只提供功能,开发者维护即可,可以尽可能的泛化。后者用户本身也要进行程序修改,就要将程序适当的特殊化,使得用户自定义特殊类时需要设置的部分减少。
程序这里就不敲了,就是将很多成员函数和成员变量放在了基类中。所以派生类中只需要给定少量的成员变量和函数。)

初始化、析构、复制

派生类和基类都有构造函数和析构函数,其执行顺序前面已经讨论过了。这里讨论的问题是该如何使用这个特性。

首先,书中强调了一个编程习惯。基类中的成员变量要在基类的构造函数中初始化,派生类的成员变量要在派生类的构造函数中初始化,以此类推。

齐次,派生类的构造函数,不仅必须给派生类的成员函数进行初始化操作,还需要为基类的成员函数提供适当的值。否则会出现编译错误

inline Fibonacci::
Fibonacci(int len, int beg_pos)
     : num_sequence(len,beg_pos,_elems)
{}

或者我们可以给基类的成员变量设定默认值

num_sequence::
num_sequence(int len=1, int bp=1, vector<int> *pe=0)
   : _length(len), _beg_pos(bp), _pelems(pe) {}

另外文中讨论了类对象的赋值操作,其实这个前面也讨论过

Fibonacci fib1(12);
Fibonacci fib2 = fib1;
//或者
Fibonacci fib2(fib1);

赋值操作前面讨论过,类的赋值操作默认为所有的成员变量分别复制过来,但是数组为指针的赋值,所以需要额外的定义。而另外一种复制方法就是利用构造函数。没啥好说的

在派生类中定义一个虚函数

其实是强调了派生类中的虚函数覆盖基类中的虚函数的条件。要求函数原型必须完全相同,包括:参数列表,返回类型,常量性。少写一个const,或者返回值类型有不同,均会导致虚函数无法覆盖,从而变成两个独立的成员函数。不过,如果出现这种情况,编译器会进行警告warning

warning #653 "const char *Fibonacci::what_am_i()"
          does not match "num_sequence::what_am_i"
          -- virtual function override intended?

不过这个事情也有例外,当返回值为某个类的pointer或者reference时,基类或者其派生类可以混用:

virtual num_sequence *clone()=0;
virtual Fibonacci *clone()=0;
虚函数的静态解析(Static Resolution)

开门见山,虚函数的机制在两种情况下是失效的:其一,基类的构造函数和析构函数内。其二,使用的是基类的对象,而不是基类的pointer或者reference。除去逻辑上的原因外,文中还解释了更多细节。当基类的构造函数运行时,派生类的成员变量还没有初始化,所以一定不能调用。

而对于其二的规则,则更加复杂,例如如下程序:

void print(LibMat object,
       const LibMat *pointer,
       const LibMat &reference)
{ 
   //基类的
   object.print();
   //派生类
   pointer->print();
   reference.print();
}

int main()
{
   AudioBook iWish(...);
   print(iWish,&iWish,*iWish);
}

这里的非成员函数print()的三个输入变量,均为基类的对象。但是具体执行其中的成员函数print()时,后两个为派生类的成员函数,而第一个为基类的成员函数。在“单一对象中展现多类型”,这就是多态,而使用pointerreference才能实现多态。同时主程序的调用过程中,输入值全部为派生类的对象,后面两个输入值可以正常的调用。而第一个输入参数,执行时只会保留基类中的成员变量,其余的部分均被切掉sliced off。因为程序编译时只保留了基类的内存空间,无法容纳更多的成员变量。

运行时的类型鉴定机制

前面已经多次使用的一个成员函数:

class Fibonacci : public num_sequence {
public:
   virtual const char* what_am_i() const {return "Fibonacci";}
   //...
};

但是问题在于不同的派生类,均要给出自己的该成员函数的定义,这不够泛化。所以文中考虑其他方法,其中一种是通过构造函数初始化字符串:

inline Fibonacci::
Fibonacci(int len, int beg_pos)
        :num_sequence(len, beg_pos, _elems, "Fibonacci")
{}

另外一种为新知识,是调用typeid运算符,这需要typeinfo这个头文件。首先给出使用方法:

#include <typeinfo>

inline const char* num_sequence::
what_am_i() const
{return typeid(*this).name();}

这里的typeid(*this)会返回一个type_info对象,对象中的name()函数会返回当前类的名称的字符串。

除此之外,这个type_info对象还有更多的功能,比如说类型比较:

num_sequence *ps = &fib;
if (typeid(*ps) == typeid(Fibonacci))

我们注意到typeid()函数的输入参数,可以是类名,也可以是类对象。而且返回的type_info还可以直接用来比较相等,从而进行类型检查。

此外,我们还可以对基类指针进行类型转换,使用这一操作是因为如下命令编译器无法编译通过:

ps-> Fibonacci::gen_elems(64);

ps是基类的指针,如果使用ps->gen_elems(64)即可调用派生类的成员函数。但是当我们声明成员函数所属的类时,反而会报错。处理方法就是前面提到的对类指针进行类型转换。具体的函数有两个选择:

if (typeid(*ps) == typeid(Fibonacci))
{
   Fibonacci *pf = static_cast<Fibonacci*>(ps);
   pf->gen_elems(64);
}

if (Fibonacci *pf = dynamic_cast<Fibonacci*>(ps))
   pf->gen_elems(64);

static_castdynamic_cast两个运算符。两者的主要区别在于,前者为无条件转换,后者为有条件转换。条件为,当前指针指向的类类型,是否为想要转换的类型。比如现在的ps实际上指向它的派生类Fibonacci,所以ps的类型才能转换成Fibonacci*

第一个运算符static_cast自己并不能判断是否满足条件,但是不满足时会出错。所以我们必须用if进行判断。而后者可以自己进行判断,如果满足条件,会返回一个Fibonacci*类型的指针,如果不满足条件,则会返回0。所以第二个代码段在条件不满足时,if语句也不成立,和第一个代码段达到了相同的效果。

OK!这是倒数第三章,最后两章内容开始减少了,胜利在望!