摘要: 理解函数式编程.
Fundebug 经授权转载, 版权归原作者所有.
在长时间学习和使用面向对象编程之后, 咱们退一步来考虑系统复杂性.
在做了一些研究之后, 我发现了函数式编程的概念, 比如不变性和纯函数. 这些概念使你能够构建无副作用的函数, 因此更容易维护具有其他优点的系统.
在这篇文章中, 将通大量代码示例来详细介绍函数式编程和一些相关重要概念.
什么是函数式编程
函数式编程是一种编程范式, 是一种构建计算机程序结构和元素的风格, 它把计算看作是对数学函数的评估, 避免了状态的变化和数据的可变.
纯函数
当我们想要理解函数式编程时, 需要知道的第一个基本概念是纯函数, 但纯函数又是什么鬼?
咱们怎么知道一个函数是否是纯函数? 这里有一个非常严格的定义:
如果给定相同的参数, 则返回相同的结果(也称为确定性).
它不会引起任何副作用.
如果给定相同的参数, 则得到相同的结果
如果给出相同的参数, 它返回相同的结果. 想象一下, 我们想要实现一个计算圆的面积的函数.
不是纯函数会这样做, 接收 radius 作为参数, 然后计算 radius * radius * PI:
- let PI = 3.14;
- const calculateArea = (radius) => radius * radius * PI;
- calculateArea(10); // returns 314.0
为什么这是一个不纯函数? 原因很简单, 因为它使用了一个没有作为参数传递给函数的全局对象.
现在, 想象一些数学家认为圆周率的值实际上是 42 并且修改了全局对象的值.
不纯函数得到 10 * 10 * 42 = 4200. 对于相同的参数(radius = 10), 我们得到了不同的结果.
修复它:
- let PI = 3.14;
- const calculateArea = (radius, pi) => radius * radius * pi;
- calculateArea(10, PI); // returns 314.0
现在把 PI 的值作为参数传递给函数, 这样就没有外部对象引入.
对于参数 radius = 10 和 PI = 3.14, 始终都会得到相同的结果:
314.0
.
对于 radius = 10 和 PI = 42, 总是得到相同的结果:
4200
读取文件
下面函数读取外部文件, 它不是纯函数, 文件的内容随时可能都不一样.
- 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!";
- }
- }
无明显副作用
纯函数不会引起任何可观察到的副作用. 可见副作用的例子包括修改全局对象或通过引用传递的参数.
现在, 咱们要实现一个函数, 该接收一个整数并返对该整数进行加 1 操作且返回.
- let counter = 1;
- function increaseCounter(value) {
- counter = value + 1;
- }
- increaseCounter(counter);
- console.log(counter); // 2
该非纯函数接收该值并重新分配 counter, 使其值增加 1.
函数式编程不鼓励可变性. 我们修改全局对象, 但是要怎么做才能让它变得纯函数呢? 只需返回增加 1 的值.
- let counter = 1;
- const increaseCounter = (value) => value + 1;
- increaseCounter(counter); // 2
- console.log(counter); // 1
纯函数 increaseCounter 返回 2, 但是 counter 值仍然是相同的. 函数返回递增的值, 而不改变变量的值.
如果我们遵循这两条简单的规则, 就会更容易理解我们的程序. 现在每个函数都是孤立的, 不能影响系统的其他部分.
纯函数是稳定的, 一致的和可预测的. 给定相同的参数, 纯函数总是返回相同的结果.
咱们不需要考虑相同参数有不同结果的情况, 因为它永远不会发生.
纯函数的好处
纯函数代码肯定更容易测试, 不需要 mock 任何东西, 因此, 我们可以使用不同的上下文对纯函数进行单元测试:
给定一个参数 A, 期望函数返回值 B
给定一个参数 C, 期望函数返回值 D
一个简单的例子是接收一组数字, 并对每个数进行加 1 这种沙雕的操作.
- let list = [1, 2, 3, 4, 5];
- const incrementNumbers = (list) => list.map(number => number + 1);
接收 numbers 数组, 使用 map 递增每个数字, 并返回一个新的递增数字列表.
incrementNumbers(list); // [2, 3, 4, 5, 6]
对于输入[1,2,3,4,5], 预期输出是[2,3,4,5,6].
不可变性
尽管时间变或者不变, 纯函数大佬都是不变的.
当数据是不可变的时, 它的状态在创建后不能更改.
咱们不能更改不可变对象, 如果非要来硬的, 刚需要深拷贝一个副本, 然后操作这个副本.
在 JS 中, 我们通常使用 for 循环, for 的每次遍历 i 是个可变变量.
- var values = [1, 2, 3, 4, 5];
- var sumOfValues = 0;
- for (var i = 0; i <values.length; i++) {
- sumOfValues += values[i];
- }
- sumOfValues // 15
对于每次遍历, 都在更改 i 和 sumOfValue 状态, 但是我们如何在遍历中处理可变性呢? 答案就是使用递归.
- 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
上面代码有个 sum 函数, 它接收一个数值向量. 函数调用自身, 直到 list 为空退出递归. 对于每次 "遍历", 我们将把值添加到总 accumulator 中.
使用递归, 咱们保持变量不变. 不会更改 list 和 accumulator 变量. 它保持相同的值.
观察: 我们可以使用 reduce 来实现这个功能. 这个在接下的高阶函数内容中讨论.
构建对象的最终状态也很常见. 假设我们有一个字符串, 想把这个字符串转换成 url slug.
在 Ruby 的面向对象编程中, 咱们可以创建一个类 UrlSlugify, 这个类有一个 slugify 方法来将字符串输入转换为 url slug.
- 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: 删除字符串两端的空白.
split 和 join: 用给定字符串中的替换替换所有匹配实例
代码部署后可能存在的 BUG 没法实时知道, 事后为了解决这些 BUG, 花了大量的时间进行 log 调试, 这边顺便给大家推荐一个好用的 BUG 监控工具 Fundebug.
引用透明性
接着实现一个 square 函数:
const square = (n) => n * n;
给定相同的输入, 这个纯函数总是有相同的输出.
- square(2); // 4
- square(2); // 4
- square(2); // 4
- // ...
将 2 作为 square 函数的参数传递始终会返回 4. 这样咱们可以把 square(2)换成 4, 我们的函数就是引用透明的.
基本上, 如果一个函数对于相同的输入始终产生相同的结果, 那么它可以看作透明的.
有了这个概念, 咱们可以做的一件很酷的事情就是记住这个函数. 假设有这样的函数
const sum = (a, b) => a + b;
用这些参数来调用它
sum(3, sum(5, 8));
sum(5, 8) 总等于 13, 所以可以做些骚操作:
sum(3, 13);
这个表达式总是得到 16, 咱们可以用一个数值常数替换整个表达式, 并把它记下来.
函数是 JS 中的一级公民
函数作为 JS 中的一级公民, 很风骚, 函数也可以被看作成值并用作数据使用.
从常量和变量中引用它.
将其作为参数传递给其他函数.
作为其他函数的结果返回它.
其思想是将函数视为值, 并将函数作为数据传递. 通过这种方式, 我们可以组合不同的函数来创建具有新行为的新函数.
假如我们有一个函数, 它对两个值求和, 然后将值加倍, 如下所示:
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 参数并用它来处理 a 和 b, 这里传递了 sum 函数和 subtraction 并使用 doubleOperator 函数进行组合并创建新行为.
高阶函数
当我们讨论高阶函数时, 通常包括以下几点:
将一个或多个函数作为参数
返回一个函数作为结果
上面实现的 doubleOperator 函数是一个高阶函数, 因为它将一个运算符函数作为参数并使用它.
我们经常用的 filter,map 和 reduce 都是高阶函数, Look see see.
Filter
对于给定的集合, 我们希望根据属性进行筛选. filter 函数期望一个 true 或 false 值来决定元素是否应该包含在结果集合中.
如果回调表达式为真, 过滤器函数将在结果集合中包含元素, 否则, 它不会.
一个简单的例子是, 当我们有一个整数集合, 我们只想要偶数.
命令式
使用命令式方式来获取数组中所有的偶数, 通常会这样做:
创建一个空数组 evenNumbers
遍历数组 numbers
将偶数 push 到 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 高阶函数来接收偶函数并返回一个偶数列表:
- 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][2] 上解决的一个有趣问题是[Filter Array 问题][3]. 问题是过滤给定的整数数组, 并仅输出小于指定值 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]
声明式方式
对于上面的总是, 我们更想要一种更声明性的方法来解决这个问题, 如下所示:
- 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, 一开始看起来有点奇怪, 但是很容易理解.
filter 函数中的第二个参数表示上面 this, 也就是 x 值.
我们也可以用 map 方法做到这一点. 想象一下, 有一组信息
- let people = [
- { name: "TK", age: 26 },
- { name: "Kaio", age: 10 },
- { name: "Kazumi", age: 30 }
- ]
我们希望过滤 age 大于 21 岁的人, 用 filter 方式
- 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 方法通过将函数应用于其所有元素并根据返回的值构建新集合来转换集合.
假如我们不想过滤年龄大于 21 的人, 我们想做的是显示类似这样的: TK is 26 years old.
使用命令式, 我们通常会这样做:
- 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']
整个思想是将一个给定的数组转换成一个新的数组.
另一个有趣的 HackerRank 问题是[更新列表问题][3]. 我们想要用一个数组的绝对值来更新它的值.
例如, 输入 [1,2,3,- 4,5] 需要输出为[1,2,3,4,5],-4 的绝对值是 4.
一个简单的解决方案是每个集合中值的就地更新, 很危险的作法
- 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
我们想把每个值转换成一个正值(绝对值).
现在知道如何对一个值执行绝对值操作, 可以使用此函数作为参数传递给 map 函数.
还记得高阶函数可以接收函数作为参数并使用它吗? 是的, map 函数可以做到这一点
- let values = [1, 2, 3, -4, 5];
- const updateListMap = (values) => values.map(Math.abs);
- updateListMap(values); // [1, 2, 3, 4, 5]
- Reduce
reduce 函数的思想是接收一个函数和一个集合, 并返回通过组合这些项创建的值.
常见的的一个例子是获取订单的总金额.
假设你在一个购物网站, 已经将产品 1, 产品 2, 产品 3 和产品 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, 我们可以构建一个函数来处理量计算 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, 接收当前 currentTotalAmount 的函数 sumAmount, 以及对它们求和的 order 对象.
咱们也可以使用 map 将 shoppingCart 转换为一个 amount 集合, 然后使用 reduce 函数和 sumAmount 函数.
- 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 接收 product 对象并只返回 amount 值, 即[10,30,20,60], 然后, 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 }
- ]
假如相要想要购物车里类型为 books 的总数, 通常会这样做:
过滤 type 为 books 的
使用 map 将购物车转换为 amount 集合.
用 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
代码部署后可能存在的 BUG 没法实时知道, 事后为了解决这些 BUG, 花了大量的时间进行 log 调试, 这边顺便给大家推荐一个好用的 BUG 监控工具 Fundebug.
关于 Fundebug
Fundebug 专注于 JavaScript, 微信小程序, 微信小游戏, 支付宝小程序, React Native,Node.JS 和 Java 线上应用实时 BUG 监控. 自从 2016 年双十一正式上线, Fundebug 累计处理了 20 亿 + 错误事件, 付费客户有阳光保险, 核桃编程, 荔枝 FM, 掌门 1 对 1, 微脉, 青团社等众多品牌企业. 欢迎大家免费试用!
来源: http://www.jianshu.com/p/972b431129e1