- 教程
- 文档
【免费下载链接】30-seconds-of-code
Coding articles to level up your development skills
函数式编程(Functional Programming)是一种把计算看作“数学函数求值”、尽量避免更改状态和可变数据的编程范式。本篇文章以 30 seconds of code 仓库中 functional-programming-introduction.md 一文为主体,结合仓库内 JavaScript 函数式编程集合(functional-programming.yaml)及其系列文章,完整讲解纯函数、不可变性、高阶函数、递归四大核心概念,并延伸覆盖柯里化、函数组合、函数元数、偏应用、记忆化等进阶主题。读完本文,你将能够在自己的 JavaScript 项目中识别并写出更易推理、更易测试、可组合性更强的函数式代码。
什么是函数式编程
函数式编程是一种编程范式(programming paradigm)。它把计算视为数学函数的求值,并尽量避免更改状态与使用可变数据。它强调的是函数的应用,与强调状态变化的命令式(imperative)编程风格形成鲜明对比。
在命令式风格中,你通常会这样写:
let total = 0; for (let i = 0; i < numbers.length; i++) { total += numbers[i]; }代码通过显式地改变total的值来推进计算。而函数式风格更倾向于用数据转换来表达同样的逻辑,例如:
const total = numbers.reduce((acc, n) => acc + n, 0);没有循环中对状态的逐步修改,只有“把数组折叠(reduce)为一个总和”这一纯粹的描述。理解这一思维转变,是掌握函数式编程的第一步。
核心概念之一:纯函数(Pure Functions)
纯函数(pure function)是指满足以下两个条件的函数:
- 给定相同的输入,总是返回相同的输出;
- 不会在函数作用域之外产生任何副作用(side effects)。
换言之,纯函数不会修改任何外部状态或数据,它是函数式编程的基石(building blocks)。原文档给出了最直观的对比示例:
// 纯函数 const concat = (arr, val) => [...arr, val]; // 非纯函数 const push = (arr, val) => arr.push(val);concat通过展开运算符(...)创建并返回一个新数组,原数组保持不变;而push直接原地修改传入的数组并返回其长度,属于典型副作用。
仓库中更详细的讲解见 pure-functions.md,它补充了一个非常实用的判断技巧:如果一个函数“不用它的返回值调用也有意义”,或者“根本不返回任何值”,那它通常就是非纯函数——这两类情况都强烈暗示存在副作用。例如:
// 纯函数 const add = (x, y) => x + y; const order = arr => [...arr].sort((a, b) => a - b); // 先复制再排序 // 非纯函数 const addRandom = x => x + Math.random(); // 相同输入,不同输出 const reorder = arr => arr.sort((a, b) => a - b); // 原地修改输入数组纯函数更可靠、可复用,也因此更易于推理。在约束允许的前提下,应当优先使用纯函数而非非纯函数。
核心概念之二:不可变性(Immutability)
不可变性(immutability)意味着值一旦被创建就不能被更改。这正好与可变(mutable)数据相对——可变数据在创建之后可以被随意修改。不可变性与纯函数紧密相关,是函数式编程的另一核心概念。
原文档的对比示例非常经典:
// 可变数据 let arr = [1, 2, 3]; arr.push(4); // `arr` 现在是 [1, 2, 3, 4] // 不可变数据 const otherArr = [1, 2, 3]; const newArr = [...otherArr, 4]; // `newArr` 是 [1, 2, 3, 4],`otherArr` 仍然是 [1, 2, 3]在不可变风格中,我们不再修改旧数据,而是基于旧数据创建新数据。原数组依然可用,程序的其他部分随时可以继续引用它,这有助于防止意外的连锁影响。
需要特别留意的是,immutability.md 中有一条醒目的NOTE:上面两个示例中的数组在技术上都是可变的,无论用let还是const声明。区别在于第一个示例通过Array.prototype.push()原地修改(mutate)了数组,而第二个示例使用展开运算符创建了新数组、没有修改原数组。也就是说,“不可变”的关键不在声明关键字,而在于不调用会修改原对象的方法、不直接改动已有数据。
[!NOTE]
const只保证变量绑定不能被重新赋值,并不保证对象内容不可变。若需要真正的深层不可变,可参考仓库中的 deep-freeze-object.md 或 immutable-object-proxy.md 等进阶篇目。
核心概念之三:高阶函数(Higher-order Functions)
高阶函数(higher-order function)是满足以下任一条件的函数:
- 接收函数作为参数;
- 返回一个函数。
高阶函数在函数式编程中极为常见,常被用来构造更复杂的函数。原文档的例子展示了“返回函数”的高阶函数:
const isEven = num => num % 2 === 0; // 高阶函数 const inverse = fn => (...args) => !fn(...args); const isOdd = inverse(isEven);inverse接收一个函数fn,返回一个新函数——这个新函数对任意参数调用fn后取反。isOdd就是通过组合inverse与isEven得到的新函数。
之所以 JavaScript 能写高阶函数,是因为函数本身就是值:它们可以被赋值给变量、作为参数传递,也可以作为返回值。被高阶函数“调用”的参数函数通常被称为回调(callback),在事件处理、异步代码和数组操作中无处不在。
仓库的 higher-order-functions.md 用Array.prototype.filter()和Array.prototype.reduce()展示了最贴近日常的用法:
const add = (a, b) => a + b; const isEven = num => num % 2 === 0; const data = [2, 3, 1, 5, 4, 6]; const evenValues = data.filter(isEven); // [2, 4, 6] const evenSum = data.filter(isEven).reduce(add); // 12filter与reduce本身都是高阶函数,它们接收isEven、add作为回调。这样做的好处是:为任何动作建立抽象层,不必为每种过滤或归约逻辑重写算法本身。事实上,30 seconds of code 的大多数代码片段都是围绕高阶函数设计的——它们小而精、高度可复用、易于组合,能够拼装出更复杂的逻辑。
核心概念之四:递归(Recursion)
递归(recursion)是指函数调用自身的技术。它是函数式编程中解决许多问题的强大工具,尤其是在“问题的解依赖于同一个问题更小实例的解”的场景下。
原文档以阶乘为例:
// 递归函数 const factorial = num => { if (num === 0) return 1; return num * factorial(num - 1); };仓库的 recursion.md 进一步给出了斐波那契数列的递归实现,并强调了一个关键点——基线条件(base case):
const fibonacci = n => { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); }; fibonacci(6); // 8基线条件是n <= 1时直接返回n;其余情况会把fibonacci拆成n - 1与n - 2两个子调用,直到触达基线条件才逐层返回结果。如果没有基线条件,函数会无限调用自身,最终导致栈溢出(stack overflow)。
原文档与 recursion.md 也提醒读者:斐波那契这类问题用递归思路清晰,但用迭代往往更高效;反过来,对于“难以识别和索引子问题”的大量问题,递归反而是更自然的选择。递归与迭代的取舍、以及递归性能优化(记忆化、改写迭代)的完整分析,见 recursion-performance-optimization.md,我们将在下文进阶部分展开。
函数式编程的好处
为什么这么多开发者推崇函数式风格?原文档给出的核心论据可以总结为四点:
- 更易于推理(easier to reason about):纯函数没有副作用、不可变数据创建后不会变化,代码的“因果链”变得清晰可见,你只需要关注输入与输出。
- 更易于测试和调试(easier to test and debug):纯函数无需准备复杂的全局状态,给定输入即可断言输出;调试时也不会有“某个外部状态悄悄被改了”的谜题。
- 可复用(reusable):纯函数与数据无耦合,同一函数可以在不同上下文、不同模块中直接复用。
- 易于组合(easily composed):小函数可以像积木一样拼装成更大的程序,复杂系统由一批小而简单的构建块搭建而成。
此外,纯函数天然适合并发与缓存:没有共享可变状态,就没有数据竞争;相同输入必有相同输出,使得记忆化(memoization)成为可能。这些优势将在下面的进阶主题中得到印证。
从入门到实战:继续你的函数式编程之旅
原文档结尾鼓励读者深入阅读其链接的系列文章。该系列正是仓库中journeyId: js/functional-programming这条学习路径,入口集合定义见 functional-programming.yaml,共收录了 10 篇文章。下面按集合中的顺序逐一简介,并给出可直接阅读的仓库路径与核心代码。
柯里化(Currying)
柯里化(currying)把“一个接收多个参数的函数”变换为“一系列各自只接收一个参数的函数”,使代码更灵活、更可复用。仓库的 currying.md 给出两版实现。
针对参数个数固定的函数,可以利用Function.prototype.length获取函数期望的参数个数,再结合递归实现:
const curry = (fn) => { const curried = (...args) => ( args.length >= fn.length ? fn(...args) : (...rest) => curried(...args, ...rest) ); return curried; }; const add = (x, y) => x + y; const curriedAdd = curry(add); curriedAdd(1)(2); // 3对于可变参数(variadic)函数(如Math.min、Math.pow),fn.length不再可靠,需要显式传入元数(arity),并可用Function.prototype.bind()支持“预先提供部分参数”(即偏应用):
const curry = (fn, arity = fn.length, ...args) => arity <= args.length ? fn(...args) : curry.bind(null, fn, arity, ...args); curry(Math.pow)(2)(10); // 1024 curry(Math.min, 3)(10)(50)(2); // 2反向操作uncurry则通过Array.prototype.reduce()逐层调用各柯里化层级,参数不足时抛出RangeError:
const uncurry = (fn, arity = 1) => (...args) => { const next = acc => args => args.reduce((x, y) => x(y), acc); if (arity > args.length) throw new RangeError('Arguments too few!'); return next(fn)(args.slice(0, arity)); };函数组合与管道(Composition & Piping)
函数组合(function composition)是把多个函数组合成一个新函数的过程,主要收益是可读性、可复用性与模块化。仓库的 function-composition.md 指出,与数学一致,JavaScript 中的函数组合按从右到左执行:
const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args))); const digitize = n => [...`${n}`].map(i => parseInt(i)); const add5 = x => x + 5; const multiply = (x, y) => x * y; const composedFn = compose(digitize, add5, multiply); composedFn(5, 2); // [1, 5]如果从右到左不符合直觉,可以创建从左到右的pipe()——实现几乎相同,只需颠倒调用顺序:
const pipe = (...fns) => fns.reduce((f, g) => (...args) => g(f(...args))); const composedFn = pipe(multiply, add5, digitize); composedFn(5, 2); // [1, 5]注意:组合时第一个函数可以是任意元数,其余函数必须是一元函数。异步场景下的组合技巧可继续阅读 async-function-composition.md。
函数元数(Function Arity)
元数(arity)指一个函数期望接收的参数个数,与柯里化、偏应用等技巧配合时非常实用。仓库的 function-arity.md 展示了如何用Function.prototype.length获取元数:
const arity = fn => fn.length; arity(Math.sqrt); // 1 arity(Math.pow); // 2 arity((x, y, z) => x + y + z); // 3 arity((...args) => args); // 0 —— 可变参数函数元数为 0该文还系统讲解了各类元数限定器(arity wrappers):
// 一元函数:只取第一个参数 const unary = fn => val => fn(val); ['6', '8', '10'].map(unary(Number.parseInt)); // [6, 8, 10] // 二元函数:只取前两个参数 const binary = fn => (a, b) => fn(a, b); ['2', '1', '0'].map(binary(Math.max)); // [2, 1, 2] // n 元函数:只取前 n 个参数 const nAry = (fn, n) => (...args) => fn(...args.slice(0, n));以及数组型函数与可变参数函数的互转:
const toVariadic = fn => (...args) => fn(args); const fromVariadic = fn => args => fn(...args); const allPromises = toVariadic(Promise.all.bind(Promise)); let p1 = Promise.resolve(1); let p2 = Promise.resolve(2); allPromises(p1, p2).then(console.log); // LOGS: [1, 2] const arrayMax = fromVariadic(Math.max); arrayMax([1, 2, 3]); // 3unary包装器尤其常用:Array.prototype.map会向回调传入 3 个参数,Number.parseInt的第二参数恰好是radix,直接map(parseInt)会得到错误结果,包一层unary就能规避这个经典陷阱。
偏应用(Partial Application)
偏应用(partial application)是把函数的一部分参数固定下来,从而产生一个元数更小的新函数。与柯里化不同,偏应用一次可以固定任意多个参数,而非严格“每次一个”。
仓库的 partially-apply-function.md 给出两种方向:
// 前置参数 const partial = (fn, ...partials) => (...args) => fn(...partials, ...args); const greet = (greeting, name) => greeting + ' ' + name + '!'; const greetHello = partial(greet, 'Hello'); greetHello('John'); // 'Hello John!' // 追加参数 const partialRight = (fn, ...partials) => (...args) => fn(...args, ...partials); const greetJohn = partialRight(greet, 'John'); greetJohn('Hello'); // 'Hello John!'两种实现都依赖展开运算符:partial把固定参数放在调用参数之前,partialRight则放在之后,适用于固定参数位于签名末端的函数。
记忆化(Memoization)
记忆化(memoization)用缓存保存已完成计算的结果,从而避免重复执行相同工作,显著加速耗时的函数。仓库的 memoization.md 总结了使用记忆化的三条判断标准:
- 函数调用慢、开销大或耗时长,值得缓存;
- 预期在相同条件下多次调用同一函数(记忆化加速的是“后续调用”);
- 结果存储在内存中,因此应避免在“同一函数以差异极大的参数反复调用”的场景使用。
基于Map的经典实现如下:
const memoize = fn => { const cache = new Map(); const cached = function (val) { return cache.has(val) ? cache.get(val) : cache.set(val, fn.call(this, val)) && cache.get(val); }; cached.cache = cache; return cached; }; // 该函数很慢,适合记忆化 const anagrams = str => { if (str.length <= 2) return str.length === 2 ? [str, str[1] + str[0]] : [str]; return str .split('') .reduce( (acc, letter, i) => acc.concat( anagrams(str.slice(0, i) + str.slice(i + 1)).map(val => letter + val) ), [] ); }; const anagramsCached = memoize(anagrams); anagramsCached('javascript'); // 耗时较长 anagramsCached('javascript'); // 命中缓存,几乎瞬间返回Map之所以合适,是因为它以“键值对”保存结果,且会记住键的插入顺序。此外,JavaScript 的Proxy对象可通过handler.apply()陷阱拦截函数调用、透明地加入缓存,是另一种优雅的实现方式(见 memoization.md 与 proxy-introduction.md)。
递归性能优化(Recursion Performance Optimization)
递归代码常因重复计算而低效。仓库的 recursion-performance-optimization.md 以斐波那契为例,先用日志展示朴素递归存在大量重复调用,再给出两种优化方案。
方案一是记忆化——每个n的值只计算一次,其余调用全部命中缓存:
const fibonacciCache = new Map(); const fibonacciNumber = n => { const cacheKey = `${n}`; if (fibonacciCache.has(cacheKey)) { return fibonacciCache.get(cacheKey); } const r = n >= 2 ? fibonacciNumber(n - 1) + fibonacciNumber(n - 2) : n; fibonacciCache.set(cacheKey, r); return r; };方案二是改写为迭代——从小问题迭代到大问题,无缓存、无递归调用,资源占用更少:
const fibonacciNumber = n => { let r = 0, l = 1, s = 0; for (let i = 0; i < n; i++) { r = l; l = s; s = r + l; } return s; };该文的结论对任何递归代码都有普适价值:迭代更适合“使用频率低”的递归计算,而记忆化的缓存可在多次调用之间持续生效,更适合“以不同参数反复调用”的场景。优化前务必先想清楚实际使用场景。
集合中的其他主题
这条学习路径还包含 reorder-function-arguments.md 一篇,讲解如何在不改变函数实现的前提下重排函数参数顺序,常与柯里化、偏应用组合使用,帮助把既有函数的参数顺序调整到组合所需的形式。
在仓库中定位这些文章
上述所有内容都存放在仓库的 content/snippets/js/s/ 目录下。每篇文章是一个带 YAML frontmatter 的 Markdown 文件,例如本文主体 functional-programming-introduction.md 的 frontmatter 包含:
title: An introduction to functional programming language: javascript tags: [function] listed: true journeyId: js/functional-programmingtags用于按主题归类(本文属于function);journeyId把系列文章串联成一条学习路径;- 集合文件 functional-programming.yaml 中的
snippetIds字段按顺序列出该路径下的全部文章 ID,是上述“从入门到实战”顺序的直接来源。
读者可以按照该集合的snippetIds顺序依次阅读,从 functional-programming-introduction.md 入门,逐篇掌握纯函数、不可变性、高阶函数、递归、柯里化、函数组合、元数、偏应用与参数重排,构建完整的 JavaScript 函数式编程知识体系。
小结
函数式编程以数学函数求值为模型,通过纯函数、不可变性、高阶函数与递归四大核心概念,换取更易推理、更易测试、更可复用的代码。30 seconds of code 的这篇入门文章及其所在集合,恰好为 JavaScript 开发者提供了一条从概念到实战的完整路径:先理解四大概念,再进阶到柯里化、函数组合、偏应用、记忆化等工程利器,最终写出可组合、可维护的函数式 JavaScript。
<输出文章>
- 教程
- 文档
【免费下载链接】30-seconds-of-code
Coding articles to level up your development skills
相关推荐
30 seconds of Python函数式编程:不可变数据与纯函数实践
30 seconds of Python函数式编程:不可变数据与纯函数实践 你是否在Python开发中遇到过数据状态混乱、函数副作用难以调试的问题?本文将通过
教程文档clean-code-javascript函数式编程:高阶函数与不可变数据的优势
clean code javascript函数式编程:高阶函数与不可变数据的优势 你是否曾面对这样的困境:修改一处代码却在系统另一角落引发难以预料的bug?或者
文档教程代码质量50projects50days函数式编程:纯函数与不可变性
50projects50days函数式编程:纯函数与不可变性 你还在为JavaScript代码的可维护性发愁吗? 当项目规模从"hello world"膨胀到企
示例工程前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考