走近(javascript, 函数式)

走近(javascript, 函数式)

什么是函数式

目前主流的命令式编程方式当中,将程序抽象成数据和过程的集合。在这里,“名词”是第一词汇,我们将程序视为一系列自上而下的命令,去不断修改其中的数据,我们更专注于描述不同数据结构之间的关系。其中,我们把一类相关的数据和命令封装在一起,形成了类和对象,形成了面向对象的编程方式。

函数式编程却属于声明式的编程方式,这种范式会描述一系列的操作,而不去暴露它们是如何实现的,以及数据是如何从中间穿过。在这里,“动词”是第一词汇,我们更加注重于描述不同动作之间的组合和顺序。

比如,我们需要将一个数组的每个数平方以后,找出其中的奇数,并生成新的数组。

命令式编程:

var array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
var result = [];
for(let i = 0; i < array.length; i++) {
   array[i] = Math.pow(array[i], 2);
   if(array[i]%2 != 0){
       result.push(array[i])
   }
}
console.log(result); //-> [0, 1, 9, 25, 49, 81]

函数式编程:

var array  = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
var result = array.map(function(num) {
        return Math.pow(num, 2); 
      }).filter(function(num) {
         return num%2 !=0;
      });
console.log(result); //-> [0, 1, 9, 25, 49, 81]

为什么要函数式编程

函数式编程只关注于输入和输出的结果,带来最大的好处就是可以把业务逻辑拆分成清晰、固定、小块的功能函数模块。在函数式编程里,把要处理的数据看做一个在许多函数中流动的数据流。再将声明的函数模块组合出正确的函数组合,让数据“流”过一串函数后达到想要的目的。

比如说,在停车场中,找到某一个车牌的车,找到这个车停车的位置,并调用对应位置的摄像头给这个车照相的功能,就可以如下描述:

var carPhoto = takePhoto(findPosition(findCar(number)));

用 Ramda.js 的函数式 js 库中的组合函数来表示,等于一下:

var getCarPhoto = R.compose(takePhoto, findPosition, findCar);
var carPhoto = getCarPhoto(number);

要达到这个目标,我们需要消除函数的副作用减少对状态的改变,因此需要引入一个概念:纯函数。纯函数具有以下特性:

  • 仅取决于提供的输入,而不依赖于任何在函数求值期间或调用间隔时可能变化的隐藏状态和外部状态。
  • 不会造成超出其作用域的变化,例如修改全局对象或引用传递的参数。

考虑以下代码:

var  arr = [1,2,3,4,5];
for (let i = 0; i< arr.length; i++) {
    arr[counter] = arr[counter] * 2;
}
console.log(arr); //[2,4,6,8,10]
for (let i = 0; i< arr.length; i++) {
    arr[counter] = arr[counter] * 2;
}
console.log(arr); //[4,8,12,16,20]

调用同一个循环体,造成两次结果不一样的原因在于,循环里进行了一个不纯的操作,即改变了循环体之外全局变量数组 arr 里面每一项的值。

采用纯函数组合成的函数式编程方法,致力于减少或消除类似这样可能的错误:

var arr = [1,2,3,4,5];
console,log(arr.map((num => num*2)); //[2,4,6,8,10]
console,log(arr.map((num => num*2)); //[2,4,6,8,10]
console,log(arr.map((num => num*2)); //[2,4,6,8,10]

无论执行多少次,对于同样的函数来说,只要输入相同,那么输出一定相同。

因此,可以将函数式编程可以抽象成对一系列纯函数的声明式的求值过程。对于实践函数式编程来说,需要强迫自己去思考纯的操作,将函数看作永不会修改数据的闭合功能单元,从而简化编程流程,减少潜在 bug 的可能性。

javascript 对函数式编程的支持

在 javascript 中,函数是一等的,也就是说,在 javascript 中,函数本身也是一个值。例如一般函数声明为:

function someDo(params) {
  //balala
}

也可以写成匿名函数的赋值:

var someDo = function (params) {
    //balala 
}

甚至函数也能作为函数的参数或者返回值, 这样的函数称为高阶函数:

function someDoInner (params) {
  //balala
}

function someDoParam(params) {
  //balalal
}

function someDoOuter(funcParam) {
   var a = funcParam();
   return someDoInner;
}

someDoOuter(someDoParam);

因为 javascript 中函数的一等性高阶性,使得函数可以作为语言中主要的工作单元,从而完整的支持函数式编程的特性。

例子

以下的例子将实现罗马数字与阿拉伯数字的互换。规则是,罗马数字的对应字符如下:

{
    'I': 1,
    'V': 5,
    'X': 10,
    'L': 50,
    'C': 100,
    'D': 500,
    'M': 1000
  }

其中,罗马数字从大到小排列,只有以下情况除外:

  • 'I' 可以在 'V', 'X' 前,表示减1, 例如 'IV' 表示 4
  • 'X' 可以在 'L', 'C' 前,表示减10
  • 'C' 可以在 ‘D’,'M'前,表示减100

在例子中为了便于理解,未采用例如 ramda.js 之类任何函数式 javascript 库。感兴趣的可以自行重构。

考虑将罗马数字转换为阿拉伯数字的情形,按命令式方式会写出如下代码:

var romanNum = 'DCDLLXLXXIVII'  // 500+400+50+50+40+10+10+4+1+1
var talble = {
    'I': 1,
    'V': 5,
    'X': 10,
    'L': 50,
    'C': 100,
    'D': 500,
    'M': 1000
 }
var arrRoman = romanNum.split('');
var result = 0;
for (let i = 0; i< arrRoman.length< i++) {
   if(i>0) {
      if(table[arrRoman[i]] >table[arrRoman [i-1]] ) {
         arrRoman[i] = -table[arrRoman[i]]
      }
   } 
   result += arrRoman[i];
}

console.log(result);

而按照函数式编程的思路来重构这段程序,我们的思路将进行转变。我们将实现几个小的单一功能的纯函数,最后将这些函数组合起来。我们的小的单元函数包括:

  • 将单个罗马数字变成阿拉伯数字
  • 将罗马数字字符串变成阿拉伯数字数组
  • 将数组中符合条件的数字变成负数
  • 累加数组中的数字

于是我们的代码如下:

const romanLetterToInt = (letter) => {
  const table = {
    'I': 1,
    'V': 5,
    'X': 10,
    'L': 50,
    'C': 100,
    'D': 500,
    'M': 1000
  }
  if (!table[letter]) {
    throw Error('capacity should be positive integer')
  } else {
    return table[letter];
  }
}

const strSplit = (str) => {
  return str.split('').map(romanLetterToInt);
}

const subtractItem = (arr) => {
  return arr.map((element, index, arr) => {
    return index < arr.length - 1 && element < arr[index + 1] ? -element : element
  })
}

const getInt = (str) => {
  return subtractItem(strSplit(str)).reduce((acc, curr) => {
    return acc += curr;
  },0)
}

console,log(getInt('DCDLLXLXXIVII'));

在其中,我们隐藏了类似 arrRoman, result, i 这一类的中间变量。直接组合出声明的函数,完成了同样的功能。

最后,让我们完成数字到罗马字符的转换:

  • 将数字变成同等数量的 'I' 的字符串
  • 按规则将可以进位的数字合并进位
  • 拆分出应该合并进位的字符串数组
  • 将拆分的数组进行组合
const table = {
  5: ['I', 'V', 'X'],
  50: ['X', 'L', 'C'],
  500: ['C', 'D', 'M']
}

const intToIString = (number) => {
  return new Array(number).fill('I').join('');
}

const mergeLetter = (str, numIndex) => {
  const length = str.length;
  const headLen = Math.floor(length / 10);
  const tailLen = length % 10;
  const headerArray = new Array(headLen).fill(table[numIndex][2]);
  switch (tailLen) {
    case 9:
      return headerArray.concat([`${table[numIndex][0]}${table[numIndex][2]}`]).join('');
    case 4:
      return headerArray.concat([`${table[numIndex][0]}${table[numIndex][1]}`]).join('');
    default:
      const tailArray = new Array(Math.floor(tailLen / 5)).fill(table[numIndex][1]).concat(str.substring(str.length - tailLen % 5).split(''));
      return headerArray.concat(tailArray).join('');
  }
}

const splitStr = (str, numIndex) => {
  const index = str.split('').findIndex((ele) => {
    return ele !== table[numIndex][0];
  })
  return index >= 0 ? [str.substring(0, index), str.substring(index)] : [str, ''];
}

const mergeStr = (arr, numIndex) => {
  return mergeLetter(arr[0], numIndex) + arr[1];
}

const getRoman = (num) => {
  return mergeStr(splitStr(mergeStr(splitStr(mergeStr(splitStr(intToIString(num), 5), 5), 50), 50), 500), 500);
}

console.log(getRoman(3682));

之后

以上,我们“走近”了 javascript 中函数式编程的第一步,了解了函数式编程的思维方式。为了方便起见,例子中依然引用了许多对象的方法,例如 Array.map, Array.filter等。实际应用中,还需要学习一些 javascript 中的函数式编程库,例如 ramda.js, lodash.js 中的基本方法。

编辑于 2019-04-29

文章被以下专栏收录