成为函数式编程工程师四年,我为什么说它既“流氓”又“可爱”

2022-06-13 10:58:06 浏览数 (2)

作者 | John A De Goes

翻译 | 王强

策划 | 褚杏娟

近年来,函数式编程发展突飞猛进。探讨该主题的书籍和会议数量激增、Scala 和 Clojure 等语言在快速普及,还有 John Carmack、Bob Martin 等名人的支持,都说明了这一事实。

如今,没有哪种新发布的编程语言不支持“函数式编程”,甚至保守温和、经过企业认证的 Java 也开始有了 lambdas 甚至 monads。

是的,这是一个全新的世界。

为什么转向函数式编程?

我成为一名函数式编程软件工程师已经有四年多了。我喜欢函数式编程(FP),每天都能学到更多东西。

最近我接受了一份短期合同,参与一个现有 Java 应用程序的开发工作。在开发这个应用程序(在我看来它基本可以算作是“企业级 Java”)时,我重新审视了自己喜欢上函数式编程的基本原因。这些原因包括:

  • 高阶函数(让你把函数传递给函数,或从函数中返回函数)帮助你在程序中剔除很多重复内容。我重构了现有的 Java 应用,改为使用高阶函数,并在此过程中发现和修复了几个错误(都与复制和粘贴的错误有关)。
  • 不可变的数据结构在 FP 中经常使用,让你不必时刻担心代码会对传递的数据做什么奇怪的事情。在这个 Java 应用中,我发现了大量“防御性复制代码”。在我把许多核心数据结构从可变改为不可变后,轻松地删掉了这些复制代码。
  • 强类型出现在许多函数式编程语言中(但不是全部),它告诉我们更多关于代码的静态验证属性的信息。在这个 Java 应用程序中,我把很多代码从使用 null 改为使用一个通用的可选数据结构,这样可以更清楚地传达值可能不存在的情况。于是,我也就能删除很多防御性的 null 检查,同时修复一些不常见代码路径中的 NPE。
  • 纯函数,即没有副作用的函数(即它们的输出是其输入的确定性函数),更容易理解和测试,因为你不必怀疑函数的行为是否会根据隐藏状态而改变。在这个 Java 应用程序中,我将很多有状态的函数转换为无状态的函数,让代码更加简洁,并修复了一些错误。

此外还有其他的一些好处(当然也有缺点),但总的来说,在这个 Java 应用程序中,我能够用较少的代码行修复错误并实现大量的新功能。在我的经验中,这是很常见的收益。

这些好处是众所周知的。与 5 年前相比,今天的大多数程序员都听说过函数式编程,许多人都在使用 FP 中的一些技术(至少是高阶函数),而且越来越多的人加入进来,成为了 FP 的传教士。

函数式编程的“宗教信仰”

在函数式编程(FP)的光谱上,人们都落在了两个极端上。在一个极端,FP 是一种能够丰富指令式编程的方式(例如,将一个轻量级的回调传递给一个函数,或将一个块传递给一个循环)。而在另一个极端,FP 是一种编写所谓“纯”代码的方式——也就是没有副作用的代码,是纯粹的、参考透明的函数。

有些人已经深深地爱上了 FP(非常可以理解!),他们简直将 FP 当作了一种信仰。因此,我把它称为 FP 的“宗教信仰”。但只要是“宗教”就会有一个问题,那就是可能存在盲目的教条主义。

对于 FP 来说,我认为它蒙蔽了许多人的眼睛,让他们看不清一些本该显而易见的东西。

函数式编程(不管其定义如何)并不是软件工程的目标。相反,它是达到目的的一种手段,就像软件工程师口袋里的其他工具一样。

我知道这是异端邪说,所以让我来澄清一下。

软件工程的目标

作为一名软件工程师,我的工作一般来说是生产可运行、可理解,及可维护的软件。向我付费的人们大都希望开发结果包括以下几个方面:

  1. 代码能够可靠地工作,即使是应用程序中不经常使用的部分也是如此。
  2. 代码能被其他人轻易理解。我不会永远陪在他们身边解释代码。
  3. 编写代码时,尽量使未来的更改成本最小化。有一件事是不变的,那就是需求永远不会停止变化。

当然,他们通常也希望代码既快又便宜,但这就是另一个主题了。

其实,他们的希望也是我所希望的。我喜欢没有 bug 的代码,这让我对自己的工作有一种自豪感,而且我讨厌调试。我希望我写的所有代码都容易理解,因为我可能需要在几个月或几年后再回来看这些代码(另外它有助于减少错误)。而且我非常喜欢那些组织得很好的代码,我可以很容易和安全地改变它以适应新的需求。

因此,如果软件工程的目标是正常运作的、可理解及可维护的软件,那么顺着这个逻辑提出的问题是:函数式编程能帮助我们实现它吗?我的答案是:不一定。

“流氓”的函数式编程

为了说明我的观点,我决定在函数式编程语言 Haskell 中实现快速排序。按照其主页上的描述,Haskell 是一种高级的、纯粹的函数式编程语言,目前也是我最喜欢的编程语言之一。

你几乎不可能在其他语言中得到比 Haskell 更多的“FP”基因了。所有用 Haskell 编写的程序都是纯函数式的(虽然有一些方法可以作弊,但我们在这里可以忽略不计)。

说到这里,请打起精神,看看我对快排的实现。

代码语言:javascript复制
module Main (main) where
import Control.Applicative
import Data.Array.MArray
import Data.Array.IO
import Data.IORef
type Array a = IOArray Int a
whileM :: IO Bool -> IO () -> IO ()
whileM pred effect = do
  rez <- pred
  if rez
  then do
    effect
    whileM pred effect
  else return ()

quick_sort :: Ord a => Array a -> IO (Array a)
quick_sort a = do
  (m, n) <- getBounds a
  let loop' = loop
  loop' a m (n   1)
  where 
    loop ary m n = if (n < 2) then return ary else do
      let readVal idx = readArray ary (idx   m)

      let writeVal idx = writeArray ary (idx   m)

      let readValRef ref = readIORef ref >>= readVal
      let writeValRef ref v = readIORef ref >>= writeVal <*> pure v

      pivotVal <- readVal $ n `div` 2

      leftIdxRef  <- newIORef 0
      rightIdxRef <- newIORef $ n - 1

      let incLeft  = modifyIORef leftIdxRef ( 1)
      let decRight = modifyIORef rightIdxRef (subtract 1)

      let readLeftIdx = readIORef leftIdxRef
      let readRightIdx = readIORef rightIdxRef

      whileM ((<=) <$> readLeftIdx <*> readRightIdx) $ do
        leftVal  <- readValRef leftIdxRef
        rightVal <- readValRef rightIdxRef

        if (leftVal < pivotVal) then incLeft
        else if (rightVal > pivotVal) then decRight
        else do 
          writeValRef leftIdxRef rightVal
          writeValRef rightIdxRef leftVal
          incLeft
          decRight

      leftIdx  <- readLeftIdx
      rightIdx <- readRightIdx

      loop a m       (rightIdx   1)
      loop a leftIdx (n - leftIdx)
main = newListArray (0, 7) [9, 2, 3, 45, 2, 9, 2, 1] >>= quick_sort >>= getElems >>= putStrLn.show

尽管这个程序是“纯函数式的”,但它的代码是完全、彻底的垃圾:

  • 当我第一次写好它后,它出现了几个 bug,我花了很多时间来追踪它们。
  • 它很难理解。事实上,C 语言的实现可能会更容易理解。
  • 对于这样一个小函数来说,它非常难以维护。安全地修改代码需要大量的思考和测试,而且你可能无法重用很多代码。

注意,我用的词是“垃圾”。但我很清楚,有时不得不为编写系统级、性能优先的代码而付出代价。然而,对于大多数现代软件工程来说,情况并非如此。

上述就是一个纯粹的函数式程序,它与软件工程的目标完全无关。这是一个不那么典型的示范,但还有许多更能说明问题的现实范例,函数式程序员会很认同它们的。

这是 FP 的流氓行为,也证明了代码是“纯函数式“并不意味着就一定有什么价值。

可爱的函数式编程

现在我想给大家看一下 Haskell 中比较有名的快排例子。这并不完全是经典的快速排序,因为它并不是原地排序,但也足够接近了。

代码语言:javascript复制
quicksort :: Ord a => [a] -> [a]
quicksort []     = []
quicksort (p:xs) = (quicksort lesser)    [p]    (quicksort greater)
    where
        lesser  = filter (< p) xs
        greater = filter (>= p) xs

这才是优雅的实现!这也是为什么人们会这么喜欢 FP 的原因。

从定义上来说,这段代码的确是正确的。如果你了解 Haskell 的语法,它就很容易理解,而且没有什么排序代码比它更容易维护的了(好吧,filter 确实应该被 partition 取代,因为 filter 会破坏信息;使用 filter 需要手动否定布尔谓词< p,这代表了重复的信息内容)。

我们现在有两个纯粹的函数式程序,都是用同样的语言编写的,但两者之间却有天壤之别。

这是什么原因呢?

函数式编程不是目标

我的观点是,尽管 FP 让我们更容易编写好的代码,但仅仅因为某些东西是函数式的,甚至是“纯函数式的”,并不一定意味着它就有多好。

换句话说,作为试图改进自己技术的软件工程师,我们不应该仅仅因为某个东西是“函数式的”或“纯函数式的”就崇拜它或为它辩护。虽然使用函数式编程的技术有可能写出好代码,但也有可能写出坏代码。

FP 并不能保护我们。我们需要另一种标准来衡量“好代码“,而不是简单地认为“函数式“就是好代码。

我认为这个标准与可组合性、可理解性和正确性有很大关系。

good_code=c^3

本质而言,我认为所有的好代码都具有以下特性:

  1. 你可以很好地理解它是如何工作的,以至于有理由相信它是正确的(并且在大多数情况下,这种信心是正确的!)。
  2. 你可以把两段可理解的、正确的代码拿出来,并很容易地把它们组成另一段既可理解又正确的代码。

这是对好软件的一个非常人性化,并是以认知为中心的定义。

毕竟,我们是被美化的、会说话的猿猴,填充我们头骨的脂肪从来就不是为了写软件而设计的。

认识到这一事实后,我们就可以通过好代码的定义来尝试提升自己编写和维护正常软件的能力(事实上,我们在这方面的能力是相当有限的)。

函数式编程不是答案

在给好代码下定义时,我没有提到任何与函数式编程、静态类型或其他很多东西相关的内容,因为这些“只是”达到目的的手段。有时这些手段可以帮助我们创建、理解和编排正确的代码。

但就其本身而言,它们并不是我们工作的目标。

换句话说,一个东西是否是“坏“的,与它是否“纯函数式“无关。“纯粹的函数式“既不是好代码的必要条件,也不是充分条件。

我们不能停留在函数式的世界里。我们不能因为自己写出来“纯函数式“的代码就拍拍屁股走人。我们不能忽视“非函数式化“的编程技术,包括逻辑编程和响应式编程等等一大堆范式。

每一种技术都必须根据其自身的特点来衡量优劣,而与它是否是“函数式“无关。

原文链接:

https://degoes.net/articles/fp-is-not-the-answer

0 人点赞