栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > C/C++/C#

Essential C++ Lippman 第5章 面向对象的编程风格 摘录

C/C++/C# 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Essential C++ Lippman 第5章 面向对象的编程风格 摘录

5.1 面向对象编程概念

面向对象编程概念的最主要的特质是:继承(inheritance)和多态(polymorphism)。前者使我们得以将一群相关的类组织起来,并让我们得以分享期间的共享数据和操作行为,后者让我们在这些类上进行编程时,可以如同操作单一个体,而非相互独立的类,并赋予我们更多弹性来加入或移除任何特定类。

继承机制定义了父子(parent/child)关系。父类(parent)定义了所有子类(children)共通的公有接口(public interface)和私有实现(private implementation)。每个子类都可以增加或覆盖(override)继承而来的东西,以实现其自身独特的行为。

在C++中,父类被称为基类(base class),子类被称为派生类(derived class)。父类和子类之间的关系则称为继承体系(inheritance hierachy)。

事实上这个抽象十分关键。我们称之为"抽象基类(abstrac base class)"。

面向对象编程风格的第二个独特概念是多态(polymorphism):让基类的pointer或reference得以十分透明地(transparently)指向其任何一个派生类的对象。

动态绑定(dynamic binding)是面向对象编程风格的第三个独特概念。

若在程序执行之前就已经解析出应该调用哪个函数,所以这种方式被称为静态绑定"static binding"。

但在面向对象编程方法时,”找出实际被调用的究竟是哪一个派生类的函数“这一解析操作会延迟之运行时(run-time)才进行。此即我们所谓的动态绑定。

在面向对象的应用中,我们会间接利用指向抽象基类的pointer或reference来操作系统中的各个对象,而不直接操作某个实际对象。这让我们得以在不更动旧有程序的前提下,加入或移除任何一个派生类。

继承特性让我们得以定义一整群互有关联的类,并共享共通的接口。

多态则让我们得以用一种与类型无关(type-independent)的方式来操作这些类对象。

多态和动态绑定特性,只有在使用pointer或reference时才能发挥作用。

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

默认情况下,member function的解析resolve皆在编译时静态地进行。若要令其在运行时动态进行,我们就得在它的声明前加上关键字virtual。

class LibMat
{
public:
    LibMat() {...}
    virtual ~LibMat() {...}
    virtual void print() const {...}
};

当程序定义出一个派生类对象,基类和派生类的constructor都会被调用。当派生类对象被销毁时,基类和派生类的destructor也都会被执行[但次序颠倒]。

为了清楚标示这个新类乃是继承自一个已存在的类,其名称后面必须接一个冒号:,然后紧接着关键字public和基类的名称:

class Book : public LibMat
{
public:
    Book(const string& title, const string& author) : _title(title), _author(author) {...}
    virtual ~Book() {...}
    virtual void print() {...}
    
    const string& title() const { return _title; }
    const string& author() const { return _author; }

protected:
    string _title;
    string _author;
};

title()和author()是两个所谓的访问函数(access function),都是non-virtual inline函数。

被声明为protected的所有成员都可以被派生类直接访问,类外都不得直接访问protected成员。

使用派生类时,不必刻意区分“继承而来的成员”和“自身定义的成员”。

5.3 不带继承的多态

极费工夫,尤其事后的维护更是工程浩大。

5.4 定义一个抽象基类

定义一个抽象类的第一个步骤就是找出所有子类共通的操作行为。举个例子,所有数列类的共通操作行为是?这些操作行为所代表的便是num_sequence这个基类的公有接口(public interface)。以下是第一次尝试;

class num_sequence
{
public:
    // elem(pos): 返回pos位置上的元素
    // gen_elems(): 产生直到pos位置的所有元素
    // what_an_i(): 返回确切的数列类型 
    // print(os): 将所有元素写入os
    // check_integrity(pos): 检查pos是否为有效位置
    // max_elems(): 返回所支持的最大位置值

    int elem(pos);
    void gen_elems();
    const char* what_an_i();
    ostream& print(os);
    bool check_integrity(pos);
    static int max_elems();
};

设计抽象基类的下一步,便是设法找出那些操作行为与类型相关(tyep-dependent)。也就是说,有哪些行为必须根据不同的派生类而有不同的实现方式。这些操作行为应该成为整个类继承体系中的虚函数(virtual function)。

注意static member function无法被声明为虚函数。

设计抽象基类的第三步,便是找出每个操作行为的访问层级(access level)。如果某个操作行为应该让一般程序员皆能访问,那么我们应该将其声明为public。但如果某个操作行为在基类之外不需要被用到,我们就将其声明为private。即使是该基类的派生类,亦无法访问基类的私有成员(private member)。如果该操作可以让派生类访问,却不允许一般程序使用,我们就将其声明为protected。

以下便是修改过后的nue_sequence:

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

    virtual int elem(pos) const = 0
    virtual const char* what_an_i() const = 0;
    static int max_elems() {return _max_elems;}
    virtual ostream& print(ostream& os = cout) const = 0;

protected:
    virtual void gen_elems() = 0;
    bool check_integrity(pos);
    static const int _max_elems = 1024;
};

每个虚函数,要么得有其定义,要么可设为纯虚函数(pure virtual function)--如果对于该类而言,这个虚函数并没有实质意义的话,将虚函数赋值为0,意思便是令它为一个纯虚函数。

任何类如果声明有一个(或多个)纯虚函数,那么,由于其接口的不完整性(纯虚函数没有函数定义,是谓不完整),程序无法为它产生任何对象。这种类只能作为派生类的子对象(subobject)使用,而且前提是这些派生类必须为所有虚函数提供确切的定义。

由于num_sequence只是为数列继承体系提供了一个接口:其派生类必须自行设计自身的data member。

由于num_sequence类并没有任何non-static data member需要进行初始化操作,所以其constructor亦无存在价值。不过我会为它设计destructor。是的,根据一般规则,凡基类定义一个或多个虚函数,应该将其destructor声明为virtual。

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

ps是基类num_sequence的指针,但它实际是指向派生类Fibonacci的对象。当delete作用在ps上,destructor会先应用于指针所指的对象身上,于是将此对象的占用的内存空间归还给程序的空闲空间(free store)。non-virtual函数会在编译期间便已完成解析(resolved),根据该对象被调用时的类型来判断。因此正确的情形应该是“根据实际对象的类型选择调用哪一个destructor”,而此解析操作应该在运行时进行。为了促使正确行为的发生,我们必须将destructor声明为virtual。

但是我并不建议在我们的这个基类中将其destructor声明为(pure virtual)纯虚函数--虽然它其实不具有任何实质意义的内容。对于这类destructor而言,最好是提供空白定义。

5.5 定义一个派生类

派生类由两部分组成:一是基类构成的子对象(subobject),由基类的non-static data member--如果有的话,二是派生类的部分(由派生类的non-static data member组成)。

派生类的名称之后紧跟着冒号、关键字public,以及基类的名称。唯一的规则是,类进行继承声明之前,其基类的定义已经存在。

Fibonacci class必须为从基类继承而来的每个纯虚函数提供定应得实现。除此之外,它还必须声明Fibonacci class专属的member。以下便是Fibonacci class的定义:

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


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

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

如果通过基类的接口无法访问length()和beg_pos()会对我们造成困扰,因为这两个函数并无基类所提供的实体覆盖。那么我们应该回过头去修改基类的接口。重新设计的方式之一便是在基类num_sequence内加上两个纯虚函数。代价则是其他派生类都必须对他们定义。

另一种设计的方法是,将存储长度和起始位置的空间,由派生类抽离出来,移至基类。于是length()和beg_pos()都成为了继承而来的inline nonvirtual function。

当我们面临“萃取基类和派生类之间的性质,以决定那些东西(包括接口和实际成员)”属于“谁”时,面向对象设计所面对的挑战,将不只是编程层面而已。一般而言,这是一个不断迭代的过程,过程之中借着程序员的经验和用户的反馈,不断改进。

派生类的虚函数必须精确吻合基类中的函数原型。在类之外对虚函数进行定义时,不必指明关键字virtual。下面时elem()的实现:

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

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

    return _elems[pos - 1];
}

继承而来的public成员和protected成员,不论在继承体系中的深度如何,都可被视为派生类自身的成员。至于基类的private member,则完全无法让派生类使用。

在elem()内,我们清楚地知道我们究竟想调用哪一个gen_elems()。在Fibonacci::elem()中想调用的是Fibonacci::gen_elems(),明确得很,不必等到运行时才进行gen_elems()的解析操作。事实上,我们希望跳过虚函数机制,使该函数在编译时就完成解析,不必等到运行时才解析。这就是我们指明调用对象的原因。通过class scope运算符,我们可以明确告诉编译器,我们想调用那一份函数实例。于是,运行时发生的虚拟机制被遮掩了。

每当派生类有某个member与其基类的member同名,便会遮掩住基类的那份member。如果要在派生类使用基类的那份member,必须使用class scope运算符加以限定。

基类指针调用基类的非虚函数时,只调用基类本身的非虚函数,而不会调用派生类中同名的非虚函数。

基于这个原因,一般而言,在基类和派生类中提供同名的non-virtual函数,不是一个好主意。

5.6 运用继承体系

对于基类指针ns而言,我们知道,ns并非指向实际的num_sequence对象,而是指向num_sequence的某个派生类对象。对于ns所指对象的解析,会在运行时依据ns所指对象的真实类型进行解析。

面向对象设计大大简化了修改和扩展的负担。

5.7 基类应该多么抽象

在目前的设计之下,抽象基类提供的是接口,并未提供任何实现。每个派生类不仅必须提供本身专属元素产生算法,还必须支持特定元素的搜索、元素的打印等等任务。

以下是另一种设计方式,将所有派生类共有的内容剥离出来,移至基类。接口依旧没有变动。这样的设计简化了我们为提供派生类而必须付出的努力。

class num_sequence
{
public:
    virtual ~num_sequence(){};
    virtual const char* what_an_i() const = 0;
    
    ostream& print(ostream& os = cout) const = 0;
    int length() const { return _length; }
    int beg_pos() const { return _beg_pos; }
    int elem(pos) const = 0

    static int max_elems() {return _max_elems;}

protected:
    virtual void gen_elems() = 0;
    bool check_integrity(pos);
    static const int _max_elems = 1024;

    num_sequence(int) len, int bp, vector& re) : _length(len), _beg_pos(bp), _relems(re){}

    int _length;
    int _beg_pos;
    vector& _relems;
};

Data member如果是个reference,必须在constructor的member initialization list加以初始化。一旦初始化,就再也无法指向另一个对象了。也不需要检查reference是否是NULL。

5.8 初始化、析构、复制

如今的num_sequence具有实际的data member,我必须为其提供初始化行为。较好的设计方式是,为基类提供constructor,并利用这个constructor处理基类所声明的所有data member的初始化操作。由于其是一个抽象基类,故我们无法为它定义任何对象。所以,我将基类的constructor声明为protected,而非public。

另一个做法是,为num_sequence提供默认constructor。不过,在此处必须将_relems改为指针,并且在每次访问vector内容之前,都检验这个指针是否不为NULL。

基类的destructor会在派生类结束之后自动被调用。我们无法在派生类中对它做明确的调用操作。

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

当我们定义派生类时,我们必须决定,究竟要将基类中的虚函数覆盖掉,还是原封不动地加以继承。如果我们继承了纯虚函数,那么这个派生类也会被视为抽象类,也就无法为它定义任何对象。

如果我们覆盖基类所提供的虚函数,那么派生类提供的新定义,其函数原型必须完全符合基类所声明的函数原型,包括:参数列表,返回类型,常量性。

class num_sequence
{
public:
    virtual const char* what_am_i() const { ... }
};

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

如果匹配并非完全符合,派生类所提供的函数,并未被用来覆盖基类所提供的同名函数,原因是两个函数并非完全吻合。

class num_sequence
{
public:
    virtual num_sequence* clone() = 0;
};

class Fibonacci : public num_sequence
{
public:
    virtual Fibonacci* clone() {...}
};

”返回类型必须完全吻合“这一规则有个例外--当基类的虚函数返回某个基类形式(通常是pointer或reference)时。派生类的同名函数便可返回该基类所派生出来的类型。

当我们派生类中,为了覆盖基类的某个虚函数,而进行声明操作时,不一定的加上关键字virtual。编译器会根据两个函数的原型声明,决定某个函数是否会覆盖其基类中的同名函数。

虚函数的静态解析(static resolution)

有两种情况,虚函数机制不会出现预期行为:(1)基类的constructor和destructor内,(2)当我们使用的是基类对象,而非基类对象的指针或引用时。

在我们构造派生类对象时,基类的constructor会先被调用。如果在基类的constructor中调用某个虚函数,会出现问题。因为此派生类中的data member尚未初始化。如果此时调用派生类的那一份虚函数,它便有可能访问未经初始化的data member,这可不是一件好事。基于上述原因,在基类的constructor中,派生类的虚函数绝对不会被调用。

为了能够在“单一对象中展现多种类型”,多太(polymorphism)需要一层间接性。在C++中,唯有用基类的pointer或reference才能支持面向对象编程概念。

当把一个派生类对象赋值给基类对象时,派生类对象中的只有基类的成员能够被赋值给基类。其派生的成员将会被切掉。

5.10 运行时的类型鉴定机制

一种设计方法是每个类都拥有一份what_am_i()函数,都返回一个足以代表该类的字符串。

另一个设计手法,便是只提供唯一一份what_am_i()函数,令各派生类通过继承机制加以复用。这种设计手法可使各派生类不必再提供各自的what_am_i()。这种设计的一个可能的做法是num_sequence增加一个string member,并令每一个派生类的constructor都将自己类名作为参数,传递给num_sequence的constructor。

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

另一种实现是利用所谓的typied运算符,RTTI的一部分,由于程序语言支持。它让我们得以查询多态化的class pointer 或 class reference,获得其所指对象的实际类型。

#include

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

使用typied运算符之前,必须先包含typeinfo头文件。typied运算符会返回一个type_info对象,其存储着与类型相关的种种信息。

type_info class也支持相等和不相等两个比较操作。

//
Fibonacci fib;
num_sequence* ps = &fib;

if (typied(*ps) == typied(Fibonacci))

为了调用Fibonacci所定义的gen_elems(),我们必须指示编译器,将ps的类型转换为Fibonacci指针,static_cast运算符可以担起这个任务。

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

static_cast其实有着潜在危险,因为编译器无法确认我们所进行的转换是否完全正确。这也就是将其安排在if条件为真下额原因。

//
if (Fibonacci* pf = dynamic_cast(ps))
{
    pf->gen_elems(64);
}

dynamic_cast运算符就不同,它提供有条件的转换。它会砸运行时检验操作,检验ps所指对象是否属于Fibonacci类,如果是,转换操作就会发生。如果不是,则会返回0。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/588873.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号