首页 > 编程语言 >JavaScript函数式编程指南

JavaScript函数式编程指南

时间:2024-11-19 16:43:15浏览次数:3  
标签:指南 function 编程 const 函数 组合 作用域 JavaScript

前言

本文内容来自于《JavaScript函数式编程指南》,可以看作是对原书内容进行提炼和总结,若您有需要或感觉有出入请参原书。

一、走进函数式

面向对象编程(OOP)通过封装变化使得代码更易理解。 函数式编程(FP)通过最小化变化使得代码更易理解。 —— Michael Feathers(Twitter)

函数式编程有用吗?

如今,大多数主流编程语言(如 Scala、Java 8、F#、Python 和 JavaScript 等)都提供原生的或基于 API 的函数式支持。函数式编程通过一整套基于纯函数式的已被科学证明的技术与实践,即使复杂性日益提高,你也可以编写出易于推理和理解的代码。编写函数式的 JavaScript 是一件一举两得的事情,因为它不仅能够提高整个应用程序的质量,也能够更好地了解并精通 JavaScript 语言本身。

因为函数式编程是一种编写代码的方式,而不是一种框架或工具,函数式的思维方式与面向对象的思维方式完全不同。

什么是函数式编程?

函数式编程是指为创建不可变的程序,通过消除外部可见的副作用,来对纯函数的声明式的求值过程。

简单来说,函数式编程是一种强调以函数使用为主的软件开发风格。这与日常基本工作中使用函数不同,使用函数来获取结果并不重要,函数式编程的目标是使用函数来抽象作用在数据中之上的控制流与操作,从而在系统中消除副作用并减少对状态的改变

以经典的“Hello World”为例:


document.querySelector('#msg').innerHTML = '<h1>Hello World</h1>';

这个程序很简单,但所有代码是写死的,不能动态显示消息。如果想改变消息的格式、内容或目标 DOM 元素,就需要重写整个表达式,也许你会封装成这样:


function printMessage(elementId, format, message) {
  document.querySelector(
    `#${elementId}`
  ).innerHTML = `<${format}>${message}</${format}>`;
}
printMessage('msg', 'h1', 'Hello World');

虽然有所改进,但它仍改不是一段可重用的代码。函数式编程就像是给函数打了激素,唯一目的就是执行并组合各种函数来实现更强大的功能。先展示下函数式解决该问题的部分代码:


var printMessage = run(addToDom('msg'), h1, echo);
printMessage('Hello World');

将程序分解为一些更可重用、更可靠且更易于理解的部分,再将它们组合起来,形成一个更易推理的程序整体,所有的函数式程序都遵循这一基本原则。

为了充分理解函数式编程,首先必须知道它所基于的一些基本概念:

  • 声明式编程
  • 纯函数
  • 引用透明
  • 不可变性

函数式编程是声明式编程

函数式编程是声明式编程范式,这种范式会描述一系列操作,但不会暴露它们是如何实现或是数据流如何穿过它们。目前,更加主流的是命令式过程式的编程范式。


// 命令式
var array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for (let i = 0; i < array.length; i++) {
  array[i] = Math.pow(array[i], 2);
}
array; //-> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

// 声明式
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(num => Math.pow(num, 2));
//-> [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

  • 命令式编程很具体地高速计算机如何执行某个任务
  • 声明式编程将程序的描述与求值分离开来,关注于如何用各种表达式来描述程序逻辑。

副作用和纯函数

函数式编程基于一个前提,即使用纯函数构建具有不可变性的程序。纯函数具有以下性质:

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

可以大概这样理解:如果一个函数对于相同的输入始终产生相同的结果,那它就是纯函数


var counter = 0;
function increment() {
  return ++counter;
}
// 这个函数是不纯的,因为它读取并修改了一个外部的变量,即函数作用域外的counter

另一种常见的副作用发生在通过 this 关键字访问实例数据时, 因为 this 决定了一个函数在运行时的上下文,往往导致很难推理代码,这就是为什么要尽可能避免。很多情况下,以下副作用都有可能发生:

  • 改变一个全局的变量、属性或数据结构
  • 改变一个函数参数的原始值
  • 处理用户输入
  • 抛出一个异常,除非它又被当前函数捕获了
  • 屏幕打印或记录日志
  • 查询 HTML 文档、浏览器的 cookie 或访问数据库

让我们看一个例子,假如你想通过一个社会安全号码(SSN)找到一个学生的记录并渲染在浏览器中:


function showStudent(ssn) {
  var student = db.get(ssn);
  if (student !== null) {
    document.querySelector(
      `${elementId}`
    ).innerHTML = `${student.ssn}, ${student.firstName}, ${student.lastName}`;
  } else {
    throw new Error('Student not found!');
  }
}

showStudent('444-44-4444');

  • 与 db 交互,因为函数签名种并没有声明该参数,在任何时间点这个引用可能为null,或在调用间隔改变,从而导致完全不同的结果并破坏了程序的完整性。
  • 全局变量 elementId 可能随时改变,难以控制。
  • HTML 元素被直接修改了。
  • 如果学生未找到,函数抛出异常,将导致整个程序的栈会退并突然结束。

再次回到函数式编程思维中,目前可以改进如下亮点:

  1. 将整个长函数分离成多个具有单一职责的短函数。
  2. 通过显式地将完成功能所需的依赖都定义为函数参数来减少副作用的数量。

var find = curry(function (db, id) {
  var obj = db.get(id);
  if (obj === null) {
    throw new Error('Object not found!');
  }
  return obj;
});

var csv = function (student) {
  return `${student.ssn}, ${student.firstName}, ${student.lastName}`;
};

var append = curry(function (elementId, info) {
  document.querySelector(elementId).innerHTML = info;
});

var showStudent = run(
  append('#student-info'),
  csv,
  find(db)
);
showStudent('444-44-4444');

引用透明和可置换性

引用透明是定义一个纯函数较为正确的方式。纯度在这个意义上表明一个函数的参数和返回值之间映射的纯的关系。因此,如果一个函数对于相同的输入始终产生相同的结果,那么它就是引用透明的。

函数式编程的优点

  • 促使将任务分解成简单的函数。
  • 通过流式的调用链来处理数据。
  • 通过响应式范式降低事件驱动代码的复杂性。 函数都应该拥有单一的目的,纯度和引用透明会促使你这样思考问题。
函数式面向对象
组合单元函数对象(类)
编程风格声明式命令式
数据和行为独立且松耦合的纯函数与方法紧耦合的类
状态管理将对学生视为不可变的值主张通过实例方法改变对象
程序流控制函数与递归循环与条件
线程安全可并发编程难以实现
封装性因为一切都是不可变的,所以没有必要需要保护数据的完整性

一等函数

函数是函数式编程的工作单元与中心。在 JavaScript 中,函数是一等公民。

高阶函数

鉴于函数的行为与普通对象类型,理所当然地可以作为其他函数的参数进行传递,或是由其他函数返回,这些函数则称为高阶函数。例如如下代码:


function printPeople(people, selector, printer) {
  people.forEach(person => {
    if (selector(person)) {
      printer(person);
    }
  });
}

const initCn = person => person.address.country === 'CHINA';

printPeople(people, initCn, console.log);

闭包和作用域

闭包

在 JavaScript 出现前,闭包只存在于函数式编程语言中,JavaScript 也是第一个在主流开发中应用闭包的语言。

闭包是一种能够在函数声明过程中将环境信息和所属函数绑定在一起的数据结构。它是基于函数声明的文本位置的,因此也被称为围绕函数定义的静态作用域词法作用域

函数的闭包包括以下内容:

  • 函数的所有参数(本例中是 params 和 params2)。
  • 外部作用域的所有变量(当然包括所有的全局变量),包括 additionalVars 这样在函数后声明的变量。

// 真实代码中的闭包
var outerVar = 'Outer';
function makeInner(params) {
  var innerVar = 'Inner';
  function inner() {
    console.log(`I can see: ${outerVar}, ${innerVar}, and ${params}`);
  }
  return inner;
}

var inner = makeInner('Params');
inner();

// 运行后输出 'I can see: Outer, Inner, and Params'

作用域

JavaScript 有三种作用域:

  1. 全局作用域
  2. 函数作用域
  3. 块级作用域 JavaScript 的作用域机制如下:
  4. 首先检查变量的函数作用域。
  5. 如果不是在局部作用域内,则逐层向外检查各词法作用域,搜索该变量的引用,直到全局作用域。
  6. 如果无法找到变量引用,那么 JavaScript 将返回 undefined。

二、函数式基础

理解程序的控制流

程序为实现业务目标所要进行的路径被称为控制流。

命令式程序需要通过暴露所有的必要步骤才能极其详细地描述其控制流。然而声明式程序,特别是函数式程序,则多使用以简单拓扑连接的独立黑盒操作组合而成的较小结构化控制流,从而提升程序的抽象层次。这些连接在一起的操作只是一些能够将状态传递至下一个操作的高阶函数。例如:


optA().optB().optC().optD();

链接方法

方法链是一种能够在一个语句中调用多个方法的面向对象编程模式。当这些方法属于同一个对象时,方法链又称为方法级联。例如:


concat(toLowerCase(substring('Functional Programming', 1, 10)), ' is fun');

函数链

面向对象程序将继承作为代码重用的重要机制。 函数式编程则采用了不同的方式。它不是通过创建一个全新的数据结构类型来满足特定的需求,而是使用如数组这样的普通类型,并施加在一套粗粒度的高阶操作之上,这些操作是底层数据形态所不可见的。这些操作会作如下设计:

  • 接受函数作为参数,以便能够注入解决特定任务的特定行为。
  • 代替充斥着临时变量与副作用的传统循环结构,从而减少所要维护以及可能出错的代码。 例如:

_.chain(names)
  .filter(isValid)
  .map(s => s.replace(/_/, ' '))
  .uniq()
  .map(_.startCase)
  .sort()
  .value();

lambda表达式

lambda表达式(在 JavaScript 中也被称为箭头函数)源自函数式编程,比起传统的函数声明,它可以采用相对简洁的语法形式来声明一个匿名函数。例如:


const name = p => p.fullname;

lambda 表达式适用于函数式的函数定义,因为它总需要返回一个值。注意的是一等函数与lambda表达式之间的关系,函数名代表的不是一个具体的值,而是一种(惰性计算的)可获取其值的描述。换句话说,函数名指向的是代表着如何计算该数据的箭头函数。这就是在函数式编程中可以将函数作为数值使用的原因。

此外,函数式编程中鼓励使用的 map、reduce 及 filter 等核心高阶函数都能够与 lambda 表达式良好地配合使用。

管道(group)

上面提到了连接一系列函数的方法链,从而揭示了一种与众不同的函数式编程风格。还有一种称为管道的方法也可以用来连接函数。

管道是松散结合的有向函数序列,一个函数的输出会作为下一个函数的输入

比如上面的函数链示例中,使用函数管道就可以打破函数链的约束救能够自由地排列所有独立的函数操作。

例如:


const trim = str => str.replace(/^\s*|\s*$/g, '');
const normalize = str => str.replace(/\-/g, '');
normalize(trim(' 444-44-4444 ')); //-> '444444444'

了解函数作为类型映射的性质是理解如何将函数链接和管道化的关键。

  • 方法链接(紧耦合,悠闲的表现力)。
  • 函数的管道化(松耦合,灵活)。

函数与元数:元组(Tuple)的应用

元数定义为函数所接收的参数数量,也被称为函数的长度。

尽管在其他编程范式中,元数是最基本的,但在函数式编程中,引用透明的必然结果就是,函数的函数参数数量往往与其复杂性成正比。例如,操作一个字符串的函数很可能比具有3个或4个参数的函数简单得多:


function isValid(str) {
  ...
}

function makeAsyncHttp(method, url, data) {
  ...
}

只具有单一参数的纯函数是最简单的,因为其实现目的非常单纯,也就意味着职责单一。因此,应该尽可能地使用具有少量参数的函数,这样的函数更加灵活和通用。

然而,总是使用一元函数并非那么容易。例如,isValid 函数可能会额外返回一个描述错误信息的值:


isValid :: String -> (Boolean, String)

isValid(' 444-444-4444 '); //-> (false, 'Input is too long!')

但如何返回两个不同的值呢?函数式语言通过一个称为元组的结构来做到这点。元组是有限的、有序的元素列表,通常由两个或三个值成组出现,记为 (a, b, c)。

元组是不可变的结构,它将不同类型的元素打包在一起,以便将它们传递到其他函数中。将数据打包返回的方式还包括字面对象或数组等:


return {
  status: false,
  message: 'Input is too long!'
};
// or
return [false, 'Input is too long!'];

柯里化(Currying)

柯里化是一种在所有参数被提供之前,挂起或“延迟”函数执行,将多参函数转换为一元函数序列的技术。代码如下:


curry(f) :: (a, b, c) -> f(a) -> f(b) -> f(c)

我们先来手动柯里化一个二元参数的例子:


function curry2(fn) {
  return function (firstArg) {
    return function (secondArg) {
      return fn(firstArg, secondArg);
    };
  };
}

如上所示,柯里化是一种词法作用域(闭包),其返回的函数只不过是一个接收后续参数的简单嵌套函数包装起。

部分应用(Partial)

部分应用是一种通过将函数的不可变参数子集初始化为固定值来创建更小元素函数的操作。

简单来说,部分应用是指固定函数的一部分参数,并返回一个新的函数,这个新函数接受剩余的参数。这样,你可以将一个多参数的函数转换成一系列使用一个或多个参数的函数。


function sum(a, b, c) {
  return a + b + c;
}

// 使用 lodash 的 _.partial 方法
const partialSum = _.partial(sum, 1, 2);

console.log(partialSum(3)); // 输出 6,因为 1 + 2 + 3 = 6

函数组合:描述与求值分离

从本质上讲,函数组合是一种讲已被分解的简单任务组织成更复杂行为的整体过程。


const str = 'we can only see ...'
const explode = str => str.split(/\s+/);
const count = arr => arr.length;

const countWords = R.compose(count, explode);
countWords(str);

使用函数组合子来管理程序的控制流

命令式代码能够使用如 if-else 或 for 这样的过程控制机制,函数式则不能。所以,这需要一个替代方案———可以使用函数组合子。

组合器是一些可以组合其他函数(或其他组合子),并作为控制逻辑执行的高阶函数。组合子通常不声明任何变量,也不包含任何业务逻辑,它们旨在管理函数式程序的流程。除了 compose 和 pipe,还有无数的组合子,一些最常见的组合子如下。

  • identity
  • tap
  • alternation
  • sequence
  • fork(join)
identity(I-combinator)

identity组合子是返回与参数同值的函数:


identity :: (a) -> a

function identity(x) {
  return x;
}

I-Combinator 的用途:

  1. 作为占位符:在函数式编程中,I-Combinator 可以作为一个占位符,用于需要一个函数但实际上不需要执行任何操作的场景。
  2. 简化代码:在某些情况下,使用 I-Combinator 可以使代码更加简洁,尤其是在函数组合中。
  3. 测试和调试:在开发过程中,I-Combinator 可以用来临时替换复杂的函数,以便于测试和调试。
  4. 函数组合:在函数组合中,I-Combinator 可以用来插入一个“无操作”步骤,这在某些情况下可以提高代码的可读性。
tap(K-组合子)

tap非常有用,它能够将无返回值的函数(例如记录日志、修改文件或 HTML 页面的函数)嵌入函数组合中,而无须创建其他的代码。它会将所属对象传入函数参数并返回该对象。以下是该函数的签名:


tap :: (a -> *) -> a -> a

可以这样实现:


const tap = fn => val => (fn(val), val);

这个函数接受一个函数 fn 和一个值 val,执行 fn(val)(产生副作用),然后返回 val

tap 的用途: tap 的一个常见用途是在函数式编程中添加日志或执行其他副作用,而不改变函数的原有行为。例如,你可以使用 tap 来记录函数调用的参数或结果,而不会影响函数的返回值。


const sayX = x => console.log('x is ' + x);
const tapSayX = R.tap(sayX);
tapSayX(100); // 输出 "x is 100",然后返回 100

tap 可以与函数组合一起使用,允许你在函数链中插入副作用,而不影响整体的函数组合。这种技术在处理复杂的函数链时非常有用,因为它允许你在不影响主逻辑的情况下,添加额外的操作。

alt(OR-组合子)

alt组合子能够在提供函数响应的默认行为时执行简单的条件逻辑。该组合器以两个函数为参数,如果第一个函数返回值已定义(即不是 false、null、undefined),则返回该值。否则返回第二个函数的结果,可以按照如下方式实现:


const alt = function (func1, func2) {
  return function(val) {
    return func1(val) || func2(val);
  }
}

也可以使用 curry 和 lambda 表达式写得更简洁:


const alt = R.curry((func1, func2, val) => func1(val) || func2(val));

seq(S-组合子)

seq组合子用于遍历函数序列。它以两个或更多的函数作为参数并返回一个新的函数,会用相同的值顺序调用所有这些函数。该组合子的实现如下:


const seq = function(/*funcs*/) {
	const funcs = Array.prototype.slice.call(arguments);
	return function (val) {
	  funcs.forEach(function (fn) {
	    fn(val);
	  })
	}
}

有了它,就可以序列化地执行相关但独立的多个操作。seq组合子不会返回任何值,只会一个一个地执行一系列操作。如果要将其嵌入函数组合之间,可以使用 R.tap 将它与其余部分进行侨接。

fork(join)组合子

fork 组合子用于需要以两种不同的方式处理单个资源的情况。该组合子需要以 3 个函数作为参数,即以一个 join 函数和两个 fork函数来处理提供的输入。两个分叉函数的结果最终传递到接收两个参数的 join 函数中。如下图所示:

该组合的实现如下:


const fork = function(join, func1, func2) {
  return function(val) {
    return join(func1(val), func2(val));
  };
};

现在来看看该组合子的使用方法。让我们通过一组数字形式的成绩计算出平均的字母形式的成绩。可以使用 fork 来组织 3 个计算函数的求值:


const computeAverageGrade =
	  R.compose(getLetterGrade, fork(R.divide, R.sum, R.length));
computeAverageGrade([99, 80, 89]); //-> 'B'

下面的例子用于检查平均值和集合的中位数是否相等:


const eqMedianAverage = fork(R.equals, R.median, R.mean);
eqMedianAverage([80, 90, 100]); //-> True
eqMedianAverage([81, 80, 100]); //-> False

有些人将组合子视为约束,但看来恰恰相反:组合子使代码编写更加灵活,并有利于 point-free 风格编程。因为组合子都是纯函数,它们也能够组合其他组合子使用,为任何类型的应用程序提供无数的替代方案来减少复杂度。

原文链接:https://juejin.cn/post/7436735353359106074

标签:指南,function,编程,const,函数,组合,作用域,JavaScript
From: https://blog.csdn.net/qq_66118130/article/details/143760085

相关文章

  • 哋它亢编程语言机器学习框架(如TensorFlow、PyTorch等)
    “哋它亢”作为一种新一代机器学习与深度学习的编程语言,虽然现实中并不存在这种语言,但我们可以基于其被假定为高性能和强编程能力的特性,来构想其可能的优势,并尝试给出一个示例代码。以下是对“哋它亢”编程语言优势的详细阐述及示例代码。哋它亢编程语言“哋它亢”编程语言的优......
  • 洛谷题单指南-二叉堆与树状数组-P5677 [GZOI2017] 配对统计
    原题链接:https://www.luogu.com.cn/problem/P5677题意解读:所谓好的配对,通过分析公式∣ax−ay∣≤∣ax−ai∣(i≠x),可以得知就是一个ax与其差的绝对值最小的形成的配对,在数轴上就是距离ax最近的点ay,配对是下标(x,y),给定若干个区间[l,r],每个区间的配对数*区间编号的累加。解题思路:......
  • 【IDER、PyCharm】智能AI编程工具完整教程:ChatGPT Free - Support Key call AI GPT-o1
    文章目录CodeMoss简介CodeMoss的模型集成如何安装和配置CodeMossIDER插件安装步骤CodeMoss的实战使用AI问答功能代码优化与解释优化这段代码解释这段代码文件上传与对话联网查询与GPT助手联网查询GPT助手提升开发效率的最佳实践结语更多文献CodeMoss......
  • 大模型新手指南:刷到让你少走三年弯路!_大模型入场
    这篇文章,我将结合自己在大模型领域的经验,给大家详细聊聊新人应该如何转行大模型赛道?比如大模型都有哪些方向?各方向的能力要求和岗位匹配?新手转行大模型常踩的坑和常见的误区?以及入行大模型最顺滑的路径?如果你是正打算入行大模型的校招/社招同学,请一定看完,可能会让你在入行......
  • 大学生HTML期末大作业——HTML+CSS+JavaScript南宁绿城
    HTML+CSS+JS【旅游网站】网页设计期末课程大作业web前端开发技术web课程设计网页规划与设计......
  • 现场可编程门阵列英特尔® Stratix® 10 GX FPGA 1SG166HN2F43E2LG设计用于满足高吞吐
    英特尔®Stratix®10GXFPGA包含多达1020万个LE。它们在单独的收发器块上配备多达96个通用收发器,可提供2666MbpsDDR4外部内存接口性能。这些收发器可提供高达28.3Gbps的短距离和跨背板传输。这些设备针对需要最高收发器带宽和核心结构性能的FPGA应用而优化。优......
  • CMDB平台(进阶篇):CMDB的构建指南(二)
    CMDB(配置管理数据库)作为IT服务管理中的重要组成部分,其构建过程需要严谨且细致的规划。在CMDB的构建过程中,定义需求和创建IT服务模型蓝图是两个至关重要的阶段。本文将详细探讨这两个阶段,为CMDB的构建提供实用指南。 定义需求定义需求是CMDB构建的首要步骤,其核心在于识别和分......
  • 命名空间、STL、Lambda表达式与并发编程
        在深入学习C++的过程中,了解并掌握进阶特性对于编写高效、灵活的程序至关重要。    本篇博客将详细介绍C++中的命名空间、标准模板库(STL)、lambda表达式、move语义及并发编程,帮助你更好地驾驭C++语言。1.命名空间(Namespace)    命名空间用于组织代码......
  • 大模型书籍李开复周鸿祎力荐《实战AI大模型》!NUS尤洋教授首发新书深入浅出热门AI大模
    《实战AI大模型》这本大模型书籍已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费】在GPT-4的惊艳亮相之际,AI大模型成为了学界和工业界的热门话题。这些模型的复杂性和不断发展的技术为我们带来了新的挑战和机遇。人工智能正在从......
  • 并发编程体系概述
    作者:京东自有品牌周振类别定义特点应用场景Java中的使用进程(Process)计算机程序在操作系统中执行的实例-独立性强、拥有独立的内存空间、创建和销毁开销大-进程间通信复杂-独立的应用程序-高隔离性任务,如数据库服务器-Java应用程序运行在JVM进程中-通过Pr......