要成为更优秀的程序员,先在脑中推演小段逻辑

要成为更优秀的程序员,先在脑中推演小段逻辑

本系列是我个人翻译的英文阅读笔记,既为巩固所学,也为分享知识(如有侵权请联系删除)
下面是正文

原文链接:https://the-nerve-blog.ghost.io/to-be-a-better-programmer-write-little-proofs-in-your-head/

这是一篇关于我偶然学到的编程技巧的短文,它能帮助我更快速、更准确地编写代码。我称之为"技巧",但其实随着职业生涯的发展,我不知不觉就养成了这个习惯。

当你处理复杂问题时,不妨在编写代码时同步在脑海中勾勒出逻辑验证——证明这段代码确实能实现你的预期目标。这个理念看似简单,实践起来却颇有难度:要在不打断编程流畅度的状态下"实时"完成这种思维推演,需要大量练习。但当你真正掌握后,会发现令人惊喜的现象——你的代码经常能在首次或第二次尝试时就成功运行,这种体验简直如同魔法。

实现这一目标的方法多种多样,我不想给出刻板的教条。这里仅列举几个我在实践中常用的即时推理案例,以便您理解核心思路.

单调性

在验证代码逻辑时,需要特别关注哪些部分具有单调性。

您可能从数学中了解过单调函数。通俗地说,这类函数不会"走回头路"——递增单调函数只能保持或增大,递减单调函数只能保持或减小(这两类函数也分别被称为非减函数和非增函数)

代码的单调性概念比数学中的单调函数更为抽象,但核心思想相同——都指代只能单向推进的过程。以检查点机制为例:假设某个脚本需要顺序执行多项任务,可以通过磁盘存储的少量状态数据记录已完成任务数。当脚本意外崩溃时,只需读取磁盘状态即可确定中断位置,并从最早未执行的状态点重新开始。

检查点机制意味着脚本中的"当前步骤"指针只能单向前进,因为脚本无法回退重跑已完成步骤。从这个角度看,脚本的推进过程具有单调性,显然只要脚本最终成功运行,就必然保证每个步骤都被精确执行一次。

这种操作日志的机制看似简单,却常见于各类系统设计中,比如日志文件系统和数据库预写日志。更复杂的数据库范例是LSM树(日志结构合并树),某些数据库用它来管理内存和磁盘中的数据存储。本质上,LSM树会记录所有插入、删除和更新操作,读取时通过扫描日志重建行数据。系统会定期通过压缩过程清理过期操作以节省空间——LSM树占用的空间通常只增不减(仅在压缩时单向缩减)。

不妨将其与传统数据库结构B树进行对比——B树会直接在原位置删除和更新数据行。这种结构通常需要做更多工作来回收删除后的空闲空间:既要重组数据结构为可能扩容的更新操作预留空间,又要确保足够的缓冲区间等。建议您可以深入比较B树和LSM树的工作原理,看看哪种架构的运作逻辑更符合您的思维直觉。

值得关注单调性(monotonicity)这一特性,因为它往往能帮助我们排除大量可能的结果。这个概念还有个近亲叫不可变性(immutability)——创建不可变对象后,该对象就无法被修改。数值只能在对象构建时一次性赋值,既不能"撤回"也无法"撤销"。这种特性让我们可以直接忽略对象可能被意外修改的所有情况。

前置条件与后置条件

前置条件和后置条件是用于限定函数行为的约束规范。函数的前置条件指该函数执行前必须满足的假设条件,这些条件既可以是针对输入参数的约束,也可以是关于程序状态或运行环境的通用声明。函数的后置条件是指该函数返回时必须成立的假设条件。与前置条件类似,这些断言可以涉及任何方面。如果在函数执行前其前置条件成立,而函数执行后后置条件不成立,则说明该函数的实现存在错误——至少不符合既定的约束规范。这些都是基础(甚至显而易见)的概念,其本身并非真正的证明技术,但通过规范化的术语来准确记录这些概念,将有助于提升你的逻辑推理能力。

(有时你可能会发现自己的函数并没有明确定义的前置条件和后置条件——能意识到这点同样很有价值!)

明确界定后置条件,是生成单元测试思路的有效方法。同时,防御性地添加断言来验证前置条件和后置条件是否成立(否则就触发崩溃),能让你更轻松地推断代码在未崩溃时的行为——这看似最多只是个中性的取舍,但通常来说,代码尽早崩溃总比产生不可预测的行为更安全。

不变量

一段代码的不变量是指无论发生什么情况,在该代码运行前、运行期间及运行后都应始终为真的条件。与前置条件和后置条件类似,不变量几乎可以涉及任何方面。

用不变量来思考一致性会非常实用——在这些情况下,不变量就是"该数据结构始终保持一致/有效"。你需要向自己证明,无论发生什么情况,代码在每个环节都能维持这个不变量。一个简单的方法是将代码拆分为多个原子性的"步骤",然后证明每个步骤都能独立保持不变量。这样你就能得出结论:无论这些步骤以何种顺序执行,不变量都将始终成立。会计恒等式是最古老且最著名的不变量范例之一,它构成了复式记账法的基础。会计恒等式的基本含义是:企业账簿上的借方总额必须等于贷方总额。显而易见,若交易前借贷平衡,则交易后仍保持平衡。因此,这一恒等式始终成立。

另一种维护特定不变量的方法是使用监听器或生命周期方法,确保这些不变量在关键节点始终保持成立。另一种保持特定不变量的方法是使用监听器或生命周期方法,确保这些不变量在关键节点始终成立。当需要保持多个状态同步时(例如C++通过构造函数和析构函数确保对象所需内存仅在对象实际存在期间保持分配),这种技术尤为常用。React组件中的useEffect也实现了类似功能。

(由于不变量必须适用于所有可能场景,因此在引入较少新执行路径的变更时,通常更容易进行逻辑推演。)

隔离性

我始终坚信,软件开发的"技艺"很大程度上(或者说应该)聚焦于如何在不破坏现有系统稳定性的前提下进行修改或功能扩展。当对代码库进行改动时,若能证明那些本不应改变的行为确实未被改变,这种能力将极具价值。

我经常依赖一种方法来验证这一点——虽然不确定它是否有专业名称,甚至不确定该称之为方法还是思维模式。最贴切的描述是:每个改动都有"影响范围"——代码某处的修改可能需要连锁调整其他部分,才能保证整个系统的一致性/正确性。这种连锁反应可能不断延伸,而要确定改动究竟会影响哪些行为、不会影响哪些行为,关键在于找出能阻断改动传播的结构性"防火墙"。这有点像封装(encapsulation)的概念近亲。

这个概念比较抽象,所以举个Nerve项目中的例子来说明:


Nerve是一个查询引擎,能让用户像操作单个巨型API那样查询多个数据源。其查询流水线包含两个核心组件:查询规划器(负责制定具体的分步执行计划)和查询执行器(负责实施该计划)。在Nerve系统中,查询可同时包含实体字段和虚拟字段——虚拟字段本质上是衍生字段,即实体字段直接从源API获取,而虚拟字段则运行时根据其他虚拟/实体字段计算得出。

实体字段的处理相对简单——只需发起对应请求并按需提取响应数据即可。虚拟字段则较为棘手,因为它们可能依赖其他字段。我们必须确保在计算虚拟字段前,其所有前置依赖都已就位。若要求用户手动添加这些依赖字段,无疑会增加不必要的操作负担。因此,我们需要建立某种机制,在计算虚拟字段前自动获取其依赖项。但问题在于:这套机制应该放在系统哪个环节?

一种直接方案是同时改造查询规划器和查询执行器,使其能够识别"依赖字段"——这类实体字段并非查询指定项,而是为满足虚拟字段计算需求所提取的中间数据。这些依赖字段需保留至计算完成,但最终不应出现在查询结果中。此外还需考虑其他设计因素:例如如何通过单次请求同时获取常规实体字段及其依赖字段等实现细节。

这本质上是对查询管道的扩展方案:虽然略显复杂,但完全可行。不过我们还可以耍个小聪明——通过过量提取事后清理的方式来规避这个问题。

第二种方案完全不引入新概念。在查询规划阶段,我们会计算每个虚拟字段的依赖项,直接将其加入查询语句后交给执行器。执行器无需感知当前执行的查询是否经过改造——它只需按标准流程操作:先提取所有实体字段,再计算相关虚拟字段(由于依赖字段已通过某种方式自动包含,执行器永远无需额外获取虚拟字段的依赖项!)

这种方案的主要优势在于,所有改动都被严格限制在查询管道的首尾两个薄层——中间的查询引擎核心部分完全无需变动。特别是查询规划器与执行器之间的边界就像一道"防火墙",有效阻止了改动的扩散。这使得我们可以轻松证明:当执行无需提取依赖项的查询时(因为此时运行的完全是未经改动的原始代码),我们的修改绝不会引发任何功能回退。

这种处理方式有时适用,有时未必,但在同等条件下,尽量保持代码原封不动总能有效降低认知负担。

(您可能听说过这个概念在"开闭原则"中的讨论。该原则包含了许多与当前场景无关的面向对象编程细节;真正重要的是其核心理念:“当需求变更时,应该通过添加新代码来扩展程序行为,而非修改现有可正常运行的旧代码。”)

归纳法

许多有趣的程序都涉及递归函数或递归数据结构(从某种理论意义上说,递归本身就是计算行为的核心)。根据你所从事的领域,你可能经常遇到递归,也可能只是偶尔接触,但无论哪种情况,掌握递归的推理方法都能让你的工作轻松许多。

递归数据结构是指包含自身副本的结构(不一定是完全相同的副本,而是同类型结构的实例)。这个副本又可以包含副本,如此不断延伸;这个过程要么无限延续,要么在"基准情形"处终止。例如,分形就是一种递归结构。

在计算机科学中,递归数据结构的经典范例是树结构。树由节点和若干子节点组成,而每个子节点本身又是一棵树。没有子节点的树称为叶节点,这正是递归的基准情形。

列表同样可以采用递归方式定义,尽管人们通常不会这样思考。每个递归列表都由两部分组成:表头(即列表的"首个"或最左侧元素)和表尾(包含剩余元素)。表尾本身也是一个列表,而空列表则是递归的基准情形。(同理,自然数也可视为递归结构——除作为基准情形的0之外,每个自然数都是1加上另一个更小的自然数。)

递归函数是指能够调用自身的函数。这类函数通常用于处理递归数据结构,因为它们可以在数据结构的递归副本上自我调用(例如,处理树结构的函数可以在所有子树结构上递归调用自身)。

还有一种专门用于处理递归结构的证明方法,称为归纳法。其"经典"形式用于证明命题P(n)对所有自然数n成立,证明过程包含两个步骤:

  • 证明P(0)成立。
  • 证明若P(b)成立,则可推出P(n+1)成立。

第二步称为归纳步骤,其中假设P(n)成立的条件称为归纳假设。归纳步骤正是归纳法的精髓所在——只要掌握了归纳假设这个工具,P的证明往往就会变得简单许多。归纳法的核心在于构建"渐进式"证明,而非试图一次性验证所有数值情况。

当你编写递归函数时,可以尝试用归纳法来验证其正确性。这里有一个简单示例(根据Nerve代码库稍作改编):


在不深入技术细节的情况下——Nerve代码库中有个特定场景需要向用户展示抽象语法树(AST)。由于完整AST过于复杂,我们在显示前需要移除用户可能不关心的节点。当移除某个节点时,该节点的父节点应当"继承其所有子节点"(用技术术语来说,就是需要收缩被移除节点与其父节点之间的边)

关于术语的简要说明:严格来说,“收缩”(contraction)这个术语仅适用于边(edges),但为了方便起见,我会放宽标准,将"收缩节点"和"收缩树"也纳入讨论范围。当我说"收缩一个节点"时,实际意思是"收缩该节点与其父节点之间的边";当我说"收缩一棵树"时,指的是"收缩树中的某条边"。

以下是我们在Nerve中使用的函数(并非原函数,但能说明核心逻辑):

function simplifyTree(root: Node): Node {
  let newChildren = [] as Array;
  
  for (const child of root.children) {
    const simplifiedChild = simplifyGraph(child);
  
    if (shouldContract(simplifiedChild)) {
      for (const grandChild of simplifiedChild.children) {
        newChildren.push(grandChild);    
      }
    } else {
      newChildren.push(simplifiedChild);
    }
  }

  root.children = newChildren;

  return root;
}

我们需要该函数能最大程度简化传入的抽象语法树。换句话说,其执行后的状态必须满足:simplifyGraph返回的图应处于"完全收缩"状态——即图中不应再存在任何可收缩的边。

  • 让我们从基础情况开始。根据定义,根节点无法被收缩,因为它没有父节点可供合并。因此基础情况——单个叶节点——已经满足后置条件。如果我们向simplifyGraph传入叶节点,它会直接原样返回,由此可以判定该函数在基础情况下运行正确。
  • 现在进入关键环节:归纳步骤。我们需要证明,如果simplifyGraph函数对树T的每个子树都正确有效,那么它对T本身也必然正确。关键在于,此时我们可以运用归纳假设——这意味着我们可以假定每个子树(即每个以simplifiedChild为根的树)都已达到无法进一步收缩的状态。

现在唯一需要考虑的新收缩可能性存在于简化子节点(simplifiedChild)与根节点(root)之间。如果我们判定某个simplifiedChild需要被收缩,就会移除该节点并将其所有子节点嫁接到root上。完成所有子节点的这种处理后,我们可以确定:以root为根的树已达到完全不可收缩状态——因为若仍可收缩,则意味着至少存在一个子树还可收缩,这与归纳假设矛盾。证毕!

如果你能开始本能地运用这种归纳推理思维,就会发现处理递归函数会变得更容易。

(若愿意,你可以尝试通过整体性推理——而非数学归纳法——来说服自己simplifyGraph函数对所有可能的输入都能正确工作。这两种论证方式中,哪一种让你感觉更自然呢?)

以可验证性作为代码质量指标

到目前为止,我的核心论点可以概括为:“你应该尝试在脑海中为代码构建小型证明”。但本文其实存在一个隐秘的对偶版本:“你应该尝试将代码写成易于构建小型证明的形式”。

这种对偶性同样体现在本文每个章节中:

  • **观察单调性与不可变性.**→ 编写具有单调性并使用不可变数据结构的代码
  • **维护前置条件与后置条件.**→ 从明确的前后条件出发构建代码,确保这些条件易于概念化和验证
  • 通过验证每个工作单元来保证函数不变性. → 将代码拆分为可维护不变性的最小单元
  • 关注组件边界作为阻止变更传播的"防火墙". → 尽可能多地构建此类"防火墙",并在开发新功能时充分利用
  • 使用归纳法逐步验证递归函数假设归纳假设已成立并加以利用→ 采用增量方式编写递归函数. 假设递归调用已实现,专注构建从n到n+1的递推关系最后单独实现基准条件。

核心思想是:代码质量可以通过其可验证性来衡量。如果你能轻松论证代码的正确性,说明设计良好;反之,若验证过程持续令人沮丧或困难,就应该考虑通过重构来提升代码的清晰度。

我曾想用"可证性"(provability)来命名这个特质,但该术语已有特定含义,因此改称为"验证亲和性"(proof-affinity)。

正如前文建议所示,我们完全可以(至少主观上)以最大化"验证亲和性"为目标进行设计。

当然,"验证亲和性"并非软件质量的唯一维度(代码还需确保正确性、高效性及易用性),但我认为它至关重要——毕竟无论是构建、扩展、优化还是测试代码,都必须准确理解其实际功能、能力边界及潜在可能性。这听起来或许有些宏大,但从本质上看,"验证亲和性"实则是优质编程的催化剂!

如何提升这项能力

正如开篇所言,这种微观推理能力只有在形成本能反应时才能真正见效。就像盲打技能——只有当手指记忆完全取代了低头找键,打字速度才会真正提升。两者都需要通过…刻意练习来培养直觉!我认为没有捷径可走,必须投入足够的时间。

最佳训练方式是撰写更多(数学)证明。虽然针对程序的证明特别有效,但任何主题的证明构建过程都能锤炼逻辑思维能力——这对处理复杂系统至关重要(关键要动笔写,不能只看解析。务必动手做题!)。就我个人而言,前段时间开始把数学当兴趣培养,明显感觉到证明写作让我的思维在各种场景下都更加清晰。

若不知从何入手,推荐斯坦福大学在EdX平台上的本科算法课程——这位教授(在我看来)讲解生动,且全程贯穿证明训练!

另一个训练场(虽然我不情愿承认)是Leetcode。和多数人一样,我认为Leetcode面试模式存在严重缺陷,但自主练习时很有价值——许多题目难度恰好能锻炼证明构建能力。不必计时(我通常也不计),尽量避开依赖"解题技巧"的题目,选择那些需要严谨逻辑推导和完整实现的问题。争取用最少提交次数通过测试(遇到语法错误之类的小问题很正常)。

祝编程愉快/证明顺利!

你可能感兴趣的:(英文资讯,算法,网络,linux)