声明式 vs 命令式
目录
介绍
函数式编程是一种声明式编程范式,与命令式编程范式相对。
声明式编程是一种描述程序做什么的范例,无需明确指定其控制流。
命令式编程是一种范式,它通过逐步明确指定每个指令(或语句)来描述程序应该如何执行某些操作,从而改变程序的状态。
这种“什么与如何”经常被用来比较这两种方法,因为......嗯,这实际上是一种描述它们的好方法。
当然,最终一切都会被编译成 CPU 的指令。因此,从某种程度上来说,声明式编程是在命令式编程之上的一层抽象。
在某些时候,程序的状态必须改变才能使某些事情发生,而这些改变只能通过指令将数据从一个位置(缓存、内存、硬盘……)移动到另一个位置来实现。但我们这里不讨论低级编程,所以让我们专注于高级语言。
从声明式到“命令式代码”的转换通常由引擎、解释器或编译器完成。
例如,SQL 是一种声明式语言。使用SELECT * FROM users WHERE id <= 100
查询时,我们是在表达(或声明)我们想要的内容:数据库中注册的前 100 位用户。检索这些行的方式完全委托给了 SQL 引擎:它可以使用索引来加速查询吗?它是否应该/可以使用多个 CPU 核心来更早地完成查询?
从开发者的角度来看,我们不知道这些数据实际上是如何检索的。而且我们也不关心,除非我们正在研究一些性能问题。我们只关心告诉程序我们想要检索什么数据,而不是如何检索。引擎/编译器足够智能,可以找到最优的检索方式。
对于使用声明式范式的语言(例如 Haskell、SQL),这个“底层命令式世界”对开发人员来说是抽象/隐藏的。我们无需为此担心。
对于多范式语言(例如 JavaScript、Scala),仍然可以编写命令式代码。这使我们能够基于自己编写的命令式代码编写声明式代码。例如,这对于支持语言中非内置的函数式编程 (FP) 功能非常有用,或者仅仅是为了使代码更具“声明性”,在我看来,这会使代码更具可读性和易理解性。
命令式代码被声明式代码抽象出来,声明式代码是开发人员实际编写软件时使用的代码。命令式部分成为软件的实现细节。
制作巧克力蛋糕
让我们举一个现实世界的例子:我们想做一个巧克力蛋糕。如果用这两种范式,它会是什么样子?
命令式方法
- 首先,打开烤箱,预热至180°C。
- 接下来,将面粉、糖、可可粉、小苏打和盐放入一个大碗中,然后用搅拌器搅拌混合物。
- 然后,将牛奶、植物油、鸡蛋和香草精加入混合物中,以中速搅拌直至充分混合。
- 将蛋糕糊均匀地倒入大蛋糕盘中,然后烘烤约 30 分钟。
- 用隔热垫将锅从烤箱中取出,冷却 10 分钟。
- 最后,用敲击法将蛋糕从烤盘中取出,并用巧克力糖霜均匀地涂抹。
声明方式
- 您必须将烤箱预热至 180°C。
- 你必须将干原料混合在碗里。
- 将干成分混合后,您必须将湿成分添加到混合物中,并混合在一起形成蛋糕面糊。
- 一旦烤箱和面糊准备好了,你必须将面糊放入平底锅中,然后烘烤 30 分钟。
- 烘烤完成后,您必须将锅从烤箱中取出并让其冷却 10 分钟。
- 最后,你必须将蛋糕从锅中取出,然后将其抹上糖霜。
- 准备好了吗?出发!
分析
以命令式的方式,我们被告知要做什么,更重要的是如何去做:使用一个大碗,用桨搅拌,以中速搅拌,使用一个大平底锅,均匀分布面糊,用隔热垫取出平底锅,使用轻敲法,均匀地冷冻。
这些细节在实际制作蛋糕时非常有用,尤其是对于初学者来说。但是,当我们在“更高层次”的抽象上描述如何制作蛋糕时,我们不需要所有这些信息。
此外,我们实际上每一步都在做一些事情,也就是说,我们正在一步步改变我们周围的世界。如果我们选择在中间步骤停下来,那么我们基本上就“浪费”了前面步骤中的所有工具和原料。
在声明式编程中,我们被告知要做什么才能做蛋糕。直到最后一步,什么都不会真正发生,也就是说,直到我们到达第七步,世界才会发生变化。
换句话说,我们提前准备好所有步骤,然后在最后按照描述执行。那么,我们如何执行这些步骤中描述的操作呢?它是抽象的:所有“如何”的部分都尽可能在“准备好了吗?”和“开始!”之间提供,由开发人员(对于多范式语言)或引擎/编译器提供。例如,“从烤箱中取出平底锅”和“使用锅架”之间的绑定就是在这里完成的。我们也可以将其绑定到“使用平底锅手柄”,而无需更改第 5 步的定义。
一些例子
假设我们想将给定数字列表的每个值都翻倍。在 JavaScript 中,有很多方法可以遍历列表并转换其每个元素:
- 声明式:递归函数,或已有函数,例如数组的
map
和方法reduce
- 命令式:
for
循环,while
循环
为了证明命令式代码可以被声明式代码抽象,我们可以使用for
循环并将其隐藏在transformEachElement
函数内部:
// "hidden" in a utils/helper/whatever module, or library-like
function transformEachElement<A, B>(
elements: A,
action: (element: A) => B
): B[] {
const result = []
for (let i = 0; i < elements.length: i++) {
result.push(action(elements[i]))
}
return result
}
// What do we want? Double each number of a given list
const res = transformEachElement([1, 2, 3], n => n * 2)
但是我们可以直接使用map
,因为它已经是声明性的,并且因这种类型的用例而广为人知:
const res = [1, 2, 3].map(n => n * 2)
这是另一个示例,我们想要定位网页中某个元素的文本。该元素位于元素层次结构(称为 DOM 树)的下几层。问题在于,这些元素在实际中可能并不存在。
因此,每次我们前进树中的一个节点时,我们都必须检查下一个节点是否可用。
命令式的方式可能看起来像这样:
function getMainTitle(): string | null {
const main = document.getElementById('main')
if (main !== null) {
const title = main.querySelector('.title')
if (title !== null) {
const text = title.querySelector<HTMLElement>('.title-text')
if (text !== null) {
return text.innerText
} else {
return null
}
} else {
return null
}
} else {
return null
}
}
这是相当冗长的,并且达到元素的深度越深,厄运金字塔就越大。
此外,我们还泄露了一个实现细节:一个不存在的节点的值为null
。它本来可以是undefined
,或者'nothing'
,或者其他完全不同的值。关键在于我们必须理解是一个魔法值,用来表示树中元素的缺失。理解这个函数的作用null
应该不需要知道这一点。
这是一种更具声明性的方法:
const main: Option<Element> =
Option(document.getElementById('main'))
function getTitle(main: Element): Option<Element> {
return Option(main.querySelector('.title'))
}
function getTitleText(title: "Element): Option<HTMLElement> {"
return Option(
title.querySelector<HTMLElement>('.title-text')
)
}
function getMainTitle(): Option<string> {
return main
.flatMap(getTitle)
.flatMap(getTitleText)
.map(text => text.innerText)
}
在第二个版本中,我们只关心如何访问树中的元素,中间元素可能缺失。换句话说,我们编写了“如何”访问包含所需文本的元素。
这假设我们可以访问Option
代码库中的某些数据结构。互联网上有很多文章讨论这种Option
(也称为Maybe
)数据类型。本质上,它允许我们以声明的方式表达某个值可能缺失的情况,如果该值存在则对其进行转换,并将其与其他可能缺失的值组合起来。
事实上,这种数据类型非常有用,一些语言已经在其标准库中提供它(例如 Scala、Haskell、F#),甚至是更成熟的语言(例如Optional
Java、C++)。
此时, “
flatMap
和map
”这两个术语可能看起来很“神秘”。我们将在本系列的最后,也就是关于代数数据结构和类型类的文章中讨论它们。在函数式程序中,你经常会遇到这些函数或它们的等效函数,具体取决于语言:
map
也称为fmap
,,lift
<$>
flatMap
也称为bind
,,chain
>>=
几年前(2019 年 12 月),可选运算符提案在 EcmaScript 规范中达到了第 4 阶段,适用于 JavaScript 和 TypeScript。这使我们能够大大简化上述代码,而无需依赖任何库:
function getMainTitle(): string | null {
return document.getElementById('main')
?.querySelector('.title')
?.querySelector<HTMLElement>('.title-text')
?.innerText
}
null
这仍然“泄露”了应该使用或undefined
值来标记元素缺失的事实,但它仍然比之前的第一个命令式版本更具表现力。
何时使用声明性代码
本节仅适用于多范式语言。显然,如果您使用的是函数式语言(例如 Haskell),则始终使用声明式代码。
因此,在某种程度上,让命令式代码看起来像声明式代码是可能的。在这种情况下,我建议将命令式代码与代码库的其余部分隔离开来,以确保开发人员使用“声明式”函数。
在多范式语言中,声明式和命令式之间的界限并非黑白分明,而是多种灰色地带。我们需要自行决定哪种风格最适合我们的项目和团队。
根据我的经验,以下是每种方法的优缺点的非详尽列表:
声明式
优点 | 缺点 |
---|---|
提高代码的可读性和理解性 | 更多代码行,其中可能隐藏潜在错误 |
更好地控制世界变化的实际执行 | 由于更多的内存分配和中间函数调用,可能会造成性能损失 |
由于堆栈跟踪较大,调试时间更长 | |
开发人员通常不太适应这种编程方式 |
至关重要的
优点 | 缺点 |
---|---|
总体而言,代码更少,因为不需要将命令式代码包装在声明式函数中 | 需要花费更多时间来阅读和理解代码的作用 |
由于堆栈跟踪较小,调试时间更短 | 但由于状态突变和“控制力较弱”的改变,整体调试难度较大 |
开发人员通常更习惯这种编程方式 |
由于代码注定要被人类阅读和理解,我认为在我们的软件中使用更多的声明式编程是一种很好的做法。
有时,性能至关重要,需要使用命令式编程(我们这里指的是多范式语言)。在这种情况下,注释和文档对于理解代码库至关重要。否则,除了一些例外情况外,代码应该通过良好的命名和声明式步骤实现自解释,并且不需要注释就能很好地理解。
对于 Haskell 和 SQL 等严格声明性语言,编译器/引擎会进行尽可能最佳的优化;因此没有必要(也没有办法)编写命令式代码来提高性能。
结论
在本文中,我尝试通过一些示例来说明这两种方法之间的区别,以及声明式方法的优势。声明式方法最大的好处是使代码更具可读性和易理解性。
对代码库中某些部分职责的误解是导致 bug 产生的最常见原因之一。这也是为什么添加改进和功能需要更多时间的原因之一,因为我们需要先了解代码的功能,然后再进行任何更改。
函数式编程的核心在于表达我们想要用数据做什么,但直到最后一刻才真正执行。执行某些操作需要改变状态并运行语句。这些部分由引擎/解释器/编译器处理,因为它们知道“如何”高效地执行我们在代码库中编写的“操作”。
完全理解这种代码编写方式并非必要,因为随着你编写的函数式代码越来越多,这种理解会越来越自然。通过阅读本系列文章,你会发现声明式编程无处不在,尽管它并没有被明确提及。
感谢您读到这里!一如既往,如有需要,欢迎留言。下一篇文章将讨论纯函数和引用透明性。到时候见!
特别感谢Tristan Sallé审阅本文草稿。
照片由Xavi Cabrera在Unsplash上拍摄。
用Excalidraw制作的图片。
文章来源:https://dev.to/ruizb/declarative-vs-imperative-4a7l