作者 | 马大伟
多年以后,面对这篇文章,我会想起那两天失败的令人崩溃的开发过程。当时,只是一个简单的编码需求,我信心满满的计划一下午搞定,但是最终的过程却是令人如此沮丧,让我不得不怀疑我还适不适合继续当程序员。
思绪飘到那天的场景,我在开发过程中遇到一个很简单的需求:将 JSON 格式的文件转换成 JavaScript 的常量文件(json到js的转换不只是格式的转换,还要在js文件生成json的全路径)。如下图:
我的想法是先把 JSON 转成一棵抽象语法树(AST),然后遍历这棵树,在特定的节点打印出所需的字符就可以了。JSON 转 AST 直接用 Clojure 的神器 instaparse 库。我对 Clojure 不熟悉,刚好可以通过这个过程提升下,也能试试这个神器到底神不神。通过这种特殊需求能一举多得,让无聊的开发过程变得有期待。
第一步是将 JSON 转 AST。对于 instaparse 库来说这是个很简单的任务,网上随便搜索下就找到了解析 JSON 的代码。耗时不过几分钟。
第二步是需要遍历这棵树。遍历树是我在大学算法课程上就学过的,虽然年代久远算法的细节都已经忘记,但是我还记得有深度遍历和广度遍历两种方式。我的这个需求特殊之处在于需要在遍历的时候打印相关的字符,比如需要在遍历某个节点开始和结束的时候都得打印 [] 或 {} 。Clojure 应该有具体的库做这个事,简单搜索下很快就找到了 walk 和 tree-seq 这两个函数。这两个函数看起来比较复杂,找了一些例子大概了解到: walk 函数可以在遍历是提供入和出两个钩子来执行对集合元素的转换,而 tree-seq 会以深度遍历树的方式输出一个节点序列。理解后就开始尝试,花了半天后发现事情比我想象中的复杂,这两个函数看起来强大,但是无法在遍历节点时保存状态,而我却需要这个状态来记录我遍历的路径。看起来需要自己写个遍历算法来实现了,这时候半天已经过去了,但我目前的进度只解决了一半的问题。
自己写遍历树的算法是一件不难的事情,我用 Java 也实现过,现在用 Clojure 实现看起来也不难。但是 Clojure 和 Java 的差异很大:它是函数式的,数据类型都不可变,很多操作都是通过递归来完成。用递归来实现深度遍历也不是难事,但是当你用不熟悉的语言去实现问题可能就会变得不可控。
在尝试了一天多并写了三个失败的版本后我陷入了绝望的状态,因为一个非常简单的问题我却搞不定。在第二个版本的时候我以为我解决了这个问题,最终把实际的数据输入却发现结果不符合预期。因为我用了简单的测试数据,实际的数据比测试数据全面,我写的版本只是解决了测试数据的问题。在第三个版本的时候因为考虑的情况更多写的也更复杂了,导致程序始终跑不起来。因为我不熟悉 Clojure 的语法,始终难以写出满足条件的递归代码。
由于长时间在这个问题上耗着又没有任何思路,我在周末连续搞了十几小时后眼睛和腰终于受不了了。第二天整个人身心俱疲,在床上躺了半天后琢磨如何寻求帮助。脑海中第一个念头就是在 Clojure 的社区里直接提问。为了能让大家有意愿回答我的问题,我首先把自己的问题梳理了下,画了一个简单的草图:
然后在 StackOverflow 提了这个问题,并在 Clojure 的 Discord 群组、Telegram 国内社群和微信群里发了这个问题。大概不到半小时,微信群里有两个人发了自己的代码。这两种代码体现了不同的解决思路,并且附带优雅的实现,具体的实现方案我整理到了这个 livebook 中。
第一种方案直接通过递归将 AST 语法树转换成了目标 Map 的数据结构,然后使用 Json 库打印成 Json 格式。第二种方案没有使用 AST 语法树,直接通过 Json 库拿到 Json 数据结构然后递归遍历输出最终目标数据结构。
在群里与这两个人沟通的过程中,我发觉我在不知不觉中犯了几个错误:
- 不熟悉 Clojure 代码,导致没法使用最佳的函数和思路去解决问题;
- 通过 Json 库去输出最终数据结构,而我却是采用打印的方式将问题复杂化;
- 没必要通过抽象语法树去解决,通过 Json 库递归遍历 Json 是更简单的方案;
- 没使用更好的工具。我一开始用命令行自带的 Repl,后来觉得编辑长函数不方便,所以在网上找了一个在线 Repl。不过后来看到群友提供的在线 livebook, 这种能更方便的开发并记录这类代码。
回顾这个问题的解决过程,我总结此次开发失败的原因有以下:
- 理解需求错误。我在遇到这个问题后并未做深入的分析思考,导致一开始就冲着问题的表象去解决。想着用打印的方式去解决问题,实际上可以用库来输出目标格式。
- 不熟悉相关技术。我对 Clojure 的熟悉程度还不足以解决这类并不简单的问题。
- 解决问题不全面。问题总有很多解,拿着锤子很容易看啥都是钉子。我从一开始就想通过 AST 去解决这个问题,导致思维局限到一条线上了。
- 害怕失败。因为一开始觉得问题很简单,害怕自己没法在很短的时间解决,心态处于失衡的状态。后期耗着的时间越长,思考能力越不在状态,反而越来越迷糊。
失败驱动开发
不了解程序员的人眼中的程序员可能是这样的:
但开发程序或维护程序,失败是很常见的:
- 编译失败;
- 运行失败;
- 网络失败;
- 内存失败;
- 并发失败;
- I/O 失败;
- 认证失败;
- 权限失败;
- 依赖失败;
- 资源失败;
- 上线失败;
- 升级失败;
- 环境设置失败;
- 理解需求失败;
- 项目管理失败;
- 架构设计失败;
程序员的日常就是要在无数失败中找寻让程序正常运行的那一种组合,成功运行更像是运气与实力的双重作用,这也就有了失败驱动开发(Failure Driven Development)。
失败既然是不可避免的,要做好一个程序员,与失败平和相处是必须要解决的问题,不然情绪会长期处于失衡状态。
如何以失败驱动开发?我会从以下清单出发找寻处理失败的方法:
是否全面理解问题?
很多时候不是问题复杂,而是我错误的理解了问题,在错误的路上越走越远。每当失败时我会重新全面的思考问题,看是否能发现新的解决问题的思路。
是否涉及知识盲区?
盲区是你不知道自己不知道。用有限的知识去解决未知的问题很容易陷入盲区而不自知。我的方法是如果一个失败的原因我没法在几天内解决,那很可能就是遇到知识盲区了。要跳脱盲区必须全面的搜索关联的知识,通过知识的交叉理解或寻找更了解这个领域的人帮忙是有效的解决方法。
对技术的掌握是否满足要求?
用不熟悉的技术去解决不懂的问题很容易失败。如果对技术不熟悉并且难以解决问题的话,我会从短期和长期两个方面出发制定不同的方案。短期可能会寻求外部帮助让更了解的人来帮我解决,长期我会投入更多时间提升这方面的技术。
所用技术或工具是否合适?
用不合适的技术和工具去解决问题也很容易导致失败,并且这种失败是难以察觉的。有时候不合适的技术或工具并不会让问题无法得到解决,而是会浪费你大量的时间去解决技术或工具本身的问题。要解决这类失败需要扩大知识广度,在搜索资料时不局限某一种技术,如果你对多种技术有一定的理解,就很容易发觉技术之间的差异。用合适的技术或工具能达到事半功倍的效果。
是否存在解决方案?
很多问题早已经被前人解决。所以当遇到感觉复杂的问题,我会先搜索一番已经存在的解决方法,对问题现存的解决方法有个大概的认知,然后修改这些解决方法让其能更好的解决我的问题。
是否需要记录问题?
各类很难搞的问题是提高能力的好机会,学习现存的解决方法能消灭知识盲区。所以不断的记录总结这种问题是提高我能力的好方法。如果一个人一辈子遇不到难题,他也只能停留在现有的能力圈无法破圈。
是否需要寻求帮助?
花了很多时间问题却解决不了是很令人沮丧,有些问题还很紧迫。在尝试一定时间还毫无头绪时我就会想办法找人帮忙。让人愿意帮忙也需要一些技巧,如果你提出一个很大的问题,没人会愿意免费帮忙。所以我会把问题相关的上下文都写下来或画下来,然后将我错误的解决方法放上去,标记清楚失败的点在哪里,然后把问题发给我觉得有这方面技术的朋友、同事及相关的社区。
如果问题比较复杂,我会提出付费咨询的请求。在别人帮忙解决后,及时表达感谢之情,如有必要也可以发个红包。当你通过这种方法认识不同领域的人,逐渐地你解决问题的效率也会得到提高。一些人会担心,将自己的愚蠢公开暴露出来,尤其是一些低级错误出现的时候,是一件很掉面子的事情。其实一开始我也担心,但是在网上你可以有很多虚拟身份,能缓解这种不适。
更重要的是,暴露自己的愚蠢能有效的解决自己的知识盲区,你觉得很复杂的问题在有经验的人看来是很简单的事情。这其实是一种极其有效的学习成长方式,在这个过程中我不仅可以解决我的难题,还能学习有经验的人在这领域里的方法论和效率工具。
身体状态是否合适?
长时间耗在一个问题上,身体和大脑都会疲惫。当心态失衡时,解决问题的能力也会直线下降。我经常会陷入一种急迫解决问题的困境,直到身体完全扛不住才放弃。这其实是一种低效的方式,情绪会在这个过程中逐渐压制理智,让人很难全面的思考问题。
与自己平和相处,接纳自己的不足,休息好重新出发才能走的更长远。所以当遇到自己很难解决的问题时,试着先确保身体状态是正常的,如果身体很疲惫,先休息而不是直接攻克难题。
每一次失败都是一次提升自己的机会。正是对失败过程的不断迭代解决,多年以后,让我成为一个更好的开发者。