设计哲学:职责单一还是接口单一
今天跟康神讨论设计,他提到一个设计哲学(这个词就很让人崇拜),很是有道理。大概意思是说,在设计中,有人喜欢职责单一,所有东西分的很细,有人喜欢接口单一,保证接口稳定,这是个设计哲学问题,各有优缺。
我自己是一个极度喜欢保持职责单一的人,面对康神设计的接口单一但内部逻辑较复杂的模块,多少有点想重构的冲动。其实这两种设计哲学都没太大问题,确实得看需求来确定到底用什么。
接口单一的设计,最典型例子的就是大家都知道的printf,这个东西博大精深,可以组合打印任意格式的文字,如果C标准委员会乐意,还可以通过扩展格式字符串来实现更多的打印选项,同时保证100%向下兼容。还有很多接口,它们通过传递一个结构来传递参数,这个结构拥有众多成员,每次调用接口时只用其中一部分。这些接口也能像printf一样易扩展,康神设计的就是后面这种接口。
这样设计的好处是保持整个系统接口的统一和稳定,这对于一个大型工业系统来说至关重要,因为牵一发动全身,改变任何一个通用接口都会付出很大代价。但它的缺点也十分明显,主要体现是效率不高和模块职责不明。
可以想象,printf的运行效率并不高,对于%以及后面参数的解析是在运行时完成的,每次运行都会在解析格式字符串上花时间。传结构的缺点是内存使用效率不高,并且随着业务逻辑不断变复杂,结构会大到不能接受的地步,最终肯定在结构中设置一个类似于格式字符串或者表达式的东西,逐渐转变成printf的设计模式。
同时,由于接口过于强大,在接口背后隐藏的逻辑也必定复杂无比。想想解析格式字符串或解析结构时该需要多少烦人的if…else,就知道要实现这种设计将会写出多少逻辑复杂的代码,会产生多少的bad smell,给后来维护的人造成不少困扰,甚至会引入不少潜在的错误。
反观职责单一的设计,没有接口单一设计的缺点,但劣势也很明显,就是没有统一的接口,使得整个系统模块之间的调用方式始终难以稳定。如果整个系统由一个人开发还好,如果多人协作,则会大大降低开发效率,会将大量时间用在沟通接口和解决联调问题上面。公平的说,职责单一的设计也可以用facade模式来提供统一接口,但是facade模式的实现代码又会变得和接口统一时一样,没有根本改变,只是一个折中和权衡。
那是否说明“完美”的职责单一的设计不适合大型开发呢?也不一定,因为我们有C++和无比变态的模板技巧。
比如我们可以设计出一个这样的print,功能类似于printf。注意,这个函数设计成只在print的时候才输出,expression类不会造成任何输出。
pattern p = builder() << "uid: " << uid << " uname: " << name;
uid = 1000;
name = "realdodo";
print(p); // 打印出"uid: 1000 uname: realdodo"
如果熟悉boost.spirit的朋友肯定觉得这个代码很眼熟,没办法,谁要我是这个库的忠实粉丝呢。在这里,pattern_builder类只是一个模板生成器,而不进行任何计算(不同于sstream这样的类),它用<<运算符针对各种类型生成各种特化的模板类,类似于一个嵌套的pair<>。这些模板类层层嵌套,但是pattern类可以轻松搞定,方法就是设计一个concrete_pattern类。
struct concrete_pattern_base
{
concrete_pattern_base() {}
virtual ~concrete_pattern_base() {}
virtual string to_string() const = 0;
};
tempate <typename builder>
struct concrete_pattern : public concrete_pattern_base
{
builder builder_;
concrete_pattern(const builder & b)
: builder_(b)
{}
virtual string to_string() const
{
// 用模板特化的方法实现to_string()方法,这里略去
}
};
这样,pattern的实现就非常简单。
class pattern
{
concrete_pattern_base * pattern_;
public:
template <typename builder>
pattern(const builder & b)
{
pattern_ = new concrete_pattern(b);
}
public:
// 其他方法……如果需要获得当前字符串,只需调用pattern_->to_string()
};
注意,按照print的语义,pattern虽然也要实现一个类似于to_string()的方法,但它完全没有必要从concrete_pattern_base派生。
这种设计的好处是在保证职责单一的时候同时维护了接口统一。实际上,那个复杂的接口逻辑还是存在,但是由编译器在编译期间完成了。程序员只用关注builder、pattern和print的实现,它们三个一个负责生成表达式,一个负责计算表达式,一个负责输出,职责明确,泾渭分明,超级高效,实现各种新的需求也毫无问题。
可惜,这世界上没有银弹,这种滥用模板技巧的方法也有很大的问题。最大问题是可维护性。模板编程的复杂度远超过普通面向对象编程的复杂度,如果开发/维护者没有很深的基础,很可能都无法看懂这些代码。次要问题是扩展成RPC调用非常困难。对于大型工业应用,很可能一个接口因为某种原因要变成跨进程甚至于跨机器的调用,这就需要序列化所有的参数。模板是静态技巧,要序列化就一定会生成不亚于printf格式字符串的东西,而且还要实现一个客户端解析这个烦人的东西,那真是吃力不讨好,还不如直接实现一个printf好呢。
所以,最后的结论是没有结论,设计要随着需求而定,这才是王道。没有万能的设计,只有最符合需求的设计,并且设计要随着需求改变而改变,不能止境。

UNIX的设计哲学(Keep It Simple,Stupid),是不是可以看出职责单一的设计方式呢
[回复]