이 기사의 많은 부분에서는 함수형 프로그래밍의 장점을 설명합니다. 개인적으로 저자는 함수형 프로그래밍에 특정 이점이 있다는 것을 인식하지만 특히 복잡한 애플리케이션 논리 개발의 경우 완전한 함수형 프로그래밍을 권장하지 않습니다.
JavaScript 함수형 프로그래밍
최근에는 함수 함수형 프로그래밍은 JavaScript 커뮤니티에서 가장 인기 있는 주제 중 하나가 되었습니다. 이 프로그래밍 개념을 이해하든 그렇지 않든 간에 이미 이에 대해 알고 계시리라 믿습니다. 몇 년 전 함수형 프로그래밍이 아직 대중적이지 않았을 때에도 저는 많은 대규모 애플리케이션 코드 베이스에서 함수형 프로그래밍 개념에 대한 심층적인 사례를 많이 발견했습니다. 함수형 프로그래밍은 소프트웨어 개발 프로젝트에서 공유 상태(Shared State), 변경 가능한 상태(Mutable Data) 및 부작용(Side Effects)의 사용을 피하는 것을 의미합니다. 함수형 프로그래밍에서 전체 애플리케이션은 데이터에 의해 구동되며 애플리케이션의 상태는 서로 다른 순수 함수 간에 흐릅니다. 명령형 프로그래밍을 선호하는 객체 지향 프로그래밍과 비교하여 함수형 프로그래밍은 코드가 더 간단하고 명확하며 예측 가능하고 테스트 가능합니다. . 함수형 프로그래밍은 본질적으로 프로그래밍 패러다임(Programming Paradigm)으로, 소프트웨어 시스템 구축을 위한 일련의 기본 정의 원칙을 나타냅니다. 다른 프로그래밍 패러다임에는 객체 지향 프로그래밍과 절차적 프로그래밍이 포함됩니다.
순수기능
이름 그대로 제안하자면, 순수 함수 함수는 입력 매개변수에만 기초하여 출력을 결정하고 부작용을 일으키지 않는 함수를 의미하는 경우가 많습니다. 순수 함수의 가장 좋은 특징 중 하나는 결과의 예측 가능성입니다.
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
는 add 함수에서 z 변수를 연산하지 않습니다. 즉, z 값을 읽거나 z 값을 수정하지 않습니다. . 단순히 매개변수로 입력된 x 및 y 변수를 사용하여 두 변수의 합을 반환합니다. 이 add 함수는 일반적인 순수 함수입니다. add 함수에 z 변수를 읽거나 수정하는 경우 순수성을 잃습니다. 다른 함수를 살펴보겠습니다.
function justTen() { return 10; }
입력 매개변수가 없는 함수의 경우 순수 함수로 유지하려면 함수의 반환 값이 상수여야 합니다. 하지만 이와 같이 고정된 상수를 반환하는 함수는 특정 상수로 정의하는 것이 좋을 수도 있습니다. 따라서 함수를 과도하게 사용할 필요는 없습니다. 따라서 대부분의 유용한 순수 함수는 최소한 하나의 입력 매개변수를 허용한다고 생각할 수 있습니다. 다음 함수를 다시 살펴보세요.
function addNoReturn(x, y) { var z = x + y }
이 함수는 어떤 값도 반환하지 않습니다. 여기에는 두 개의 입력 매개변수 x와 y가 있으며 두 변수를 추가하고 이를 z에 할당하므로 다음과 같은 함수가 생성됩니다. 이것은 또한 의미가 없는 것으로 간주될 수도 있습니다. 여기서 우리는 가장 유용한 순수 함수에는 반환 값이 있어야 한다고 말할 수 있습니다. 요약하면, 순수 함수에는 다음과 같은 특수 효과가 있어야 합니다.
●대부분의 순수 함수에는 하나 이상의 매개변수 값이 있어야 합니다.
● 순수 함수에는 반환값이 있어야 합니다.
●동일한 입력을 갖는 순수 함수의 반환값은 일관되어야 합니다.
●순수한 기능은 부작용을 일으킬 수 없습니다.
공유 상태 및 부작용
공유 상태는 임의의 변수일 수 있습니다. , 객체 또는 공유 범위(전역 범위 및 클로저 범위)에 존재하거나 다른 범위에 전달된 객체 속성으로 존재하는 메모리 공간입니다. 객체지향 프로그래밍에서는 다른 객체에 속성을 추가하여 객체를 공유하는 경우가 많습니다. 공유 상태의 문제는 개발자가 함수의 역할을 이해하려면 해당 함수가 각 공유 변수에 미칠 수 있는 영향을 자세히 이해해야 한다는 것입니다. 예를 들어, 이제 클라이언트가 생성한 사용자 객체를 서버에 저장해야 하는 경우 saveUser() 함수를 사용하여 서버에 대한 요청을 시작하고 사용자 정보 인코딩을 전달한 후 서버가 응답할 때까지 기다릴 수 있습니다. 요청을 시작한 동시에 사용자는 개인 아바타를 수정하여 또 다른 함수 updateAvatar() 및 또 다른 saveUser() 요청을 트리거했습니다. 일반적으로 서버는 첫 번째 요청에 먼저 응답하고 두 번째 요청의 사용자 매개변수 변경 사항을 기반으로 메모리나 데이터베이스에 저장된 사용자 정보를 수정합니다. 그러나 예상치 못한 상황에서는 두 번째 요청이 첫 번째 요청보다 먼저 서버에 도착할 수 있으므로 사용자가 선택한 새 아바타가 첫 번째 요청의 이전 아바타로 덮어쓰여지게 됩니다. 여기서 서버에 저장되는 사용자 정보는 소위 공유 상태이며, 여러 동시 요청으로 인해 발생하는 데이터 일관성 장애는 소위 경쟁 조건이기도 하며, 이는 공유 상태로 인해 발생하는 대표적인 문제 중 하나입니다. 공유 상태의 또 다른 일반적인 문제는 공유 상태에 대한 작업이 타이밍에 따라 달라지는 경우가 많기 때문에 서로 다른 호출 순서가 알 수 없는 오류를 유발할 수 있다는 것입니다.
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
부작용은 함수 호출 중 반환 값에 반영되지 않고 관찰 가능한 모든 애플리케이션 상태 변경을 의미합니다. 일반적인 부작용은 다음을 포함하지만 이에 국한되지는 않습니다.
● 외부 변수 수정 또는 외부 객체 속성
●콘솔에 로그 출력
●写入文件
●发起网络通信
●触发任何外部进程事件
●调用任何其他具有副作用的函数
在函数式编程中我们会尽可能地规避副作用,保证程序更易于理解与测试。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)))))