整洁代码的艺术-全-

整洁代码的艺术(全)

原文:zh.annas-archive.org/md5/5d311ce4883ae093018391d5c9537b1c

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

曾几何时,比尔·盖茨的父母邀请传奇投资者沃伦·巴菲特到家里一起度过一段时光。在一次 CNBC 的采访中,沃伦·巴菲特回忆道,这次机会中,比尔的父亲请沃伦和比尔写下他们成功的秘诀。我会马上告诉你他们写了什么。

当时,技术神童比尔·盖茨只见过著名投资者沃伦·巴菲特一两次,但他们已经成为了快速的朋友,两人都在领导着成功的亿万级公司。年轻的比尔·盖茨正处于实现自己的使命的边缘——通过快速增长的软件巨头微软,将每张桌子上都放一台电脑。沃伦·巴菲特则凭借其作为世界上最成功的商业天才之一而成名。著名的是,沃伦将他控股的公司——伯克希尔·哈撒韦,成功地从一家破产的纺织厂发展成为了一个国际化的商业巨头,涉足保险、运输和能源等多个行业。

那么,这两位商业传奇人物认为他们成功的秘诀是什么呢?故事是这样的,在没有任何合作的情况下,比尔和沃伦各自写下了一个词:专注

尽管这个“成功秘诀”听起来简单,你可能会想:它也适用于我的编程职业吗?专注在实际中是什么样的——通宵编程喝能量饮料和吃比萨,还是吃全蛋白饮食并在日出时起床?过专注的生活会有哪些不太明显的后果?更重要的是,作为一个程序员,我如何从这一抽象原则中获得可操作的建议,以提高我的工作效率?

本书旨在回答这些问题,帮助你作为程序员过上更加专注的生活,并在日常工作中变得更加高效。我会告诉你如何通过编写简洁、清晰、专注的代码来提高工作效率,这种代码更易于阅读、编写,并与其他程序员协作。正如我将在接下来的章节中展示的那样,专注的原则适用于软件开发的每个阶段;你将学会如何编写清晰的代码,创建专注的函数做到“一件事做得好”,创建快速响应的应用程序,设计符合可用性和美学的专注用户界面,以及使用最小可行产品规划产品路线图。我甚至会展示如何通过专注的纯粹状态显著提升你的集中力,并帮助你从工作中获得更多的兴奋和乐趣。正如你将看到的,这本书的主旨是:在所有方面做到专注——接下来的章节里,我将向你展示如何做到这一点。

对于任何一位认真的编码者来说,持续提高专注力和生产力是至关重要的。当你做更多有价值的工作时,通常会获得更多的回报。然而,仅仅增加产出并不是解决问题的办法。陷阱是这样的:如果我写更多的代码,创建更多的测试,读更多的书,学更多的东西,想更多的东西,沟通更多,结识更多的人,我就能做得更多。但你不能在不做的情况下做更多。时间是有限的——你每天有 24 小时,一周有 7 天,就像我和其他所有人一样。存在一个不可逃避的数学限制:在有限的空间中,如果一件事增长了,其他事必须缩小以腾出空间。如果你读更多的书,你可能会见到更少的人。如果你见到更多的人,你可能会写更少的代码。如果你写更多的代码,你可能会有更少的时间陪伴你爱的人。你无法逃避这种根本性的权衡:在有限的空间里,不能有更多而没有更少

本书不是仅仅聚焦于做更多的显而易见结果,而是从相反的角度出发:你减少复杂性,从而让自己在工作更少的情况下,从结果中获得更多的价值。深思熟虑的极简主义是个人生产力的圣杯,正如你在后续章节中看到的那样,它是有效的。通过以正确的方式编写计算机程序并运用本书中展示的永恒原则,你可以用更少的资源创造更多的价值。

通过创造更多的价值,你也能获得更高的报酬。比尔·盖茨曾说:“一位优秀的车床操作工的工资是普通车床操作工的数倍,而一位优秀的软件编程师的价值是普通软件编程师的 10,000 倍。”

其中一个原因是,优秀的软件开发者从事的是一种高杠杆活动:以正确的方式编写计算机程序可以取代成千上万的工作和数百万小时的有偿劳动。例如,运行自动驾驶汽车的代码可以取代数百万名人类司机的劳动,同时更便宜、更可靠,并且(可以说)更安全。

这本书适合谁?

你是一个希望通过更快的代码和更少的痛苦创造更多价值的编码从业者吗?你是否曾经发现自己陷入了找 bug 的模式?代码的复杂性是否经常让你不堪重负?你是否为选择下一个要学习的内容而苦恼,面对数百种编程语言——Python、Java、C++、HTML、CSS、JavaScript——以及成千上万的框架和技术——Android 应用、Bootstrap、TensorFlow、NumPy?如果你能用“YES!”(甚至是“yes”)回答任何一个问题,那么你手里拿的是一本对的书!

这本书适合所有希望提高生产力——用更少的资源做更多事的程序员。如果你寻求简单并相信奥卡姆剃刀原理:“用更少的东西做能够做到的事情,是徒劳的”,这本书就是为你而写的。

你将学到什么?

本书向你展示如何实际应用九条原则,成倍提升作为程序员的潜力。这些原则将简化你的生活,减少复杂性、困惑和工作时间。我并不声称这些原则是全新的,它们已经广为人知并且被许多最成功的编码员、工程师、哲学家和创造者验证有效。正是这些验证过的原则才使得它们成为原则!然而,在本书中,我将把这些原则明确地应用于程序员,提供现实世界的例子,并尽可能提供代码示例。

  1. 第一章将揭示提高生产力价值的主要挑战:复杂性。你将学习识别生活和代码中复杂性的来源,并理解复杂性可能对生产力和产出造成的危害。复杂性无处不在,你需要时刻保持警惕,抵御它。保持简洁!

  2. 第二章中,你将了解80/20 原则对作为程序员的你生活产生的深远影响。大多数效果(80%)源自少数原因(20%);这是编程中的普遍主题。你将了解到 80/20 原则是分形的:20%的 20%的程序员赚取 80%的 80%的薪资。换句话说,全球 4%的程序员赚取 64%的薪资。追求持续的杠杆效应和优化始终在进行中!

  3. 第三章中,你将学习如何构建最小可行产品,以便早期验证假设、减少浪费,并提高通过构建、衡量和学习循环的速度。其核心思想是通过尽早获取反馈,了解应该集中精力和注意力的地方。

  4. 第四章中,你将学习到编写简洁清晰代码的好处。与大多数人直观假设的相反,编写代码时,首要目标应该是最大化可读性,而不是最小化中央处理器(CPU)周期的使用。集体程序员的时间和精力比 CPU 周期更为稀缺,写出难以理解的代码会降低你所在组织的效率——以及我们共同的人类智慧。

  5. 第五章中,你将学习到性能优化的概念基础以及过早优化的陷阱。计算机科学之父之一的唐纳德·克努斯曾说过:“过早的优化是万恶之源!”当你确实需要优化代码时,使用 80/20 原则:优化运行 80%时间的 20%的函数。消除瓶颈,忽略其余部分。重复此过程。

  6. 第六章中,你将和我一起探索米哈伊·契克森米哈伊(Mihaly Csikszentmihalyi)那令人兴奋的心流世界。心流状态是一种纯粹的专注状态,能够成倍提高生产力,并帮助在深度工作中建立文化——这也是计算机科学教授 Cal Newport 的观点,他的思想也为本章贡献了一些观点。

  7. 第七章中,你将学习 Unix 哲学中的做一件事并做好的理念。与其拥有一个庞大功能的单体内核(并可能更高效),Unix 的开发者选择实现一个小巧的内核,并提供大量可选的辅助功能。这帮助 Unix 生态系统在扩展的同时保持简洁和(相对)简单。我们将看到如何将这些原则应用到你自己的工作中。

  8. 第八章中,你将进入计算机科学中的另一个重要领域,这一领域同样受益于极简主义思维:设计与用户体验(UX)。想想 Yahoo 搜索和 Google 搜索、黑莓和 iPhone、OkCupid 和 Tinder 之间的差异。最成功的技术往往都配有极其简单的用户界面,因为在设计中,少即是多

  9. 第九章中,你将重新审视专注的力量,并学习如何将其应用到不同领域,以大幅提升你的(以及你项目的)产出!

  10. 最后,我们将总结本章内容,给你一些可操作的下一步建议,并将你释放到复杂的世界中,带着一套可靠的工具来简化这个世界。

第一章:复杂性如何损害你的生产力

在本章中,我们将全面探讨一个重要且高度未被充分研究的话题——复杂性。究竟什么是复杂性?它在哪里发生?它如何损害你的生产力?复杂性是精益高效的组织和个人的敌人,因此值得仔细观察我们在哪些领域发现复杂性以及它表现出哪些形式。本章专注于问题——复杂性,接下来的章节将探讨通过重新分配之前被复杂性占用的资源来有效解决这一问题的方法。

让我们先快速概览一下新手程序员可能会觉得复杂的地方:

  • 选择一种编程语言

  • 选择一个编程项目来进行——从成千上万的开源项目和无数的问题中选择

  • 决定使用哪些库(scikit-learn 与 NumPy 与 TensorFlow)

  • 决定将时间投入哪些新兴技术——Alexa 应用、智能手机应用、基于浏览器的 web 应用、集成的 Facebook 或微信应用、虚拟现实应用

  • 选择一个编码编辑器,如 PyCharm、集成开发与学习环境(IDLE)和 Atom

鉴于这些复杂性来源所带来的巨大困惑,“我该从哪里开始?” 成为编程初学者最常见的问题之一,也就不足为奇了。

最好的开始方式是不要通过选择一本编程书并阅读编程语言的所有语法特性来开始。许多有抱负的学生购买编程书籍作为激励,然后把学习任务加入他们的待办事项列表——如果他们已经花钱买了书,他们最好去读,否则投资就会丧失。但和许多待办事项列表中的任务一样,阅读编程书籍很少能完成。

最好的开始方式是选择一个实用的编码项目——如果你是初学者,可以选择一个简单的项目——并推动它完成。在完成一个完整项目之前,不要去阅读编码书籍或互联网上的随机教程,也不要在 StackOverflow 上翻阅无休止的帖子。只需设置好项目并利用你现有的有限技能和常识开始编码。我的一位学生想要创建一个财务仪表盘应用,用于查看不同资产配置的历史回报,以回答像“由 50% 股票和 50% 政府债券组成的投资组合在某一年最大的亏损是多少?”这样的提问。起初,她不知道如何着手这个项目,但很快发现了一个名为 Python Dash 的框架,这个框架专注于构建基于数据的 web 应用。她学习了如何设置服务器,并且只研究了她所需的超文本标记语言(HTML)和层叠样式表(CSS),最终她的应用已经上线,并且帮助了成千上万的人找到合适的资产配置。但更重要的是,她加入了创建 Python Dash 的开发团队,甚至与 No Starch Press 一起写了一本关于它的书。她在一年内完成了这一切——你也可以做到。如果你不理解自己在做什么也没关系;你会逐渐增加自己的理解。只要通过阅读文章推动当前项目的进展即可。完成第一个项目的过程会引入一系列非常相关的问题,包括:

  1. 你应该使用哪个代码编辑器?

  2. 如何安装你项目的编程语言?

  3. 如何从文件中读取输入?

  4. 如何在程序中存储输入以供后续使用?

  5. 如何处理输入以获得期望的输出?

通过回答这些问题,你将逐步建立起一个全面的技能组合。随着时间的推移,你将能够更好、更轻松地回答这些问题。你将能够解决更大、更复杂的问题,并积累起一个内部的编程模式和概念洞察数据库。即便是高级程序员,也通过相同的过程进行学习和提升——只不过编码项目变得更大、更复杂。

采用这种基于项目的学习方法,你很可能会发现自己在诸如寻找日益增长的代码库中的 bug、理解代码组件及其交互、选择下一个要实现的功能以及理解代码的数学和概念基础等方面与复杂性作斗争。

复杂性无处不在,贯穿项目的每个阶段。这种复杂性的隐藏成本通常是,刚入门的程序员会放弃,他们的项目最终无法实现。所以,问题就来了:我该如何解决复杂性问题?

答案很简单:简单性。在编码的每个阶段寻求简单性和专注。如果你从这本书中只记住一件事,那就是:在你遇到的编程领域中采取极简的立场。全书将讨论以下所有方法:

  • 清理你的日程,做更少的任务,把精力集中在重要的任务上。例如,与其*行启动 10 个有趣的新代码项目,不如仔细挑选一个并将所有精力集中在完成这个项目上。在第二章,你将更详细地了解编程中的 80/20 原则。

  • 给定一个软件项目,去除所有不必要的功能,专注于最小可行产品(见第三章),发布它,并快速高效地验证你的假设。

  • 在可能的地方编写简单而简洁的代码。在第四章,你将学到如何实现这一目标的许多实用技巧。

  • 减少在过早优化上花费的时间和精力——没有必要的代码优化是导致不必要复杂性的主要原因之一(见第五章)。

  • 通过将大量时间块集中用于编程,减少切换时间,以达到心流状态——这是心理学研究中的术语,用来描述一种集中的心理状态,它能够提高你的注意力、专注力和生产力。第六章将全面讲解如何达到心流状态。

  • 运用 Unix 哲学,专注于代码功能的单一目标(“做好一件事”)。有关 Unix 哲学的详细指南,包含 Python 代码示例,请见第七章。

  • 在设计中应用简化原则,创造美观、简洁、专注且易于使用的直观用户界面(见第八章)。

  • 在规划职业、下一个项目、一天的工作或你的专业领域时,应用聚焦技巧(见第九章)。

让我们更深入地探讨复杂性这一概念,理解它是你编码生产力的巨大敌人之一。

什么是复杂性?

在不同的领域,复杂性这个术语有不同的含义。有时它被严格定义,比如计算机程序的计算复杂度,这为分析给定代码功能在不同输入下的表现提供了一种方法。其他时候,它是宽泛定义的,指的是系统组件之间的交互量或结构。在本书中,我们将更广泛地使用它。

我们将复杂性定义如下:

  1. 复杂性是由多个部分组成的整体,难以分析、理解或解释。

复杂性描述的是一个整体或实体。由于复杂性使得系统难以解释,它会引起挣扎和困惑。因为现实世界的系统很混乱,你会发现复杂性无处不在:股市、社会趋势、新兴的政治观点、以及拥有数十万行代码的大型计算机程序——例如 Windows 操作系统。

如果你是一个程序员,你尤其容易受到复杂性困扰,来自这些我们将在本章讨论的不同来源:

  1. 项目生命周期中的复杂性

  2. 软件和算法理论中的复杂性

  3. 学习中的复杂性

  4. 流程中的复杂性

  5. 社交网络中的复杂性

  6. 你日常生活中的复杂性

项目生命周期中的复杂性

让我们深入探讨项目生命周期的不同阶段:规划、定义、设计、构建、测试和部署(见图 1-1)。

生命周期图示,展示一个从规划到定义、设计、构建、测试、部署再回到规划的箭头循环。

图 1-1:基于电气与电子工程师协会(IEEE)软件工程标准的软件项目六个概念性阶段

即使你正在处理一个非常小的软件项目,你也很可能经历软件开发生命周期的所有六个阶段。请注意,你不一定只能经历每个阶段一次——在现代软件开发中,更倾向于采用迭代方法,每个阶段会被反复 revisited。接下来,我们将探讨复杂性如何对每个阶段产生重要影响。

规划

软件开发生命周期的第一阶段是规划阶段,有时在工程学文献中被称为需求分析。这个阶段的目的是确定产品的外观。成功的规划阶段将导致一个严格定义的、需要交付给最终用户的特性集合。

无论你是一个人独自工作于你的爱好项目,还是负责管理和协调多个软件开发团队的协作,你都必须弄清楚软件的最优特性集合。需要考虑许多因素:构建一个特性的成本、无法成功实施该特性的风险、最终用户的预期价值、市场营销和销售的影响、可维护性、可扩展性、法律限制等等。

这个阶段至关重要,因为它可以帮助你避免以后浪费大量精力。规划上的错误可能导致数百万美元的资源浪费。另一方面,谨慎的规划可以为接下来的几年中业务的巨大成功奠定基础。规划阶段是应用你新获得的 80/20 思维技巧的时候(见第二章)。

规划阶段也是一个难以做到完美的阶段,因为涉及的复杂性。许多因素增加了复杂性:提前正确评估风险,弄清楚公司或组织的战略方向,猜测客户的反应,权衡不同功能候选项的积极影响,并确定某个软件功能的法律影响。综合来看,解决这个多维度问题的复杂性令我们不堪重负。

定义

定义阶段包括将规划阶段的结果转化为恰当规范的软件需求。换句话说,它将上一阶段的输出正式化,以便获得客户和最终用户的批准或反馈,他们将在后续使用该产品。

如果你花费了大量时间来规划和弄清楚项目需求,但却未能有效地传达这些需求,后续将会带来严重的问题和困难。一项错误表述的需求,尽管有助于项目,可能和一项正确表述的需求但并无帮助的情况一样糟糕。有效的沟通和精确的规范对于避免歧义和误解至关重要。在所有的人类沟通中,由于“知识诅咒”和其他心理偏见的影响,信息传递是一个极为复杂的任务,这些偏见往往压倒了个人经验的相关性。如果你试图将想法(或需求)从自己脑海中传递到他人脑海中,要小心:复杂性在等着你!

设计

设计阶段的目标是草拟系统的架构,决定提供已定义功能的模块和组件,并设计用户界面——同时牢记之前两个阶段中开发的需求。设计阶段的黄金标准是创建一个清晰明确的图像,展示最终软件产品的外观以及其构建方式。这适用于所有软件工程方法。敏捷方法只会更快速地迭代这些阶段。

但是,细节决定成败!一位优秀的系统设计师必须了解他们可能用来构建系统的各种软件工具的优缺点。例如,一些库可能对程序员来说容易使用,但执行速度较慢。构建自定义库对程序员来说更为复杂,但可能带来更高的速度,从而提高最终软件产品的可用性。设计阶段必须解决这些变量,以便最大化收益与成本的比率。

构建

构建阶段是许多程序员希望投入所有时间的地方。这是从架构草图到软件产品的转变发生的地方。你的想法转化为具体的结果。

通过前期阶段的适当准备,许多复杂性已经被排除。理想情况下,构建者应该知道要实现哪些功能,功能的外观如何,以及要使用哪些工具来实现它们。然而,构建阶段总是充满了新出现的问题。外部库中的错误、性能问题、数据损坏以及人为错误等意外情况可能会减缓进度。构建软件产品是一项极其复杂的工作。一个小小的拼写错误就可能破坏整个软件产品的可行性。

测试

恭喜你!你已经实现了所有要求的功能,并且程序似乎正常工作。然而,你还没有完成。你仍然需要测试软件产品在不同用户输入和使用模式下的行为。这个阶段通常是最重要的——以至于许多实践者现在提倡使用测试驱动开发,即在实施(构建阶段)之前,必须先编写所有的测试。虽然你可以反对这种观点,但通常来说,花时间通过创建测试用例并检查软件是否为这些测试用例提供正确的结果,是个不错的主意。

例如,假设你正在实现一辆自动驾驶汽车。你必须编写单元测试,检查代码中每个小功能(即单元)是否为给定输入生成预期的输出。单元测试通常能发现一些在特定(极端)输入下表现异常的错误函数。例如,考虑以下 Python 函数代码,它计算图像的*均红色、绿色和蓝色(RGB)值,也许可以用来区分你是穿越城市还是森林:

def average_rgb(pixels):
    r = [x[0] for x in pixels]
    g = [x[1] for x in pixels]
    b = [x[2] for x in pixels]
    n = len(r)
    return (sum(r)/n, sum(g)/n, sum(b)/n)

例如,以下像素列表将分别得出 96.0、64.0 和 11.0 的*均红、绿、蓝值:

print(average_rgb([(0, 0, 0),
                   (256, 128, 0),
                   (32, 64, 33)]))

这是输出结果:

(96.0, 64.0, 11.0)

虽然这个函数看起来足够简单,但在实际操作中,许多事情可能会出错。如果像素元组列表被损坏,其中一些(RGB)元组只有两个元素而不是三个怎么办?如果其中一个值是非整数类型呢?如果输出必须是整数元组,以避免所有浮点计算固有的浮点错误怎么办?

单元测试可以测试所有这些条件,以确保函数在孤立情况下正常工作。

下面是两个简单的单元测试,一个检查函数是否在输入为零的边界情况下正常工作,另一个检查函数是否返回整数元组:

def unit_test_avg():
    print('Test average...')
    print(average_rgb([(0, 0, 0)]) == average_rgb([(0, 0, 0), (0, 0, 0)]))

def unit_test_type():
    print('Test type...')
    for i in range(3):
        print(type(average_rgb([(1, 2, 3), (4, 5, 6)])[i]) == int)

unit_test_avg()
unit_test_type()

结果显示类型检查失败,函数没有返回正确的类型,应该是一个整数元组:

Test average...
True
Test type...
False
False
False

在更现实的环境中,测试人员需要编写数百个单元测试来检查函数对所有类型输入的响应——并验证它是否生成了预期的输出。只有当单元测试表明函数运行正常时,我们才能继续测试应用程序的更高级功能。

事实上,即使所有单元测试都成功通过,您仍然没有完成测试阶段。您必须测试各个单元如何正确地相互作用,共同构建一个更大的整体。您必须设计现实世界的测试,像驾驶汽车一样测试数千甚至数万辆英里,以揭示在陌生和不可预测的情况下出现的意外行为模式。假设您的汽车行驶在没有路标的小路上怎么办?如果前方的车突然停下怎么办?如果多辆车在十字路口互相等待怎么办?如果驾驶员突然转向迎面而来的车流怎么办?

需要考虑的测试非常多;复杂性极高,以至于许多人在这里放弃了。理论上看起来不错的东西,即使是首次实现后,往往在实践中也会失败,特别是在应用了不同级别的软件测试(如单元测试或现实世界使用测试)后。

部署

该软件现在已经通过了严格的测试阶段。是时候部署它了!部署可以有多种形式。应用程序可能会发布到市场,软件包可能会发布到仓库,重大(或次要)版本可能会公开发布。在一种更迭代和敏捷的软件开发方法中,您需要多次回顾部署阶段,采用持续部署的方式。根据具体项目的不同,这一阶段可能需要您推出产品、创建营销活动、与早期用户沟通、修复在用户使用后必然暴露的新问题、协调软件在不同操作系统上的部署、支持和排除各种问题,或维护代码库以便随着时间的推移适应和改进。由于您在前几个阶段做出的各种设计选择及其相互依赖性,这个阶段可能会变得非常混乱。后续章节将提供一些策略,帮助您克服这些混乱。

软件和算法理论中的复杂性

一段软件内部的复杂性可能与软件开发过程中所涉及的复杂性一样多。许多软件工程的度量标准会以正式的方式衡量软件的复杂性。

首先,我们来看看算法复杂度,它涉及不同算法的资源需求。通过算法复杂度,你可以比较解决同一问题的不同算法。例如,假设你实现了一个带有高分评级系统的游戏应用程序。你希望得分最高的玩家排在列表的顶部,得分最低的玩家排在底部。换句话说,你需要对列表进行排序。有成千上万的算法可以用来排序列表,且排序列表在 1,000,000 名玩家时比在 100 名玩家时更具计算需求。有些算法随着列表输入的增大而表现更好,而有些则表现更差。当你的应用服务几百个用户时,选择哪个算法并不重要,但随着用户数量的增长,列表的运行时间复杂度会超线性增长。不久,最终用户将不得不等待越来越长的时间才能看到列表排序完成。他们会开始抱怨,而你需要更好的算法!

图 1-2 举例说明了两种示意性算法的算法复杂度。x 轴显示要排序的列表大小。y 轴显示算法的运行时间(单位为时间)。算法 1 比算法 2 要慢得多。事实上,算法 1 的低效性在需要排序更多列表元素时变得更加明显。使用算法 1,你的游戏应用随着玩家人数的增加而变得越来越慢。

图表显示列表大小在 x 轴,运行时间在 y 轴。线条呈现稳定的上升趋势。

图 1-2:两种不同排序算法的算法复杂度

让我们看看这是否适用于真实的 Python 排序算法。图 1-3 比较了三种流行的排序算法:冒泡排序、快速排序和 Timsort。冒泡排序的算法复杂度最高。快速排序和 Timsort 的渐进算法复杂度相同。但 Timsort 算法仍然要快得多——这就是为什么它被用作 Python 默认的排序算法。随着列表大小的增长,冒泡排序的运行时间爆炸性增加。

在图 1-4 中,我们重复了这个实验,但只比较了快速排序和 Timsort。再次,我们看到算法复杂度的显著差异:Timsort 更适应规模增长,并且在增加的列表大小下更快。现在你明白为什么 Python 的内建排序算法这么长时间没有改变了!

图表显示列表大小(元素数量)在 x 轴,运行时间(秒)在 y 轴。三条线分别代表冒泡排序、快速排序和 Timsort。快速排序和 Timsort 的线条在 y 轴的 0 秒点附**稳。Timsort 的线条是一个稳定的、稍微弯曲的上升趋势。

图 1-3:冒泡排序、快速排序和 Timsort 的算法复杂度

图表显示了列表大小(元素数量)在横轴上,运行时间(秒)在纵轴上。两条线分别代表快速排序和 TimSort。快速排序的曲线从 0.0 秒略微上升至约 0.05 秒,随着横轴的移动。TimSort 的曲线在横纵轴之间呈锯齿状但相当*稳的上升。

图 1-4:快速排序和 TimSort 的算法复杂度

列表 1-1 显示了 Python 代码,如果你想重现实验,可以参考这个代码。我建议你选择一个较小的n值,因为代码在我的机器上运行较长时间才会终止。

import matplotlib.pyplot as plt
import math
import time
import random

def bubblesort(l):
 # src: https://blog.finxter.com/daily-python-puzzle-bubble-sort/
    lst = l[:] # Work with a copy, don't modify the original
    for passesLeft in range(len(lst)-1, 0, -1):
        for i in range(passesLeft):
            if lst[i] > lst[i + 1]:
                lst[i], lst[i + 1] = lst[i + 1], lst[i]
    return lst

def qsort(lst):
 # Explanation: https://blog.finxter.com/python-one-line-quicksort/
    q = lambda lst: q([x for x in lst[1:] if x <= lst[0]])
                    + [lst[0]] 
                    + q([x for x in lst if x > lst[0]]) if lst else []
    return q(lst)

def timsort(l):
 # sorted() uses Timsort internally
    return sorted(l) 

def create_random_list(n):
    return random.sample(range(n), n)

n = 50000
xs = list(range(1,n,n//10))
y_bubble = []
y_qsort = []
y_tim = []

for x in xs:

 # Create list
    lst = create_random_list(x)

 # Measure time bubble sort
    start = time.time()
 bubblesort(lst)
    y_bubble.append(time.time()-start)

 # Measure time qsort
    start = time.time()
    qsort(lst)
    y_qsort.append(time.time()-start)

 # Measure time Timsort
    start = time.time()
    timsort(lst)
    y_tim.append(time.time()-start)

plt.plot(xs, y_bubble, '-x', label='Bubblesort')
plt.plot(xs, y_qsort, '-o', label='Quicksort')
plt.plot(xs, y_tim, '--.', label='Timsort')

plt.grid()
plt.xlabel('List Size (No. Elements)')
plt.ylabel('Runtime (s)')
plt.legend()
plt.savefig('alg_complexity_new.pdf')
plt.savefig('alg_complexity_new.jpg')
plt.show()

列表 1-1:测量三种流行排序算法的运行时间

算法复杂度是一个经过深入研究的领域。在我看来,从这些研究中产生的改进算法是人类最有价值的技术资产之一,它们让我们能够用更少的资源不断地解决相同的问题。我们确实站在巨人的肩膀上。

除了算法复杂度之外,我们还可以通过圈复杂度来衡量代码的复杂性。这个度量标准由托马斯·麦凯布(Thomas McCabe)于 1976 年提出,描述了代码中线性独立路径的数量,或者说是具有至少一条边不属于其他路径的路径数。例如,包含if语句的代码将导致通过代码的两条独立路径,因此它的圈复杂度要高于没有任何分支的*坦代码。 图 1-5 展示了两个处理用户输入并作出响应的 Python 程序的圈复杂度。第一个程序只包含一个条件分支,可以看作是道路上的一个分叉。可以选择其中一个分支,但不能同时选择两个分支。因此,圈复杂度为 2,因为有两条线性独立的路径。第二个程序包含两个条件分支,导致总共三条线性独立的路径,圈复杂度为 3。每增加一个if语句,圈复杂度就会增加。

图左侧显示了圈复杂度为 2 的示意图。一个起始节点指向包含条件代码的第二个节点。两条箭头从该节点退出:路径 1 指向结束节点,路径 2 指向包含打印语句的第二个节点。

图右侧显示了圈复杂度为 3 的示意图。起始节点指向包含条件的节点。两条箭头从该节点退出,第一条指向结束节点,第二条指向第二个条件节点。从该节点,箭头指向两个打印语句节点。从这些条件节点中的每个节点,箭头指向相同的结束节点。](image_fi/502185c01/f01005.png)

图 1-5:两个 Python 程序的圈复杂度

环状复杂度是一个很好的代理度量,用于衡量难以度量的认知复杂性,即理解给定代码库的难度。然而,环状复杂度忽略了诸如多个嵌套的for循环相比于*坦的for循环所带来的认知复杂性。这就是为什么其他度量如NPath复杂度能在环状复杂度的基础上进行改进。总的来说,代码复杂性不仅是算法理论中的一个重要主题,而且对于实现代码时的所有实际问题也具有相关性——并且对于编写易于理解、可读且稳健的代码至关重要。算法理论和编程复杂性已经被深入研究了几十年。这些努力的一个主要目标是减少计算和非计算的复杂性,以减轻其对人类和机器的生产力和资源利用的负面影响。

学习中的复杂性

事实不是孤立存在的,它们是相互关联的。考虑以下两个事实:

  1. 沃尔特·迪士尼出生于 1901 年。

  2. 路易斯·阿姆斯特朗出生于 1901 年。

如果你给程序输入这些事实,它能够回答诸如“沃尔特·迪士尼的出生年份是什么?”以及“谁出生于 1901 年?”之类的问题。为了回答后者,程序必须弄清楚不同事实之间的相互依赖关系。它可能会像这样建模信息:

(Walt Disney, born, 1901)
(Louis Armstrong, born, 1901)

要获取所有出生于 1901 年的人,可以使用查询(*, born, 1901),或者其他任何方式将事实关联并归类。

2012 年,谷歌推出了一项新的搜索功能,在搜索结果页面上显示信息框。这些基于事实的信息框使用一种叫做知识图谱的数据结构来填充,知识图谱是一个庞大的包含数十亿个相互关联事实的数据库,用于以网络结构的方式表示信息。这个数据库不仅存储独立的客观事实,还维护不同事实及其他信息片段之间的相互关系。谷歌搜索引擎利用这个知识图谱,增强其搜索结果,自动生成答案。

图 1-6 展示了一个示例。知识图谱中的一个节点可能是著名计算机科学家艾伦·图灵。在知识图谱中,艾伦·图灵这一概念与不同的信息片段相关联,例如他的出生年份(1912)、他的研究领域(计算机科学哲学语言学)以及他的博士导师(阿隆佐·丘奇)。这些信息片段还与其他事实相关联(阿隆佐·丘奇的研究领域也是计算机科学),从而形成一个巨大的互相关联的事实网络。你可以利用这个网络获取新信息并以编程方式回答用户查询。关于“图灵博士导师的研究领域”的查询将得出推导的答案“计算机科学”。虽然这听起来可能微不足道或显而易见,但生成这些新事实的能力促成了信息检索和搜索引擎相关性领域的突破。你可能会同意,通过联想学习远比记忆无关的事实更有效。

知识图谱表示各个主题之间的联系。七个主题节点包括:1912、语言学、艾伦·图灵、计算机科学、阿隆佐·丘奇、哲学和人类。箭头指向它们,通过“具有研究领域”和“具有出生年份”等标准将它们连接起来。

图 1-6:知识图谱表示

每个研究领域仅关注图谱中的一小部分,每部分包含无数相互关联的事实片段。你只能通过考虑相关事实来真正理解一个领域。要全面了解艾伦·图灵,你必须研究他的信仰、他的哲学观点以及他博士导师的特点。要了解丘奇,你必须调查他与图灵的关系。当然,图谱中有太多相关的依赖关系和事实,无法期望理解所有内容。这些相互关系的复杂性给你学习的雄心设立了最基本的边界。学习与复杂性是同一枚硬币的两面:复杂性位于你已获得知识的边界上。要学习更多,你必须首先学会如何控制复杂性。

我们有点抽象了,让我们来看一个例子!假设你想编程一个交易机器人,根据一套复杂的规则买卖资产。在开始项目之前,你可以学习许多有用的知识:编程基础、分布式系统、数据库、应用程序编程接口(API)、网络服务、机器学习、数据科学以及相关的数学知识。你可以学习一些实用工具,如 Python、NumPy、scikit-learn、ccxt、TensorFlow 和 Flask。你可以学习交易策略和股市哲学。许多人以这样的心态来处理这些问题,所以永远觉得自己没有准备好开始项目。问题在于,学得越多,你觉得自己知道的越少。你永远无法在所有这些领域达到足够的精通,去真正满足自己准备充分的愿望。被整个任务的复杂性压得喘不过气来,你感到想放弃。复杂性即将成为下一个受害者:你。

幸运的是,在本书的章节中,你将学到应对复杂性的技能:专注、简化、缩小、减少和极简主义。本书将教你这些技能。

过程中的复杂性

过程是为实现定义结果而采取的一系列行动。过程的复杂性由其行动、参与者或分支的数量来计算。通常,行动(和参与者)越多,过程就越复杂(参见图 1-7)。

图的上半部分展示了一个简单的过程,"Code"指向"Test",然后指向"Launch"。下半部分展示了一个稍微复杂的过程,"Code"出现三次,全部指向"feedback"。从"feedback"一个标为 Yes 的右箭头指向 Test,一个标为 No 的向下箭头指向 Code。从 Test 一个标为 Yes 的右箭头指向 Launch,一个标为 No 的向下箭头指向 Code。

图 1-7:两个示例过程:单人开发与团队开发

许多软件公司会遵循不同方面的流程模型,试图简化过程。以下是一些示例:

  1. 软件开发可能会使用敏捷开发或 Scrum。

  2. 客户关系发展可能会使用客户关系管理(CRM)和销售脚本。

  3. 新产品和商业模型的创建可能会使用商业模型画布。

当组织积累了过多流程时,复杂性开始阻塞系统。例如,在优步出现之前,从 A 地点到 B 地点的过程通常涉及许多步骤:寻找出租车公司的电话号码、比较价格、准备不同的支付方式以及规划不同的交通方式。对于许多人来说,优步简化了从 A 到 B 的旅行过程,将整个规划过程整合到一个易于使用的移动应用程序中。优步的这种彻底简化让客户的出行更加便捷,并且相比传统的出租车行业,减少了规划时间和成本。

在过于复杂的组织中,创新很难找到改变的途径,因为它无法突破复杂性。随着流程中的行动变得冗余,资源被浪费。为了修复这些困扰企业的痛苦,管理者将精力投入到建立新的流程和新行动中,恶性循环开始摧毁企业或组织。

复杂性是效率的敌人。这里的解决方案是极简主义:为了保持流程高效,必须彻底剔除不必要的步骤和行动。你很难发现你的流程会过于简化

你日常生活中的复杂性,或者说千刀万剐

这本书的目的是提高你编程工作的生产力。你的进步可能会被你个人的日常习惯和惯例所打断。你必须应对日常的干扰以及对你宝贵时间的持续竞争。计算机科学教授卡尔·纽波特在他那本出色的书《深度工作:在分心的世界中如何高效专注》(Grand Central Publishing,2016)中谈到过这一点。他认为,像编程、研究、医学和写作这样的工作——需要深度思考的工作——的需求正在增加,而由于通讯设备和娱乐系统的普及,可用于此类工作的时间在减少。如果需求增加而供给减少,经济学理论表明,价格会上涨。如果你能从事深度工作,那么你的经济价值就会增加。对于能够进行深度工作程序员来说,这绝对是一个前所未有的好时机。

现在,警告:如果你不严格执行优先级的管理,几乎不可能进行深度工作。外部世界是不断的干扰。你的同事会走进你的办公室。你的智能手机每 20 分钟就会吸引你的注意。你的收件箱每天都会弹出新的电子邮件——每封都在争夺你的一点时间。

深度工作带来的是延迟的满足感;当你在一个计算机程序上花费数周时间,最终发现它有效时,那是一种令人满足的感觉。然而,在大多数时刻,你渴望的是即时的满足感。你的潜意识经常寻找逃避深度工作努力的方式。小小的奖励能轻松刺激内啡肽的分泌:查看消息、进行无意义的闲聊、翻看 Netflix。与即时满足感的快乐、色彩斑斓、充满活力的世界相比,延迟满足感的承诺变得越来越不吸引人。

你保持专注和高效的努力容易在无数小细节中消耗殆尽。是的,你可以关闭智能手机,依靠意志力避免查看社交媒体或打开你喜欢的节目,但你能每天都做到吗?答案也在于将极简主义应用于问题的根本:卸载社交媒体应用,而不是试图管理你的使用,减少你参与的项目和任务数量,而不是通过更多工作来试图做得更多,深入学习一门编程语言,而不是花费大量时间在多个语言间切换。

结论

到现在为止,你应该已经彻底被克服复杂性的需求所激励。为了进一步探讨复杂性以及我们如何克服它,我确实推荐阅读 Cal Newport 的《深度工作》。

复杂性会损害生产力并降低专注力。如果你不及早控制复杂性,它将迅速吞噬你最宝贵的资源:时间。在你的一生结束时,你不会根据回复了多少电子邮件、玩了多少小时的电脑游戏或解了多少数独来评判你是否过上了有意义的生活。通过学习如何应对复杂性,保持简单,你将能够对抗复杂性,并为自己提供强大的竞争优势。

在第二章中,你将了解 80/20 原则的力量:专注于重要的少数,忽略琐碎的多数。

第二章:80/20 原则

在这一章中,你将了解80/20 原则对你作为程序员生活的深远影响。它有很多名字,包括以其发现者维尔弗雷多·帕累托命名的帕累托原则。那么,这个原则是如何运作的,为什么你应该关心它?80/20 原则指的是,大多数效果(80%)来自少数原因(20%)。它为你指明了一条道路,让你作为一名专业编码员,通过集中精力在少数重要的事情上,忽略那些几乎无法产生影响的事务,从而获得更多的成果。

80/20 原则基础

该原则表示,大多数效果来自少数原因。例如,大多数收入由少数人获得,大多数创新来自少数研究人员,大多数书籍是由少数作者写的,等等。

你可能听说过 80/20 原则——它在个人生产力的文献中随处可见。它之所以如此流行,有两个原因。首先,原则让你能够既放松又高效,只要你能找出那些重要的事情,这些事情构成了 20%的活动,能够带来 80%的结果,并且全力以赴地专注于这些事情。第二,我们可以在各种情境中观察到这个原则,从而赋予它相当的可信度。甚至很难想出反例,其中效果与原因的比例相等。试着找到一些 50/50 分布的例子,其中 50%的效果来自 50%的原因!当然,分布不总是 80/20——具体的数字可以是 70/30、90/10,甚至是 95/5——但分布总是严重倾向于少数原因产生大多数效果。

我们用帕累托分布来表示帕累托原则,如图 2-1 所示。

显示帕累托分布的图表。y 轴表示结果,从 0.0 到 0.7,x 轴表示原因,从 0 到 100。图线呈现出一个略微弯曲的 L 形。

图 2-1:一般帕累托分布的示例

帕累托分布将结果(y 轴)与原因(x 轴)绘制在一起。结果可以是任何衡量成功或失败的标准,比如收入、生产力,或者软件项目中的错误数量。原因可以是与这些结果相关的任何实体,比如员工、企业,或者软件项目等。为了得到典型的帕累托曲线,我们根据产生结果的原因对其进行排序。例如,收入最高的人排在 x 轴的最前面,接着是收入第二高的人,依此类推。

让我们来看一个实际的例子。

应用软件优化

图 2-2 显示了在一个虚拟软件项目中帕累托原则的实际应用:少数代码负责大部分的运行时间。x 轴显示按运行时间排序的代码函数,y 轴显示这些代码函数的运行时间。图表下方阴影区域的面积表明,大部分代码函数对整体运行时间的贡献远低于少数选定的代码函数。帕累托原则的早期发现者之一 Joseph Juran 将后者称为 关键少数,前者称为琐碎多数。花费大量时间优化琐碎多数几乎无法改善整体运行时间。帕累托分布在软件项目中的存在得到了科学证据的支持,例如 Louridas、Spinellis 和 Vlachos(2008)在《软件中的幂律》一文中的研究。

与图 2-1 相同的图表,但在 x 轴到 20 处的曲线下方区域被阴影标注,并标注为“20 个代码函数生成了绝大多数运行时间!”

图 2-2:软件工程中的帕累托分布示例

像 IBM、微软和苹果这样的公司运用帕累托原则,通过将焦点集中在少数关键部分上来构建更快、更易于使用的计算机,也就是通过反复优化*均用户最常执行的 20% 代码来实现。并非所有代码都一样重要。少量的代码对用户体验有主导作用,而大部分代码的影响很小。你可能每天多次双击文件资源管理器图标,但很少会更改文件的访问权限。80/20 原则告诉你在哪里集中优化工作!

这个原则容易理解,但要知道如何在自己的生活中应用这一原则,可能会更难。

生产力

通过专注于少数关键部分,而非琐碎的多数部分,你的生产力可以提高 10 倍,甚至 100 倍。你不相信吗?让我们计算一下这些数字的来源,假设底层存在 80/20 分布。

我们将使用保守的 80/20 参数(80% 的结果来自 20% 的人),然后计算每个组的生产率。在某些领域(例如编程),这种分布可能会更倾斜。

图 2-3 显示,在一个拥有 10 名员工的公司中,只有 2 名员工产生了 80% 的结果,而 8 名员工只产生了 20% 的结果。我们将 80% 结果除以 2 名员工,得出每个高绩效员工的*均产出为 40%。如果我们将 8 名员工产生的 20% 结果除以 8,得出每个低绩效员工的*均产出为 2.5%。绩效差异是 16 倍!

图表显示了十个人的数字位于顶部,下面有两行分别标注为“结果”和“每人*均结果”。前两个人在结果行中显示 80%,在每人*均结果行中显示 16 次。最后八个人在结果行中显示 20%,在每人*均结果行中显示 1 次。

图 2-3:排名前 20%的表现者的*均产出是排名后 80%表现者*均产出的 16 倍。

这种 16 倍的*均表现差异是全球数百万组织中的普遍现象。帕累托分布也是分形的,这意味着排名前 20%的前 20%的人产生了 80%结果中的 80%,在有成千上万名员工的大型组织中,这会导致更为显著的表现差异。

结果的差异不能仅通过智力来解释——一个人不可能比另一个人聪明 1,000 倍。相反,结果的差异来自于个人或组织的具体行为。如果你做相同的事情,你也能得到相同的结果。然而,在改变行为之前,你必须清楚你想要达成的结果,因为研究表明,几乎任何你能想到的指标都会存在极度不*等的结果分布。

  1. 收入 美国 10%的人获得了* 50%的收入。

  2. 幸福感 在北美,少于 25%的人将自己的幸福感评为 9 或 10(0-10 分制,其中“最糟糕的生活”是 0 分,“最好的生活”是 10 分)。

  3. 每月活跃用户 仅 2 个面向所有受众的前 10 大网站就获得了 48%的累计流量,如表 2-1 所示(基于www.ahrefs.com/提供的信息)。

  4. 图书销售 仅 20%的作者可能会获得 97%的销售额。

  5. 科学生产力 例如,5.2%的科学家占据了 38%的已发表文章。

本章末尾的资源部分提供了一些文章链接,以支持这些数据。结果的不*等是社会科学中一个广泛认可的现象,通常通过一个叫做基尼系数的指标来衡量。

表 2-1:美国十大流量网站的累计流量

# 域名 月度流量 累计
1 en.wikipedia.org 1,134,008,294 26%
2 youtube.com 935,537,251 48%
3 amazon.com 585,497,848 62%
4 facebook.com 467,339,001 72%
5 twitter.com 285,460,434 79%
6 fandom.com 228,808,284 84%
7 pinterest.com 203,270,264 89%
8 imdb.com 168,810,268 93%
9 reddit.com 166,277,100 97%
10 yelp.com 139,979,616 100%
4,314,988,360

那么,如何才能成为表现最出色的人之一呢?或者更一般地说:如何在你的组织中向左移动,在帕累托分布曲线中向左移动(见图 2-4)?

条形图展示了 y 轴上的产出,x 轴上的十个人物图标。第五个图标上方有一个问号,箭头指向第四个图标。这个插图暗示第五个人物希望与第四个人物交换位置,后者的产出条更高。

图 2-4:为了创造更多产出,你需要向曲线的左侧移动。

成功指标

假设你想优化收入。那么,如何在帕累托曲线的左侧移动呢?我们这里不再谈精确的科学,因为你需要找到在你特定行业中成功的人的原因,并制定可以控制和执行的可操作成功指标。我们将成功指标定义为衡量导致在你领域取得更多成功的行为的标准。关键在于,不同行业中的最重要的成功指标是不同的。80/20 原则也适用于成功指标:一些成功指标对你在某个领域的表现有较大影响,而其他指标几乎毫无影响。

例如,当我在做博士研究时,我很快意识到成功就是被其他研究者引用。作为研究者,你的引用越多,你的可信度、可见性和机会就越大。然而,增加引用次数几乎不是一个可以每天优化的可操作成功指标。引用次数是一个滞后指标,因为它基于你过去采取的行动。滞后指标的问题在于,它们只记录过去行动的结果,而不会告诉你每天该采取什么行动来取得成功。

为了获得做对事情的衡量标准,引入了领先指标的概念。领先指标是一种在滞后指标发生变化之前预测变化的指标。如果你多做领先指标,滞后指标很可能会因此得到改善。作为研究者,你通过发表更多高质量的研究论文(领先指标),将获得更多的引用(滞后指标)。这意味着写高质量论文是大多数科学家最重要的活动,而不是像准备演讲、组织活动、教学或喝咖啡这样的次要活动。因此,研究者的成功指标就是产生尽可能多的高质量论文,如图 2-5 所示。

条形图现在展示了成功指标:“写作字数”在 y 轴上,x 轴上有十个人物图标。第五个图标上方有一个感叹号,箭头指向第四个图标。这个插图暗示第五个人物正在积极尝试与第四个人物交换位置。

图 2-5:研究中的成功指标:写作字数与高质量论文的关系

要在研究中向左推进,你必须今天写更多的字,尽快发布下一篇高质量的论文,更快地获得更多引用,扩大你的科研影响力,成为一名更成功的科学家。粗略来说,许多不同的成功指标都可以作为“在科学中取得成功”的代表。例如,将它们按滞后指标到领先指标的顺序排列,你可能会得到引用次数撰写的高质量论文数量一生中写的总字数今天写的字数

80/20 方法让你能够识别出必须关注的活动。更多地关注成功指标,尤其是可操作的领先指标,将会提升你的职业成功,而这才是最重要的。把时间从其他任务中剥离出来,拒绝陷入“千刀万剐”的死法。对于所有活动都要懒惰,除了一个:每天写更多的字

假设你每天工作 8 小时,并将一天分为八个 1 小时的活动。在完成成功指标练习后,你意识到每天可以跳过两个 1 小时的活动,并通过不那么完美的方式在半小时内完成另外四项活动。你节省了 4 小时,但仍然完成了 80%的成果。现在,你可以每天投入 2 小时来写更多的高质量论文字数。几个月内,你会提交额外的论文,随着时间的推移,你提交的论文数量会超过你的任何同事。你每天只工作 6 小时,且大多数工作任务的质量并不完美。但你在关键地方发光:你提交的研究论文比你周围的任何人都多。结果,你很快就会成为顶尖的 20%研究人员之一。你用更少的时间创造更多的成果。

你不再成为那种“什么都懂,但什么都不精”的人,而是专注于你最重要的领域。你会将精力集中在少数关键点上,忽视琐碎的无关紧要的事物。你过上了更轻松的生活,但你从投入的劳动、努力、时间和金钱中收获更多的成果。

专注与帕累托分布

一个我想讨论的相关话题是专注。我们将在本书的多个地方讨论专注——例如,第九章详细讨论了专注的力量——但是 80/20 原则解释了为什么专注如此强大。让我们深入探讨这个论点!

考虑图 2-6,它展示了向分布顶部移动时的百分比改善。Alice 是组织中第五高产的人。如果她超越组织中的一个人,成为第四高产的人,她的输出(薪水)将增加 10%。再进一步,她的输出将增加 额外 20%。在帕累托分布中,每个排名的增长呈指数爆炸式增长,因此即使是小幅度的生产力提高,也能带来收入的巨大增加。提高生产力会导致收入、幸福感和工作中的快乐有超线性的提升。有些人称这种现象为“赢家通吃”。

柱状图显示纵轴为输出,横轴为十个代表人的图标。第五个图标上方有一个+10%的标注,箭头指向第四个图标。此图示意,如果第五个图标的人能成为公司中第四高产的人,那么她可以增加 10% 的薪水。每个指向左侧的箭头代表输出(薪水)增加 10%,最左边的图标可以增加高达 50%。

图 2-6:在帕累托分布中提高排名的收益不成比例

这就是为什么分散注意力没有好处的原因:如果你不专注,你就会参与许多帕累托分布。考虑图 2-7:Alice 和 Bob 每天都可以投入三单位的学习精力。Alice 专注于一件事:编程。她将三单位的精力投入到学习编程上。Bob 将精力分散到多个学科:一单位时间用来提高他的国际象棋技能,一单位用来提高编程技能,另一单位用来提高政治技能。他在这三项技能中都达到了*均水*。但帕累托分布会不成比例地奖励顶尖表现者,因此 Alice 能获得更多的总输出奖励。

上半部分显示了一个名为 Alice 的人物,她的三个图表分别代表了编程、国际象棋和政治这三项技能。下半部分显示了一个名为 Bob 的人物,他的三个柱状图与 Alice 的相同。每个柱状图的纵轴表示输出,横轴上有十个代表人的图标。横轴中突出显示的图标展示了 Alice 和 Bob 在各项技能中的状态。因为 Alice 将她的所有时间投入到编程中,所以她在编程中排名第一,在国际象棋和政治中分别排名第九。而 Bob 将时间分配到各个领域,他在这三项技能中分别排在第六位。

图 2-7:排名输出的非线性——专注力强大力量的战略解释

每个领域中的不成比例的回报也存在。例如,Bob 可能花时间阅读三本通用书籍(我们称它们为Python 入门C++入门Java 入门),而 Alice 则阅读三本深入探讨使用 Python 进行机器学习的书籍(我们称它们为Python 入门使用 Python 进行机器学习入门专家级机器学习)。结果,Alice 会专注于成为机器学习专家,并能为她的专业技能要求更高的薪水。

对编码人员的启示

在编程领域,结果往往比大多数其他领域更加倾斜于顶端。不是 80/20,而是 90/10 或 95/5 的分布更为常见。比尔·盖茨曾说过:“一位优秀的车床操作员的薪资是普通车床操作员的几倍,而一位优秀的软件代码作者的价值是普通软件开发者的 10,000 倍。”盖茨认为,一位优秀的软件开发者和一位普通软件开发者的差距不是 16 倍,而是 10,000 倍!以下是软件领域容易出现极端帕累托分布的几个原因:

  • 一位优秀的程序员能够解决一些普通程序员根本无法解决的问题。在某些情况下,这使得他们的生产力比普通程序员高出无限倍。

  • 一个优秀的程序员可以写出比普通程序员快 10,000 倍的代码。

  • 一位优秀的程序员编写的代码漏洞更少。想想一个安全漏洞对微软声誉和品牌的影响!此外,每一个额外的漏洞都会为后续的代码修改和功能添加带来时间、精力和资金的成本——漏洞带来的不利影响是逐步累积的。

  • 一位优秀的程序员编写的代码更容易扩展,这可能提高成千上万开发人员在软件开发过程后期对该代码进行维护时的生产力。

  • 一位优秀的程序员能够跳出框架思考,找到创造性的解决方案,以避免成本高昂的开发工作,并帮助专注于最重要的事情。

实际上,这些因素的结合在起作用,因此差距可能会更大。

所以,对你来说,关键问题可能是:你如何成为一名优秀的程序员?

程序员的成功指标

不幸的是,"成为一名优秀程序员" 这个说法并不是你可以直接优化的成功指标——这个问题是多维度的。一个优秀的程序员能够迅速理解代码,掌握算法和数据结构,了解不同的技术及其优缺点,能够与他人协作,具备沟通和创造力,保持学习并了解如何组织软件开发过程,并拥有数百项软技能和硬技能。但你不可能在所有方面都做到精通!如果你不专注于少数关键要素,你就会被琐事所淹没。要成为一名优秀的程序员,你必须专注于少数关键的要素。

需要专注的关键活动之一就是编写更多的代码行。你写的代码行数越多,你就会成为更好的程序员。这是一个多维问题的简化:通过优化代理指标(编写更多代码行),你增加了成功达到目标指标(成为一名优秀的软件编写者)的机会。参见图 2-8。

两张柱状图,与本章中的其他类似,表示一个程序员对于如何提升技能只有模糊的概念,但不知道如何进步,而另一个程序员则有明确的成功指标并积极尝试晋升。

图 2-8:编程中的成功指标:编写的代码行数

通过编写更多的代码,你会更好地理解代码,并且你的言谈举止也会更像一名专家程序员。你将吸引更多优秀的程序员加入你的网络,找到更多有挑战性的编程任务,因此你会写更多的代码,并且变得更好。你写的每行代码都会让你获得更多的报酬。你或者你的公司可以外包那些琐碎的任务。

这里有一个你可以每天跟进的 80/20 活动:记录你每天编写的代码行数并加以优化。将其作为一个游戏,至少每天达到你的*均值。

现实世界中的帕累托分布

我们将快速浏览一下帕累托分布在现实世界中的一些例子。

GitHub 仓库 TensorFlow 贡献

我们可以看到 GitHub 仓库贡献中的帕累托分布极端示例。让我们以一个广受欢迎的 Python 机器学习计算库仓库:TensorFlow 为例。图 2-9 展示了该 GitHub 仓库的前七名贡献者。表 2-2 以数字形式展示了相同的数据。

GitHub 上 TensorFlow 仓库的截图,显示一个用户的提交次数超过 20,000 次,而其他用户的提交次数都少于 1500 次。

图 2-9:GitHub TensorFlow 仓库提交分布

表 2-2:TensorFlow 提交次数及其贡献者

贡献者 提交次数
tensorflower-gardener 21,426
yongtang 1,251
mrry 1,120
gunan 1,091
River707 868
benoitsteiner 838
sanjoy 795

用户tensorflow-gardener贡献了这个代码库中 93,000 次提交的超过 20%。考虑到有成千上万的贡献者,这个分布比 80/20 分布要极端得多。原因在于,贡献者tensorflow-gardener实际上是谷歌的一个开发团队,这个团队创造并维护了这个代码库。然而,即使过滤掉这个团队,其余的顶尖个人贡献者也是非常成功的程序员,拥有令人印象深刻的履历。你可以在公开的 GitHub 页面上查看他们。许多人已经找到为非常有吸引力的公司工作的激动人心的职位。无论他们是在大量提交开源代码库之前还是之后成功的,这只是一个理论上的讨论。从实际角度来看,你应该开始培养自己的成功习惯:每天写更多的代码。从现在开始,什么也不会阻止你成为 TensorFlow 代码库的第二名——只要在接下来的两到三年里,每天提交两到三次有价值的代码到 TensorFlow 代码库。如果你坚持下去,你就能通过选择一个强大的习惯并坚持几年,加入世界上最成功的程序员行列!

程序员净资产

果然,程序员的净资产也呈现帕累托分布。出于隐私原因,很难获取个人的净资产数据,但网站www.networthshare.com/确实展示了包括程序员在内的各类职业的自报净资产。数据有点杂乱,但它显示了现实世界中帕累托分布的独特偏斜性(图 2-10)。

条形图显示程序员用户名在 X 轴,净资产最高可达 1600 万在 Y 轴。排名第一的用户的净资产比第二名高出超过千万,之后净资产迅速下降。

图 2-10:60 位程序员的自报净资产

在我们这个小样本 29 个数据点中,确实有相当多的软件百万富翁!但在现实世界中,曲线可能会更加偏斜,因为也有很多亿万富翁程序员——马可·扎克伯格、比尔·盖茨、埃隆·马斯克和史蒂夫·沃兹尼亚克等人不禁浮现在脑海。这些技术天才每个人都亲自创造了他们服务的原型,动手编写了源代码。最*,我们在区块链领域也见到了更多这样的软件亿万富翁。

自由职业项目

自由职业开发领域由两个主流*台主导,程序员可以在这些*台上提供服务,客户也可以雇佣自由职业者:Upwork 和 Fiverr。两个*台的用户和收入每年都呈现两位数增长,且都致力于颠覆全球人才的组织模式。

自由职业开发者的*均收入为每小时$51。但这只是*均水*——自由职业开发者的前 10%赚取的时薪要高得多。在或多或少开放的市场中,收入呈帕累托分布。

我从三个角度观察到这种收入分布的不均衡:(1)作为自由职业者,(2)作为雇佣了数百名自由职业者的客户,(3)作为提供 Python 自由职业教育的课程创作者。大多数学生未能达到甚至是*均的收入潜力,因为他们的坚持时间不到一个月。那些每天坚持做自由职业业务几个月的人通常能达到*均的每小时$51 收入目标。而少数非常有野心并且专注的学生能够达到每小时$100 甚至更高。

那么,为什么有些学生失败而有些学生成功呢?让我们绘制出 Fiverr *台上成功完成的工作数,要求*均评分至少为 4 分(满分 5 分)的自由职业开发者。我关注的是机器学习这一热门领域的图 2-11。我从 Fiverr 网站收集了数据,追踪了在机器学习类别的前两条搜索结果中,71 名自由职业者完成的工作数。不出所料,对我们来说,分布呈现帕累托分布。

直方图,x 轴表示“成功完成的工作数”,y 轴表示“自由职业者人数”。大约 48 个自由职业者完成了 10 个工作,工作完成数迅速下降,只有 10 个自由职业者完成了 20 个工作。

图 2-11:Fiverr *台自由职业者及其完成的工作数的直方图

根据我作为成千上万自由职业学生的教师经验,我发现绝大多数学生完成的工作少于 10 个。我敢肯定,其中很多学生以后会说:“自由职业不行。”对我来说,这个说法像“工作不起作用”或“生意不起作用”一样自相矛盾。这些自由职业学生之所以失败,是因为他们没有足够努力和持之以恒。他们以为可以轻松赚钱,当意识到必须坚持不懈地工作才能成为自由职业的赢家时,他们很快就放弃了。

这种缺乏自由职业坚持的现象,实际上为你提供了一个很好的机会,可以在帕累托分布中向上移动。一个几乎能确保你最终进入自由职业前 1-3%的简单成功标准是:完成更多的工作。坚持更久。任何人都可以做到这一点。你正在阅读这本书,说明你有决心、雄心和动力,成为前 1-3%的自由职业编程专业人士。大多数人缺乏专注力,即使他们有技能、聪明且有很好的社会联系,也无法与专注、敬业、懂得帕累托法则的程序员竞争。

帕累托是分形的

Pareto 分布是分形的。如果你放大,只观察整体分布的一部分,你会看到另一个 Pareto 分布!只要数据不太稀疏,这种情况就成立;如果数据过于稀疏,它就失去了分形性质。例如,单个数据点不能被视为 Pareto 分布。我们在图 2-12 中可以看到这个特性。

一张 Pareto 分布图,y 轴和 x 轴上的曲线都已变成各自的 Pareto 分布图。

图 2-12:Pareto 分布的分形特性

在图 2-12 的中心,是来自图 2-1 的 Pareto 分布。我使用清单 2-1 中的简单 Python 脚本放大了这个 Pareto 分布:

import numpy as np
import matplotlib.pyplot as plt

alpha = 0.7

x = np.arange(100)
y = alpha * x / x**(alpha+1)

plt.plot(x, y)

plt.grid()
plt.title('Pareto Distribution')
plt.show()

清单 2-1:一个交互式脚本,让你放大 Pareto 分布

你可以自己玩代码;只需将其复制到你的 Python 终端并运行。如果你在 Python 终端中执行此操作,你将能够放大 Pareto 分布的不同区域。

Pareto 分布在生活和编程中有各种实际应用,我将在本书中讨论其中的一些,但根据我的经验,对你来说最具变革性的应用将是成为一名80/20 思维者;也就是说,你会不断寻找用更少的资源完成更多任务的方法。请注意,虽然具体的 Pareto 数字——80/20、70/30 或 90/10——在你自己的生活中可能会有所不同,但你可以从生产力和产出分布的分形性质中得到一些启发。例如,总是有这样的事实:少数程序员赚得比其他人多,而且这些高收入者中的一部分人赚得比其他高收入者还要多。只有在数据变得过于稀疏时,这种模式才会停止。以下是一些例子:

  1. 收入:前 20% 的程序员将赚取 80% 的 80% 收入。换句话说,4% 的程序员将赚取 64% 的收入!这意味着,即使你已经属于前 20% 的程序员,你也永远不会被困在当前的财务状况中。(这篇论文只是许多展示收入分布分形特性的论文之一:journalarticle.ukm.my/12411/1/29%20Fatimah%20Abdul%20Razak.pdf。)

  2. 活动:你这周做的 20%的最有影响力的 20%的活动,通常会占到 80%的结果。在这种情况下,0.8%的活动将导致 51%的结果。粗略来说,如果你每周工作 40 小时,那么 20 分钟可能就能占据你工作周的一半成果!一个这样的 20 分钟活动的例子是编写一个自动化商业任务的脚本,每几周节省几个小时的时间,你可以将这些时间投入到其他活动中。如果你是程序员,决定跳过实现一个不必要的功能,可以为你节省数十小时的无谓工作。如果你开始应用一些 80/20 的思维方式,你会迅速在自己的工作中发现许多具有杠杆效应的活动。

  3. 进展:无论你在任何帕累托分布中处于何种位置,通过运用你的成功习惯和专注的力量,“向左移动”都能指数级提高你的产出。只要最优状态尚未达到,总有进步的空间,有机会以更少的投入获得更多成果——即便你已经是一个高度发展的个体、公司或经济体。

让你在帕累托曲线上前进的活动并不总是显而易见的,但它们从来不是随机的。许多人因为认为结果的概率性质使得成功完全是随机的,而放弃了在他们领域中寻找成功衡量标准。这是一个错误的结论!要成为一名大师级程序员,每天少写代码是无法做到的,就像每天少练习棋艺不能让你成为职业棋手一样。其他因素也会发挥作用,但这并不意味着成功是随机的游戏。通过专注于你所在行业的成功衡量标准,你将使概率向你有利。作为一个 80/20 思维者,你是赌场,而赌场通常是赢家。

80/20 实践技巧

让我们通过九个技巧来结束本章,利用帕累托原则的力量。

弄清楚你的成功衡量标准。

  1. 首先定义你的行业。识别你所在行业中最成功的专业人士在做哪些事情做得异常出色,以及你每天可以做哪些任务将你推向前 20%的位置。如果你是程序员,你的成功衡量标准可能是编写的代码行数。如果你是作家,你的成功衡量标准可能是为下一本书写下的字数。创建一个电子表格,每天追踪你的成功衡量标准。把它当作一个游戏,坚持下去并超越自己。设定一个最低标准,直到完成最低标准才结束一天的工作。更好的是,在你没有完成这个标准之前,不要开始一天的工作!

弄清楚你的人生大目标。

  1. 把它们写下来。没有明确的长期目标(例如:10 年目标),你就无法坚持做一件事足够长的时间。你已经看到了,提升帕累托曲线的关键策略之一是:在游戏中坚持更长时间,而参与更少的游戏。

寻找用更少的资源实现相同目标的方法。

  1. 如何在 20%的时间里完成 80%的结果?你能否去除那些占用了 80%时间但只带来 20%成果的活动?如果不能,你能外包它们吗?Fiverr 和 Upwork 是寻找人才的便宜途径,利用他人的技能是值得的。

反思你自己的成功。

  1. 你做了什么事情,取得了很大的成果?你如何能做得更多?

反思你自己的失败。

  1. 如何做得更少,减少那些导致失败的事情?

多读些行业书籍。

  1. 通过阅读更多书籍,你可以模拟实际经验,而无需花费大量的时间和精力亲身经历。你从他人的错误中学习。你学习到新的做事方式。你在你的领域获得了更多的技能。一位高学历的专家程序员能够比初学者快 10 到 100 倍解决问题。在你的领域里,读书可能是成功的关键指标之一,它将把你推向成功。

将大部分时间花在改进和调整现有产品上。

  1. 做这个,而不是发明新产品。再一次,这来自帕累托分布。如果你的业务只有一个产品,你可以将所有精力投入到提升这个产品在帕累托曲线上的位置,为你和你的公司带来指数级增长的成果。但如果你一直创造新产品,而不改进和优化旧产品,你就永远会有低于*均水*的产品。永远不要忘记:大成果是在帕累托分布的左侧找到的。

微笑。

  1. 有些结果看起来很简单,令人惊讶。如果你是一个积极的人,许多事情都会变得更容易。更多的人会与你合作。你会体验到更多的正能量、幸福和支持。微笑是一项高效的活动,具有巨大的影响力且成本低廉。

不要做那些减少价值的事情。

  1. 这些东西包括吸烟、不健康饮食、睡眠不足、饮酒和看太多 Netflix。避免那些让你陷入困境的事情是你最大的一项杠杆点。如果你能避免做那些伤害你的事,你将变得更健康、更快乐、更成功。而且你将有更多的时间和金钱去享受生活中的美好事物:人际关系、大自然和积极的体验。

在下一章,你将学习一个关键概念,帮助你专注于软件的关键特性:你将学习如何构建一个最小可行产品。

资源

让我们来看看本章中使用的来源——可以进一步探索它们,找到更多帕累托原则的应用!

  1. Panagiotis Louridas,Diomidis Spinellis 和 Vasileios Vlachos,“软件中的幂律,”ACM Transactions on Software Engineering and Methodology 18,1 号(2008 年 9 月),doi.org/10.1145/1391984.1391986/

科学证据表明,开源项目的贡献是帕累托分布的:

  1. Mathieu Goeminne 和 Tom Mens,“开源软件活动中的帕累托原则证据,”会议:CSMR 2011 软件质量与可维护性研讨会(SQM),2011 年 1 月,www.researchgate.net/publication/228728263_Evidence_for_the_Pareto_principle_in_Open_Source_Software_Activity/

GitHub 仓库 TensorFlow 中提交分布的来源:

  1. github.com/tensorflow/tensorflow/graphs/contributors/

我关于自由开发者收入分配的博客文章:

  1. Christian Mayer,“Python 自由职业者的小时费率是多少?”Finxter(博客),blog.finxter.com/whats-the-hourly-rate-of-a-python-freelancer/

科学证据表明,开放市场遵循帕累托原则:

  1. William J. Reed,“收入的帕累托法则——解释与扩展,”Physica A: Statistical Mechanics and its Applications 319(2003 年 3 月),doi.org/10.1016/S0378-4371(02)01507-8/

一篇展示收入分配分形特性的论文:

  1. Fatimah Abdul Razak 和 Faridatulazna Ahmad Shahabuddin,“马来西亚家庭收入分配:从分形的角度看,”Sains Malaysianna 47,9 号(2018),dx.doi.org/10.17576/jsm-2018-4709-29/

关于如何通过 Python 作为自由开发者建立副业的信息:

  1. Christian Mayer,“如何建立你的高收入技能 Python。”视频,blog.finxter.com/webinar-freelancer/

  2. Python 自由职业者资源页面,Finxter(博客),blog.finxter.com/python-freelancing/

更深入了解 80/20 思维的力量:

  1. Richard Koch,The 80/20 Principle: The Secret to Achieving More with Less,伦敦:Nicholas Brealey,1997。

美国 10% 的人几乎赚取了 50% 的收入:

  1. Facundo Alvaredo,Lucas Chancel,Thomas Piketty,Emmanuel Saez 和 Gabriel Zucman,World Inequality Report 2018,World Inequality Lab,wir2018.wid.world/files/download/wir2018-summary-english.pdf

北美不到 25%的人在 0 到 10 的幸福评分中给自己打 9 或 10,其中“最糟糕的生活”是 0,“最好的生活”是 10:

  1. John Helliwell, Richard Layard 和 Jeffrey Sachs,编,《2016 年世界幸福报告,更新版》(第一卷)。纽约:可持续发展解决方案网络,worldhappiness.report/ed/2016/

20%的书籍作者可能占据 97%的图书销量:

  1. Xindi Wang, Burcu Yucesoy, Onur Varol, Tina Eliassi-Rad 和 Albert-László Barabási,“书籍成功:在出版前预测图书销量”,《EPJ 数据科学》 8 卷,第 31 期(2019 年 10 月),doi.org/10.1140/epjds/s13688-019-0208-6/

  2. Jordi Prats,“哈利·波特与帕累托的胖尾”,《显著性》(2011 年 8 月 10 日),www.significancemagazine.com/14-the-statistics-dictionary/105-harry-potter-and-pareto-s-fat-tail/

在科学家中,5.2%的人占据了 38%的期刊文章:

  1. Javier Ruiz-Castillo 和 Rodrigo Costas,“29 个广泛科学领域中的个人和领域引用分布”,《信息计量学期刊》 12 卷,第 3 期(2018 年 8 月),doi.org/10.1016/j.joi.2018.07.002/

第三章:构建最小可行产品

本章介绍了一个在埃里克·里斯(Eric Ries)的《精益创业》一书中普及但仍然被低估的著名理念(Crown Business,2011 年)。这个理念是构建一个最小可行产品MVP),即一个只保留最必要功能的产品版本,以便快速测试和验证你的假设,而不必浪费大量时间去实现用户可能根本不会使用的功能。特别是,你将学习如何通过专注于用户确认过的他们需要的功能,来大幅简化软件开发周期,因为这些功能在你的 MVP 中得到了确认。

在本章中,我们将通过分析没有使用 MVP 进行软件开发的陷阱来介绍 MVP。然后我们将更详细地阐述这个概念,并为你提供一些实用的建议,帮助你在自己的项目中使用 MVP,加速进展。

一个问题场景

构建 MVP 的想法是为了应对在隐形模式下编程时出现的问题(见图 3-1)。隐形模式是指在没有寻求潜在用户反馈的情况下,完成一个项目。假设你想出了一个改变世界的绝妙程序创意:一个专为搜索代码而设计的、增强型的机器学习搜索引擎。你开始兴奋地连续几个晚上编写这个创意的代码。

图解展示某人有了一个创意,进入“隐秘世界”进行编程,最终在一百万美元的发布时才现身。

图 3-1:隐形编程模式包括在最终的完善版本发布之前保持应用程序的保密,以期望能迅速取得成功。在大多数情况下,这是一个谬论。

然而,在实际操作中,一次性编写完整应用程序几乎不可能马上取得成功。这种方式的成功结果非常、非常、非常罕见。以下是遵循隐形编程模式更可能的结果:

你很快就开发出原型,但当你尝试你的搜索引擎时,发现推荐结果中的许多搜索词并不相关。当你搜索Quicksort时,你得到一个MergeSort的代码片段,并附有评论# This is not Quicksort。这似乎不对劲。所以,你不断调整模型,但每次你改善一个关键词的结果时,其他搜索结果却出现了新的问题。你始终对结果不满意,而且你觉得无法将你那糟糕的代码搜索引擎展示给世界,原因有三:没人会觉得它有用;第一个用户会因为它不够专业且不够完善而在你的网页上制造负面宣传;你还担心如果竞争对手看到你那个实现不佳的概念,他们会窃取它并做得更好。这些令人沮丧的想法让你失去了信心和动力,应用程序的进展几乎停滞不前。

图 3-2 展示了在隐形模式下编程中可能会出现的问题。

与图 3-1 相同,但现在在不同点上标注了可能出错的地方,包括:失去动力、分心、比预期花费时间更长,然后在发布时成本已经增加到 9700 万,结果我们发现没有产品或市场契合度。

图 3-2:编程隐形模式中的常见陷阱

在这里,我将讨论在隐形模式下工作的六个最常见的陷阱。

动力丧失

在隐形模式下,你和自己的想法独处,怀疑会定期出现。最初,你会抵抗这些怀疑,因为你对项目的热情足够大,但随着你在项目上投入的时间越来越长,怀疑也会随之加剧。也许你遇到了类似的现有工具,或者你开始相信这个项目是做不成的。缺乏动力可能会彻底扼杀你的项目。

另一方面,如果你发布了工具的早期版本,来自早期用户的鼓励性话语可能会激励你坚持下去,而用户的反馈可能会激发你改善工具或克服问题。你获得了外部动力。

分心

当你独自在隐形模式下工作时,日常的干扰很难忽视。你有日常工作,要花时间陪伴家人和朋友,其他的想法也会时常浮现。这些天,注意力是一种稀缺的资源,许多设备和服务都在争夺它。你处于隐形模式的时间越长,在没有完成打磨好的应用之前被干扰的可能性越大。

最小可行产品(MVP)可以通过减少从想法到市场反馈的时间来应对这一点,创造一个能更快反馈的环境,帮助你重新集中注意力。谁知道呢——也许你会发现一些积极的早期用户,他们能够推动应用开发的进展。

超过预定时间

项目完成的另一个强大敌人是错误的规划。假设你估计产品需要 60 小时才能完成,因此你最初计划每天花 2 小时来工作,持续一个月。然而,动力丧失和分心使得你每天*均只能工作 1 小时。进一步的延误是由于你必须进行的研究、外部干扰以及你必须处理的突发事件和 bug。无数因素会延长你预期的项目持续时间,几乎没有什么能缩短它。到第一个月结束时,你距离原计划的目标还远得很,这又回到了动力丧失的恶性循环中。

最小可行产品(MVP)去掉了所有不必要的功能。因此,你的规划错误会减少,进展也会更具可预测性。功能减少意味着出错的可能性也会降低。此外,项目的可预测性越强,你或者投资于项目的人越容易相信项目的成功。投资者和利益相关者喜欢可预测性!

缺乏反馈

假设你克服了低动机并完成了产品。你最终发布它,但什么也没有发生。只有少数用户查看了它,而且他们对它并不热情。任何软件项目最可能的结果就是沉默——没有正面或负面的反馈。一个常见的原因是你的产品没有提供用户所要求的特定价值。在第一次尝试中几乎不可能找到所谓的产品市场契合点。如果在开发过程中没有从真实世界获得任何反馈,你就会开始脱离现实,开发出没人使用的功能。

你的 MVP 将帮助你更快地找到产品与市场的契合点,因为正如你将在本章稍后看到的,基于 MVP 的方法将项目发展为直接解决最迫切的客户需求,增加客户参与的机会,从而提高早期产品版本得到反馈的可能性。

错误的假设

潜伏模式失败的主要原因是你自己的假设。你以一堆假设开始一个项目,比如假设用户是谁,他们的职业是什么,他们面临什么问题,或者他们多频繁地使用你的产品。这些假设往往是错误的,如果没有外部测试,你就会盲目地创造出你的目标受众并不需要的产品。一旦没有反馈或负面反馈,这会侵蚀任何动力。

当我创建我的 Finxter.com 应用程序时,它是通过解决评分的编程难题来学习 Python。我假设大多数用户会是计算机科学专业的学生,因为我曾是(现实情况是:大多数用户并不是计算机科学家)。我假设在我发布应用程序时,用户会自动来(现实情况是:最初没人来)。我假设很多用户会通过社交媒体账号分享他们在 Finxter 上的成功(现实情况是:只有极少数用户分享了他们的编程排名)。我假设用户会提交自己的编程难题(现实情况是:从数十万用户中,只有少数人提交了难题)。我假设用户会喜欢花哨的设计,带有颜色和图片(现实情况是:简单的极客风格设计反而提升了使用行为——见第八章关于简洁设计的内容)。所有这些假设导致了具体的实施决策,浪费了我数十小时甚至数百小时的时间,实施了许多我目标受众根本不需要的功能。如果我当时更懂得,我会在 MVP 中测试这些假设,回应用户反馈,节省时间和精力,并降低项目成功受威胁的可能性。

不必要的复杂性

隐形编程模式的另一个问题是:不必要的复杂性。假设你实现了一个包含四个特性的软体产品(见图 3-3)。你运气不错——市场接受了它。你花了相当多的时间实现这四个特性,并且将积极的反馈视为对这四个特性的强化。所有未来的产品发布都会包含这四个特性——除此之外,还会包含你未来新增的特性。

在隐形模式下构建包含四个特性的产品。发布后,市场响亮地回应“是!”,但我们无法确定四个特性中哪个是热门的。

图 3-3:由四个特性组成的有价值的软件产品

然而,如果你一次性发布四个特性的包,而不是一次发布一到两个特性,你就不知道市场是否会接受,甚至偏好任何特性子集(见图 3-4)。

图 3-3 中相同的四个特性,这次按小组发布并测试,以查看哪种特性组合获得了“是!”回答,哪种组合得到了“不是!”回答

图 3-4:市场接受的特性子集有哪些?

特性 1 可能完全无关紧要,尽管它是你实现过程中花费最多时间的一个特性。同时,特性 4 可能是市场高度需求的有价值特性。从n个特性中可以组成 2n种不同的软件产品组合。如果你把它们作为特性包发布,你怎么可能知道哪个有价值,哪个是浪费时间呢?

实现错误特性的成本已经很高,而发布一堆错误特性则会产生维护不必要特性所带来的累积成本:

  • 更长、更重特性的项目需要更多时间来“加载”整个项目到你的大脑中。

  • 每个特性都有可能引入新的错误。

  • 每一行代码都会增加项目打开、加载和编译的时间成本。

  • 实现特性n需要你检查所有之前的特性 1、2、...、n – 1,以确保特性n不会干扰它们的功能。

  • 每一个新特性都需要新的单元测试,必须在你发布下一个版本的代码之前编译并运行通过。

  • 每新增一个特性,都会让代码库变得更加复杂,新加入项目的程序员理解起来会更加困难,从而增加了学习时间。

这并不是一个详尽的列表,但你明白了要点。如果每个特性都将你未来的实现成本增加x个百分点,维护不必要的特性可能会导致编码生产力的数量级差异。你不能在你的代码项目中系统地保留不必要的特性!

所以,你可能会问:如果编程的隐形模式不太可能成功,那么解决方案是什么?

构建最小可行产品

解决方案很简单:构建一系列 MVP。明确提出一个假设——例如用户喜欢解决 Python 难题——并创建一个只验证这个假设的产品。去除所有不帮助验证这个假设的功能。基于这个特性构建一个 MVP。通过每次发布仅实现一个功能,你能够更深入地理解市场接受哪些功能,哪些假设是正确的。但无论如何,都要避免复杂性。毕竟,如果用户不喜欢解决 Python 难题,那为什么还要继续实施 Finxter.com 网站呢?一旦你在实际市场上测试了你的 MVP,并分析它是否有效,你就可以构建第二个 MVP,添加下一个最重要的功能。通过一系列 MVP 寻找合适产品的策略被称为快速原型开发。每个原型都基于你从前一个版本中学到的经验,每个原型的设计都旨在以最小的时间和精力获取最大的信息。你早发布,频繁发布,以便找到产品-市场契合度,这意味着明确你的目标市场的产品需求和欲望(即使这个目标市场在最初非常小)。

让我们看一个使用代码搜索引擎的例子。你首先提出一个假设进行验证:程序员需要一种搜索代码的方式。想一想,针对你的代码搜索引擎应用,初始的 MVP 可能是什么样子?一个基于 shell 的 API?一个执行数据库查询的后台服务器,查找所有开源 GitHub 项目中的精确单词匹配?第一个 MVP 必须验证主要假设。因此,你决定验证这个假设并获得一些可能查询的见解最简单的方式是建立一个用户界面,而没有复杂的后台功能,自动获取查询的结果。你建立了一个网站,设立了一个输入框,并通过在编码小组和社交媒体上分享你的想法,并花一点钱做广告来吸引一些流量。应用界面很简单:用户输入他们想要搜索的代码并点击搜索按钮。你并不打算对搜索结果进行太多优化;这不是你第一个 MVP 的重点。相反,你决定简单地将谷歌搜索结果进行快速后处理后转发。重点是收集前 100 个搜索查询,找出一些常见的用户行为模式,甚至在你开始开发搜索引擎之前!

你分析数据后发现,90%的搜索查询与错误信息有关;程序员们只是将他们的编码错误复制并粘贴到搜索框中。此外,你发现 90 个查询中有 60 个与 JavaScript 相关。你得出结论,最初的假设得到了验证:程序员确实在搜索代码。然而,你获得了一个宝贵的信息:大多数程序员搜索的是错误信息,而不是比如说函数等内容。根据你的分析,你决定将第二版 MVP 从一个通用的代码搜索引擎缩小到一个错误搜索引擎。通过这种方式,你可以根据实际的用户需求来调整产品,并从一部分程序员那里获得更多互动反馈,从而迅速学习并将你的学习整合到一个有用的产品中。随着你获得越来越多的市场关注和洞察,你总是可以随着时间的推移扩展到其他语言和查询类型。如果没有第一个 MVP,你可能会花费数月时间开发几乎没人使用的功能,比如查找代码中任意模式的正则表达式功能,而牺牲了每个程序员都在使用的功能,比如错误信息搜索。

图 3-5 概述了这一软件开发和产品创建的黄金标准。首先,你通过反复推出 MVP 来找到产品与市场的契合点,直到用户爱上你的产品。MVP 的连续发布随着时间的推移积累了兴趣,并允许你根据用户反馈逐步改进软件的核心理念。一旦你达到了产品与市场的契合点,就开始添加新功能——每次一个功能。只有当一个功能能够证明它能改善关键用户指标时,它才会留在产品中。

右侧:三次迭代的 MVP 实施,每次都通过用户反馈来寻找产品与市场的契合点。左侧:经过正面反馈的 MVP 被扩展并进行拆分测试。

图 3-5:软件开发的两个阶段包括:(1)通过迭代创建 MVP 来找到产品与市场的契合点,并随着时间的推移建立兴趣;(2)通过精心设计的拆分测试,添加和验证新功能来扩展规模。

以 Finxter.com 为例,如果我从一开始就遵循 MVP 规则,我可能会创建一个简单的 Instagram 账号,分享代码难题并检查用户是否喜欢解决它们。在没有验证的情况下花费一年时间开发 Finxter 应用,我本可以花几周甚至几个月时间在社交网络上分享难题。然后,我可以通过与社区互动得到的经验教训,构建一个具有稍多功能的第二版 MVP,比如一个专门托管编程难题及其正确解答的网站。这种方法将使我能在大大缩短时间的同时,避免开发一些不必要的功能。开发去除所有不必要功能的 MVP 这一教训,我是通过亲身经历学到的。

精益创业中,埃里克·里斯讨论了价值十亿美元的公司 Dropbox 如何著名地采用了 MVP 方法。创始人并没有花时间和精力在一个未经验证的想法上,去实现同步文件夹结构到云端的复杂 Dropbox 功能——这一功能需要在不同操作系统中进行紧密集成,并彻底实现一些繁琐的分布式系统概念,比如副本同步——而是通过一个简单的产品视频验证了这个想法,尽管视频中展示的产品当时并不存在。经过无数次迭代,验证过的 Dropbox MVP 加入了更多有助于简化用户生活的核心功能。从那时起,这一概念已经被成千上万的成功软件公司(甚至其他行业)所验证。

如果市场信号表明用户喜欢并重视你的产品创意,那么你就已经通过一个简单、精心设计的 MVP 达到了产品与市场的契合。从这里开始,你可以不断迭代构建和优化你的 MVP。

当你使用基于 MVP 的方法进行软件开发时,逐步添加一个功能,能够识别应该保留哪个功能、拒绝哪个功能至关重要。MVP 软件创建过程的最后一步是分割测试:与其将带有新功能的迭代版本发布给全部用户,不如先将新产品发布给一小部分用户,并观察他们的隐性和显性反应。只有当你喜欢看到的结果时——例如,用户在你网站上的*均停留时间增加——你才会保留这个功能。否则,你就拒绝它,并继续使用没有这个功能的上一个版本。这意味着你必须牺牲开发这个功能所花费的时间和精力,但它确实让你保持产品尽可能简单,从而保持灵活、敏捷和高效。通过使用分割测试,你能够进行数据驱动的软件开发。

最小可行产品的四大支柱

在基于 MVP 思维构建第一个软件时,请考虑以下四个支柱:

  1. 功能性 产品向用户提供了一个明确的功能,并且做得很好。这个功能不必具备很高的经济效率。你为聊天机器人设计的 MVP 可能实际上只是你自己与用户聊天;这显然无法扩展,但你展示了高质量聊天的功能——即使你还没找到如何以经济可行的方式提供这一功能。

  2. 设计 产品设计良好且聚焦,其设计支持你的产品为目标用户群体提供的价值。一个常见的 MVP 生成错误是创建一个界面,未能准确反映你单一功能的 MVP 网站。设计可以是简单直接的,但必须支持价值主张。想想 Google 搜索——他们在发布搜索引擎的第一个版本时,确实没有花费大量精力在设计上,但该设计却非常适合他们提供的产品:无干扰的搜索体验。

  3. 可靠性 仅仅因为你的产品是极简的,并不意味着它就不可靠。确保写出测试用例并严格测试你代码中的所有功能。否则,你从 MVP 中获得的学习将会被基于其不可靠性的负面用户反馈所污染,而非针对功能的反馈。记住:你想在最小的努力下最大化学习效果。

  4. 可用性 MVP 必须易于使用。功能清晰明了,设计也要支持这一点。用户不需要花费大量时间去弄清楚该做什么或点击哪个按钮。MVP 需要足够响应迅速且快速,以便允许流畅的交互。对于一个专注且极简的产品来说,这通常更容易实现:当页面只有一个按钮和一个输入框时,如何使用页面是显而易见的。同样,Google 搜索引擎的初始原型就是一个典型例子,其可用性强到足以维持了超过二十年。

许多人误解了 MVP 的这一特性:他们错误地认为,MVP 作为产品的极简版本,必定提供较少的价值、差的可用性和懒散的设计。然而,极简主义者知道,MVP 的简洁实际上源于对一个核心功能的严格聚焦,而非懒散的产品创造。以 Dropbox 为例,制作一个有效展示意图的视频,比实现该服务本身更容易。MVP 是一个高质量的产品,具有出色的功能、设计、可靠性和可用性。

最小可行产品(MVP)的优势

MVP 驱动的软件设计有着多方面的优势。

  • 你可以以最便宜的方式测试你的假设。

  • 你通常可以在确认确实需要时再编写代码,而当你开始编写代码时,可以最大程度地减少在收集真实世界反馈之前所做的工作量。

  • 你花费更少的时间编写代码和找寻 bug——而你所花的时间对你的用户来说将会非常有价值。

  • 你发布给用户的任何新特性都会提供即时反馈,持续的进展让你和你的团队保持动力,源源不断地推出新特性。这大大降低了你在编程隐形阶段所面临的风险。

  • 你将大幅降低未来的维护成本,因为 MVP 方法通过减少代码库的复杂性,为你未来的所有特性提供了更简单、更少出错的开发路径。

  • 你将取得更快的进展,并且在整个软件生命周期中,实施将变得更加容易——这会让你保持动力,并走向成功之路。

  • 你将更快地推出产品,更快地从软件中赚取收入,并且更可预测、可靠地建立你的品牌。

隐秘编程 vs. 最小可行产品方法

一个常见的反对快速原型制作、支持隐秘编程的论点是,隐秘编程可以保护你的创意。人们认为他们的创意是独特而特别的,如果他们以原始形式发布,即 MVP,它就会被那些能够更快实现它的大公司偷走。坦率地说,这是一个谬论。创意便宜,执行才是王道。任何一个创意都不太可能是独一无二的,很有可能你想出来的创意已经被其他人想过。隐秘编程并不会减少竞争,反而可能会促使其他人也开始开发同样的创意,因为像你一样,他们认为没有别人已经想到这个点子。要使一个创意成功,必须有一个人将它变为现实。如果快进几年,最终成功的人会是那个采取快速、果断行动、早发布、频繁发布、根据真实用户的反馈进行改进并利用之前发布的动力逐步完善软件的人。将创意保密只是限制了它的成长潜力。

结论

在编写代码之前,设想你的最终产品,并考虑用户的需求。专注于你的 MVP,确保它有价值、设计合理、响应迅速且易于使用。去除所有不必要的功能,仅保留实现目标所必需的功能。一次专注于一项任务。然后,快速且频繁地发布 MVP——随着时间的推移,通过逐步测试和添加更多功能来改进它们。少即是多!比起实际实施每个功能,花更多时间思考下一个功能如何实现。每个功能不仅带来直接的实现成本,还会为未来的所有功能带来间接成本。通过分割测试同时测试两个产品变体,并快速舍弃那些没有带来关键用户指标(如留存率、页面停留时间或活跃度)提升的功能。这会使你对业务采取更全面的方式——认识到软件开发只是整个产品创作和价值传递过程中的一步。

在下一章中,你将学习为什么以及如何编写干净简洁的代码,但请记住:不编写不必要的代码是实现干净简洁代码的最可靠途径!

第四章:编写干净且简洁的代码

干净的代码是易于阅读、理解和修改的代码。它简洁且简明扼要,只要这些特性不妨碍可读性。虽然编写干净的代码更多是一种艺术而非科学,但软件工程行业已经达成一致,提出了多项原则,如果遵循这些原则,将帮助你编写更干净的代码。在本章中,你将学习 17 个编写干净代码的原则,它们将显著提高你的生产力并解决复杂性问题。

你可能会想知道干净代码和简洁代码之间的区别。这两个概念紧密相关,因为干净的代码往往是简洁的,简洁的代码往往是干净的。但也有可能遇到复杂的代码,它仍然是干净的。简洁性关注的是避免复杂性,而干净的代码则更进一步,关注如何管理不可避免的复杂性——例如,通过有效使用注释和标准。

为什么要编写干净的代码?

在前面的章节中,你学到了复杂性是任何代码项目的头号公敌。你还学到了简洁性能够提高你的生产力、动机以及代码库的可维护性。在本章中,我们将进一步扩展这个概念,并展示如何编写干净的代码。

干净的代码更容易被未来的自己和其他同事理解,因为人们更可能为干净的代码做出贡献,协作的潜力也会增加。因此,干净的代码可以显著降低项目成本。正如 Robert C. Martin 在他的书《Clean Code》(Prentice Hall,2008)中所指出的,程序员花费绝大多数时间阅读旧代码,以便编写新代码。如果旧代码易于阅读,那么这一过程将大大加速。

确实,阅读与编写代码的时间比例远远超过 10:1。我们在编写新代码的过程中,持续不断地阅读旧代码。[因此,]使代码易于阅读,也使得编写代码变得更容易。

如果我们字面理解这个比例,这个关系在图 4-1 中得到了可视化。x 轴对应于在一个代码项目中编写的行数,y 轴对应于编写一行额外代码所需的时间。一般来说,项目中已编写的代码越多,写一行额外代码所需的时间就越长。这对于干净代码和脏代码都适用。

假设你已经写了n行代码,并且你添加了第n + 1 行代码。添加这一行可能会影响所有之前写的代码行。它可能会带来小的性能损失,从而影响整个项目。它可能会使用在其他地方定义的变量。它可能会引入一个 bug(概率为c),要找到这个 bug,你必须搜索整个项目。这意味着你每行代码的预期时间——因此,成本——是c * T(n),其中时间函数T随着输入n的增加而稳定增长。添加一行代码还可能迫使你编写额外的代码行,以确保向后兼容性。

更长的代码可能会引入许多其他复杂性,但你已经明白了这个要点:你写的代码越多,额外的复杂性就越大,这会减慢你的进度。

折线图,横轴为“编写的代码行数”,纵轴为“每增加一行的时间”。“快速而脏的代码”在每行所需的时间迅速增加,而“深思熟虑的干净代码”则保持*稳的速率。

图 4-1:干净的代码提高了代码库的可扩展性和可维护性。

图 4-1 同样展示了写脏代码与干净代码之间的差异。脏代码在短期内和小型代码项目中消耗的时间较少——如果写脏代码没有好处,没人会去做!如果你将所有功能压缩到一个 100 行的代码脚本中,你就不需要投入大量时间思考和重构你的项目。问题只有在你添加更多代码时才会出现:随着你的单一代码文件从 100 行增长到 1000 行,它的效率将不如采用更有思考性的方式,按照逻辑结构将代码分成不同模块、类或文件来开发的代码。

一条经验法则是:总是编写深思熟虑且干净的代码。重新思考、重构和重整代码的额外成本将在任何非琐碎项目中带来多倍的回报。有时,赌注可能相当高:1962 年,美国国家航空航天局(NASA)尝试将一艘航天器送往金星,但由于源代码中缺少一个连字符,一个微小的 bug 导致工程师发出了自毁命令,最终导致一枚价值超过 1800 万美元的火箭损失。如果代码更干净,工程师可能会在发射前发现这个错误。

无论你是否在做火箭科学,精心编写程序的理念都能让你在人生中走得更远。简洁的代码也有利于将项目扩展到更多程序员和更多功能,因为较少的程序员会被项目的复杂性吓跑。

那么,让我们来学习如何编写简洁干净的代码吧!

编写干净代码:原则

我在从零开始开发一个分布式图处理系统作为博士研究的一部分时,以一种非常艰难的方式学会了编写干净的代码。如果你曾经编写过分布式应用程序——两个进程分别驻留在不同的计算机上,通过消息互相通信——你就知道这种复杂性可以迅速变得让人不堪重负。我的代码增长到几千行,错误频繁出现。好几个星期我都没有任何进展;这让我感到非常沮丧。理论上的概念很有说服力,但不知为何它们在我的实现中并没有奏效。

最终,在大约一个月的时间里,我全职工作在代码库上,却没有看到任何令人鼓舞的进展,于是我决定彻底简化代码库。除了其他改动,我开始使用现成的库,而不是自己编写功能。我删除了那些曾经注释掉、可能以后会用到的代码块。我重命名了变量和函数。我将代码结构化成逻辑单元,并创建了新类,而不是将所有东西塞进一个“上帝”类中。大约一周后,我的代码不仅对于其他研究人员来说更加易读和易懂,而且也更加高效,出错率更低。我的沮丧转变为热情——干净的代码救了我的研究项目!

改进代码库并减少复杂性叫做重构,如果你想编写干净简洁的代码,它必须成为你的软件开发过程中一个有计划且至关重要的环节。编写干净代码主要是记住两件事:知道从头开始构建代码的最佳方法,并定期回去进行修订。我将在接下来的 17 条原则中介绍一些保持代码干净的重要技巧。虽然每条原则涵盖了编写更干净代码的独特策略,但其中一些原则是重叠的,我觉得将重叠的原则合并会降低清晰度和可操作性。既然这些已经讲完,我们就开始第一个原则吧!

原则 1:思考大局

如果你在做一个非简单项目,你很可能会遇到多个文件、模块和库在整体应用中一起工作。你的软件架构定义了软件元素如何相互作用。良好的架构决策可以带来巨大的性能、可维护性和可用性的提升。要建立一个好的架构,你需要退后一步,思考整体局面。首先决定需要的功能。在第三章关于构建最小可行产品(MVP)中,你学会了如何将项目聚焦于必要的功能。如果你这样做了,你会节省很多工作,并且代码会更加简洁有序。到这个阶段,我们假设你已经创建了第一个包含多个模块、文件和类的应用程序。你如何运用整体思维来给这些混乱的代码带来一些秩序?考虑以下问题可以给你一些关于如何清理代码的思路:

  • 你需要所有独立的文件和模块吗,还是可以合并其中一些,减少代码之间的相互依赖?

  • 你能将一个大而复杂的文件拆分成两个更简单的文件吗?请注意,通常在两种极端之间会有一个“甜点”区域:一个庞大、单一的代码块完全无法阅读,或者是无数个小代码块,你很难一一追踪。两者都不可取,而大多数介于两者之间的阶段会是更好的选择。可以将其视为一个倒 U 形曲线,最大值代表了少数大代码块和大量小代码块之间的甜点区域。

  • 你能将代码通用化并将其转化为库,从而简化主应用程序吗?

  • 你能使用现有的库来消除许多代码行吗?

  • 你能使用缓存来避免一次又一次地重新计算相同的结果吗?

  • 你能使用更直接和更合适的算法来实现与你当前算法相同的功能吗?

  • 你能去除那些不会提升整体性能的过早优化吗?

  • 你能使用其他更适合当前问题的编程语言吗?

从大局思考是一种高效的方式,可以大幅度降低整个应用程序的复杂性。有时候,在后续的不同阶段实施这些改变会很困难,或者可能因为合作影响而无法进行。特别是,对于像 Windows 操作系统这样有数百万行代码的应用程序,这种高层次的思维方式可能会变得很困难。然而,你绝对不能完全忽视这些问题,因为所有的小调整加起来也无法弥补错误或懒惰设计选择带来的负面影响。如果你在一个小型创业公司工作,或者仅仅为自己工作,你通常可以迅速做出大胆的架构决策,比如改变算法。如果你在一个大组织中工作,你的灵活性可能会小一些。应用程序越大,你越容易找到简单的修复和容易解决的问题。

原则 2:站在巨人的肩膀上

发明轮子很少是有价值的。编程已经是一个有几十年历史的行业。世界上最优秀的程序员为我们提供了伟大的遗产:一个包含数百万个精细调优和经过充分测试的算法和代码函数的数据库。访问数百万程序员的集体智慧,就像使用一行导入语句那么简单。在你自己的项目中,完全没有理由不利用这个超级能力。

使用库代码可能会提高你的代码效率。已经被成千上万的程序员使用过的函数往往比你自己写的更加优化。而且,库函数调用通常比你自己编写的代码更容易理解,占用的代码空间也更少。例如,假设你需要一个聚类算法来可视化客户的聚类。你可以通过从外部库导入一个经过良好测试的聚类算法并将数据传入其中,站在巨人的肩膀上。这比使用你自己的代码更为高效——它将以更少的错误、更少的空间和更高效的代码实现相同的功能。库是高级程序员用来千倍提高生产力的主要工具之一。

作为一个能够节省时间的库代码示例,下面是从 scikit-learn Python 库中导入 KMeans 模块以在存储在变量 X 中的给定数据集上找到两个聚类中心的两行代码:

from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=2, random_state=0).fit(X)

如果你自己实现 KMeans 算法,可能需要几个小时,且很可能需要超过 50 行代码,甚至会让你的代码库变得杂乱无章,使得未来的代码实现更加困难。

原则 3:为人编程,而不是为机器编程

你可能认为源代码的主要目的是定义机器应该做什么以及如何做。其实并非如此。像 Python 这样的编程语言的唯一目的是帮助人类编写代码。编译器完成繁重的工作,将你写的高级代码翻译成机器能理解的低级代码。是的,你的代码最终会由机器执行。但代码仍然主要是由人编写的,在今天的软件开发过程中,代码很可能必须经过多层人类的判断才能被部署。最重要的是,你写代码是为了人,而不是机器。

总是假设别人会阅读你的源代码。想象一下,你转到一个新项目,其他人不得不接手你的代码库。为了让他们的工作更轻松,减少挫败感,有很多方法可以做到。首先,使用有意义的变量名,这样读者就能轻松理解每一行代码的意图。列表 4-1 展示了一个使用不当变量名的例子。

xxx = 10000
yyy = 0.1
zzz = 10

for iii in range(zzz):
    print(xxx * (1 + yyy)**iii)

列表 4-1:使用不恰当变量名的代码

很难猜测这段代码计算了什么。列表 4-2 则是一个语义等效的代码,使用了有意义的变量名。

investments = 10000
yearly_return = 0.1
years = 10

for year in range(years):
    print(investments * (1 + yearly_return)**year)

列表 4-2:使用有意义变量名的代码

在这里理解发生了什么要容易得多:变量名表明如何计算一个初始投资 10000 的价值,这笔投资在 10 年内复利增长,假设年回报率为 10%。

虽然我们在这里不会讨论实现这一原则的每一种方式(尽管后面的原则会更详细地讨论一些方法),但它也体现在其他可以明确意图的方面,例如缩进、空格、注释和行长度等。干净的代码极大地优化了人类可读性。正如软件工程国际专家、畅销书《重构》的作者马丁·福勒所言:“任何傻瓜都能写出计算机能理解的代码。优秀的程序员写的是人类能理解的代码”(Addison-Wesley,1999 年)。

原则 4:使用正确的名称

相关的,经验丰富的程序员通常会就函数、函数参数、对象、方法和变量的命名约定达成共识,无论是隐性还是显性。遵守这些约定对每个人都有好处:代码变得更易读、更易理解,也更简洁。如果你违反这些约定,阅读你代码的人很可能会认为这段代码是由一个没有经验的程序员写的,并可能不会认真对待你的代码。

这些约定可能因编程语言而异。例如,按约定,Java 使用 camelCaseNaming 来命名变量,而 Python 使用 underscore_naming 来命名变量和函数。如果你在 Python 中使用驼峰命名法,可能会让读者感到困惑。你不希望你的非传统命名约定分散读者的注意力。你希望他们关注的是代码的功能,而不是你的编码风格。正如最小惊讶原则所述,通过选择不常见的变量名来让其他开发者感到惊讶没有任何价值。

那么,接下来我们来看看在编写源代码时可以考虑的命名规则。

  1. 选择描述性名称 假设你创建了一个函数,用来将美元(USD)转换为欧元(EUR)。应该命名为 usd_to_eur(amount),而不是 f(x)

  2. 选择不含歧义的名称 你可能会认为 dollar_to_euro(amount) 是一个不错的货币转换函数名。虽然它比 f(x) 更好,但它比 usd_to_eur(amount) 差,因为它引入了不必要的歧义。你是指美元、加元,还是澳元?如果你在美国,答案可能对你来说很明显,但澳大利亚的开发者可能不知道代码是用美国的美元编写的,可能会假设不同的输出。尽量减少这些混淆!

  3. 使用易读的名称 大多数开发者在阅读代码时,都会在脑中默读。如果一个变量名无法发音,那么解读它的过程就会占用注意力,消耗宝贵的脑力空间。例如,变量名 cstmr_lst 可能很描述性且不含歧义,但它无法发音。选择变量名 customer_list 会让代码更容易理解,尽管它多占用了些空间。

  4. 使用命名常量,而非魔法数字 在你的代码中,你可能会多次使用魔法数字 0.9 作为因子,将美元金额转换为欧元金额。然而,代码的读者——包括未来的你——必须思考这个数字的目的。这个数字并不是自解释的。处理魔法数字 0.9 的一个更好的方法是将其存储在一个全大写的变量中——用来表示它是一个不会改变的常量——比如 CONVERSION_RATE = 0.9,并在转换计算中使用它作为因子。例如,你可以通过 income_euro = CONVERSION_RATE * income_usd 来计算你的欧元收入。

这些只是一些命名规则。除了这些快速提示,学习命名约定的最佳方式是研究专家们精心编写的代码。搜索相关的命名约定(例如,“Python 命名约定”)是一个很好的起点。你还可以阅读编程教程,加入 StackOverflow 向其他程序员请教,查看开源项目的 GitHub 代码,或者加入 Finxter 博客社区,与其他有抱负的程序员一起互相学习,提高编程技能。

原则五:遵循标准并保持一致性

每种编程语言都会有一套隐式或显式的规则来指导如何编写整洁的代码。如果你是一个活跃的程序员,这些标准最终会成为你必须遵守的要求。然而,你可以通过花时间研究你正在学习的编程语言的代码标准来加速这一过程。

例如,你可以通过这个链接访问官方的 Python 风格指南 PEP 8:www.python.org/dev/peps/pep-0008/。和任何风格指南一样,PEP 8 定义了正确的代码布局和缩进方法;如何设置换行符;每行最大字符数;正确使用注释;编写函数文档的方法;以及命名类、变量和函数的约定。例如,列表 4-3 展示了 PEP 8 指南中的正面示例,展示了如何正确使用不同的样式和约定。你需要为每个缩进级别使用四个空格,始终对齐函数参数,在列出逗号分隔的参数列表时使用单个空格,并通过使用下划线将多个单词结合起来来正确命名函数和变量:

# Aligned with the opening delimiter.
foo = long_function_name(var_one, var_two,
                         var_three, var_four)

# Add 4 spaces (an extra level of indentation) to distinguish 
# arguments from the rest.
def long_function_name(
        var_one, var_two, var_three,
        var_four):
    print(var_one)

# Hanging indents should add a level.
foo = long_function_name(
    var_one, var_two,
    var_three, var_four)

列表 4-3:根据 PEP 8 标准在 Python 中使用缩进、空格和命名

列表 4-4 展示了错误的做法。参数没有对齐,多个单词在变量和函数名称中没有正确结合,参数列表没有用单个空格正确分隔,缩进级别只有两个或三个空格,而不是四个:

# Arguments on first line forbidden when not using vertical alignment.
foo = longFunctionName(varone,varTwo,
   var3,varxfour)

# Further indentation required as indentation is not distinguishable.
def longfunctionname(
  var1,var2,var3,
  var4):
  print(var_one)

列表 4-4:在 Python 中错误使用缩进、空格和命名

所有阅读你代码的人都会期望你遵循已接受的标准。否则会导致混淆和挫败感。

然而,阅读风格指南可能是一个乏味的任务。作为一种不那么枯燥的学习约定和标准的方法,使用能够告诉你在哪里以及如何犯错的代码检查工具(如 linters)和集成开发环境(IDEs)。在与我的 Finxter 团队参加一个周末黑客马拉松时,我们创建了一个工具,叫做 Pythonchecker.com,它以有趣的方式帮助你将 Python 代码从凌乱的状态重构为超级整洁。对于 Python,最好的相关项目之一是 PyCharm 的 black 模块。类似的工具适用于所有主要的编程语言。只需在网上搜索 Linter,就可以找到适合你编程环境的最佳工具。

原则 6:使用注释

如前所述,在为人类而不是机器编写代码时,你需要使用注释来帮助读者理解代码。考虑一下 列表 4-5 中没有注释的代码。

import re

text = '''
    Ha! let me see her: out, alas! She's cold:
    Her blood is settled, and her joints are stiff;
    Life and these lips have long been separated:
    Death lies on her like an untimely frost
    Upon the sweetest flower of all the field.
'''

 f_words = re.findall('\\bf\w+\\b', text)
print(f_words)

l_words = re.findall('\\bl\w+\\b', text)
print(l_words)

'''
OUTPUT:
['frost', 'flower', 'field']
['let', 'lips', 'long', 'lies', 'like']

'''

列表 4-5:没有注释的代码

列表 4-5 分析了莎士比亚的 罗密欧与朱丽叶 中的一段短文本,通过正则表达式进行处理。如果你不熟悉正则表达式,你可能会很难理解这段代码的作用。即使是有意义的变量名称也没多大帮助。

让我们看看几个注释是否能够解决你的困惑(见 Listing 4-6)。

import re

text = '''
    Ha! let me see her: out, alas! She's cold:
    Her blood is settled, and her joints are stiff;
    Life and these lips have long been separated:
    Death lies on her like an untimely frost
    Upon the sweetest flower of all the field.
'''

❶ # Find all words starting with character 'f'.
f_words = re.findall('\\bf\w+\\b', text)
print(f_words)

❷ # Find all words starting with character 'l'.
l_words = re.findall('\\bl\w+\\b', text)
print(l_words)

'''
OUTPUT:
['frost', 'flower', 'field']
['let', 'lips', 'long', 'lies', 'like']
'''

Listing 4-6:带注释的代码

这两个简短的注释 (❶ ❷) 解释了正则表达式模式 '\\bf\w+\\b''\\bl\w+\\b' 的目的。我不会在这里深入探讨正则表达式,但这个例子展示了注释如何帮助你大致理解他人的代码,而无需理解那些语法糖。

你还可以使用注释来概括一段代码。例如,如果你有五行代码涉及更新数据库中的客户信息,在这段代码之前加上简短的注释来解释这一过程,像 Listing 4-7 中所示。

❶ # Process next order
order = get_next_order()
user = order.get_user()
database.update_user(user)
database.update_product(order.get_order())

❷ # Ship order & confirm customer
logistics.ship(order, user.get_address())
user.send_confirmation()

Listing 4-7:注释块概述了代码

这展示了一个在线商店如何通过两个高级步骤完成客户订单:处理下一个订单 ❶ 和发货 ❷。注释帮助你快速理解代码的目的,而不需要逐一解读每个方法调用。

你还可以使用注释提醒程序员潜在的不可取后果。例如,Listing 4-8 提醒我们调用函数 ship_yacht() 会将一艘昂贵的游艇发货给客户。

##########################################################
# WARNING                                                #
# EXECUTING THIS FUNCTION WILL SHIP A $1,569,420 YACHT!! #
##########################################################
def ship_yacht(customer):
    database.update(customer.get_address())
    logistics.ship_yacht(customer.get_address())
    logistics.send_confirmation(customer)

Listing 4-8:作为警告的注释

你可以以更多有用的方式使用注释;它们不仅仅是正确应用标准。当编写注释时,始终将“为人类编写代码”的原则放在心中,你就会做得很好。随着你阅读经验丰富程序员的代码,你会逐渐并几乎自动地吸收那些不言而喻的规则。因为你是自己编写代码的专家,帮助性的注释让外人能够一窥你的思路。别忘了与他人分享你的见解!

原则 7:避免不必要的注释

话虽如此,并不是所有的注释都能帮助读者更好地理解代码。在某些情况下,注释实际上会减少代码的清晰度,反而让代码库的读者感到困惑。为了编写干净的代码,你不仅要使用有价值的注释,还要避免不必要的注释。

在我作为计算机科学研究员的期间,我的一位技术高超的学生成功地申请到了谷歌的工作。他告诉我,谷歌的猎头批评了他的代码风格,因为他添加了太多不必要的注释。评估你的注释是另一个专家级编码人员用来判断你是初学者、中级还是高级程序员的方式。代码中的问题,例如违反风格指南、懒惰或草率地写注释,或者为某个编程语言写出不符合习惯的代码,统称为代码异味,这些都会暴露出潜在的问题,专家级的程序员能一眼识别。

如何判断哪些注释是多余的?通常情况下,如果注释是冗余的,那它就是不必要的。例如,如果你使用了有意义的变量名,代码通常可以自解释,不需要逐行注释。我们来看一下清单 4-9 中使用有意义变量名的代码片段。

investments = 10000
yearly_return = 0.1
years = 10

for year in range(years):
    print(investments * (1 + yearly_return)**year)

清单 4-9:使用有意义变量名的代码片段

代码已经很明确地计算了你在 10 年内的累计投资回报,假设收益率为 10%。为了论证这一点,让我们在清单 4-10 中添加一些不必要的注释。

investments = 10000 # Your investments, change if needed
yearly_return = 0.1 # Annual return (e.g., 0.1 --> 10%)
years = 10 # Number of years to compound

# Go over each year
for year in range(years):
    # Print value of your investment in current year
    print(investments * (1 + yearly_return)**year)

清单 4-10:不必要的注释

清单 4-10 中的所有注释都是多余的。如果你选择了不太有意义的变量名,它们中的一些可能会有用,但用注释解释一个名为 yearly_return 的变量(表示年回报)只会增加不必要的杂乱。

一般来说,你应该凭常识来判断注释是否必要,但以下是一些主要的指南。

  1. 不要使用内联注释。通过选择有意义的变量名,这些注释完全可以避免。

  2. 不要添加显而易见的注释。在清单 4-10 中,解释 for 循环语句的注释是多余的。每个程序员都知道 for 循环,因此给出注释 # 遍历每一年 与表达式 for year in range(years) 一起并没有增加额外的价值。

  3. 不要注释掉旧代码;直接删除它。我们程序员常常在决定删除心爱的代码片段时,会仅仅将它们注释掉,尽管我们已经(不情愿地)决定移除它们。这会破坏代码的可读性!始终删除不必要的代码——为了心安理得,你可以使用版本历史工具(如 Git)来保存项目的早期草稿。

  4. 使用文档功能,许多编程语言,如 Python,内置了文档功能,可以让你描述代码中每个函数、方法和类的目的。如果这些内容各自只有单一责任(根据原则 10),通常使用文档而非注释来描述代码功能已经足够。

原则 8:最小惊讶原则

最小惊讶原则指出,系统的一个组件应该以大多数用户预期的方式运行。这一原则是设计有效应用程序和用户体验的黄金法则之一。例如,当你打开 Google 搜索引擎时,光标会自动定位到搜索输入框,以便你可以立即开始输入搜索关键词,正如你所预期的那样:没有惊讶。

干净的代码也遵循这个设计原则。假设你编写了一个货币转换器,将用户输入的美元金额转换为人民币。你将用户的输入存储在一个变量中。哪个变量名更合适,user_input 还是 var_x?最小惊讶原则为你解答了这个问题!

原则 9:不要重复自己

不要重复自己(DRY)是一个广泛认可的原则,直观地建议避免重复代码。例如,考虑列表 4-11 中的 Python 代码,它将相同的字符串五次打印到终端。

print('hello world')
print('hello world')
print('hello world')
print('hello world')
print('hello world')

列表 4-11:打印hello world五次

列表 4-12 中展示的代码大大减少了重复。

for i in range(5):
    print('hello world')

列表 4-12:减少列表 4-11 中的重复

列表 4-12 中的代码将打印hello world五次,就像列表 4-11 所做的那样,但没有冗余。

函数也可以是减少重复的有用工具。假设你需要在代码的多个地方将英里转换为千米,如列表 4-13 所示。

首先,你创建一个变量miles并通过将其乘以 1.60934 将其转换为千米。其次,你通过将 20 乘以 1.60934 将 20 英里转换为千米,并将结果存储在变量distance中。

miles = 100
kilometers = miles * 1.60934

distance = 20 * 1.60934

print(kilometers)
print(distance)

'''
OUTPUT:
160.934
32.1868
'''

列表 4-13:将英里转换为千米两次

你已经通过将英里值乘以因子 1.60934 两次来完成英里到千米的转换。DRY 建议,最好像列表 4-14 中那样写一个miles_to_km(miles)函数,而不是在代码中多次显式执行相同的转换。

def miles_to_km(miles):
    return miles * 1.60934

miles = 100
kilometers = miles_to_km(miles)

distance = miles_to_km(20)

print(kilometers)
print(distance)

'''
OUTPUT:
160.934
32.1868
'''

列表 4-14:使用函数将英里转换为千米

这样,代码更易于维护。例如,你可以调整函数以提高转换的准确性,而只需要在一个地方进行更改。在列表 4-13 中,你需要在整个代码中查找所有实例来进行改进。应用 DRY 原则还能使代码更容易被人类读者理解。miles_to_km(20)函数的用途几乎没有疑问,但你可能需要更费劲地思考20 * 1.60934计算的目的。

违反 DRY 原则通常缩写为 WET:我们喜欢打字重复写所有内容浪费每个人的时间

原则 10:单一职责原则

单一职责原则意味着每个函数应该有一个主要任务。使用许多小函数比使用一个同时完成所有任务的大函数更好。功能的封装减少了整体代码的复杂性。

一般来说,每个类和每个函数应该只有一个职责。这个原则的发明者 Robert C. Martin 将职责定义为更改的理由。因此,在定义一个类和一个函数时,他的黄金标准是让它们专注于单一职责,这样只有需要更改这个单一职责的程序员才会请求更改定义——而其他具有不同职责的程序员甚至不会考虑对类发出更改请求,当然,前提是代码是正确的。例如,负责从数据库读取数据的函数,不应该同时负责处理数据。否则,这个函数就有两个更改的理由:数据库模型的变化和处理需求的变化。如果有多个更改的理由,多个程序员可能会同时更改同一个类。你的类有太多职责,已经变得混乱不堪。

让我们考虑一个小的 Python 示例,它可以在电子书阅读器上运行,用于建模和管理用户的阅读体验(Listing 4-15)。

❶ class Book:

  ❷ def __init__(self):
        self.title = "Python One-Liners"
        self.publisher = "NoStarch"
        self.author = "Mayer"
        self.current_page = 0

    def get_title(self):
        return self.title

    def get_author(self):
        return self.author

    def get_publisher(self):
        return self.publisher

 ❸ def next_page(self):
        self.current_page += 1
        return self.current_page

  ❹ def print_page(self):
        print(f"... Page Content {self.current_page} ...")

❺ python_one_liners = Book()

print(python_one_liners.get_publisher())
# NoStarch

python_one_liners.print_page()
# ... Page Content 0 ...

python_one_liners.next_page()
python_one_liners.print_page()
# ... Page Content 1 ... 

Listing 4-15:建模 Book 类时违反单一职责原则

Listing 4-15 中的代码定义了 Book 类 ❶,它有四个属性:标题、作者、出版商和当前页码。你为这些属性定义了 getter 方法 ❷,并实现了一个最基本的功能来翻到下一页 ❸,这个功能可以在每次用户按下电子书阅读器上的按钮时调用。print_page() 函数负责将当前页打印到阅读设备上 ❹。这只是一个占位符,在现实中会更加复杂。最后,你创建了一个名为 python_one_linersBook 实例 ❺,并通过一系列方法调用和打印语句来访问它的属性。一个真实的电子书阅读器实现,比如,每当用户请求新的一页时,会调用 next_page()print_page() 方法。

虽然代码看起来简洁明了,但它违反了单一职责原则:Book 类既负责建模数据,如书籍内容,又负责将书籍打印到设备上。建模和打印是两个不同的功能,但它们被封装在一个类中。这就有了多个更改的理由。例如,你可能希望更改书籍数据的建模方式:比如,你可以使用数据库,而不是基于文件的输入/输出方法。但你也可能想改变建模数据的表现形式,比如使用另一种书籍格式化方案来适配不同类型的屏幕。

我们来解决 Listing 4-16 中的问题。

❶ class Book:

  ❷ def __init__(self):
        self.title = "Python One-Liners"
        self.publisher = "NoStarch"
 self.author = "Mayer"
        self.current_page = 0

    def get_title(self):
        return self.title

    def get_author(self):
        return self.author

    def get_publisher(self):
        return self.publisher

    def get_page(self):
        return self.current_page

    def next_page(self):
        self.current_page += 1

❸ class Printer:

  ❹ def print_page(self, book):
        print(f"... Page Content {book.get_page()} ...")

python_one_liners = Book()
printer = Printer()

printer.print_page(python_one_liners)
# ... Page Content 0 ...

python_one_liners.next_page()
printer.print_page(python_one_liners)
# ... Page Content 1 ...

Listing 4-16:遵循单一职责原则

清单 4-16 中的代码完成了相同的任务,但它满足了单一职责原则。你创建了Book ❶和Printer ❸类。Book类表示书籍元数据和当前页码 ❷,而Printer类负责将书籍打印到设备上。你将要打印当前页码的书籍传入Printer.print_page()方法 ❹。这样,数据建模(数据是什么?)和数据呈现(数据如何展示给用户?)被解耦,代码变得更容易维护。例如,如果你想通过添加新属性publishing_year来更改书籍数据模型,你会在Book类中进行修改。而如果你想通过向读者提供此信息来反映数据呈现的变化,你会在Printer类中进行修改。

原则 11:测试

测试驱动开发是现代软件开发的重要组成部分。无论你的技能多么高超,你都会在代码中犯错。为了捕捉这些错误,你需要定期运行测试,或者一开始就编写测试驱动的代码。每个伟大的软件公司都会在发布最终产品之前进行多层次的测试,因为发现内部错误远远比从不满的用户那里得知它们要好得多。

尽管没有限制可以执行哪种类型的测试来改进你的软件应用程序,但以下是最常见的测试类型:

  1. 单元测试 通过单元测试,你编写一个单独的应用程序,检查每个功能在不同输入下的正确输入/输出关系。单元测试通常定期进行—for example,每次发布新版本的软件时进行。这减少了软件更改导致先前稳定的功能突然失败的可能性。

  2. 用户验收测试 这些测试允许你的目标市场中的人们在受控环境下使用你的应用程序,同时你观察他们的行为。然后你询问他们对应用程序的看法以及如何改进。这些测试通常在项目开发的最后阶段进行,在组织内部进行过广泛的测试后进行。

  3. 冒烟测试 冒烟测试是粗略的测试,旨在尝试在软件开发团队将应用程序交给测试团队之前让其失败。换句话说,冒烟测试通常由应用程序构建团队在将代码交给测试团队之前进行质量保证。当应用程序通过了冒烟测试,它就准备好进行下一轮测试了。

  4. 性能测试 性能测试的目的是展示应用程序是否符合甚至超出用户的性能需求,而不是测试实际功能。例如,在 Netflix 发布新功能之前,必须测试其网站的页面加载时间。如果新功能使前端变得过慢,Netflix 就不会发布该功能,从而主动避免了负面用户体验。

  5. 可扩展性测试 如果你的应用程序变得成功,你可能需要处理每分钟 1,000 个请求,而不是原本的 2 个请求。可扩展性测试将展示你的应用程序是否足够可扩展来处理这种情况。请注意,一个性能良好的应用程序不一定具备可扩展性,反之亦然。例如,一艘快艇性能非常好,但无法应付成千上万的人同时使用!

测试和重构通常会减少代码的复杂性和错误数量。然而,要小心不要过度设计(见原则 14)——你需要测试那些只会在现实世界中发生的场景。例如,测试 Netflix 应用程序是否能够处理 1000 亿个流媒体设备是没有必要的,因为地球上只有 70 亿潜在观众。

原则 12:小即是美

小代码 是指只需相对较少的行数就能完成单一特定任务的代码。以下是一个小代码函数的示例,该函数从用户处读取一个整数值并确保输入的确实是一个整数:

def read_int(query):
    print(query)
    print('Please type an integer next:')
    try:
        x = int(input())
    except:
        print('Try again - type an integer!')
        return read_int(query)
    return x

print(read_int('Your age?'))

代码会一直运行,直到用户输入一个整数。以下是一个示例运行:

Your age?
Please type an integer next:
hello
Try again - type an integer!
Your age?
Please type an integer next:
twenty
Try again - type an integer!
Your age?
Please type an integer next:
20
20

通过将读取整数值的逻辑与用户交互的逻辑分开,你可以多次重用相同的函数。但更重要的是,你将代码拆分成了较小的功能单元,这些单元相对容易阅读和理解。

然而,许多初学者程序员(或懒惰的中级程序员)会编写大型的、单体的代码函数,或者所谓的神对象,这些函数以集中的方式完成所有工作。这些单体代码块维护起来是噩梦。首先,人类一次理解一个小的代码函数比尝试将某个特定功能集成到一个 10,000 行的代码块中要容易得多。在一个大型代码块中,你可能会犯更多的错误,而在几个小函数和代码块中,错误的可能性则较小,这些小函数和代码块可以与现有代码库集成。

本章开始时,图 4-1 展示了编写代码时,每增加一行代码会使时间消耗更大,尽管从长远来看,编写清晰的代码比编写混乱的代码更快。图 4-2 比较了使用小代码块与单体代码块时所需的时间。对于大型代码块,每增加一行代码所需的时间将超线性增加。然而,如果将多个小代码功能叠加在一起,每增加一行代码的时间则接*线性增长。为了最好地实现这一效果,必须确保每个代码功能彼此独立。你将在下一个原则中了解更多关于德梅特法则的内容。

图表:横轴为“编写的代码行数”,纵轴为“每行所需时间”。“大型单体代码块”曲线随着每行代码时间的增加而快速上升,而“多个小代码块”曲线则保持稳定。

图 4-2:使用大型单体代码块时,时间呈指数增长;使用多个小代码块时,时间呈接*线性增长。

原则 13:德梅特法则

依赖关系无处不在。当你在代码中导入一个库时,代码部分依赖于该库的功能,但它自身也会有内部依赖。在面向对象编程中,一个函数可能依赖于另一个函数,一个对象可能依赖于另一个对象,一个类定义可能依赖于另一个类定义。

要编写清晰的代码,通过遵循德梅特法则,最小化代码元素之间的相互依赖性。该法则由软件开发者 Ian Holland 在 1980 年代末提出,他当时正在一个以农业、成长和生育的希腊女神德梅特命名的软件项目中工作。项目组提出了“软件成长”的理念,而不仅仅是构建软件。然而,后来被称为德梅特法则的内容,与这些更具形而上学性质的思想关系不大——它是面向对象编程中编写松耦合代码的一种实际方法。以下是该项目组网站上对德梅特法则的简明解释:ccs.neu.edu/home/lieber/what-is-demeter.html

德梅特法则的一个重要概念是将软件划分为至少两部分:第一部分定义对象,第二部分定义操作。德梅特法则的目标是保持对象和操作之间的松耦合,以便可以在不严重影响另一部分的情况下修改其中任意一部分。这大大减少了维护时间。

换句话说,你应该尽量减少代码对象之间的依赖关系。通过减少代码对象之间的依赖,你减少了代码的复杂性,从而提高了可维护性。一个具体的含义是,每个对象应该只调用自己的方法或相邻对象的方法,而不是调用它通过调用相邻对象的方法获得的对象的方法。为了说明这一点,我们假设两个对象 A 和 B 是朋友,如果 A 调用了 B 提供的方法。很简单。但是,如果 B 的方法返回了一个指向 C 对象的引用呢?现在,A 对象可能会执行类似这样的操作:B.method_of_B().method_of_C()。这就是所谓的方法链式调用——在我们的比喻中,你与朋友的朋友交谈。迪米特法则要求仅与直接朋友沟通,因此不鼓励这种方法链式调用。一开始可能会觉得有些混乱,所以让我们深入到图 4-3 中展示的实际例子。

右侧是即将在清单 4-17 中展示的计算每杯咖啡价格的错误实现的可视化表示,左侧展示了清单 4-18 中探讨的正确实现。

图 4-3:迪米特法则:仅与朋友沟通,以最小化依赖关系

图 4-3 展示了两个面向对象的代码项目,这些项目计算给定人群的每杯咖啡价格。其中一个实现违反了迪米特法则,另一个则遵循该法则。我们先从负面示例开始,示例中在Person类中使用方法链式调用与陌生人沟通 ❶(参见清单 4-17)。

# VIOLATE LAW OF DEMETER (BAD)

class Person:
    def __init__(self, coffee_cup):
        self.coffee_cup = coffee_cup

    def price_per_cup(self):
        cups = 798
      ❶ machine_price = self.coffee_cup.get_creator_machine().get_price()
        return machine_price / cups

class Coffee_Machine:
    def __init__(self, price):
 self.price = price

    def get_price(self):
        return self.price

class Coffee_Cup:
    def __init__(self, machine):
        self.machine = machine

    def get_creator_machine(self):
        return self.machine

m = Coffee_Machine(399)
c = Coffee_Cup(m)
p = Person(c)

print('Price per cup:', p.price_per_cup())
# 0.5

清单 4-17:违反迪米特法则的代码

你创建了方法price_per_cup(),该方法根据咖啡机的价格和该咖啡机生产的杯数来计算每杯咖啡的成本。Coffee_Cup对象收集有关咖啡机价格的信息,这影响每杯咖啡的价格,并将其传递给Person对象中调用price_per_cup()方法的调用者。

图 4-3 左侧的图表展示了一个不好的策略。让我们一步步解释来自清单 4-17 的相应代码。

  1. 方法price_per_cup()调用方法Coffee_Cup.get_creator_machine(),以获取创建咖啡的Coffee_Machine对象的引用。

  2. 方法get_creator_machine()返回一个指向创建了这杯咖啡的Coffee_Machine对象的引用。

  3. 方法price_per_cup()调用从前一个Coffee_Cup方法调用中获得的Coffee_Machine对象上的方法Coffee_Machine.get_price()

  4. 方法get_price()返回机器的价格。

  5. 方法price_per_cup()计算每杯咖啡的折旧,并使用此信息估算单杯咖啡的价格。然后将结果返回给方法的调用者。

这是一个糟糕的策略,因为Person类依赖于两个对象:Coffee_CupCoffee_Machine ❶。负责维护该类的程序员必须了解这两个父类的定义——任何其中一个的变化都可能影响到Person类。

德梅特法则最小化了这种依赖关系。你可以在右侧的图 4-3 和清单 4-18 中看到更好的方式来建模同一个问题。在这个代码片段中,Person类不直接与Machine类交互——它甚至不需要知道它的存在!

# ADHERE TO LAW OF DEMETER (GOOD)

class Person:
    def __init__(self, coffee_cup):
        self.coffee_cup = coffee_cup

    def price_per_cup(self):
        cups = 798
      ❶ return self.coffee_cup.get_cost_per_cup(cups)

class Coffee_Machine:
    def __init__(self, price):
        self.price = price

    def get_price(self):
        return self.price

class Coffee_Cup:
    def __init__(self, machine):
        self.machine = machine

    def get_creator_machine(self):
        return self.machine

    def get_cost_per_cup(self, cups):
        return self.machine.get_price() / cups

m = Coffee_Machine(399)
c = Coffee_Cup(m)
p = Person(c)

print('Price per cup:', p.price_per_cup())
# 0.5

清单 4-18:遵循德梅特法则的代码,不与陌生人交互

让我们逐步分析这段代码:

  1. 方法price_per_cup()调用方法Coffee_Cup.get_cost_per_cup()来获取每杯的估算价格。

  2. 方法get_cost_per_cup()——在响应调用方法之前——调用方法Coffee_Machine.get_price()来获取机器的价格。

  3. 方法get_price()返回价格信息。

  4. 方法get_cost_per_cup()计算每杯的价格,并将其返回给调用方法price_per_cup()

  5. 方法price_per_cup()只是将计算出的值传递给它的调用者 ❶。

这是一个更好的方法,因为Person类现在独立于Coffee_Machine类。依赖关系的总数减少了。对于一个有数百个类的项目,减少依赖关系大大降低了应用程序的整体复杂性。对于大型应用程序,随着复杂度的增长,潜在的依赖关系数量呈超线性增长:大致来说,超线性曲线增长得比直线快。例如,双倍增加对象的数量可能会使依赖关系的数量增加四倍(也就是复杂度)。然而,遵循德梅特法则可以通过显著减少依赖关系的数量来抵消这一趋势。如果每个对象只与k个其他对象交互,且你有n个对象,那么依赖关系的总数受限于kn,这是一个线性关系(如果k*是常数)。因此,德梅特法则在数学上可以帮助你优雅地扩展应用程序!

原则 14:你根本不需要它

这个原则建议你永远不要实现你仅仅是怀疑将来某一天可能会用到的代码——因为你根本不需要它!只有在你百分之百确定它是必要的时候才编写代码。为今天的需求编写代码,而不是明天的。如果将来你确实需要你之前仅仅怀疑过的代码,你仍然可以在那时实现该功能。但与此同时,你节省了很多不必要的代码行。

有助于从基本原则出发思考:最简单、最干净的代码就是空文件。从那里开始——你需要添加什么?在第三章中,你学到了 MVP:去除特性后的代码,专注于核心功能。如果你最小化所追求的特性数量,你将获得比通过重构方法或所有其他原则结合起来所能达到的更简洁、更清晰的代码。考虑剔除那些与其他特性相比,提供相对较少价值的特性。机会成本往往没有得到衡量,但通常是显著的。在考虑实现某个功能之前,你真的需要它。

这一点的一个含义是要避免过度工程:创建一个性能和健壮性更强或包含更多特性的产品,超出了实际需要。这增加了不必要的复杂性,这应当立刻引起警觉。

例如,我常常遇到这样的问题,使用一种简单的算法方法可以在几分钟内解决,但像许多程序员一样,我拒绝接受这些算法的局限性。于是,我研究了最先进的聚类算法,试图比简单的 KMeans 算法获得几个百分点的聚类性能。这些尾部优化代价极高——我花了 80%的时间,只获得了 20%的提升。如果我需要这 20%的提升,并且没有其他办法获取它,这种情况是不可避免的,但实际上,我并不需要实现复杂的聚类算法。这是过度工程的典型案例!

总是先从容易实现的部分入手。使用简单的算法和直接的方法建立基准,然后分析哪些新特性或性能优化能为整体应用带来更好的结果。要从全局出发,而不是局部:关注大局(如原则 1 所示),而非那些耗时的小修小补。

原则 15:不要使用过多的缩进层级

大多数编程语言使用文本缩进来可视化可能嵌套的条件块、函数定义或代码循环的层级结构。然而,过度使用缩进会降低代码的可读性。示例 4-19 展示了一个代码片段,缩进层级过多,导致难以快速理解。

def if_confusion(x, y):
    if x>y:
        if x-5>0:
            x = y
            if y==y+y:
                return "A"
            else:
                return "B"
        elif x+y>0:
            while x>y:
                x = x-1
            while y>x:
                y = y-1
            if x==y:
                return "E"
        else:
            x = 2 * x
            if x==y:
                return "F"
            else:
                return "G"
    else:
        if x-2>y-4:
            x_old = x
            x = y * y
            y = 2 * x_old
            if (x-4)**2>(y-7)**2:
                return "C"
            else:
                return "D"
        else:
            return "H"

print(if_confusion(2, 8))

示例 4-19:嵌套代码块的层级过多

如果你现在试图猜测这段代码片段的输出,你会发现实际上很难追踪。代码函数if_confusion(x, y)对变量xy进行了相对简单的检查。然而,在不同的缩进层级中,很容易迷失。代码根本不简洁。

示例 4-20 展示了如何更加简洁清晰地编写相同的代码。

def if_confusion(x,y):
    if x>y and x>5 and y==0:
        return "A"
    if x>y and x>5:
        return "B"
    if x>y and x+y>0:
        return "E"
    if x>y and 2*x==y:
        return "F"
    if x>y:
        return "G"
    if x>y-2 and (y*y-4)**2>(2*x-7)**2:
        return "C"
    if x>y-2:
        return "D"
    return "H"

示例 4-20:减少嵌套代码块的层级

在清单 4-20 中,我们减少了缩进和嵌套。现在,你可以检查所有条件,看看哪些条件首先适用于你的两个参数xy。大多数程序员比起阅读高度嵌套的代码,更喜欢阅读扁*化的代码——即使这意味着冗余检查;例如,这里x>y就被检查了多次。

原则 16:使用度量

使用代码质量度量来跟踪你代码随时间变化的复杂性。最终的(虽然非正式的)度量标准被称为每分钟的 WTF 数,旨在衡量代码阅读者的挫败感。清晰简洁的代码其结果会很低,而脏乱复杂的代码结果则会很高。

作为这一难以量化标准的代理,你可以使用如第一章所讨论的 NPath 复杂度或圈复杂度等已建立的度量标准。对于大多数 IDE,许多在线工具和插件会在你编写源代码时自动计算复杂性。这些工具包括 CyclomaticComplexity,你可以在 JetBrains 的插件部分通过 plugins.jetbrains.com/ 查找它。在我看来,实际使用的复杂性度量标准并不重要,关键是意识到你需要尽可能地去除复杂性。我强烈建议使用这些工具,帮助你编写更清晰、更简洁的代码。你投入的时间将获得极高的回报。

原则 17:童子军法则与重构

童子军法则很简单:将营地保持得比你找到时更干净。这是一个很好的生活和编码准则。养成清理每一段你遇到的代码的习惯。这不仅能改善你参与的代码库,让你自己的工作更轻松,还能帮助你培养像大师级程序员那样的敏锐眼光,快速评估源代码。额外的好处是,它能提高你团队的生产力,同事们也会感激你这种注重价值的态度。需要注意的是,这不应该违背我们之前提到的避免过早优化(过度工程化)的原则。花时间清理代码以减少复杂性几乎总是高效的。这样做将大大减少维护开销、bug 和认知负担。简言之,过度工程化往往会增加复杂性,而清理代码则能减少复杂性。

改进代码的过程被称为重构。你可以说,重构是包含我们讨论的每个原则的整体方法。作为一名优秀的程序员,你从一开始就会融入许多清洁代码的原则。然而,即便如此,你仍然需要偶尔重构代码,清理你所犯的任何错误。特别是,在发布任何新特性之前,你应该重构代码,以保持代码的整洁。

有许多重构代码的技巧。其中一种方法是向同事解释你的代码,或者让他们帮忙查看,以发现你可能没有注意到的糟糕决策。例如,你可能创建了两个类,CarsTrucks,因为你预期你的应用程序需要同时建模这两者。当你向同事解释代码时,你意识到你并不经常使用Trucks类——而且当你使用它时,你用的方法已经存在于Car类中。你的同事建议创建一个Vehicle类,处理所有的汽车和卡车。这样,你就可以立即删除许多行代码。这种思考方式可以带来巨大的改善,因为它迫使你从宏观角度审视你的决策,并解释你的项目。

如果你是一个内向的程序员,你可以改为向一只橡胶鸭解释你的代码——这是一种被称为橡胶鸭调试的技巧。

除了与同事(或橡胶鸭)交流外,你还可以利用这里列出的其他简洁代码原则,时不时地快速评估你的代码。当你这样做时,你很可能会发现一些可以快速应用的调整,从而通过清理代码库大幅度减少复杂性。这个软件开发过程中不可或缺的部分将显著提高你的成果。

结论

在本章中,你已经学习了 17 条编写简洁清晰代码的原则。你了解了简洁代码如何减少复杂性,提高生产力,并增强项目的可扩展性和可维护性。你知道了在可能的情况下应使用库,以减少冗余并提高代码质量。你明白了选择有意义的变量和函数名称,并遵守标准,能有效减少未来阅读代码者的理解障碍。你学会了设计只做一件事的函数。通过最小化依赖(根据德梅特法则)来减少复杂性并提高可扩展性,可以通过避免直接和间接的方法链调用来实现。你学会了以一种能展现你思路的方式注释代码,但你也学会了避免不必要或琐碎的注释。最重要的是,你明白了释放简洁代码超级能力的关键是为人类编写代码,而不是为机器编写代码。

你可以通过与优秀的程序员合作,阅读他们在 GitHub 上的代码,以及研究你所使用编程语言中的最佳实践,逐渐提高你的简洁代码编写技巧。将一个动态检查你代码是否符合最佳实践的代码审查工具(linter)集成到你的编程环境中。时不时地,回顾这些简洁代码的原则,并将其应用到你当前的项目中。

在下一章中,你将学习另一个有效编码的原则,它不仅仅是写出干净的代码:过早优化。你会惊讶地发现,许多程序员在尚未意识到过早优化是万恶之源的情况下,浪费了大量的时间和精力。

第五章:提前优化是万恶之源

在本章中,你将了解提前优化如何妨碍你的生产力。提前优化是指将宝贵的资源——时间、精力、代码行数——浪费在不必要的代码优化上,尤其是在你还没有掌握所有相关信息之前。这是编写糟糕代码的主要问题之一。提前优化有很多种形式;本章将介绍其中一些最相关的情况。我们将通过实际案例来研究提前优化可能发生的地方,这些案例对你的代码项目也具有相关性。最后,我们将提供关于性能调优的可操作建议,确保它不是提前优化

六种提前优化的类型

优化代码本身并没有错,但它总是伴随着成本,无论是额外的编程时间还是更多的代码行数。当你优化代码片段时,通常是在用复杂性换取性能。有时你可以同时获得低复杂性和高性能,例如通过编写简洁的代码,但你必须花费编程时间来实现这一点!如果在过程中过早地进行优化,你往往会花时间优化那些在实际中可能永远不会用到的代码,或者对程序的整体运行时影响甚微。你还可能在没有足够信息的情况下进行优化,比如不了解代码何时被调用或可能的输入值。浪费宝贵的资源,如编程时间和代码行数,可能会大幅降低你的生产力,因此,知道如何明智地投资这些资源非常重要。

但不要仅仅听我说的。看看计算机科学史上最具影响力的科学家之一,唐纳德·克努斯(Donald Knuth)如何评价提前优化:

程序员会浪费大量时间去思考或担心他们程序中非关键部分的速度,而这种效率尝试在调试和维护时实际上会产生强烈的负面影响。我们应该忘记那些小的效率提升,大约 97%的时间如此:提前优化是万恶之源。^(1)

提前优化可以有多种形式,因此,为了探讨这个问题,我们将看看我遇到的六个常见案例,可能你也会在这些情况下忍不住过早地关注小的效率,从而拖慢了进度。

优化代码函数

小心在你还不清楚这些功能会被使用多少的情况下,浪费时间优化它们。假设你遇到一个自己无法忍受不优化的功能。你推理自己,使用简单的方法是糟糕的编程风格,应该使用更高效的数据结构或算法来解决这个问题。于是你进入研究模式,花费数小时进行算法的研究和微调。然而,结果是,这个功能在最终项目中仅执行了几次:优化并未带来显著的性能提升。

优化功能

避免添加那些并非严格必要的功能,并浪费时间去优化这些功能。假设你正在开发一款将文本翻译成摩尔斯电码并通过闪烁的灯光表示的智能手机应用程序。在第三章中,你已经学到,先实现一个最小可行产品(MVP),而不是创建一个具有许多可能不必要功能的完美终端产品,是最好的做法。在这个案例中,MVP 将是一个简单的应用程序,只有一个功能:通过简单的输入表单输入文本,点击按钮后,应用将这个文本翻译成摩尔斯电码。然而,你认为 MVP 规则并不适用于你的项目,决定增加一些额外的功能:一个文本转语音转换器和一个接收器,将光信号翻译成文本。发布应用后,你发现用户从未使用过这些功能。过早优化显著拖慢了你的产品开发周期,并延迟了你吸纳用户反馈的能力。

优化规划

如果你在规划阶段过早优化,试图解决尚未发生的问题,你可能会延迟接收到宝贵反馈的能力。虽然你当然不应该完全避免规划,但在规划阶段停滞不前同样代价高昂。要向现实世界交付有价值的产品,你必须接受不完美。你需要用户反馈和来自测试人员的理智检查,来帮助你确定应该集中在哪些方面。规划可以帮助你避免某些陷阱,但如果你不采取行动,你将永远无法完成项目,依旧困于理论的象牙塔中。

优化可扩展性

在你对受众的实际需求还没有清晰认识之前,过早优化应用程序的可扩展性可能成为一个主要干扰因素,并且很容易让你花费数万美元的开发和服务器时间。假设你预期会有数百万用户,你设计了一个分布式架构,在必要时动态地添加虚拟机来处理峰值负载。然而,创建分布式系统是一个复杂且容易出错的任务,可能需要几个月才能完成。许多项目最终都会失败;即使你真的像梦想中那样成功了,也会有充足的机会随着需求的增加来扩展系统。更糟糕的是,分布式系统可能减少应用程序的可扩展性,因为它增加了通信和数据一致性的开销。可扩展的分布式系统是有代价的——你确定你需要支付这个代价吗?在你服务了第一个用户之前,不要试图扩展到数百万用户。

优化测试设计

过早优化测试也是浪费开发者时间的主要原因之一。测试驱动开发有许多热心的追随者,他们误解了在功能之前实现测试的思想,认为始终应该先写测试——即使代码功能的目的是纯粹的实验,或者该功能本身并不适合进行测试。编写实验性代码是为了测试概念和想法,而为实验代码增加额外的测试层会妨碍进展,并且不符合快速原型开发的哲学。此外,假设你相信严格的测试驱动开发并坚持要求百分之百的测试覆盖率。有些功能——例如,处理用户输入的自由文本的功能——由于其不可预测的人类输入,并不适合单元测试。对于这些功能,只有真实的用户才能以有意义的方式进行测试——在这种情况下,现实世界的用户是唯一重要的测试。然而,你却过早优化了单元测试的完美覆盖率。这种做法几乎没有价值:它拖慢了软件开发周期,同时引入了不必要的复杂性。

*### 优化面向对象的世界构建

面向对象的方法常常引入不必要的复杂性和过早的“概念性”优化。假设你想通过一个复杂的类层次结构来建模你的应用程序世界。你写了一个关于赛车的小游戏。你创建了一个类层次结构,其中 Porsche 类继承自 Car 类,Car 类又继承自 Vehicle 类。毕竟,每一辆 Porsche 都是车,而每一辆车都是交通工具。然而,多个层级的类继承导致了代码库的复杂性,未来的程序员很难弄清楚你的代码在做什么。在很多情况下,这种堆叠的继承结构增加了不必要的复杂性。通过使用 MVP(最简模型)的思想来避免这种情况:从最简单的模型开始,只有在必要时才扩展它。不要为了建模一个世界的更多细节而优化你的代码,应用程序并不需要那么多细节。

过早优化:一个故事

既然你已经对过早优化可能带来的问题有了大致的了解,让我们编写一个小的 Python 应用程序,实时演示过早优化如何为一个不需要优雅地扩展到成千上万用户的小型交易跟踪应用程序增加不必要的复杂性。

Alice、Bob 和 Carl 每周五晚上玩扑克。经过几轮游戏后,他们决定需要开发一个系统,用来记录每个玩家在某个游戏夜后的欠款。Alice 是一位热衷的程序员,她创建了一个小型应用程序来跟踪玩家的余额,见 Listing 5-1。

transactions = []
balances = {}

❶ def transfer(sender, receiver, amount): 
    transactions.append((sender, receiver, amount))
    if not sender in balances:
        balances[sender] = 0
    if not receiver in balances:
        balances[receiver] = 0
  ❷ balances[sender] -= amount 
    balances[receiver] += amount

def get_balance(user):
    return balances[user]

def max_transaction():
    return max(transactions, key=lambda x:x[2])

❸ transfer('Alice', 'Bob', 2000) 
❹ transfer('Bob', 'Carl', 4000) 
❺ transfer('Alice', 'Carl', 2000) 

print('Balance Alice: ' + str(get_balance('Alice')))
print('Balance Bob: ' + str(get_balance('Bob')))
print('Balance Carl: ' + str(get_balance('Carl')))

print('Max Transaction: ' + str(max_transaction()))

❻ transfer('Alice', 'Bob', 1000) 
❼ transfer('Carl', 'Alice', 8000) 

print('Balance Alice: ' + str(get_balance('Alice')))
print('Balance Bob: ' + str(get_balance('Bob')))
print('Balance Carl: ' + str(get_balance('Carl')))

print('Max Transaction: ' + str(max_transaction()))

Listing 5-1: 跟踪交易和余额的简单脚本

该脚本有两个全局变量,transactionsbalances。列表 transactions 跟踪玩家之间发生的交易。每一笔交易都是一个元组,包含 sender 标识符、receiver 标识符,以及要从发送方转移到接收方的 amount ❶。字典 balances 跟踪玩家的当前余额:一个将用户标识符映射到该用户根据目前为止的交易所拥有的积分数量的字典 ❷。

函数 transfer(sender, receiver, amount) 创建并存储一个新的交易到全局列表中,如果 senderreceiver 不存在,则为它们创建新的余额,并根据给定的 amount 更新余额。函数 get_balance(user) 返回给定用户的余额,而 max_transaction() 会遍历所有交易并返回其中交易金额最大的一个,即元组的第三个元素。

最初所有余额为零。应用程序将 2,000 单位从 Alice 转给 Bob ❸,4,000 单位从 Bob 转给 Carl ❹,以及 2,000 单位从 Alice 转给 Carl ❺。此时,Alice 欠 4,000(负余额为 −4,000),Bob 欠 2,000,而 Carl 有 6,000 单位。打印出最大交易后,Alice 将 1,000 单位转给 Bob ❻,Carl 将 8,000 单位转给 Alice ❼。现在,账户余额发生了变化:Alice 有 3,000,Bob 有 −1,000,而 Carl 有 −2,000 单位。特别地,应用程序返回以下输出:

Balance Alice: -4000
Balance Bob: -2000
Balance Carl: 6000
Max Transaction: ('Bob', 'Carl', 4000)
Balance Alice: 3000
Balance Bob: -1000
Balance Carl: -2000
Max Transaction: ('Carl', 'Alice', 8000)

但是 Alice 对这个应用程序不满意。她意识到调用max_transaction()会导致冗余计算——因为该函数被调用了两次,脚本需要遍历列表transactions两次来找到最大交易金额。但当第二次计算 max_transaction() 时,它部分地重复了之前的计算,遍历所有交易以找到最大值——包括那些它已经知道最大值的交易,即前面的三笔交易❸–❺。Alice 正确地看到了通过引入一个新变量 max_transaction 来优化的潜力,这个变量跟踪每当创建新交易时已经看到的最大交易。

列表 5-2 显示了 Alice 添加的三行代码来实现这一变化。

transactions = []
balances = {}
**max_transaction = ('X', 'Y', float('-Inf'))**

def transfer(sender, receiver, amount):
...
    **if amount > max_transaction[2]:**
 **max_transaction = (sender, receiver, amount)**

列表 5-2:应用优化以减少冗余计算

变量max_transaction维护了到目前为止所有交易中的最大交易金额。因此,不需要在每个游戏夜晚后重新计算最大值。最初,您将最大交易值设置为负无穷大,这样第一个真实交易就一定会大于该值。每次添加新交易时,程序会将新交易与当前最大值进行比较,如果新交易更大,则当前交易变为当前最大值。如果没有优化,如果您在一个包含 1,000 笔交易的列表上调用max_transaction() 1,000 次,您将不得不执行 1,000,000 次比较来找到 1,000 个最大值,因为您需要遍历 1,000 个元素的列表 1,000 次(1,000 * 1,000 = 1,000,000)。有了优化,您只需为每个函数调用检索 max_transaction 中当前存储的值一次。由于列表中有 1,000 个元素,您最多需要 1,000 次操作来维护当前最大值。这将使所需的操作数量减少三个数量级。

很多程序员无法抗拒实施这种优化,但它们的复杂性会逐渐累积。在爱丽丝的案例中,她很快就需要跟踪一些额外的变量,以记录她朋友们可能感兴趣的额外统计信息:min_transactionavg_transactionmedian_transactionalice_max_transaction(用于跟踪她自己最大交易值)。每添加一个变量,就会增加几行代码,从而提高了出现 bug 的概率。例如,如果爱丽丝忘记在正确的位置更新一个变量,她将不得不花费宝贵的时间去修复它。更糟糕的是,她可能完全忽略这个 bug,导致爱丽丝账户的余额被损坏,甚至造成几百美元的损失。她的朋友们甚至可能怀疑爱丽丝是为了自己的利益写了这段代码!这一点听起来可能有些讽刺,但在现实情况下,后果可能更为严重。二阶后果比起复杂性的首阶后果,往往更加难以预测和可怕。

所有这些潜在问题本来可以通过爱丽丝避免实施过早优化来避免。这个应用程序的目标是为三位朋友在一个晚上进行交易撮合。实际上,最多也不过几百笔交易和十几次 max_transaction 的调用,而不是为成千上万的交易而设计的优化代码。爱丽丝的电脑能够在一瞬间执行未优化的代码,鲍勃和卡尔甚至不会意识到代码没有经过优化。而且,未优化的代码更简单,维护起来也更容易。

然而,假设消息传开,一家依赖高性能、可扩展性和长期交易历史的赌场联系到爱丽丝,希望她实施同样的系统。在这种情况下,她仍然可以通过避免重新计算最大值,改为快速跟踪最大值来解决瓶颈。但此时她就能确信,增加的代码复杂度确实是一项值得的投资。通过将优化推迟到应用程序真正需要它时,爱丽丝能够避免数十次不必要的过早优化。

性能调优的六个技巧

爱丽丝的故事不仅为我们提供了过早优化实践中的详细画面,还暗示了成功优化的正确方式。重要的是要记住,唐纳德·克努斯并没有认为优化本身是万恶之源。真正的问题是过早优化。如今,克努斯的名言已经变得相当流行,但许多人错误地认为这是一种反对所有优化的论点。然而,在合适的时机,优化可能是至关重要的。

*几十年来,技术的快速进步在很大程度上得益于优化:芯片上的电路布局、算法以及软件的可用性都在不断优化中。摩尔定律指出,使计算变得极其便宜和高效的计算机芯片技术改进将在未来很长一段时间内继续呈指数增长。芯片技术的进步潜力巨大,不能被认为是过早的优化。如果这些优化为很多人创造价值,它们便是进步的核心。

一般来说,只有在你有明确证据——例如来自性能优化工具的测量——证明需要优化的代码部分或功能确实是瓶颈,而且应用的用户会欣赏甚至要求更好的性能时,你才应该进行优化。比如,优化 Windows 操作系统启动速度并不是过早优化,因为它将直接惠及数百万用户;然而,优化一个每月最多 1000 名用户、仅请求静态网站的 Web 应用的可扩展性就是过早优化了。开发应用的成本远不及数千名用户使用它的成本。如果你能花一个小时的时间来为用户节省几秒钟,通常来说这就是一种双赢!用户的时间比你的时间更宝贵。这就是我们最初使用计算机的原因——提前投入一些资源,之后获得更多资源。优化并不总是过早的。有时候,你必须进行优化,以便首先创造出有价值的产品——为什么要发布一个没有经过优化、不产生任何价值的产品呢?在看到避免过早优化的几个理由后,我们将看六个性能优化的建议,帮助你选择如何以及何时优化你的代码。

先测量,再改进

测量你的软件性能,这样你才能知道哪些地方可以和应该进行改进。你没有测量的东西是无法改进的,因为你没有办法追踪进展。

过早优化通常是在你还没有进行任何衡量的情况下就进行的优化,这也是“过早优化是万恶之源”这一观点的直接依据。你应该在开始衡量尚未优化代码的性能后再进行优化,比如内存占用或速度。这就是你的基准。比如,如果你不知道原始的运行时间,尝试优化运行时间就毫无意义。除非你有明确的基准,否则无法判断你的“优化”是否真正增加了总运行时间,或是否根本没有任何可测量的效果。

作为测量性能的一般策略,首先编写最简单、最直观且易读的代码。你可以称之为你的原型朴素方法MVP。将你的测量记录在电子表格中,这就是你的第一个基准。然后创建一个替代的代码解决方案,并将其性能与基准进行比较。一旦你严格证明你的优化提升了代码性能,那么新的优化代码就成为你的新基准,所有后续的改进都应该超过这个基准。如果某个优化没有明显提高代码性能,就把它丢弃。

通过这种方式,你可以跟踪代码随时间的改进情况。你还可以记录、证明并为优化方案辩护,无论是对老板、同事,还是对科学界。

帕累托法则是王道

第二章中讨论的 80/20 原则,或称帕累托原则,同样适用于性能优化。一些功能会比其他功能占用更多的资源,如时间和内存,因此专注于改善这些瓶颈将帮助你有效地优化代码。

为了举例说明我操作系统中不同进程并行运行时的高度不*衡,可以看看我当前的中央处理单元(CPU)使用情况,如图 5-1 所示。

作者的 CPU 使用截图,显示所有运行的程序中,只有七个程序使用了超过 1%的 CPU。

图 5-1:Windows PC 上不同应用程序 CPU 需求的不*衡分布

如果你在 Python 中绘制这个图,你会看到一个类似帕累托分布的模式,如图 5-2 所示。

映射 CPU 使用情况的图表,显示只有少数几个程序占用了大量 CPU 百分比。

图 5-2:Windows PC 上不同应用程序的 CPU 使用情况

一小部分应用程序代码占用了大量 CPU 资源。如果我想减少计算机的 CPU 使用率,只需关闭 Cortana 和 Search,然后—瞧—大部分的 CPU 负载消失了,如图 5-3 所示。

与图 5-2 相同的图表,移除了前两个 CPU 使用者;现在“Explorer”和“系统”是前两名,但 CPU 使用模式与图 5-2 相同。

图 5-3:通过关闭不需要的应用程序来“优化”Windows 系统后的结果

移除两个最昂贵的任务大大降低了 CPU 的负载,但请注意,新的图表乍一看与第一个图表相似:这次是 Explorer 和 System 两个任务,它们仍然比其他任务贵得多。这证明了一个重要的性能调优规则:性能优化是分形的。只要你移除了一个瓶颈,另一个瓶颈就会悄悄出现。瓶颈在任何系统中都会存在,但如果你不断地移除它们,你就能获得最大化的“性价比”。在实际的代码项目中,你会看到同样的分布:相对少量的函数占用了大部分资源(例如,CPU 周期)。通常,你可以将优化工作集中在占用最多资源的瓶颈函数上,比如通过用更复杂的算法重写它,或者考虑避免计算的方式(例如,缓存中间结果)。当然,在解决当前瓶颈后,下一个瓶颈会随之出现;这就是为什么你需要衡量代码,并决定何时停止优化的原因。例如,当用户根本感受不到差异时,将 Web 应用程序的响应时间从 2 毫秒提高到 1 毫秒就没有太大意义。由于优化的分形性质和帕累托原则(见第二章),获得这些小的提升通常需要大量的努力和开发时间,并且可能在可用性或应用程序效用上带来的收益非常有限。

算法优化的胜利

假设你已经决定你的代码需要某种优化,因为用户反馈和统计数据显示你的应用程序太慢。你已经测量了当前的速度(以秒或字节为单位),并知道你要达到的目标速度,并且你找到了瓶颈。你的下一步是弄清楚如何克服这个瓶颈。

许多瓶颈可以通过调整你的算法和数据结构? 来解决。例如,假设你正在开发一个财务应用程序。你知道你的瓶颈是函数 calculate_ROI(),它遍历所有可能的买入和卖出点的组合来计算最大利润。由于这个函数是整个应用程序的瓶颈,你希望为它找到一个更好的算法。经过一番研究,你发现了 最大利润算法,这是一种简单且强大的替代方案,能够显著加速你的计算。同样的研究也可以应用于造成瓶颈的数据结构。

为了减少瓶颈并优化性能,请问问自己:

  • 你能找到已经验证过的更好的算法吗?例如,在书籍、研究论文或甚至维基百科中?

  • 你能为你的特定问题调整现有算法吗?

  • 你能改进数据结构吗?一些常见的简单解决方案包括使用集合代替列表(例如,检查成员资格时,集合比列表快得多)或使用字典代替元组集合。

花时间研究这些问题,不仅对你的应用有帮助,对你个人也大有裨益。在这个过程中,你将成为一个更优秀的计算机科学家。

万岁,缓存!

一旦你根据前面的提示做了必要的修改,你可以继续使用这个简单而粗暴的技巧来去除不必要的计算:将你已经执行过的一部分计算结果存储在缓存中。这个技巧在各种应用中出奇地有效。在执行任何新的计算之前,你首先检查缓存,看看你是否已经做过这个计算。这类似于你在脑海中进行简单计算时的方式——在某个时候,你并不会实际计算 6 * 5,而是依靠记忆迅速给出结果。因此,缓存只有在应用程序中多次出现相同类型的中间计算时才有意义。幸运的是,这在大多数现实世界的应用中都成立——例如,成千上万的用户可能在一天内观看同一个 YouTube 视频,所以将其缓存到离用户更*的地方(而不是远在几千英里外的一个数据中心)能节省宝贵的网络带宽资源。

让我们通过一个简单的代码示例来探索缓存如何显著提高性能:斐波那契算法

def fib(n):
    if n < 2:
        return n
    fib_n = fib(n-1) + fib(n-2)
    return fib_n

print(fib(100))

这将输出通过反复加和数列中的最后一个和倒数第二个元素,直到第 100 个元素的结果:

354224848179261915075

这个算法运行缓慢,因为 fib(n-1)fib(n-2) 函数计算的内容几乎是相同的。例如,它们分别计算 (n−3) 位置的斐波那契数,而不是复用彼此的计算结果。冗余计算累积——即使对于这个简单的函数调用,计算也花费了太长时间。

提高性能的一种方法是创建一个缓存。缓存允许你存储之前计算的结果,这样在这种情况下,fib2(n-3) 只会计算一次,当你需要它时,可以直接从缓存中提取结果。

在 Python 中,我们可以通过创建一个字典来制作一个简单的缓存,将每个函数输入(例如作为输入字符串)与函数输出关联起来。然后,你可以让缓存返回你已经执行过的计算结果。

这是 Python 斐波那契的缓存变体:

cache = dict()

def fib(n):
    if n in cache:
        return cache[n]
    if n < 2:
        return n
    fib_n = fib(n-1) + fib(n-2)
  ❶ cache[n] = fib_n 
    return fib_n

print(fib(100))
# 354224848179261915075

你将fib(n-1) + fib(n-2)的结果存储在缓存中 ❶。如果你已经有了第n个斐波那契数的结果,你可以直接从缓存中提取,而不是一次又一次地重新计算。在我的机器上,当计算前 40 个斐波那契数时,这样做使速度提升了* 2000 倍!

有两种有效缓存的基本策略:

提前进行计算(“离线”)并将结果存储在缓存中。

  1. 这是一个非常适合 Web 应用的策略,你可以一次性填满一个大缓存,或者每天填充一次,然后将预计算的结果提供给用户。对他们来说,你的计算看起来非常迅速。地图服务 heavily 使用这个技巧来加速最短路径计算。

在计算出现时进行计算(“在线”)并将结果存储在缓存中。

  1. 一个例子是一个在线比特币地址检查器,它将所有进入的交易求和,并扣除所有出去的交易,以计算给定比特币地址的余额。计算完成后,它可以缓存这个地址的中间结果,以避免用户再次检查时重新计算相同的交易。这种反应式的形式是最基本的缓存形式,你不需要提前决定执行哪些计算。

在这两种情况下,你存储的计算越多,缓存命中的可能性就越高,即相关的计算可以立即返回。然而,由于缓存条目数量通常有内存限制,因此你需要一个合理的缓存替换策略:由于缓存大小有限,可能会很快填满。此时,缓存只能通过替换旧的条目来存储新的值。一个常见的替换策略是先进先出(FIFO),它会用新的条目替换最旧的缓存条目。最佳策略取决于具体的应用场景,但 FIFO 是一个不错的初步选择。

少即是多

你的问题是否太难以高效解决?让它变得更简单!这听起来显而易见,但许多程序员都是完美主义者。他们接受庞大的复杂性和计算开销,只是为了实现一个用户可能根本不会注意到的小特性。与其优化,通常更好的做法是减少复杂性,去除不必要的特性和计算。举个例子,考虑搜索引擎开发者面临的问题:“给定搜索查询,什么是完美匹配?”解决这个问题的最优方案非常困难,并且需要在数十亿个网站中进行搜索。然而,像谷歌这样的搜索引擎并没有找到最优解;相反,它们通过使用启发式方法,在有限的时间内尽力而为。它们不是在数十亿个网站中逐一与用户搜索查询进行匹配,而是通过粗略的启发式方法估算个别网站的质量(例如著名的 PageRank 算法),并在没有其他高质量网站提供答案的情况下,参考次优网站。在大多数情况下,你也应该使用启发式方法,而不是最优算法。问问自己以下问题:你当前的瓶颈是什么?它为什么存在?解决这个问题值得投入精力吗?你能移除这个特性或者提供一个简化版本吗?如果该特性仅为 1%的用户所用,但 100%的用户感知到了增加的延迟,那么可能是时候考虑极简主义了(移除那些很少使用但对使用它的用户体验造成负面影响的特性)。

为了简化你的代码,思考一下是否有以下其中一项操作是合理的:

  • 通过直接跳过该特性,完全消除你当前的瓶颈。

  • 通过将问题替换为一个更简单版本来简化问题。

  • 根据 80/20 原则,移除一个昂贵的特性,增加 10 个便宜的特性。

  • 放弃一个重要的特性,以便你可以追求一个更重要的特性;考虑机会成本。

知道何时停止

性能优化可能是编程中最费时的方面之一。总有改进的空间,但当你已经用尽了最基础的优化方法后,进一步提升性能所需的努力会逐渐增加。到某个时候,提升性能可能就成为浪费时间。

定期问问自己:继续优化值得吗?答案通常可以通过研究你应用程序的用户来找到。他们需要什么样的性能?他们能分辨出应用程序的原版与优化版之间的差异吗?其中一些用户是否抱怨性能差?回答这些问题将帮助你大致估算应用程序的最大运行时间。现在,你可以开始优化瓶颈,直到达到这个阈值,然后停止。

结论

在本章中,你已经了解了为什么避免过早优化很重要。如果一个优化所需的代价超过了它所带来的价值,那么这个优化就是过早的。根据项目的不同,价值通常可以通过开发者时间、可用性指标、应用程序或功能的预期收入,或者它对特定用户子群体的效用来衡量。例如,如果一个优化能够为成千上万的用户节省时间或金钱,那么即使你必须投入大量开发资源来优化代码库,它也很可能不是过早优化。然而,如果该优化无法显著改善用户或程序员的生活质量,那么它很可能是过早优化。是的,确实存在许多更先进的软件工程模型,但常识和对过早优化危险的基本认识已经足够,而你不需要研究复杂的书籍或软件开发模型的研究论文。例如,一个有用的经验法则是:一开始写出可读且简洁的代码,不必过于关注性能,然后基于经验、性能追踪工具的硬数据和用户研究的实际结果,优化那些预期价值较高的部分。

在下一章中,你将学习关于“心流”概念——程序员最好的朋友。*

第六章:心流

心流是极致人类表现的源代码。

—Steven Kotler

在这一章中,你将了解心流的概念,以及如何利用它提升你的编程生产力。许多程序员发现自己身处充满不断干扰、会议和其他分心事物的办公环境中,这使得他们几乎不可能进入纯粹的高效编程状态。为了更深入地理解什么是心流,以及如何在实践中实现它,我们将在本章中探讨许多例子,但一般来说,心流是一种纯粹的集中和专注状态——有些人可能称之为“进入状态”。

心流不是一个严格的程序化概念,而是一种可以应用于任何领域中任何任务的状态。在这里,我们将探讨如何达到心流状态,以及它如何对你有益。

什么是心流?

心流这一概念由米哈伊·奇克森米哈伊(Mihaly Csikszentmihalyi)普及,他是克莱蒙特研究生大学的心理学和管理学杰出教授,曾任芝加哥大学心理学系主任。1990 年,奇克森米哈伊出版了关于他一生工作的开创性著作,名为心流

但什么是心流呢?我们从心流的主观定义开始,看看它的感觉如何。之后,你将学到一个更具可衡量性的心流定义——作为程序员,你会更喜欢第二个定义!

经历心流就是完全沉浸在手头任务中的状态:专注且集中。你忘记了时间,你进入了状态,超常觉醒。你可能会感受到一种欣喜,仿佛从日常生活的重担中解脱出来。你的内在清晰度提高了,你能清楚地知道接下来该做什么——活动自然而然地从一个接一个地进行。你对完成下一个活动的信心毫不动摇。完成任务本身就是一种奖励,你享受每一秒钟。你的表现和结果飞速提升。

根据由 Csikszentmihalyi 主导的心理学研究,心流状态有六个组成部分。

  1. 注意力 你会感到一种深度的专注和完全的集中。

  2. 行动 你会产生一种行动偏向,你迅速而高效地推进当前的任务——你的专注意识帮助推动动能。每个动作都为下一个动作提供动力,形成一个成功行动的流动。

  3. 自我 你变得不那么意识到自己,关闭了内心的批评、怀疑和恐惧。你少考虑自己(反思),更多地专注于手头的任务(行动)。你会完全投入到任务中。

  4. 控制 即使你变得较少自我意识,你也会享受到一种更强的对当前情境的控制感,这赋予你冷静的自信,并让你跳出框架思考,发展创造性的解决方案。

  5. 时间 你失去了体验时间流逝的能力。

  6. 奖励 活动的劳动本身就是你想做的;可能没有外部奖励,但沉浸在活动中本身就是内在的奖励。

心流注意力这两个术语是密切相关的。在 2013 年关于注意力缺陷多动症(ADHD)的论文中,Rony Sklar 指出,注意力缺陷这个术语错误地暗示患者无法集中注意力。心流的另一个术语是超专注,大量心理学研究人员(例如,Kaufmann 等,2000 年)已经证明,ADHD 患者完全能够进行超专注;他们只是无法长时间集中注意力于那些没有内在奖励的任务。你不需要被诊断为 ADHD 就知道,专注于你不喜欢做的事情是很困难的。

但是,如果你曾经完全沉浸在玩一款刺激的游戏、编写有趣的应用程序或观看一部有趣的电影中——你就知道,如果你喜欢这项活动,进入心流状态有多么容易。在心流状态下,你的身体会分泌五种令人愉悦的神经化学物质,如内啡肽、多巴胺和血清素。这就像体验服用娱乐性药物的“好处”,但没有一些负面后果——甚至 Csikszentmihalyi 也警告过,心流可能会上瘾。学会进入心流状态让你变得更聪明、更高效——如果你能够将这种心流活动引导到像编程这样的高效工作中。

现在,你可能会问:告诉我方法——我该如何进入心流?接下来我们来回答这个问题!

如何实现心流

Csikszentmihalyi 提出了实现心流的三个条件:(1)你的目标必须明确,(2)你环境中的反馈机制必须是即时的,(3)机会与能力之间必须保持*衡。

明确的目标

如果你在写代码,你必须有一个明确的目标,所有的小动作都应围绕这个目标进行。在心流状态下,每一个动作自然会引导到下一个动作,接着是下一个动作,所以必须有一个最终目标。人们在玩电子游戏时常常会进入心流状态,因为如果你成功完成了小的动作——比如跳过一个移动的障碍物——你最终会成功达成大目标——比如通过关卡。要利用心流加速你的编程效率,你必须有一个明确的项目目标。每一行代码都让你离完成更大的代码项目更*。追踪你写过的代码行数是一种将编程工作游戏化的方法!

反馈机制

反馈机制奖励期望的行为,并惩罚不期望的行为。机器学习工程师知道,他们需要一个很好的反馈机制来训练高效的模型。例如,你可以通过奖励机器人每一秒不摔倒,来教它如何走路,并要求它优化以获得最大总奖励。这样,机器人就能自动调整它的行动,以在一段时间内获得最大奖励。我们人类在学习新事物时的行为非常相似。我们寻求来自父母、老师、朋友或导师的认可——甚至来自我们不喜欢的邻居——并调整我们的行为以最大化认可,同时最小化(社交)惩罚。通过这种方式,我们学会了采取特定的行动并避免其他行为。接收反馈对于这种学习方式至关重要。

反馈是心流的前提条件。为了在工作中实现更多的心流,寻求更多的反馈。每周与项目合作伙伴会面,讨论你的代码和项目目标,然后结合伙伴们的反馈。将你的代码发布到 Reddit 或 StackOverflow,并请求反馈。早期并频繁地发布你的 MVP,以便获取用户反馈。寻求编程工作反馈效果显著,即使它带有延迟的满足感,因为它会提高你在完成导致反馈的活动中的参与度。当我发布了 Finxter,我的 Python 学习软件应用程序时,我开始接收到源源不断的用户反馈,我被深深吸引。反馈让我不断回到代码中工作,并在改进应用程序的过程中经历了许多心流状态。

*衡机会与能力

心流是一种积极的心理状态。如果任务过于简单,你会感到无聊,失去沉浸感。如果任务过于困难,你会很早就放弃。任务必须具有挑战性,但又不能让人感到压倒性。

图 6-1 展示了可能的心理状态图;该图像取自 Csikszentmihalyi 的原始研究,并进行了重新绘制。

图表,横坐标表示你的技能水*,纵坐标表示任务的挑战。图表上标记了不同的情绪状态,从左上角到右下角依次为:恐慌、焦虑、心流、无聊和冷漠。心流状态是挑战和技能的直接*衡。

图 6-1:在心流状态下,你会发现挑战既不太难也不太容易,正好符合你当前的技能水*。

横坐标量化你的技能水*,从低到高;纵坐标量化任务的难度,从低到高。所以,举个例子,如果任务对于你的技能水*来说太难,你会感到恐慌;如果任务太简单,你会感到冷漠。但如果任务的难度与你当前的技能相匹配,你就能最大化进入心流的可能性。

诀窍是不断寻求更难的挑战,但不要让自己陷入焦虑状态,并根据挑战增加相应的技能水*。这种学习循环会让你不断提升生产力和技能,并且在同时享受更多的工作乐趣。

编程人员流畅状态小贴士

在 2015 年,Owen Schaffer 在他题为《打造有趣的用户体验:一种促进流畅状态的方法》的白皮书中,提出了七个流畅状态的条件:(1)知道该做什么,(2)知道如何做,(3)知道自己做得怎么样,(4)知道该去哪里,(5)寻求挑战,(6)提升技能以应对更高的挑战,(7)从干扰中解脱出来(Human Factors International)。基于这些条件和我自己的考虑,我整理了一些针对编程领域的快速流畅状态技巧和策略。

  1. 总是有一个实际的编程项目在进行中, 而不是将时间花费在无目的的学习状态中。当新信息与某个你关心的事物产生实际影响时,你能更快地吸收它。我建议将学习时间分配成 70%的时间用于选择并完成一个实际的有趣项目,剩下 30%的时间用来读书、看教程或观看教育课程。我从在 Finxter 社区与成千上万的程序员互动和交流中学到,很多编码学生做反了这个分配,卡在学习的循环中,始终没有准备好投入到真正的项目中。故事总是一样:这些程序员一直困在编程理论中,不断学习却没有实际应用,这让他们更加意识到自己知识的局限性——这是一个负向的螺旋,最终导致停滞不前。解决的办法是设定清晰的项目目标,并无论如何都将项目推进到完成,这也与流畅状态的三个前提条件之一相吻合。

  2. 做一些能实现你目标的有趣项目。 流畅状态是一种兴奋的状态,所以你必须对你的工作充满兴奋。如果你是一个职业程序员,花时间思考你工作的目的。找到你项目的价值。如果你正在学习编程,真幸运——你可以选择一个令你兴奋的有趣项目!做对你有意义的项目。你会更享受其中,成功的概率也会更高,面对暂时的挫折时也能保持更多的韧性。如果你早上醒来就迫不及待地想投入到项目中,说明流畅状态已经触手可及。

  3. 发挥你的优势。 这是管理顾问彼得·德鲁克的黄金建议。你在很多领域的弱点总是比强项更多。在大多数活动中,你的技能可能低于*均水*。如果你只关注自己的弱点,实际上是在为失败铺路。相反,应该聚焦于你的优势,在这些优势周围构建大量的技能岛屿,并基本忽略大部分弱点。你独特的擅长是什么?你在计算机科学这个广泛领域里的具体兴趣是什么?列出清单,回答这些问题。最有助于你进步的活动之一就是找出自己的优势,并围绕这些优势强力安排你的日程。

  4. 将你的编码时间安排成大块时间。 这样你就能有足够的时间来理解面前的问题和任务——每个程序员都知道,加载一个复杂的代码项目到脑海中是需要时间的——并进入任务的节奏。假设艾丽斯和鲍勃共同参与一个代码项目。花费 20 分钟他们就能理解项目的需求,浏览项目,深入几个代码函数,并思考全局。艾丽斯每三天花三个小时工作在项目上,而鲍勃每天花一个小时。谁在项目中会取得更多进展?艾丽斯每天在项目上工作的时间是 53 分钟([3 小时 – 20 分钟] / 3)。考虑到加载时间的高消耗,鲍勃每天在项目上工作的时间只有 40 分钟。因此,在其他条件相同的情况下,艾丽斯每天将比鲍勃多工作 13 分钟。她有更高的几率进入“心流”状态,因为她能深入问题并完全沉浸其中。

  5. 在“心流”时间内消除干扰。 这看起来很明显,但有多少人做到了呢!那些能减少干扰的程序员——社交网络、娱乐应用、同事闲聊——比不能的程序员更容易进入心流状态。要取得成功,你必须做大多数人不愿意做的事:关闭干扰。关掉你的智能手机,关闭那个社交媒体标签页。

  6. 做你知道必须做的显而易见的事情, 这些事情是当下任务之外的:充足的睡眠、健康的饮食和规律的运动。作为程序员,你知道“垃圾进,垃圾出”这个道理:如果你给系统输入糟糕的信息,就会得到糟糕的结果。试着用变质的食材做一顿美味的饭菜——几乎不可能!高质量的输入会带来高质量的输出。

  7. 消耗高质量的信息, 因为输入越好,输出越好。读编程书籍而不是浅显的博客文章;更好的是,阅读发表在顶级期刊上的研究论文,这是最优质的信息。

结论

总结一下,以下是一些你可以尝试获得心流的最简单方法:阻塞较长时间的时间段,专注于一项任务,保持健康和良好的睡眠,设定明确的目标,找到你喜欢做的工作,积极寻求心流。

如果你追求心流,你最终会找到它。如果你每天系统地在心流状态下工作,你的工作效率将提高一个数量级。这是一个简单却强大的概念,适用于程序员和其他知识工作者。正如 Mihaly Csikszentmihalyi 所说:

我们生活中最美好的时刻不是那些被动、接收和放松的时光……最美好的时刻通常发生在人们的身体或大脑在自愿努力下,努力完成某个困难且有价值的事情时。

在下一章,你将深入探讨关于专注做好一件事的 Unix 哲学,这一原则被证明不仅是创建可扩展操作系统的优秀方法,也是一个很好的生活方式!

资源

  1. Troy Erstling, “心流状态的神经化学”,Troy Erstling(博客),troyerstling.com/the-neurochemistry-of-flow-states/

  2. Steven Kotler, “如何进入心流状态”,拍摄于 A-Fest 牙买加,2019 年 2 月 19 日,Mindvalley 视频,youtu.be/XG_hNZ5T4nY/

  3. F. Massimini, M. Csikszentmihalyi, 和 M. Carli, “最佳体验的监测:精神病康复工具”,神经与精神疾病杂志 175, 第 9 期(1987 年 9 月)。

  4. Kevin Rathunde, “蒙特梭利教育与最佳体验:新研究框架”,NAMTA 期刊 26, 第 1 期(2001 年 1 月):11-43。

  5. Owen Schaffer, “打造有趣的用户体验:促进心流的方法”,Human Factors International 白皮书(2015),humanfactors.com/hfi_new/whitepapers/crafting_fun_ux.asp

  6. Rony Sklar, “成人 ADHD 中的超专注:休息与唤醒状态下大脑皮层活动差异的 EEG 研究”(MA 论文,约翰内斯堡大学,2013),hdl.handle.net/10210/8640

第七章:专注做好一件事及其他 Unix 原则

这就是 Unix 哲学:编写做一件事并做好它的程序。编写能够协作的程序。编写处理文本流的程序,因为文本流是一种通用接口。

—Douglas McIlroy

Unix 操作系统的主流哲学很简单:做好一件事。这意味着,举个例子,通常创建一个能够可靠且高效地解决一个问题的函数或模块,比尝试同时解决多个问题更好。在本章稍后,你将看到一些展示“专注做好一件事”的 Python 代码示例,并学习 Unix 哲学如何应用于编程。我还会介绍一些世界上最出色的计算机工程师在创建当今操作系统时所采用的顶级原则。如果你是软件工程师,你将发现一些关于如何在自己项目中编写更好代码的宝贵建议。

但首先:Unix 到底是什么?为什么你应该关心它?

Unix 的崛起

Unix 是一种设计哲学,启发了今天许多最流行的操作系统,包括 Linux 和 macOS。Unix 操作系统家族出现在 1970 年代末期,当时 Bell Systems 将其技术的源代码公开。此后,许多扩展和新版本由大学、个人和公司开发。

今天,注册商标 Unix 标准认证了操作系统是否符合特定的质量要求。Unix 及类 Unix 操作系统对计算机产生了重大影响。大约 7 成的网络服务器运行的是基于 Unix 的 Linux 系统。今天的大部分超级计算机也都运行 Unix 系统。甚至 macOS 也是一个注册的 Unix 系统。

Linus Torvalds、Ken Thompson、Brian Kernighan——Unix 的开发者和维护者名单中包含了全球一些最具影响力的程序员。你可能会想,必须有一个很好的组织系统,才能让全球的程序员合作构建庞大的 Unix 生态系统,涵盖数百万行代码。确实如此!促成这种规模合作的哲学理念就是 DOTADIW(没错,真的!)——或者说是专注做好一件事。关于 Unix 哲学已经有许多书籍被写出来,因此在这里我们将重点关注最相关的思想,并通过 Python 代码片段来展示一些示例。据我所知,迄今为止还没有一本书将 Unix 原则与 Python 编程语言结合在一起讲解。那么,我们开始吧!

哲学概述

Unix 哲学的基本理念是构建简单、清晰、简洁、模块化的代码,易于扩展和维护。这可能意味着很多不同的事情——稍后在本章会有更多内容——但目标是通过优先考虑可读性而非效率,偏向组合性而非单体设计,来让多人可以共同在同一个代码库上工作。单体应用程序设计时没有模块化,意味着代码的大片逻辑无法在不访问整个应用程序的情况下被重用、执行或调试。

假设你写了一个程序,它接受一个统一资源定位符(URL),并在命令行上打印该 URL 的 HTML。我们把这个程序叫做url_to_html()。根据 Unix 哲学,这个程序应该做好一件事,而这件事就是从 URL 获取 HTML 并将其打印到 shell 中(见清单 7-1)。就这么简单。

import urllib.request

def url_to_html(url):
    html = urllib.request.urlopen(url).read()
    return html

清单 7-1:一个简单的代码函数,它读取给定 URL 的 HTML 并返回字符串

这就是你所需要的。不要添加更多功能,比如过滤掉标签或修复错误。例如,你可能会有冲动添加代码来修复用户可能犯的常见错误,比如忘记关闭标签,比如一个<span>标签没有用</span>关闭,如这里所示:

<a href='nostarch.com'>**<span>**Python One-Liners</a>

根据 Unix 哲学,即使你发现了这些类型的错误,你也不应该在这个特定的函数中修复它们。

另一个诱惑是为这个简单的 HTML 函数自动修复格式。例如,下面的 HTML 代码看起来并不漂亮:

<a href='nostarch.com'><span>Python One-Liners</span></a>

你可能更喜欢这种代码格式:

<a href='nostarch.com'>
    <span>
        Python One-Liners
    </span>
</a>

然而,你的函数名称是url_to_html(),而不是prettify_html()。添加像代码美化这样的功能,会增加第二个功能,而这个功能可能并不是某些用户所需要的。

相反,你会被鼓励创建另一个名为prettify_html(url)的函数,它的“唯一目标”是修复 HTML 的样式问题。这个函数可以内部使用url_to_html()函数来获取 HTML,然后再进行进一步处理。

通过让每个函数只专注于一个目标,你可以提高代码的可维护性和可扩展性。一个程序的输出是另一个程序的输入。你减少了复杂性,避免了输出中的杂乱无章,并专注于将一件事做到最好。

尽管单个子程序看起来可能很小,甚至微不足道,但你可以将这些子程序组合起来,创建更复杂的程序,同时保持它们易于调试。

15 个有用的 Unix 原则

接下来我们将深入探讨 15 个与今天最相关的 Unix 原则,并在可能的情况下用 Python 示例加以实现。我已经从 Unix 编码专家 Eric Raymond 和 Mike Gancarz 那里整理了这些原则,并将其调整为现代 Python 编程的要求。你会发现这些原则中的许多都与本书中的其他原则相符或有重叠。

1. 让每个函数专注于做好一件事

Unix 哲学的基本原则是做一件事并做好它。让我们看看这在代码中是如何表现的。在清单 7-2 中,你实现了一个函数display_html(),它接受一个 URL 字符串并显示该 URL 上的美化 HTML。

import urllib.request
import re

def url_to_html(url):
    html = urllib.request.urlopen(url).read()
    return html

def prettify_html(html):
    return re.sub('<\s+', '<', html)

def fix_missing_tags(html):
    if not re.match('<!DOCTYPE html>', html):
        html = '<!DOCTYPE html>\n' + html
    return html

def display_html(url):
    html = url_to_html(url)
    fixed_html = fix_missing_tags(html)
    prettified_html = prettify_html(fixed_html)
    return prettified_html

清单 7-2:让每个函数或程序做一件事,并做好它。

这段代码在图 7-1 中有所展示。

展示将几个 HTML 函数与多个 URL 和代码行链接的框,显示使用单一功能的函数的模块化和高效性。

图 7-1:多个简单函数概览——每个函数都做一件事,但做得很好——共同协作完成更大的任务

这段代码提供了一个示例实现,执行以下步骤:

  1. 从给定的 URL 位置获取 HTML。

  2. 修复一些缺失的标签。

  3. 美化 HTML。

  4. 返回结果给函数调用者。

如果你用一个指向不太美观的 HTML 代码'< a href="https://finxter.com">Solve next Puzzle</a>'的 URL 运行代码,函数display_html会通过协调小型代码函数的输入输出,修复格式不正确(且错误的)HTML,因为每个小函数都只做它自己擅长的事。

你可以用这一行打印主函数的结果:

print(display_html('https://finxter.com'))

这段代码会将 HTML 代码打印到你的 shell 中,同时新标签已添加,且多余的空格被移除:

<!DOCTYPE html>
<a href="https://finxter.com">Solve next Puzzle</a>

想象这个程序就像终端中的浏览器。Alice 调用函数display_html(url),它接收 URL 并将其传递给另一个函数url_to_html(url),该函数从给定的 URL 位置获取 HTML。这样就不需要实现重复的功能。幸运的是,url_to_html()函数的编写者保持了该函数的简洁性,因此你可以直接使用它返回的 HTML 输出作为fix_missing_tags(html)函数的输入。在 Unix 中,这叫做管道:一个程序的输出作为另一个程序的输入传递。fix_missing_tags()的返回值是修复后的 HTML 代码,补充了原始 HTML 中缺失的</span>闭合标签。然后你把输出传递给prettify_html(html)函数,等待结果:经过缩进修正后的 HTML 代码,让它更具用户友好性。只有在此之后,display_html(url)函数才会将美化和修复后的 HTML 代码返回给 Alice。你会发现,一系列的小函数通过连接和管道组合起来,能够完成相当大的任务!

在你的项目中,你可以实现另一个函数,该函数不美化 HTML,而仅仅添加<!DOCTYPE html>标签。然后你可以实现第三个函数,它美化 HTML 但不添加新标签。通过保持函数简洁,你可以基于现有功能轻松创建新的代码,而且不会有太多冗余。代码的模块化设计使得它具备了可重用性、可维护性和可扩展性。

将此版本与可能的单一实现进行比较,在后者中,display_html(url)函数必须自己完成所有那些小任务。你无法部分重用某些功能,比如从 URL 获取 HTML 代码或修复 HTML 代码。如果你使用一个单一的代码函数来做所有的事情,它会像这样:

def display_html(url):
    html = urllib.request.urlopen(url).read()
    if not re.match('<!DOCTYPE html>', html):
        html = '<!DOCTYPE html>\n' + html
    html = re.sub('<\s+', '<', html)
    return html

这个函数现在更复杂了:它处理多个任务,而不是专注于一个任务。更糟糕的是,如果你没有去掉打开标签 '<' 后的空格,且实现了同一函数的多个变种,你就必须复制并粘贴其余的代码行。这会导致代码冗余和可读性下降。你添加的功能越多,问题就会越严重!

2. 简单优于复杂

简单优于复杂 是本书的核心原则。你已经在许多不同的地方见过它——我强调这一点是因为,如果你不果断地简化,你就会滋生复杂性。在 Python 中,简单优于复杂 甚至进入了非官方的规则书。如果你打开 Python 的 shell 并输入 import this,你将获得著名的 Python 之禅(见 Listing 7-3)。

> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
**Simple is better than complex.**
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

Listing 7-3: Python 之禅

既然我们已经详细讨论过简单的概念,我这里就不再赘述。如果你仍然想知道为什么简单优于复杂,请回到第一章,了解高复杂性带来的负面生产力效应。

3. 小而美

与其编写庞大的代码块,不如编写小函数并像一个架构师一样调度这些函数之间的交互,如 Figure 7-1 所示。保持程序小型化有三个主要原因:

降低复杂性。

  1. 随着代码行数的增加,代码变得更难理解。这是一个认知事实:大脑一次只能处理有限的信息块。信息块过多会让你难以看清全貌。通过将代码做得更小,减少函数中的代码行数,你可以提高可读性,并减少向代码库中引入错误的可能性。

提高可维护性。

  1. 将代码拆分成许多小的功能块使得代码更容易维护。增加更多小函数不太可能产生副作用,而在一个庞大的、单一的代码块中,你所做的任何改动都可能轻易产生意外的全局效应,尤其是在多个程序员同时工作时。

提高可测试性。

  1. 许多现代软件公司采用测试驱动开发,该方法通过使用单元测试来对每个函数和单元的输入进行压力测试,并将输出与预期结果进行比较。这使得你能够找到并隔离错误。单元测试在小规模代码中更为有效且更容易实现,因为每个函数只专注于一件事,所以你能清楚知道预期的结果是什么。

Python 本身,而不是 Python 中的某个小型代码示例,正是这一原则的最佳例子。任何大师级程序员都会使用他人的代码来提升自己的编码生产力。数百万开发者花费了无数小时优化代码,而你可以在一瞬间将这些代码导入到自己的项目中。像大多数其他编程语言一样,Python 通过库提供这种功能。许多不常用的库不会随默认实现一起提供,需要显式安装。通过不将所有库作为内建功能提供,Python 安装在你的电脑上依然保持相对小巧,但不牺牲外部库的潜力。此外,这些库本身也很小——每个库都专注于一个有限的功能子集。与其拥有一个解决所有问题的庞大库,我们更倾向于拥有多个小型库,每个库负责解决一小部分问题。小即是美。

每隔几年,新的架构模式就会出现,承诺将大型单体应用拆分成优雅的小型应用,以便扩展软件开发周期。*期的例子有通用对象请求代理架构(CORBA)、面向服务的架构(SOA)和微服务。它们的思路是将一个大型的软件模块拆分为一系列独立可部署的组件,然后多个程序可以访问这些组件,而不仅仅是一个程序。希望通过相互共享和构建各自的微服务,来加速软件开发领域的整体进展。

这些趋势背后的根本驱动力是编写模块化和可重用代码的理念。通过学习本章中提出的思想,你已经为快速而深刻地理解这些趋势以及未来的趋势做好了准备,并且这些趋势也朝着模块化的方向发展。从一开始就应用正确的原则,能够让你始终走在前沿。

4. 尽早构建原型

Unix 团队是我们在第三章讨论的原则——构建 MVP——的热衷支持者。这能帮助你避免陷入完美主义的循环,不断添加功能,毫无必要地成倍增加复杂性。如果你从事的是像操作系统这样的庞大软件应用,你就无法承受复杂性的增加!

图 7-2 展示了一个早期应用启动的例子,充满了不必要的功能,完全违背了 MVP 原则。

包含过多功能的 finxter 版本截图,导致难以确定其焦点。

图 7-2:Finxter.com 应用与 Finxter MVP 对比

这个应用程序具有如交互式解题检查、拼图投票、用户统计、用户管理、付费功能以及相关视频等特点,同时也有一些简单功能,如标志。这些在产品初次发布时都是不必要的。事实上,Finxter 应用的 MVP(最简可行产品)应该仅仅是一个简单的代码拼图图像,分享在社交媒体上。这足以验证用户需求的假设,而不需要花费多年时间来构建应用程序。早失败,常失败,向前失败。

5. 优先选择可移植性而非效率

可移植性是指一个系统或程序可以从一个环境迁移到另一个环境,并且仍然能够正常工作。软件的一个主要优势是它的可移植性:你可以在自己的电脑上编写程序,数百万用户也可以在他们的电脑上运行相同的程序,而无需进行任何适配。

然而,可移植性是以效率为代价的。这种可移植性/效率权衡在技术文献中有很多讨论:通过将软件量身定制为单一环境,可以实现更高的效率,但这会牺牲可移植性。虚拟化就是这种权衡的一个典型例子:通过在软件和运行该软件的底层基础设施之间添加一层额外的软件,程序几乎可以在任何物理机器上运行。此外,虚拟机还可以将当前执行状态从一台物理机器传输到另一台。这提高了软件的可移植性。然而,虚拟化所需的附加层会降低运行时和内存效率,因为它在程序和物理机器之间增加了中介开销。

Unix 哲学提倡优先选择可移植性而非效率;这一点是合理的,因为操作系统被如此多的用户使用。

但是,倾向于可移植性的经验法则同样适用于更广泛的软件开发者群体。降低可移植性意味着降低了你应用程序的价值。如今,极大提高可移植性已经变得很常见——即使是以牺牲效率为代价。基于网页的应用程序预期能够在任何有浏览器的计算机上运行,无论是 macOS、Windows 还是 Linux。网页应用程序还越来越注重无障碍功能,例如为视力障碍者提供便利,尽管托管一个能够促进无障碍访问的网站可能效率较低。许多资源比计算周期更有价值:人类的生命、人类的时间以及计算机所带来的其他二级后果。

那么,除了这些一般性考虑外,为可移植性编程意味着什么呢?在列表 7-4 中,我们创建了一个计算指定参数*均值的函数——按照我们编写的方式,它并没有为可移植性进行优化。

import numpy as np

def calculate_average_age(*args):
    a = np.array(args)
    return np.average(a)

print(calculate_average_age(19, 20, 21))
# 20.0

列表 7-4:*均值函数,不是最大化可移植性

这段代码因两点原因而不可移植。首先,函数名calculate_average_age()不够通用,无法在任何其他上下文中使用,尽管它只是计算一个*均值。例如,你可能不会想到使用它来计算网站访问者的*均人数。其次,它不必要地使用了一个库,因为你完全可以在不依赖任何外部库的情况下轻松计算*均值(参见 Listing 7-5)。通常使用库是个好主意,但前提是它们能带来实际价值。在这种情况下,添加一个库反而降低了可移植性,因为用户可能没有安装这个库;此外,效率的提升几乎为零。

在 Listing 7-5 中,我们重新创建了具有更高可移植性的函数。

def average(*args):
    return sum(args) / len(args)

print(average(19, 20, 21))
# 20.0

Listing 7-5:*均函数,可移植

我们将函数重命名为更通用的名称,并去除了不必要的导入。现在,你不必担心这个库会被弃用,并且可以将相同的代码移植到其他项目中。

6. 将数据存储在*面文本文件中

Unix 哲学提倡使用*面文本文件来存储数据。*面文本文件是简单的文本或二进制文件,没有高级机制来访问文件内容——与例如数据库社区使用的许多更高效但更复杂的文件格式不同。这些是简单的、可供人类阅读的数据文件。常见的逗号分隔值(CSV)格式就是*面文件格式的一个例子,其中每一行都对应一个数据条目(参见 Listing 7-6),而且任何不熟悉这些数据的人,通过查看这些文件也能获得一些基本理解。

Property Number,Date,Brand,Model,Color,Stolen,Stolen From,Status,Incident number,Agency
P13827,01/06/2016,HI POINT,9MM,BLK,Stolen Locally,Vehicle,Recovered Locally,B16-00694,BPD
P14174,01/15/2016,JENNINGS J22,,COM,Stolen Locally,Residence,Not Recovered,B16-01892,BPD
P14377,01/24/2016,CENTURY ARMS,M92,,Stolen Locally,Residence,Recovered Locally,B16-03125,BPD
P14707,02/08/2016,TAURUS,PT740 SLIM,,Stolen Locally,Residence,Not Recovered,B16-05095,BPD
P15042,02/23/2016,HIGHPOINT,CARBINE,,Stolen Locally,Residence,Recovered Locally,B16-06990,BPD
P15043,02/23/2016,RUGAR,,,Stolen Locally,Residence,Recovered Locally,B16-06990,BPD
P15556,03/18/2016,HENRY ARMS,.17 CALIBRE,,Stolen Locally,Residence,Recovered Locally,B16-08308,BPD

Listing 7-6:来自Data.gov的被盗枪支数据,采用*面文件格式(CSV)提供

你可以轻松地共享*面文本文件,在任何文本编辑器中打开并手动修改它们。然而,这种便利性是以效率为代价的:为特定用途定制的数据格式可以更高效地存储和读取数据。例如,数据库使用自己在磁盘上的数据文件,并利用优化手段,如详细的索引和压缩方案来表示数据。如果你打开这些文件,你根本不会理解它们的内容。这些优化使得程序能够以比一般*面文本文件更少的内存消耗和更低的开销读取数据。在*面文件中,系统必须扫描整个文件才能读取特定的行。Web 应用程序也需要一种更高效的优化数据表示方式,以便允许用户快速访问并减少延迟,因此它们很少使用*面表示法和数据库。

然而,你应该仅在确认需要时才使用优化的数据表示——例如,如果你创建的是一个对性能要求极高的应用,如 Google 搜索引擎,它能够在毫秒级别找到与用户查询最相关的网页文档!对于许多较小的应用,例如从具有 10,000 条记录的现实世界数据集训练机器学习模型,推荐使用 CSV 格式来存储数据。使用具有专用格式的数据库会减少移植性并增加不必要的复杂性。

列表 7-7 给出了一个在*面格式更为优越的情况下的示例。它使用 Python,这是一种在数据科学和机器学习应用中最受欢迎的编程语言之一。在这里,我们希望对一个人脸图像数据集执行数据分析任务,因此我们从一个*面 CSV 文件加载数据并进行处理,选择便于移植的方式,而非使用数据库的更高效方式。

From sklearn.datasets import fetch_olivetti_faces
From numpy.random import RandomState

rng = RandomState(0)

# Load faces data
faces, _ = fetch_olivetti_faces(...)

列表 7-7:从*面文件加载数据进行 Python 数据分析任务

在函数fetch_olivetti_faces中,我们加载了 scikit-learn 的Olivetti faces数据集,该数据集包含一组人脸图像。加载函数仅仅是读取这些数据并将其加载到内存中,然后再开始实际的计算。无需数据库或层级数据结构。该程序是自包含的,无需安装数据库或设置复杂的数据库连接。

7. 利用软件杠杆获得优势

使用杠杆意味着用少量的精力来放大你努力的效果。例如,在金融领域,杠杆意味着使用他人的资金进行投资和增长。在大型公司中,它可能意味着利用广泛的分销网络将产品放置到全球的商店中。作为程序员,你应该利用前人编程者的集体智慧:使用库来实现复杂功能,而不是从头编写代码;使用 StackOverflow 和群体智慧来修复代码中的 bug;或者请其他程序员审查你的代码。这些都是杠杆的形式,可以让你用更少的努力完成更多的事情。

第二种杠杆来源是计算能力。计算机可以比人类更快地执行工作(且成本更低)。创造更好的软件,与更多的人分享,利用更多的计算能力,并更加频繁地使用他人的库和软件。优秀的程序员能够快速编写出优质的源代码,而伟大的程序员则能够利用众多可用的杠杆来源来提升他们的代码。

作为示例,列表 7-8 展示了我书中《Python One-Liners》(No Starch Press,2020)中的一个单行程序,它可以抓取给定的 HTML 文档,找到所有包含子字符串 'finxter' 并且同时包含 'test''puzzle' 的 URL。

## Dependencies
import re

## Data
page = '''
<!DOCTYPE html>
<html>
<body>

<h1>My Programming Links</h1>
<a href="https://app.finxter.com/">test your Python skills</a>
<a href="https://blog.finxter.com/recursion/">Learn recursion</a>
<a href="https://nostarch.com/">Great books from NoStarchPress</a>
<a href="http://finxter.com/">Solve more Python puzzles</a>

</body>
</html>
'''

## One-Liner
practice_tests = re.findall("(<a.*?finxter.*?(test|puzzle).*?>)", page)

## Result
print(practice_tests)
# [('<a href="https://app.finxter.com/ ">test your Python skills</a>', 'test'),
#  ('<a href="http://finxter.com/">Solve more Python puzzles</a>', 'puzzle')]

列表 7-8:分析网页链接的单行代码解决方案

通过导入re库,我们利用了正则表达式的强大技术,瞬间将成千上万行代码转化为可用代码,并让我们用一行代码编写整个程序。杠杆效应是你成为一名优秀程序员的重要伙伴。例如,使用代码库而不是自己从头实现一切,就像用应用程序来规划你的旅行,而不是通过纸质地图来逐步确定每一个细节。

8. 避免使用封闭式用户界面

封闭式用户界面是指要求用户在继续执行程序主流程之前必须与程序交互的界面。典型的例子包括小程序如 Secure Shell (SSH)、topcatvim,以及编程语言中的功能如 Python 的input()函数。封闭式用户界面限制了代码的可用性,因为它们只能在人类的参与下运行。然而,通常封闭式用户界面背后的代码功能对于需要无需人工交互就能运行的自动化程序也很有用。大致来说,如果你把优秀的代码放在封闭式用户界面后面,那么没有用户交互是无法访问的!

比如,你创建了一个简单的寿命预测计算器,它接收用户的年龄作为输入,并根据简单的启发式方法返回预期的剩余寿命。

“如果你不到 85 岁,寿命预测为 72 减去你年龄的 80%。否则,寿命预测为 22 减去你年龄的 20%。”

你最初的 Python 代码可能类似于清单 7-9。

def your_life_expectancy():
    age = int(input('how old are you? '))

    if age<85:
        exp_years = 72 - 0.8 * age
    else:
        exp_years = 22 - 0.2 * age

    print(f'People your age have on average {exp_years} years left - use them wisely!')

your_life_expectancy()

清单 7-9:寿命预测计算器——一个简单的启发式方法——作为封闭式用户界面实现

以下是清单 7-9 中的一些代码执行示例。

> how old are you? 10
People your age have on average 64.0 years left - use them wisely!
> how old are you? 20
People your age have on average 56.0 years left - use them wisely!
> how old are you? 77
People your age have on average 10.399999999999999 years left - use them wisely!

如果你想自己试试,我已经在 Jupyter notebook 中分享了该程序,链接在这里:blog.finxter.com/clean-code/#Life_Expectancy_Calculator/。不过,请不要太认真对待它!

在清单 7-9 中,我们使用了 Python 的input()函数,它会阻塞程序的执行,直到接收到用户输入。在没有用户输入的情况下,代码什么也不做。这个封闭式用户界面限制了代码的可用性。如果你想计算从 1 到 100 每个年龄的寿命预测并绘制图表,你必须手动输入 100 个不同的年龄,并将结果存储在单独的文件中。然后,你必须复制并粘贴结果到一个新的脚本中来绘制它们。现在的功能实际上做了两件事:处理用户输入并计算寿命预测,这也违反了 Unix 的第一个原则:每个函数只做一件事并做得好。

为了使代码符合这个原则,我们将用户界面与功能性分离,这通常是提升代码质量的好方法(见清单 7-10)。

# Functionality
def your_life_expectancy(age):
    if age<85:
        return 72 - 0.8 * age
    return 22 - 0.2 * age

# User Interface
age = int(input('how old are you? '))

# Combine user input with functionality and print result
exp_years = your_life_expectancy(age)
print(f'People your age have on average {exp_years} years left - use them wisely!')

清单 7-10:寿命预测计算器——一个简单的启发式方法——没有封闭式用户界面

列表 7-10 中的代码在功能上与列表 7-9 完全相同,但有一个显著的优势:我们可以在各种场景下使用这个新函数,甚至是初始开发者未曾预料到的情况。在列表 7-11 中,我们使用该函数计算 0 到 99 岁之间的预期寿命,并绘制结果;注意到去除用户输入界面带来的可移植性。

import matplotlib.pyplot as plt

def your_life_expectancy(age):
    '''Returns the expected remaining number of years.'''
    if age<85:
 return 72 - 0.8 * age
    return 22 - 0.2 * age

# Plot for first 100 years
plt.plot(range(100), [your_life_expectancy(i) for i in range(100)])

# Style plot
plt.xlabel('Age')
plt.ylabel('No. Years Left')
plt.grid()

# Show and save plot
plt.savefig('age_plot.jpg')
plt.savefig('age_plot.pdf')
plt.show()

列表 7-11:计算 0-99 岁预期寿命并绘制图表的代码

图 7-3 展示了最终的图表。

列表 7-11 的结果图,显示一条从右上方开始下降的线;x 轴表示“年龄”,y 轴表示“剩余年数”。

图 7-3:启发式方法如何处理 0-99 岁输入

好的,任何启发式方法本质上都是粗糙的——但这里的重点是,避免使用限制性的用户界面帮助我们将代码付诸实践,绘制出这个图表。如果我们没有遵循这个原则,我们就无法复用原始代码函数your_life_expectancy,因为限制性用户界面要求每年(0 到 99 岁)都要有用户输入。通过遵循这一原则,我们简化了代码,并为未来的所有程序打开了使用和扩展这个启发式方法的可能性。我们没有针对某一个特定的用例进行优化,而是将代码编写得更加通用,能够被数百个不同的应用程序使用。为什么不把它做成一个库呢?

9. 使每个程序都成为一个过滤器

可以提出一个合理的观点,即每个程序本身已经是一个过滤器。过滤器使用特定的过滤机制将输入转化为输出。这使得我们可以通过将一个程序的输出作为另一个程序的输入,轻松地将多个程序串联起来,从而大大提高代码的可重用性。例如,通常来说,在函数内部打印计算结果并不是一个好的做法——而是建议程序返回一个字符串,之后可以打印出来、写入文件或用作另一个程序的输入。

例如,一个排序列表的程序可以被视为一个过滤器,它将未排序的元素过滤成排序后的顺序,正如列表 7-12 所示。

def insert_sort(lst):

    # Check if the list is empty
    if not lst:
        return []

    # Start with sorted 1-element list
    new = [lst[0]]

    # Insert each remaining element
    for x in lst[1:]:
        i = 0
        while i<len(new) and x>new[i]:
            i = i + 1
        new.insert(i, x)

    return new

print(insert_sort([42, 11, 44, 33, 1]))
print(insert_sort([0, 0, 0, 1]))
print(insert_sort([4, 3, 2, 1]))

列表 7-12:这个插入排序算法将一个未排序的列表过滤成一个已排序的列表。

该算法创建一个新列表,并将每个元素插入到所有左侧元素都小于该插入元素的位置。此函数使用复杂的过滤机制来改变元素的顺序,将输入列表转换为排序后的输出列表。

如果任何程序本身已经是一个过滤器,你应该通过直观的输入/输出映射将其设计为一个过滤器。接下来,我将为你解释这个概念。

过滤器的黄金标准是同质的输入/输出映射,其中一种类型的输入映射到相同类型的输出。例如,如果有人用英语和你交谈,他们期望你用英语回应,而不是用另一种语言。类似地,如果一个函数接受一个输入参数,期望的输出是函数的返回值。如果一个程序从文件中读取,期望的输出是一个文件。如果一个程序从标准输入中读取,它应该把程序输出到标准输出。你明白了吧:设计一个过滤器的最直观方法就是保持数据在相同的类别中。

示例 7-13 展示了一个负面例子,使用异质输入/输出映射,在这个例子中,我们构建了一个average()函数,将输入参数转换为它们的*均值——但它没有返回*均值,而是将结果打印到终端。

def average(*args):
    print(sum(args)/len(args))

average(1, 2, 3)
# 2.0

示例 7-13:异质输入/输出映射的负面例子

更好的方法,如示例 7-14 所示,是让函数average()返回*均值(同质输入/输出映射),然后你可以在另一个函数调用中使用print()函数将其打印到标准输出。这更好,因为它允许你将输出写入文件而不是打印,或者甚至将其作为另一个函数的输入。

def average(*args):
    return sum(args)/len(args)

avg = average(1, 2, 3)
print(avg)
# 2.0

示例 7-14:同质输入/输出映射的正面例子

当然,一些程序会从一种类别过滤到另一种类别——例如,将文件写入标准输出,或者将英语翻译成西班牙语。但遵循编写只做一件事且做得好的程序的原则(参见 Unix 原则 1),这些程序不应做任何其他事情。这就是编写直观且自然的程序的黄金标准——将它们设计为过滤器!

10. 更差的就是更好的

这一原则表明,在实际开发中,功能较少的代码往往是更好的方法。当资源有限时,发布一个较差的产品并率先进入市场,往往比在发布之前不断改进它要好。这一原则由列表处理(LISP)开发者理查德·加布里埃尔(Richard Gabriel)在八十年代末提出,类似于第三章中的 MVP 原则。不要过于字面地理解这一反直觉的原则。从质量角度来看,较差并不意味着更好。如果你拥有无限的时间和资源,最好总是使程序完美。然而,在有限资源的世界里,发布一个较差的版本通常更有效。例如,一个粗糙而直接的问题解决方案可以让你占据先发优势,吸引早期采用者的快速反馈,并在软件开发过程中早期获得动力和关注。许多从业者认为,后发者必须投入更多的精力和资源,才能创造出远超前者的产品,吸引用户。

11. 清晰的代码优于聪明的代码

我稍微修改了 Unix 哲学中的原始原则,清晰胜于巧妙,首先将这个原则专注于编程代码,其次将其与您已经学到的原则对齐:如何编写简洁的代码(参见第四章)。

这个原则强调了简洁和巧妙代码之间的权衡:巧妙的代码不应以简洁为代价。

例如,看看清单 7-15 中的简单冒泡排序算法。冒泡排序算法通过迭代遍历列表并交换任何未排序的相邻元素的位置来对列表进行排序:较小的元素移到左边,较大的元素移到右边。每当发生这种交换时,列表就会变得更加有序。

def bubblesort(l):
    for boundary in range(len(l)-1, 0, -1):
        for i in range(boundary):
            if l[i] > l[i+1]:
                l[i], l[i+1] = l[i+1], l[i]
    return l

l = [5, 3, 4, 1, 2, 0]
print(bubblesort(l))
# [0, 1, 2, 3, 4, 5]

清单 7-15:Python 中的冒泡排序算法

清单 7-15 中的算法可读性高,清晰明了,达到了目标,并且没有包含不必要的代码。

现在,假设你聪明的同事辩称,你可以通过使用条件赋值来缩短代码,用一行更少的代码来表达if语句(参见清单 7-16)。

def bubblesort_clever(l):
    for boundary in range(len(l)-1, 0, -1):
        for i in range(boundary):
            l[i], l[i+1] = (l[i+1], l[i]) if l[i] > l[i+1] else (l[i], l[i+1])            
    return l    

print(bubblesort_clever(l))
# [0, 1, 2, 3, 4, 5]

清单 7-16:“聪明的”Python 冒泡排序算法

这个技巧并没有改善代码,反而降低了可读性和清晰度。条件赋值特性可能很聪明,但使用它们是以表达想法时不够简洁的代价为前提的。想了解更多如何编写简洁代码的技巧,请参考第四章。

12. 设计程序以与其他程序连接

你的程序并非孤立存在。程序被调用来执行任务,无论是由人类还是由其他程序。因此,你需要设计 API 以便与外部世界——用户或其他程序——进行交互。通过遵循 Unix 原则 9,让任何程序都成为过滤器,即确保输入/输出映射直观易懂,你已经在设计可以连接的程序,而不是让它们孤立存在。伟大的程序员既是架构师,也是工匠。他们创造的新程序是旧功能和新功能以及其他人程序的独特组合。因此,接口能够成为开发周期的核心。

13. 使你的代码健壮

如果一个代码库是健壮的,它就不容易被破坏。代码健壮性有两种视角:程序员的视角和用户的视角。

作为程序员,你可能会通过修改代码而破坏它。因此,代码库对变更具有稳健性,即使是一个粗心的程序员也可以在不轻易破坏功能的情况下对代码库进行工作。假设你有一个大的单体代码块,并且你组织中的每个程序员都对这个代码块具有编辑权限。任何小的修改都可能破坏整个系统。现在,拿这个和像 Netflix 或 Google 这样的公司开发的代码进行比较,在这些公司,每个更改都必须经过多个审批级别才能部署到实际环境中;更改会经过充分的测试,因此部署的代码能有效防止破坏性更改。通过增加保护层,Google 和 Netflix 使他们的代码比脆弱的单体代码库更加稳健。

确保代码库的稳健性的一种方法是控制访问权限,以便个别开发人员在未经至少一个额外人员验证的情况下,不能修改应用程序,确保修改更可能为代码增加价值而不是造成破坏。这个过程可能会以较低的敏捷性为代价,但如果你不是一个单人创业公司,这个代价是值得支付的。我们在本书中已经看到了确保代码稳健性的其他方法:简洁即美,创建做一件事做得很好的函数,使用测试驱动开发,保持简单。还有一些其他容易应用的技术如下:

  • 使用像 Git 这样的版本控制系统,以便你可以恢复代码的先前版本。

  • 定期备份你的应用程序数据,使其可恢复(数据不属于版本控制系统的范畴)。

  • 使用分布式系统来避免单点故障:将你的应用程序运行在多台机器上,以降低单台机器故障对应用程序的负面影响。假设一台机器的故障概率是每天 1%,它大约每 100 天就会故障。创建一个由五台独立故障的机器组成的分布式系统,可以理论上将故障概率降低到 0.01⁵ × 100% = 0.00000001%。

对于用户而言,如果你不能通过提供有缺陷甚至恶意的输入轻易破坏一个应用程序,那么这个应用程序是稳健的。假设你的用户像一群猩猩一样乱按键盘,提交随机的字符序列,并且那些技术高超的黑客比你更了解应用程序,准备利用任何即便是最小的安全问题。你的应用程序必须能够抵御这两类用户。

对于前一类错误,防范相对简单。单元测试是一个强有力的工具:测试任何函数,针对你能想到的任何函数输入,特别是边界情况。例如,如果你的函数接受一个整数并计算*方根,检查它是否能处理负数输入和 0,因为未处理的异常会破坏可靠、简单、可链式的程序链条。然而,未处理的异常会引发另一个更微妙的问题,安全专家和本书技术编辑 Noah Spahn 提醒了我这一点:提供输入来破坏程序可能会为攻击者提供进入宿主操作系统的立足点。因此,检查你的程序处理各种输入的能力,从而使你的代码更健壮!

14. 修复你能修复的错误——但要尽早且大声地失败

虽然你应该尽可能修复代码中的问题,但你不应隐藏无法修复的错误。隐藏的错误会迅速积累,长时间隐藏后会变得越来越严重。

错误可能会积累。例如,假设你在驾驶辅助应用中的语音识别系统接收了错误的训练数据,将两个完全不同的语音波形错误地分类为同一个单词(参见图 7-4)。于是你的代码在试图将这两种完全不同的语音波形映射到同一个英文单词时抛出错误(例如,当你尝试将这种矛盾信息存储到一个将英语术语映射到语音波形的倒排索引中时,错误可能就会发生)。你可以通过两种方式编写代码:隐藏错误或将错误传播给应用程序、用户或程序员。虽然许多程序员直觉上希望通过隐藏错误来提高可用性,但这并不是最明智的方法。错误信息应该包含有用的信息。如果你的代码能让你及早意识到这个问题,你可以提前找到解决方案。你最好在错误的后果积累并摧毁数百万美元甚至人命之前,尽早意识到这些错误。

图示展示分类器算法将“right”和“left”这两个术语映射到同一个术语“right”上。

图 7-4:训练阶段的分类器将两种不同的语音波形映射到相同的英文单词上。

与其埋藏无法修复的错误,宁可抛出错误并交给用户处理,即便用户可能不会欣赏错误信息,且你的应用程序的可用性因此下降。另一种做法是将错误埋藏起来,直到它们变得无法处理。

继续我们错误训练数据的例子,示例 7-17 展示了一个例子,其中 Python 的 classify() 函数接受一个输入参数——需要分类的波形——并返回与该分类相关的英文单词。假设你已经实现了一个 duplicate_check(wave, word) 函数,用来检查数据库中是否存在与某个波形相似的波形,且它们返回相同的分类结果(waveword 配对)。在这种情况下,分类是模糊的,因为两种完全不同的波形映射到相同的英文单词,你应该通过抛出一个 ClassificationError 来与用户分享这个问题,而不是返回一个随机的分类结果。是的,用户会不高兴,但至少他们有机会自己处理错误带来的后果。修复你能修复的——但要尽早且显著地失败!

def classify(wave):
    # Do the classification
    word = wave_to_word(wave)    # to be implemented

    # Check if another wave
 # results in the same word
    if duplicate_check(wave, word):

        # Do not return a random guess
 # and hide the error!
        raise ClassificationError('Not Understood')

    return word

示例 7-17:如果波形无法明确分类,使用带有噪声失败的代码片段,而不是随机猜测

15. 避免手动编写代码:如果可以,写程序来写程序

该原则建议,应该自动生成的代码应该由机器完成,因为人类在这类重复且枯燥的活动中容易出错。有许多方式可以实现这一点——事实上,现代的高级编程语言,如 Python,都是通过此类程序编译成机器码的。通过编写程序来编写程序,这些编译器的创造者帮助高级程序员创建各种应用软件,而无需担心低级硬件编程语言。如果没有这些为我们编写程序的程序,计算机行业仍然处于初期阶段。

代码生成器和编译器今天已经能够生成大量的源代码。让我们从另一个角度来思考这个原则。如今,机器学习和人工智能的技术将“写程序来写程序”这一概念提升到了另一个层次。智能机器(机器学习模型)由人类组装,然后根据数据进行自我重写(和调优)。从技术上讲,机器学习模型就是一个已经经过多次自我重写的程序,直到其行为最大化某个设定的适应函数(通常由人类设置)。随着机器学习在计算机科学领域的普及(并逐渐占据主导地位),这一原则在现代计算中将变得越来越相关。人类程序员仍将在人类使用这些强大工具的过程中发挥重要作用;毕竟,编译器并没有取代人类的劳动,而是为人类程序员打开了一个全新的应用世界。我预计在编程领域也会发生同样的事情:机器学习工程师和软件架构师将通过连接不同的低级程序(如机器学习模型)来设计高级应用程序。好吧,这只是我对这个话题的一种看法——你的看法可能更乐观或更悲观!

结论

在这一章中,你已经学习了由 Unix 创始人设计的 15 条原则,用于编写更好的代码。值得一再强调它们——在阅读这些原则时,思考每一条是如何应用到你当前的代码项目中的。

  • 让每个函数都做好一件事。

  • 简单优于复杂。

  • 小就是美。

  • 尽早构建一个原型。

  • 在效率和可移植性之间,选择可移植性。

  • 将数据存储在纯文本文件中。

  • 利用软件杠杆发挥你的优势。

  • 避免封闭的用户界面。

  • 让每个程序都成为一个过滤器。

  • 更差的东西反而更好。

  • 干净的代码优于巧妙的代码。

  • 设计程序时要考虑到与其他程序的连接。

  • 使你的代码更加健壮。

  • 修复你能修复的东西——但要尽早失败,并发出明显的信号。

  • 编写程序来编写程序。

在下一章中,你将学习极简主义对设计的影响,以及它如何帮助你设计出让用户满意的应用程序,尽管这些程序做的事情更少。

资源

  1. Mike Gancarz,《Unix 哲学》,波士顿:数字出版社,1994 年。

  2. Eric Raymond,《Unix 的艺术》,波士顿:Addison-Wesley,2004 年,www.catb.org/~esr/writings/taoup/html/

第八章:设计中的“少即是多”

简单性是程序员的生活方式。虽然你可能不认为自己是设计师,但很可能在编程生涯中会创建许多用户界面。无论你是作为数据科学家需要创建一个视觉吸引人的仪表盘,作为数据库工程师需要设计一个易于使用的 API,还是作为区块链开发者需要设计一个简单的网页前端来填充智能合约数据,掌握基本的设计原则都会为你和你的团队带来好处,而且它们也很容易掌握!本章涉及的设计原则是普遍适用的。

具体来说,你将探索计算机科学中一个最受极简主义思维影响的关键领域:设计和用户体验(UX)。为了理解极简主义在设计和用户体验中的重要性,想一想 Yahoo 搜索和 Google 搜索、黑莓手机和 iPhone、Facebook Dating 和 Tinder 之间的差异:获胜的技术往往都拥有一种极简的用户界面。是否可以说,设计中的少即是多

我们首先将简要介绍一些受创作者极致专注影响的作品。随后,我们将展示如何将极简主义应用到你自己的设计工作中。

手机演化中的极简主义

计算机设计中极简主义的典范可以在手机的演变中看到(参见图 8-1)。诺基亚的 Mobira Senator,作为最早的商业“移动”电话之一,发布于 1980 年代,重达 10 公斤,操作起来相当复杂。次年,摩托罗拉推出了自家的 DynaTAC 8000X 机型,比其轻了十倍,仅重 1 公斤。诺基亚必须加倍努力。在 1992 年,诺基亚推出了比 DynaTAC 8000X 轻一半的 1011。*十年后,2000 年,凭借摩尔定律,诺基亚以其标志性的诺基亚 3310 获得了巨大成功,重量仅为 88 克。随着手机技术变得更加复杂和精细,用户界面,包括手机的尺寸、重量,甚至按键数量,都变得大大简化。手机的演化证明了,即使应用的复杂性增加了几个数量级,极简的设计也可以实现。甚至可以说,极简设计为智能手机应用的成功和它们在当今世界中的爆炸性使用奠定了基础。你很难在诺基亚 Senator 上浏览网页、使用地图服务或发送视频信息!

1980 到 2010 年手机的时间线,起初是重达 10 公斤的诺基亚 Senator,最后是 2007 年重 135 克的 iPhone。

图 8-1:手机演变中的一些里程碑

极简设计不仅在智能手机中有所体现,许多其他产品也应用了这种设计。公司利用它来改善用户体验并创建聚焦的应用程序。还有什么能比 Google 搜索引擎更好的例子呢?

搜索中的极简主义

在图 8-2 中,我描绘了一个极简主义的设计,这种设计类似于 Google—及其模仿者—设计其主要用户界面的方式,将其作为通向互联网的一个极简化入口。不要误解,这种简洁和干净的设计并非偶然。这一登录页面每天有数十亿用户访问。它可能是 互联网 上最重要的展示区域。Google 登录页面上的一个小广告就能产生数十亿次点击,并可能为 Google 带来数十亿美元的收入,但尽管面临短期收入机会的损失,Google 依然没有让这些广告杂乱地占据其登录页面—公司管理层知道,保持品牌的完整性和专注,通过简约的设计来表达,是比通过出售这一黄金展示区来获得的收入更有价值的。

浏览器显示的页面,只有一个搜索框和一个搜索按钮。

图 8-2:现代搜索引擎的简约设计示例

现在将这种干净、专注的设计与其他搜索引擎(如 Bing 和 Yahoo!)所使用的设计进行比较,它们利用主要展示区域来进行推广(参见图 8-3)。

页面示例,搜索框和按钮挤在右上角,广告和文章占据了其余空间。

图 8-3:搜索引擎还是新闻聚合器?

即便是一些基础的搜索引擎网站,像 Yahoo! 这样的公司也走上了同样的道路:它们通过新闻和广告填充宝贵的展示区域来提高短期收入。但这些收入并没有持续下去,因为设计驱散了创造这些收入的“商品”:用户。可用性的降低导致了竞争劣势和用户搜索习惯的持续流失。任何与搜索无关的额外网站元素都会增加用户的认知负担,用户必须忽视那些吸引眼球的标题、广告和图片。流畅的搜索体验是 Google 持续扩大市场份额的原因之一。尽管最后的结论尚未出炉,但过去几十年专注型搜索引擎的日益流行表明,简约和专注设计的优势。

材料设计

Google 开发并目前遵循 材料设计 哲学和设计语言,该设计语言描述了一种根据用户直观理解来组织和设计屏幕元素的方式:物理世界的元素,如纸张、卡片、笔和阴影。图 8-3 展示了材料设计的一个例子。该网站采用卡片结构,每个卡片代表一条内容,这种布局类似于报纸的形式,有图片和标题文字。即使三维(3D)效果在二维(2D)屏幕上只是一个纯粹的幻象,网站的外观和感觉几乎是物质化的。

图 8-4 比较了左侧的材料设计和右侧去除了不必要元素的非材料设计。你可以说非材料设计更为极简,从某种意义上说,你是对的。它占用的空间更少,使用的颜色更少,非功能性的视觉元素(如阴影)也更少。然而,由于缺乏边界和直观熟悉的布局,非材料设计往往让读者感到困惑。真正的极简主义者总是会使用更少的资源来完成同样的任务。在某些情况下,这意味着减少网站上的视觉元素数量;在其他情况下,这意味着添加一些元素来减少用户需要思考的时间。一个经验法则是:用户的时间比屏幕空间更为稀缺。

你可以在material.io/design/找到完整的材料设计介绍,并且有许多美丽的案例研究。新的设计系统将会出现,用户也会越来越习惯数字化工作,因此材料隐喻可能会对下一代计算机用户变得不再那么有用。目前,值得注意的是,极简主义设计要求仔细考虑相关资源:时间、空间和金钱——你必须根据应用需求权衡这些资源。总之,极简主义设计去除所有不必要的元素,最终产生一个美丽的产品,很可能会让你的用户感到愉悦。

接下来,你将学习如何实现这一点。

左侧是遵循材料设计的一个网站卡片,具有清晰的卡片边界和明确的标题与文本结构。右侧是非材料设计,卡片没有边界,文本自由漂浮。

图 8-4:材料设计与“非材料”设计

如何实现极简主义设计

在本节中,你将学习一些技术技巧和方法,帮助你实现一个集中的极简主义设计。

使用空白

空白是极简主义设计的关键支柱之一。向你的应用程序添加空白可能看起来像是浪费宝贵的“房地产”。你一定疯了,竟然不利用高流量网站的每一寸空间,对吧?你本可以用它做广告、做“号召性用语”来卖更多产品、增加关于价值主张的额外信息,或者做更多个性化的推荐。你的应用越成功,越多的利益相关者会争夺他们能得到的每一点注意力,很可能没有人会要求你从应用中去除非空白元素。

采用“减法思维”可能不是一件自然而然的事;然而,通过用空白区域替代设计元素,将提高清晰度,并带来更集中的用户体验。成功的公司通过使用空白区域保持专注和锐利,从而将核心内容保持为核心。例如,Google 的主页使用了大量空白区域,Apple 在展示其产品时也使用了大量空白区域。当考虑用户时,请记住这一点:如果你让他们困惑,你就会失去他们。空白区域能提高用户界面的清晰度。

图 8-5 展示了一个在线披萨外卖服务的简单设计思路。空白区域帮助集中注意力于核心内容:让顾客下单披萨。不幸的是,很少有披萨外卖服务会大胆地以如此极端的方式使用空白区域。

一个披萨服务网页,网页上方是一个简单的黑白披萨线条画,下面是一个“订购你的披萨”按钮,其余部分是空白区域。

图 8-5:使用大量空白区域

空白区域也能提高文本的清晰度。看看图 8-6,它比较了两种格式化段落的方式。

两张网页,左侧的文本显得拥挤且难以阅读;右侧的文本行距较大,段落之间有额外的间距,并且段落首行缩进,使得阅读更加轻松。

图 8-6:文本中的空白区域

图 8-6 左侧的排版可读性较差。右侧通过引入空白区域改善了可读性和用户体验:文本块两侧的左、右边距,段落缩进,增加的行高,段落上下的边距以及增大的字体大小。这些额外的空间成本几乎可以忽略不计:滚动是便宜的,而且当发布物是数字化的时,我们不需要为纸张再砍更多的树木。另一方面,这些好处却非常显著:你的网站或应用程序的用户体验会显著提升!

去除设计元素

这个原则很简单:逐一审视每个设计元素,如果可能的话就去掉它。设计元素是指用户界面中所有可见的元素,比如菜单项、行动呼吁、特色列表、按钮、图片、框架、阴影、表单字段、弹窗、视频以及占用界面空间的所有其他元素。字面上去审视所有设计元素,并问自己:我能去掉它吗? 你会惊讶于答案常常是可以!

别搞错——去除设计元素并不容易!你已经花费了时间和精力去创造它们,而沉没成本偏差会让你即使知道它们不必要,仍然想要保留这些创作。图 8-7 展示了一个理想化的编辑过程,你可以根据每个元素与用户体验(UX)的相关重要性对其进行分类。例如,一个指向公司博客的菜单项在用户结账时对订购产品有帮助吗?没有,因此它应该被分类为不重要。亚马逊通过引入一键购买按钮,去除了订购过程中的所有不必要的设计元素。当我第一次在一场科学写作工作坊中了解到这种方法时,它彻底改变了我对编辑的思考方式。去除不重要和次要的设计元素,几乎没有风险却能保证提升可用性。但只有真正伟大的设计师才有勇气去去除重要的设计元素,留下非常 重要的元素。然而,这正是区分伟大设计与仅仅是好设计的关键。

两列列表,列出了“非常重要”,“重要”,“有些重要”,“不太重要”和“不重要”。右侧的除了“非常重要”和“重要”外,其他所有项都被删除了。

图 8-7:理想化编辑过程

图 8-8 展示了一个杂乱设计和一个极简编辑设计的例子。左侧的订单页面可能是你在在线披萨外卖服务中看到的。某些元素非常重要,例如送货地址和订单按钮,但像过于详细的配料列表和“新品推荐”信息框则不那么重要。右侧是经过编辑的订单页面。我们去除了不必要的元素,集中展示了最受欢迎的附加销售,合并了配料列表和标题,将标签与表单元素结合。这使我们能够增加更多的空白区域,甚至增大了一个非常重要的设计元素——美味披萨的图片!减少了杂乱,增加了焦点,可能通过改善用户体验提高了订单页面的转化率。

两张披萨外卖服务页面。左侧充满了选择项和填入送货信息的字段,还有一个“新品推荐”部分。右侧则将内容按一个个元素结构化,移除了“新品推荐”部分以及许多不太重要的输入字段。

图 8-8:去除不重要的元素:左侧是一个包含许多设计元素的杂乱订单页面;右侧是去除不必要设计元素后的聚焦订单页面。

去除功能

实现简约设计的最佳方式就是从你的应用程序中删除整合的功能!你在第三章中已经学习过关于创建最小可行产品(MVP)的概念,这些产品具有验证假设所需的最少功能。减少功能数量同样对帮助一个成熟企业重新聚焦其产品提供也很有帮助。

随着时间的推移,应用程序往往会积累各种功能——这种现象被称为功能膨胀。因此,必须越来越多地将注意力转移到维护现有功能上。功能膨胀导致软件臃肿,而臃肿的软件又会带来技术债务。这会降低组织的敏捷性。删除功能的理念是释放时间、精力和资源,并将它们重新投资于对用户最重要的少数几个功能。

功能膨胀及其对可用性造成的有害影响的典型例子包括雅虎(Yahoo!)、美国在线(AOL)和 MySpace,它们通过在用户界面中添加过多内容,某种程度上失去了原本专注的产品。

相比之下,世界上最成功的产品都是专注的,并且抵制了功能膨胀,即使表面上看起来不是这样。微软就是一个如何通过构建专注的产品帮助其成为超级成功公司了的好例子。常见的看法是,微软的产品,比如 Windows,是缓慢、低效并且充满了过多功能。但事实恰恰相反!你所看到的就是全部——你并不看到微软移除的无数功能。虽然微软非常庞大,但考虑到它的规模,实际上它非常专注。成千上万的开发者每天都在为微软编写新的代码。以下是曾在苹果和微软工作过的著名工程师 Eric Traut 对于微软专注的工程方法的看法:

很多人认为 Windows 是一个非常庞大且臃肿的操作系统,我不得不承认这可能是一个公*的描述。它的确很大,里面包含了很多东西。但从本质上讲,操作系统的内核以及构成核心部分的组件其实是相当精简的。

总结来说,当创建一个长期被许多用户使用的应用程序时,删除功能必须成为你日常工作的核心活动,因为这可以释放出资源、时间、精力和用户界面空间,这些可以重新投资于改进那些最重要的功能。

减少字体和颜色的变化

大量的变化会导致复杂性。如果你过多地改变字体类型、字体大小和颜色,你将增加认知摩擦,提高用户界面的感知复杂性,并牺牲清晰度。作为一个极简主义的开发者,你不想把这些心理效应引入到你的应用中。有效的极简设计通常只关注一种或两种字体类型、一种或两种颜色和一种或两种字体大小。图 8-9 展示了字体类型、大小、颜色和对比度的一致且极简的使用。话虽如此,请注意,设计有许多方法,也有许多方式可以在各个层面上实现聚焦和极简主义。比如,一种极简设计可能使用多种不同的颜色,以突出应用的俏皮和多彩的特征。

这是图 8-8 右侧页面,突出了页面中字体的一致性;按钮使用不同的背景色以突出显示。

图 8-9:字体大小、字体类型、颜色和对比度的极简使用

保持一致

一个应用程序通常不由单一的用户界面组成,而是一系列处理用户交互的界面。这引出了极简设计的另一个维度:一致性。我们将一致性定义为在一个给定的应用中,我们在设计选择上减少变化的程度。与其在每一步交互中呈现不同的“外观和感觉”,一致性确保应用程序感觉像一个连贯的整体。例如,苹果提供了许多 iPhone 应用,如浏览器、健康应用和地图服务,所有这些应用都有类似的外观和感觉,且能被识别为苹果产品。让不同的应用开发者达成一致的设计标准可能是具有挑战性的,但这对于苹果品牌的力量至关重要。为了确保品牌一致性,软件公司会使用品牌指南,所有参与的开发者必须遵守。在创建自己的应用时,一定要确保勾选此选项。你可以通过一致地使用模板和(CSS)样式表来实现这一点。

结论

本章重点讨论了极简主义设计师如何主导设计领域,这一点通过一些最成功的软件公司,如苹果和谷歌,得到了体现。往往,领先的技术和用户界面是极其简单的。没人知道未来会如何发展,但似乎语音识别和虚拟现实的广泛应用将导致更简单的用户界面。最终的极简设计是隐形的。随着普适计算的兴起——例如,Alexa 和 Siri——我认为在未来几十年里,我们将看到更加简单和更加专注的用户界面。所以,回答一开始提出的问题:是的,设计中“少即是多”!

在本书的下一章也是最后一章,我们将通过讨论专注力——以及它对今天程序员的相关性——来做总结。

资源

  1. 苹果公司的人机界面设计文档:developer.apple.com/design/human-interface-guidelines/

  2. 材料设计风格的文档:material.io/design/introduction/

第九章:集中注意力

在这一短章中,你将快速了解本书中最重要的课程:如何集中注意力。我们从讨论复杂性开始,这也是许多生产力障碍的根源。在这里,我们总结了如何根据你在本书中学到的内容来应对复杂性。

对抗复杂性的武器

本书的主要论点是,复杂性导致混乱。混乱是集中注意力的对立面。要解决复杂性带来的挑战,你需要使用强大的武器——集中注意力

为了证明这一论点,让我们看看这一科学概念,它在热力学和信息理论等许多科学领域中都广为人知。熵定义了一个系统中的随机性、无序性和不确定性的程度。高熵意味着高度的随机性和混乱。低熵意味着秩序和可预测性。熵是著名的热力学第二定律的核心内容,该定律指出:一个系统的熵随着时间的推移而增加——最终导致高熵状态

图 9-1 通过一个固定数量粒子排列的例子展示了熵。在左侧,你看到一个低熵状态,其中粒子的结构类似于一座房子。每个粒子的位置是可预测的,遵循更高层次的秩序和结构。粒子的排列有一个更大的计划。在右侧,你看到一个高熵状态:房屋结构已经崩溃。粒子的模式失去了秩序,进入了混乱状态。如果没有外力施加能量来减少熵,熵将随着时间的推移增加,所有秩序将被摧毁。比如,废墟中的城堡就是热力学第二定律的见证。你可能会问:热力学和编程效率有什么关系?稍后会变得清楚。让我们从基本原理继续思考。

左侧为低熵图示,显示形状像房子的点;右侧为高熵图示,点浮动四散,只能模糊地看到房子的轮廓。

图 9-1:低熵和高熵状态的对比

生产力意味着创造某些东西,无论是建造房屋、写书,还是编写软件应用程序。实质上,要想提高生产力,你必须减少熵,使资源按照一定方式排列,从而使你的大计划得以完成。

图 9-2 展示了熵与生产力之间的关系。你是一个创造者和建设者。你把原材料从高熵状态转移到低熵状态,通过专注的努力实现更宏大的计划。就这么简单!这是你生活中要超高效和成功的秘密,也是你需要的一切:花时间仔细规划你的行动路线,设定具体目标,并设计出规律的习惯和行动步骤,确保你得到你想要的结果。然后,运用专注的努力,利用你所拥有的所有资源——时间、精力、金钱和人力——直到你的计划得以实现。

高熵在左边,图中有一个人梦想到一个房子形状,上面有浮动的小点。箭头指向第二个图,图中有“力”字,显示这些小点正在形成不同的结构。低熵在右边,展示这些小点组成了一个房子形状,例证了力对一个想法的影响。

图 9-2:熵与生产力之间的关系

这听起来可能微不足道,但大多数人做错了。他们可能从未将这种专注的努力应用到一个想法的实现上,因此这个想法一直困在他们的脑海中。也有些人可能只是从一天过到另一天,什么新计划都没有。只有当你两者都做到——仔细规划并集中精力——你才会成为一个高效的人。所以,要成为比如说,智能手机应用的开发者,你必须通过规划和集中精力,将混乱中的事情整理有序,直到你实现目标。

如果这真的那么简单,为什么不是每个人都在这么做呢?主要的障碍,正如你所猜测的,是复杂性,通常是因为缺乏专注。如果你有多个计划,或者你允许计划随着时间的推移变化太多,你很可能只会朝着目标迈出几步就放弃了整个计划。只有当你专注于一个计划足够长时间时,你才能真正完成它。这适用于小成就,比如读完一本书(你快完成了!),也适用于大成就,比如编写和发布你的第一个应用程序。专注是缺失的关键。

图 9-3 是一个简单明了的图示,解释了专注的力量。

左边是一个圆圈,箭头指向各个方向,代表“没有目标的分散努力。”右边是一个圆圈,所有箭头指向一个星星,代表你的目标。这代表着朝着目标的专注努力。

图 9-3:相同的努力,不同的结果

你有限的时间和精力。假设你一天有八个小时的全力工作时间,你可以决定如何分配这些时间单位。大多数人会把时间分散到许多活动上。例如,Bob 可能会花一小时开会、一小时编程、一小时浏览社交媒体、一小时进行项目讨论、一小时闲聊、一小时编辑代码文档、一小时思考新项目、以及一小时写小说。Bob 在这些活动中最多只能取得*均水*的结果,因为他在每项活动上投入的时间和精力太少。Alice 可能会花八个小时做一件事:编程。她每天都这么做,并朝着发布一款成功应用的目标快速前进。她在少数几项事情上变得异常出色,而不是在许多事情上表现**。事实上,她在一项强大的技能上表现出色:编程。而她朝着目标前进的步伐是不可阻挡的。

统一原则

我开始写这本书时,认为专注只是许多生产力原则之一,但很快我意识到,专注是这本书中所有原则的统一原则。让我们来看一看:

80/20 法则

  1. 专注于少数关键任务:记住,20%的工作能够带来 80%的结果,忽略那些琐碎的任务,从而将你的生产力提高一到两个数量级。

构建最简可行产品

  1. 一次只专注于一个假设,从而减少产品的复杂性,减少特性膨胀,并最大化向产品市场契合的进展速度。在你编写任何一行代码之前,先明确你对用户需求的假设。去除所有除了绝对必要的功能之外的特性。少即是多!花更多的时间去思考要实现哪些功能,而不是实际编写它们。快速而频繁地发布你的 MVP,并通过测试和逐步添加来持续改进它。使用 A/B 测试来测试两个产品变体的反应,丢弃那些无法提升关键用户指标的特性。

编写简洁而清晰的代码

  1. 复杂性会减缓你对代码的理解,并增加出错的风险。正如我们从 Robert C. Martin 了解到的,“阅读与写作的时间比例远远超过 10:1。我们不断阅读旧代码,这是写新代码的一部分工作。”让你的代码更易读,可以简化新代码的编写。在他们著名的书籍《写作风格的元素》(Macmillan,1959 年)中,作者 Strunk 和 White 提出了一个永恒的原则来提升写作:省略不必要的词语。我建议你将这个原则扩展到编程中,并省略不必要的代码

过早的优化是万恶之源

  1. 将你的优化努力集中在重要的地方。过早优化是指在代码优化上花费宝贵资源,而这些优化最终证明是不必要的。正如 Donald Knuth 所说:“忘掉小的效率问题,大约 97% 的时间:过早优化是万恶之源。”我讨论了我的六个性能调优建议:进行对比度量、考虑 80/20 原则、投资改进算法、应用少即是多原则、缓存重复结果以及知道何时停止——这些都可以用一个词来总结,那就是聚焦

流状态

  1. 流状态是一种你完全投入于当前任务的状态——你专注且集中。流状态研究者 Csikszentmihalyi 提出了实现流状态的三个条件。(1) 你的目标必须明确。每一行代码都使你离成功完成更大的代码项目更*一步。(2) 你的环境中必须存在反馈机制,并且最好是即时反馈。找人,面对面或在线,来审查你的工作,并遵循 MVP 原则。(3) 机会和能力之间要保持*衡。如果任务太简单,你会失去兴奋感;如果任务太难,你可能会早早放弃。如果你遵循这些条件,你更有可能进入纯粹专注的状态。每天问自己:今天我可以做什么来将我的软件项目推向下一个水*?这个问题具有挑战性,但不会让你感到不堪重负。

做好一件事(Unix)

  1. Unix 哲学的基本思想是构建简单、清晰、简洁、模块化的代码,易于扩展和维护。这可以意味着许多不同的事情,但目标是通过优先考虑人的效率而非计算机的效率,推动多个人共同在一个代码库上工作,偏向可组合性而非单一设计。你需要让每个功能只关注一个目的。你已经学习了 15 条 Unix 原则来编写更好的代码,包括“小即美”、让每个函数只做好一件事、尽早构建原型,以及尽早并且大声地失败。如果你时刻牢记聚焦规则,在遵循这些原则时,你不一定需要记住每一条。

设计中的少即是多

  1. 这是关于用极简主义来聚焦你的设计。想想 Yahoo 搜索和 Google 搜索的差异,Blackberry 和 iPhone,OkCupid 和 Tinder:赢家通常是那些拥有极简用户界面的技术。通过使用极简的网页或应用设计,你可以专注于做得最好的那件事。将用户的注意力集中在你产品提供的独特价值上!

结论

复杂性是你的敌人,因为它最大化了熵。作为建设者和创造者,你想要最小化熵:纯粹的创造行为就是最小化熵。你通过施加集中的努力来实现这一点。专注是每个创造者成功的秘密。记住沃伦·巴菲特和比尔·盖茨都认为专注是他们成功的秘诀:专注

要在工作中实现专注,问问自己以下问题:

  • 我想将精力集中在哪个软件项目上?

  • 我想集中精力开发哪些特性来创建我的 MVP?

  • 我可以实现的最少设计元素是什么,用来测试我的产品的可行性?

  • 谁将使用我的产品,为什么?

  • 我可以从我的代码中移除什么?

  • 我的函数是否只做一件事?

  • 我怎样才能在更短的时间内实现相同的结果?

如果你不断问自己这些或类似的专注问题,那么你在这本书上花费的钱和时间都是非常值得的。

第十章:作者寄语

你已经读完了整本书,并且深入了解了如何在实践中提升编程技能。你学习了编写简洁清晰代码的技巧,以及成功从业者的策略。让我用一段个人的感言来结束这本书吧!

在研究复杂性难题后,你可能会问:如果简化如此强大,为什么大家都不这么做呢?问题在于,尽管简化有着巨大的好处,实施简化需要大量的勇气、精力和意志力。大大小小的组织常常坚决抵制去除工作和简化流程。曾经有人负责实施、维护和管理这些功能,他们常常会竭力保留自己的工作,即使他们知道这些工作大多是无关紧要的。问题在于损失厌恶——很难放弃任何即使只有一点点价值的东西。这是一个需要抗争的难题;我从未后悔我做过的任何简化措施。几乎所有事物都有价值,但重要的是要考虑你为获得这个价值付出了多少。当我开始我的 Finxter 教育网站时,我有意决定在很大程度上忽略社交媒体,立刻,我开始看到因多出来的时间而带来的显著正面结果,这些时间我可以用来做那些能推动进展的事。简化不仅对编程有益,对生活的各个领域也同样有益;它有能力让你的生活更加高效和宁静。希望通过阅读这本书,你已经更加开放于简化、减少和专注。如果你决定走简化的道路,你将与伟大的思想家同行:阿尔伯特·爱因斯坦认为:“简单而谦逊的生活方式是最适合每个人的,既有益于身体,又有益于心灵。”亨利·大卫·梭罗总结道:“简朴,简朴,再简朴!我说,让你的事务只有两三件,而不是一百件或一千件。”而孔子知道:“人生其实很简单,但我们非得让它变得复杂。”

为了帮助你持续简化,我创建了一份一页的书籍摘要,格式为便携文档格式(PDF),你可以在本书配套页面blog.finxter.com/simplicity/上下载、打印,并将其钉在办公室墙上。你也可以随时注册我的免费Finxter 邮件学院,它教授简短而简单的编程课程——我们通常专注于一些激动人心的技术领域,如 Python、数据科学、区块链开发或机器学习——但我们也讨论与极简主义、自由职业和商业策略相关的生产力技巧。

在你离开之前,请允许我表达我对你花费这么多时间与我相处的深深感激。我的人生目标是通过代码帮助人们提高工作效率,我希望这本书能帮助你实现这个目标。希望你已经获得了关于如何通过做更少的事情来提高编码生产力的见解。希望你在翻开这一页后,就能开始你的第一个或下一个编码项目,并且继续活跃在与志同道合的编码者们组成的 Finxter 社区中。为你的成功干杯!

posted @ 2025-12-01 09:44  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报