程序员修炼之道 11:当你编码时
不记录,等于没读。
这里是我阅读《程序员修炼之道》这本书的记录和思考。
编码阶段不是机械性工作,而是每一分钟都要做出决定——深思熟虑后的决定。如果把编码阶段当成机械性工作,认为这个阶段只是把设计翻译成可运行的代码段,这种态度是项目失败的最重要原因。
本章谈论编码阶段中应该注意的事项。
听从蜥蜴脑
蜥蜴脑
(Lizard Brain) 在英文环境中指大脑最原始的部分,这部分主要负责人类的生存功能,通常引申为人类的本能反应、直觉。
“听从蜥蜴脑”的意思是:利用好你的直觉。
看到这里我的直觉反应是:“这不科学”。直觉这东西听起来相当不靠谱,没法度量,意味着无法可靠复现。总之,不具有确定性。
对我来说这是一个全新的观点,我对此持怀疑态度。因此我进行了一番查证,认为它有一定的道理。
直觉就是对我们无意识大脑中存储的模式的一种反应。有些是天生的(应对天敌时的闪避),有些是不断重复学习到的(医生诊断疾病、下棋策略)。
《程序员思维修炼》给出了一个观点:新手依赖规范和指南,而专家凭直觉工作。
新手刚接触一个行业,他们不知道从何开始,因此需要明确、逐步的规范来执行任务,给他一个任务清单再好不过了。而专家在一个行业中摸滚打爬多年,他们知道哪些是无关紧要的细节,哪些是非常重要的细节。专家非常擅长做有针对性的特征匹配。
比如有一个程序员。积累了经验后,大脑就会逐渐形成一层又一层的隐形知识:哪些方法有效,哪些方法无效,某种错误的可能原因,以及你在日常工作中注意到的所有事情。
直觉无法用语言表达,但会让你感受到:你会感到紧张,或者不舒服,或者觉得这件事的工作量实在太大了。
当直觉提醒你时,你要能注意它正在发生。
当开启新项目,或仅仅启动一个新模块都可能让人不安,以至于我们会推迟迈出第一步。造成不安的原因可能有两种:
- 直觉在告诉你一些事情。这种原因很重要,当面对一项任务时感到不情愿或是一种挥之不去的疑虑,这是你过去的经验在向你示警:哪里有些不对劲。你提前注意到这些征兆,当不对劲的事情显现出来后,你更容易规避问题。
- 担心自己做不好,认为项目超出了自己的能力范围。这是一种合理的恐惧,应对也比较简单:去做。
需要警惕下面的情况:
有一段时间了,你觉得编程像在泥泞中爬坡。每走一步都需要极大的努力,走上三步就会后滑两步。你内心对此烦躁不安,但仍要求自己坚持下去,因为你觉得这就是自己的工作,而自己是个有始有终的专业人士。
真相是,此时你要做的不是坚持,而是停下来想一想,内心的抗拒很可能是告诉你:正常的开发不应如此痛苦——也许真正的原因是设计有问题、编码有问题,或者需求的理解有问题。
如果你不倾听直觉,找到根本原因,你就是在制造 BUG。倾听直觉的方法是:
- 首先,停止正在做的事情。给自己一点时间和空间,让大脑自行整理思绪。
- 如果第 1 条不起作用,就试着把问题外化。外化就是指将内在的问题转化为外部的形式,比如尝试把问题写在纸上、向同事解释一下是怎么回事。这样可以把问题暴露给大脑的不同部分,从而更好的处理难题。
- 如果还是不起作用,可以用做原型的方式来干这事。原型是一种有效的脑力突破方法。告诉自己正在做原型,因此可以不考虑过多细节,而是专心于测试一两个疑惑点。
如果在别人编写的代码上工作,记住这些人的直觉和你不同,他们会做不同的决定,但不一定不好,仅仅是不同而已。
不仅仅是编码要听从直觉,设计也是。如果一个设计让你感觉不妥,或是一些需求让你觉得不爽,就停下来分析这些感觉。
巧合式编程
有些程序能够正常运行,但是基于巧合,靠得是运气。
怎样算是巧合式编程?
你不知道原理,但是代码能正常工作。这看上去还不错,至少能正常工作,不是吗。问题是,当你再往上添加功能时,它很可能突然不能工作了。你要花费大量时间来修复问题,有时还会反复修复。
代码会出错,是因为一开始就不知道它为什么能工作!某人做了一些尝试,发现程序似乎跑起来了。他不知道其中的原理,就会畏惧修改这些代码:现在能工作了,最好别碰这块代码……即便这些代码不合理,依赖了未被承诺的特性。
不要依赖巧合式编程:
- 不要依赖文档中没有记载的行为,没有记载的东西随时都可能改变。
- 如果做不到只依赖文档中的行为,不管出于什么原因,把你的假设记录下来。
- 找到正确的做法,而不是凑合。比如结果总是与预期有偏移,不去找根源而是添加一个经验值来满足预期。
- 不要假设,要证明。
- 找到恰好能用的答案和找到正确的答案不是一回事。比如网上搜索到的代码要能理解其原理,或许他的代码环境与你的并不相同。
如何深思熟虑地编程?
- 尽可能早地捕获并修复错误.
- 时刻注意你在做什么,不要让事情失控。
- 能够向初级程序员解释清楚程序的逻辑。
- 理解你正在使用的技术,确定它为什么能用。
- 只依赖可靠的东西。不要依赖假设。
- 将假设文档化。
- 测试假设。如果假设
long
占用 4 个字节,那么用断言来测试这个假设。 - 随时准备重构,让代码一点点变好。
算法速度
无时无刻不在评估:每当编写包含循环或递归调用的东西时,都要下意识地检查运行时间和内存需求。
一些常识:
- 简单循环:O(n),时间增加和 n 线性相关。
- 嵌套循环:O(n2)
- 对有序数列二分查找、遍历二叉树:O(lg n)
- 快速排序:O(n lg n)
需要考虑代码本身,当 n 较小时,一个简单的 O(n2) 循环比一个复杂的 O(n lg n) 算法表现要好得多。在选择算法时,需要务实一些。最快的算法并不总是最适合当前工作的。
重构
随着需求的增加,有必要重新考虑早期的决策,对部分代码进行重构。
之前我一直将编程与建筑做类比。
- 建筑强调结构设计的重要性,程序也需要良好的软件架构;
- 建筑由不同的构件组成,比如墙、窗户等,程序也强调模块化;
但建筑隐喻也意味着程序一旦完成之后,主体结构就不能再动了,只能做有限的维护,这显然与程序的实际情况不符。作者在书中提出了另一种隐喻,将编程比作 园艺
:
- 程序不是一次设计完成,而是随着时间逐步发展和完善的,正如花园里的植物随着时间生长和变化。
- 根据植物的成长情况进行调整,如修剪枝叶、施肥等。程序员同样需要根据程序运行的情况进行维护和优化。
管理者可能对建筑的隐喻满意,因为它是可重复的,管理上有严格的汇报层次结构。但实际的程序更接近于园艺,也许某个程序太庞大,需要一分为二。需要删除或修建不合理的计划。
重构
的定义是:重构是一种技术,需要遵守纪律,必须遵循重构规范。这项技术用于重组现有代码体,在不改变其外部行为的前提下改变其内部结构。根据定义,可以提取出两个关键点:
- 这项活动是有纪律的,不能随意为之;
- 程序的外部行为不变;重构的过程中不能添加新功能。
重构是一项日复一日的工作,小步进行,这样能降低风险。尽早重构,经常重构。重构,和大多数事情一样,在问题很小的时候做起来更容易,要把它当做编码日常活动。重构前,必须有良好的自动化测试来保证重构没有改变外部行为。
何时该重构?
- 重复
- 非正交设计
- 知识过时
- 对需求认识更深刻时
- 通过了测试。测试驱动开发提倡一旦通过测试,立刻进行重构。
重构的核心是重新设计:根据新的事实、更深的理解、更改的需求等重新设计。重构是一项需要慢慢地、有意地、仔细地进行的活动。如果你执拗地非要将海量代码统统撕毁,可能会发现,自己所处的境地,比开始时更加糟糕。
怎样重构?
- 不要让重构和添加功能同时进行。
- 在开始重构之前,确保有良好的测试。这样,如果有变更破坏了任何东西,都将很快得知。这条非常重要,是重构的基础。
- 采取简短而慎重的步骤。保持小步骤,并在每个步骤之后进行测试。
下一次看到一段代码与它应该有的样子不符时,要把它修好。这其实是在控制疼痛——尽管现在很痛,但以后会痛得更厉害,那么就忍痛赶紧干完。
为编码测试
在本书第一版的时候(1999 年),大多数开发人员都不写测试。现在(本书第二版出版于 2019 年),如果有开发者仍然没有编写测试,至少他知道自己应该去做。
测试的重要性是什么?请记住:测试的好处发生在你考虑测试及编写测试的时候。测试给了我们一个观察代码的机会,它迫使我们编写低耦合代码。这是测试带来的最大好处,其它收益都是附带的,包括找到 BUG 。
测试时代码的第一个用户。测试所提供的反馈至关重要,可以指导编码过程。与其它代码紧密耦合的函数很难进行测试,因为你必须在运行函数前设置好所有环境。所以:让你的代码可测试,就可以减少它的耦合。
所以现在,我的一个感悟是:我不再关注代码模块化,转而关注可测试性。如果一段代码是可测的,它就是模块化的。这就是我推崇测试驱动开发的缘由:它不但减少了错误,还倒逼你写出模块化的代码。
测试驱动开发的理念是先写测试再写业务代码,基本循环是:
- 决定添加一小部分功能。
- 编写一个测试,毫无疑问,这个测试会编译失败,因为它测试的对象还没有编写。
- 编写产品代码,可以恰好让刚刚失败的测试通过。
- 再次编写测试,恰好让测试再次失败。
- 编写产品代码,可以恰好让刚刚失败的测试通过。
- … 如此循环,每个循环周期应该非常短,可能几秒到几分钟。
- 一个小功能完成后,重构。
然而,不要成为测试驱动开发的奴隶,花费太多时间来确保总是有 100% 的测试覆盖率。不要忘记停下来看看大局,要让你的代码离解决方案更近,而不是为了获得大量的“测试通过”消息。
在计算机科学稚嫩的童年,有两种设计学派:自上而下和自下而上。这两个学派实际上都没成功,因为它们忽略了软件开发中最重要的一个方面:我们不知道开始时在做什么。我们坚信,构建软件的唯一方法是增量式的。构建端到端功能的小块,一边工作一边了解问题。应用学到的知识持续充实代码,让客户参与每一个步骤并让他们指导这个过程。
端到端指的是从用户的角度出发,构建一个完整的工作流程或功能链条,确保从输入到输出整个过程都是连贯的。
端到端的方法强调在整个开发过程中保持对最终用户价值的关注,确保每次迭代都能够提供可见的价值,并且在整个系统的生命周期内保持一致性和可用性。
软件被部署到生产环境前,可以提供模块内部状态的各种视图,跟踪消息日志文件就是这样一种机制。日志消息应该采用规范一致的格式,便于理解,也便于软件自动解析。
另一种机制是按下特定组合键或是特定 URL 时,弹出一个诊断窗口,里面有各种状态信息。
你编写的所有软件最终都将被测试,如果不是由你和你的团队做测试,那么就将由最终的用户去测试。在大多数情况下,测试先行,包括测试驱动开发,可能是最佳选择。在编码期间进行测试是一个备选方案。最糟糕的做法是“以后再测”,这么说的人大概没听过勒布朗(LeBlanc)法则:稍后等于永不(Later equals never)。
对待测试代码要像对待任何产品代码一样。测试是编程的一部分,不该留给其他部门的人去做。
测试、设计、编码——都是在编程。
基于特性测试
一个人写代码,然后再写测试,有可能代码可以通过测试,只是因为它是根据你的理解去完成工作的。所以可以让计算机来做一些测试,它不会受你的先入之见影响。我们的代码要遵循一定的契约和不变式,可以用这些特性来自动化我们的测试,这就是基于特性测试。
- 对列表排序,其数据元素个数应不变。
- 订单处理和库存控制系统,物品不会凭空出现,也不会凭空消失。
基于特性的测试会让你从契约式和不变式中考虑代码,你会思考什么不能改变,什么必须为真。这种额外的洞察力会对代码产生神奇的影响,可以消除边界情况。
基于特性的测试是对单元测试的补充,二者处理不同的关注点。
出门在外注意安全
数据泄漏、系统被劫持、网络欺诈等越来越多。在绝大多数情况下,这并不是因为攻击者非常聪明,他们甚至都谈不上有多大能力。开发人员实在太粗心了。
在编写代码时,你可能反复经历着“可以跑了!”和“为什么不工作?”的循环,间或抱怨一句“这不可能啊……”经历几次起伏后,很容易对自己说:“吁,都跑起来!”并宣告代码已经完成。当然,还没有完成。
接下来你需要分析代码中可能出错的路径,考虑传入错误的参数、泄漏的资源、可能不存在的资源等可靠性事项。
当然,这也还没完,因为这些都是内部错误。接下来,要考虑有哪些外部参数可能会搞砸系统。或许你会说:“这并不是什么重要东西,甚至没人知道这台服务器……”
醒醒吧,通过隐藏来实现安全性是行不通的。
安全性的基本原则:
- 将攻击面积最小化:
- 代码复杂性滋生攻击载体,编写简单、简洁的代码。
- 输入数据是一种攻击载体,永远不要信任来自外部实体的数据。
- 未经身份认证的服务成为攻击载体
- 经过身份认证的服务成为攻击载体,确保授权用户的数量保持在最小范围。
- 输出数据成为攻击载体,确保输出的数据适合该用户的权限,对危险信息进行截断或混淆(比如手机号、身份证)。
- 调试信息成为攻击载体
- 最小特权原则:在最短的时间内使用最少的特权。
- 安全的默认值。
- 敏感数据要加密。
- 维护安全更新:尽早打上补丁。
事物命名
事物应该根据它们在代码中扮演的角色来命名。只要你要创建某个东西时,你要停下来思考“我创建这个的动机是什么?”
每一份打赏,都是对创作者劳动的肯定与回报。!