Javen-Studio 咖啡小屋

http://javenstudio.org/ - C++ Java 分布式 搜索引擎 - Naven's Research Laboratory - Thinking of Life, Imagination of Future

  C++博客 :: 首页 :: 新随笔 :: 联系 :: 聚合  :: 管理 ::
  24 随笔 :: 52 文章 :: 117 评论 :: 4 Trackbacks

侯捷《C++/OOP/GP/DP》讲座心得

                                                                                                                 ——— 作者: naven

 

    很高兴侯捷老师又来公司了,给我们上了四天非常生动的技术讲座,受益匪浅,现在我简要介绍一下我的学习心得,与大家分享。这次讲座主要集中在《 C++/OOP/GP/DP 》主题,针对有一些编程基础的工程师,对一些常用的代码和设计做了非常通俗易懂的剖析,非常有帮助。当然更深入的理解还需要结合多种技术名著来学习,我结合我的理解以及自己的学习和开发的经验介绍一下 C++/OO/Template 以及 Design Pattern 的理会,考虑到讲座的性质,我并不直述本次讲座的内容,欢迎批评指正 J

 

    侯捷老师的讲座基本是讲述他多年来在 C++ 领域的研究成果,基本大部分都可以在他的书籍和网站上能读到,但是考虑到最近几年软件技术的蓬勃发展,如 Design Pattern 的更广泛应用,又有许多心得,基本上是较为泛的基础的层面,并结合实际代码和应用,对实际项目开发非常有益。下面我逐个主题泛泛地讲一遍。

 

    面向对象中的合成( Composition )和继承( Inheritance )关系

 

    通常扩展一个类的功能主要有两种方式,一种是大家很熟悉的继承( inheritance ),另一种就是合成( composition ),很多初学 OO (面向对象)并有一些经验都很容易搞混这个的区别,其实很简单,继承是解决 Is-a 的问题,而合成是解决 Has-a 的问题。比如说小鸟有两个翅膀,就是合成,而鸟是一种飞禽,就是继承了,设计一个“小鸟”的类,它继承自”飞禽”,就具有“飞”的特性,但要用合成的方法“包含”一个“翅膀”的类才具有真正“飞”的功能。

    别看这两个定义很简单,其实很多人都犯过错误,包括 Java 类库的设计者,他们就把 Properties 直接“继承”自 Hashtable 了,这里其实应该用“合成”。

 

    讲到合成,就应该说说聚合( Aggregation ),它是描述整体和局部的关系,合成其实是一种“强烈”的聚合,它与局部具有相同的生命周期,“容纳”局部的“对象”,而聚合只是“容纳”局部的一个“指针”。比如说,人和脑袋就是合成,而汽车与发动机就是聚合,改装汽车可以任意替换更好的发动机,而人的脑袋就不行(目前是这样:)

    聚合在 UML 中是以空心棱形的箭头表示,合成是以实心棱形的箭头表示。

 

    还有一种关系叫委托( Delegation ),委托是一种让合成( composition )变得像继承( inheritance )的复用能力一样强大的方式。( a way of making composition as powerful for reuse as inheritance [Lie86, JZ91] )在委托中,两个对象在处理一个请求的时候发生关联:一个接收的对象委派操作给它的委托对象。这跟子类( subclass )延迟请求( deferring requests )给它的父类( parent class )来实现类似。但是在继承里,一个被继承的操作( inherited operation )通过 this 成员变量能够经常引用到那个接收的对象。为了在委托里达到同样的效果,接受者传递它自己给它的委托者,以便被委托的操作能够引用到这个接收者。

 

    再说一下继承( Inheritance ),它是将基类( base-class )所有一切(包括 private )都继承下来,所以假如你想实现一个新的类,只想继承一部分,就用合成( Composition )别用继承。或者更进一步来讲,如果你想改造一个类,想改造一些接口( interface ),也建议用合成,通过转调内部对象的方法实现,别用虚函数( virtual function )。这是非常符合最基本的 OCP 设计原则( Open-Closed Principle ,开闭原则)的方式了。

 

    类的构造( Constructor )和析构( Destructor

 

    类的构造和析构是最基础的知识了,任何一个类的对象产生和销毁都必须有这两个步骤,但是它们是如何工作的,编译器是如何制造缺省的 ctor dtor 的,估计少有人关注了。

    一个类的对象的产生,会依次从它最里面的类开始构造,同一个类会跟据内部类成员定义的顺序依次构造。类对象的销毁的过程则相反。基类的构造器会在用户定义的 ctor 之前调用,基类的析构则是在用户定义的 dtor 之后进行。熟悉这些过程,非常有利于设计出优秀的类库,也不容易出现内存泄露和资源耗尽等问题。下面举个例子更容易理解:

 

    class A { public: A(); ~A(); };

    class B { public: B(); ~B(); };

    class C { public: C(); ~C(); };

    class D : public A, B {

        public: D() { init(); } ~D() { release(); }

        private: void init(); void release(); C c;

    };

 

    上面的定义中 D 类的 ctor 构造过程如下:

    A::A();

    B::B();

    c.C::C();

    D::init();

 

    D 类的 dtor 析构过程如下:

    D::release();

    c.C::~C();

    B::~B();

    A::~A();

 

    更复杂的继承关系以及多重继承的构造和析构过程类似,有兴趣的人可以写程序测试:)

 

    还有一个问题,编译器会在什么时候自动产生 ctor dtor 的呢,又是如何产生的呢

    其实很简单,当你没有写缺省构造函数( default constructor )和缺省析构函数( default destructor )的时候,编译器就会给你自动生成一个,换句话说,任何类都有构造函数和析构函数,虽然有时候什么都不做,还有复制构造函数( copy ctor )也会自动生成。但是如何产生会跟你的类的成员有关。如果成员都是原生类型,还有如果类成员也全部为原生类型, ctor 将只会跟普通变量定义的初始化一样,给一个初值, dtor 则什么都不做, copy ctor 则会使用内存复制( memcpy )的方式复制对象。如果成员包含一个或多个类成员,而且至少有一个类成员定义有缺省构造方法,则产生的 ctor 会依次调用每个成员的 ctor dtor copy-ctor 产生方法类似。(详见《 Inside the C++ Object Model 》)

 

    多态( Polymorphism )和虚函数( Virtual function

 

    多态是面向对象的基本特性, C++ 里是通过 virtual 关键词来提供的,它是通过在类对象里加入 vtbl 虚函数表来实现的,这一点相信大部分程序员都很清楚,不过怎么做到多态功能估计了解的不多了。要详细了解,还请阅读《 Inside the C++ Object Model 》一书,下面简单介绍一下原理。

 

    一般编译都会给包含有 virtual function 的类头部(有的编译也会放到底部,比如 VC )增加一个成员 vptr 指针,指向一个 vtbl 虚函数表,为定长数组,大小是所有带 virtual 的函数数目再加 1 。虚函数指针从 vtbl[1] 开始,按照定义顺序,指向特定的函数实现。如果子类定义了父类中带 virtual 的函数,则 vtbl 相应的指针指向子类的函数实现,否则就指向父类的实现。另外再说明一点,其中 vtbl[0] 是有别的用途,用来存放类型信息,做 dynamic_cast 用途。

    仍以上面的例子为例,如下的代码编译器是如何处理:

 

    A *p = new D();     // up-cast

    p->vfunc1();         // 编译器会转化为如下代码

(*(p->vptr))[n](p); // n 为编译期确定的固定数,即相应 virtual function

// 所在位置

 

    需要牢记一点,总是让 base class 拥有 virtual destructor 。因为当如下操作时

 

    delete p;

 

    如果 A B 的析构函数不是虚函数,则不会调用子类 D dtor ,就有可能造成内存泄露或者资源没有释放等严重问题。如果给 base class 加了 virtual dtor ,由于有多态的特性,就会自动调用 subclass dtor ,接下来就会上面的介绍,依次调用各个 base class dtor ,因而就没有问题了。

 

    C++ template STL containers

 

    C++ template 即模板技术是实现泛型编程技术的,能够使得写一份代码可以应用到类似用途的不同地方。模板技术其实原理比较简单,但是使用还是比较复杂的,看看 STL 源码就知道了,如果还不相信,再看看 Boost 代码好了,会把你搞得晕头转向。候捷老师把这个技术讲解得非常清楚易懂,还具体分析了 STL 里各个大组件的运作原理,我这里就不讲述了,基本都是源码的剖析,请阅读候捷老师的《 STL 源码剖析》一书。

 

    在讲解 STL 中用模板如何实现 function class (实现函数功能的类,在 stl_functions.h )中,有这样一段代码

 

template <class _Operation>

class binder1st

  : public unary_function<typename _Operation::second_argument_type,

                          typename _Operation::result_type> {

protected:

  _Operation op;

  typename _Operation::first_argument_type value;

public:

  binder1st(const _Operation& __x,

            const typename _Operation::first_argument_type& __y)

      : op(__x), value(__y) {}

  typename _Operation::result_type

  operator()(const typename _Operation::second_argument_type& __x) const {

    return op(value, __x);

  }

};

 

    有人提出上面 _Operation op; 为什么不定义为引用,如 _Operation &op; 呢。我的想法如下,因为构造方法为

  binder1st(const _Operation& __x, // 这里为 const 类型

            const typename _Operation::first_argument_type& __y)

 

    传入的参数为 const 类型,这时不应在本调用方法(这里是构造方法)之外使用引用或指针指向它,因为带 const T &t 的参数一般情况都视为临时对象,很有可能是在方法调用的时候临时产生的,比如说自动转型产生的临时对象都是 const T & 类型,它的生命周期都在此方法调用期间内,方法调用结束即被销毁,所以就不能在方法外部用引用或指针之类指向它了。举例来说,可能比较容易理解,比如大家常用的 string 类,假如有一个方法和调用如下:

 

    void func(const string &s);

    func("abcdfd");

 

    这个时候就会出现自动转型行为,编译器会做如下处理

 

    func(string("abcdfd"));

 

    即产生一个临时的 string 对象,这个对象是以 const 类型传入的。假如你的方法定义改成如下

 

    void func(string &s);

 

    现在大部分编译器严格的处理都会报错,以前的 VC6 就不会,但是好像最新的 VC2005 也报错了。

    这是其中一个原因,还有一个原因我认为是 _Operation 类只是一个 function class ,没有成员,所以做复制构造也不会有多大的开销,基本不会影响效率。再加模板和 inline 方法的处理,编译器经过优化,应该都不会产生临时对象了,所以也不必用引用了。不过我觉得最重要是上面第一个原因。

 

    内存池和小对象分配器( memory pool, small object allocator

 

    候捷老师在内存池方面也有很丰富的研究经验,他基本将目前主流的内存池实作都剖析了一遍,介绍了它们各自的特点,以及如何与上层框架的配合。内存池是一个非常基础也非常关键的底层库,一般大型的框架自己都带有一个内存池库,比如 STL MFC 等。即使在目前内存比较便宜的今天,内存资源也是最宝贵的系统资源之一,设计一个优秀的内存池对提高系统的效率和稳定性都非常有帮助,尤其是设计专门针对小内存对象(一般低于 128 字节)的分配器非常重要,因为这样对象分配和释放非常频繁,只用简单的 malloc() free() 来处理非常影响效率,不是一个优秀的设计。下面我简要介绍一下目前主流内存池设计的特点,以及我自己的想法,另外再加一个候捷老师没提到 ACE 中的内存池管理器的设计特点。

 

    SGI STL 中的内存分配器( allocator

 

    SGI STL allocator 应该是目前设计最优秀的 C++ 内存分配器之一了,它的运作原理候捷老师在《 STL 源码剖析》里讲解得非常清楚。基本思路是设计一个 free_list[16] 数组,负责管理从 8 bytes 128 bytes 不同大小的内存块( chunk ),每一个内存块都由连续的固定大小( fixed size block )的很多 chunk 组成,并用指针链表串接起来。比如说

 

    free_list[3]->start_notuse->next_notuse->next_notuse->...->end_notuse;

 

    当用户要获取此大小的内存时,就在 free_list 的链表找一个最近的 free chunk 回传给用户,同时将此 chunk free_list 里删除,即把此 chunk 前后 chunk 指针链结起来。用户使用完释放的时候,则把此 chunk 放回到 free_list 中,应该是放到最前面的 start_free 的位置。这样经过若干次 allocator deallocator 后, free_list 中的链表可能并不像初始的时候那么是 chunk 按内存分布位置依次链接的。假如 free_list 中不够时, allocator 会自动再分配一块新的较大的内存区块来加入到 free_list 链表中。

    可以自动管理多种不同大小内存块并可以自动增长的内存池,这是 SGI STL 分配器设计的特点。

 

    Loki 中的小对象分配器( small object allocator

 

    Loki 的分配器与 SGI STL 的原理类似,不同之处是它管理 free_list 不是固定大小的数组,而是用一个 vector 来实现,因此可以用户指定 fixed size block 的大小,不像 SGI STL 是固定最大 128 bytes 的。另外它管理 free chunks 的方式也不太一样, Loki 是由一列记录了 free block 位置等信息的 Chunk 类的链表来维护的, free blocks 则是分布在另外一个连续的大内存区间中。而且 free Chunks 也可以根据使用情况自动增长和减少合适的数目,避免内存分配得过多或者过少。

    Loki 的分配器使用也不太一样,可以直接调用,如下

 

    SmallObjAllocator myAlloc(2048, 256); // 参数 1 chunk size

                                          // 参数 2 max fixed size block size

    // 可以用于小于 256 bytes 的各种大小内存的分配

    void *p1 = (void*)myAlloc.Allocate(20);

    void *p2 = (void*)myAlloc.Allocate(100);

    void *p3 = (void*)myAlloc.Allocate(256);

    void *p4 = (void*)myAlloc.Allocate(300); // 大于 256 将转交给系统处理

    myAlloc.Deallocate(p1,20);

    myAlloc.Deallocate(p2,100);

    myAlloc.Deallocate(p3,256);

    myAlloc.Deallocate(p4,300);

 

    MFC CPlex CPtrList (扮演 memory pool 角色)

 

    CPlex 任务比较简单,只负责管理一大块 memory 并串接起来,用户每次获取都返回一大块。分割由使用者(如 Collection classes CFixedAlloc )将这一大块切割为一个个小的内存块。

    CPtrList 则负责管理这些切割后的小内存块,这一点有点类似 Loki 中的 free Chunks ,不过要简单多了。

    MFC 还有一个类叫 CFixedAlloc ,它是提供给应用类来分配固定大小(根据具体应用类的大小)的内存分配器。通过在应用类中定义 DECLARE_FIXED_ALLOC(Foo) IMPLEMENT_FIXED_ALLOC(Foo) 两个宏来实现。

 

    Boost object_pool

 

    Boost 中的 object_pool 也是一个可以根据用户具体应用类的大小来分配内存块的,也是通过维护一个 free nodes 的链表来管理的。可以自动增加 nodes 块,初始是 32 nodes ,每次增加都以两倍数向 system heap 要内存块。 object_pool 管理的内存块需要在其对象销毁的时候才返还给 system heap

 

    ACE 中的 ACE_Cached_Allocator ACE_Free_List

 

    ACE 框架中也有一个可以维护固定大小的内存块的分配器,原理与上面讲的内存池都差不多。它是通过在 ACE_Cached_Allocator 中定义个 Free_list 链表来管理一个连续的大内存块的,里面包含很多小的固定大小的未使用的区块( free chunk ),同时还使用 ACE_unbounded_Set 维护一个已使用的 chuncks ,管理方式与上面讲的内存池类似。也可以指定 chunks 的数目,也可以自动增长,定义大致如下所示:

 

template<class T>

class ACE_Cached_Allocator : public ACE_New_Allocator<T> {

public:

    // Create a cached memory pool with @a n_chunks chunks

    // each with sizeof (TYPE) size.

    ACE_Cached_Allocator(SIZET n_chunks = ACE_DEFAULT_INIT_CHUNKS);

    T* allocate();

    void deallocate(T* p);

private:

    // List of memory that we have allocated.

    Fast_Unbounded_Set<char *> _allocated_chunks;

    // Maintain a cached memory free list.

    ACE_Cached_Free_List<ACE_Cached_Mem_Pool_Node<T> > _free_list;

};

 

    设计模式

 

    最后一个主题重点讲讲设计模式,设计模式现在已经应用很广泛了,可以说是无处不在。设计模式现在对程序员是非常的重要,甚至到了不懂设计模式就不算真正的程序员一样。不过设计模式却又是非常高阶的理论,需要有多年的编程经验才能真正领悟,所以学习起来非常头痛。因为它道理非常简单,但是却非常抽象,候捷老师通过一大堆实际案例给我们逐个讲述了几个常用的模式的区别和用法。设计模式最经典最权威当属著名的有字天书 GoF 的《 Design Patterns 》了,我结合自己学习和实践的体会介绍一下几个模式。

 

    结构型模式之 Composite (合成模式)

 

    GoF 的定义: Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects. 翻译为中文大致意思是:将对象 (s) 组成为树状结构,用以表示“局部 - 整体”的层次体系,使得让 clients 可以以一致的方式对待“单个对象”和“合成对象”。

 

    比较典型的例子就是文件系统中“文件”和“目录”的关系,还有 Windows 窗口系统也是,在一个窗口中还可以开另一个窗口,多个窗口组合成的窗口还可以当作一个窗口放入另一个窗口中,比如在 Word 中打开多个文档就是这种情况。 Composite 模式的好处就是使得 clients 调用简单,可以用一致的接口处理单个对象或者多个单一对象组合成的对象。

 

    实例: Java swing library Component , Label , Container 就是 Composite 模式的应用。其中 Label Container 都继承自 Component ,但是 C ontainer 中只是一个存放 Component 的数组,所以 Container 中就可以放很多 Component ,比如 ScrollPane 就是继承自 Container ,它可以放 Label ,还有 List , Scrollbar 等等,甚至还可以放一个 ScrollPane ,所以就达到了 Composite 模式的效果,简化了 client 的使用。

 

    结构型模式之 Decorator (装饰模式)

 

    GoF 的定义: Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality. 翻译为中文大致的意思是:以动态的方式给一个对象添加一些额外的职责,使得不必进行 subclassing 就能扩展功能。

 

    Decorator 模式与 Composite 模式的区别就是它只内含一个 component object field ,而 Composite 则内含一个 collection of component field Decorator 负责将一个对象“装饰”起来,做一些“改造或者扩展”,提供额外的功能,它只针对一个 class 。而 Composite 是一组“类似”的对象及其容纳它们的容器一视同仁,使得 client 更简单地处理单个对象和一组对象。它们目的不一样。

 

实例: Java IO library BufferedReader , Reader 之间使用的就是 Decorator 模式,其中 BufferedReader 继承自 Reader ,同时它内部含有一个 Reader 引用,它是通过另一个 Reader 对象构造而来,因此就为 Reader 提供更多的功能,如带缓冲的 Reader 。使用非常简单,只需要如此定义:

 

Reader in = new BufferedReader(new FileReader("test.txt"));

 

就为文件读取增加了带缓冲的 IO 功能,非常方便。还可以多个 Decorator 的类组合使用,可以提供更强大的功能,多使用一下 Java IO library 就会体会到。

 

    行为模式之 Observer (观察者模式)