COM沉思录(八)

COM沉思录(八)

            接口的不变性
            
            将“接口与实现分离”,让调用者不能了解库提供者的任何细节,而仅仅知道库所提供的公开接口,可以进一步降低重用的难度。而这些接口则是两者之间进行通信的协议,也可以称之为“契约”或“合同”。
            
            只要双方都按照“合同”所规定的方式去做,“合同”之外的东西互相不需要了解。那么无论一方何时做出何种改变,只要仍然符合“合同”的所有规定,那么对另一方则毫无影响。
            
            但是,一旦“合同”发生了变化,也就意味着双方之前的约定被破坏。除非双方能够协商建立新的“合同”,并都按照新的合同去操作,否则,双方的合作将会被破坏。
            
            既然接口就是双方(库和其调用者)合作的“合同”,所以接口必须具备“不变性”,即一旦接口被发布,将永不能变。
            由于接口是函数声明的集合,所以接口的“变”有三种,集合中的函数被增加,减少,或改变。这个改变是指函数的参数以及返回值的改变,不包括函数名的改变(改变一个函数的名字,等同于先减少一个函数,再增加另外一个函数)。
            
            不能减少和改变一个接口中的函数很容易可以理解,为什么增加一个函数也不可以?既然没有改变和减少原来的函数,原来的调用者似乎并不会受到影响。
            
            但现实往往是残酷的。且不考虑增加一个接口函数就意味着向“合同”里增加新的“条款”一样已经改变了合同双方最初的约定等人文因素,即使从纯技术的因素考虑,也会带来致命的伤害。让我们来看看为什么?
            
            因为接口是允许继承的,即一个接口可以从继承另外一个接口的函数集合,例如:
            struct interface_A
            {
                 virtual void func_A1(int) = 0;
                 virtual int func_A2(void) = 0;
            };
            
            struct interface_B: public interface_A
            {
                  virtual void func_B1(void) = 0;
            };
            
            编译后,interface_A和interface_B的二进制结构为:
            
                    vptr ---------> |-----------------|
                                    |    func_A1      |
                                    |-----------------|
                                    |    func_A2      |
                                    |-----------------|
            
                    vptr ---------> |-----------------|
                                    |    func_A1      |
                                    |-----------------|
                                    |    func_A2      |
                                    |-----------------|
                                    |    func_B1      |
                                    |-----------------|
            
            如果现在我们为interface_A增加一个新的函数func_A3,则interface_B的二进制结构变为:
                    vptr ---------> |-----------------|
                                    |    func_A1      |
                                    |-----------------|
                                    |    func_A2      |
                                    |-----------------|
                                    |    func_A3      |
                                    |-----------------|
                                    |    func_B1      |
                                    |-----------------|
            
            如果一个对象(组件)O实现了interface_B,而调用者使用了这个对象,则对于原来的版本,调用者调用接口B的函数func_B1时,编译出来的结果是访问vtbl的第3项。但对于新的版本,由于调用者没有重新编译,其仍然以vtbl的第三个入口来调用func_B1,但此时,对象O的接口interface_B的vtbl中的第三项已经改变为func_A3,于是错误的结果产生了。
            
            关于这个问题,Don
            Box给出了一个不充分,甚至是错误的解释。他认为新的调用者(即针对修改后的接口编译的调用者)在碰巧调用旧版本的接口时,由于找不到新的接口函数会引起崩溃。仔细考虑一下,在同一台机器上,既然库已经升级为新版本,怎么可能还会调用到旧的版本?
            
            但是,世间万物总是不完美的,接口也不能幸免。有时候,接口确实需要被改变。此时,至少有两种途径可以做到这一点:
            
            1)如果仅仅是扩充接口的功能,即增加新的接口函数,可以通过继承原有的接口,形成新的接口。
            2)干脆重新设计新的接口,让对象(组件)继承多个不相关的接口。


             darwin_yuan @ 14:56 | 阅读全文 | 评论(0) | 引用Trackback(0) | 编辑 

            2004-03-26  14:50
            COM沉思录(七)

            ——继承
            之前,我们把一个接口的二进制结构定义为一个vptr以及vptr指向的vtbl。一个实现了这个接口的对象的第一个属性就是vptr,于是我们可以很容易的定位并访问一个对象的接口。
            如果我们限制组件只能实现一个接口,则大大降低了我们这种技术的方便性。我们必须允许一个组件可以实现多个接口。
            既然一个接口包含一个vptr和一个vtbl,那么一个组件实现了多少个接口,组件里就会包含多少个vptr和vtbl。这些vptr依次放在组件对象结构的最前面,它们的顺序依赖于被声明的顺序。比如:
            class Component: public interface_A, interface B, interface_C
            {
            public:
            .... // 接口函数的声明及实现
            private:
             int a;
            };
            则在组件Component对象的前面依次为interface_A, interface_B,
interface_C的vptr,如下所示:
                |---------------------|
                |  interface_A_vptr   |---> interface_A_vtbl
                |---------------------|
                |  interface_B_vptr   |---> interface_B_vtbl
                |---------------------|
                |  interface_C_vptr   |---> interface_C_vtbl
                |---------------------|
                |      int a          |
                |---------------------|
            而调用者想访问哪个接口,就使用哪个vptr。一切都很明了。
            另外,也必须允许接口继承接口,这在面向对象的理论和实践中是一种自然的行为,不必过多的叙述。但这种继承必须是单继承。因为多重继承会影响一个接口的二进制格式。
            我们之前已经把一个接口的二进制格式设计为一个vptr和一个vtbl,如果一个接口继承自一个接口,并不会改变这种布局。比如:
            struct interface_A
            {
             virtual int func_A1(void) = 0;
             virtual void func_A2(int) = 0;
            };
            struct interface_C: public interface_A
            {
             virtual void func_C1(void) = 0;
            };
            则interface_C的结构为:
               interface_B_vptr -----> |-------------------|
                                       |    func_A1        |
                                       |-------------------|
                                       |    func_A2        |
                                       |-------------------|
                                       |    func_C1        |
                                       |-------------------|
            这没有问题。但如果是多继承,则一个接口会包含多个vptr及多个vtbl。比如:
            struct interface_B
            {
             virtual int func_B1(int) = 0;
            };
            struct interface_C: public interface_A, interface_B
            {
             virtual void func_C1(void) = 0;
            };
            则interface_C的二进制结构为:
               interface_A_vtpr -----> |-----------------|
               interface_B_vtpr --|    |    func_A1      |
                                  |    |-----------------|
                                  |    |    func_A2      |
                                  |    |-----------------|
                                  |    |    func_C1      |
                                  |    |-----------------|
                                  |
                                  |--->|-----------------|
                                       |   func_B1       |
                                       |-----------------|
            这显然与我们制定的接口二进制格式不符。另外,让接口进行多继承是没有必要的。我们完全可以通过让组件继承多个接口来实现与之等价的功能,同时却不破坏一个接口的二进制定义。
            对于这个例子,我们可以通过如下方法实现:
            struct interface_C: public interface_A
            {
             virtual void func_C1(void) = 0;
            };
            class Component: public interface_C, interface_B
            {
            public:
             // 接口函数声明及实现。
            private:
             // 组件属性
            };
            仔细考虑一下,你就会看出,两种方法得到了组件二进制结构是完全相同的(此时,你或许会质疑我之前的讨论),但两者之间在语义及操作上有着本质的不同,对于前者,组件实现的接口只有interface_C,那么就应该只存在一个vptr和一个vtbl,但组件却存在两个vptr和两个vtbl。而后者实现了两个接口,当然应该存在两套vptr和vtbl。


             darwin_yuan @ 14:50 | 阅读全文 | 评论(0) | 引用Trackback(0) | 编辑 

            2004-03-24  14:35
            COM沉思录(六)

            ——二进制接口
            
            将“接口与实现分离”,进一步降低了重用的难度。但现实生活中仍然有很多的问题在阻碍重用。其中最重要是各种开发语言的互操作问题。
            
            我们梦中存在的一种景象是,用任何一种语言开发出来的库,可以被任意其它语言轻松调用。
            
            这几乎无法做到,一个问题就可以把我们这个梦击的粉碎——编译型语言如何调用解释性语言所写的库?有人会说,这可以做到:编译型程序启动那个解释性语言的解释器,由它来解释由这种解释性语言写的库就行了。
            
            除非我疯了,或者有人拿刀架在我的脖子上,否则我绝对不会采取这个方案。一个库的提供者,如果它提供这套库的目标是想做到让所有的语言都可以轻松调用它,却选取解释性语言来写,那它一定是大脑出了问题。
            
            难道就这样放弃我们的梦想?别着急,冷静,冷静,再冷静。管理学中有一个重要的理论:最优目标是很难达到的,现实中我们往往追寻的是次优目标。我们不妨换个思路,如果我们的库是用编译型语言写的,那么是不是所有的语言都可以相对容易的调用?
            
            一个重要的事实是,所有现代主流的的解释型语言都提供了对编译型语言,如C或C++库的调用接口,并且事实上它们在调用这些库的时候,不需要借助任何东西,调入内存,执行它就是了,这样性能反而更高。
            
            啦……啦……,我们生活在一个幸福的年代,我们是一群幸运儿~~
            
            Come down!
            即使是编译型语言,由于编译器的实现千差万别,想实现跨语言调用,甚至同一种语言内部的互相调用,并不如想像的那么轻松。假如库是由C++写的,由于Name
            Mangling,C语言如何通过符号名调用它?即使由C++调用,由于不同编译器的Name
            Mangling的方案不同,也无法通过符号名调用。
            
            事情似乎又回到了列宁的问题上:怎么办?
            
            解决问题的思路当然是对症下药。既然存在Name
            Mangling,那我们不使用符号名来对应调用关系就行了,而是指定一个大家约定好的二进制格式,双方都可以根据这种格式找到相应的调用接口。
            
            不错的思路,但还需要加一个约束,那就是这种二进制格式必须让各种编译型语言可以轻易的生成。而所有语言都可以轻松的识别。
            
            让我们的头脑风暴继续狂野的进行下去——
            
            在面向过程的语言中,所谓接口,其实一个函数集合。而在面向对象的语言中,一个函数集合可以被定义为一个仅仅包含函数成员的的类。比如:
            struct interface{
               void function_1(int);
               int function_2(void);
               float function_3(int, float);
            };
            
            由于我们不能以符号——在这里就是函数名来进行双方的约定,那就使用函数指针。并且由于接口背后的实现可以被替换,也就是说这些函数指针在不同的实现中可以指向不同的函数,但函数原型却是一致的。所以,在C中,我们按如下方式定义一套接口:
            struct interface
            {
               void (*function_1)(int);
               int (*function_2)(void);
               float (*function_3)(int, float);
            };
            
            而在C++中,我们把函数定义为virtual的,就能让函数变为函数指针。
            
            struct interface
            {
               virtual void function_1(int) = 0;
               virtual int function_2(void) = 0;
               virtual float function_3(int, float) = 0;
            };
            
            将两者编译,C语言写成的结构将生成一个和定义吻合的函数指针表,但C++除了生成一张函数指针表外,还生成一个指向这张表格的指针。这张表在C++中被称作vtbl,这个指针称作vptr。
            
            到底应该使用哪种方式?考虑一下我们需要的是什么。
            
            按照“将接口与实现分离”的原则,调用者知道的仅仅是接口,但调用者通过这些接口仍然要操作数据,这些数据可能是全局变量,但更常见的应该是具体的对象,而这些对象是由库提供的接口函数来创建的。
            
            好吧,我们进一步的说,按照《Object-Oriented Software
            Construction》所描述的样子,软件重用应该像硬件重用那样,提供的是一个一个元件,这些元件实现了一个或多个接口,然后把这些元件通过接口组合起来,形成系统。而在软件中,一个元件就是一个对象或者结构,这个对象有自己的属性,同时提供对外的接口。
            
            回到原来的推理上,我们通过调用库,得到一个对象,然后调用这些接口从这个对象获取我们需要的功能。
            
            既然一个元件即有自己的属性,还有自己的接口,那么它的布局应该是怎样的?
            
            稍加思考我们就知道,C++的方式更符合我们的需要。因为按照C的方式,每一个对象实例都会包含所有的函数指针,这无疑是没有必要的。而C++让所有的对象实例共享同一个vtbl,每个对象实例仅仅包含一个vptr就够了。
            
            我们的组件实现一个接口时,在C++中就是在实现类中继承这个接口类,这个实现类中可以有自己的私有属性,以及内部接口(私有的或受保护的),比如:
            
            class component: public interface
            {
            public:
               virtual void function_1(int);
               virtual int function_2(void);
               virtual float function_3(int, float);
            protected:
                void self_func(void);
            private:
                int na;
                char* nb;
                long nc;
            };
            
            编译出来的布局为:
            
                |-------------------|             vtbl
                |     vptr          |------>|----------------|
                |-------------------|       |  p_function_1  |-->
                |     int na;       |       |----------------|
                |-------------------|       |  p_function_2  |-->
                |     char* nb      |       |----------------|
                |-------------------|       |  p_function_3  |-->
                |     long nc       |       |----------------|
                |-------------------|
            
            这样,当调用者得到一个对象时,它就能很容易的找到这个对象的vtbl,然后根据索引,而不是链接的符号名来调用vtbl中的接口函数。
            
            我们把这个对象中的接口部分拿出来,就是如下的结构。
                |------------------|                   vtbl
                |     vptr         |------>|-----------------|
                |------------------|       |   p_function_1  |-->
                                           |-----------------|
                                           |   p_function_2  |-->
                                           |-----------------|
                                           |   p_function_3  |-->
                                           |-----------------|
            
            对于这样一个接构,用C可以很轻松的实现。我们之前用C声明的结构体就是vtbl,我们只需要在对象结构的最前面加上一个vptr就行了,如下:
            struct object
            {
               struct interface* vptr;
               int na;
               char* nb;
               long nc;
            };
            
            对于C++对象模型非常了解的人一定会提出这样两个问题:
            1)不同编译器对vptr实现的位置不同。
            2)对于vtbl,由于RTTI,其前面会有一个Type_info。
            
            是这样,但它们都不影响我们使用C++生成这样的结构。
            
            对于第一个问题,有些编译器把vptr放在对象其它数据的前面,有些放在后面,比如下面的类
            
            struct T
            {
               virtual void func1(void) = 0;
               virtual void func2(int) = 0;
            private:
                int na;
                long nb;
            };
            
            由两种编译器编译出来的结构如下。
                  |-----------------|      |----------------|
                  |      vptr       |      |     int na     |
                  |-----------------|      |----------------|
                  |     int na      |      |     long nb    |
                  |-----------------|      |----------------|
                  |     long nb     |      |     vptr       |
                  |-----------------|      |----------------|
            
            但这个这是基于没有继承关系的类而言的,如果struct T被继承,比如:
            
            class Derived: public struct T
            {
            public:
               virtual void func1(void);
               virtual void func2(int);
            private:
               char nc;
            };
            
            则两种编译器编译出的结构如下:
                  |------------------|      |------------------|
                  |       vptr       |      |     int na       |
                  |------------------|      |------------------|
                  |     int na       |      |     long nb      |
                  |------------------|      |------------------|
                  |    long nb       |      |     vptr         |
                  |------------------|      |------------------|
                  |    char nc       |      |    char nc       |
                  |------------------|      |------------------|
            
            仔细观察这两个结构,你就会知道,基类对象的所有属性都被排放在继承类属性之前。而如果我们把基类属性都除去,则vptr就成为唯一从基类继承下来的东西,那么它自然就成为继承类的第一个元素。
            
            在我们的方案中,接口不包含任何属性,所有的实现类都是从接口类继承下来的,所以vptr当然被放在实现类对象的最前面。
            
            对于第二个问题,RTTI是后来加入C++的特征,对于任意一个C++编译器,为了实现和早期的C++程序的链接,它们都提供了相应的编译选项,除去这个特征,比如g++提供了-nortti。
            
            我们就这样得到了一个简单却绝对有效的,任何通用目的的编译型语言都可以毫不费力的生成的,任何非特殊目的的编程语言,无论是编译型还是解释型的(C,
            C++, Java, Ada,甚至Basic)都可以很容易识别的二进制结构。


             darwin_yuan @ 14:35 | 阅读全文 | 评论(0) | 引用Trackback(0) | 编辑 

            2004-03-23  13:25
            COM沉思录(五)


            ——接口与实现分离
            
            动态模型让重用变得更容易,但还远远不够。
            
            因为动态模型仅仅从物理上将可重用库和库的调用者分开。而两者逻辑上可能存在千丝万缕的联系。一旦库发生了调用者可见的变化,调用者必须做出相应的改变。
            
            先看一个例子。假如库A由一个调用者可见的类。
            
            class T
            {
            public:
                 void do_something(int v) { a = v; }
            private:
               int a;
            };
            
            调用者在自己的程序中使用了这个结构体,比如直接声明这个类的一个实例。然后开始调用这个类的公开方法。如下:
            
            void func(int v)
            {
              T t;
              t.do_something(v);  
            }
            
            然后,编译调用者,并指定其和库进行动态链接。
            
            显然,调用者的执行进入func的时候,由于其了解class T的细节,所以它知道需要在stack中为class
            T的实例t,分配4个字节的空间(如果int的宽度为4
            bytes的话),然后调用类函数T::do_something,这个函数的相关代码在库里,它会对a进行操作,如下所示:
            
                      Library                     Client(Caller)
                 |----------------|  Calling  |------------------|   
                 |   Function     |<----------|                  |
                 |  do_something  | Operation |  |------------|  |
                 |                |-----------|->|A T Instance|  |
                 |----------------|           |  |------------|  |
                                              |------------------|
            
            这不会有问题,因为无论是库函数还是调用者,大家对T的认知是一致的。
            
            但随后,由于某种原因,在库的升级版本中,将T的定义改变了,如下:
            
            class T
            {
            public:
                 void do_something(int v)   { b=a; a = v; }
            private:
               int a;
               int b;
            };
            
            此时,将库重新编译为动态链接库,然后将旧的版本替换掉,然后启动没有被重新编译的调用者,紧接着,系统在短暂的运行之后崩溃了。为什么会这样?
            
            这是因为调用者对T的认知仍然停留着4个字节的阶段,而在升级之后的库看来,T应该是8个字节,类成员函数T::do_something会按照新的认知来操作T的实例t,于是发生越界操作。系统当然会崩溃掉。
            
            那么,如果在调用者不直接声明T的实例,而是动态申请呢?比如调用者函数func修改为:
            void func(int v)
            {
              T* t = new T;
              t->do_something(v);  
            }
            
            结果是一样的,因为new事实上调用的是调用者的operator new(size_t size);请注意这个函数的参数size。T *t
            = new T其实最终被转化为T *t = operator
            new(sizeof(T))。而这个sizeof(T)在第一次被编译是为4,后来既然没有重新编译调用者,那么它依然为4。
            
            由这个例子可以很清楚的得知,如果调用者了解库所提供的某个数据结构的实现细节,那么它就对这个库产生了编译依赖,当库里任何它的调用者所依赖的实现细节发生改变的时候,调用者也必须进行重新编译。
            
            怎样才能避免这一点?很简单,那就是将“接口与实现分离”——库应该对调用者坚决隐藏所有实现细节,仅仅公开外部调用接口。如果想引用一个库提供的对象的时候,也应该由库来创建,然后调用者通过一个指向对象的指针来引用它。而对这个对象的操作,也完全有库所提供的公开接口来进行。既然对象的创建和对对象的操作都是由库完成的,那么无论库进行怎样的改变,它对实现细节的认知肯定是一致的。


             darwin_yuan @ 13:25 | 阅读全文 | 评论(0) | 引用Trackback(0) | 编辑 

            2004-03-22  13:22
            COM沉思录(四)


            ——动态链接
            
            Microsoft,这只软件业最大的超级恐龙,首先是一个操作系统提供商。它希望它的操作系统所提供的服务——比如库,不仅仅由固定的某种或某几种语言来调用。这是一个重用问题。
            
            但对于传统的编译链接模型,如果不加任何约束,不可能轻易做到这一点。
            
            在动态链接出现之前,软件一直使用静态链接模型,但静态链接存在很多问题。
            
            首先,使用静态链接模型无疑会造成资源的无谓浪费。有多少个系统调用某个库,那么这个库就会有多少份拷贝,这首先占用了静态资源(磁盘),当这些系统运行时,每个系统同样要为这些库在内存中分配空间,这就浪费了宝贵的动态资源(内存)。而这种浪费根本就是不必要的。
            
            其次,库生产厂商提供库,其它软件厂商在自己的产品中调用这些库,这是一种自然的重用行为。但静态链接模型无疑提高了重用的成本。
            
            任何一段代码都不可能是完美的,恒久不变的。随着需求的变化,本来完全运行良好的代码会变得不合需要。更不用说代码本身就存在BUG。所以,升级在软件领域(在其它领域也一样)是一个再普遍不过的行为。
            
            如果仅仅是库升级了,而调用这个库的程序本身没有任何改变,在静态链接模型下,必须对整个系统进行重新链接,并把链接之后的产品重新发布,这是一件麻烦事。
            
            这还不算什么,如果在一个指定的平台上,有多个系统调用同一套库;当这个库做出升级后,所有调用它的程序如果想使用新特性,或者想稳定工作(如果老版本的库有Bug),都必须重新编译。而这些系统很可能由不同的厂商提供,嗯,真是麻烦透顶。
            
            于是,动态链接模型出现了。动态链接模型将库和它的调用者分开,在磁盘上只保存库的一份拷贝,在运行时也是如此,首先节约了资源。另外,如果库本身只是修改了某些代码逻辑,而没有修改任何对外是可见的数据结构,调用者则无须做任何事情,平台拥有者将旧版本的库替换掉就是了。
            
            由此可见,动态链接技术降低了重用成本,使重用变得更容易。


             darwin_yuan @ 13:22 | 阅读全文 | 评论(0) | 引用Trackback(0) | 编辑 

            日历

              2004 年 12 月 
            SunMonTueWenThuFriSat
               1234
            567891011
            12131415161718
            19202122232425
            262728293031


      

            用户登录
            用户名:
            密   码:
                      

            搜索

          


            最近更新
      为goto正名
      相等性判断的自动化
      属性也应是接口
      Delegate Class
      Package vs. Namespace
      Function Signature:新思维
      Visibility: 两种观点
      Java在Interface方面的缺陷
      也谈对象的生命
      Unlink操作


            最新评论
      soloist:To darwin_yuan:<.
      soloist:一、楼主所描述的.
      dreamhead:1 对于只有get和.
      tinyfool:属性当然是接口,.
      javasea:这不是java特有的.
      fox: 随便问一句,您?
      fox:很高兴看到沉寂很.
      fox:好久没看到您的更.
      mochow:好久没更新了,什.
      dreamhead:这个名字起的太有.
            存档

原文地址:https://www.cnblogs.com/dayouluo/p/92290.html