Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

函数式编程概述 #102

Open
peng-yin opened this issue Mar 20, 2023 · 0 comments
Open

函数式编程概述 #102

peng-yin opened this issue Mar 20, 2023 · 0 comments

Comments

@peng-yin
Copy link
Owner

函数式编程概述

函数式编程倡导使用数学中的函数思想指导编程语言中函数的编写,使用函数构建程序。其背后的哲学思想是程序的本质是计算,而函数是计算的严密的精确化的表达。函数是编程关注做什么,而如何做都抽象到函数中,通过函数屏蔽了如何做的复杂性。函数式编程让能程序更健壮、易测试、易扩展和复用、随意并发等优点。

一些书籍说函数式还能让代码更容易理解和合理,此观点我目前还没看到。实际上,生活体验告诉我,大多数人的思维方式都是逻辑不严密的、分散的,使用数学里的函数思想来指导编程,是困难的,不容易写出好代码。估计这也是函数式编程或者声明式编程不那么流行,而面向对象、面向过程等命令式编程范式大行其道的原因。

也可能是我还没理解到函数式编程的精华。

编程范式

编程范式,编程方式或者编程原则,代表着编程语言或者编程语言的设计者或者使用者对程序的基本看法。学习一门新语言式时,从编程范式去理解它的设计思想,对写好代码有很好的帮助,它更加高屋建瓴。

编程范式一般包括三个方面,以 OOP 为例:

  • 学科的逻辑体系——规则范式:如类/对象、继承、动态绑定、方法改写、对象替换等等机制。

  • 心理认知因素——心理范式:按照面向对象编程之父 Alan Kay 的观点,“计算就是模拟”。OO 范式极其重视隐喻(metaphor)的价值,通过拟人化,按照自然的方式模拟自然。

  • 自然观/世界观——观念范式:强调程序的组织技术,视程序为松散耦合的对象/类的集合,以继承机制将类组织成一个层次结构,把程序运行视为相互服务的对象们之间的对话。

简单的说,编程范式是程序员看待程序应该具有的观点。

不同的编程范式代表不同的世界观,使用不同的编程范式编程,代码的组织方式、可读性、测试难度,也会不同。

有的语言只主要支持一种编程范式,比如Haskell只是支持函数式编程,C 只支持过程式编程,有的语言支持多种,比如 JavaScript、 C++ 支持过程式、面向对象、函数式等,是多范式语言。

非结构化编程 (Non-structured programming)

最早期的编程范式,利用 goto 或者 label 语句在代码块之间跳转,比如汇编、早期的 BASIC 等,经过计算机科学的不懈探索,发现 goto 语句有害,代码非常难以阅读和推理,这种编程范式基本不再使用。

结构化编程 (structured programming)

计算机科学发现 goto 语句的有害后,证明了只需要顺序语句循环语句条件语句 就能完成编程,结构化编程就此诞生。计算机科学们对编程语言是否废除goto有不少争论,有人认为 goto 能使得程序清晰,有人认为使得程序不清晰,现在的流行的编程语言来看,主张废除一派占据上风,广大变程序员已经适应了没有 goto 语句的代码。本人认为 goto 确实使得程序难以理解。

广泛使用的编程语言都是结构化编程语言。

命令式编程

使用语句改变程序状态的编程范式。需要一步一步告诉计算机如何执行代码。

let age = age + 1 // 改变 age ,即改变程序状态

// 一步一步告诉计算机如何执行代码
let array = [1, 2, 4]
const size = array.length
for (let i = 0; i < size; i++) {
  console.log(array[i])
}

支持这一范式的编程语言有:C、C++、Java、JavaScript、Python 等

结构化编程又可以细分为面向对象、面向过程、面向切面等编程范式。

可以说命令式编程是程序状态或者副作用驱动的,根据状态执行代码。

面向对象

面向对象编程认为,任何一个事物都可抽象成一个对象,用数据(属性)表示对象的状态,用方法表示对数据的操作,对象是数据和行为的结合。

把具有相同或者相似的对象组合,抽象成类或者类别,比如自行车有 2 个轮子、轿车有 4 个轮子,但是他们都是交通交通工具,就可抽象一个交通工具的类统一管理这些车,需要某个类别的车,由类创建即可,当然,你也可以把自行车、汽车分成两个类,它们可继承交通工具类的一些属性。

类是对象的模板。

这是基于类的面向对象。支持面向对象的语言有 Java、C++、JavaScript 等。

有人认为不需要类来抽象对象,而是需要对象的原型,这就是基于原型的面对对象。JavaScript 支持这种编程范式。

基于原型和基于类有何不同呢?为何很多 JavaScript 代码式基于类的风格,而不是原型?

面对对象的编程范式取得巨大成功,很多大规模软件都使用这种编程范式开发。

面向过程

数据和处理的数据的函数分开。比如 C 。

面向切面

根据分离关注点的原则,可把面向对象分为面向切面面向角色面向学科编程。

关于分离关注点,体会不是很深,还需要阅读相关资料。

声明式编程

与命令式编程相反,声明编程主张告诉计算机做什么,而不是指定如何做

比如SQLscheme正则等语言。

// 命令式写法
let array = [1, 2, 4]

const size = array.length
for (let i = 0; i < size; i++) {
  console.log(array[i])
}
// 声明式写法
array.forEach(item => {
  console.log(item)
})

比如声明式,forEach 更加好理解,也不容易出错。

声明式编程的基础是形式逻辑,具有容易推理、容易测试等特点,缺点就是不容易写出好代码,可能和人的天生反逻辑有关,否则我们都是数学家了。

声明式编程可细分为函数式编程逻辑式编程

函数式编程

是一种古老的编程范式,在冯诺依曼计算诞生之前,就已经存在了,因为数学家们研究计算已经很久了。它的理论基础是拉姆达演算

命令式编程式副作用或者状态的改变驱动的,而函数式编程主张无状态,是函数演算驱动的,它认为程序的本质式计算,而数学函数正好可以完成这些计算。

具有的特点:

  • 无状态: 函数不维护状态。面对对象里,对象具有状态

  • 数据不可变:输入数据不可改变,改变了就要返回新的数据

  • 函数一等值

  • 带有闭包

  • 尾递归

  • 无控制流

一等值:一等不是类似一流王牌的文学表达,而是编程语言的专业术语,满足下列三个条件的函数,才能是一等值:

  1. 可作为参数

  2. 可最为返回值

  3. 可赋值给变量

仅满足第一条,叫作二等值;3 条都不满足,叫三等值。 数字、字符串、布尔值等常用的值在很多编程语言中都是一等值。

纯函数式, 即仅支持函数式编程的语言里,是没有变量的,故第三条可理解为可赋值给常量,比如 Haskell

JS 中的函数是一等值。

具有的优点:

  • 代码容易理解

  • 很容易修改

  • 容易并行执行或者异步执行

  • 容易推理,就很容易测试

劣势:

  • 门槛比较高,写好函数式代码很难;

  • 数据复制频繁,消耗内存。

泛型编程

泛型为程语言提供了更高层级的抽象,即参数化类型。换句话说,就是把一个原本特定于某个类型的算法或类当中的类型信息抽象出来。泛型编程提供了更高的抽象层次,这意味着更强的表达能力。

很多面向对象的语言都支持泛型。

其他常用的编程范式

  • 事件驱动

程序的流程由诸如用户操作(鼠标单击、按键)、传感器输出或从其他程序或线程传递的消息等事件决定。通过有一个主循环在监听事件的触发,然后执行回调函数。

很多高级语言都支持。

  • 数据驱动

程序语句描述要匹配的数据和所需的处理,而不是定义要采取的一系列步骤。数据驱动编程类似于事件驱动编程,两者都被构造为模式匹配和结果处理,并且通常由主循环实现,尽管它们通常应用于不同的领域。

数据驱动和其他编程范式结合,可得到数据驱动的设计。react、vue、angular 三大前端框架,都主张数据驱动视图,是一个编程范式的体现。

  • 数据流编程 (data flow programming)

程序的操作是数据流动。这种编程范式具有函数式编程的一些特征。

命令式编程由分支结构、循环结构、顺序结构就能完成了,但是数据是静止的,比如面对对象编程中,对象之间的数据封装在内部的,不会轻易被外部改变。

相比之下,数据流编程强调数据的移动,让数据的输入输出连接程序操作,有点像数据驱动。程序不共享状态,天然支持并行。

数据流编程可细分,基于流编程响应式编程,响应式编程用的比较多。

响应式编程(reactive programming):使用数据流和变化驱动程序操作。

还需要了解更多。

RxJS 是 JS 的响应式编程库,在 angular 框架中被使用,门槛比较高。

学习 RxJS

参考

函数式编程(Functional programming)

编程范式

编程范式,程序员的编程世界观

数学中的函数

函数表达式:

F(X)=Y

一个输入 X (可以是一个数,可以是一个坐标,获取其他式子),经过变换 F 或者映射 F,得到输出 Y。当 F 满足确定一个 X,就唯一有一个 Y 时,就叫变换 F 叫函数。

确定和唯一:

函数 F 依赖 X,只要确定 X,Y 就确定了。 F 仅依赖输入。

一个 X 只能对应一个 Y, 而一个 Y 允许对应多个 X. 这点和确定 X,就唯一确定 Y,不矛盾。同一个 X,多次按照 F 变换后,还是得到同一个结果。

只要确定 X,就能唯一确定 Y,如果编程语言的函数也能按照这个性质编写,再把这些函数组成程序,那么程序就非常容易预测,测试起来就非常简单了。

函数式编程正是这种思想指导下的编程方式。这种编程思想在计算机诞生之前,就已经存在了。计算一直被数学家、逻辑学家和哲学家研究、使用,而计算机的发明,让计算更加高效、准确和快速。

函数式中的函数

纯函数就是数学上的函数,是函数式编程的全部。

按照数学中的函数编写的函数:仅依赖输入或者参数就能完成自身逻辑的函数。函数的依赖仅有参数,这保证了相同的参数多次调用函数,返回值相同。这样函数叫纯函数,纯函数没有副作用。

函数 vs 方法

JS 中,函数是可被调用的变量,和其他变量一样,函数可作为参数和返回值。

方法,必须和一个特定的对象绑定,通过该对象调用的函数。

副作用

纯函数内部不会改变系统状态,当函数能改变系统状态时,就说这个函数具有副作用。可理解成希望执行某个操作,也顺带执行其他操作,这个其他操作就是副作用。

相同的参数,第一次调用和第二次的结果不同,往往是因为函数产生副作用。

slice 是纯的,splice 是不纯的。

const numbers = [1, 2, 3, 4, 5, 6]
// 纯的
xs.slice(0, 3) // [1,2,3]

xs.slice(0, 3) // [1,2,3]

xs.slice(0, 3) // [1,2,3]

numbers.splice(0, 3) // [1,2,3]
// numbers 被修改成 [4,5,6] 即产生了副作用
numbers.splice(0, 3) // [4,5,6]
// numbers 被修改成 [] 即产生了副作用
numbers.splice(0, 3) // []
// numbers 被修改成 [] 即产生了副作用

函数式编程中,希望消除副作用。

再来一个例子:

const minNum = 21
// 不纯的,因为它依赖外部变量,相同的输入,会因为外部变量得到不同的输出
const checkAge1 = age => age >= minNum
// 纯的
const checkAge2 = age => {
  const minimum = 21
  return age >= minimum
}

不纯的函数返回值取决于系统状态(system state);这一点令人沮丧,因为它引入了外部的环境,从而增加了认知负荷(cognitive load)。

这种依赖状态是影响系统复杂度的罪魁祸首。输入值之外的因素能够影响返回值,不仅让它变得不纯,而且导致每次我们思考整个软件的时候都痛苦不堪。

试想,当你看到 checkAge1 时,给它一个参数,你要知道外部变量才能预测它的结果,你可能又在项目里到处寻找这是外部依赖是什么,要是需要修改,你可能要考虑想改两个地方。

而 checkAge2 就不存在这个问题。

可以让 minimum 成为一个不可变(immutable)对象:

const immutableState = Object.freeze({
  minimum: 21
})

我认为不可变对象还是不如纯函数,因为认知负荷还是没有减少。

这让我想起,动车供电系统的设计:

图中 v 字形的装置叫受电弓,随车移动,可折叠取回车内,其上方和电线接触的那个横条和高压电线接触。

电线和受电弓上横条长期高速摩擦,肯定会有损耗,极端的情况就是电线被摩断了,那么就可能导致列车减速甚至停止运行,但是我们从来没听说过这样的事故,这是为何?

不得不提及受电弓的巧妙设计 ---- 上方和电线接触的横条是石墨,导电好,但是硬度不及电线,因此石墨会先被磨损坏掉。

一辆列车上有很多受电弓,一个坏了,不会给列车造成很多的影响,这和软件里的容灾设计异曲同工。

受电弓坏了,列车到站修复,马上上路,要是电线那点断了,要到深山老林去检测那点坏了,理论上电线上每个点都可能坏,动辄几千公里的电线,高压还有列车行驶,无疑有很大的安全隐患。(实际上检查电线时不会一公里一公里地检查,但是了解极端情况就好。)

而让受电弓先磨损,就很方便停车时修理了,工程设计中这叫集中伤害,为了能集中检测集中修理

而软件设计中,扩展功能时,修改的地方越少越好,低耦合的体现,需要修改的代码越集中,越好,高内聚的体现,高内聚低耦合集中伤害集中修理 有异曲同工之妙。

纯函数是依赖最小的方式,也是能集中伤害的方式。当需要扩展程序的功能时,我们只需要添加新的函数。当需要修复 bug 时,由于纯函数只依赖参数,我们只需修改函数,而不用考虑其他外部依赖。

再理解副作用

::: tip 副作用
参与计算的代码单元(变量、函数调用、包块、语句等)对整个表单式的影响,仅限于代码单元的返回值,就认为该代码单元没有副作用(side effect),反之,就认为它有副作用。
:::

let [a, b, c] = [1, 2, 3]
const d = (a + b) * c++
const e = c * c

::: warning 分析
c++, 参与计算 d ,然后自增 1,会影响后面 c 参与的计算;

a+b, 这个代码单元不会影响后续的计算,即把 a+b 的结果使用一个数值代替,不会有任何差别。
:::

常见的副作用:

  1. 修改文件系统;
  2. 往数据库插入数据;
  3. 发生 http 请求;
  4. 可变数据
  5. 获取用户输入;
  6. DOM 查询;
  7. 修改系统状态;
  8. 赋值操作

等等。

只要是跟函数外部环境发生的交互就都是副作用。

无副作用,还可以编程吗?函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。

不是禁止副作用,而是减少让副作用,让副作用可控,副作用难以预测,无法定位 bug。

副作用让函数不纯,纯函数必须要能够根据相同的输入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了。

纯函数的好处

数学中的函数,相同的输入,不管计算多少遍,都得到相同结果。比如sin(x)sin(π/6),不管你求多少次,都是0.5

相同的参数,函数执行执行多次,返回值都相同,这种特性叫引用透明(referential transparency),引用透明的函数的依赖仅有参数。

关于引用透明
是分析哲学的一个概念,该学科研究自然语言的语义和含义。
如果一个句子中的实体(事物)被另一个相同的实体代替,不改变原来的含义,这个句子就是引用透明的。

一个例子:

[北京]比老鼠大 是真;

[中国的首都]比老鼠大 是真;

[(116.46E,39.92N)的城市]比老鼠大 是真;

北京替换成中国的首都北京的经纬度,都不改变语义。这些命题的真正含义不会因为我们选择不同的引用或指称名称而受到影响。我们就可以认为场景北京比老鼠大是引用透明的。

与引用透明相反,蒯因认为存在引用不透明( REFERENTIALLY OPAQUE),名称的改变对于整个语句意思非常重要。

比如:北京有2个字符是真。那么:中国的首都有 2 个字符 是假。

这样场景 xx有2个字符 的含义因为名称指称(name/reference)不同而不同。

::: tip 引用透明
个人认为,引用透明翻译得不好,指代等价或者替换等价更加容易理解,也更加契合数学的等价。

数学中的函数是引用透明的。
:::

sin(π/6)0.5是等价的,sin(π/6) 参与计算的式子中,可直接使用 0.5 替换,这叫替换模型(substitution model),如果一个函数是引用透明的,就可直接使用的返回值替换函数执行过程。这使得并发、缓存、预测等轻而易举。

::: tip 纯函数的另一种定义
满足两个条件:
① 无副作用;
② 返回值仅依赖于参数的,即相同输入,必须得到相同的输出。
:::
数学函数相同的输入必定得到相同的输出,这很自然,但是程序中的函数的输出可能依赖隐藏的状态,比如当前时间。

纯函数有诸多好处:

  1. 可缓存

既然纯函数对于相同的输入,都得到相同的返回,那么参数第一次执行的结果,都可缓存起来,下次在调遇到相同的参数,从缓存中获取结果。

function factorial(n) {
  if (n === 0) {
    return 1
  } else {
    return n * factorial(n - 1)
  }
}

const memorized = fn => {
  const factorialObj = {}
  return n => (factorialObj[n] ? factorial[n] : (factorialObj[n] = fn(n)))
}

const fastFactorial = memorized(factorial)
console.time('cache')
console.log(fastFactorial(40))
console.timeEnd('cache') // 8.113ms
console.log('**************')
console.time('no cache')
console.log(factorial(40))
console.timeEnd('no cache') // 0.061ms

没缓存的函数更快速,不符合预期,这是为何?

  1. 可移植/自文档化 (Portable / Self-Documenting)

纯函数是完全自给自足的,它仅依赖参数。移植方便。

因为函数式编程让开发者只关注做什么,没有如何做的细枝末节,具由很强的自文档化。

const numbers = [1, 2, 3]
// 命令式: 需要维护下标,修改麻烦,容易出错,移植困难,理解困难
for (let i = 0, len = numbers.length; i < len; i++) {
  console.log(number[i])
}
// 函数式: 一个函数搞定,只需关注参数和 console.log() 修改容易,移植容易,理解容易
numbers.forEach(item => {
  console.log(item)
})

再来一个例子:

// 不纯的
const signUp = function(attrs) {
  const user = saveUser(attrs);
  welcomeUser(user);
};

const saveUser = function(attrs) {
    const user = Db.save(attrs);
    // ...
};

const welcomeUser = function(user) {
    Email(user, ...);
    // ...
};

// 纯的

const signUp = function(Db, Email, attrs) {
  return function() {
    const user = saveUser(Db, attrs);
    welcomeUser(Email, user);
  };
};

const saveUser = function(Db, attrs) {
    // ...
};

const welcomeUser = function(Email, user) {
    // ...
};

命令式编程中“典型”的方法和过程都深深地根植于它们所在的环境中,通过状态依赖和有效作用(available effects)达成;

纯函数与此相反,它与环境无关,只要我们愿意,可以在任何地方运行它,保证了纯函数可移植。

vue2 的 options API ,方法、状态都依赖组件的 this,使得组件之间共享逻辑非常困难,vue3 转到 composition API 这个问题就减轻很多,我们可以复用函数来实现共享逻辑。

Erlang 语言的作者 Joe Armstrong 说的这句话:“面向对象语言的问题是,它们永远都要随身携带那些隐式的环境。你只需要一个香蕉,但却得到一个拿着香蕉的大猩猩...以及整个丛林”。

自文档化,其实很大程度上取决于命名和编程语言的特性。

  1. 可测试性(Testable)

只需简单地给函数一个输入,然后断言输出就好了。

QuickCheck ---- 纯函数测试工具,JS 版本不受欢迎。

函数式编程提倡编写小的函数,一个函数只做一件事,复杂的任务通过函数的组合来完成,函数越小越容易测试。

  1. 合理性(Reasonable)

纯函数的执行结果,可使用它的返回只替换,和符合人类的直觉。

  1. 并行执行

这一点是决定性的,可 并行运行 任意纯函数。

因为纯函数根本不需要访问共享的依赖,比如内存、全局变量等,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。

并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。

如何编写纯函数

前面对纯函数的定义,都是结果导向的,无法指导程序员编写出纯函数。也就是说,判断一个函数是否为纯函数,还需要可操作的方式。

满足这两个条件,一定为纯函数:

::: tip 纯函数
① 不修改外部变量;
② 不读取外部变量,仅依赖参数。
:::

限制第一条,是为了不产生副作用。这个条件过于严格,其实,只要保证满足第二条约束,即使被修改的变量不被自己和其他代码读取,修改的变量为只写变量,即使函数修改了外部变量,副作用就不对程序产生影响。

限制第二条,是为了保护子不受到外部变量影响,仅依赖参数才能保证相同的输入得到相同的输出。

依据以上分析,JS 编写纯函数时,需要遵循的规则:

  1. 不改变参数,参数为对象和数组时,是可变的

  2. 必须有返回值。

没返回值是副作用驱动的特征。

函数内部的副作用

JS 不是专门的函数式编程语言,完全消除副作用是困难的,也是不必要的。换句话说,要限制副作用仅限于函数内部。

JS 的很多操作和内置函数都是具有副作用的。

不改变参数,复制参数,会有性能损耗,尤其是频繁调用的函数,所以以下情况,允许改变参数:

  1. 根据参数返回一个稍微修改的对象或者数组。

  2. 递归调用。

不变性

函数式编程强调数据不变性(immutability)。数据不能改变,只能被替换,就具有不变形。js 中基本数据类型都是不变的,数组、对象和函数是可变的。

可借助一些开源库来实现数据不可变,比如immutable

使用复制、冻结等实现数组的不变性,比较繁琐,计算机科学家想出了无需整体复制即可实现不可变的数据结构 ---- 共享结构体。

其他

导致代码难以理解的原因:

命名混乱的原因:同一概念使用不同的命名

通用代码的问题:在命名时容易把名字绑定到具体的数据上

// 只针对当前的博客
const validArticles = articles =>
  articles.filter(article => article !== null && article !== undefined),

// better
const compact = xs => xs.filter(x => x !== null && x !== undefined);

// best
const filterResult = array = array.filter(item=>![null,void 0].includes(item))

使用函数时必须小心this

const fs = require('fs')

// 太可怕了
fs.readFile('freaky_friday.txt', Db.save)

// 好一点点
fs.readFile('freaky_friday.txt', Db.save.bind(Db))

函数计算得到函数

函数式编程中把函数当成值,那么能根据函数计算得到新函数吗?当然能。

通过函数计算得到新的函数,我们便可以写更小的函数,便于测试和维护。

高阶函数、柯里化、部分应用、组合和管道等,都是由函数计算得到函数的方法。

高阶函数

接收函数作为参数或者返回函数的函数叫高阶函数。

js 里又不少高阶函数:map、find、some、forEach、 setTimeout 等。

const hof = (this,fn) => fn.bind(this) // hof 是一个高阶函数

高级函数是函数的更高级级的函数抽象和封装,可以代码更容易理解和复用。

有哪些使用场景?

React 受控组件收集表单数据。

如果不使用高阶函数 每个表单项都要写一个事件处理函数,难以阅读和难以维护。

import React, { Component } from 'react'
class MyForm extends Component {
  state = {}
  render() {
    return (
      <div>
        <h2>受控组件</h2>
        <form>
          <label htmlFor='name'>
            用户名:
            <input type='text' id='name' name='myName' onChange={this.onChange('myName')} />
          </label>
          <label htmlFor='password'>
            年纪:
            <input type='number' id='password' name='age' onChange={this.onChange('age')} />
          </label>
        </form>
      </div>
    )
  }
  // 高阶函数
  onChange = key => event => this.setState({ [key]: event.target.value })
}

export default MyForm

或者这样写:

class MyForm extends Component {
  state = {}
  render() {
    return (
      <div>
        <h2>受控组件</h2>
        <form>
          <label htmlFor='name'>
            用户名:
            <input
              type='text'
              id='name'
              name='myName'
              onChange={event => {
                this.onChange('myName', event)
              }}
            />
          </label>
        </form>
      </div>
    )
  }
  // 高阶函数
  onChange = (key, event) => this.setState({ [key]: event.target.value })
}

export default MyForm

显然,高阶函数更加优雅。

部分应用

函数的默认参数

ES6 支持默认参数

const rangeNumber = (end, start = 0, step = 1) => {
  if (typeof end !== 'number' || typeof start !== 'number' || typeof step !== 'number') return []
  if (end < start) return []
  const list = []
  while (start <= end) {
    list.push(start)
    start += step
  }
  return list
}
console.log(rangeNumber(10)) // [0,1,2,3,4,5,6,7,8,9,10]
console.log(rangeNumber(10, 3)) // [3,4,5,6,7,8,9,10]
console.log(rangeNumber(10, void 0, 3)) // [0,3,6,9]
console.log(rangeNumber(10, 2, 2)) // [2,4,6,8]

默认参数使得传递部分参数即可调用函数,给使用函数带来方便。

默认参数有哪些问题?

  1. 默认参数只能放在参数列表的后面;
  2. 不能动态设置默认参数值。

通过闭包改善以上的问题

const rangeNumber = (start = 0, step = 1) => {
  return end => {
    if (typeof end !== 'number' || typeof start !== 'number' || typeof step !== 'number') return []
    if (end < start) return []
    const list = []
    // NOTE 不要直接修改 start 否则闭包会记住上次调用的值
    let begin = start
    while (begin <= end) {
      list.push(begin)
      begin += step
    }
    return list
  }
}
const numberFrom3to = rangeNumber(3)
console.log(numberFrom3to(10))
console.log(numberFrom3to(20))
const numberFrom4To = rangeNumber(4, 2)
console.log(numberFrom4To(10))

::: tip 温习闭包
闭包:内层函数能记住外层函数作用域的特性。
闭包可访问三个作用域的变量:

  1. 全局作用域;
  2. 外部函数作用域,即外层函数的参数和局部变量;
  3. 自身声明的作用域,即内层函数的参数和局部变量。

闭包在外层函数执行时候创建,访问外层函数的作用域是使用闭包的目的。

部分应用、柯里化等技术,都依赖闭包。

函数式编程语言都有闭包的特性。
:::

const outer = (count = 0) => {
  const scopedStr = 'vue'
  return function inner() {
    console.log(scopedStr)
    return ++count
  }
}
const count = outer()
const scopedStr = 'react' // 具有迷惑性
console.log(count()) // vue 1
console.log(count()) // vue 2
console.log(count()) // vue 3

outer 调用时创建了一个闭包,闭包能记住【外层函数】的作用域,在 count 函数调用时,还是能访问它的参数和局部变量。

const a = 100
function print(fn) {
  const a = 200
  fn()
}
function fn() {
  console.log(a)
}
print(fn) // 100

闭包、作用域都是在函数定义时确定的。

使用闭包对默认参数的限制有所改善,还是不够灵活:返回的函数只支持一个参数。

要是能有一个函数,将上面的rangeNumber函数自动计算得到一个支持传递部分参数的新函数,在使用时,支持把所有参数分成任意两部分传递,就更加灵活了,部分应用就是这种技术。

:::tip 部分应用
将函数调用分成两个阶段,第一个阶段传递一部分参数,返回一个函数,第二阶段调用新的函数,传递剩余参数。
这种函数使用方式叫部分应用,可以有效拆分参数,给函数使用带来方便。
:::

改写上面的函数:

const rangeNumber = (step, start, end) => {
  if (typeof end !== 'number' || typeof start !== 'number' || typeof step !== 'number') return []
  if (end < start) return []
  const list = []
  while (start <= end) {
    list.push(start)
    start += step
  }
  return list
}
// 部分应用函数
const partial = (fn, ...partialArgs) => {
  return (...otherParams) => {
    return fn(...partialArgs.concat(otherParams))
  }
}
const numberFrom3to = partial(rangeNumber, 1, 3) // 生成步长为 1,开始为 3 的函数
numberFrom3to(10) // [3,4,5,6,7,8,9,10]
const numberStep2 = partial(rangeNumber, 2) // 生成步长为 2 的函数
const numberStep2From2 = partial(numberStep2, 2) // 再次部分应用
numberStep2(2, 14) // [2,4,6,8,10,12,14]
numberStep2From2(14) // [2,4,6,8,10,12,14]
const numberFrom3to10 = partial(rangeNumber, 1, 3, 10)
numberFrom3to10() // 同 numberFrom3to(10)

使用部分应用后,函数的参数更加灵活了。

上面的部分应用函数,第一阶段传递的参数最后调用时,是在左边的,它无法处理最终调用在右边的参数。

const delay100Ms = partial(setTimeout, 100)
// 等同于 setTimeout(100,() => {
//   console.log(100)
// })
// 无法执行
delay100Ms(() => {
  console.log(100)
})

编写一个处理右边参数的部分应用

const partialRight = (fn, ...partialArgs) => {
  return (...otherParams) => {
    return fn(...otherParams.concat(partialArgs))
  }
}
const delay100Ms = partialRight(setTimeout, 100)
delay100Ms(() => {
  console.log(100)
})

向右边的部分应用,和函数的默认参数顺序一致,不用改写函数。

ES6 的默认参数是参数列表的末尾开始的。

Number.parseInt(numberStr, radix) radix 指定 numberStr 的进制,默认 10. 使用部分应用

const parseDecimal = partialRight(Number.parseInt, 10)
const parseBinary = partialRight(Number.parseInt, 2)
const parseHex = partialRight(Number.parseInt, 16)
parseDecimal('10') // 10
parseBinary('10') // 2
parseHex('1110A') // 69898

向右 vs 向左

向右的部分应用不用改写原函数,能充分利用函数的默认参数,向左的则不能,默认情况下都是向右的才是我们希望的,向左的我们另外定义函数。

const partial = (fn, ...partialArgs) => {
  return (...otherParams) => {
    return fn(...otherParams.concat(partialArgs))
  }
}
const prettyPrintJson = partial(JSON.stringify, null, 2)
prettyPrintJson({ name: 'jack' })

const partialLeft = (fn, ...partialArgs) => {
  return (...otherParams) => {
    return fn(...partialArgs.concat(otherParams))
  }
}

柯里化

对函数多次部分应用,得到的新函数的参数越来越少。

一次性对函数的所有参数部分应用,也要再调用一次函数才能得到结果。

可以不必再调用一次函数吗?柯里化能避免再调用一次函数。

::: tip 柯里化
将多参函数变成一系列单参函数的手段。
:::

比如:

function sum(a, b, c) {
  return a + b + c
}
function curriedSum(a) {
  return function (b) {
    return function (c) {
      return a + b + c
    }
  }
}
sum(1, 2, 3)
curriedSum(1)(2)(3)

希望编写一个函数,自动把 sum 变成单参函数。

const curryFn = fn => a => b => c => fn(a, b, c)

上面柯里化一个三个参数的函数,任意数量的参数如何柯里化呢?

通用的柯里化函数

const curry = fn => {
  if (typeof fn !== 'function') {
    throw new Error('no function provided!')
  }
  // NOTE 为何不使用箭头函数?因为需要递归调用
  return function curriedFn(...args) {
    // 实参数数量小于形参数量
    // NOTE fn.length 必需参数的数量
    if (args.length < fn.length) {
      return function () {
        // 实际参数一直在合并,一定会等到两者相等的时候
        return curriedFn(...args.concat(Array.from(arguments)))
      }
    }
    return fn(...args)
  }
}

关键代码:

if (args.length < fn.length) {
  return function () {
    // 实际参数一直在合并,一定会等到两者相等的时候
    return curriedFn(...args.concat(Array.from(arguments)))
  }
}

实参数量小于形参数量,就递归调用

为何不使用箭头函数呢?

因为在函数内部需要使用arguments获取实参。

::: tip 箭头函数的特性

  1. 没有 this、super、arguments、new.target: 由所在的、最靠近的非箭头函数来决定;
  2. 不能当成构造函数:没有[[Construct]]方法;
  3. 没有 prototype 属性,因为 2;
  4. 没有 arguments 对象:既然箭头函数没有 arguments 绑定,你必须依赖于具名参数或剩余参数来访问函数的参数;
  5. 不允许重复的具名参数:箭头函数不允许拥有重复的具名参数,无论是否在严格模式下;而相对来说,传统函数只有在严格模式下才禁止这种重复;
  6. 不能作为生成器:因为不能使用yield操作符号;
    :::

Array.from(arguments) 将类类数组转为数组。

::: tip 常见的转 arguments 为数组的方法
按照性能排序:

  1. 剩余参数:const toArray=(...params)=>params
  2. for 循环:arguments[i];
  3. [].slice.call(arguments)
  4. Array.from(arguments)
    :::

将上面的改成箭头函数的写法:

const curry = fn => {
  if (typeof fn !== 'function') {
    throw new Error('no function provided!')
  }
  // 因为要递归,使用箭头函数会不方便
  return function curriedFn(...args) {
    // 递归出口放在前面,更加好理解
    if (args.length === fn.length) {
      return fn(...args)
    }
    // 箭头函数没有 arguments 需要显示给出参数
    return (...params) => {
      return curriedFn(...args.concat(params))
    }
  }
}

柯里化如何改善代码

有一个日志函数:

const loggerHelper = (mode, initialMessage, errorMessage, lineNO) => {
  switch (mode) {
    case 'DEBUG':
      console.debug(initialMessage, `${errorMessage} at line ${lineNO}`)
      break
    case 'ERROR':
      console.error(initialMessage, `${errorMessage} at line ${lineNO}`)
      break
    case 'WARN':
      console.warn(initialMessage, `${errorMessage} at line ${lineNO}`)
      break
    default:
      throw new Error('Wrong mode!')
  }
}

// 习惯样调用
loggerHelper('ERROR', 'Error at index.js', '报错了', 10)
loggerHelper('ERROR', 'Error at main.js', '报错了', 13)

还能改善吗?

以上调用在传递很多重复参数,可以使用柯里化改善。

::: tip 柯里化帮助去除重复参数和样板代码

const errorLog = curry(loggerHelper)('ERROR')
errorLog('Error at index.js', '报错了', 10)
errorLog('Error at index.js')('报错了', 10)
errorLog('Error at index.js')('报错了')(13)
// 柯里化时传递所有参数,得到最后的调用结果
curry(loggerHelper)('ERROR', 'Error at main.js', '报错了', 130)
// 部分应用所有参数,需要再调用一次函数才能得到结果
const errorLog2 = partialLeft(loggerHelper, 'ERROR', 'Error at index.js', '报错了', 130)
errorLog2()

:::

再来一个例子:

const fruits = ['apple', 'mango', 'orange']
const newFruits = fruits.filter(function (name) {
  return name.startsWith('a') // NOTE 希望这里的参数是动态的,即调用 filter 时才传入参数
})

改进 1:

function startsWith(text, name) {
  return name.startsWith(text)
}
const newFruits = fruits.filter(fruit => startsWith('a', fruit))

达到目的了,但是我们不得不在 filter 的回调函数中再次调用 startsWith,希望 filter(startsWith('a')), 显然这个更加可读,如何办?

改进 2:

把 startsWith 柯里化,参数顺序很重要,外层函数的参数一般是固定的。

function startsWith(text) {
  return function (name) {
    return name.startsWith(text)
  }
}
const newFruits = fruits.filter(startsWith('a'))

部分应用 vs 柯里化

目的相同

通过部分应用和柯里化,能把多参函数变成参数更小、行为更加具体的函数,方便使用。

参数更少:调用时更加方便,只需要关注变化的参数。

行为更加具体:反映到函数名称上,让函数更加自文档化。

使用方式不同

柯里化时更加彻底的部分应用,使用部分应用,还需要再调用一次函数。

fn.length vs arguments.length

函数有一个length属性,表示必需的参数数量,arguments.length 表示实际参数数量,当有默认参数和剩余参数时,两者可能不等。

我们的柯里化函数用到了fn.length,因此无法处理默认值。

curry 函数对参数的处理是向左的,即先传递的而参数,放在 fn 的前面。还可向右,不常用,不写了。

为了能使用函数的默认值,可以在提供一个参数。

const curry = (fn, argsSize = fn.length) => {
  if (typeof fn !== 'function') {
    throw new Error('no function provided!')
  }
  return function curriedFn(...args) {
    if (args.length === argsSize) {
      return fn(...args)
    }
    return (...params) => {
      return curriedFn(...args.concat(params))
    }
  }
}

const rangeNumber = (end, start = 1, step = 1) => {
  if (typeof end !== 'number' || typeof start !== 'number' || typeof step !== 'number') return []
  if (end < start) return []
  const list = []
  while (start <= end) {
    list.push(start)
    start += step
  }
  return list
}

console.log(curry(rangeNumber)(10)) // start = 1 step = 1
console.log(curry(rangeNumber, 2)(10)(5)) // step = 1
console.log(curry(rangeNumber, 3)(10, 5)(2)) // start = 5 step 2

curry(
  setTimeout,
  2 // 不传递,会是 5
  //  TODO 为何是 5 ? 没查到相关资料
)(() => {
  console.log('1000毫秒')
})(1000)

柯里化性能差?

::: warning 使用频繁的函数不要柯里化
由于柯里化层层嵌套,当参数很多,嵌套会很深,频繁执行的函数,柯里化后比不柯里化的函数慢。
:::

组合

Unix 或者 Linux 命令,想要计算单词 word 在给定文本中出现的次数:

cat test.txt | grep 'word' | wc

一个复杂的任务,通过管道组合三个简单的命令就完成了。

::: tip unix 理念

  1. 每个程序只做好一件事。为了完成一个新任务,重新构建要比在复杂的旧程序中添加新功能困难。
  2. 每个程序的输出应该是另一个程序的输入。

:::

理念 1:应该写职责单一的小函数,然后组合它们来处理复杂任务。

理念 2:小函数都需要输入,然后返回数据。

函数式编程中主张写小函数,复杂任务的处理通过组合小函数来完成。
因为函数越小,越容易复用和维护。

const compose = (fnA, fnB) => c => fnA(fnB(c))

形如这样,函数fnB的输出作为fnA输入的函数,叫函数组合。

先调用 fnB,再调用 fnA。也可以先调用 fnA ,再调用 fnB。

例子,给一个字符串数字进行四舍五入:

const num = '4.56'
// 常规写法
const data = Number.parseFloat(num)
const round = Math.round(data)
// 或者
const round2 = Math.round(Number.parseFloat(num))
// 组合函数的写法
const number = compose(Math.round, Number.parseFloat)
const result = number(num)

再看一个例子:统计hello function programming中单词个数

const splitSentence = str => str.split(' ')
const count = array => array.length
const str = `hello function programming`
const wordCount = compose(count, splitSentence)(str)

以上例子,都是一个参数的函数组合,而且例子太简单,多个参数还能组合吗?

多个参数的组合

通过柯里化和部分应用,可以把多参函数转为一参函数,然后组合他们。

const books = [
  { name: 'vue', price: 45.5, author: 'Even You', rate: 9.4 },
  { name: 'react', price: 50.5, author: 'facebook', rate: 9.7 },
  { name: 'angular', price: 60.5, author: 'google', rate: 3.7 },
  { name: 'jquery', price: 40.5, author: 'apache', rate: 4.7 },
]
const filterGoodBooks = book => book.rate > 5

const projectNameAndAuthor = book => ({ name: book.name, author: book.author })
const projectName = book => ({ name: book.name })

const queryGoodBooks = partial(filter, filterGoodBooks)
const mapTitleAndAuthor = partial(map, projectNameAndAuthor)

const nameAndAuthorForGoodBooks = compose(mapTitleAndAuthor, queryGoodBooks)
nameAndAuthorForGoodBooks(books)

const nameForGoodBooks = compose(partial(map, projectName), queryGoodBooks)
nameForGoodBooks(books)

任意函数的组合

目前compose只能组合两个函数,想要组合两个以上函数如何写?

使用 reduce 进行归约

const compose = (...fns) => {
  // 存在一个不是函数 立即返回
  if (fns.some(fn => typeof fn !== 'function')) return
  return value => fns.reverse().reduce((acc, fn) => fn(acc), value)
}

上面统计单词的例子,还向指导单词数是奇数还是偶数。

const oddOrEven = count => (count % 2 === 0 ? 'even' : 'odd')
const oddOrEvenWords = compose(oddOrEven, count, splitSentence)
const str = `hello function programming react`
console.log(oddOrEvenWords(str)) // even

为何要反转参数?

fns.reverse(),反转参数,希望从最后一个函数开始调用。

compose(oddOrEven, count, splitSentence) splitSentence 最开始调用。

管道

希望能像 linux 命令那样使用|来连接多个命令,操作结果依次传递,即希望函数从左到右执行。

const pipe = (...fns) => {
  // 存在一个不是函数 立即返回
  if (fns.some(fn => typeof fn !== 'function')) return
  return value => fns.reduce((acc, fn) => fn(acc), value)
}

使用上面的例子测试:

const oddOrEvenWords = pipe(splitSentence, count, oddOrEven)
const str = `hello function programming react`
console.log(oddOrEvenWords(str)) // even

组合 vs 管道

思路一样,数据流向不同。感觉管道更符合直觉。

管道和组件满足结合律

compose(compose(fnA, fnB), fnC) === compose(fnA, compose(fnB, fnC))
pipe(pipe(fnA, fnB), fnC) === pipe(fnA, pipe(fnB, fnC))

再添加一个返回 true 或者 false 的函数

const isOdd = str => str === 'odd'
const isOddWords = pipe(splitSentence, pipe(count, oddOrEven), isOdd)
const str = `hello function programming react`
console.log(isOddWords(str)) // false

如何调试组合函数中的错误

组合和管道都是一些函数连续执行,如何知道哪个函数执行错误呢?

可以在中间加入日志输出函数。

const identity = value => {
  console.log(value)
  return value
}

因为每个函数的处理的数据都不同,根根据数据输出,很容易推断出问题。

比如,想知道splitSentence的处理结果。

pipe(splitSentence, identity, pipe(count, oddOrEven), isOdd)

参考

蒯因与引用透明

函数式编程

函数式编程的练习

What is Functional Programming? Tutorial with Example

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant