JavaScript 中的函数式编程原理

更新日期: 2019-05-03阅读: 2.6k标签: 原理
原文:Functional Programming Principles in Javascript
作者:TK
译者:博轩

经过很长一段时间的学习和面向对象编程的工作,我退后一步,开始思考系统的复杂性。

“复杂性是任何使软件难以理解或修改的东西。” - John Outerhout

做了一些研究,我发现了函数式编程概念,如不变性和纯函数。 这些概念使你能够构建无副作用的功能,而函数式编程的一些优点,也使得系统变得更加容易维护。在这篇文章中,我将通过 JavaScript 中的大量代码示例向您详细介绍函数式编程和一些重要概念。


什么是函数式编程?

维基百科:Functional programming

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


纯函数

当我们想要理解函数式编程时,我们学到的第一个基本概念是纯函数。 那么我们怎么知道函数是否纯粹呢? 这是一个非常严格的纯度定义:

  • 如果给出相同的参数,它返回相同的结果(它也称为确定性
  • 它不会引起任何可观察到的副作用

如果给出相同的参数,它返回相同的结果

我们想要实现一个计算圆的面积的函数。 不纯的函数将接收半径:radius 作为参数,然后计算 radius * radius * PI :

const PI = 3.14;

const calculateArea = (radius) => radius * radius * PI;

calculateArea(10); // returns 314

为什么这是一个不纯的功能? 仅仅因为它使用的是未作为参数传递给函数的全局对象。

想象一下,数学家认为 PI 值实际上是 42, 并且改变了全局对象的值。

不纯的函数现在将导致 10 * 10 * 42 = 4200 .对于相同的参数(radius= 10),我们得到不同的结果。

我们来解决它吧!

const PI = 3.14;

const calculateArea = (radius, pi) => radius * radius * pi;

calculateArea(10, PI); // returns 314

现在我们将 PI 的值作为参数传递给函数。 所以现在我们只是访问传递给函数的参数。 没有外部对象(参数)

  • 对于参数 radius = 10 和 PI = 3.14,我们将始终具有相同的结果:314
  • 对于参数 radius = 10 和 PI = 42,我们将始终具有相同的结果:4200

读取文件 (Node.js)

如果我们的函数读取外部文件,它也不是纯函数 - 文件的内容可以更改:

const fs = require('fs');
const charactersCounter = (text) => `Character count: ${text.length}`;

function analyzeFile(filepath) {
    let fileContent = fs.readFileSync(filepath);
    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 。 然后使用不纯的函数接收该值并重新为 counter 赋值,使其值增加 1 。

注意:在函数式编程中不鼓励可变性。

上面的例子中,我们修改了全局对象。 但是我们如何才能让函数变得纯净呢? 只需返回增加1的值。

let counter = 1;

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

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

可以看到我们的纯函数 increaseCounter 返回 2 ,但是 counter 还保持之前的值。该函数会使返回的数字递增,而且不更改变量的值。

如果我们遵循这两个简单的规则,就会使我们的程序更加容易理解。每个功能都是孤立的,无法影响到我们的系统。

纯函数是稳定,一致并且可预测的。给定相同的参数,纯函数将始终返回相同的结果。我们不需要考虑,相同的参数会产生不同的结果,因为它永远不会发生。


纯函数的好处

容易测试

纯函数的代码更加容易测试。我们不需要模拟任何执行的上下文。我们可以使用不同的上下文对纯函数进行单元测试:

  • 给定参数 A -> 期望函数返回 B
  • 给定参数 C -> 期望函数返回 D

一个简单的例子,函数接收一个数字集合,并期望数字集合每个元素递增。

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

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

我们接收到数字数组,使用 map 递增每个数字,并返回一个新的递增数字列表。

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

对于输入 [1, 2, 3, 4, 5],预期输出将是 [2, 3, 4, 5, 6]。

不变性

随着时间的推移不变,或无法改变

当数据具有不可变性时,它的状态在创建之后,就不能改变了。你不能去更改一个不可变的对象,但是你可以使用新值去创建一个新的对象。

在 JavaScript 中,我们常使用 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

对于每次迭代,我们都在改变变量 i 和 sumOfValues 的状态。但是我们要如何处理迭代中的可变性?使用递归

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 函数接收数值向量。 该函数调用自身,直到我们将列表清空。 对于每个“迭代”,我们会将该值添加到总累加器。

使用递归,我们可以保持变量的不可变性。 列表和累加器变量不会更改,会保持相同的值。

注意:我们可以使用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"

他已经实现了!(It’s implemented!)

这里我们使用命令式编程,准确的说明我们想要在 函数实现的过程中(slugify)每一步要做什么:首先是转换成小写,然后移除无用的空格,最后用连字符替换剩余的空格。

但是,在这个过程中,函数改变了输入的参数。

我们可以通过执行函数组合或函数链来处理这种变异。 换句话说,函数的结果将用作下一个函数的输入,而不修改原始输入字符串。

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

function slugify(string) {
  return string.toLowerCase()
    .trim()
    .split(" ")
    .join("-");
}

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

这里我们:

  • toLowerCase:将字符串转换为全部小写
  • trim:从字符串的两端删除空格
  • split 和 join :用给定字符串中的替换替换所有匹配实例

我们将所有这四个功能结合起来,就可以实现 slugify 的功能了。

参考透明度

维基百科:Referential transparency

如果表达式可以替换为其相应的值而不更改程序的行为,则该表达式称为引用透明。这要求表达式是纯粹的,也就是说相同输入的表达式值必须相同,并且其评估必须没有副作用。-- 维基百科

让我们实现一个计算平方的方法:

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

在给定相同输入的情况下,此纯函数将始终具有相同的输出。

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

把 2 传递给 square 方法将始终返回 4。所以,现在我们可以使用 4 来替换 square(2)。我们的函数是引用透明的。

基本上,如果函数对同一输入始终产生相同的结果,则引用透明

pure functions + immutable data = referential transparency

纯函数 + 不可变数据 = 参照透明度

有了这个概念,我们可以做一件很 cool 的事情,就是使这个函数拥有记忆(memoize)。
想象一下我们拥有这样一个函数:

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

我们用这些参数调用它:

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

sum(5, 8) 等于 13。这个函数总是返回 13。因此,我们可以这样做:

sum(3, 13);

这个表达式总是会返回 16 。我们可以用一个数值常量替换整个表达式,并记住它。

这里推荐一篇淘宝FED关于 memoize 的文章:性能优化:memoization


函数是一等公民

函数作为一等公民,意味着函数也可以视为值处理,并当做数据来使用。

函数作为一等公民有如下特性:

  • 可以当做常量,或者变量来引用
  • 将函数当做参数传递给其他函数
  • 将函数作为其他函数的返回值

我们的想法是函数视为值并将它们作为参数传递。 这样我们就可以组合不同的函数来创建具有新行为的新函数。

想象一下,我们有一个函数可以将两个值相加,然后将该值加倍:

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函数进行组合并创建一个新行为。


高阶函数

维基百科:Higher-order function

当我们谈论高阶函数时,通常是指一个函数同时具有:

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

我们上面实现的 doubleOperator 函数是一个高阶函数,因为它将一个运算符函数作为参数并使用它。

您可能已经听说过 filter,map 和 reduce 。 我们来看看这些。

Filter

给定一个集合,我们希望按照属性进行过滤。filter 函数需要 true 或者 false 值来确定元素是否应该包含在结果集合中。基本上,如果回调表达式返回的是 true ,filter 函数返回的结果会包含该元素。否则,就不会包含该元素。

一个简单的例子是当我们有一个整数集合时,我们只想要过滤偶数。

命令式方法

使用 JavaScript 来实现时,需要如下操作:

  • 创建一个空数组 evenNumbers
  • 迭代数字数组
  • 将偶数推到 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上解决的一个有趣问题是Filter Array问题。 问题的想法是过滤给定的整数数组,并仅输出那些小于指定值X的值。

针对此问题,命令式JavaScript解决方案如下:

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。


声明性处理

但我们想要一种更具声明性的方法来解决这个问题,并使用过滤器高阶函数。

声明性 JavaScript 解决方案将是这样的:

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 首先看起来有点奇怪,但很容易理解。

this 将作为第二个参数传给 filter 方法。在这个示例中,3(x)代表 this。

这样的操作也可以用于集合。 想象一下,我们有一个人物集合,包含了 name、 age 属性。

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

我们希望仅过滤指定年龄值的人,在此示例中,年龄超过18岁的人。

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

代码摘要:

  • 我们有一份人员名单(姓名和年龄)。
  • 我们有一个函数 oldThan18。在这种情况下,对于 people 数组中的每个人,我们想要访问年龄并查看它是否超过18岁。
  • 我们根据此功能过滤所有人。

Map

map 的概念是转换一个集合。

map 方法会将集合传入函数,并根据返回的值构建新集合。

让我们使用刚才的 people 集合。我们现在不想过滤年龄了。我们只想得到一个列表,元素就像:TK is 26 years old。所以最后的字符串可能是 :name is:age years old 其中 :name 和 :age 是 people 集合中每个元素的属性。

下面是使用命令式 JavaScript 编码的示例:

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']

下面是使用声明式 JavaScript 编码的示例:

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] 。 -4 的绝对值是 4。

一种简单的解决方案是将每个集合的值进行就地更新 (in-place)。

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]

Wow,鹅妹子嘤!

Reduce

reduce 函数的概念是,接收一个函数和一个集合,然后组合他们来创建返回值。

一个常见的例子是获得订单的总金额。想象一下,你正在一个购物网站购物。你增加了 Product 1,Product 2,Product 3,Product 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 ,我们可以创建一个用来处理累加的函数,并将其作为参数传给 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 = (cart) => cart.reduce(sumAmount, 0);

getTotalAmount(shoppingCart); // 120

这里我们有 shoppingCart,sumAmount函数接收当前的 currentTotalAmount ,对所有订单进行累加。

getTotalAmount 函数会接收 sumAmount 函数 从 0 开始累加购物车的值。

获得总金额的另一种方法是组合使用 map 和 reduce。 那是什么意思? 我们可以使用 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 函数接收产品对象并仅返回金额值。 所以我们这里有 [10,30,20,60] 。 然后,通过 reduce 累加所有金额。Nice~

我们看了每个高阶函数的工作原理。 我想向您展示一个示例,说明如何在一个简单的示例中组合所有三个函数。

还是购物车,想象一下在我们的订单中有一个产品列表:

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

Done!

链接: https://www.fly63.com/article/detial/3173

CSS定位之BFC背后的神奇原理

BFC已经是一个耳听熟闻的词语了,网上有许多关于 BFC 的文章,介绍了如何触发 BFC 以及 BFC 的一些用处(如清浮动,防止 margin 重叠等)。BFC直译为\"块级格式化上下文\"。它是一个独立的渲染区域,只有Block-level box参与

天天都在使用CSS,那么CSS的原理是什么呢?

作为前端,我们每天都在与CSS打交道,那么CSS的原理是什么呢?开篇,我们还是不厌其烦的回顾一下浏览器的渲染过程,学会使用永远都是最基本的标准,但是懂得原理,你才能触类旁通,超越自我。

Angular ZoneJS 原理

如果你阅读过关于Angular 2变化检测的资料,那么你很可能听说过zone。Zone是一个从Dart中引入的特性并被Angular 2内部用来判断是否应该触发变化检测

Vue.js响应式原理

updateComponent在更新渲染组件时,会访问1或多个数据模版插值,当访问数据时,将通过getter拦截器把componentUpdateWatcher作为订阅者添加到多个依赖中,每当其中一个数据有更新,将执行setter函数

new运算符的原理

一个继承自 Foo.prototype 的新对象被创建;使用指定的参数调用构造函数 Foo,并将 this 绑定到新创建的对象。new Foo 等同于 new Foo(),也就是没有指定参数时,Foo 不带任何参数调用的情况

彻底弄懂HTTP缓存机制及原理

Http 缓存机制作为 web 性能优化的重要手段,对于从事 Web 开发的同学们来说,应该是知识体系库中的一个基础环节,同时对于有志成为前端架构师的同学来说是必备的知识技能。

https的基本原理

HTTPS = HTTP + TLS/SSL,简单理解 HTTPS 其实就是在 HTTP 上面加多了一层安全层。HTTP 可以是 Http2.0 也可以是 Http1.1,不过现在 Http2.0 是强制要求使用 Https 的。使用非对称密钥(即公钥私钥))和对称密钥)(即共享密钥)相结合

Node中的Cookie和Session

HTTP是无状态协议。例:打开一个域名的首页,进而打开该域名的其他页面,服务器无法识别访问者。即同一浏览器访问同一网站,每次访问都没有任何关系。Cookie的原理是

理解Promise原理

Promise 必须为以下三种状态之一:等待态(Pending)、执行态(Fulfilled)和拒绝态(Rejected)。一旦Promise 被 resolve 或 reject,不能再迁移至其他任何状态(即状态 immutable)。

小程序底层实现原理及一些思考

当时的我将我们的小程序定位成一个SPA单页应用 ,因为我们的小程序的宿主环境是浏览器。它只是看起来像小程序(因为这个窗口没有地址栏什么的),但其实包括UI渲染和事件交互在内的绝大部分功能都是基于Web技术

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!