Home > Article > Web Front-end > Introduction to Functional Programming in JavaScript
Many parts of this article explain the advantages of functional programming. Personally, the author recognizes that functional programming has certain benefits, but does not advocate thorough functional programming, especially for the development of complex application logic.
JavaScript Functional Programming
In recent years, functions Functional Programming (Functional Programming) has become one of the hottest topics in the JavaScript community. Whether you appreciate this programming concept or not, I believe you already know something about it. Even when functional programming was not yet popular a few years ago, I have discovered many in-depth practices of functional programming concepts in many large application code bases. Functional programming means avoiding the use of shared state (Shared State), mutable state (Mutable Data), and side effects (Side Effects) in software development projects. In functional programming, the entire application is driven by data, and the state of the application flows between different pure functions. Compared with object-oriented programming, which prefers imperative programming, functional programming prefers declarative programming. The code is simpler, clearer, more predictable, and more testable. . Functional programming is essentially a programming paradigm (Programming Paradigm), which represents a series of basic definition principles for building software systems. Other programming paradigms include Object Oriented Programming and Procedural Programming.
Pure function
As the name suggests, pure function Functions often refer to functions that determine output based only on input parameters and do not produce any side effects. One of the best features of pure functions is the predictability of their results:
var z = 10; function add(x, y) { return x + y; } console.log(add(1, 2)); // prints 3 console.log(add(1, 2)); // still prints 3 console.log(add(1, 2)); // WILL ALWAYS print 3
The z variable is not manipulated in the add function, that is, the value of z is not read or modified. It simply takes the x and y variables entered as parameters and returns the sum of the two. This add function is a typical pure function, and if the add function involves reading or modifying the z variable, then it loses its purity. Let's look at another function:
function justTen() { return 10; }
For a function without any input parameters, if it is to remain a pure function, the return value of the function must be a constant. However, a function like this that returns a fixed constant might as well be defined as a certain constant. There is no need to overuse the function. Therefore, we can think that most useful pure functions allow at least one input parameter. Look at the following function again:
function addNoReturn(x, y) { var z = x + y }
Note that this function does not return any value. It does have two input parameters x and y, and then adds the two variables and assigns them to z, so a function like this It can also be considered meaningless. Here we can say that most useful pure functions must have a return value. In summary, pure functions should have the following special effects:
●Most pure functions should have one or more parameter values.
●Pure functions must have a return value.
●The return values of pure functions with the same input must be consistent.
●Pure functions cannot produce any side effects.
Shared State and Side Effects
Shared State can Is any variable, object, or memory space that exists in a shared scope (global scope and closure scope) or as an object property passed to a different scope. In object-oriented programming, we often share an object by adding properties to other objects. The problem with shared state is that if developers want to understand the role of a function, they must understand in detail the impact that function may have on each shared variable. For example, if we now need to save the user object generated by the client to the server, we can use the saveUser() function to initiate a request to the server, pass the user information encoding and wait for the server to respond. At the same time that you initiated the request, the user modified the personal avatar, triggering another function updateAvatar() and another saveUser() request. Normally, the server will respond to the first request first, and make corresponding modifications to the user information stored in memory or database based on the changes in user parameters in the second request. However, in some unexpected circumstances, the second request may arrive at the server before the first request, so that the new avatar selected by the user will be overwritten by the old avatar in the first request. The user information stored on the server here is the so-called shared state, and the data consistency disorder caused by multiple concurrent requests is the so-called race condition, which is also one of the typical problems caused by shared state. Another common problem with shared state is that different calling orders may trigger unknown errors, because operations on shared state are often timing dependent.
const x = { val: 2 }; const x1 = () => x.val += 1; const x2 = () => x.val *= 2; x1(); x2(); console.log(x.val); // 6 const y = { val: 2 }; const y1 = () => y.val += 1; const y2 = () => y.val *= 2; // 交换了函数调用顺序 y2(); y1(); // 最后的结果也受到了影响 console.log(y.val); // 5
Side effects refer to any observable application state changes that are not reflected by the return value during the function call. Common side effects include but are not limited to:
●Modify any external variables or external Object properties
●Output log in console
●写入文件
●发起网络通信
●触发任何外部进程事件
●调用任何其他具有副作用的函数
在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。Haskell或者其他函数式编程语言通常会使用Monads来隔离与封装副作用。在绝大部分真实的应用场景进行编程开始时,我们不可能保证系统中的全部函数都是纯函数,但是我们应该尽可能地增加纯函数的数目并且将有副作用的部分与纯函数剥离开来,特别是将业务逻辑抽象为纯函数,来保证软件更易于扩展、重构、调试、测试与维护。这也是很多前端框架鼓励开发者将用户的状态管理与组件渲染相隔离,构建松耦合模块的原因。
不变性
不可变对象(Immutable Object)指那些创建之后无法再被修改的对象,与之相对的可变对象(Mutable Object)指那些创建之后仍然可以被修改的对象。不可变性(Immutability)是函数式编程的核心思想之一,保证了程序运行中数据流的无损性。如果我们忽略或者抛弃了状态变化的历史,那么我们很难去捕获或者复现一些奇怪的小概率问题。使用不可变对象的优势在于你在程序的任何地方访问任何的变量,你都只有只读权限,也就意味着我们不用再担心意外的非法修改的情况。另一方面,特别是在多线程编程中,每个线程访问的变量都是常量,因此能从根本上保证线程的安全性。总结而言,不可变对象能够帮助我们构建简单而更加安全的代码。
在JavaScript中,我们需要搞清楚const与不可变性之间的区别。const声明的变量名会绑定到某个内存空间而不可以被二次分配,其并没有创建真正的不可变对象。你可以不修改变量的指向,但是可以修改该对象的某个属性值,因此const创建的还是可变对象。JavaScript中最方便的创建不可变对象的方法就是调用Object.freeze()函数,其可以创建一层不可变对象
const a = Object.freeze({ foo: 'Hello', bar: 'world', baz: '!' }); a.foo = 'Goodbye'; // Error: Cannot assign to read only property 'foo' of object Object
不过这种对象并不是彻底的不可变数据,譬如如下的对象就是可变的:
const a = Object.freeze({ foo: { greeting: 'Hello' }, bar: 'world', baz: '!' }); a.foo.greeting = 'Goodbye'; console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);
如上所见,顶层的基础类型属性是不可以改变的,不过如果对象类型的属性,譬如数组等,仍然是可以变化的。在很多函数式编程语言中,会提供特殊的不可变数据结构Trie Data Structures来实现真正的不可变数据结构,任何层次的属性都不可以被改变。Tries还可以利用结构共享(Structural Sharing)的方式来在新旧对象之间共享未改变的对象属性值,从而减少内存占用并且显著提升某些操作的性能。JavaScript中虽然语言本身并没有提供给我们这个特性,但是可以通过Immutable.js与Mori这些辅助库来利用Tries的特性。我个人两个库都使用过,不过在大型项目中会更倾向于使用Immutable.js。估计到这边,很多习惯了命令式编程的同学都会大吼一句:在没有变量的世界里我又该如何编程呢?不要担心,现在我们考虑下我们何时需要去修改变量值:譬如修改某个对象的属性值,或者在循环中修改某个循环计数器的值。而函数式编程中与直接修改原变量值相对应的就是创建原值的一个副本并且将其修改之后赋予给变量。而对于另一个常见的循环场景,譬如我们所熟知的for,while,do,repeat这些关键字,我们在函数式编程中可以使用递归来实现原本的循环需求:
// 简单的循环构造 var acc = 0; for (var i = 1; i <= 10; ++i) acc += i; console.log(acc); // prints 55 // 递归方式实现 function sumRange(start, end, acc) { if (start > end) return acc; return sumRange(start + 1, end, acc + start) } console.log(sumRange(1, 10, 0)); // prints 55
注意在递归中,与变量i相对应的即是start变量,每次将该值加1,并且将acc+start作为当前和值传递给下一轮递归操作。在递归中,并没有修改任何的旧的变量值,而是根据旧值计算出新值并且进行返回。不过如果真的让你把所有的迭代全部转变成递归写法,估计得疯掉,这个不可避免地会受到JavaScript语言本身的混乱性所影响,并且迭代式的思维也不是那么容易理解的。而在Elm这种专门面向函数式编程的语言中,语法会简化很多:
sumRange start end acc = if start > end then acc else sumRange (start + 1) end (acc + start)
其每一次的迭代记录如下:
sumRange 1 10 0 = -- sumRange (1 + 1) 10 (0 + 1) sumRange 2 10 1 = -- sumRange (2 + 1) 10 (1 + 2) sumRange 3 10 3 = -- sumRange (3 + 1) 10 (3 + 3) sumRange 4 10 6 = -- sumRange (4 + 1) 10 (6 + 4) sumRange 5 10 10 = -- sumRange (5 + 1) 10 (10 + 5) sumRange 6 10 15 = -- sumRange (6 + 1) 10 (15 + 6) sumRange 7 10 21 = -- sumRange (7 + 1) 10 (21 + 7) sumRange 8 10 28 = -- sumRange (8 + 1) 10 (28 + 8) sumRange 9 10 36 = -- sumRange (9 + 1) 10 (36 + 9) sumRange 10 10 45 = -- sumRange (10 + 1) 10 (45 + 10) sumRange 11 10 55 = -- 11 > 10 => 55 55
高阶函数
函数式编程倾向于重用一系列公共的纯函数来处理数据,而面向对象编程则是将方法与数据封装到对象内。这些被封装起来的方法复用性不强,只能作用于某些类型的数据,往往只能处理所属对象的实例这种数据类型。而函数式编程中,任何类型的数据则是被一视同仁,譬如map()函数允许开发者传入函数参数,保证其能够作用于对象、字符串、数字,以及任何其他类型。JavaScript中函数同样是一等公民,即我们可以像其他类型一样处理函数,将其赋予变量、传递给其他函数或者作为函数返回值。而高阶函数(Higher Order Function)则是能够接受函数作为参数,能够返回某个函数作为返回值的函数。高阶函数经常用在如下场景:
●利用回调函数、Promise或者Monad来抽象或者隔离动作、作用以及任何的异步控制流
●构建能够作用于泛数据类型的工具函数
●函数重用或者创建柯里函数
●将输入的多个函数并且返回这些函数复合而来的复合函数
典型的高阶函数的应用就是复合函数,作为开发者,我们天性不希望一遍一遍地重复构建、测试与部分相同的代码,我们一直在寻找合适的只需要写一遍代码的方法以及如何将其重用于其他模块。代码重用听上去非常诱人,不过其在很多情况下是难以实现的。如果你编写过于偏向具体业务的代码,那么就会难以重用。而如果你把每一段代码都编写的过于泛化,那么你就很难将这些代码应用于具体的有业务场景,而需要编写额外的连接代码。而我们真正追寻的就是在具体与泛化之间寻求一个平衡点,能够方便地编写短小精悍而可复用的代码片,并且能够将这些小的代码片快速组合而解决复杂的功能需求。在函数式编程中,函数就是我们能够面向的最基础代码块,而在函数式编程中,对于基础块的组合就是所谓的函数复合(Function Composition)。我们以如下两个简单的JavaScript函数为例:
var add10 = function(value) { return value + 10; }; var mult5 = function(value) { return value * 5; };
如果你习惯了使用ES6,那么可以用Arrow Function重构上述代码:
var add10 = value => value + 10; var mult5 = value => value * 5;
现在看上去清爽多了吧,下面我们考虑面对一个新的函数需求,我们需要构建一个函数,首先将输入参数加10然后乘以5,我们可以创建一个新函数如下:
var mult5AfterAdd10 = value => 5 * (value + 10)
尽管上面这个函数也很简单,我们还是要避免任何函数都从零开始写,这样也会让我们做很多重复性的工作。我们可以基于上文的add10与mult5这两个函数来构建新的函数:
var mult5AfterAdd10 = value => mult5(add10(value));
在mult5AfterAdd10函数中,我们已经站在了add10与mult5这两个函数的基础上,不过我们可以用更优雅的方式来实现这个需求。在数学中,我们认为f ∘ g是所谓的Function Composition,因此`f ∘ g可以认为等价于f(g(x)),我们同样可以基于这种思想重构上面的mult5AfterAdd10。不过JavaScript中并没有原生的Function Composition支持,在Elm中我们可以用如下写法:
add10 value = value + 10 mult5 value = value * 5 mult5AfterAdd10 value = (mult5 << add10) value
这里的<<操作符也就指明了在Elm中是如何组合函数的,同时也较为直观的展示出了数据的流向。首先value会被赋予给add10,然后add10的结果会流向mult5。另一个需要注意的是,(mult5 << add10)中的中括号是为了保证函数组合会在函数调用之前。你也可以组合更多的函数:
f x = (g << h << s << r << t) x
如果在JavaScript中,你可能需要以如下的递归调用来实现该功能:
g(h(s(r(t(x)))))