软件系统解耦:理解依赖关系

转自:https://zhuanlan.zhihu.com/p/31391535html

 

在实际工做中,复杂度上来后,各模块之间错综复杂,调用关系网千头万绪。即便有各类设计模式作指导,作出合理的设计也并不容易。程序员每天疲于应对层出不穷的变化,在不断紧逼的deadline压力下,面对巨大的重构工做量每每感到爱莫能助。linux

系统复杂度的根源除了业务自己的复杂度,就是设计了不恰当的耦合关系。本文试图探讨依赖关系的主要类型,并总结应对依赖的编程范式。git

耦合:依赖和变化

耦合是一个有歧义的词语(为何“耦合”概念该要摒弃)。当咱们说“A和B耦合”的时候,咱们是想表达A和B之间有紧密的联系。具体是什么,不容易讲清楚。程序员

在我看来,耦合至少包含了两个方面的含义:依赖和变化。github

业务逻辑固有的复杂度决定了,模块之间必然存在着依赖。规范模块间的依赖关系,就是梳理业务复杂度的过程。最终的成果反映在代码中,表明了对业务复杂度的一种认识。这种认识随着业务需求的变化而演化,随着设计者的能力提高而深化。依赖不能被消除,可是能够被优化。探讨一些应对的范式有助于规避已知的陷阱。编程

变化则来源于两个方面:发展中的用户需求,完善中的系统模型。用户的需求是咱们努力的方向。系统模型则表明了咱们对需求的理解,是经验和智慧的结晶。一个完善的系统模型,表达能力要足够强,对业务的适应能力要足够强。变化,意味着工做量,意味着成本,应该尽可能下降。若是咱们把“系统变动”和“业务需求变动”写成函数:设计模式

SystemChange = f(RequirementChange)

咱们但愿自变量不变的状况下,“系统变动”这个函数值越小越好。特别是“业务需求变动”在当前系统设计假设条件下产生调整的时候,“系统变动”应该局限在很小的范围内。网络

依赖的种类

在UML类图中,依赖关系被标记为<<use>>。A依赖B意味着,A模块能够调用B模块暴露的API,但B模块毫不容许调用A模块的API(IBM Knowledge Center)。闭包

在类图中,依赖关系是指更改一个类(供应者)可能会致使更改另外一个类(客户)。供应者是独立的,这是由于更改使用者并不会影响供应者。异步

例如,Cart 类依赖于 Product 类,由于 Product 类被用做 Cart 类中的“添加”操做的参数。在类图中,依赖关系是从 Cart 类指向 Product 类。换句话说,Cart 类是使用者元素,而 Product 类是供应者元素。更改 Product 类可能会致使更改 Cart 类。

在类图中,C/C++ 应用程序中的依赖关系将两个类链接起来,以指示这两个类之间存在链接,而且该链接比关联关系更加具备临时性。依赖关系是指使用者类执行下列其中一项操做:

  • 临时使用具备全局做用域的供应者类,
  • 将供应者类临时用做它的某个操做的参数,
  • 将供应者类临时用做它的某个操做的局部变量,
  • 将消息发送至供应者类。

模块之间产生依赖的主要方式是数据引用和函数调用。检验模块依赖程度是否合理,则主要看“变动”的容易程度。软件模块之间的调用方式能够分为三种:同步调用、回调和异步调用(异步消息的传递-回调机制)。同步调用是一种单向依赖关系。回调是一种双向依赖关系。异步调用每每伴随着消息注册操做,因此本质上也是一种双向依赖。

三种调用方式

有一种观点将“依赖”直接总结为人脑中的依赖(为何“耦合”概念该要摒弃),我很是认同。文中提到:

只要程序员编写模块A时,须要知道模块B的存在,须要知道模块B提供哪些功能,A对B依赖就存在。甚至就算经过所谓的依赖注入、命名查找之类的“解耦”手段,让模块A不须要import B或者include "B.h",人脑中的依赖仍旧一点都没有变化。惟一的做用是会骗事后文会提到的代码打分工具,让工具误觉得两个模块间没有依赖。

代码的复杂度更主要的体如今阅读和理解,若是只是纠结于编译器所看到的依赖,实在是分错了主次,误入了歧途。

单向依赖与单一职责原则(SRP)

单向依赖是最简单的依赖。

单向依赖

上述都是单向依赖的例子。其中,(1)是最理想的状况。当逻辑变复杂后,单个模块每每承担了过多的责任。即便模块之间能够保持简单的单向关系,模块内部各行为之间却造成高强度的耦合总体。根据单一职责原则(SRP),这样的模块也是难以维护的,咱们须要对模块作拆分。

在有多个模块的状况下,(2)的依赖关系显然要好于(3),由于在(2)中模块的依赖关系要比(3)少。这样的解释过于抽象,咱们用游戏中比较典型的一个应用场景来讲明一下。

场景对象管理器GameObjectManager,管理着场景对象GameObjectInstance,而场景对象的构造须要资源AssetStore的支持。他们的调用关系,用(2)和(3)的模式分别实现一遍:

//(2) GameObjectManager从AssetStore取资源数据,而后调用GameObjectInstnce的初始化流程 class GameObjectManager{ public: AssetForGameObject* GetAsset(DWORD dwID){m_Asset.GetAsset(dwID);} GameObjectInstance* Create(DWORD dwAssetID){ AssetForGameObject* pAsset = GetAsset(dwAssetID); return m_GameObjects[dwNewID] = new GameObjectInstance(pAsset); } void TickGameObject(){foreach(auto go = m_GameObjects) go.Tick();} private: AssetStore m_Asset; map<DWORD, GameObjectInstance*> m_GameObjects; }; 

 

//(3) GameObjectInstance本身调用AssetStore的方法取资源数据,作初始化 class GameObjectManager{ public: GameObjectInstance* Create(AssetStore* pAssets, DWORD dwAssetID){ GameObjectInstance* pGo = new GameObjectInstance(); pGo->Init(pAssets, dwAssetID); return m_GameObjects[dwNewID] = pGo; } private: AssetStore m_Asset; map<DWORD, GameObjectInstance*> m_GameObjects; }; class GameObjectInstance{ public: void Init(AssetStore* pAssets, DWORD dwAssetID){ m_Data = pAssets->GetAsset(dwAssetID); } }; 

GameObjectInstance只须要依赖于AssetForGameObject,可是在依赖关系(3)中,却要依赖于一个范围更大的概念AssetStore。

将双向依赖转换为单向依赖

双向依赖关系在网络游戏中也是比比皆是。咱们来看一个双向依赖的典型例子:网络数据包的收发。若是把“上层业务逻辑”和“底层网络链接”看做两个模块。在发数据包的过程当中,业务逻辑调用底层发送接口发送数据。业务逻辑依赖于底层网络链接。而在收数据包的时候,数据首先在网络链接模块接收,再分派到不一样的业务逻辑。上层业务逻辑和底层网络链接造成了一种自然的双向依赖关系。

class Logic{ public: void SendMessage(byte* pbyBuffer, size_t uLen){ m_pConnection->Send(pbyBuffer, uLen); } void HandleMessage(byte* pbyBuffer, size_t uLen){/*...*/} private: Connection* m_pConnection; }; class Connection{ public: void SetLogic(Logic* pLogic){m_pLogic = pLogic;} void SendMessage(byte* pbyBuffer, size_t uLen){/*...*/} void RecvMessage(byte* pbyBuffer, size_t uLen){ m_pLogic->HandleMessage(pbyBuffer, uLen); } private: Logic* m_pLogic; }; 

用最天然的方式,咱们写出了上面的代码。这实际上是用“依赖注入”实现的回调。容易发现,当Logic增减成员变量或成员函数,Connection就须要从新编译,甚至从新调整代码。这样的耦合度是没法接受的。

咱们能够尝试用"Don't call us, we will call you"把双向依赖转换为单向依赖。简单来讲,当网络链接收到数据包后,能够先放到一个存储区。等调度到业务逻辑的时候,业务逻辑主动去取数据并处理。在存储区存储一个数据,就至关于存储一个对业务逻辑的调用请求。这样就演变为了单向依赖关系(3),模块C就至关于存储区。须要说明的是,存储区并不必定必需要独立出来一个模块,彻底能够维护在模块B中。此种情形,A能够直接向B要数据。

并非全部的双向依赖关系均可以很容易的转换为单向依赖。上述例子中,若是业务逻辑来不及处理数据包,网络链接层就要维护一个数据列表。这增长了存储开销。并且有时候把数据延迟处理是不合适的。代码也所以变得晦涩难懂,难以维护。若是致使这种结果,那就与咱们转换依赖关系的初衷背道而驰了。

弱化双向依赖:回调与中间层

通常状况下,为了弱化双向依赖的影响,咱们能够增长一个中间层。虽然调用链路是从“网络链接”又回到了“业务逻辑”,可是因为中间层的存在,变化被隔离,原先很强的依赖关系变弱了。如下介绍四种典型的中间层。

经过添加稳定的中间层隔离变化

须要说明的是,上述所说的中间层,偏向于概念,在代码实现中并不必定要独立成一个单独的模块。但为了方便,仍是借用模块(如上图中的模块C)来表述。

1)接口与继承

咱们很天然想到,依赖注入可使用接口。当Connection依赖的是Logic的接口(假定为ILogic),虽然Logic变动,只要ILogic不变,就不会影响Connection。可是在实践中根本不是这么回事。

咱们常常据说,只要把接口设计得“正交”“紧凑”,就能保证接口的稳定。可是,在实践中,混乱的继承关系随处可见。大多数程序员都停留在利用继承思惟构造业务逻辑关系,并尽快实现功能。极少有能力有时间检视继承关系是否恰当。正确使用继承对程序员的要求过高了。

当从新审视继承的时候咱们发现,继承的父类和子类之间实际造成了一种双向依赖。继承和多态不只规定了函数的名称、参数、返回类型,还规定了类的继承关系,是一种强耦合(, p45)。接口约定了外部调用的规范,继承类必须按照这些规范去实现。只要规范不变,继承类的实现能够调整而不将影响传递出去。糟糕的是,无论是规范仍是实现,都基本上不可能一开始就肯定好。当变化发生的时候,接口类和继承类都须要作大量的修改,而这些修改也很容易影响到全部使用接口的那些模块。

稳定的继承关系能够提供良好的扩展性,也能够避免把相同的逻辑写获得处都是(DRY原则)。可是滥用继承也会是灾难性的。在"Is-A"和"Has-A"的取舍中,要谨慎行事。

2)Delegation

一个对调用者和被调用者约束较小的方式是代理(Delegation)。所谓代理,就是将依赖转移到较稳定的代理类上。经过一个仿函数,调用不一样类中有相同签名的方法。一个典型的代理类的例子以下所示(The Impossibly Fast C++ Delegates)。其最第一版本须要对每种参数作不一样处理。后来发展出来一种更通常的代理方式(C++ Delegates On Steroids),能够接受任意类型和任意数量的参数。

class delegate { public: delegate() : object_ptr(0), stub_ptr(0){} template <class T, void (T::*TMethod)(int)> static delegate from_method(T* object_ptr){ delegate d; d.object_ptr = object_ptr; d.stub_ptr = &method_stub<T, TMethod>; // #1 return d; } void operator()(int a1) const{ return (*stub_ptr)(object_ptr, a1); } private: typedef void (*stub_type)(void* object_ptr, int); void* object_ptr; stub_type stub_ptr; template <class T, void (T::*TMethod)(int)> static void method_stub(void* object_ptr, int a1){ T* p = static_cast<T*>(object_ptr); return (p->*TMethod)(a1); // #2 } }; 

3) Bind/Function

Bind/Function机制不要求被绑定的类有任何继承规范。其更像是C中的函数指针,比代理类要更简单。除了和代理类同样须要函数签名一致,不须要程序员额外维护一个类。

如今C++11提供了很好用的bind/function(Bind illustratedC++11: std::function and std::bind)。咱们能够将上述的数据包处理回调重写以下:

class Logic{ public: void Init(){ m_pConnection->SetCallbackFunc(std::bind(HandleMessage), this); } void SendMessage(byte* pbyBuffer, size_t uLen){ m_pConnection->Send(pbyBuffer, uLen); } void HandleMessage(byte* pbyBuffer, size_t uLen){/*...*/} private: Connection* m_pConnection; }; class Connection{ public: void SetCallbackFunc(Logic* pLogic){m_pLogic = pLogic;} void SendMessage(byte* pbyBuffer, size_t uLen){/*...*/} void RecvMessage(byte* pbyBuffer, size_t uLen){ m_callbackfunc(pbyBuffer, uLen); } private: func* m_callbackfunc; }; 

4) Lambda与闭包

严格来讲,bind/function的实现也属于闭包。这里把Lambda/Closure单独列出来是想强调Lambda表达式能够经过匿名函数把相同的事作的更简洁。比起bind一个成员函数,直接bind一个在局部空间定义的lambda表达式给程序员带来的思惟负担更小。

毕竟,修改lambda表达式时,能够清楚知道影响的范围。而修改被bind的成员函数时,还要考虑该成员函数是否是在其余地方被用到。

相关文章
相关标签/搜索