逸言

从Go语言的设计学习设计决策

| Comments

阅读了Rob Pike撰写的《Go在谷歌:以软件工程为目的的语言设计》,颇多感触。这些感触并不在于语言层面,或者Go这门语言的语法以及底层实现;而是因为语言设计者们在设计Go这门语言时,做出设计决策的合理性以及基于的事实与根据。正如此文标题所言,显然,Go的创造者们从一开始就树立了准确的愿景与目标,并且清晰地确定了该语言的适用场景,即它需要解决“由多核处理器、系统的网络化、大规模计算机集群和Web编程模型带来的编程问题”,这是理解这门语言,进而明确其设计意图的最根本所在。

刚刚参加了公司八叉大神组织的轮子大赛,我们写了一个轻量级的IoC容器Melt。就这个容器或者说框架本身,和八叉讨论了框架设计的特点。他提到“framework开发和功能开发的一个最大分别就在于,你需要规定在这个framework里那些是支持的,哪些是不支持的。这条线要你自己来划,或者说你的framework要有态度。”这是我非常认同的观点。Framework要有态度,这意味着你在践行并且在引导一种最佳实践。或者我们可以理解为这是一种架构的约束。我们都知道,在软件设计中,如果没有任何约束,带来的问题反而会更大。约束是一种驱动力,例如我们需要可伸缩性的约束,就需要我们设计的服务不应该是有状态的。框架的态度大意如此。

回过头来看这篇文章介绍的Go设计理念,无时无刻不是在体现设计者施加在这门语言身上的态度。必须注意,这种态度或者说设计理念又绝对离不开这门语言的设计愿景。若是脱离这种具体场景来看Go语言,或许有众多不合理之处,但我们并不能依此妄加论断。正如话剧演员在舞台上的表演,总带着几分略带夸张的表情与语气,我们却不能指责这种表演不够生活化。当我们在思考一个设计决策是否合理时,是否参考了当时的场景做出判断呢?进一步讲,当我们自己在进行设计决策时,又是否充分地考虑了具体的场景呢?例如,Go语言之所以采用C语言风格的花括号,其考量并非简单地延续C语言风格那么简单,在前面提及的愿景的大前提下,设计者必须考虑如果使用Python或Haskell风格的空格缩进,对于大规模程序而言,可能会造成太多的问题。如文中所云:“我们的观点是,虽然空格缩进对于小规模的程序来说非常适用,但对大点的程序可不尽然,而且程序规模越大、代码库中的代码语言种类越多,空格缩进造成的问题就会越多。为了安全可靠,舍弃这点便利还是更好一点,因此Go采用了花括号表示的语句块。”

再看Go语言的依赖处理,它施加了一个看似比较独裁的约束,即“不允许其依赖图中有循环性的包含关系,编译器和链接器都会对此进行检查以确保不存在循环依赖”。设计者并不否认循环依赖存在一定的价值,然而在大规模程序的前提下,它带来的问题远远超过了可能存在的价值。文中提到:

循环依赖要求编译器同时处理大量源文件,从而会减慢增量式build的速度。更重要的是,如果允许循环依赖,我们的经验告诉我们,这种依赖最后会形成大片互相纠缠不清的源代码树,从而让树中各部分也变得很大,难以进行独立管理,最后二进制文件会膨胀,使得软件开发中的初始化、测试、重构、发布以及其它一些任务变得过于复杂。

不支持循环import偶尔会让人感到苦恼,但却能让依赖树保持清晰明了,对package的清晰划分也提了个更高的要求。就象Go中其它许多设计决策一样,这会迫使程序员早早地就对一些大规模程序里的问题提前进行思考(在这种情况下,指的是package的边界),而这些问题一旦留给以后解决往往就会永远得不到满意的解决。

显然,Go语言的设计者并不是要设计一门大而全,兼容并包的全能语言,它严格而合理地压制着设计时可能膨胀的欲望,不与设计愿景相悖,并时刻从工程学的角度看待设计。设计一门语言如此,开发一个产品或项目更应如此。例如对于产品而言,当下流行的Lean Startup对于产品的理念,正是这种实效的工程主义。MVP(最小可验证产品)的划分将精简与避免浪费做到了极致,严格避免功能盲目的扩大化。

Rob Pike还提到:“在依赖关系方面保持良好状况要比代码重用重要”,甚至支持“为了使用一个函数,把所需的那一小段代码拷贝过来要比拉进来一个比较大的库强”。虽然我们在设计与开发时,常常会严格遵循DRY原则,同时也尽可能地追求重用,因为我们知道重复其实是一种“恶”。若要最大限度地重用,就必须保证实体的细粒度。从类级别来讲,粒度越细,就意味着类的数量越多,这可能会加大系统的复杂度。Kent Beck提出的简单设计中,第四条即为保证类的数量尽可能少。整体结合来看,实质是指在没有看到重用以及变化的征兆前,应尽可能避免类的数量被无谓地扩大。熊节将其概述为“如无必要,勿增实体”,非常准确。显然,细粒度的类虽然可以在重用上带来好处,但却可能使得系统变得更复杂。细粒度的类还在可控制的范畴,因为我们可以采用一些方式例如Facade或Mediator模式来简化或隐藏多个细小类之间的协作。然而,对于模块(指物理模块)层面来讲,粒度过细的模块会导致对依赖的管理变得复杂。我曾经在一个.NET项目中看到过多达100多个程序集,若尝试在Visual Studio中为其生成依赖图,可能会耗尽内存。而且这些细粒度的程序集,也会导致本地构建时间的增长。关于.NET项目中依赖管理的问题,Patrick Smaccla的文章《避免在.NET代码中出现不恰当依赖》有详细论述。然而,若是不能保证模块的细粒度,根据Robert Martin提出的共同复用原则,则可能导致即使是对一个细小功能的重用,也需要引入对整个包的依赖。

看来,我们有必要正视依赖与重用之间存在的鱼与熊掌不可兼得的问题。我的意见是当出现此类问题时,我们可以考虑职责分配上是否出现问题。如果模块的分解遵循了“高内聚”原则,可能此类依赖就只会发生在模块的内部。另一种思路是考虑我提出的所谓“依赖的沉淀”,即随时绘制组件图或包图,清晰地标明依赖的关系和方向,并根据权值来判断该模块应该位于纵向的物理分布层级的哪一层。具体细节可参考我写的一篇文章《模块间的职责分配》。

文中还提到了Go语言故意缺失的一个特性就是不支持缺省参数。设计者认为:

缺省参数太容易通过添加更多的参数来给API设计缺陷打补丁,进而导致太多使程序难以理清深圳费解的交互参数。默认参数的缺失要求更多的函数或方法被定义,因为一个函数不能控制整个接口,但这使得一个API更清晰易懂。哪些函数也都需要独立的名字, 使程序更清楚存在哪些组合,同时也鼓励更多地考虑命名–一个有关清晰性和可读性的关键因素。

姑且不谈这一设计的驱动因素是否可取,这里显然教会了我们在软件设计时应该懂得如何去权衡。权衡的能力是架构师必备的技能,就好像老婆和老妈同时落水了,你该去救哪一个,这个命题总是让人不舒服,因而不肯回答。说来简单,在进行设计决策时,如果要权衡多个指标,一定要以最重要的哪个指标为主。问题是当我们不知道哪个指标更重要时,应该怎么办?我想,答案还是应该从愿景中去寻找。根据Go语言的愿景,对于大规模程序而言,清晰易懂的API会降低维护成本,并在一定程度上保障软件的质量,这显然比API的兼容性更为重要。

Go语言对于可见性的设计非常漂亮,“名字自己包含了可见性的信息,而不是使用常见的private,public等关键字来标识可见性:标识符首字母的大小写决定了可见性。如果首字母是大写字母,这个标识符是exported(public); 否则是私有的。”最初看来,这样的约定非常怪异,可是仔细琢磨,你不觉得通过这样一个简单的约定,让程序一下子变得精简了许多吗?其实只要明确了这一约定,代码的可见性仍然是清晰可知的。从某种程度讲,甚至比显式地声明public或private更加清晰。

Go语言对于继承的处理也显得特立独行,因为在Go语言中,没有类型层级(type hierarchy)的概念。它选择了组合而非继承,在于它对继承滥用表达了足够的忧虑和担心。作者认为:“类型层次结构这种模型会促成早期的过度设计,因为程序员要尽力对软件可能需要的各种可能的用法进行预测,不断地为了避免挂一漏万,不断的增加类型和抽象的层次。这种做法有点颠倒了,系统各个部分之间交互的方式本应该随着系统的发展而做出相应的改变,而不应该在一开始就固定下来。”我对此持保留意见,但在支持接口的前提下,这种规避继承的做法,仍有可取之处。因为接口可以支持OO中多态的特性,而组合又能保证逻辑的重用。这意味着,继承给我们带来的好处已经找到了合理的替代品。

Go抛弃了大多数传统语言所支持的异常机制,而转而使用error。这种机制建立在一个前提,即Go语言支持多个返回值。倘若像Java、C#等多数语言那样,仅支持一个返回值,则异常机制就变得完全有必要了。鉴于对多返回值的支持,且Go中的error又是抽象的接口类型,这样的设计就变得合乎情理了。

我们注意到Go语言中一些与众不同的特征,其实皆为设计者设计理念的体现,从中我们可以看到设计者做出设计决策的依据。显然,这些决策皆围绕着最高的设计愿景,并结合实际的工程场景,在通过合理权衡的前提下做出的。这种决策之道,值得软件架构师与设计师借鉴。

Comments