耦合的三重境界(接上次讨论)

148 views
Skip to first unread message

pongba

unread,
Sep 19, 2007, 3:12:46 AM9/19/07
to pon...@googlegroups.com
上次的讨论真过瘾啊:-) 我把大家的意思总结了一下,接着讨论!

本文首先将耦合从本质上分为三个境界,然后阐述为什么C本质上趋向于更松耦合的设计(没错,的确跟C语言本身有关),以及为什么C++的GP也能够带来同样的效果。以及为什么OO吸引人们作出不好的(更紧耦合的)设计。

耦合的三重境界为:实体耦合、接口耦合、签名耦合。

最常见的就是实体耦合:

void f(Stack s) { ... }

然后是接口耦合:

void f(IStack s) { ... }

最后是签名耦合:

struct stack
{
int (*pop)();
void (*push)(int);
...
};

void f(struct stack s) { ... }

下面讨论各个语言对这三种耦合的支持程度,以及各自语言里面最容易达到哪种耦合。

C里面没有接口,所以只存在两种耦合,实体耦合和签名耦合。在C里面,要实现抽象,几乎唯一的办法就是利用函数指针,有时候也会用到将函数指针捆绑成一个结构体,但请注意这跟接口的本质区别,接口里面的函数有名字,接口自己也有名字,所以如果一个类没有实现这个接口但具有其语意的话,就必须做一个adapter层(就是所谓的适配器模式)。但C里面的一组函数指针其实是一组delegate,没有名字,不用吃药,也不用打针,任何签名相同的东东都可以挂在上面。所以在C里面要实现抽象的话往往就是最小的依赖,因为签名依赖已经小得不能再小了。我认为这也是为什么许多人觉得用C做设计好的原因,因为它强迫你去用最小的签名依赖。

加入了OO的语言,几乎无一例外的鼓励用接口来实现复用,一个算法要复用,就得针对接口编程,而后者就要求必须先提取接口,接口里面有一组函数,每个都有名字,这就引入了一组名字依赖,这个依赖远远强于刚才提到的签名依赖(delegate),其导致的后果是,如果将一个算法应用到一个第三方库上的话,就需要做接口的适配层,导致不必要的粘合层。

那么,是不是就意味着,要想实现最低依赖,就必须求助于C的方式或者C#那样的delegate呢?不然。
GP就提供了一个很好的解决方案,GP鼓励的泛化复用方式不像OO那样是针对接口,而是针对概念。比如std::foreach

template<typename Iter, typename Pred>
void for_each(Iter begin, Iter end, Pred pred);

这里的Iter和Pred其实都是delegate,for_each对它们的依赖都只是签名依赖,而非接口依赖。

上次Longshanks给出的例子里面:

template<typename T, typename Pred, typename A>
float collect(T& item, Pred filter, A ammount) {

这里的filter和amount其实都是delegate,引入的都是签名依赖,而不是接口依赖。用传统OO实现,势必要引入本质上更强的接口依赖。其实这个collect里面还有一个成分也可以lift出来(泛型算法的建设过程就是不断lift这些公共成分的过程),就是取item的各个子项目, 原来实现是用item.children,这就耦合于item的具体实现了,而且更糟糕的是耦合于其成员,lift一下可以加一个参数(如果使用麻烦的话可以加一个缺省实参)。

但其实最关键的还是,GP编程的泛化过程鼓励的不是提升出接口,而是提升出概念(Iter, Pred),这些概念引入的依赖是签名依赖。

我认为这是GP导致松耦合的本质性质。

此外,别忘了C++的GP的作用比C更强大,你可以bind一个成员函数,你可以传一个functor(stateful function),等等。

对比一下C的qsort,同是delegate,同时签名依赖,但C的更不直观:

void sort(void* arr, int num, int size, int (*f)(const void*, const void*));

当然,不可否认的是,如果是二进制复用,那就只能用C了。

[本文主要阐述为什么C能够导致更松耦合的设计,以及为什么C++的GP也能够带来同样的效果。]


--
刘未鹏(pongba)|C++的罗浮宫
http://blog.csdn.net/pongba
TopLanguage
http://groups.google.com/group/pongba

li liu

unread,
Sep 19, 2007, 4:08:43 AM9/19/07
to pon...@googlegroups.com
这三重境界的总结非常精辟.

从多态绑定时间来说,GP是编译时绑定,OO是运行时绑定.
如果只需要编译时多态的话,选择GP实现,可以使耦合度更小,重用性更高,也不会出现恼人的粘合层了.

Huafeng Mo

unread,
Sep 19, 2007, 8:17:25 AM9/19/07
to pon...@googlegroups.com
明白了,明白了!
实体耦合最强,接口耦合次之,签名耦合最弱。
这都是在没有泛型参与的情形。无论是OOP还是SP,实体耦合都存在。OOP有接口耦合,没有签名耦合。(只考虑纯OOP)。SP有签名耦合,但没有接口耦合。
在没有复用要求的情况下,大家都用实体耦合,相安无事。但一旦出现了复用需求,那么他们各自去寻求耦合度更小的方案。OOP只有接口耦合,SP只有签名耦合。但是,签名耦合弱于接口耦合,所以双方都在竭尽全力的情况下,SP耦合弱于OOP的耦合。所以C设计下的结构更趋灵活和简洁。
但是,SP的弱耦合不是没有代价的。SP的弱耦合是以放弃类型安全为代价的。而OOP则保全了类型的安全,但却付出了强耦合的代价。
现在,如果引入泛型,对于OOP而言(我的那个OOP风格的GP方案),大幅弱化了耦合性,却没有损失类型安全。而对于SP而言(我最初的那个SP风格的GP方案),类型安全得到了保证,却没有增强耦合。
这也表明了GP的能量:消除了两种方案(OOP和SP)的缺陷,但却没有带来损害。
GP万岁!!!:-)

Huafeng Mo

unread,
Sep 19, 2007, 8:53:10 AM9/19/07
to pon...@googlegroups.com
其实这个collect里面还有一个成分也可以lift出来(泛型算法的建设过程就是不断lift这些公共成分的过程),就是取item的各个子项目, 原来实现是用item.children,这就耦合于item的具体实现了,而且更糟糕的是耦合于其成员,lift一下可以加一个参数(如果使用麻烦的话可以加一个缺省实参)
=================================
顺着这个思路,我来整理一下泛型算法的抽象过程:
首先,我只针对Account写一个collect算法,并找出耦合的地方(黑体):

float collect(Account& item) {
   if(g_specialAccount.IsSpecial(item.AccountType())==false)
       return  0;

    float result(0);

   Account::child_vect& children=item.children();
   if(children.size==0 )        //最明细科目,没有子科目
       return  item.ammount();

   Account::child_vect::iterator ib(children.begin()), ie(children.end());
   for(; ib!=ie; ++ib)
   {
       result+=collect(*ib);
   }
}
然后, 把collect编程模板,Account换成T,所有的item有关的成员调用都改为delegate,把非依赖性的类型,变成依赖性的。于是就变成了:
template<typename T, typename Pred, typename Sub, typename M>
typename M::return_type collect(T& item, Pred p, Sub s, M m) {
   if(p(item)==false)
       return  0;

   typename M::return_type result(0);

   T::child_vect& children=s(item);
   if(children.size()==0)
       return m(item);

   T::child_vect::iterator ib(children.begin()), ie(children.end());
   for(; ib!=ie; ++ib)
   {
       result+=collect(*ib, p, s, m);
   }
    return result;
}

这样的耦合性已经非常小了,残存的耦合性是子节点容器,依赖于T必须存在一个类型或类型别名,是一个标准容器。以及返回值类型依赖于M定义的返回类型。我觉得这应该是必要的约定。

完毕,烦请各位老大指正。

On 9/19/07, pongba <pon...@gmail.com> wrote:

redsea

unread,
Sep 19, 2007, 9:38:21 AM9/19/07
to TopLanguage
总结起来的确是这样.
最近这两年, 我是凭感觉变化编程方式, 并没有进行深入思考和总结, 这次大讨论一次, 感觉思路理清了很多 :)

On 9月19日, 下午3时12分, pongba <pon...@gmail.com> wrote:
> 上次的讨论 <http://groups.google.com/group/pongba/t/e553a21476ba2ebd>真过瘾啊:-)
> 我把大家的意思总结了一下,接着讨论!

redsea

unread,
Sep 19, 2007, 9:44:34 AM9/19/07
to TopLanguage
只要不碰到动多态, GP 的优点很突出, 再来说说 C++ GP 的缺点:

1. 目前的 C++的 GP 实现, 好像是有一个 c++ 编译器可以从 lib 里面提取模板进行实例化的, 是不是 g++? VC 一定
是需要头文件的, 这样, 一点改掉就会引起所有的程序重新编译, 再加上 c++ 的编译速度, 这个比较讨厌.

2. 项目的分工需要先定义各个模块之间的 API, OOP 的话, API 用 interface 来定义即可, GP 怎么办 ?

pongba

unread,
Sep 19, 2007, 9:45:54 AM9/19/07
to pon...@googlegroups.com
其实这里还有一个问题,就是当出现一组delegate的时候。比如Iter这个concept就有一组delegate(++,--,*等等)。这时客户类就不能直接省事的delegate了,往往还是要adapter一下。
STL的Iterator分类体系其实就是一个接口体系。一旦这个体系建立起来了,就有了依赖性。STL的Iterator体系被证明分类分得并不好,把遍历方式跟取值的方式混在一起了,违反了正交性法则。

redsea

unread,
Sep 19, 2007, 9:51:09 AM9/19/07
to TopLanguage
遍历方式和取值方式混在一起是不是有原因的?

模糊记得以前看到一篇文章(是不是 effective c++? ), 讲到 stack 的API 设计, 如果将 pop 和 get
current value 两个东西分开, 会容易导致异常不安全还是什么地, 记性不好, 忘了.


On 9月19日, 下午9时45分, pongba <pon...@gmail.com> wrote:
> 其实这里还有一个问题,就是当出现一组delegate的时候。比如Iter这个concept就有一组delegate(++,--,*等等)。这时客户类就 不能直接省事的delegate了,往往还是要adapter一下。

oldrev

unread,
Sep 19, 2007, 9:53:38 AM9/19/07
to pon...@googlegroups.com
迭代器把函数签名的耦合都去掉了,不过这种操作符重载的形式恐怕不太适合非数值的类型。

另外,你说的是 boost 的那个 new-style 迭代器?这也是我很感兴趣的地方,不知道能不能进 C++0x

oldrev <oldrev(at)gmail(dot)com>
Regards

>>> 引用的文字:

oldrev

unread,
Sep 19, 2007, 9:57:09 AM9/19/07
to pon...@googlegroups.com
似乎世界上唯一一个支持 extern 关键字的 C++ 编译器是 Edison Design Group EDG C++,我一直奇怪把模板的实现和声明分离是
不能实现的啊

oldrev <oldrev(at)gmail(dot)com>
Regards

>>> 引用的文字:

redsea

unread,
Sep 19, 2007, 9:57:46 AM9/19/07
to TopLanguage
exceptional C++ 里面有相关的文章.

我弄反了, 是弹出和取值混合的话, 无法涉及出异常安全的 API, 还是应该设计正交的 API 才对.

> > STL的Iterator分类体系其实就是一个接口体系。一旦这个体系建立起来了,就有了依赖性。STL的Iterator体系被证明分类分得并不好,把遍历方 式跟取值的方式混在一起了,违反了正交性法则。- 隐藏被引用文字 -
>
> - 显示引用的文字 -

red...@gmail.com

unread,
Sep 19, 2007, 10:46:10 AM9/19/07
to pon...@googlegroups.com
这个不对吧 ?

我做过 函数指针耦合, 这样做, 不必放弃类型安全:

typedef struct for_cls_t { } cls1_t;

cls1_t * getInstance(...);
void draw(cls1_t, ....);

你传给 draw 的东西, 只能从 getInstance 中获得, 别的地方你取不到.
draw 里面会将 cls1_t 再强制转换成它知道的具体类型来调用 --- 我以前做的时
候, 这里是一个类对象, 为 api 的每个 c 函数写了一个 wrapper function, 有
点烦, 不过接口看起来很简单, 所以我认了.

要说强制类型转换, 在这里用似乎也不是很邪恶.

Huafeng Mo 写道:
> 但是,SP的弱耦合不是没有代价的。SP的弱耦合是以放弃类型安全为代价的。而
> OOP则保全了类型的安全,但却付出了强耦合的代价。

pongba

unread,
Sep 19, 2007, 11:12:00 AM9/19/07
to pon...@googlegroups.com
那个早就已经进了。是属于LWG(library working group)的事情 :)

yq chen

unread,
Sep 19, 2007, 11:20:59 AM9/19/07
to pon...@googlegroups.com
这个就是QueryInterface。在强调以接口耦合来降低类型耦合的系统中,常常会出现的东东,我以前讲过,在java代码中这样的OO系统中转型必然存在,其实COM中也是必然存在的。
 
常常会出现的原因是必然的,因为如果一个具体类实现了多个接口,而这多个接口各自定义了一些方法,如果你在一个时候持有某个对象的指针,但是它被声明为该对象实现的某个接口的指针。这个时候如果有一个方法接收另一个接口作它的参数,那么你要调用那个方法,你必须转型。要么是向下,转换为具体的类型;要么就是QueryInterface,向系统询问,我是不是这个类型,如果系统答道是的,那么OK,就实施转型。
 
无论是转型到接口还是转换到具体类型,都不是太复杂,看起来也不是很邪恶。唯一的问题是,你的函数必须多依赖于一个接口或者多依赖于一个类型。
 
其实不仅是以接口为编程典范的系统有这个问题,普通的OO中也普遍存在这样的问题。当你有一个基类时,你从该基类中派生了几个派生类,这几个派生类除了重写基类的虚函数之外,为了增强功能,又公布了自己额外的公有方法(实际上你的目的是重用基类的接口和代码,派生类的职责太多)。这个时候,你往往就要用到转型了。因为可能在for(...)或者for_each这样的算法中,你通常传入的是基类的引用(指针),而具体到某一个元素,你又想调用它特有的功能,那么转型在所难免。
 
其实这里,是对OO机制中代码重用的典型误解。一个派生类应该是对基类的接口实施更强的约束,从而使得该派生类被framework使用,调用你的更强约束或者特殊处理,你重用了控制流程,实现了控制依赖于抽象。如果为了重用了基类的代码,组合更好,而不是继承。
 
如果你的派生类比实现了基类要求更多的接口,或许框架就要为你的特殊进行相关的特定类型的转型,从而使得框架的控制流必须依赖于底层的类型,或者轻微一点,框架在一个点将依赖于自己定义的另一个接口,这两个接口在这一点上有了联系。
 
在07-9-19,red...@gmail.com <red...@gmail.com> 写道:

longshanksmo

unread,
Sep 19, 2007, 7:56:22 PM9/19/07
to TopLanguage
我举个例子,就拿SP版的collect说事:
对于Account上的ammount()而言,collect可以声明成。为了简单,我暂时不考虑Pred:
float collect(Account* a, float (Account::*m)()) {...}
为了复用,我们可以把它变成这样:
float collect(void* a, float (*m)()){..}
在C中,只能将所有类型归一化成void*,然后在内部处理类型问题。
但是这个函数只能用于一个类上的float ()类型的成员。现在,我要计算Department::EmployeeNum()来统计总人数。由于人
不会有半个,该成员函数返回int。于是,上述版本的collect无法使用。而GP则不但时a的类型得到解放,不再绑定于一个类型,而且使得那些签名
不再是一个,而是一簇签名。进一步为算法解耦。解耦的结果就是,算法的应用范围更广,灵活性更强。
强制类型转换通常被描述成"恶魔",也是有道理的。它的安全性完全依赖于程序员的行为,即必须遵守共同的约定。但程序员会犯错,而在强制类型转换方面一
旦犯错,错误是非常隐蔽的,很难找。而且危害也不总是显式的,有时偷偷摸摸地修改一个不相干的对象,而程序员毫不知情。不管什么情况下,能不用当然不用
的好。如果没有其他办法,那只能使用类型转换。但是,GP则可以无需使用强制类型转换,却达到相同的效果。
我们应当考察的,不仅仅是某种方案的编码的各种开销,而且还要考虑此后的维护、使用、代码安全性等方面的问题。而后面这些往往会比单纯的编码带来更大的
开销。

lijie

unread,
Sep 19, 2007, 9:46:20 PM9/19/07
to pon...@googlegroups.com


在07-9-20,longshanksmo <longsh...@gmail.com> 写道:
我举个例子,就拿SP版的collect说事:
对于Account上的ammount()而言,collect可以声明成。为了简单,我暂时不考虑Pred:
float collect(Account* a, float (Account::*m)()) {...}
为了复用,我们可以把它变成这样:
float collect(void* a, float (*m)()){..}
在C中,只能将所有类型归一化成void*,然后在内部处理类型问题。
但是这个函数只能用于一个类上的float ()类型的成员。现在,我要计算Department::EmployeeNum()来统计总人数。由于人
不会有半个,该成员函数返回int。于是,上述版本的collect无法使用。而GP则不但时a的类型得到解放,不再绑定于一个类型,而且使得那些签名
不再是一个,而是一簇签名。进一步为算法解耦。解耦的结果就是,算法的应用范围更广,灵活性更强。

为什么一定要更那样用泛型?是很自由,但却没有任何约束,void*也是不可取的,都太自由了,但并不是说这两种自由之外就没有另一种有约束的自由,具有合理约束的自由才是和谐的(法制社会嘛)。可以试着把一些类型泛化,而不是整个进行泛化,比如:
template<class Ret, class T>
Ret collect(T& a, Ret(*m)(T&)){...}
至少在形式上更确定。如果说包装成一个函数比较麻烦,那是因为语言不支持委托,改成D语言版本的:

Ret collect(Ret, T)(T a, bool delegate(T) filter, Ret delegate(T) convert);
调用时可能是这样:

float result = collect(
    account,
    (Account acc){return acc.IsSpecial;},
    (Account acc){return acc.amount ;}
);

int result = collect(
    department,
    (Department){return true;}, // all_true
    (Department department){return department.xxx;}
);

我认为理想情况下,应该尽可能确定各种类型。由于C++语言本身对一些特性的不支持,造成了不确定的函数形式:
template<typename T, typename Pred, typename Sub, typename M>
typename M::return_type collect(T& item, Pred p, Sub s, M m);

至于上面的委托调用成本,这应该是编译器要进行优化的,本来就是模板函数,这里的委托又是直接量,不能优化也可以抱怨编译器。相比起前面的各个参数都不确定的做法,我更喜欢这种方式。

剩下的一个问题是,collect的第一个参数的children问题,我觉得这就是这个例子最病态的地方,即便是前面的GP版本,解决的也不好。这是个侵入性算法,算法知道数据的细节,这并不是什么"算法和数据分离"。

那个GP算法还告诉调用者:
1、有了孩子再来调用我
2、你的孩子必须能够使用迭代器来访问
3、你的孩子们需要有size属性
4、你给的参数必须是仿函数
5、你的T类型需要是树型的

但这些信息从函数名称上都看不出来,虽然函数形式就这样了,但哪次升级也可能增加或减少上面这些需求。

下面标注了我认为不舒服的地方:

jinq...@gmail.com

unread,
Sep 19, 2007, 10:11:19 PM9/19/07
to pon...@googlegroups.com
还要看哪个使用简单.

pongba wrote:
> 上次的讨论 <http://groups.google.com/group/pongba/t/e553a21476ba2ebd>
> 真过瘾啊:-) 我把大家的意思总结了一下,接着讨论!
>

> 本文首先将耦合从本质上分为三个境界,然后阐述为什么C本质上趋向于更松耦
> 合的设计(没错,的确跟C语言本身有关),以及为什么C++的GP也能够带来同样

> <http://groups.google.com/group/pongba>
> >


--
blog: http://blog.csdn.net/jq0123

yq chen

unread,
Sep 19, 2007, 10:17:53 PM9/19/07
to pon...@googlegroups.com
我认为实际上可以将children这个概念隐藏掉,T直接提供begin和end这两个方法来表示其子结点的第一个元素和最后一个元素。这样实际上可以改成  for( T::iterator ib = item.begin(); ib!=item.end(); ++ib)
   {
       result+=collect(*ib, p, m);
   }
 
Sub和s可以省掉。
 
这样对T的假设可能更小一点儿。

 
在07-9-19,Huafeng Mo <longsh...@gmail.com> 写道:

red...@gmail.com

unread,
Sep 19, 2007, 10:33:33 PM9/19/07
to pon...@googlegroups.com
"方便" 必须有 context, 写一个200 行的一次性学生作业程序, 和写一个 10w
行, 需要持续维护的程序, "方便" 的含义并不相同.

jinq...@gmail.com 写道:
> 还要看哪个使用简单.
>
>

redsea

unread,
Sep 19, 2007, 10:35:03 PM9/19/07
to TopLanguage
写错了, 应该回答 "简单" 的含义.

On 9月20日, 上午10时33分, red...@gmail.com wrote:
> "方便" 必须有 context, 写一个200 行的一次性学生作业程序, 和写一个 10w
> 行, 需要持续维护的程序, "方便" 的含义并不相同.
>

> jinq0...@gmail.com 写道:
>
>
>
> > 还要看哪个使用简单.- 隐藏被引用文字 -
>
> - 显示引用的文字 -

Reply all
Reply to author
Forward
0 new messages