Guru of the Week 条款30附录:接口原则

(至此,GotW1~30即《Exceptional C++》的原型,补全。)

Herb Sutter在March 1998于C++ Report上发表的文章《What's In a Class - The Interface Principle》,属《Exceptional C++》的Item 32~34。

接口原则(the Interface Principle)

类里面是什么?-接口原则

This article appeared in C++ Report, 10(3), March 1998.

 

我开始于一个简单而令人困惑的问题:

l         类里面是什么?也就是,什么组成了类及其接口?

更深层的问题是:

l         它怎样和C风格的面向对象编程联系起来?

l         它怎样和C++的Koenig lookup联系起来?和称为Myers Example的例子又有什么联系?(我都会讨论。)

l         它怎样影响我们分析class间的依赖关系以及设计对象模型?

那么,“class里面是什么?”

首先,回顾一下一个class的传统定义:

Class(定义)

一个class描述了一组数据及操作这些数据的函数。

程序员们常常无意中曲解了这个定义,改为:“噢,一个class,也就是出现在其定义体中的东西--成员数据和成员函数。”于是事情变味了,因为它限定“函数”为只是“成员函数”。看这个:

         //*** Example 1 (a)

     class X { /*...*/ };

     /*...*/

     void f( const X& );

问题是:f是X的一部分吗?一些人会立即答“No”,因为f是一个非成员函数(或“自由函数”)。另外一些人则认识到关键性的东西:如果Example 1 (a)的代码出现在一个头文件中,也就是:

     //*** Example 1 (b)

     class X { /*...*/

     public:

       void f() const;

     };

再考虑一下。不考虑访问权限〖注1〗,f还是相同的,接受一个指向X的指针或引用。只不过现在这个参数是隐含的。于是,如果Example 1 (a)全部出现在同一头文件中,我们将发现,虽然f不是X的成员函数,但强烈地依赖于X。我将在下一节展示这个关联关系的真正含义。

换种方式,如果X和f不出现在同一头文件中,那么f只不过是个早已存在的用户函数,而不是X的一部分(即使f需要一个X类型的实参)。我们只是例行公事般写了一个函数,其形参类型来自于库函数头文件,很明确,我们自己的函数不是库函数中的类的一部分。

The Interface Principle

接口原则(Interface Principle)

依靠那个例子,我将提出接口原则:

接口原则

对于一个类X,所有的函数,包括自由函数,只要同时满足

(a) “提到”X,并且

(b)  与X“同期提供”

那么就是X的逻辑组成部分,因为它们形成了X的接口。

根据定义,所有成员函数都是X的“组成部分”:

(a) 每个成员函数都必须“提到”X(非static的成员函数有一个隐含参数,类型是X*(WQ:C++标准制定后,精确地说,应该是X* const)或const X*(WQ:const X* const);static的成员函数也是在X的作用范围内的);并且

(b) 每个成员函数都与X“同期提供”(在X的定义体中)。

用接口原则来分析Example 1 (a),其结论和我们最初的看法相同:很明确,f“提到”X。如果f也与X“同期提供”(例如,它们存在于相同的头文件和/或命名空间中〖注2〗),根据接口原则,f是X的逻辑组成部分,因为它是X的接口的一部分。

接口原则在判断什么是一个class的组成部分时,是个有用的试金石。把自由函数判定为一个class的组成部分,这违反直觉吗?那么,给这个例子再加些砝码,将f改得更常见些:

     //*** Example 1 (c)

     class X { /*...*/ };

     /*...*/

     ostream& operator<<( ostream&, const X& );

现在,接口原则的基本理念就很清楚了,因为我们理解这个特别的自由函数是怎么回事:如果operator<<与X“同期提供”(例如,它们存在于相同的头文件和/或命名空间中),那么,operator<<是X的逻辑组成部分,因为它是X的接口的一部分。

据此,让我们回到class的传统定义上:

Class(定义)

一个class描述了一组数据及操作这些数据的函数。

这个定义非常正确,因为它没有说到“函数”是否为“成员”。

这个接口原则是OO的原则呢,还是只是C++特有的原则?

我用了C++的术语如“namespace”来描述“与...同期提供”的含义的,所以,接口原则是C++特有的?还是它是OO的通行原则,并适用于其它语言?

考虑一下这个很类似的例子,从另一个语言(实际上,是个非OO的语言):C。

     /*** Example 2 (a) ***/

     struct _iobuf { /*...data goes here...*/ };

     typedef struct _iobuf FILE;

     FILE* fopen ( const char* filename,

                   const char* mode );

     int   fclose( FILE* stream );

     int   fseek ( FILE* stream,

                   long  offset,

                   int   origin );

     long  ftell ( FILE* stream );

          /* etc. */

这是在没有class的语言中实现OO代码的典型“处理技巧”:提供一个结构来包容对象的数据,提供函数--肯定是非成员的--接受或返回指向这个结构的指针。这些自由函数构造(fopen),析构(fclose)和操作(fseek、ftell等等……)这些数据。

这个技巧是有缺点的(例如,它依赖于用户能忍住不直接胡乱操作数据),但它“确实”是OO的代码--总之,一个class是一组数据及操作这些数据的函数。虽然这些函数都是非成员的,但它们仍然是FILE的接口的一部分。

现在,考虑一下怎样将Example 2 (a)用一个有class的语言重写:

     //*** Example 2 (b)

     class FILE {

     public:

       FILE( const char* filename,

             const char* mode );

      ~FILE();

       int  fseek( long offset, int origin );

       long ftell();

            /* etc. */

     private:

       /*...data goes here...*/

     };

那个FILE*的参数变成为隐含参数。现在就明确了,fseek是FILE的一部分,和Example 2 (a)中它还不是成员函数时一样。我们甚至可以将函数实现为一部分是成员函数,一部分是非成员函数:

     //*** Example 2 (c)

     class FILE {

     public:

       FILE( const char* filename,

             const char* mode );

      ~FILE();

       long ftell();

            /* etc. */

     private:

       /*...data goes here...*/

     };

     int fseek( FILE* stream,

                long  offset,

                int   origin );

很清楚,是不是成员函数并不是问题的关键。只要它们“提及”了FILE并且与FILE“同期提供”,它们就是FILE的组成部分。在Example 2 (a)中,所有的函数都是非成员函数,因为在C语言中,它们只能如此。即使在C++中,一个class的接口函数中,有些必须(或应该)是非成员函数:operator<<不能是成员函数,因为它要求一个流对象作为左操作参数,operator+不该是成员函数以允许左操作参数发生(自动)类型转换。

介绍Koenig Lookup

接口原则还有更深远的意义,如果你认识到它和Koenig Lookup干了相同的事的话〖注4〗。此处,我将用两个例子来解说和定义Koenig Lookup。下一节,我将用Myers Example来展示它为何与接口原则有直接联系。

这是我们需要Koenig lookup的原因,来自于C++标准中的例子:

     //*** Example 3 (a)

     namespace NS {

       class T { };

       void f(T);

     }

     NS::T parm;

     int main() {

       f(parm);    // OK: calls NS::f

     }

很漂亮,不是吗?“明显”,程序员不需要明确地写为NS::f(parm),因为f(parm)“明确”意味着NS::f(parm),对吧?但,对我们显而易见的东西对编译器来说并不总是显而易见,尤其是考虑到连个将名称f带入代码空间的“using”都没用。Koenig lookup让编译器得以完成正确的事情。

它是这么工作的:所谓“名称搜索”就是当你写下一个“f(parm)”调用时,编译器必须要决策出你想调哪个叫f的函数。(由于重载和作用域的原因,可能会有几个叫f的函数。)Koenig lookup是这么说的,如果你传给函数一个class类型的实参(此处是parm,类型为NS::T),为了查找这个函数名,编译器被要求不仅要搜索如局部作用域这样的常规空间,还要搜索包含实参类型的命名空间(此处是NS)〖注5〗。于是,Example 3 (a)中是这样的:传给f的参数类型为T,T定义于namespace NS,编译器要考虑namespace NS中的f--不要大惊小怪了。

不用明确限定f是件好事,因为这样我们就很容易限定函数名了:

     //*** Example 3 (b)

     #include <iostream>

     #include <string>  // this header

         //   declares the free function

         //   std::operator<< for strings

     int main() {

       std::string hello = "Hello, world";

       std::cout << hello; // OK: calls

     }                     //   std::operator<<

这种情况下,没有Koenig lookup的话,编译器将无法找到operator<<,因为我们所期望的operator<<是个自由函数,我们只知道它是string包的一部分。如果程序员被强迫为必须限定这个函数名的话,会很不爽,因为最后一行就不能很自然地使用运算符了。取而代之的,我们必须写为“std::operator<<( std::cout, hello );”或“using namespace std;”。如果这种情况触痛了你的话,你就会明白为什么我们需要Koenig lookup了。

总结:如果你在同一命名空间中提供一个class和一个“提及”此class的自由函数〖注6〗,编译器在两者间建立一个强关联〖注7〗。再让我们回到接口原则上,考虑这个Myers Example:

更多的Koenig Lookup:Myers Example

考虑第一个(略为)简化的例子:

     //*** Example 4 (a)

     namespace NS { // typically from some

       class T { }; //   header T.h

     }

     void f( NS::T );

     int main() {

       NS::T parm;

       f(parm);     // OK: calls global f

     }

Namespace NS提供了一个类型T,而在它外面提供了一个全局函数f,碰巧此函数接受一个T的参数。很好,天空是蔚蓝的,世界充满和平,一切都很美好。

时间在流逝。有一天,NS的作者基于需要增加了一个函数:

     //*** Example 4 (b)

     namespace NS { // typically from some

       class T { }; //   header T.h

       void f( T ); // <-- new function

     }

     void f( NS::T );

     int main() {

       NS::T parm;

       f(parm);     // ambiguous: NS::f

     }              //   or global f?

在命名空间中增加一个函数的行为“破坏”了命名空间外面的代码,即使用户代码没有用“using”将NS中的名称带到它自己的作用域中!但等一下,事情是变好了--Nathan Myers〖注8〗指出了在命名空间与Koenig lookup之间的有趣行为:

     //*** The Myers Example: "Before"

     namespace A {

       class X { };

     }

     namespace B {

       void f( A::X );

       void g( A::X parm ) {

         f(parm);   // OK: calls B::f

       }

     }

很好,天很蓝……。一天,A的作者基于需要增加了另外一个函数:

     //*** The Myers Example: "After"

     namespace A {

       class X { };

       void f( X ); // <-- new function

     }

     namespace B {

       void f( A::X );

       void g( A::X parm ) {

         f(parm);   // ambiguous: A::f or B::f?

       }

     }

“啊?”你可能会问。“命名空间卖点就是防止名字冲突,不是吗?但,在一个命名空间中增加函数却看起来造成'破坏'了另一个完全隔离的命名空间中的代码。”是的,namespace B中的代码被破坏了,只不过是因为它“提及”了来自于namespace A中的一个类型。B中的代码没有在任何地方写出“using namespace; A”,也没有写出“using A::X;”。

这不是问题,B没有被“破坏”。事实上,这是应该发生的正确行为〖注9〗。如果在X所处的命名空间中有应该函数f(X),那么,根据接口原则,f是X的接口的一部分。f是一个自由函数根本不是关键;想确认它仍然是X的在逻辑组成部分,只要给它另外一个名字:

     //*** Restating the Myers Example: "After"

     namespace A {

       class X { };

       ostream& operator<<( ostream&, const X& );

     }

     namespace B {

       ostream& operator<<( ostream&, const A::X& );

       void g( A::X parm ) {

         cout << parm; // ambiguous:

       }               //   A::operator<< or

     }                 //   B::operator<<?

如果用户代码提供了一个“提及”X的函数,而它与X所处的命名空间提供的某个函数签名重合时,调用将有二义性。B必须明确表明它想调用哪个函数,它自己的还是与X“同期提供”的。这正是我们期望接口原则应该提供的东西:

接口原则

对于一个类X,所有的函数,包括自由函数,只要同时满足

   (a) “提到”X,并且

   (b)  与X“同期提供”

就是X的逻辑组成部分,因为它们组成了X的接口。

简而言之,接口原则与Koenig lookup的行为相同并不是意外。Koenig lookup的行为正是建立在接口原则的基础上的。

(下面“关联有多强?”这节展示了为什么成员函数class之间仍然有比非成员函数更强的关联关系)

“组成部分”的关联有多强?

虽然接口原则说明成员和非成员函数都是class的逻辑“组成部分”,但它并没说成员和非成员是平等的。例如,成员函数自动得到class内部的全部访问权限,而非成员函数只有在它们被申明为友元时才能得到相同的权限。同样,在名称搜索(包括Koenig lookup)中,C++语言特意表明成员函数与class之间有比非成员函数更强的关联关系:

 //*** NOT the Myers Example

 namespace A {

   class X { };

   void f( X );

 }

 class B {

 // class, not namespace

  void f( A::X );

  void g( A::X parm ) {

  f(parm); // OK: B::f,

           // not ambiguous

  }

 };

我们现在讨论的是class B而不是namespace B,所以这没有二义:当编译器找到一个叫f的成员函数时,它不会自找麻烦地使用Koenig lookup来搜索自由函数。

所以,在两个主要问题上--访问权限规则和名称搜索规则--即使根据接口原则,当一个函数是一个class的组成部分时,成员函数与class之间有比非成员函数更强的关联关系。

一个class依赖于什么?

class里面是什么”并不只是一个哲理问题。它是一个根基性问题,因为,没有正确的答案的话,我们就不能恰当地分析class的依赖关系。

为了证明这一点,看一下这个似乎不相关的问题:怎么实现一个class的operator<<?有两个主要方法,但都有取舍。我都进行分析,最后我们将发现我们又回到了接口原则上,它在正确分析取舍时提供了重要的指导原则。

第一种:

     //*** Example 5 (a) -- nonvirtual streaming

     class X {

       /*...ostream is never mentioned here...*/

     };

     ostream& operator<<( ostream& o, const X& x ) {

       /* code to output an X to a stream */

       return o;

     }

这是第二个:

     //*** Example 5 (b) -- virtual streaming

     class X { /*...*/

     public:

       virtual ostream& print( ostream& o ) {

         /* code to output an X to a stream */

         return o;

       }

     };

     ostream& operator<<( ostream& o, const X& x ) {

       return x.print();

     }

假设两种情况下,class和函数都出现在相同的头文件和/或命名空间中。你选择哪一个?取舍是什么?历来,C++的资深程序员用这种方式分析了这些选则:

l         选择(a)的好处是X有更低的依赖性。因为X没有成员函数“提及”了“流(ostream)”,X(看起来)不依赖于流。选择(a)同样也避免了一个额外的虚函数调用的开销。

l         选择(b)的好处是所有X的派生类都能正确print,即使传给operator<<的是一个X&。

这个分析是有瑕疵的。运用接口原则,我们能发现为什么--选择(a)的好处是假象,因为:

l         根据接口原则,只要operator<<“提及”了X(两种情况下都是如此)并且与X“同期提供”(两种情况下都是如此),它就是X的逻辑组成部分。

l         两种情况下,operator<<都“提及”了流,所以operator<<依赖流。

l         因为两种情况下operator<<都是X的逻辑组成部分且都依赖于流,所以,两种情况下X都依赖流。

所以,我们素来认为的选择(a)的主要好处根本就不存在--两种情况下X都依赖于流!如果(通常也如此)operator<<和X出现在相同的X.h中,两种情况下X的实现体和使用了X的实体的用户模块都依赖流,并至少需要流的前向申明以完成编译。

随着第一大好处的幻像的破灭,选择(a)就只剩下没有虚函数调用开销的好处了。不借助于接口原则的话,我们就没法如此容易地在这个很常见的例子上分析依赖性上的真象(以及事实上的取舍)。

底线:区分成员还是非成员没有太大的意义(尤其是在分析依赖性时),而这正是接口原则所要阐述的。

一些有趣(甚至是诧异)的结果

通常,如果A和B都是clss,并且f(A,B)是一个自由函数:

l         如果A与f同期提供,那么f是A的组成部分,并且A将依赖B。

l         如果B与f同期提供,那么f是B的组成部分,并且B将依赖A。

l         如果A、B、f都是同期提供的,那么f同时是A和B的组成部分,并且A与B是循环依赖。这具有根本性的意义--如果一个库的作者提供了两个class及同时涉及二者的操作,那么这个操作恐怕被规定为必须同步使用。现在,接口原则对这个循环依赖给出了一个严格的证明。

最后,我们到了一个真的很有趣的状态。通常,如果A和B都是class,并且A::g(B)是A的一个成员函数:

l         因为A::g(B)的存在,很明显,A总是依赖B。没有疑义。

l         如果A和B是同期提供的,那么A::g(B)与B当然也是同期提供的。于是,因为A::g(B)同时满足“提及”B和与B“同期提供”,根据接口原则(恐怕有些诧异):A::g(B)是B的组成部分,而又因为A::g(B)使用了一个(隐含的)A*参数,所以B依赖A。因为A也依赖B,这意味着A和B循环依赖。

首先,它看起来只是“将一个class的成员函数判定为也是另一个class的组成部分”的推论,但这只在A和B是同期提供时才成立。再想一下:如果A和B是同期提供的(也就是说,在同一头文件中),并且A在一个成员函数中提及了B,“直觉”也告诉我们A和B恐怕是循环依赖的。它们之间肯定是强耦合的,它们同期提供和互相作用的事实意味着:(a)它们应该同步使用,(b)更改一个也会影响另一个。

问题是:在此以前,除了“直觉”很难用实物证明A与B间的循环依赖。现在,这个循环依赖可以用接口原则的来推导证明了。

注意:与class不同,namespace不需要一次申明完毕,这个“同期提供”取决于namespace当前的可见部分:

     //*** Example 6 (a)

     //---file a.h---

     namespace N { class B; } // forward decl

     namespace N { class A; } // forward decl

     class N::A { public: void g(B); };

     //---file b.h---

     namespace N { class B { /*...*/ }; }

A的用户包含了a.h,于是,A和B是同期提供的并是循环依赖的。B的用户包含了b.h,于是A和B不是同期提供的。

总结

我希望你得到3个想法:

l         接口原则:对于class X,所有的函数,包括自由函数,只要同时满足(a)“提及”X,(b)与X“同期提供”,那么它就是X的逻辑组成部分,因为它们是X的接口的一部分。

l         因此,成员和非成员函数都是一个class的逻辑组成部分。只不过成员函数比非成员函数有更强的关联关系。

l         在接口原则中,对“同期提供”的最有用的解释是“出现在相同的头文件和/或命名空间中”。如果函数与class出现在相同的头文件中,在依赖性分析时,它是此class的组成部分。如果函数与类出现在相同的命名空间中,在对象引用和名称搜索时,它是此class的组成部分。

 

1.即使最初f是一个友元,情况还是这样的。

2.我们在本文后面的篇幅中将详细讨论命名空间之间的关联关系,因为它指出了接口原则实际上和Koenig lookup的行为是一致的。

3.成员和非成员函数间的相似性,在另外一些可重载的操作符上表现得甚至更强烈。例如,当你写下“a + b”时,你可以调用a.operator+(b)也可以是operator+(a,b),这取决于a和b的类型。

4.以Andrew Koenig命名的,因为最初由他写出了这个定义,他是AT&T's C++ team 和C++ standards committee的长期会员。参见由 A. Koenig 和B. Moo写的《Ruminations on C++》(Addison-Wesley, 1997)。

5.还有其它一些细节,但本质就是这个。

6.通过传值、传引用、传指针,或其它方式。

7.要承认,其关联关系要比class与其成员间的关联关系要弱那么一点点。后文有“关联有多强”这么一节。

8.Nathan也是C++ standards committee的长期会员,并且是标准中的locale facility的主要作者。

9.这个特别的例子出现在November 1997的Morristown会议上,它激起我思考成员关系和依赖关系。Myers Example的意思很简单:命名空间不象人们通常想象的那样密不透风,但它们在隔离性上已足够完美并恰好满足它们本职工作。

你可能感兴趣的:(Guru of the Week 条款30附录:接口原则)