类里面是什么?-接口原则
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的意思很简单:命名空间不象人们通常想象的那样密不透风,但它们在隔离性上已足够完美并恰好满足它们本职工作。