软件系统解耦:理解依赖关系(二)

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

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

上述都是单向依赖的例子。其中,(1)是最理想的情况。当逻辑变复杂后,单个模块往往承担了过多的责任。即使模块之间可以保持简单的单向关系,模块内部各行为之间却形成高强度的耦合整体。根据单一职责原则(SRP),这样的模块也是难以维护的,我们需要对模块做拆分。
在有多个模块的情况下,(2)的依赖关系显然要好于(3),因为在(2)中模块的依赖关系要比(3)少。这样的解释过于抽象,我们用游戏中比较典型的一个应用场景来说明一下。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//(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。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。但是在实践中根本不是这么回事。

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

当重新审视继承的时候我们发现,继承的父类和子类之间实际形成了一种双向依赖。继承和多态不仅规定了函数的名称、参数、返回类型,还规定了类的继承关系,是一种强耦合(https://github.com/downloads/chenshuo/documents/CppPractice.pdf, p45)。接口约定了外部调用的规范,继承类必须按照这些规范去实现。只要规范不变,继承类的实现可以调整而不将影响传递出去。糟糕的是,不管是规范还是实现,都基本上不可能一开始就确定好。当变化发生的时候,接口类和继承类都需要做大量的修改,而这些修改也很容易影响到所有使用接口的那些模块。

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

2)Delegation

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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)。我们可以将上述的数据包处理回调重写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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的成员函数时,还要考虑该成员函数是不是在其他地方被用到。

请我喝杯咖啡吧~

支付宝
微信