理解关联¶
在工作中,常常看到一些工程师对关联的本质缺乏认识,浪费了不少讨论时间,本文细化 一下这里的逻辑。
当我们说两个对象有“关联”:
直接感觉,是A和B总是以某种方式发生关系,比如一方或者两方拥有对方的指针(甚至是 内存本身),例如::
struct A {
struct B *b;
...
}
或者::
struct A {
struct B {
struct A *a;
}
}
这种关系可以变得更加复杂,比如A中有方法可以调用B::
class A {
void dealWithB(struct B *b);
...
}
或者另一个对象把两者关联起来:
- class C {
- Map<class A, class B> ab_map;
}
甚至可以从表面上看不出来的,下面是一个从表面上看不出的关联::
class A {
void storeStudentData(FILE *file);
}
class B {
void takeStrudentRecords(FILE *file)
}
A把特定的数据存到文件中,B从这个文件中拿这些数据,A和B就有了关联。
严格来说,你可以说一个系统中任何两个模块有关联。所以,我们不是为了说明两个模块 有“关联”,所以就要在架构说明的时候就说两个模块有“关联”,我们是因为这种关联对我 们的的下一层设计有影响,所以我们才需要在架构定义的时候说明它们有“关联”。
所以,关联其实关心的是两个模块的“同步”关系。
所谓同步关系,可以表现为多种形式,比如:
A的逻辑发生了修改,B也必须同步修改,整个系统才能是正常的。前面这个文件的关联就 是一个例子,如果你修改了A保存文件的方式,虽然整个程序编译一点问题没有,但如果你 不修改B,这个程序就不可能正常。
所以,很多人觉得,把函数接口变成消息接口,文件接口,这个系统就“解耦”了,这完全 是在骗外行,我们从架构上从来不这样看问题。关联是因为两者在业务上有关系,代码的 逻辑链在逻辑上对另一方的设计方法有“依赖”,不消除这种逻辑上的“依赖”,就不可能“解 耦”,这你怎么玩各种定义都是玩不出花来的。而消除解耦的唯一方式,是消除多余的“依 赖”,比如我只需要你是个指针,不需要你是个unsigned long int,你就不要说这个变量 是unsigned long int;你打印指针就用"%p",你不要用"%xl";你要给我传递“学生数据” ,你就给我"interface student",不要给我“class student_implement_on_file”,这样 就能消除耦合,但每个消除耦合的动作都是额外的工作量,所谓你必须在聚合了很多关联 的地方建立有限的解耦设计,这才能在工作量和解耦方面取得平衡,架构设计从来不是可 以被简单描述的工作,否则,它就变成一种“编码”了。
再说远一点,昨天评审了一个设计,谈到一个软硬件之间的FIFO接口,设计者兴高采烈地 给我介绍了一个精妙的设计,叫做xxx_id,我听了半天,原来逻辑是这样的:软件要写一 个请求到硬件上,不能直接向Ring Buffer(循环队列)的尾巴上直接写这个请求的内容, 而要在里面找一个已经释放的BD(Buffer Descriptor),再把这个BD的下标(称为xxx_id ),写到另一个叫Queue的数据结构中,硬件会按FIFO的形式从Queue里面读xxx_id,然后得 到这个BD……
我一听,WTF,这不就是把FIFO实现到Queue里面吗,从我架构的角度来说,这就是一个 FIFO,这些什么Ring Buffer之类的细节,统统都是浪费我的时间。这根本不在我这一层考 虑。
我这个例子很简单,但想想你们平时的设计,这种情况无处不在,我给你抽出来了,你觉 得这东西显而易见,拿着你的具体设计,你就又晕菜了。我强迫我们的工程师必须写设计 文档,因为你写出来,你就会强迫自己抽取逻辑链,否则,你以为你“设计精妙”,其实就 是个傻逼设计。
越扯越远了,我们回到同步关系的例子。另一种我们常常考虑关联的场景是线程和锁设计 。两个对象(对象不一定是面向对象语言中说的“对象”)有关联,就意味着我修改一个对 象的时候,必须和另一个关联对象发生同步关系。
比如我们常常面对的一种情形:A是B的一个集合,当我们单独访问A的时候,只需要锁住A 就可以了,当我们访问B的时候,就需要锁住集合中的所有A。这种锁设计很容易发生问题 ,因为A,B上的锁的访问顺序很容易就会在不同的流程下不一样,很容易就死锁了。
讨论这种问题的时候,不少工程师会陷入换各种不同的锁实现,加入各种无锁算法的陷阱 。但他们都没有注意一个关键的问题,A和B之所以同步有问题,是因为A和B有关联,是因 为我们在修改其中一方的时候,需要把本方和另一方的其他相关操作“停下来”。锁设计本 身不会改变这种“需求”,你要解决这个问题,唯一的方法是“解耦”,也就是确定“仅仅什么 修改的时候,才需要等待仅仅什么操作完成”。不讨论这种问题,而去讨论把B的指针放到A 里还是B里放一个数组指向A,乃至加上各种RCU操作,都不解决问题的啊。