干净的代码和编程原则——终极初学者指南
本文是针对初学者的编程原理的入门指南。
首先,我们要探讨什么是好的代码。好的代码的品质。这是因为这些品质先于编程原则。编程原则只是帮助我们将这些品质应用于代码的指导方针。
之后,我们将从入门的层面逐一介绍最重要的编程原则。
希望本文不会让人觉得“拥有小函数”,而更像是“出于原因 1、2 和 3,这些是您希望代码具备的品质。因此,正如您所见,小函数可以帮助您以 X、Y 和 Z 方式实现这些品质”。
我相信这种理解比仅仅了解一些随意的规则更有益处。如果你过去一直不知道如何应用某些编程原则,那么这些规则尤其有用。了解这些原则的用途和目标,即使在不熟悉的情况下,也能帮助你运用它们。
目标受众
我相信这篇文章适合所有读者。
如果您是初级开发者,本文中提到的某些内容可能过于抽象。但其他一些内容应该能立即发挥作用。即便您现在还不完全理解,本文也能帮助您理解,这对您未来发展大有裨益。
如果您是中级开发人员,您可能会受益最多。您可能正在编写中大型程序。您已经掌握了基础知识。现在,您需要学习如何编写可扩展(规模)的代码。这正是编程原则可以帮助您的地方。
如果你是高级开发人员,你可能已经了解了其中的大部分内容。不过,你仍然可能会喜欢这篇文章。
优秀代码的品质
什么是好的代码?
要回答这个问题,首先我们需要考察代码的需求。然后,我们(人们)需要哪些特质才能让代码易于使用。如此一来,优秀代码的特质就显而易见了。
如果您想跳过讨论,以下是结论:
代码的要求如下:
- 它应该能按预期工作,没有错误
- 它应该尽可能快速高效地构建(不牺牲质量)(就像所有产品一样)
- 它应该易于操作和修改(以便下次使用时使用)
我们的一些局限性如下:
- 我们一次无法记住太多信息。这意味着我们不会记得修改 X 会破坏 Y 和 Z。
- 我们发现复杂的事情比简单的事情困难得多
- 进行多次类似的更改对我们来说很容易出错
- 我们有时会感到无聊,无法集中注意力,也不太注意
- 无论如何,我们总会犯错。这意味着我们需要测试(手动或自动)以及其他错误捕捉工具。
从这两者出发,经过一些推理,我们得出结论,代码应该:
- 简单点(因为我们不擅长处理复杂的事情)
- 能够立即理解(这样我们就能快速理解并做出修改。同时,我们也不会误解并产生错误,尤其是在我们那天没有真正集中精力的情况下)
- 井然有序(这样我们就可以更容易地理解项目结构,更快地找到需要修改的文件)
- 独立(这样我们就可以对 X 进行合理的更改,而不会破坏项目中的其他 1,000 个内容)
- 重复最少(因为我们不擅长重复更改。而且速度也比较慢)
更多细节和解释如下。如果您不感兴趣,请跳至下一部分。
代码要求
软件是一种产品。企业雇佣程序员来开发软件产品。它通常不是抽象艺术,而是为了特定目的而构建的东西。
从商业角度来看,产品:
- 必须适合目的并按预期工作
- 应尽可能便宜和高效地创建(不牺牲质量)
这同样适用于软件。
但软件有一些独特之处。它需要不断修改。这是因为软件通常永远不会“完成”。公司可能在首次发布后的几十年里都要求增加新功能。此外,软件中可能随时存在需要修复的错误。最后,在开发过程中,程序员会不断修改代码。
因此,为了使软件产品的创建和维护尽可能高效和便宜,代码需要易于快速地使用和修改。
更不用说易于使用意味着因更改而产生的错误更少。
因此,代码的要求是:
- 它应该能按预期工作,没有错误
- 它应该尽可能快速高效地构建(不牺牲质量)
- 它应该易于操作和修改(以便下次使用时使用)
有关这方面的更多详细信息,请参阅软件的帖子要求。
人为的局限性和糟糕的代码
由于我们的限制,代码可能难以使用。
以下是我们的一些局限性以及我们可以采取的措施来克服它们。
记忆
我们一次记不住太多东西。我突然想起那句关于短期记忆和神奇数字7加减2的名言。
为了解决这个问题,我们需要代码足够独立(解耦),并且没有隐藏的依赖关系。这样,当我们修改代码时,就不会因为忘记更新不记得存在的依赖关系而意外破坏代码。
我们喜欢简单的事情
复杂的事情对我们来说难度过大。部分原因是我们需要同时记住很多事情。因此,我们应该让代码简洁易用。
我们不耐烦
我们变得没有耐心,经常浏览内容,遇到糟糕的日子并感到无聊。
为了解决这个问题,我们应该使代码简单、易于理解和易于使用。
我们不擅长重复性工作
重复对我们来说容易出错,特别是如果每次重复都略有不同。
重复性工作意味着更容易出错。此外,可能由于缺乏耐心和专注力,我们更容易仓促完成这类工作。我们通常无法对每一个变更都给予必要的关注。为了避免这种情况,我们应该尽量减少重复性工作。
我们会犯错
我们经常犯错,在生活的各个领域都是如此。这包括编程、数学、工程、艺术、设计以及其他一切。
因此,我们总是需要反复检查我们的工作。为此,我们使用代码审查和自动化测试等实践。我们还使用工具对代码进行静态分析。
我们应该如何开发软件
我们应该谨慎地开发软件。我们应该尽可能多地了解和理解我们正在开发的代码。这意味着我们能够尽可能地确保我们做的事情是正确的,并且不会破坏任何东西。
相比之下,如果我们只是随机尝试,我们不确定它们是否会有效。我们尝试的大多数方法都不会奏效,除了最后一个(到那时我们就会停止)。而且,我们只有通过测试才能知道它们是否有效。我们可能会手动测试我们尝试的所有内容。
这是有问题的,因为我们不太确定我们在做什么,我们可能会破坏其他我们不会想到要测试的东西。
因此,为了最大限度地减少出错的机会,尽可能多地了解我们正在做的事情非常重要。
最好的方法是使代码简单、易于理解和易于使用。
代码应该如何
到目前为止,我们所探讨的一切都指向了代码应该遵循的某种方式。代码应该:
- 简单点(因为我们不擅长处理复杂的事情)
- 能够立即理解(这样我们就能快速理解并做出修改。同时,我们也不会误解并产生错误,尤其是在我们那天没有真正集中精力的情况下)
- 井然有序(这样我们就可以更容易地理解项目结构,更快地找到需要修改的文件)
- 独立(这样我们就可以对 X 进行合理的更改,而不会破坏项目中的其他 1,000 个内容)
- 重复最少(因为我们不擅长重复更改。而且速度也比较慢)
接下来,我们来研究一下编程原则。
务实——最重要的原则
不仅在编程中,而且在生活中的几乎所有事情中,务实都至关重要。
这意味着要记住你想要实现的真正目标,并最大限度地实现它,不要偏离目标。
在编程中,你的目标是:
- 有正确运行的代码
- 尽可能快速有效地进行更改
- 使代码更容易、更快速地运行,以便下次有人处理它
编程原则是帮助你实现这一点的指导方针。但是,你的目标才是最重要的。如果某个编程原则不利于你的目标,你就不应该应用它。
不要把原则运用到极端
例如,代码简洁通常被认为是一件好事。它有很多好处,我们稍后会详细讨论。但是,如果代码的简洁会导致理解和使用更加困难,那么就不应该这样做。
不要玩“代码高尔夫”,即使用复杂的语法和数学技巧来让代码尽可能短。这只会让代码更加复杂,更难理解。
换句话说,代码要简短(指导方针),但前提是它能让代码更简单、更容易理解(你的目标)。
平衡重构所花费的时间
此外,你需要在合理的时间范围内进行更改。你必须平衡重构代码所花费的时间与它能带来的收益。
例如,如果你有一些非常难以理解的代码,你绝对应该重构它。这可能需要几个小时,但这可能是值得的。从长远来看,这将使你的项目更易于开发。你将来会通过更高的效率收回重构所花费的时间。
但是,如果你有一些近乎完美的代码,不要仅仅为了稍微改进一下就花三天时间重构它。你花了三天时间几乎没有任何收获。相反,你本可以更好地利用这段时间。你可以编写一个新功能,或者重构代码库中更合适的部分。
这里的重点是:你需要根据价值来确定优先级。这通常意味着保持代码简洁,并在需要时进行重构。但这并不意味着要花费大量时间进行几乎没有任何好处的重构。
亚格尼
另一个需要讨论的重要话题是YAGNI。它的意思是“你不需要它”。
它警告你不要为了预测未来可能需要的功能而编写代码。举一个简单的例子,你可能创建一个函数foo
,它有一个参数bar
。但你可能会想:“将来可能会添加功能 X,它需要一个参数baz
,所以我现在就把它添加到函数中。”
一般来说,你应该谨慎行事。首先,这个功能可能永远都不需要。其次,你现在会增加代码的复杂性,使其更难使用。第三,如果将来需要这个功能,你的代码可能会与现在的预期有所不同。
相反,你应该编写最简单的解决方案来满足你当前的需求。然后,在需要时(如果有的话)再对功能进行必要的更改。
这是最佳方案,因为你不会无谓地浪费时间,也不会让代码库变得更加复杂。即使你正确地预测了某个功能,在需要时再去编写它也会比你过早地编写所有代码所花费的时间快得多。
个人建议
为您今天的需求创建一个相当简单的解决方案,该解决方案易于理解和使用。
编写简洁的代码并持续维护,使其保持干净。重构可能需要前期投入时间,但从长远来看,它会带来回报,因为代码更容易维护。
只有当编程原则能够让你的代码变得更好、更易于使用时才应用它们。
如果你对编程原则还不熟悉,可以考虑在练习时更深入地运用它们。这样你就能练习运用它们,并且能够感觉到自己是否用得太深了。
KISS(保持简单愚蠢)和最小惊讶原则
KISS(保持简单,愚蠢)是另一个适用于生活中大多数事物的原则。这意味着你的代码应该简单易懂。
最小惊讶原则也很重要。这意味着事情应该完全按照你的预期进行,不应该让你感到意外。它和 KISS 原则类似。
如果您不让事情变得简单易懂,那么:
- 一切都需要更长的时间才能理解
- 有时你可能不明白事情是如何运作的,即使你花了很多时间
- 你可能会误解软件的工作原理。然后,如果你修改软件,就很容易产生错误。
如何应用 KISS 和最小惊讶原则
以下是一些让您的代码简单且易于理解的指南。
默认编写愚蠢的代码,避免编写聪明的代码
愚蠢的代码就是简单的代码。聪明的代码可能不是简单的代码。
真正巧妙的代码并不简单,它难以理解,而且很棘手。人们很容易误解它,从而导致错误。
保持代码简短
较短的代码更有可能简单。
短代码意味着函数和类等单元执行的操作更少。这意味着它们更简单、更容易理解。
使用好名字
如果函数名称命名得当,你无需阅读函数体,就能从名称中理解它的功能。所有代码都是如此。这能让你的工作更快速、更轻松。
名称也提供了含义,可以帮助您更快地破译代码。
比如,你看到这段代码2 * Math.PI * radius
,即使读完之后,可能也不明白它在做什么,以及为什么这么做。你可能会看着它,然后问:“什么?圆周率?半径?这是什么??”
但是,如果你看到了const circleArea = 2 * Math.PI * radius
,你马上就会觉得“哦,我明白了。当然,它是在计算圆的面积。难怪圆周率和半径在那里……”。
始终考虑程序员第一次阅读代码
你要为这些人优化代码。他们可能是之前从未接触过这段代码的同事,甚至可能是你自己,六个月后,当你忘记这段代码的作用和工作原理时。
“如果你六个月或更长时间没有查看过你自己编写的任何代码,那么它很可能是由别人编写的。”——伊格尔森定律
想象一下,当你写代码的时候,你知道代码需要做什么,然后就直接写代码了。但是第一次读代码的人,必须解析代码在做什么,还要理解它为什么要这么做。
考虑不变性(从不重新分配变量的值)
不变性保证了值永远不会改变。
这使得代码更容易理解,因为您不必通过代码来追踪变量的历史记录,以防它发生在代码库中的任何地方发生变化。
遵循现有惯例
遵循现有约定的代码不足为奇。违反约定的代码则可能非常出乎意料。浏览代码的人可能不会意识到它没有遵循约定,因此可能会误解它的工作原理。
尽量遵循代码库中已有的约定。虽然编程语言或框架中已有的约定并非必需遵循,但也建议遵循。
关注点分离
关注点分离意味着在代码中很好地组织功能。
代码应该被划分成合理的单元(模块、类、函数和方法)。阅读代码的人应该能够立即理解每个单元的功能。
例如,如果你有一个Circle
类、一个Enumerable
接口、一个Math
对象或一个模块,你通常很清楚它们的作用和内容。你可能会期望找到Math.PI
、 或Math.pow(base, exponent)
(这些方法存在于 JavaScriptMath
对象中)。然而,你不会期望找到Math.printHelloToTheScreen()
或Math.produceAccountingReport()
。后一个例子中的方法可能是意料之外的,这会违反 KISS 原则和最小惊讶原则。
此外,单元应该很小,并且只做一件事(也称为单一职责原则)。另一种思考方式是,不同的关注点应该在粒度级别上分离。
例如,你不应该创建一个包含所有可能形状功能的“大类” Shape
。相反,你应该为每个形状创建一个小类。
此代码是错误的版本:
// Bad god class
class Shape {
constructor(typeOfShape, length1, length2 = null) { // length2 is an optional parameter
this.type = typeOfShape;
if (this.type === 'circle') {
this.radius = length1;
} else if (this.type === 'square') {
this.width = length1;
} else if (this.type === 'rectangle') {
this.width = length1;
this.length = length2
}
// And so on for many more shapes
}
getArea() {
if (this.type === 'circle') {
return Math.PI * this.radius ** 2;
} else if (this.type === 'square') {
return this.width * this.width;
} else if (this.type === 'rectangle') {
return this.width * this.length;
}
// And so on for many more shapes
}
}
这是好的版本:
// Good small and simple classes
class Circle {
constructor(radius) {
this.radius = radius;
}
getArea() {
return 2 * Math.PI * this.radius;
}
}
class Rectangle {
constructor(width, length) {
this.width = width;
this.length = length;
}
getArea() {
return this.width * this.length;
}
}
这是另一个例子。
此代码是错误的版本:
// Function does too many things
function sendData(data) {
const formattedData = data
.map(x => x ** 2)
.filter(Boolean)
.filter(x => x > 5);
if (formattedData.every(Number.isInteger) && formattedData.every(isLessThan1000)) {
fetch('foo.com', { body: JSON.stringify(formattedData) });
} else {
// code to submit error
}
}
此代码是更好的版本:
// Functionality is separated well over multiple functions
function sendData(data) {
const formattedData = format(data);
if (isValid(formattedData)) {
fetch('foo.com', { body: JSON.stringify(formattedData) });
} else {
sendError();
}
}
function format(data) {
return data
.map(square)
.filter(Boolean)
.filter(isGreaterThan5);
}
function isValid(data) {
return data.every(Number.isInteger) && data.every(isLessThan1000);
}
function sendError() {
// code to submit error
}
应该拥有小而具体的单元的想法适用于所有代码。
小型单位的优势
更小、更具体的单位具有多重优势。
更好的代码组织
从技术上讲,有了神级Shape
,您就知道去哪里找到圆圈功能,因此组织起来还不错。
Circle
但是,有了和的更具体的单位Rectangle
,您可以更快、更轻松地找到功能。
示例中不太明显sendData
,但道理相同。假设你想找到验证数据的功能。你可以在第二个版本中立即找到。有一个函数明确地命名为isValid
。sendData
它还调用isValid(formattedData)
,标记了数据验证的位置。
然而,在 的第一个版本中sendData
,您需要花费更多时间仔细阅读 的详细信息sendData
才能找到它。此外,数据验证的部分没有标签。您必须解析代码并识别执行数据验证的行。如果您不熟悉代码,这可能会很困难。
总而言之,较小的单位可以提供更好的组织。
简单易懂
如果你仔细检查这个Shape
例子,你会发现代码相当长而且复杂,很难理解。相比之下,类Circle
和Rectangle
函数非常简单,因此更容易理解。
在这个sendData
例子中,理解sendData
第二个版本的作用更容易一些。它读起来几乎像英语一样:
- 格式化数据
- 如果数据有效:获取
- 否则:sendError
您也不必阅读单独函数的实现,例如isValid
,因为它们的名称已经告诉您它们的作用。
所有较小的函数也更简单。它们都有清晰的标签(即使实现起来很复杂,也能帮助你理解它们),而且它们只做很小的事情。
一般来说,较小的单元代码量较少,功能也较少。这符合 KISS 原则,使代码更易于阅读和理解。
更容易改变
功能较少的代码比功能较多的代码更容易更改。
至少,你需要修改的代码不会被其他需要小心避免修改的代码所包围。此外,你需要在修改代码之前先理解它,这对于小单元来说更容易。
考虑一下神级Shape
示例。所有形状的功能代码都纠缠在一起。如果你尝试更改圆形的代码,可能会意外修改其他内容并导致 bug。此外,圆形的功能存在于 内部的多个不同方法中Shape
。你必须跳转并更改多个不同的内容。
另一方面,Circle
和Rectangle
非常容易更改。不相关的代码根本不存在。你不会意外破坏任何其他形状。
对于示例来说也是如此sendData
。
在第二个版本中,如果你想修改数据验证,只需修改代码isValid
即可。你不会破坏任何无关的代码,因为根本没有相关的代码。
然而,在第一个版本中,由于许多不相关的代码放在一起,您可能会意外地更改其他内容。
更容易测试
一般来说,如果一个单元做的事情较少,那么测试起来会比做较多事情时更容易。
更易于重复使用
如果一个单元只做一件事,那么当你需要做这件事的时候,它就可以立即被复用。但是,如果一个单元做 10 件事,甚至 2 件事,除非你需要所有这些事,否则它通常无法复用。
如何应用关注点分离
为了应用关注点分离,您需要提取功能。
例如,Shape
如果将圆形功能的所有相关代码提取到其自己的类中,则最终会得到Circle
。
这是一个更循序渐进的过程。
再次提供此Shape
参考。
class Shape {
constructor(typeOfShape, length1, length2 = null) { // length2 is an optional parameter
this.type = typeOfShape;
if (this.type === 'circle') {
this.radius = length1;
} else if (this.type === 'square') {
this.width = length1;
} else if (this.type === 'rectangle') {
this.width = length1;
this.length = length2
}
// And so on for many more shapes
}
getArea() {
if (this.type === 'circle') {
return Math.PI * this.radius ** 2;
} else if (this.type === 'square') {
return this.width * this.width;
} else if (this.type === 'rectangle') {
return this.width * this.length;
}
// And so on for many more shapes
}
}
让我们定义一个名为的类Circle
。
class Circle {}
从中Shape
,我们只提取与 circle 相关的构造函数功能。也就是constructor
方法内部和if (this.type === 'circle')
条件语句内部的部分。
class Circle {
constructor(radius) {
this.radius = radius;
}
}
对函数重复getArea
:
class Circle {
constructor(radius) {
this.radius = radius;
}
getArea() {
return Math.PI * this.radius ** 2;
}
}
依此类推,直到找到所有可能的方法Shape
。然后,对其他形状重复此操作。
同样的过程也适用于sendData
,尽管在这种情况下我们并没有sendData
像 和Shape
那样完全替换Circle
。相反,我们将功能提取到单独的函数中,并在 内部调用它们sendData
。
例如,将格式化数据的代码移入formatData
函数中,将检查数据是否有效的代码移入isValid
函数中。
何时应用关注点分离
现在您已经了解了关注点分离的“原因”和“方式”,那么您应该何时应用它呢?
一般来说,您需要“只做一件事的小型、特定的单位”。
然而,“一件事”的定义各不相同,取决于上下文。
如果你把神级的东西展示Shape
给别人看,他们可能会理直气壮地说它只做一件事。“它处理形状”。
有人可能会说它Shape
能做很多事。“它可以处理圆形、矩形等等。这可是很多种功能啊。”
这两种说法都是正确的。这完全取决于你考虑的抽象层次。
一般来说,考虑小层次的抽象是好的。你需要一些单元来执行一些细小而具体的事情。
这是因为,正如已经研究过的,较小的单位比较大的单位有更多的好处。
因此,这里有一些指导方针。
当代码感觉庞大而复杂时
如果您觉得某些代码难以理解或太大,请尝试从中提取一些单元。
可以继续提取吗?
罗伯特·马丁 (Robert Martin) 有一种技术,他称之为“提取直至你倒下”。
简而言之,您不断提取功能,直到没有合理的方法再提取功能为止。
在编写代码时,请考虑:“我可以从这个单元中提取更多功能到单独的单元中吗?”
如果可以进一步提取,那么考虑这样做。
有关此技术的更多信息,请参阅罗伯特·马丁 (Robert Martin) 的博客文章“提取直到你放弃” 。
改变的原因
考虑一下,这段代码需要改变的原因是什么?
就像我们已经检查过的,放在一起的代码,由于不同的原因而需要改变(不同的部分可能在不同的时间改变),是不好的。
解决方案是将具有不同原因的代码移动到单独的单元中。
考虑以下Shape
示例。Shape
将在以下情况下发生变化:
- 圆圈需要改变什么
- 矩形需要改变什么
- 任何其他形状都需要改变
- 需要添加或删除新形状
在sendData
示例中,sendData
如果发生以下情况,则可能会发生改变:
- 数据格式需要改变
- 数据验证需要改变
- 错误请求中的数据需要更改
- 错误请求的端点(URL)需要更改
- 请求中的数据
sendData
需要更改 - 请求的端点(URL)
sendData
需要更改
所有这些原因都表明您可能想要分离该功能。
谁(公司中的哪个角色)可能想要更改此代码
这是“这段代码需要改变的原因是什么”的另一种说法。
它询问谁(公司中的哪个角色)可能想要更改代码。
在sendData
示例中:
- 开发人员可能想要更改请求的 URL 端点或请求主体
- 会计师将来可能会想要改变数据验证
- 使用提交的数据生成报告的产品所有者可能希望将来以不同的方式格式化数据
这两个问题(什么可以改变以及谁可能想要改变)试图指出代码中的不同关注点,而分离可能会使这些问题受益。
务实
最后一点是务实。
你不必把所有东西都分离到极端。目标是让代码易于使用。
例如,你不需要强制代码库中每个函数的长度不超过 4 行(这是可以做到的)。这样你最终会得到数百个极小的函数。它们可能比那些平均长度在 4 到 8 行之间的更合理大小的函数更难处理。
最少知识原则
在软件开发中,最小化知识是有益的。这包括代码对其他代码(依赖关系)的了解,以及处理特定代码区域所需的知识。
换句话说,你希望软件能够解耦,并且易于使用。修改代码不应该破坏看似不相关的代码。
代码中的知识
在编程中,知识意味着依赖关系。
如果某个代码(称之为模块 A)知道另一个代码(称之为模块 B),就意味着它使用了那个代码。它依赖于那个代码。
如果某些代码正在其他地方使用,则意味着更改它的方式存在限制,否则您将破坏使用它的代码。
如果没有纪律和控制,你可能会陷入连锁反应,导致变更不断扩散。这种情况可能发生:你只想做一个小改动,却不得不修改系统中的每个文件。你修改了文件 A,而文件 B 和 C 又在用,所以你必须同时修改这两个文件来适应你对文件 A 的修改。反过来,文件 B 和 C 在其他文件 B 和 C 中被使用,你也不得不修改它们。如此反复。
每次更改都容易出错,多次级联更改则更糟糕。
此外,你需要真正记住或知道这些依赖项的存在。这相当困难,尤其是当依赖项在整个代码中广泛传播时。但是,如果你不记得,你就无法进行所有必要的更改,并且会立即引入错误。
这就是为什么您需要尽量减少代码中的知识。
代码修改
以下是您可以对现有代码进行的可能的更改。
合同无变化
您唯一可以做的、不会传播更改的更改是不会影响代码库中任何其他内容的更改。
例如:
// Original
function greet(name) {
return 'Hello ' + name;
}
// After change
function greet(name) {
return `Hello ${name}`;
}
从调用者的角度来看,这两个函数是等效的。它们具有相同的契约。如果您从一个版本更改为另一个版本,代码库中的其他内容无需更改,因为此更改不会对任何内容产生影响。
更改“私有”函数的契约
下一个最佳情况是当你更改私有函数的契约时。私有函数对大多数代码库来说并不公开。在这种情况下,即使更改契约,受影响的代码也非常小。
例如,考虑这个 Circle 类:
// Circle.js
class Circle {
constructor(radius) {
this.radius = radius;
}
getArea() {
return _privateCalculation(this.radius);
}
}
function _privateCalculation(radius) {
return Math.PI * radius ** 2;
}
export default Circle;
接下来,假设我们要删除_privateCalculation
。以下是更改后的代码:
// Circle.js
class Circle {
constructor(radius) {
this.radius = radius;
}
getArea() {
return Math.PI * this.radius ** 2;
}
}
export default Circle;
当我们删除 时_privateCalculation
,getArea
受到影响。因此,我们还必须进行修改getArea
以适应这些变化。但是,由于_privateCalculation
未在代码库的其他地方使用,并且getArea
没有更改其契约,所以我们已经完成了。代码库中的其他任何内容都无需修改。
更改公共函数的契约
这种模式以同样的方式延续下去。如果你改变了任何东西的契约,你就必须修改所有使用它的东西来适应它。如果你因此改变了更多的契约,你就必须修改更多的东西。如此反复。
例如,如果您删除getArea
,则必须更新代码库中所有使用它的代码。由于getArea
是一个公共函数,因此许多对象都可以使用它。
一般来说,您希望避免这些情况。
真正避免这些问题的唯一方法是妥善分离关注点。你需要将代码组织成对项目有意义的合理单元。如果做得好,就能最大限度地减少将来需要更改这些单元契约的可能性。
Circle
比如,班级需要更改合同的可能性有多大?非常低。
除此之外,请将所有内容保密,这样当您需要更改代码时,受影响的程度就会很小。
现在,有时需要对公共内容进行更改。这就是生活。这可能是由于新的需求,也可能是由于大规模的重构。您需要在需要时处理它们,但希望这种情况不会太频繁。
更多提示
最小知识原则还有更多应用。它们都致力于使代码不受变化的影响,并最大限度地减少处理代码所需的脑力知识。
该原则的其他应用包括:
- 接口隔离原则。这使得接口保持较小。这意味着使用接口的代码依赖更少的东西。这使得将来的修改更容易,例如根据接口拆分类,或者为接口创建一个更小的单独类。
- 迪米特法则。这可以防止函数/方法依赖于长链对象组合。
- 不变性。这消除了对变量的更改。这意味着您无需跟踪变量随时间的变化。这减少了您工作所需的知识。
- 只能访问本地作用域(或者可能是实例作用域)内的内容。全局内容可以被代码库中的许多内容访问。更改它们可能会破坏许多内容。跟踪它们随时间的变化也很困难,因为许多内容都可能改变它们。然而,本地内容更“私有”。这使得跟踪更改更容易。
抽象并且不要重复自己(DRY)
DRY(不要重复自己)是编程的核心原则。
它说的是,如果你有多个类似的代码实例,你应该将它们重构为一个单一的抽象。这样,你最终只会得到一个代码实例,而不是多个。
为了适应差异,最终的抽象接受参数。
DRY 的动机
DRY 原则的原因之一是减少编写代码所需的时间。如果您已经有了 X 功能的抽象,那么您可以导入并使用它,而不必每次需要时都从头开始重新编写代码。
另一个原因是为了让修改更容易。正如前文所述,我们不擅长重复性工作。如果代码遵循 DRY 原则,那么你只需在一个地方进行特定的更改。如果代码不符合 DRY 原则,那么你就必须在多个地方进行类似的更改。进行一次更改比进行多次类似的更改更安全、更快捷。
此外,保持代码 DRY 原则也体现了关注点分离。抽象必须放置在代码库中合理的位置(有利于代码组织)。此外,抽象的实现与调用者分离。
如何应用抽象和 DRY
以下是应用 DRY 的一些指导原则。
将相似的代码合并为一个抽象
当你发现多个相同或相似代码的实例时,请将它们合并为一个抽象。如果实例之间存在细微差异,请接受参数来处理它们。
在您的整个职业生涯中,您可能已经做过很多次这样的事了。
为了说明这一点,让我们以函数map
为例。map
是一个处理这个常见过程的函数:
- 创建一个新的空数组
- 使用 for 循环遍历数组
- 对每个值运行一些功能
- 将结果值推送到新数组
- for 循环结束后,返回新数组
这个过程非常常见,它经常出现在许多代码库中。
这是使用 for 循环的正常情况。
function double(x) {
return x * 2;
}
function doubleArray(arr) {
const result = [];
for (let i = 0; i < arr.length; i++) {
const element = arr[i];
const transformedElement = double(element);
result.push(transformedElement);
}
return result;
}
const arr = [1, 2, 3, 4];
const result = doubleArray(arr);
除了函数 之外doubleArray
,还有很多其他几乎完全相同的函数。唯一的区别在于它们迭代的数组以及对每个元素进行的转换。
因此,从这些函数中提取公共部分,并将它们放入一个名为的单独函数中map
。接受每次不同的参数,即数组和要在每个元素上运行的转换。
以下是最终的代码。
function map(array, transformationFn) {
const result = [];
for (let i = 0; i < array.length; i++) {
const element = arr[i];
const transformedElement = transformationFn(element);
result.push(transformedElement);
}
return result;
}
然后,在代码库中每个类似于的函数中doubleArray
,改用map
。
function double(x) {
return x * 2;
}
function doubleArray(arr) {
return map(arr, double);
}
const arr = [1, 2, 3, 4];
const result = map(arr, double);
(当然,JavaScript 中的数组已经有内置方法map
,因此您不需要创建独立map
函数。这只是为了说明目的。)
您可以对任何其他代码执行相同的操作。每当您遇到类似的代码时,请将其合并为一个抽象,并接受任何差异的参数。
三的法则
三的规则是为了防止过早地组合功能。
它指出,如果某个功能出现了三次,则应该将其合并为一个抽象。如果只出现了两次,则不要合并。
这是因为您组合的代码实例将来可能会出现分歧(每个实例可能会发生不同的变化)。
例如,考虑以下代码:
function validateUsername(str) {
return str.length >= 6;
}
function validatePassword(str) {
return str.length >= 6;
}
将重复的功能组合到其自己的抽象中可能是错误的,如下所示:
// combined too early
function validateUsername(str) {
return validate(str);
}
function validatePassword(str) {
return validate(str);
}
function validate(str) {
return str.length >= 6;
}
问题是,未来,validateUsername
和validatePassword
可能会发生不同的变化。不难看出这种情况会如何发生。
例如,将来validateUsername
可能需要检查没有特殊字符,而密码可能需要特殊字符。
显然,您可以validate
使用条件使这两种情况在函数中起作用,但这会比将功能分开时更加混乱。
这就是我们使用“三倍法则”的原因。等到第三次出现,类似的功能更有可能是重要的,而不是巧合。这意味着未来不太可能出现分歧。
这也使得如果三个相似代码实例中的一个出现分歧,您可以将其分离,同时仍然保留其他两个实例的抽象。另一方面,如果您在第二个实例中合并了功能,然后必须再次将它们分离出来,则必须同时还原它们。
总而言之,第二次重构更有可能浪费时间。
当然,“三分法则”只是一个指导原则。记住要务实,做对你的项目最有利的事情。一些类似的代码实例可能每次都以相同的方式更改。或者,它们可能都非常复杂,每次更改都必须进行类似的更改。在这种情况下,将它们合并为一个单一的抽象可能对你的项目更有利,即使你不得不忽略“三分法则”。
副作用
我们最后要讨论的是副作用。这不是一个单一的原则,而是许多原则+务实的结合。
(不,它们不仅仅是函数式编程的领域。所有代码都必须正确处理副作用。)
在编程中,副作用的一般定义是任何改变系统状态的事情。这包括:
- 改变变量的值
- 登录到控制台
- 修改 DOM
- 修改数据库
- 任何突变
它还包括可能不被视为变异的“动作”,例如通过网络发送数据。
我还说过,访问非本地作用域是一种副作用。它可能不在官方定义中,但它和其他副作用一样不安全,尤其是当你试图访问的变量是可变变量时。毕竟,如果你访问一个全局变量,而它的值不是你预期的,那么即使相关代码没有修改它,也会出现 bug。
所有代码都需要“副作用”才能发挥作用。例如,你必须在某些时候修改数据库或 DOM。
但副作用可能很危险,需要谨慎处理。
副作用的危险
副作用不会直接造成伤害,但可能会间接造成伤害。
例如,代码 A 和 B 可能都依赖于一个全局变量的值。你可能因为想影响代码 A 而更改了全局变量的值。但是,你却忘记了代码 B 也会受到影响。结果,你就遇到了一个 bug。
这些隐藏的依赖关系(当您更改一件事时其他事情就会中断)可能很难记住、跟踪和管理。
另一个例子是更改 DOM。DOM 可以被认为是一个具有状态的全局对象。问题在于,如果不同的代码片段在不同的时间以不兼容的方式影响 DOM,则可能会出现 bug。也许代码 A 依赖于元素 X 的存在,但代码 B 在代码 A 运行之前将整个部分全部删除了。
也许您在工作中也遇到过类似的错误。
此外,副作用破坏了我们迄今为止介绍的大部分原则:
- KISS 和最小惊讶原则
- 最少知识原则(因为代码会影响其他看似不相关的代码)
- 关注点分离(因为关注点不一定是独立的或组织良好的)
然而,需要理解的一件重要事情是,副作用本身并非有害。只有当我们编写的代码不正确时,它们才会导致 bug。副作用是指我们编写的代码恰好与我们编写的其他代码不兼容。我们编写了代码 A,然后又编写了代码 B,而这在某些情况下会破坏代码 A。
副作用的主要危险在于它们通常非常难以追踪。原因在于追踪全局状态非常困难,因为任何事物都可能随时修改全局状态。如果不加控制,你如何才能追踪 DOM 随时间的变化?你可能需要追踪太多东西,以至于追踪全局状态几乎不可能。
异步和竞争条件也增加了跟踪副作用的复杂性和难度。
副作用的另一个缺点是具有副作用的代码通常更难测试。
处理副作用
尽管副作用很危险,但可以有效地处理。
务实
最重要的一点,一如既往,就是务实。
你不必极力避免所有副作用。你只需要小心潜在的不兼容代码。
例如,不变性是避免多种副作用的好方法。然而,不变性对函数的局部作用域几乎没有影响。
例如,这里有两个做同样事情的函数。一个使用了不可变性,另一个没有。
function factorial1(n) {
let result = 1;
for (let i = 1; i <= n; i++) {
result *= i;
}
return result;
}
function factorial2(n) {
if (n <= 1) {
return 1;
}
return n * factorial2(n - 1);
}
示例中,使用了突变。和factorial1
的值在执行过程中都会发生变化。result
i
factorial2
使用不变性。函数执行期间,其内部变量的值永远不会改变。
但这没什么区别。除了递归的一些语言限制(在本例中我们将忽略这些限制)之外,就所有意图和目的而言,从调用者的角度来看,factorial1
它们factorial2
都是完全相同的。
事实上,人们往往不太适应递归,因此factorial2
对于你的团队来说,这实际上可能是更糟糕的选择。
因此,要务实,做对你的项目最有利的事情。
不变性
话虽如此,不变性是避免大部分副作用的简单方法。
通过避免在代码中不必要地修改变量,可以避免一个大问题。这样就不会发生意外的变化。您也无需跟踪变量的生命周期来了解它们包含的值。
从简单的开始,逐渐在工作中实现尽可能多的不变性。
不要修改变量,而是创建一个新的变量来赋予新的值。不要修改对象,而是创建一个新的对象来赋予新的值。
例如:
// Example 1 - Don't do this
function doubleArray(array) {
for (let i = 0; i < array.length; i++) {
array[i] = array[i] * 2; // mutates the original array
}
}
const arr = [0, 1, 2, 3];
doubleArray(arr);
// Example 2 - Do this
function double(x) {
return x * 2;
}
function doubleArray(array) {
return array.map(double); // returns a new array, without modifying the original
}
const arr = [0, 1, 2, 3];
const result = doubleArray(arr);
在示例 1 中,原始数组被修改。
在示例 2 中,原始数组未被修改。doubleArray
创建并返回一个包含双倍值的新数组。在函数外部,我们创建新变量result
来保存新数组。
不变性性能问题
不变性可能会稍微降低性能。不过,你不必担心,因为:
- 不应该过早地进行性能优化。除了代码本身的瓶颈之外,不要担心性能问题。
- 在大多数情况下,不变性不会对性能产生重大影响
- 你可以使用高性能的不可变数据结构库,例如 JavaScript 的 Immer。它将一些操作从 Big-O(n) 时间复杂度(例如复制整个对象)转换为 Big-O(1) 时间复杂度。
- 你可以务实一点。你不必在那些会影响性能的地方应用不变性。
此外,在某些情况下,不变性可以使事情更容易并行运行,从而提高性能。
避免非本地作用域
避免访问或修改函数或方法局部作用域之外的内容。这意味着,修改源自局部作用域的变量可能没问题,但不能修改作为参数传入的变量(源自局部作用域之外的变量)。
如果有必要,可以将事物改变为实例或模块范围。
离局部作用域越远,就越危险,因为事情会变得更加全局化。这会让事情更难追踪,并在代码中引入深远的依赖关系。
尽可能:
- 明确地将内容作为参数传递
- 尽可能贴近本地范围
例如:
// Example 1 - Don't do this
function doubleResult() {
result *= 2; // Accesses and mutates a variable outside of the local scope
}
let result = 5;
doubleResult();
// Example 2 - Do this
function double(n) {
return n * 2; // Accesses parameter which is in local scope. Doesn't mutate anything
}
const initialValue = 5;
const result = double(initialValue);
在示例 1 中,doubleResult
访问了result
,这是一个位于其局部作用域之外的变量。它还会对其进行变异,从而改变其值。现在,如果代码库中的任何其他代码访问result
,它将看到新的值。
在示例 2 中,double
它仅访问其参数,该参数属于其局部作用域的一部分。它不会改变其局部作用域之外的任何值。
在实际的代码库中,类似示例 1 的情况可能非常难以追踪。变量的定义位置可能离函数以及函数调用的位置result
都很远。这使得追踪 的值变得更加困难。doubleResult
result
另外,如果result
结果与你预期的不一样,那么说明你遇到了 bug。比如,你可能已经调用了doubleResult
3 次,但你可能不记得了。
总的来说,在示例 1 中,除非你知道当时result
的确切值,否则你无法预测某个函数将会做什么。为此,你需要搜索并跟踪整个代码库,以便随时掌握其动态。result
result
在第二个例子中,initialValue
始终为 5,因此不会有任何意外。此外,您可以立即看到函数正在执行的操作,并轻松预测将会发生什么。
要格外小心
有时你不能仅仅依赖不变性。例如,在某些时候,你必须改变 DOM 或数据库,或者调用第三方 API,或者运行某种副作用。正如前面提到的,异步只会加剧问题。
在这种情况下,你必须格外小心。
副作用可能是代码库中大多数 bug 的根源。它们是最难理解和追踪的代码。
无论您做什么来尝试管理它们,您都必须始终投入必要的时间和精力。
分离纯功能和非纯功能
在大多数情况下,尽量将有副作用的代码和没有副作用的代码区分开。你的函数不应该既有副作用又有“纯”代码。它们应该只做其中之一(在合理范围内)。
这也被称为命令-查询分离原则。它也是关注点分离的一种应用。
首先,像写入数据库这样的操作与计算写入数据库的内容截然不同。这两个关注点可能会因为不同的原因而独立地发生变化。正如我们在“关注点分离”中讨论的那样,它们应该被分开。
此外,纯函数通常易于理解、复用和测试。而具有副作用的函数则不然。因此,为了使代码库易于使用,您可能希望尽可能多的函数是纯函数。这意味着您应该将纯函数与副作用分开。
例如,不要这样:
function double(x) {
return x * 2;
}
function doubleArrayAndDisplayInDOM(array) { // this function does a non-trivial calculation / operation and performs a side effect
const doubled = array.map(double); // (pretend this is a non-trivial calculation / operation)
document.querySelector('#foo').textContent = doubled; // writing to the DOM is a side effect
}
function main() {
doubleArrayAndDisplayInDOM([1, 2, 3, 4]);
}
执行以下操作:
function double(x) {
return x * 2;
}
function doubleArray(array) { // this function only does a calculation / operation
return array.map(double);
}
function displayInDom(content) { // this function only performs a side effect
document.querySelector('#foo').textContent = content;
}
function main() {
const doubled = doubleArray([1, 2, 3, 4]);
displayInDom(doubled);
}
明确责任范围
你需要尽可能确保你的代码没有冲突。执行副作用的代码不应该与在不同时间执行其他副作用的代码冲突。
一个好方法是在代码中划分不同的责任区域。
例如,如果代码 A 修改了 DOM 中的元素 X,那么理想情况下,它应该是唯一修改该 DOM 部分的代码。所有其他需要影响 X 的代码都应该与代码 A 交互。这样,跟踪元素 X 的更改就会尽可能简单。
此外,请尝试妥善组织代码依赖关系。例如,如果任何其他代码的运行会与代码 A 冲突,则代码 A 不应运行。此外,如果代码 A 所依赖的状态不存在或不符合其预期,则代码 A 不应运行。
成对的副作用
对于成对出现的副作用(例如打开/关闭文件),启动副作用的函数也应该完成它。
例如,不要这样:
/* Note, this is pseudocode */
function openFile(fileName) {
const file = open(fileName);
return file;
}
const file = openFile('foo.txt');
/* Lots of other code in-between */
doStuffToFile(file);
close(file);
执行以下操作:
/* Note, this is pseudocode */
function useFile(fileName, fn) {
const file = open(fileName);
fn(file);
close(file);
}
useFile('foo.txt', doStuffToFile);
Robert Martin 将这种技术称为“传递块”。该函数useFile
会打开和关闭文件,因此不会在系统中留下打开的文件指针。
这确保文件不再需要时将被关闭。
至于对文件执行的功能,则传递给函数。它是参数fn
。
这可以确保您以后不会忘记完成副作用。它还能提供良好的代码组织,使代码易于理解和跟踪。所有副作用都集中在一个地方处理。
考虑使用框架或函数式编程语言
与不变性一样,最好的选择可能是尽可能避免副作用。
为了解决这个问题,您可以考虑将其中一些委托给框架、库或函数式编程语言。
例如,为了使用 DOM,您可以使用诸如React之类的库(或众多替代方案之一)。
像 React 这样的框架会处理所有与 DOM 相关的副作用。这样,在你的应用中,你只需编写纯函数即可。你不会直接修改 DOM。相反,你的函数会生成一个对象来表示 DOM 应该是什么样子。
这对您有好处,因为使用纯函数比处理副作用要容易得多。
至于实际修改 DOM,这些副作用仍然会发生,但它们现在是 React 的问题。
此外,React 的父子层次结构可确保您的 DOM 操作不会相互冲突并导致问题。例如,如果元素 X 实际上不存在,则涉及元素 X 的 React 代码将不会运行。这是一个良好的代码组织和结构示例,可防止与其他副作用发生冲突。
当然,使用类似的东西还有很多优点和缺点。但这只是一个供你考虑的选择。
进一步阅读
以上是对我认为编写优秀代码最重要的概念的概述。希望本文能帮助您理解简洁代码和编程原则背后的推理、动机和概述。希望这些知识能够在您继续学习更多编程原则或寻找更多实际示例时为您提供帮助。
下一步,我建议更实际地学习简洁代码和编程原则。使用能够通过大量示例和代码应用来解释相关概念的资源。
我强烈推荐Robert Martin创作的内容。就“快速”免费版本而言,我发现他的讲座《一起编码,共创美好世界 第一部分》和《一起编码,共创美好世界 第二部分》是我看过的最好的编程视频之一。想要了解更多详细信息,你可以看看他的书《代码整洁之道》或他的视频《代码整洁之道》(从基础系列和 SOLID 原则开始)。我从 Robert Martin 的资源中学到了很多。我尤其喜欢他非常务实的讲解,为每个原则都提供了大量的实例,并且提供了大量的通用信息。
我也觉得《程序员修炼之道》这本书很棒。有些细节虽然过时了,但概念却依然清晰。这本书真正地诠释了务实的理念。如果有人读过《程序员修炼之道》20周年纪念版,请告诉我你的看法。这本书在我的书单上,但我还没读过。
我相信还有其他令人惊叹的资源,但这些是我熟悉的并且可以亲自推荐的。
最后,我建议你好好思考一下这些编程原则。挑战一下它们,思考一下它们在哪些方面有用,哪些方面没用。花点时间好好思考一下本文讨论的所有内容。
好吧,如果你对本文讨论的内容有任何意见、反馈,甚至是反驳,请在评论区告诉我。我很乐意与你讨论。下次再见。
文章来源:https://dev.to/programmingduck/clean-code-programming-principles-the-ultimate-beginner-s-guide-5605