JavaScript 进阶 范式的转变 函数式 引用透明

函数式编程是一种编程范式-一种构建计算机程序的结构和元素的方式-将计算视为对数学函数的评估,并且避免了状态和可变数据的更改

来自Wiki

它既是从特定角度去看待问题的思维,又是实现思维的配套工具,现代编程语言常常是多范式,支持多样化的编程范式,如面向对象、元编程、函数式、过程式等等。

  1. 引用透明,值或状态的不可变性,无副作用
  2. 变换,记忆、映射、筛选、化约/折叠

范式的转变

编程范型、编程范式或程序设计法(英语:Programming paradigm),(范即模范、典范之意,范式即模式、方法),是一类典型的编程风格,是指从事软件工程的一类典型的风格(可以对照方法学)。如:函数式编程、程序编程、面向对象编程、指令式编程等等为不同的编程范型。

如熟悉的面向对象,面向过程什么的这就是范式,当然范也不止局限与程序上我们所熟知的面向对象,面向过程这类似编程思想,更多的应该是对程序执行的看法,比如面向对象思想中程序是一系列相互作用的对象,而在函数式思想中一个程序会被看作是一个无状态的函数计算的序列

来自百度百科

下面以一个字符串数组为例子,取出数组中字符串长度大于3,并将每个元素首字母大写,并以逗号分隔为字符串

const capitalize = str => str.replace(str[0], str[0].toUpperCase())
let names = ['jan', 'Tyrande', 'illidan', 'maiev', 'Dk']

命令式:

let result = []
for(let i = 0, len = names.length; i < len; ++i){
  if( names[i].length > 3 ){
    result.push(capitalize(names[i]))
  }
}
result = result.reduce( (x, y) => x + ',' + y )
console.log(result)
// => Tyrande,Illidan,Maiev

函数式:

console.log( names.filter(s => s.length > 3)
  .map(capitalize)
  .reduce( (x, y) => x + ',' + y )
)
// join(',')
// => Tyrande,Illidan,Maiev

在这样的问题上我个人更喜欢函数式的风格

引用透明 纯函数(Pure functions)

如果给定相同的参数,它将返回相同的结果,并且不会引起任何明显的副作用,这个副作用就是不会修改全局对象或通过引用传递参数的变量,也就是每一个函数,每一个功能都是独立的,不会影响到程序其它任何一处地方

首先借作者TK的一个例子:

let PI = 3.14
const calculateArea = radius => radius * radius * PI
console.log(calculateArea(10)) //=> 314

这个方法可以接收一个半径作为参数,返回为这个半径的圆的面积,但是如果当变量 PI 的值被改变,那们就违背了,给定相同的参数相同的结果的这一定义,因为一个纯函数是不会修改全局对象或通过引用传递参数的变量,下面是另一个例子,如何将上面方法变成一个纯函数:

let PI = 3.14
const calculateArea = (radius, pi) => radius * radius * pi
console.log(calculateArea(10, PI)) //=> 314

即使将 PI 值改变和 radius 改变,方法 aclculateArea 返回值与变量PI没有任何关系,有关的而是参数 raduis 和 pi

也正是因为纯函数这种结构在函数式中就显得十分重要,函数式的引用透明和变换都不能离开纯,如果一个函数不纯,比如这个时候将该函数“记忆”起来,函数的内部函数的外部状态都可以被改变,就不能作为记忆来使用,记忆后是不能改变任何状态的,应该是无副作用,显然纯函数显得十分重要

引用透明 函数式的数据结构

“异常”违背了大多数函数式语言所遵循的一些前提条件。函数式偏好没有副作用的纯函数,抛出异常的行为本身就是一种副作用,会导致程序进入异常流程。函数式语言以操作值为其根本,因此喜欢在返回值里表明错误并作出响应,这样就不需要打断程序的一般流程了

函数式的错误处理

出现异常时的返回值的限制是首先会遇阻碍,语言规定了方法只能返回一个值,当然可以把多个值放进一个对象里面或数组里一起返回

利用对象返回多个值:

const divide = (x, y) => y == 0 ?
  {'exception' : new Error('被零除')} : {'answer': x / y}

console.assert( 2 == divide(4, 2) )
// Assertion failed
console.assert( 2 == divide(4, 2).answer )
// 什么都没有
console.assert( 2 == divide(4, 0).answer )
// Assertion failed

当断言失败时并不知道发生了什么,也就是有什么错误类型,当成功也不知道是否成功,还需要看是否包含了某个键,所以用键值对返回多个值有以下三个缺点:

  1. 键没有安全性可能会随时改变,也就是不能捕获到类型方面的错误(将键变成指定类型可有限的处理)
  2. 方法的调用者无法直接得知执行是否成功,还需要用键进行对比
  3. 没有办法强制结果只包含一个键值对

Either 数据结构

Either 设计规定它要么有左值要么有右值,但绝对不会同时存在两者。这种数据结构也叫不相交联合体(disjoint union),错误处理是 Either 的主要用途之一

函数式习惯将异常置于左值,正常结果置于右值

但通常的对于此类数据结构,如果数据类型不能明确,键不能明确或者是状态的未知。比如 Scala 语言天生属于函数式 Either 也是得到了实现,再比如 Java 虽然天生不属于函数式但有泛型可以选择性的实现 Either

JavaScript 天生也是一个多范式语言,也因为 JavaScript 的鸭子类型众多在该方面上也显得有不足

下面则利用 ES10 的特性稍严谨的实现一个 Either 类的例子:

class Either {
  // ES10 # 私有
  #left = null
  #right = null

  // !!!此处应该为私有的构造器,访问外界的修改
  // ES10 还未实现私有的构造器
  // 这里模拟一次私有的构造器
  constructor(left, right){
    this.#left = left
    this.#right = right
  }

  getLeft(){ return this.#left }
  getRight(){ return this.#right }
  isLeft(){ return !!this.#left }
  isRight(){ return !!this.#right }
  static setLeft(left){
    return new Either(left, null)
  }
  static setRight(right){
    return new Either(null, right)
  }

}

const divide = (x, y) => y == 0 ?
  Either.setLeft(new Error('除数不能为0')) :
    Either.setRight( x / y)
    
const result = divide(4, 2)
console.assert( 2 == result.getRight() )
// 什么都没有
console.assert( 200 == result.getRight() )
// Assertion failed

不再需要寻找键而判断执行是否成功,直接利用 either 类的特点(正常结果为右值),并且一旦内部方法设定好后其 left right 记录值不会在改变,也做到了“键”的唯一,无论异常还是正常结果都多了一次间接层,调用 get 方法,恰恰也是这间接层为实现缓求值提供了可靠的基础

借用 JavaScript 超集 TypeScript 可以弥补 JavaScript 明确类型的不足,或是借助第三方库Ramda.js这个函数式库

参考链接

  • Neal Ford 作者阐述的函数式
  • 图灵程序设计丛书 函数式思维 Neal Ford
  • JavaScript 函数式编程思想 潘俊
  • 冴羽的博客 JavaScript专题之函数柯里化

你可能感兴趣的:(js)