入门函数式(一)-Functional Programming

前言

当你点开这篇文章,说明你想要了解,或者你已经了解了什么是函数式编程。那你又是通过什么地方知道的函数式编程呢?也许是在课堂上听过这一编程范式?又或者是通过React了解到的这一块?

大家的理由可能各种各样,但是不可否认的是函数式对于今天前端的影响已经越来越深了。既然大家来到这篇文章,那我们就一起进入函数式的世界吧。

作者水平有限,文中疏漏在所难免,欢迎大家指正,也希望大家可以发表自己的想法和意见。

命令式 VS 声明式

假设有这么一个需求,查找出地图中所有的动物,按照编号写在图鉴上。

命令式代码如下

js 复制代码
// 地图数据
const map = [
  {
    type: 'animal',
    name: 'duck',
    index: 1
  },
  {
    type: 'rock'
  },
  {
    type: 'animal',
    name: 'monkey',
    index: 4
  },
  {
    type: 'rock'
  },
  {
    type: 'tree'
  },
  {
    type: 'animal',
    name: 'fish',
    index: 2
  },
  {
    type: 'animal',
    name: 'bird',
    index: 3
  }
]
const illustrated = []

// 筛选出类型是动物的选项
const animals = []
for (let key = 0; key < map.length; key++) {
  if (map[key].type === 'animal') {
    animals.push(map[key])
  }
}

// 给动物按照编号升序排序
for (let i = 0; i < animals.length - 1; i++) {
  for (let j = 0; j < animals.length - 1 - i; j++) {
    if (animals[j].index > animals[j + 1].index) {
      [animals[j], animals[j + 1]] = [animals[j + 1], animals[j]]
    }
  }
}

// 把动物映射到图鉴上
for (let animal = 0; animal < animals.length; animal++) {
  illustrated.push(`动物:${animals[animal].name} 编号:${animals[animal].index}`)
}

而声明式代码如下

js 复制代码
const filterAnimals = (map) => {
  // 过滤出类型是动物的选项
  return map.filter(option => option['type'] === 'animal')
}
const sortAnimals = (illustrated) => {
  // 给动物按照编号升序排序
  return illustrated.sort((a, b) => a.index - b.index)
}
const mapToIllustrated = (animals) => {
  // 把动物映射到图鉴上
  return animals.map(animal => `动物:${animal.name} 编号:${animal.index}`)
}
// 组合函数,后面我们会一起讨论
const pipe = (...args) => args.reduce((g, f) => (...rest) => f(g(...rest)))
const illustrated = pipe(
  filterAnimals,
  sortAnimals,
  mapToIllustrated
)
illustrated(map)

你会发现声明式的代码的简洁,可读性明显要高于命令式,声明式我们最后只关注pipe里那三个函数,只要你的函数名取得有语义化,那函数结果将一目了然,如果程序出现错误,声明式我们可以直接挨个定位到相关函数排查,而命令式我们将难以快速定位到错误。

那声明式和函数式有什么关系吗?函数式是声明式的子集,命令式是关注过程的,而声明式是关注结果的,函数式也是一样关注结果的,你只需要告诉我怎么做,而不用教我怎么做。

命令式就是关注过程,声明式就是关注结果。

函数式的三要素

想要了解函数式,那我们就不得不了解函数式的三要素纯函数第一公民不可变值

纯函数

什么是纯函数?

纯函数是指给定相同的参数一定返回相同的结果。

js 复制代码
let a = 1
let b = 2
const add1 = () => a + b
add1()

const add2 = (a, b) => a + b
add2(1, 2)

上面两个add函数的结果都是3,不过add1不是纯函数,add2是纯函数。add1函数是没有参数,内部依赖了外部的变量,外部的a和b有可能变成任何值。而add2函数接收两个参数a和b,调用的时候传入相同的参数一定会返回相同的值。

js 复制代码
const list = [1, 2, 3, 4, 5]

list.splice(1, 1)
// toSpliced()是在node v20.10.0,浏览器兼容性可以查看MDN
list.toSpliced(1, 1)

splice这个函数修改了原数组,除了执行函数本身的功能外,还对外部状态造成了影响,这就是函数的副作用,而toSpliced就是一个纯函数,它会返回一个新数组,而不会去修改原数组,传入相同的参数一定返回相同的结果。

从上面两个例子,我们能够看出来纯函数就是,除了入参和返回值,函数不会再依赖外部状态,也不会影响外部状态。由此,相同的输入一定是返回相同的输出。

怎么写纯函数?

根据上面的结论我们已经知道什么是纯函数,什么是副作用,我们看看下面的几段程序。

js 复制代码
const resize = (w, h) => {
  const box = document.querySelector('.box')
  box.style.width = w
  box.style.height = h
}

const getData = async (url) => {
  try {
    const { data } = await fetch(url)
    return data
  } catch (error) {
    console.error('请求错误', error)
  }
}

const fs = require('fs')
const readFile = (url) => {
  try {
    const data = fs.readFileSync(url, 'utf-8')
    return data
  } catch (error) {
    console.error('读取错误', error)
  }
}

上面的三段程序都不是纯函数,他们都对程序外部产生了影响,DOM操作、网络请求、文件读取。

写纯函数有3个要求

  1. 数据引用透明 函数的参数应该是明确安全的,像外部的网络请求和文件可能会被篡改
  2. 功能职责单一 一个函数只做它自身应该做的事
  3. 除了入参和返回值,不应该对外部产生影响

为什么要是纯函数?

我们发现,如果要写纯函数,不能有副作用,那我们的程序可能什么都干不了,既然纯函数这么多约束,为什么函数式一定要是纯函数呢?

纯函数有下面两大好处

  1. 函数引用透明,函数的返回值依赖函数的入参,而不是外部,保证程序的安全
  2. 函数职责单一,结构清晰,方便维护
  3. 易于测试,因为相同的结果一定返回相同结果
js 复制代码
const toSum = (a, b) => a + b

test('1+2等于3', () => {
  expect(toSum(1, 2)).toBe(3)
})

但是为了这些好处,就不能写副作用了吗?答案是否定的,程序一定会有副作用,后面我们会学习怎么把副作用降低到最小。

没有副作用的函数就是纯函数,副作用是指函数对外部世界的影响,由此,纯函数是指函数只完成自身的功能,不会对外部程序,也不会被外部程序所影响返回的结果,相同的输入一定返回相同的输出

一等公民

函数在JS中是一等公民,是指函数可以被当作一个变量,被赋值,被传入函数参数,被函数返回到外部。

js 复制代码
// 函数赋值给变量
const add = (a, b) => a + b
const copyAdd = add

// 函数被当作参数传入另一个函数内部
const map = (arr, cb) => arr.map((...args) => cb(...args))

// 函数被一个函数当作函数值返回
const memoize = (fn) => {
  const cache = {}
  return (...args) => {
    const key = JSON.stringify(args)
    !cache[key] && (cache[key] = fn(...args))
    return cache[key]
  }
}

接受一个函数作为参数,或者返回一个函数的函数也被叫做高阶函数,JS中的高阶函数有map、filter、sort、forEach、reduce...

那函数作为一等公民的好处是什么呢?好处就是,函数有最大的自由度,这也是函数式的基础,函数可以被存入数组和对象。

在一个语言中,函数可以被当作一个变量,被赋值,被传入函数参数,被函数返回到外部 ,就可以说函数在这门语言中是一等公民。接受一个函数作为参数,或者返回一个函数的函数也被叫做高阶函数

不可变值

函数式对原数据有着非常严格的要求,不允许函数对原数据有修改。因为原数据可变,对于结果我们是难以预测的。

对于不可变值,可以使用const,但是const只能保证原始值不会被修改,对于数组和对象,const也无能为力。或者使用Object.freeze()冻结对象。

js 复制代码
const obj = {
  name: 'zhang san',
  age: 18
}
Object.freeze(obj)
const arr = [1, 2, 3]
Object.freeze(arr)

对于原数据,在函数内部不会直接操作它,而是复制一个副本,来操作副本,对于复制一个对象,可以使用深度拷贝。

js 复制代码
const deepClone = (obj) => {
  if (Object.prototype.toString.call(obj) !== '[object Object]') {
    return obj
  }
  const newObj = {}
  for (const key in obj) {
    newObj[key] = deepClone(obj[key])
  }
  return newObj
}
const obj = {
  val: 1,
  left: {
    val: 2,
    left: {
      val: 4
    }
  },
  right: {
    val: 3
  }
}
console.log(obj === deepClone(obj)) // false

但是又出现了一个问题,函数内部操作的数据也许只有几个,但是我们却复制了全部的数据,这消耗了大量的内存。业界对此的解决方案是immutable.js,immutable修改数据会创建一块新的内存地址,把修改的数据指向新地址,而其他没有修改的数据,则指向原数据的地址。

js 复制代码
const { Map } = require('immutable')
const person1 = Map({
  name: '张三',
  age: 18
})
const person2 = person1.set('age', 19)
for(const [key, value] of person1.entries()) {
  console.log(key, value) // name 张三 age 18
}
for(const [key, value] of person2.entries()) {
  console.log(key, value) // name 张三 age 19
}
console.log(person1 === person2) // false

至此,我们已经了解了函数式的三要素。不过还是不能够写函数式程序,函数式的精髓是组合,在此之前,我们先来看看柯里化这一概念。

柯里化

什么是柯里化?

在讨论柯里化之前,我们先来看一看函数的元

js 复制代码
const add = (a, b, c) => a + b + c

这个add函数就是一个三元函数,它的元分别是a, b, c,那和柯里化有什么关系吗?柯里化就是把一个多元函数变成一个单元函数,一次只接收一个参数,上面的三元add函数变成单元add函数就是:

js 复制代码
const add = a => b => c => a + b + c
console.log(add(1)(2)(3))

设计一个可以把一个n元函数转成n个一元函数的柯里化函数:

js 复制代码
const curry = (fn) => {
  const args_L = fn.length
  const curried = (...args) => {
    return args.length < args_L
      ? (...rest) => curried(...args, ...rest)
      : fn(...args)
  }
  return curried
}
const add = (a, b, c) => a + b + c
const add1 = curry(add)
console.log(add1(1, 2, 3)) // 6
console.log(add1(1)(2, 3)) // 6
console.log(add1(1, 2)(3)) // 6
console.log(add1(1)(2)(3)) // 6

为什么要柯里化?

可是这么写函数的好处是什么呢?柯里化的好处是

  1. 不完全调用函数,可以让函数惰性求值
  2. 减少函数的参数,让关注集中到函数本身上面
  3. 为了满足函数式的精髓,组合。

组合

函数式的本质是函数,函数式的精髓是组合,那么函数式就是把一个个的函数组合起来的过程。

我们可以把每个函数都当作是一个黑盒,我们不必关注这个黑盒内部是怎么实现的,我们只需要关注数据是怎么在这一个个的黑盒里面流动的,那我们的程序就是靠这一个个的黑盒搭建而来。

而要把我们的函数组合在一起,就需要我们组合函数compose和pipe,compose是从右到左的执行函数,而pipe是从左到右的执行函数。

js 复制代码
const compose = (...args) => {
  return args.reduceRight((g, f) => {
    return (...rest) => {
      return f(g(...rest))
    }
  })
}

const pipe = (...args) => {
  return args.reduce((g, f) => {
    return (...rest) => {
      return f(g(...rest))
    }
  })
}

reduce累加器,就像一个加工厂,把你一个个函数依次拿进来执行,并把结果给下一个函数。

Point-Free

Point-Free是函数式的一种代码风格,是指没有可见参数的一种代码风格,这样做的好处是,我们可以把关注集中在函数本身,这和柯里化的好处是一样的,就像最开始介绍声明式那个时候的代码,pipe里面的函数都没有参数,只在它返回的函数调用时传入了一个参数。

我想你已经想到了怎么做了,就是结合我们的柯里化和组合,把个个函数减少到最小元(函数本身的代码规则,参数也不应该太多),最把这些函数组合成一个新函数,如果你愿意,你也可以把这个组合的函数再和其他函数再组合,你发现了吗?这个一个个的函数的组合,像不像我们搭建房子,一块一块的砌砖,是的,我们就是把一个个职责单一,最小副作用和最小元的纯函数组合起来,听起来是不是感觉很酷。

总结

函数式编程的优点

  1. 函数解耦度高
  2. 易测试
  3. 方便维护

函数式编程的缺点

  1. 由于不可变值,代码会存在很多副本,导致内存消耗变大
  2. 学习难度比面向对象要大,因为现在主流的还是面向对象

第二篇我们会讲一些函子和如何处理副作用

相关推荐
小华同学ai8 分钟前
vue-office:Star 4.2k,款支持多种Office文件预览的Vue组件库,一站式Office文件预览方案,真心不错
前端·javascript·vue.js·开源·github·office
问道飞鱼21 分钟前
【前端知识】强大的js动画组件anime.js
开发语言·前端·javascript·anime.js
k093323 分钟前
vue中proxy代理配置(测试一)
前端·javascript·vue.js
若川1 小时前
Taro 源码揭秘:10. Taro 到底是怎样转换成小程序文件的?
前端·javascript·react.js
IT女孩儿2 小时前
JavaScript--WebAPI查缺补漏(二)
开发语言·前端·javascript·html·ecmascript
@解忧杂货铺6 小时前
前端vue如何实现数字框中通过鼠标滚轮上下滚动增减数字
前端·javascript·vue.js
真的很上进11 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
噢,我明白了14 小时前
同源策略:为什么XMLHttpRequest不能跨域请求资源?
javascript·跨域
sanguine__15 小时前
APIs-day2
javascript·css·css3
关你西红柿子15 小时前
小程序app封装公用顶部筛选区uv-drop-down
前端·javascript·vue.js·小程序·uv