改进代码的步骤

2025-05-25

改进代码的步骤

这是《编程第一年》一章的早期版本这本书为新开发者提供了实用的操作指南和建议。如果你正在考虑从事软件行业,请访问 https://leanpub.com/firstyearincode查看。


初学者指南

刚开始编程时,你通常会花上一两年的时间,完全不了解“好代码”的规则。你可能会听到“优雅”或“简洁”之类的词,但你无法定义它们。没关系。对于一个没有任何经验的程序员来说,只有一个指标值得关注:它是否有效?

不过,很快你就需要提高你的期望值了。好的代码不仅仅是能用,它还应该简洁、模块化、可测试、可维护、考虑周到。这些术语中的一些可能在你不知情的情况下适用于你的代码,但也可能并非如此。如果你幸运的话,你的团队会精心规划和构建代码解决方案,并温柔地指导你,相信你会培养出编写良好软件的直觉。如果你不幸运,他们每次看到你的代码都会皱眉 抱怨。无论如何,学习一些通用原则都能让你受益匪浅。

以全局变量为例:任何不受作用域限制且可在整个代码库中轻松访问的变量。假设您的应用中有一个username变量,该变量在用户登录时设置,并且只需引用变量名即可从应用中的任何函数访问该变量——这就是全局变量。一些颇具影响力的博主和代码规范普遍鄙视全局变量,但大多数入门级程序员并不理解其中的原因。原因是——请注意,因为几乎所有编码最佳实践都基于此——它使代码编写速度更快,但更难理解。在这种情况下,全局变量可以让您轻松地将用户名插入到应用中的任何位置,这意味着您和下一个生产版本之间可以减少代码行数和时间。然而,这只是一种虚假的安慰:您为了方便而牺牲了安全性。如果您发现涉及的 bug username,则不仅需要调试单个类或方法,还需要调试整个项目。我稍后会详细讨论这一点。

“好代码”和“坏代码”之间的区别通常不在于它对编写代码的影响。代码始终是一种共享资源:你可以与其他开源维护者、团队中的其他开发人员、未来接手你工作的人、未来的你(他根本不知道现在的你在想什么)共享它,甚至可以与“调试你的人”(他正在检查你的新代码以查找错误,并且会感到非常沮丧)共享它。如果你的代码合理,所有这些人都会感激不尽。这会让他们的工作更轻松,压力更小。从这个意义上讲,编写好的代码是一种职业礼仪。

如果您仍然怀疑,请继续阅读——我将讨论几个导致良好代码的原则,并尝试证明每个原则的合理性。

条款

在我们开始之前,先来简单定义一下:

  • 状态:程序运行时存储在内存中的数据。你赋值的每个变量都是程序状态的一部分。
  • 重构:修改程序代码但不改变其行为(就用户而言)。重构的目标通常是使代码更简洁、更有条理、更易读。

1.关注点分离

编程就好比写菜谱。简单的菜谱中,每个步骤都依赖于前一个步骤,一旦所有步骤完成,菜谱就算完成了。但如果你曾经尝试过按照更复杂的菜谱操作,你可能会体会到炉子上两口锅沸腾、微波炉里旋转的盘子、砧板上切好的三种蔬菜,以及散落在台面上的一大堆香料和香草瓶(你根本记不住哪些已经加进去了)。

厨房里多一个厨师,问题往往既变得简单,也变得复杂。你需要浪费时间协调、来回传递物品,还要为炉灶空间和烤箱温度争吵。要想把事情做好,需要不断练习。

如果你知道厨房里会有好几位厨师,那么把食谱拆分成几个基本独立的子食谱不是更高效吗?这样你就可以把食谱的一部分交给每个厨师,他们之间互动越少越好。其中一个厨师负责煮意大利面,另一个厨师负责切菜和烹饪蔬菜,另一个厨师负责切丝奶酪,另一个厨师负责制作酱汁。而且,每个厨师的互动点都定义清晰,这样每个人都知道什么时候交接工作。

最糟糕的代码形式就像一份简单的菜谱:一堆按顺序排列的步骤,每个步骤都定义在同一个空间里,并且从上到下串联起来。为了理解和修改它,你必须把整篇文章读上好几遍。第 2 行的一个变量可能会影响第 832 行的操作,而找出原因的唯一方法就是仔细阅读整个程序。

稍微好一点的代码形式就像厨房里多了一位厨师。你把一些操作交给程序的其他部分,但你的目标主要是降低代码库的复杂性,而不是对其进行组织。这算是一种改进,只是还不够深入。

最佳的代码形式就像将菜谱拆分成多个子菜谱,这些子菜谱在代码中通常被称为“模块”或“类”。每个模块只关注一个内聚的操作或数据片段。蔬菜厨师不必担心酱汁的配料,煮意大利面的人也不必担心奶酪磨碎机。他们的关注点是分离的(因此,关注点分离)。

这样做的好处非常显著。假设一位程序员稍后需要修改程序——例如,为一位患有乳糜泻的客户添加无麸质版本,或者添加一种时令蔬菜。这位程序员只需阅读、理解并修改程序的一小部分即可。如果所有与蔬菜相关的代码都包含在一个接口精简的小类中,那么程序员就无需担心添加蔬菜会破坏酱汁的原汁原味。

这里的目标是确保,为了做出任何给定的更改,编码人员只需考虑尽可能少的程序部分,而不是同时考虑所有部分。

2.全局变量(不好)

让我们回到username变量本身。在构建应用的登录表单时,你意识到需要在几个地方显示用户名,比如页眉和设置页面。所以你选择了阻力最小的方案:将其创建为全局变量。在 Python 中,它使用global关键字 声明。在 JavaScript 中,它是对象的一个​​属性window。这似乎是一个不错的解决方案。在任何需要显示用户名的地方,只需传入username变量即可。为什么不是所有变量都这样维护呢?

然后事情就出问题了。代码里有一个 bug,它和 有关username。尽管大多数 IDE 都提供了即时代码搜索工具,但修复这个问题仍然需要一段时间。你搜索一下username,会得到成百上千个结果;有些是你在项目开始时设置的全局变量,有些是碰巧也叫 的其他变量username,有些是注释中的“用户名”、类名、方法名等等。你可以优化搜索,减少冗余信息,但调试仍然会花费比预期更长的时间。

解决方案是将username其放置在合适的位置:一个容器(例如,一个类或数据对象)内,该容器会被注入或作为参数传递给需要它的类和方法。这个容器也可以容纳类似的数据——“登录时设置的任何数据都是不错的选择(但不要存储密码。永远不要存储密码)。如果您愿意,可以将这个容器设置为不可变的,这样一旦username设置就永远无法更改。这将使调试变得非常容易,即使username它在您的应用中被使用数万次。

这种方式的编码将使您的工作更加轻松。您始终能够在一个地方找到所需的数据。如果您需要跟踪某条数据的使用或更改时间,只需添加 getter 或 setter 即可。

3.干燥

让我们来谈论一下关系。

恋爱的美好之处在于伴侣的陪伴和支持。恋爱的糟糕之处在于,每次遇到新朋友,或者很久没见的人,他们都会想听你们相遇的故事。而这个故事很少像“我们在杂货店聊了聊,第二天就结婚了”这样简单。所以,你每周都要讲好几次同一个15分钟的故事,很快就会厌倦。

更糟糕的是,想象一下几个月后,你对这段一见钟情的故事有了新的认识:你以为这只是一场美好的意外,但结果却并非如此。一位共同的熟人经过数月的精心策划,成功地安排了你们第一次见面,并用潜意识暗示让彼此喜欢上对方。一方面,一切顺利,你们都很高兴。另一方面,几个月来,你们一直在讲述一个非常不完整的故事。当人们发现真相时,他们可能会认为你对他们撒了谎(也许是善意的谎言,但仍然令人尴尬)。

事态发展到这种地步,你彻底沮丧了,于是创建了一个网页,上面放着“我们如何相遇”的最新版本,然后去联邦快递打印了一千张名片,上面附上了网页的简化链接。你把一张名片寄给了所有听过这个老掉牙的故事的人。从现在起,每当有人问起你是如何认识你的伴侣的,你只需从后兜里掏出那叠名片,递给他们一张即可。如果故事有变,你只需更新网页,每个人都能访问。

这不仅是缓解关系中最棘手问题之一的绝佳方法,也是编写代码的最佳方式:每个操作(每个算法、每个呈现元素、每个与外部接口的交互)只需编写一次,每当其他代码需要了解该操作时,只需通过名称引用即可。每次在代码库中复制粘贴代码时,都应该问问自己是否做错了什么。如果“对象如何LonelyUser映射到MarriedUser对象故事(或任何其他故事)”的问题被重复提及多次,那么就该重构了。

目标是:如果某个操作需要以某种方式更改,您只需修改一个类或方法。这比维护相同代码的多个副本更快捷、更可靠——当需要更改时,更新所有副本将花费更长的时间,而且不可避免地会遗漏一两个副本,从而导致难以诊断的错误。

4.隐藏复杂性

我有一辆车要卖给你。你需要一些培训才能学会如何使用它。

要启动汽车,请将红线 2 号和白线 7 号接在一起,同时用脚踢发动机转速风轮,并将适量的燃油倒入位于中控台下方的喷油器中。汽车启动后,伸手进入变速箱,将中间轴推入差速器轴上的一档齿轮。要加速,请增加进入喷油器的燃油流量。要刹车,请用脚抵住轮胎。

我希望你和我一样讨厌这辆车。现在,把这份厌恶投射到那些界面过于复杂的代码元素上。

构建类或方法时,首先应该编写的是接口:即其他代码(调用者)需要了解才能使用该类或方法的部分。对于方法而言,这也称为签名。每次在 API 文档(例如 MDN 或 jquery.com)中查找函数或类时,您看到的都是接口——仅包含使用它所需了解的内容,而不包含其包含的任何代码。

接口应该简洁但富有表现力。它应该用简单的语言表达,并且不需要调用者了解事情发生的顺序、调用者不负责的数据或全局状态。

这是一个糟糕的界面:

function addTwoNumbersTogether(number1, number2, memoizedResults, globalContext, sumElement, addFn) // returns an array
Enter fullscreen mode Exit fullscreen mode

这是一个很好的界面:

function addTwoNumbersTogether(number1, number2) // returns a number
Enter fullscreen mode Exit fullscreen mode

如果接口可以精简,就应该精简。如果你显式提供的值可以从其他值推断出来,就应该精简。如果某个方法包含多个参数,你应该问问自己是否做错了什么(尽管依赖注入的构造函数可以例外)。

不要过度。如果你为了避免向函数传递参数而设置和使用全局变量,那你就错了。如果一个方法需要大量不同的数据,请尝试将其拆分成更具体的函数;如果这不可行,请创建一个专门用于传递这些数据的类。

请记住,类拥有的所有方法和数据,如果在类外部可以访问,都是其接口的一部分。这意味着你应该尽可能多地将方法和字段设为私有。在 JavaScript 中,使用varlet或声明的变量const,只要你不返回它们或将它们赋值给对象,它们就自动对声明它们的函数是私有的;在许多其他语言中,有一个private关键字。这应该是你最好的朋友。仅在需要知道的情况下公开数据。

5. 接近性

尽可能在靠近使用地点的地方声明事物。

程序员本能地想要组织代码,但这种情况可能会适得其反。你可能认为一个组织良好的方法应该是这样的:

function () {
  var a = getA(),
      b = getB(),
      c = getC(),
      d = getD();

  doSomething(b);
  doAnotherThing(a);
  doOtherStuff(c);
  finishUp(d);
}
Enter fullscreen mode Exit fullscreen mode

getA()并且它的同胞没有在这个段中定义,但想象它们返回有用的值。

在像这样的小方法中,你可能会认为代码组织良好、易于阅读。但事实并非如此。d不知何故, 在第 4 行声明了它,尽管它直到第 9 行才被使用,这意味着你必须阅读几乎整个方法才能确保它没有在其他任何地方被使用。

更好的方法如下:

function () {
  var b = getB();
  doSomething(b);

  var a = getA();
  doAnotherThing(a);

  var c = getC();
  doOtherStuff(c);

  var d = getD();
  finishUp(d);
}
Enter fullscreen mode Exit fullscreen mode

现在很清楚何时使用变量:声明后立即使用。

大多数情况下,情况并非如此简单;如果b需要同时传递给doSomething()和 ,该怎么办doOtherStuff()?在这种情况下,您需要权衡各种选择,并确保该方法仍然简单易读(主要是保持简短b)。无论如何,请确保在首次使用之前不要声明,并在尽可能短的代码段中使用它。

如果你坚持这样做,有时会发现某个方法的某个部分完全独立于其上下层代码。这是一个很好的机会,可以将其提取到自己的方法中。即使该方法只使用一次,它也很有价值,因为它可以将操作的所有部分封装在一个易于理解且命名良好的代码块中。

6. 深度嵌套(不好)

JavaScript 中有一个令人不舒服的情况,被称为“回调地狱”:

通过https://vimeo.com/131192407

看到});页面中间那条痕迹了吗?这就是回调地狱的标志。虽然可以避免,但这个问题已经有很多作者讨论过了。

我希望你考虑的是更像“如果地狱”的事情。

callApi().then(function (result) {
  try {
    if (result.status === 0) {
      model.apiCall.success = true;

      if (result.data.items.length > 0) {
        model.apiCall.numOfItems = result.data.items.length;

        if (isValid(result.data) {
          model.apiCall.result = result.data;
        }
      }
    }
  } catch (e) {
    // suppress errors
  }
});
Enter fullscreen mode Exit fullscreen mode

数一数有多少对{花括号}。六对,其中五对是嵌套的。太多了。这段代码很难读,部分原因是代码快要溢出屏幕右侧,而程序员讨厌水平滚动;部分原因是你必须读完所有if条件才能弄清楚是怎么走到第 10 行的。

现在看看这个:

callApi().then(function (result) {
  if (result.status !== 0) {
    return;
  }

  model.apiCall.success = true;

  if (result.data.items.length <= 0) {
    return;
  }

  model.apiCall.numOfItems = result.data.items.length;

  if (!isValid(result.data)) {
    return;
  }

  model.apiCall.result = result.data;
});
Enter fullscreen mode Exit fullscreen mode

这样就好多了。我们可以清楚地看到代码的“正常路径”,只有在异常情况下,代码才会偏离代码块if。调试也简单多了。如果我们想添加额外的代码来处理错误情况,只需在这些代码块中添加几行代码即可if(想象一下,如果if原始代码中的代码块else附加了代码块,那该有多恐怖啊)。

另外,我删除了 try-catch 代码块,因为你永远、永远、永远不应该抑制错误。错误是你的朋友,没有它们的帮助,你的应用程序就会变成垃圾。

7.纯函数

纯函数(或函数式方法)是指不改变或依赖外部状态的方法。换句话说,对于给定的输入,无论外部发生什么变化,它始终会提供完全相同的输出,并且应用程序状态完全不受其内部发生的变化的影响。所有纯函数都至少有一个参数和一个返回值。

这个函数是纯函数:

function getSumOfSquares(number1, number2) {
  return (number1 * number1) + (number2 * number2);
}
Enter fullscreen mode Exit fullscreen mode

而这个不是:

function getSumOfExponents(number1, number2) {
  scope.sum = Math.pow(number1, scope.exp) + Math.pow(number2, scope.exp);
}
Enter fullscreen mode Exit fullscreen mode

如果你想调试第一个函数,你需要的一切都在这里。你可以把它粘贴到一个单独的环境中,比如 jsfiddle 或浏览器控制台,然后反复尝试,直到找出问题所在。

如果要调试第二个函数,你可能需要仔细检查整个程序,以确保找到所有访问scope.sumscope.exp的地方。如果你想把这个函数移到另一个类,你还需要检查它是否在相同的作用域内可以访问所有 和 。

并非所有方法都可以是纯函数;如果你的应用程序没有状态,它的实用性就会受到限制。但你应该尽可能多地编写纯函数。这将使你的程序易于维护和扩展。

8. 单元测试(很好)

任何不仅仅是对其他代码进行裸包装的类或方法——也就是说,任何包含逻辑的类或方法——都应该附带单元测试。该单元测试应该作为构建管道的一部分自动运行。

正确编写的单元测试可以消除错误的假设,使你的代码更易于理解。如果有人不知道一段代码的功能,他们可以查看单元测试并了解用例。编写测试可能很麻烦,我也不主张 100% 的测试覆盖率,但如果你在开始编写代码时想到,这可真棘手,那么这肯定表明你应该在整个过程中编写测试。

结论

好的代码维护起来、基于它构建代码、解决问题都充满乐趣。糟糕的代码则如同灵魂拷问。选择编写好的代码。

编写代码时,不妨问问自己一个好问题:当我们不再需要它时,它是否容易删除?如果它嵌套很深、到处都是复制粘贴的代码、依赖于程序中各种状态和代码行,或者其他方面很糟糕,人们将无法理解它的用途和影响,并且会不愿意删除它。但如果它的用途以及它与多少行代码交互一目了然,那么当它不再有用时,人们就能放心地删除它。我知道你热爱你的代码,但事实是,有一天没有它,世界会变得更美好。

想要更全面地了解优秀代码的构成要素,我推荐 Steve McConnell 的《代码大全》。这本书很厚(而且有点过时),但可读性很强,能帮助你从“工作型代码程序员”成长为“优秀、简洁、优雅的代码程序员”。

这篇文章最初发表在medium.com上

文章来源:https://dev.to/isaacdlyman/steps-to-better-code
PREV
HTTPS 的工作原理
NEXT
CSS - 保持简洁!打造专业外观的 3 个核心概念