2022,值得学习系列之:函数式编程

在诞生近 70 年之后, 函数式编程(Functional Programming)开始获得越来越多的关注。并且随着时间推移、技术演变,它与前端开发,结合的也越发紧密;前些日子,读了些文章,对函数式编程有了粗浅的了解,并在代码上做了些实践,发现这种思想非常有趣,喜之不已。近日我女神对它突感好奇,于是乎,便结合业界已有之分享,结合自己理解与实践,梳理成文,以便解释。

与非函数式的区别

非函数式的示例代码如下:

let counter = 0
const increment = () => {
    counter++
}
const increment = (counter) => {
    return counter + 1
}

你可能会觉得这个例子太过普通。但,这就是函数式编程的准则:不依赖于外部的数据,而且也不改变外部数据的值,而是返回一个新的值给你

再看一段复杂一点,却较为 实用的代码示例

const handleSearch = (_keywords) => {
  let queryResultArr;
  if (!_keywords) {
    queryResultArr = []
  } else {
    queryResultArr = getQueryResult(_keywords)
  }
  setQueryResultArr(queryResultArr)
  setIsShowResults(queryResultArr.length > 0)
}

const throttle = (fn, wait) => {
  let pre = Date.now();
  return function () {
    const context = this;
    const args = arguments;
    const now = Date.now();
    if (now - pre >= wait) {
      fn.apply(context, args)
      pre = Date.now()
    }
  }
}

const requestSearchFunc = throttle(handleSearch, 200)

const handleInputChange = (event) => {
  const value = event.target.value;
  setKeywords(value)
  requestSearchFunc(value)
}

可以使用 throttle节流函数,如:requestSearchFunc;这个技术其实就是 Currying 技术(把一个函数的多个参数分解成多个函数, 然后把函数多层封装起来,每层函数都返回一个函数去接收下一个参数这样,可以简化函数的多个参数)。从这个技术上,你可能体会到函数式编程的理念:把函数当成变量来用,关注于描述问题而不是怎么实现,这样可以让代码更易读。

过程式 vs 函数式

过程式编程,主要要采取过程调用,或函数调用的方式来进行流程控制;它主要关注:一步一步地解决问题。这是完全有效的编码方式,但当您希望应用程序扩展时,它存在许多缺点。而函数式编程( Functional Programming)关注的是:描述要做什么,而不是如何做(describe what to do, rather than how to do it)。

举例来说,对于一个英文名数组,需要将其中短横线命名,转化为大驼峰格式。基于传统过程式编程,可能你不会想太多,直接将想法用代码来表达出来,临时变量、循环语句,用的飞起:

const nameArr = ['nice-jade', 'rayso-lee', 'jon-snow', 'arya-stark'];
const newArr = [];
for (let i = 0, len = nameArr.length; i < len ; i++) {
  let name = nameArr[i];
  let tempNameArr = name.split('-');
  let newNameArr = [];
  for (let j = 0, nameLen = tempNameArr.length; j < nameLen; j++) {
    let newFormatName = tempNameArr[j][0].toUpperCase() + tempNameArr[j].slice(1);
    newNameArr.push(newFormatName);
  }
  newArr.push(newNameArr.join(' '));
}
console.log(newArr)
// [ 'Nice Jade', 'Rayso Lee', 'Jon Snow', 'Arya Stark' ]

这是可以达成目标的答案,它完全面向过程;虽然在编码时候,能够将想法淋漓尽致表现出来。但对于阅读的人,十分不够友好。因为这中间夹杂了复杂的逻辑,充斥了大量临时变量、循环、状态变化等等;通常您需要:从头读到尾才知道它具体做了什么,而且一旦出问题,很难定位。当然,你也可以将如上代码,拆分成几个函数:

const nameArr = ['nice-jade', 'rayso-lee', 'jon-snow', 'arya-stark']
const capitalize = (str) => str[0].toUpperCase() + str.slice(1).toLowerCase()

const convertName = (name) => {
  let tempNameArr = name.split('-')
  let newNameArr = []
  for (let j = 0, nameLen = tempNameArr.length; j < nameLen; j++) {
    let newFormatName = capitalize(tempNameArr[j])
    newNameArr.push(newFormatName)
  }
  return newNameArr.join(' ')
}

const getNewArr = (nameArr) => {
    const newArr = []
    for (let i = 0, len = nameArr.length; i < len; i++) {
      let name = nameArr[i]
      const newName = convertName(name)
      newArr.push(newName)
    }
    return newArr
}

const newNameArr = getNewArr(nameArr)
console.log(newNameArr)
// [ 'Nice Jade', 'Rayso Lee', 'Jon Snow', 'Arya Stark' ]

如此一来,阅读代码时,需要考虑的上下文少了许多,也就更容易。不像第一个示例,如果没有合理的注释说明,你还需要花些时间来理解。而把代码逻辑封装成了函数后,就相当于:给每个相对独立的程序逻辑取了个名字,于是代码成了自解释的。幸好,在这份代码中,函数间调用,没有依赖共享的变量,否则将会更加复杂。但,仍是充斥了临时变量、循环,增加代码量的同时,也加大了理解之难度。

如果基于函数式编程思想,那会是怎样的代码呢?

const { compose, map, join, split } = require('ramda')

const nameArr = ['nice-jade', 'rayso-lee', 'jon-snow', 'arya-stark']
const capitalize = (str) => str[0].toUpperCase() + str.slice(1).toLowerCase()
const convertName = compose(join(' '), map(capitalize), split('-'))
const newNameArr = nameArr.map((item) => convertName(item))

console.log(newNameArr)
// [ 'Nice Jade', 'Rayso Lee', 'Jon Snow', 'Arya Stark' ]>)

如上代码(有借助 ramda ——「实用的函数式 JavaScript 工具库」来实现),虽依然把程序的逻辑分成了函数,不过这些函数都是 Functional 的。因为它们有三个症状:

  1. 它们之间没有共享的变量。
  2. 函数间通过参数和返回值来传递数据。
  3. 在函数里没有临时变量。

从这个编程思路,可以清晰看出,函数式编程的思维过程是完全不同的,它的着眼点是函数,而不是过程,它强调的是:通过函数的组合、变换去解决问题,而不是通过写什么样的语句去解决问题;当你的代码越来越多的时候,这种函数的拆分和组合,就会产生出更加强大的力量。

什么函数式编程?

函数式编程 ,或称函数程序设计、泛函编程(英语:Functional Programming),是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态以及易变对象。比起指令式编程,函数式编程更加强调程序执行的结果,而非执行的过程;倡导利用若干简单的执行单元,让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。

在函数式编程中,函数是头等对象,意思是说一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。

不仅最古老的函数式语言 Lisp 重获青春,而且新的函数式语言层出不穷,比如 Erlang、clojure、Scala、F#等等。目前最当红的 Python、Ruby、Javascript,对函数式编程的支持都很强,就连老牌的面向对象的 Java、面向过程的 PHP,都忙不迭地加入对匿名函数的支持。越来越多的迹象表明,函数式编程已经不再是学术界的最爱,开始大踏步地在业界投入实用。

也许继"面向对象编程"之后,"函数式编程"会成为下一个编程的主流范式(paradigm)。未来的程序员恐怕或多或少都必须懂一点。—— 函数式编程初探 @阮一峰 (2012 年)

函数,即是一种描述集合和集合之间的转换关系,输入通过函数,都会返回有且只有一个输出值。函数实际上是一个关系,或者说成一种映射,而这种映射关系是可 组合 的,当知道一个函数的输出类型,可以匹配另一个函数的输入,那他们就可以进行组合。如上述代码中提及的 convertName 函数。

在编程世界中,需要处理的逻辑,其实只有“数据”和“关系”,而关系就是 函数。一旦映射关系(函数)找到了,问题即能迎刃而解;剩下的事情,就是让数据通过这种关系,然后转换成另一个数据而已。

函数式编程的特点

函数是"第一等公民"

所谓 "第一等公民" (First Class),指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。如上文中代码:

const convertName = compose(join(' '), map(capitalize), split('-'))

无状态和数据不可变

无状态(Statelessness)和数据不可变(Immutable data),这是函数式编程的核心概念:

  • 数据不可变:它要求你所有的数据都是不可变的,这意味着如果你想修改一个对象,那你应该创建一个新的对象用来修改,而不是修改已有的对象。
  • 无状态:主要是强调对于一个函数,不管你何时运行,它都应该像第一次运行一样,给定相同的输入,给出相同的输出,完全不依赖外部状态的变化。

为了实现这个目标,函数式编程提出函数应该具备的特性:没有副作用和纯函数。

没有"副作用"

所谓"副作用"(side effect),指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。

函数式编程强调没有"副作用",意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。

引用透明

引用透明(Referential transparency),指的是函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。

有了前面的第三点和第四点,这点是很显然的。其他类型的语言,函数的返回值往往与系统状态有关,不同的状态之下,返回值是不一样的。这就叫"引用不透明",很不利于观察和理解程序的行为。

惰性执行

所谓惰性执行(Lazy Evaluation),指的是函数只在需要的时候执行,即:不产生无意义的中间变量。像上面👆的例子,函数式编程跟命令式编程最大的区别就在于:几乎没有中间变量,它从头到尾都在写函数,只有在最后的时候才通过调用 convertName 产生实际的结果。

尾递归优化

迭代在函数式语言中常用递归来完成,用递归来实现控制流程的机制,是函数式编程的一个非常重要的特点。我想您当然知道 递归 的害处,那就是如果递归很深的话,stack 受不了,并会导致性能大幅度下降。所以,我们使用尾递归优化技术——每次递归时都会重用 stack,这样一来能够提升性能。尾递归的实现,其实是基于编译器的识别和优化的,编译器发现一个函数是尾递归,就会把它实现为与命令式编程中的迭代差不多的汇编码。

函数式编程相关技术

  • map & reduce :这个技术不用多说了,函数式编程最常见的技术,就是对一个集合做 Map 和 Reduce 操作。这比起过程式的语言来说,在代码上要更容易阅读。(传统过程式的语言需要使用 for/while 循环,然后在各种变量中把数据倒过来倒过去的)这个很像 C++中的 STL 中的 foreach,find_if,count_if 之流的函数的玩法。
  • pipeline:这个技术的意思是,把函数实例成一个一个的 action,然后,把一组 action 放到一个数组或是列表中,然后把数据传给这个 action list,数据就像一个 pipeline 一样顺序地被各个函数所操作,最终得到我们想要的结果。
  • compose:它可以接收多个独立的函数作为参数,然后将这些函数进行组合串联,最终返回一个“组合函数”。pipe 与 compose 的共同点是:都返回“组合函数”,区别则是执行的顺序不同,前者是从左向右执行,后者则是从右向左执行。
  • recursing 递归 :递归最大的好处就简化代码,他可以把一个复杂的问题用很简单的代码描述出来。注意:递归的精髓是描述问题,而这正是函数式编程的精髓。
  • currying:把一个函数的多个参数分解成多个函数, 然后把函数多层封装起来,每层函数都返回一个函数去接收下一个参数这样,可以简化函数的多个参数。在 C++中,这个很像 STL 中的 bind_1st 或是 bind2nd。
  • higher order function 高阶函数:所谓高阶函数就是函数当参数,把传入的函数做一个封装,然后返回这个封装函数。现象上就是函数传进传出,就像面向对象对象满天飞一样。

函数式编程的意义

  • 代码简洁,开发快速:大量使用函数,减少了代码的重复,因此程序比较短,开发速度较快;
  • 接近自然语言,易于理解:函数式编程的自由度很高,可以写出很接近自然语言的代码;
  • 更方便的代码管理:不依赖、也不会改变外界的状态,只要给定输入参数,返回的结果必定相同;
  • 更少的出错概率:因为每个函数都很小,而且相同输入,永远可以得到相同的输出,测试也很简单;
  • 易于"并发编程":因为它不修改变量,所以根本不存在"锁"线程的问题;
  • 代码的热升级:基于没有副作用特点,只要保证接口不变,内部实现是外部无关的。

函数式编程,结语

正如您在函数式编程中看到的,它希望使用小的(理想情况下是纯函数)函数来解决问题。这种方法也非常具有可 扩展性,并且函数可以 重用

没有更好和更坏的范式。有经验的开发人员,可以看到每种范式的优点,并为给定的问题选择相对更合适的。过程式编程,并不是说你不能使用函数;函数式编程也不会阻止你使用“类”。这些范式,只是以一种随代码增长而有益的方式,来帮助解决问题。

原文首发于:浅谈关于「函数式编程」的理解 | 宜想悠然亭,@2022 年 05 月 30 日。

所参考系列文章

猜您可能感兴趣的文章