函数式编程简介

函数式编程无疑是目前的热门技术话题之一,值得每个开发者认真研究。iOS、Android这几年不约而同的都更换了编程语言,从Objective-C到Swift,从Java到Kotlin,一个明显的改变就是,语言不再是纯粹面向对象的,都加入对函数式编程的支持。

读过《代码大全》的朋友应该对“软件复杂度”一词印象深刻,“软件复杂度”估计是全书出现频率最高的词了,也是技术发展的核心因变量。很多编程技巧、思想都是为了控制和降低软件复杂度。

复杂度就是任何使软件难以理解或修改的东西。— John Outerhout

回想一下被自己或同事的一大坨代码折磨的可怕情景,马上要发版上线了,突然发现有一个bug需要修改,令人沮丧的是,代码千缠百结,动一发而牵全身,只能硬着头皮碰运气。有什么解决办法吗?函数式编程或许有所帮助。

函数式编程中的不可变性、纯函数等概念帮助我们消除了函数的副作用,有效降低了代码的复杂度。

什么是函数式编程?

函数式编程是一种编程范式(构建计算机程序结构和元素的风格),将计算视为函数的求值,避免状态变化和可变数据。— 维基百科

一种声明式编程范式,因为编程是用表达式或声明而不是语句来完成的。

函数式代码是幂等的:函数的返回值只依赖于其参数,相同的参数永远产生相同的返回值。相反,在命令式编程中,除了函数的参数,全局状态也会影响函数的返回值。消除副作用,即不依赖于函数输入的状态变化,可以使代码更容易理解,这是函数式编程的核心动机。

前面的话翻译自维基百科,有些过于书面化。下里巴人的话是:

函数式编程是通过纯函数的组合来构建软件的一种编程方式,强调避免共享状态可变数据副作用。函数式编程是声明式的,而不是命令式的,应用状态通过纯函数流动。对比面向对象编程,其应用状态通常是共享的,并且与对象中的方法关联。

函数式编程是一种编程范式,也就是说,它是基于一些基本的、决定性的原则来构建软件的一种方法。其它的常见编程范式包括面向对象、面向过程、面向协议等。注意函数式编程与面向过程编程的区别,很多人错误地认为传统的C语言编程就是函数式编程,真是让人啼笑皆非。

函数式代码通常比命令式或面向对象的代码更简洁、可预测性更强、更易于测试 -- 不过,如果你对函数式编程和其常见模式不了解,函数式代码也可能看起来头疼,因为其信息密度更高。

所有的声明式编程,概念都相对比较多,用过Rx的应该深有体会。函数式编程也不例外,涉及到的概念比较多,学习曲线比较陡,随便来几个概念感受一下:Monad、Applicative、Functor。生僻的单词有没有激起你想逃跑的本能?

不要被生僻词吓跑,它并没有看起来那么恐怖。

暂且不用管Monad这种看起来似乎很深奥的家伙,作为初学者,只要先掌握以下概念即可:

  • 纯函数
  • 函数组合
  • 避免共享状态
  • 避免可变状态
  • 避免副作用

请牢记这几个概念,这是函数式编程的核心。

纯函数

函数式编程的第一个基础概念就是纯函数。纯函数是什么?如何使函数变“纯”?

如何判断一个函数是否为纯函数?对“纯”的定义如下:

  • 参数相同,则返回值相同(也称为确定性
  • 不会引起任何可观察的副作用

参数相同,则返回值相同

让我们来实现一个计算圆形面积的函数。一种非纯函数的实现方式是,接受一个radius参数,然后返回radius * radius * PI:

let PI = 3.14;
const calculateArea = (radius) => radius * radius * PI;
calculateArea(10); // returns 314.0

为什么这不是纯函数?因为它使用了一个全局变量,而不是将其作为参数传入。

假设我们需要提高PI的精度,将其改为3.1415926,对于同样的参数10,上面函数的返回值将改变。

重写一下:

let PI = 3.14;
const calculateArea = (radius, pi) => radius * radius * pi;
calculateArea(10, PI); // returns 314.0

现在我们将PI作为参数传入,除了输入参数,函数不访问任何外部对象。毫无疑问,对于相同的参数,函数必然返回相同的结果。

读取文件

如果函数读取外部文件,则函数不是纯函数 — 文件的内容可能会改变。

const charactersCounter = (text) => `Character count: ${text.length}`;
function analyzeFile(filename) {
  let fileContent = open(filename);
  return charactersCounter(fileContent);
}

随机数

任何依赖随机数生成的函数都不是纯函数。

function yearEndEvaluation() {
  if (Math.random() > 0.5) {
    return "You get a raise!";
  } else {
    return "Better luck next year!";
  }
}

不产生可观察的副作用

副作用是除了返回值以外,在被调用函数之外可观察到的任何状态变化。包括:

  • 修改外部变量或对象的属性(例如,全局变量,父函数范围内的变量)
  • 控制台日志输出
  • 屏幕输出
  • 写文件
  • 输出到网络
  • 调用外部进程
  • 调用其它含有副作用的函数

Haskell和其它函数式编程语言通常使用monads从纯函数中隔离和封装副作用。monads的概念比较深,足够写一本书了,感兴趣的可以自己查资料了解。

现在我们需要知道的是,副作用需要与软件的其它部分隔离。如果将副作用与其它代码逻辑分开,软件将更易于扩展、重构、调试、测试和维护。这也是大多数前端框架鼓励将状态管理和组件绘制独立、松散耦合地维护的原因。

假设我们要实现一个对整数加1的函数。

let counter = 1;

function increaseCounter(value) {
  counter = value + 1;
}

increaseCounter(counter);
console.log(counter); // 2

纯函数实现:

let counter = 1;

const increaseCounter = (value) => value + 1;

increaseCounter(counter); // 2
console.log(counter); // 1

函数式编程不鼓励可变性。

如果我们遵循这两个简单的原则,我们的代码会变得更容易理解。每个函数都是隔离的,不会影响系统的其它部分。

纯函数是稳定的、一致的和可预测的。给定相同的参数,纯函数始终返回相同的结果。我们不需要考虑相同参数而结果不同的情况— 因为它永远不会发生。

纯函数的好处

代码明显更易于测试。不需要模拟任何东西,所以我们可以用不同的上下文对纯函数进行单元测试:

  • 给定参数A,期望返回值B
  • 给定参数C,期望返回值D

一个简单的例子是,函数接受一个数字的列表,对列表中的每个数字加1:

let list = [1, 2, 3, 4, 5];

const incrementNumbers = (list) => list.map(number => number + 1);

接受numbers数组,使用map对每个数字加1,返回新的数字列表。

incrementNumbers(list); // [2, 3, 4, 5, 6]

对于输入[1, 2, 3, 4, 5],期望的输出是[2, 3, 4, 5, 6]

不可变性

不随时间而改变,或不可改变。

当数据不可变时,其状态在创建之后不可更改。如果需要改变一个不可变对象,那就重新创建一个。

我们经常使用for循环,for循环中有几个变量:

var values = [1, 2, 3, 4, 5];
var sumOfValues = 0;

for (var i = 0; i < values.length; i++) {
  sumOfValues += values[i];
}

sumOfValues // 15

每次迭代,改变isumOfValue的状态。

如果要求不改变外部状态,应该如何做呢?用递归。

let list = [1, 2, 3, 4, 5];
let accumulator = 0;

function sum(list, accumulator) {
  if (list.length == 0) {
    return accumulator;
  }

  return sum(list.slice(1), accumulator + list[0]);
}

sum(list, accumulator); // 15
list; // [1, 2, 3, 4, 5]
accumulator; // 0

初看可能不太容易理解,sub函数接受一个数字列表以及当前累加值,每次迭代将第一个元素加到累加值上,然后用其它元素和新的累加值进行下一轮迭代,直到列表为空。

通过使用迭代,我们保持了状态的不变性。listaccmulator在运算过程中没有发生变化。

可以使用reduct实现此函数,见后面高阶函数部分。

注意:此处的代码其实变得更难以理解了,这也是纯函数式编程难以流行的原因。真正的高手,打的都是组合拳,适可而止很重要,否则过犹不及。

对各种对象做转换是很常见的任务。比如,有一个字符串,我们需要将其转换为url slug(看一下浏览器的地址栏,用-分割的名称就是url slug)。

下面是Ruby的OOP实现,我们创建一个类UrlSlugify,此类有一个slugify方法用来实现此转换。

class UrlSlugify
  attr_reader :text
  
  def initialize(text)
    @text = text
  end

  def slugify!
    text.downcase!
    text.strip!
    text.gsub!(' ', '-')
  end
end

UrlSlugify.new(' I will be a url slug   ').slugify! # "i-will-be-a-url-slug"

此处是命令式编程,明确说明了slugify的步骤 — 首先转小写,然后移除无用的空格,最后用连字符替换剩余的空格。

在处理过程中,我们改变了输入状态。

不改变输入状态的方式应该如何实现?可以用函数组合或函数链。函数的结果作为下一个函数的输入,而不改变原始输入。

const string = " I will be a url slug   ";

const slugify = string =>
  string
    .toLowerCase()
    .trim()
    .split(" ")
    .join("-");

slugify(string); // i-will-be-a-url-slug

此处:

  • toLowerCase:将字符串转化为小写
  • trim:移除字符串两端的空白字符
  • splitjoin:替换匹配字符

通过以上四个函数的组合,我们实现了slugify

引用透明性

我们来实现一个平方函数:

const square = (n) => n * n;

给定相同的输入,此纯函数总是返回相同的结果。

square(2); // 4
square(2); // 4
square(2); // 4
// ...

给定参数2square函数总是返回4。所以我们可以用4替换square(2),这就叫引用透明性

基本上,如果一个函数对于相同的输入,总是产生相同的结果,那么就可以说它是引用透明的。

纯函数 + 不可变数据 = 引用透明性

有了这个概念,我们就可以将函数“记忆化”(一种程序优化技术,通过缓存复杂函数的结果,当输入相同时直接返回缓存结果,以此来提升程序的运行速度)。

假设有以下函数:

const sum = (a, b) => a + b;

有如下调用:

sum(3, sum(5, 8));

sum(5, 8)等于13,此函数的结果总是13。因此上面的表达式等于:

sum(3, 13);

此表达式的结果总是16。我们可以将整个表达式用一个数字常量来替换,将其记忆化。

函数是第一类对象

函数是第一类对象,是说函数被作为值对待,可作为普通数据使用。

函数作为第一类对象使得可以:

  • 使用常量或变量引用它
  • 将其作为参数传入其它函数
  • 将其作为函数返回值

此思想将函数视为值,并作为数据传递。通过这种方式,我们可以组合不同的函数来创建具有新行为的新函数。

假设有一个函数,对两个输入值求和并加倍:

const doubleSum = (a, b) => (a + b) * 2;

另一个函数,对两个输入值求差值并加倍:

const doubleSubtraction = (a, b) => (a - b) * 2;

两个函数逻辑类似,不同之处在于运算符函数。如果我们可以将函数作为值,并作为参数传递,我们可以构建一个函数,运算符函数作为其参数。

const sum = (a, b) => a + b;
const subtraction = (a, b) => a - b;

const doubleOperator = (f, a, b) => f(a, b) * 2;

doubleOperator(sum, 3, 1); // 8
doubleOperator(subtraction, 3, 1); // 4

此处有运算符函数参数f,用于处理参数ab。通过sumsubtraction函数与doubleOperator函数的组合创造了新的行为。

高阶函数

当我们讨论高阶函数时,我们指的是这样一个函数:

  • 将一个或多个函数作为参数,或
  • 返回一个函数作为返回值

上面的doubleOperator函数是一个高阶函数,因为其将运算符函数作为参数。

你可能已经听说过filtermapreduce,最常用的三个操作容器(数组、字典等)的高阶函数,我们再一起看一下。

Filter

给定一个集合,我们希望根据某个属性做筛选。filter函数期望一个布尔值,用于判定元素是否应该包含在结果集合中。如果回调函数返回true,那么当前元素将会包含在结果集合中,否则不会。

一个简单的例子是,我们希望从一个整数列表中筛选出所有偶数。

命令式

命令式的实现方式:

  • 创建一个空的evenNumbers数组
  • 遍历numbers数组
  • 将偶数放入evenNumbers数组中
var numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
var evenNumbers = [];

for (var i = 0; i < numbers.length; i++) {
  if (numbers[i] % 2 == 0) {
    evenNumbers.push(numbers[i]);
  }
}

console.log(evenNumbers); // (6) [0, 2, 4, 6, 8, 10]

我们也可以使用filter高阶函数,向其传递一个even函数:

const even = n => n % 2 == 0;
const listOfNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
listOfNumbers.filter(even); // [0, 2, 4, 6, 8, 10]

在Hacker Rank FP(类似于LeetCode、牛客网)上有一个Filter数组问题,过滤一个整数数组,输出小于x的所有数字。

一种命令式解法可以是:

var filterArray = function(x, coll) {
  var resultArray = [];

  for (var i = 0; i < coll.length; i++) {
    if (coll[i] < x) {
      resultArray.push(coll[i]);
    }
  }

  return resultArray;
}

console.log(filterArray(3, [10, 9, 8, 2, 7, 5, 1, 3, 0])); // (3) [2, 1, 0]

精确地说明函数需要做什么 - 遍历集合,将当前数字与x比较,如果满足条件则放入结果数组resultArray

声明式

如果我们想要使用filter高阶函数的更加声明化的解法呢?

一种声明式的解法可以是:

function smaller(number) {
  return number < this;
}

function filterArray(x, listOfNumbers) {
  return listOfNumbers.filter(smaller, x);
}

let numbers = [10, 9, 8, 2, 7, 5, 1, 3, 0];

filterArray(3, numbers); // [2, 1, 0]

smaller函数中使用this初看起来可能有点儿怪,不过它本身很好理解 (JS的骚语法,其它常见编程语言未必有类似用法)。filter函数的第二个参数会成为this,在上例中,this表示3(x)。

也可以这样操作maps。假设有一个包含nameage的map:

let people = [
  { name: "TK", age: 26 },
  { name: "Kaio", age: 10 },
  { name: "Kazumi", age: 30 }
];

我们想要筛选出所有21岁以上的人:

const olderThan21 = person => person.age > 21;
const overAge = people => people.filter(olderThan21);
overAge(people); // [{ name: 'TK', age: 26 }, { name: 'Kazumi', age: 30 }]

Map

map用来对集合做转换。

map将一个函数应用于集合的所有元素,根据函数的结果构建一个新的集合,以实现对集合的转换。

还是上面的people数组,不过此次不是根据年龄做筛选,而是想得到每个人的描述,类似TK is 26 years old。也就是说,对于每个人,我们希望得到一个字符串:name is :age years old,其中:name:agepeople数组中每个元素的属性。

一个命令式的实现方式:

var people = [
  { name: "TK", age: 26 },
  { name: "Kaio", age: 10 },
  { name: "Kazumi", age: 30 }
];

var peopleSentences = [];

for (var i = 0; i < people.length; i++) {
  var sentence = people[i].name + " is " + people[i].age + " years old";
  peopleSentences.push(sentence);
}

console.log(peopleSentences); // ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']

而声明式的实现方式可能是:

const makeSentence = (person) => `${person.name} is ${person.age} years old`;

const peopleSentences = (people) => people.map(makeSentence);
  
peopleSentences(people);
// ['TK is 26 years old', 'Kaio is 10 years old', 'Kazumi is 30 years old']

Hacker Rank上有一个修改列表问题,将数组中的每个数字修改为其绝对值。

例如,对于数组[1, 2, 3, -4, 5],结果应该是[1, 2, 3, 4, 5]

一种简单的实现方案是对集合中的每个数进行就地更新:

var values = [1, 2, 3, -4, 5];

for (var i = 0; i < values.length; i++) {
  values[i] = Math.abs(values[i]);
}

console.log(values); // [1, 2, 3, 4, 5]

我们使用Math.abs函数将每个数转换为其绝对值,然后就地更新。

不是函数式的实现方式。

我们前面介绍了不可变性,强调了不可变性对函数一致性、可预测性的重要作用。既然想要构建一个绝对值组成的新集合,为什么不用map呢?

首先看一下Math.abs函数如何操作一个数字:

Math.abs(-1); // 1
Math.abs(1); // 1
Math.abs(-2); // 2
Math.abs(2); // 2

Math.abs将数字转换为其绝对值。

我们已经知道如何获取一个数字的绝对值,我们可以将此函数传入map函数。还记得高阶函数可以接受其它函数作为其参数吗?

let values = [1, 2, 3, -4, 5];
const updateListMap = (values) => values.map(Math.abs);
updateListMap(values); // [1, 2, 3, 4, 5]

是不是简洁、优雅很多?

Reduce

接受一个函数和一个集合,将其组合为一个新的值。

一个常见的例子是,获取一个订单的总额。假设我们在某个电商网站上,将Product 1Product 2Product 3Product 4加入了购物车(订单),我们想要计算购物车商品的总金额。

一种命令式的实现方式是,遍历商品列表,累加商品的金额。

var orders = [
  { productTitle: "Product 1", amount: 10 },
  { productTitle: "Product 2", amount: 30 },
  { productTitle: "Product 3", amount: 20 },
  { productTitle: "Product 4", amount: 60 }
];

var totalAmount = 0;

for (var i = 0; i < orders.length; i++) {
  totalAmount += orders[i].amount;
}

console.log(totalAmount); // 120

使用reduce函数,我们可以构建一个用来amount sum的函数,并将其传给reduce函数。

let shoppingCart = [
  { productTitle: "Product 1", amount: 10 },
  { productTitle: "Product 2", amount: 30 },
  { productTitle: "Product 3", amount: 20 },
  { productTitle: "Product 4", amount: 60 }
];

const sumAmount = (currentTotalAmount, order) => currentTotalAmount + order.amount;

const getTotalAmount = (shoppingCart) => shoppingCart.reduce(sumAmount, 0);

getTotalAmount(shoppingCart); // 120

此处,我们有购物车商品列表shoppingCart,函数sumAmount接受当前总额currentTotalAmount和订单order对象以执行计算。

getTotalAmount函数使用sumAmount和起始值0对shoppingCartreduce

另外一种获取总额的方式是组合mapreduce函数。什么意思呢?我们可以首先使用mapshoppingCart转换为amount的数组,然后再使用sumAmount函数做reduce

const getAmount = (order) => order.amount;
const sumAmount = (acc, amount) => acc + amount;

function getTotalAmount(shoppingCart) {
  return shoppingCart
    .map(getAmount)
    .reduce(sumAmount, 0);
}

getTotalAmount(shoppingCart); // 120

getAmount函数接受订单对象order并返回金额amount。此处,我们有[10, 30, 30, 60]。然后reduce累加所有的值以得到结果。漂亮!

我们再来看一个将上面讲解的几个高阶函数组合应用的例子。

依然以购物车shopping cart为例,假设购物车中有以下商品:

let shoppingCart = [
  { productTitle: "Functional Programming", type: "books", amount: 10 },
  { productTitle: "Kindle", type: "eletronics", amount: 30 },
  { productTitle: "Shoes", type: "fashion", amount: 20 },
  { productTitle: "Clean Code", type: "books", amount: 60 }
]

我们希望获得购物车中所有书籍的总额。如何做呢?

  • 筛选出所有的书籍(filter)
  • 将商品列表转换为金额的列表(map)
  • 将所有值累加(reduce)
let shoppingCart = [
  { productTitle: "Functional Programming", type: "books", amount: 10 },
  { productTitle: "Kindle", type: "eletronics", amount: 30 },
  { productTitle: "Shoes", type: "fashion", amount: 20 },
  { productTitle: "Clean Code", type: "books", amount: 60 }
]

const byBooks = (order) => order.type == "books";
const getAmount = (order) => order.amount;
const sumAmount = (acc, amount) => acc + amount;

function getTotalAmount(shoppingCart) {
  return shoppingCart
    .filter(byBooks)
    .map(getAmount)
    .reduce(sumAmount, 0);
}

getTotalAmount(shoppingCart); // 70

参考资源

  • Functional Programming Principles
  • What is Functional Programming?
  • Functional Programming Paradigm

你可能感兴趣的:(函数式编程简介)