声明式 vs 命令式

2025-05-26

声明式 vs 命令式

目录


介绍

函数式编程是一种声明式编程范式,与命令式编程范式相对

声明式编程是一种描述程序做什么的范例,无需明确指定其控制流。

命令式编程是一种范式,它通过逐步明确指定每个指令(或语句)来描述程序应该如何执行某些操作,从而改变程序的状态。

这种“什么与如何”经常被用来比较这两种方法,因为......嗯,这实际上是一种描述它们的好方法。

当然,最终一切都会被编译成 CPU 的指令。因此,从某种程度上来说,声明式编程是在命令式编程之上的一层抽象。

在某些时候,程序的状态必须改变才能使某些事情发生,而这些改变只能通过指令将数据从一个位置(缓存、内存、硬盘……)移动到另一个位置来实现。但我们这里不讨论低级编程,所以让我们专注于高级语言。

从声明式到“命令式代码”的转换通常由引擎、解释器或编译器完成。

例如,SQL 是一种声明式语言。使用SELECT * FROM users WHERE id <= 100查询时,我们是在表达(或声明)我们想要的内容:数据库中注册的前 100 位用户。检索这些行的方式完全委托给了 SQL 引擎:它可以使用索引来加速查询吗?它是否应该/可以使用多个 CPU 核心来更早地完成查询?

从开发者的角度来看,我们不知道这些数据实际上是如何检索的。而且我们也不关心,除非我们正在研究一些性能问题。我们只关心告诉程序我们想要检索什么数据,而不是如何检索。引擎/编译器足够智能,可以找到最优的检索方式。

对于使用声明式范式的语言(例如 Haskell、SQL),这个“底层命令式世界”对开发人员来说是抽象/隐藏的。我们无需为此担心。

对于多范式语言(例如 JavaScript、Scala),仍然可以编写命令式代码。这使我们能够基于自己编写的命令式代码编写声明式代码。例如,这对于支持语言中非内置的函数式编程 (FP) 功能非常有用,或者仅仅是为了使代码更具“声明性”,在我看来,这会使代码更具可读性和易理解性。

命令式代码被声明式代码抽象出来,声明式代码是开发人员实际编写软件时使用的代码。命令式部分成为软件的实现细节。

制作巧克力蛋糕

让我们举一个现实世界的例子:我们想做一个巧克力蛋糕。如果用这两种范式,它会是什么样子?

命令式方法

  1. 首先,打开烤箱,预热至180°C。
  2. 接下来,将面粉、糖、可可粉、小苏打和盐放入一个大碗中,然后用搅拌器搅拌混合物。
  3. 然后,将牛奶、植物油、鸡蛋和香草精加入混合物中,以中速搅拌直至充分混合。
  4. 将蛋糕糊均匀地倒入大蛋糕盘中,然后烘烤约 30 分钟。
  5. 用隔热垫将锅从烤箱中取出,冷却 10 分钟。
  6. 最后,用敲击法将蛋糕从烤盘中取出,并用巧克力糖霜均匀地涂抹。

声明方式

  1. 您必须将烤箱预热至 180°C。
  2. 你必须将干原料混合在碗里。
  3. 将干成分混合后,您必须将湿成分添加到混合物中,并混合在一起形成蛋糕面糊。
  4. 一旦烤箱和面糊准备好了,你必须将面糊放入平底锅中,然后烘烤 30 分钟。
  5. 烘烤完成后,您必须将锅从烤箱中取出并让其冷却 10 分钟。
  6. 最后,你必须将蛋糕从锅中取出,然后将其抹上糖霜。
  7. 准备好了吗?出发!

分析

以命令式的方式,我们被告知要做什么,更重要的是如何去做:使用一个大碗,用桨搅拌,以中速搅拌,使用一个大平底锅,均匀分布面糊,用隔热垫取出平底锅,使用轻敲法,均匀地冷冻。

这些细节在实际制作蛋糕时非常有用,尤其是对于初学者来说。但是,当我们在“更高层次”的抽象上描述如何制作蛋糕时,我们不需要所有这些信息。

此外,我们实际上每一步都在做一些事情,也就是说,我们正在一步步改变我们周围的世界。如果我们选择在中间步骤停下来,那么我们基本上就“浪费”了前面步骤中的所有工具和原料。

在声明式编程中,我们被告知要做什么才能做蛋糕。直到最后一步,什么都不会真正发生,也就是说,直到我们到达第七步,世界才会发生变化。

换句话说,我们提前准备好所有步骤,然后在最后按照描述执行。那么,我们如何执行这些步骤中描述的操作呢?它是抽象的:所有“如何”的部分都尽可能在“准备好了吗?”和“开始!”之间提供,由开发人员(对于多范式语言)或引擎/编译器提供。例如,“从烤箱中取出平底锅”和“使用锅架”之间的绑定就是在这里完成的。我们也可以将其绑定到“使用平底锅手柄”,而无需更改第 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)


Enter fullscreen mode Exit fullscreen mode

但是我们可以直接使用map,因为它已经是声明性的,并且因这种类型的用例而广为人知:



const res = [1, 2, 3].map(n => n * 2)


Enter fullscreen mode Exit fullscreen mode

这是另一个示例,我们想要定位网页中某个元素的文本。该元素位于元素层次结构(称为 DOM 树)的下几层。问题在于,这些元素在实际中可能并不存在。

左侧是使用嵌套框表示的网页元素层次结构。右侧是使用圆圈(表示节点)和箭头表示的 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
  }
}


Enter fullscreen mode Exit fullscreen mode

这是相当冗长的,并且达到元素的深度越深,厄运金字塔就越大。

此外,我们还泄露了一个实现细节:一个不存在的节点的值为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)
}


Enter fullscreen mode Exit fullscreen mode

在第二个版本中,我们只关心如何访问树中的元素,中间元素可能缺失。换句话说,我们编写了“如何”访问包含所需文本的元素。

这假设我们可以访问Option代码库中的某些数据结构。互联网上有很多文章讨论这种Option(也称为Maybe)数据类​​型。本质上,它允许我们以声明的方式表达某个值可能缺失的情况,如果该值存在则对其进行转换,并将其与其他可能缺失的值组合起来。

事实上,这种数据类型非常有用,一些语言已经在其标准库中提供它(例如 Scala、Haskell、F#),甚至是更成熟的语言(例如OptionalJava、C++)。

此时, “flatMapmap”这两个术语可能看起来很“神秘”。我们将在本系列的最后,也就是关于代数数据结构和类型类的文章中讨论它们。在函数式程序中,你经常会遇到这些函数或它们的等效函数,具体取决于语言:

  • 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
}


Enter fullscreen mode Exit fullscreen mode

null这仍然“泄露”了应该使用或undefined值来标记元素缺失的事实,但它仍然比之前的第一个命令式版本更具表现力。

何时使用声明性代码

本节仅适用于多范式语言。显然,如果您使用的是函数式语言(例如 Haskell),则始终使用声明式代码。

因此,在某种程度上,让命令式代码看起来像声明式代码是可能的。在这种情况下,我建议将命令式代码与代码库的其余部分隔离开来,以确保开发人员使用“声明式”函数。

在多范式语言中,声明式和命令式之间的界限并非黑白分明,而是多种灰色地带。我们需要自行决定哪种风格最适合我们的项目和团队。

根据我的经验,以下是每种方法的优缺点的非详尽列表:

声明式

优点 缺点
提高代码的可读性和理解性 更多代码行,其中可能隐藏潜在错误
更好地控制世界变化的实际执行 由于更多的内存分配和中间函数调用,可能会造成性能损失
由于堆栈跟踪较大,调试时间更长
开发人员通常不太适应这种编程方式

至关重要的

优点 缺点
总体而言,代码更少,因为不需要将命令式代码包装在声明式函数中 需要花费更多时间来阅读和理解代码的作用
由于堆栈跟踪较小,调试时间更短 但由于状态突变和“控制力较弱”的改变,整体调试难度较大
开发人员通常更习惯这种编程方式

由于代码注定要被人类阅读和理解,我认为在我们的软件中使用更多的声明式编程是一种很好的做法。

有时,性能至关重要,需要使用命令式编程(我们这里指的是多范式语言)。在这种情况下,注释和文档对于理解代码库至关重要。否则,除了一些例外情况外,代码应该通过良好的命名和声明式步骤实现自解释,并且不需要注释就能很好地理解。

对于 Haskell 和 SQL 等严格声明性语言,编译器/引擎会进行尽可能最佳的优化;因此没有必要(也没有办法)编写命令式代码来提高性能。

结论

在本文中,我尝试通过一些示例来说明这两种方法之间的区别,以及声明式方法的优势。声明式方法最大的好处是使代码更具可读性和易理解性。

对代码库中某些部分职责的误解是导致 bug 产生的最常见原因之一。这也是为什么添加改进和功能需要更多时间的原因之一,因为我们需要先了解代码的功能,然后再进行任何更改。

函数式编程的核心在于表达我们想要用数据做什么,但直到最后一刻才真正执行。执行某些操作需要改变状态并运行语句。这些部分由引擎/解释器/编译器处理,因为它们知道“如何”高效地执行我们在代码库中编写的“操作”。

完全理解这种代码编写方式并非必要,因为随着你编写的函数式代码越来越多,这种理解会越来越自然。通过阅读本系列文章,你会发现声明式编程无处不在,尽管它并没有被明确提及。

感谢您读到这里!一如既往,如有需要,欢迎留言。下一篇文章将讨论纯函数和引用透明性。到时候见!


特别感谢Tristan Sallé审阅本文草稿。

照片由Xavi CabreraUnsplash上拍摄。

用Excalidraw制作的图片

文章来源:https://dev.to/ruizb/declarative-vs-imperative-4a7l
PREV
网站插图
NEXT
Vim 快速入门/备忘单 Vim 备忘单