写软件和造楼房一样需要设计,但是和建筑行业严谨客观的设计规范不同,软件设计常常很主观,且容易引发争论。

作者 | 杜沁园(悬衡)

来源 | 阿里开发者公众号

引言

写软件和造楼房一样需要设计,但是和建筑行业严谨客观的设计规范不同,软件设计常常很主观,且容易引发争论。设计模式被认为是软件设计的“规范”,但是在互联网快速发展的过程中,也暴露了一些问题。相比过程式代码的简单与易于修改,设计模式常常导致代码复杂,增加理解与修改的成本,我们称之为 “过度设计”。因而很多人认为,设计模式只是一种炫技,对系统没有实质作用,甚至有很大的挖坑风险。这个观点容易让人因噎废食,放弃日常编码中的设计。本文将深入探索如下问题:

  • 为什么长期来看,设计模式相比过程式代码是更好的?
  • 什么情况下设计模式是有益的,而什么情况下会成为累赘?
  • 如何利用设计模式的益处,防止其腐化?
设计模式的缺陷

“过度设计” 这个词也不是空穴来风,首先,互联网软件的迭代比传统软件快很多,传统软件,比如银行系统,可能一年只有两个迭代,而网站的后台可能每周都在发布更新,所以互联网非常注重软件修改的便捷性。其次,设计模式的 “分模块”,“开闭原则” 等主张,天然地易于拓展而不利于修改,和互联网软件频繁迭代产生了一定的冲突。

开闭原则的缺陷

开闭原则:软件中对象应该对扩展开放,对修改关闭。

基于开闭原则,诞生了很多中台系统。应用通过插件的方式,可以在满足自身定制业务需求的同时,复用中台的能力。

当业务需求满足中台的主体流程和规范时,一切看上去都很顺利。一旦需求发生变更,不再符合中台的规范了,往往需要中台进行伤筋动骨的改造,之前看到一篇文章吐嘈 “本来业务上一周就能搞定的需求,提给中台需要8个月”。

所以基于中台无法进行深度的创新,深度创新在软件上必然也会有深度的修改,而中台所满足的开闭原则是不利于修改的。

最小知识原则的缺陷

最小知识原则:一个对象对于其他对象的了解越少越好。

最小知识原则又称为 “迪米特法则”,基于迪米特法则,我们会把软件设计成一个个 “模块”,然后对每个 “模块” 只传递需要的参数。

在过程式编码中,代码片段是拥有上下文的全部信息的,比如下面的薪资计算代码:

// 绩效 int performance = 4; // 职级 int level = 2; String job = "engineer"; switch (job) { case "engineer": // 虽然计算薪资时只使用了 绩效 作为参数, 但是从上下文中都是很容易获取的 return 100 200 * performance; case "pm": // .... 其余代码省略 }

而如果我们将代码改造成策略模式,为了满足迪米特法则,我们只传递需要的参数:

// 绩效 int performance = 4; // 职级 int level = 2; String job = "engineer"; // 只传递了需要 performance 参数 Context context = new Context(); context.setPerformance(performance); strategyMap.get(job).eval(context);

需求一旦变成 “根据绩效和职级计算薪资”,过程式代码只需要直接取用上下文的参数,而策略模式中需要分三步,首先在 Context 中增加该参数,然后在策略入口处设置参数,最后才能在业务代码中使用增加的参数。

这个例子尚且比较简单,互联网的快速迭代会让现实情况更加复杂化,比如多个串联在一起模块,每个模块都需要增加参数,修改成本成倍增加。

可理解性的缺陷

设计模式一般都会应用比较高级的语言特性:

  • 策略模式在内的几乎所有设计模式都使用了多态
  • 访问者模式需要理解动态分派和静态分派
  • ...

这些大大增加了设计模式代码的理解成本。而过程式编码只需要会基本语法就可以写了,不需要理解这么多高级特性。

小结

这三点缺陷造成了设计模式和互联网快速迭代之间的冲突,这也是应用设计模式时难以避免的成本。过程式编码相比设计模式,虽然有着简单,易于修改的优点,但是却有永远无法回避的本质缺陷。

过程式编码的本质缺陷

上文中分析,过程式编码的优点就是 “简单,好理解,易于修改”。这些有点乍看之下挺对的,但是仔细想想都很值得怀疑:

  • “简单”:业务逻辑不会因为过程式编码而变得更加简单,相反,越是大型的代码库越会大量使用设计模式(比如拥有 2400w 行代码的 Chromium);
  • “好理解”:过程式编码只是短期比较好理解,因为没有设计模式的学习成本,但是长期来看,因为它没有固定的模式,理解成本是更高的;
  • “易于修改”:这一点我相信是对的,但是设计模式同样也可以是易于修改的,下一节将会进行论述,本节主要论述前两点。

软件复杂度

软件工程著作 《人月神话》 中认为软件复杂度包括本质复杂度偶然复杂度

本质复杂度是指业务本身的复杂度,而偶然复杂度一般是因为方法不对或者技术原因引入的复杂度,比如拆分服务导致的分布式事务问题,就是偶然复杂度。

如果一段业务逻辑本来就很复杂,即本质复杂度很高,相关模块的代码必然是复杂难以理解的,无论是采用设计模式还是过程式编码。“用过程式编码就会更简单” 的想法在这种情况下显然是荒谬的,相反,根据经验,很多一直在采用过程式编码的复杂模块,最后都会变得逻辑混乱,缺乏测试用例,想重构时已经积重难返。

那么设计模式会增加偶然复杂度吗?阅读有设计模式的代码,除了要理解业务外,还要理解设计模式,看起来是增加了偶然复杂度,但是下文中我们会讨论,从长期的角度来看,这不完全正确。

理解单一问题 vs 理解一类问题

开头提到,设计模式是软件设计的“规范”,和建筑业的设计规范类似,规范能够帮助不同背景的人们理解工程师的设计,比如,当工人们看到三角形的结构时,就知道这是建筑师设计的支撑框架。

过程式代码一般都是针对当前问题的某个特殊解决方法,不包含任何的 “模式”,虽然表面上减少了 “模式”的学习成本,但是每个维护者/调用者都要去理解一遍这段代码的特殊写法,特殊调用方式,无形中反而增加了成本。

以数据结构的遍历为例,如果全部采用过程式编码,比如二叉树打印的代码是:

public void printTree(TreeNode root) { if (root != null) { System.out.println(root.getVal()); preOrderTraverse1(root.getLeft()); preOrderTraverse1(root.getRight); } }

图的节点计数代码是:

public int countNode(GraphNode root) { int sum = 0; Queue<Node> queue = new LinkedList<>(); queue.offer(root); root.setMarked(true); while(!queue.isEmpty()){ Node o = queue.poll(); sum ; List<Node> list = g.getAdj(o); for (Node n : list) { if (!n.isMarked()) { queue.add(n); n.setMarked(true); } } } return sum; }

这些代码本质上都是在做数据结构的遍历,但是每次读到这样的代码片段时,你都要将它读到底才发现它其实就是一个遍历逻辑。幸好这里的业务逻辑还比较简单,就是一个打印或者计数,在实际工作中往往和更复杂的业务逻辑耦合在一起,更难发现其中的遍历逻辑。

而如果我们使用迭代器模式,二叉树的打印代码就变成:

public void printTree(TreeNode root) { Iterator<TreeNode> iterator = root.iterator(); while (iterator.hasNext()) { TreeNode node = iterator.next(); System.out.println(node); } }

图的节点计数代码变成:

public int countNode(GraphNode root) { int sum = 0; Iterator<TreeNode> iterator = root.iterator(); while (iterator.hasNext()) { iterator.next(); sum ; } return sum; }

这两段代码虽然有区别,但是它们满足一样的 ”模式“,即 “迭代器模式”,看到 Iterator 我们就知道是在进行遍历,甚至都不需要关心不同数据结构具体实现上的区别,这是所有遍历统一的解决方案。虽然在第一次阅读这个模式的代码时需要付出点成本学习 Iterator,但是之后类似代码的理解成本却会大幅度降低。

设计模式中类似上面的例子还有很多:

  • 看到 XxxObserver,XxxSubject 就知道这个模块是用的是观察者模式,其功能大概率是通过注册观察者实现的
  • 看到 XxxStrategy 策略模式,就知道这个模块会按照某种规则将业务路由到不同的策略
  • 看到 XxxVisitor 访问者模式 就知道这个模块解决的是嵌套结构访问的问题
  • ...

是面对具体问题 case by case 的学习,还是掌握一个通用原理理解一类问题?肯定是学习后者更有效率。

“过程式代码更加好理解”往往只是针对某个代码片段的,当我们将范围扩大到一个模块,甚至整个系统时,其中会包含大量的代码片段,如果这些代码片段全部是无模式的过程代码,理解成本会成倍增加,相似的模式则能大大降低理解成本,越大的代码库从中的收益也就越大。

新人学习过程式编码和设计模式的学习曲线如下图:

18个经典噎死人的歪理(因噎废食的陷阱)(1)

过程式编码虽然刚开始时没有任何学习压力,但是不会有任何积累。设计模式虽然刚开始时很难懂,但是随着学习和应用,理解会越来越深刻。

点击查看原文,获取更多福利!

https://developer.aliyun.com/article/1092076?utm_content=g_1000365272

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

,