跳到主要内容

什么是好的软件架构

转载自 架构,性能和游戏(对原文做了微小改动,虽然作者是以游戏举的例子,但是这种架构的思维却是通用的)

什么是好的软件架构?

好的设计意味着当我作出改动,整个程序就好像正等着这种改动。 我可以仅调用几个函数就完成任务,而代码库本身无需改动。这听起来很棒,但实际上不可行,所以一般:“把代码写成改动不会影响其表面上的和谐。” 就足以满足一般的需求。

让我们通俗些。第一个关键点是架构是关于改动的。 总会有人改动代码。如果没人碰代码,那么它的架构设计就无关紧要——无论是因为代码至善至美,还是因为代码糟糕透顶以至于没人会为了修改它而玷污自己的文本编辑器。 评价架构设计的好坏就是评价它应对改动有多么轻松。 没有了改动,架构好似永远不会离开起跑线的运动员。

应该如何处理改动?

在改动代码去添加新特性,去修复漏洞,或者随便用文本编辑器干点什么的时候,一般都需要理解代码正在做什么。当然,你不需要理解整个程序,但你需要将所有相关的东西装进你的大脑。一旦把所有正确的上下文都记到了你的大脑里,想一会,你就能找到解决方案。可能有时也需要反复斟酌,但通常比较简单。一旦理解了问题和需要改动的代码,实际的编码工作有时是微不足道的。

我们通常无视了这步,但这往往是编程中最耗时的部分。 如果你认为将数据从磁盘上分页到 RAM 上很慢, 那么通过一对神经纤维将数据分页到大脑中无疑更慢。

你将一些代码加入了游戏,但肯定不想下一个人被留下来的小问题绊倒。 除非改动很小,否则就还需要一些微调新代码的工作,使之无缝对接到程序的其他部分。 如果你做对了,那么下个编写代码的人无法察觉到哪些代码是新加入的。

简而言之,编程的流程图看起来是这样的:

解耦帮了什么忙?

将代码载入到神经元太过缓慢,找些策略减少载入的总量是件很值得做的事。(哇,作者这句话真的总结的很到位!!)

可以用多种方式定义 “解耦”,但我认为如果有两块代码是耦合的,那就意味着无法只理解其中一个。如果解耦了它们俩,就可以单独地理解某一块。这当然很好,因为只有一块与问题相关,只需将这一块加载到你的大脑中而不需要加载另外一块。

所以软件架构的关键目标: 最小化在编写代码前需要了解的信息

当然,也可以从后期阶段来看。 解耦的另一种定义是:当一块代码有改动时,不需要修改另一块代码。 肯定也得修改一些东西,但耦合程度越小,改动会波及的范围就越小。

代价呢?

解耦任何东西,然后就可以像风一样编码。 每个改动都只需修改一两个特定方法,你可以在代码库上行云流水地编写代码。

这就是抽象、模块化、设计模式和软件架构使人们激动不已的原因。 在架构优良的程序上工作是极佳的体验,每个人都希望能更有效率地工作。 好架构能造成生产力上巨大的不同。它的影响大得无以复加。

但是,天下没有免费的午餐。好的设计需要汗水和纪律。 每次做出改动或是实现特性,你都需要将它优雅的集成到程序的其他部分。 需要花费大量的努力去管理代码,使得程序在开发过程中面对千百次变化仍能保持它的结构。

你得考虑程序的哪部分需要解耦,然后再引入抽象。 同样,你需要决定哪部分能支持扩展来应对未来的改动。

人们对这点变得狂热。 他们设想,未来的开发者(或者他们自己)进入代码库, 发现它极为开放,功能强大,只需扩展。 他们想要有 “至尊代码应众求”。

但是,事情从这里开始变得棘手。 每当你添加了抽象或者扩展支持,你就是在 赌以后这里需要灵活性。 你向游戏中添加的代码和复杂性是需要时间来开发、调试和维护的。

如果你赌对了,后来使用了这些代码,那么功夫不负有心人。 但预测未来很难,模块化如果最终无益,那就有害。 毕竟,你得处理更多的代码。

当你过分关注这点时,代码库就失控了。 接口和抽象无处不在。插件系统,抽象基类,虚方法,还有各种各样的扩展点,它们遍地都是。

你要消耗无尽的时间回溯所有的脚手架,去找真正做事的代码。 当需要作出改动时,当然,有可能某个接口能帮上忙,但能不能找到就只能听天由命了。 理论上,解耦意味着在修改代码之前需要了解更少的代码, 但抽象层本身也会填满大脑。

像这样的代码库会使得人们反对软件架构,特别是设计模式。 人们很容易沉浸在代码中,忽略了目标是要发布游戏。对可扩展性的过分强调使得无数的开发者花费多年时间制作 “引擎”,却没有搞清楚做引擎是为了什么。

性能和速度

软件架构和抽象有时因损伤性能而被批评,而游戏开发尤甚。 让代码更灵活的许多模式依靠虚拟调度、 接口、 指针、 消息和其他机制, 它们都会加大运行时开销。

还有一个原因。很多软件架构的目的是使程序更加灵活,作出改动需要更少的付出,编码时对程序有更少的假设。 使用接口可以让代码可与任何实现了接口的类交互,而不仅仅是现在写的类。 比如可以使用观察者和事件队列让游戏的两部分相互交流, 以后可以很容易地扩展为三个或四个部分相互交流。

但性能与假设相关,实现优化需要基于确定的限制。 敌人永远不会超过256个?好,可以将敌人ID编码为一个字节。 只在这种类型上调用方法吗?好,可以做静态调度或内联。 所有实体都是同一类?太好了,可以使用 连续数组存储它们。

注:所谓内联函数就是指函数在被调用的地方直接展开,编译器在调用时不用像一般函数那样,参数压栈,返回时参数出栈以及资源释放等,这样提高了程序执行速度。 简单通俗的讲就是 把方法内部调用的其它方法的逻辑,嵌入到自身的方法中去,变成自身的一部分,之后不再调用该方法,从而节省调用函数带来的额外开支。

C++,内联函数这个概念,一般用 inline 关键字修饰;在 C++定义类时,写中 Class定义里面的函数,也被编译器当做内联函数处理。C++中是否为内联函数由自己决定,而在 Java中,则是由编译器决定的。

Java 不支持直接声明为内联函数的,如果想让他内联,只能够使用 final 关键字向编译器提出请求,最后是不是内联函数,是由编译器说了算。

一种折中的办法是保持代码灵活直到确定设计,再去除抽象层来提高性能。

糟糕代码的优势

下一观点:不同的代码风格各有千秋,糟糕的代码也有一定的优势。

编写架构良好的代码需要仔细地思考,这会消耗时间。 在项目的整个周期中保持良好的架构需要花费大量的努力。 你需要像露营者处理营地一样小心处理代码库:总是让它比之前更好些。

当你要在项目上花费很久时间的时这是很好的。 但就像早先提到的,游戏设计需要很多实验和探索(编写软件也是)。特别是在早期,写一些你知道将会扔掉的代码是很普遍的事情。

如果只想试试游戏的某些点子是否可行, 良好的架构就意味着在屏幕上看到和获取反馈之前要消耗很长时间。 如果最后证明这点子不对,那么删除代码时,那些让代码更优雅的工夫就付诸东流了。

注意!!得清楚,可抛弃的代码即使看上去能工作,也不能被维护,必须重写。 如果有可能要维护这段代码,就得防御性地好好编写它。

作者这里举了个例子:

老板:“嗨,我有些想试试的点子。只要原型,不需要做得很好。你能多快搞定?”

开发者:“额,如果删掉这些部分,不测试,不写文档,允许很多的漏洞,那么几天能给你临时的代码文件。”

老板:“太好了。”

几天后

老板:“嘿,原型很棒,你能花上几个小时清理一下然后变为成品吗?”

保持平衡

有些因素在相互角力:

  1. 为了在项目的整个生命周期保持其可读性,需要好的架构。
  2. 需要更好的运行时性能。
  3. 需要让现在想要的特性更快地实现。

这些目标至少是部分对立的。 好的架构长期来看提高了生产力, 也意味着每个改动都需要消耗更多努力保持代码整洁。

草就的代码很少是运行时最快的。 相反,提升性能需要很多的开发时间。 一旦完成,它就会污染代码库:高度优化的代码不灵活,很难改动。

总有今日事今日毕的压力。但是如果尽可能快地实现特性, 代码库就会充满黑魔法,漏洞和混乱,阻碍未来的产出。

保持代码的简单

我的目标是正确获得数据结构和算法(大致是这样的先后),然后再从那里开始。 我发现如果能让事物变得简单,最终的代码就更少, 就意味着改动时有更少的代码载入脑海。

它通常跑的很快,因为没什么开销,也没什么代码需要执行。 (虽然大部分时候事实并非如此。你可以在一小段代码里加入大量的循环和递归。)

但是,注意我并没有说简单的代码需要更少的时间编写。 你会这么觉得是因为最终得到了更少的代码,但是好的解决方案不是往代码中注水,而是蒸干代码

举个例子:新手程序员,他们经常这么干:为每种情况编写条件逻辑。

但这一点也不优雅,那种风格的代码遇到一点点没想到的输入就会崩溃。 当我们想象优雅的代码时,想的是通用的那一个: 只需要很少的逻辑就可以覆盖整个用况

总结

  • 抽象和解耦让扩展代码更快更容易,但除非确信需要灵活性,否则不要在这上面浪费时间。

  • 在整个开发周期中为性能考虑并做好设计,但是尽可能推迟那些底层的,基于假设的优化,那会锁死代码。

  • 快速地探索游戏的设计空间,但不要跑得太快,在身后留下烂摊子。毕竟你总得回来打扫。

  • 如果打算抛弃这段代码,就不要尝试将其写完美。摇滚明星将旅店房间弄得一团糟,因为他们知道明天就走人了。