ホームページ > 記事 > ウェブフロントエンド > JavaScript 関数型プログラミング_JavaScript スキルの詳細な説明
有时,优雅的实现是一个函数。不是方法。不是类。不是框架。只是函数。 - John Carmack,游戏《毁灭战士》首席程序员
関数型プログラミングとは、問題を一連の関数に分解することです。通常、関数は互いに連鎖し、相互にネストされ、やり取りされ、第一級市民として扱われます。 jQuery や Node.js などのフレームワークを使用したことがある場合は、これらのテクニックのいくつかを無意識のうちに使用したことがあるでしょう。
まずは Javascript について少し恥ずかしい話から始めます。
通常のオブジェクトに割り当てられる値のリストが必要だとします。これらのオブジェクトには、データ、HTML オブジェクトなど、あらゆるものを含めることができます。
var obj1 = {value: 1}, obj2 = {value: 2}, obj3 = {value: 3}; var values = []; function accumulate(obj) { values.push(obj.value); } accumulate(obj1); accumulate(obj2); console.log(values); // Output: [obj1.value, obj2.value]
このコードは動作しますが、不安定です。どのコードでも、accumulate() 関数を渡さずに値オブジェクトを変更できます。 また、空の配列 [] を値に割り当てるのを忘れた場合、このコードはまったく機能しません。
しかし、変数が関数内で宣言されている場合、いたずらなコードによって変更されることはありません。
function accumulate2(obj) { var values = []; values.push(obj.value); return values; } console.log(accumulate2(obj1)); // Returns: [obj1.value] console.log(accumulate2(obj2)); // Returns: [obj2.value] console.log(accumulate2(obj3)); // Returns: [obj3.value]
まさか!渡された最後のオブジェクトの値のみが返されます。
最初の関数の中に関数を入れ子にすることで、この問題を解決できるかもしれません。
var ValueAccumulator = function(obj) { var values = [] var accumulate = function() { values.push(obj.value); }; accumulate(); return values; };
しかし、問題は依然として存在しており、現在、accumulate 関数と変数の値にアクセスできません。
必要なのは自己呼び出し関数です
自己呼び出し関数とクロージャ
値の配列を返す関数式を返すことができたらどうなるでしょうか?関数内で宣言された変数には、自己呼び出し関数を含む関数内のすべてのコードからアクセスできます。
自己呼び出し機能を使用すると、以前の恥ずかしさは消えます。
var ValueAccumulator = function() { var values = []; var accumulate = function(obj) { if (obj) { values.push(obj.value); return values; } else { return values; } }; return accumulate; }; //This allows us to do this: var accumulator = ValueAccumulator(); accumulator(obj1); accumulator(obj2); console.log(accumulator()); // Output: [obj1.value, obj2.value] ValueAccumulator = -> values = [] (obj) -> values.push obj.value if obj values
これはすべて範囲に関するものです。変数値は、外部コードがこの関数を呼び出した場合でも、内部関数accumulate()で表示されます。 これを閉鎖といいます。
Javascript におけるクロージャとは、親関数が実行を完了した場合でも、関数が親スコープにアクセスできることを意味します。
クロージャはすべての関数型言語の機能です。従来の命令型言語にはクロージャがありません。
高階関数
自己呼び出し関数は、実際には高階関数の一種です。高階関数は、他の関数を入力として受け取るか、関数を出力として返す関数です。
従来のプログラミングでは高階関数は一般的ではありません。命令型プログラマはループを使用して配列を反復処理しますが、関数型プログラマはまったく異なるアプローチを使用します。 高階関数を使用すると、配列内の各要素を関数に適用して、新しい配列を返すことができます。
これは関数型プログラミングの中心的な考え方です。高階関数には、オブジェクトなどの関数にロジックを渡す機能があります。
JavaScript では、Scheme や Haskell などの古典的な関数が言語であるのと同様に、関数は第一級市民として扱われます。 これは少し奇妙に聞こえるかもしれませんが、実際に意味するのは、関数は数値やオブジェクトと同じようにプリミティブ型として扱われるということです。 数値とオブジェクトをやり取りできる場合は、関数もやり取りできます。
ぜひご自身の目で確かめてください。ここで、前のセクションの ValueAccumulator() 関数を高階関数とともに使用します:
// forEach() を使用して配列を反復処理し、要素ごとにコールバック関数 accumulator2
を呼び出します
var accumulator2 = ValueAccumulator();
var object = [obj1, obj2, obj3] // この配列は非常に大きくなる可能性があります
object.forEach(accumulator2);
console.log(accumulator2());
純粋関数
純粋な関数によって返される計算結果は、渡されたパラメーターにのみ関連します。ここでは外部変数とグローバル状態は使用されず、副作用はありません。 つまり、入力として渡された変数は変更できません。したがって、プログラム内で使用できるのは純粋な関数によって返された値のみです。
数学関数を使用して簡単な例を示します。 Math.sqrt(4) は常に 2 を返し、設定や状態などの非表示の情報を使用せず、副作用も引き起こしません。
純粋関数は、入力と出力の間の関係である数学的な「関数」の真の解釈です。彼らのアイデアはシンプルで再利用が簡単です。 純粋関数は完全に自己完結型であるため、繰り返し使用するのに適しています。
不純関数と純粋関数を比較する例。
// 把信息打印到屏幕中央的函数 var printCenter = function(str) { var elem = document.createElement("div"); elem.textContent = str; elem.style.position = 'absolute'; elem.style.top = window.innerHeight / 2 + "px"; elem.style.left = window.innerWidth / 2 + "px"; document.body.appendChild(elem); }; printCenter('hello world'); // 纯函数完成相同的事情 var printSomewhere = function(str, height, width) { var elem = document.createElement("div"); elem.textContent = str; elem.style.position = 'absolute'; elem.style.top = height; elem.style.left = width; return elem; }; document.body.appendChild( printSomewhere('hello world', window.innerHeight / 2) + 10 + "px", window.innerWidth / 2) + 10 + "px"));
不適切な関数はウィンドウ オブジェクトの状態に依存して幅と高さを計算しますが、自己完結型の純粋関数はこれらの値をパラメーターとして渡す必要があります。 実際、どこにでも情報を印刷できるため、この機能はより多用途になります。
不純な関数は、要素を返すのではなく要素の追加を内部で実装するため、より簡単な選択のように見えます。 値を返す純粋な関数 printSomewhere() は、他の関数型プログラミング手法と組み合わせるとパフォーマンスが向上します。
var messages = ['Hi', 'Hello', 'Sup', 'Hey', 'Hola']; messages.map(function(s, i) { return printSomewhere(s, 100 * i * 10, 100 * i * 10); }).forEach(function(element) { document.body.appendChild(element); });
当一个函数是纯的,也就是不依赖于状态和环境,我们就不用管它实际是什么时候被计算出来。 后面的惰性求值将讲到这个。
匿名函数
把函数作为头等对象的另一个好处是匿名函数。
就像名字暗示的那样,匿名函数就是没有名字的函数。实际不止这些。它允许了在现场定义临时逻辑的能力。 通常这带来的好处就是方便:如果一个函数只用一次,没有必要给它浪费一个变量名。
下面是一些匿名函数的例子:
// 写匿名函数的标准方式 function() { return "hello world" }; // 匿名函数可以赋值给变量 var anon = function(x, y) { return x + y }; // 匿名函数用于代替具名回调函数,这是匿名函数的一个更常见的用处 setInterval(function() { console.log(new Date().getTime()) }, 1000); // Output: 1413249010672, 1413249010673, 1413249010674, ... // 如果没有把它包含在一个匿名函数中,他将立刻被执行, // 并且返回一个undefined作为回调函数: setInterval(console.log(new Date().getTime()), 1000) // Output: 1413249010671
下面是匿名函数和高阶函数配合使用的例子
function powersOf(x) { return function(y) { // this is an anonymous function! return Math.pow(x, y); }; } powerOfTwo = powersOf(2); console.log(powerOfTwo(1)); // 2 console.log(powerOfTwo(2)); // 4 console.log(powerOfTwo(3)); // 8 powerOfThree = powersOf(3); console.log(powerOfThree(3)); // 9 console.log(powerOfThree(10)); // 59049
这里返回的那个函数不需要命名,它可以在powersOf()函数外的任何地方使用,这就是匿名函数。
还记得累加器的那个函数吗?它可以用匿名函数重写
var obj1 = { value: 1 }, obj2 = { value: 2 }, obj3 = { value: 3 }; var values = (function() { // 匿名函数 var values = []; return function(obj) { // 有一个匿名函数! if (obj) { values.push(obj.value); return values; } else { return values; } } })(); // 让它自执行 console.log(values(obj1)); // Returns: [obj.value] console.log(values(obj2)); // Returns: [obj.value, obj2.value] obj1 = { value: 1 } obj2 = { value: 2 } obj3 = { value: 3 } values = do -> valueList = [] (obj) -> valueList.push obj.value if obj valueList console.log(values(obj1)); # Returns: [obj.value] console.log(values(obj2)); # Returns: [obj.value, obj2.value]
真棒!一个高阶匿名纯函数。我们怎么这么幸运?实际上还不止这些,这里面还有个自执行的结构, (function(){...})();。函数后面跟的那个括号可以让函数立即执行。在上面的例子里, 给外面values赋的值是函数执行的结果。
匿名函数不仅仅是语法糖,他们是lambda演算的化身。请听我说下去…… lambda演算早在计算机和计算机语言被发明的很久以前就出现了。它只是个研究函数的数学概念。 非同寻常的是,尽管它只定义了三种表达式:变量引用,函数调用和匿名函数,但它被发现是图灵完整的。 如今,lambda演算处于所有函数式语言的核心,包括javascript。
由于这个原因,匿名函数往往被称作lambda表达式。
匿名函数也有一个缺点,那就是他们在调用栈中难以被识别,这会对调试造成一些困难。要小心使用匿名函数。
方法链
在Javascript中,把方法链在一起很常见。如果你使用过jQuery,你应该用过这种技巧。它有时也被叫做“建造者模式”。
这种技术用于简化多个函数依次应用于一个对象的代码。
// 每个函数占用一行来调用,不如…… arr = [1, 2, 3, 4]; arr1 = arr.reverse(); arr2 = arr1.concat([5, 6]); arr3 = arr2.map(Math.sqrt); // ……把它们串到一起放在一行里面 console.log([1, 2, 3, 4].reverse().concat([5, 6]).map(Math.sqrt)); // 括号也许可以说明是怎么回事 console.log(((([1, 2, 3, 4]).reverse()).concat([5, 6])).map(Math.sqrt));
这只有在函数是目标对象所拥有的方法时才有效。如果你要创建自己的函数,比如要把两个数组zip到一起, 你必须把它声明为Array.prototype对象的成员.看一下下面的代码片段:
Array.prototype.zip = function(arr2) {
// ...
}
这样我们就可以写成下面的样子
arr.zip([11,12,13,14).map(function(n){return n*2});
// Output: 2, 22, 4, 24, 6, 26, 8, 28
递归
递归应该是最著名的函数式编程技术。就是一个函数调用它自己。
当函数调用自己,有时奇怪的事情就发生了。它的表现即是一个循环,多次执行同样的代码,也是一个函数栈。
使用递归函数时必须十分小心地避免无限循环(这里应该说是无限递归)。就像循环一样,必须有个停止条件。 这叫做基准情形(base case)。
下面有个例子
var foo = function(n) { if (n < 0) { // 基准情形 return 'hello'; } else { // 递归情形 return foo(n - 1); } } console.log(foo(5));
译注:原文中的代码有误,递归情形的函数调用缺少return,导致函数执行得最后没有结果。这里已经纠正。
递归和循环可以相互转换。但是递归算法往往更合适,甚至是必要的,因为有些情形用循环很费劲。
一个明显的例子就是遍历树。
var getLeafs = function(node) { if (node.childNodes.length == 0) { // base case return node.innerText; } else { // recursive case: return node.childNodes.map(getLeafs); } }
分而治之
递归不只是代替for和while循环的有趣的方式。有个叫分而治之的算法,它递归地把问题拆分成更小的情形, 直到小到可以解决。
历史上有个欧几里得算法用于找出两个数的最大公分母
function gcd(a, b) { if (b == 0) { // 基准情形 (治) return a; } else { // 递归情形 (分) return gcd(b, a % b); } } console.log(gcd(12,8)); console.log(gcd(100,20)); gcb = (a, b) -> if b is 0 then a else gcb(b, a % b)
理论上来说,分而治之很牛逼,但是现实中有用吗?当然!用Javascript的函数对数组排序不是很好, 它不但替换了原数组,也就是说数据不是不变的,并且它还不够可靠、灵活。通过分而治之,我们可以做得更好。
全部的实现代码大概要40行,这里只展示伪代码:
var mergeSort = function(arr) { if (arr.length < 2) { // 基准情形: 只有0或1个元素的数组是不用排序的 return items; } else { // 递归情形: 把数组拆分、排序、合并 var middle = Math.floor(arr.length / 2); // 分 var left = mergeSort(arr.slice(0, middle)); var right = mergeSort(arr.slice(middle)); // 治 // merge是一个辅助函数,返回一个新数组,它将两个数组合并到一起 return merge(left, right); } }
译注:关于用分而治之的思路进行排序的一个更好的例子是快排,使用Javascript也只有13行代码。 具体请参考我以前的博文 《优雅的函数式编程语言》
惰性求值
惰性求值,也叫做非严格求值,它会按需调用并推迟执行,它是一种直到需要时才计算函数结果的求值策略, 这对函数式编程特别有用。比如有行代码是 x = func(),调用这个func()函数得到的返回值会赋值给x。 但是x等于什么一开始并不重要,直到需要用到x的时候。等到需要用x的时候才调用func()就是惰性求值。
这一策略可以让性能明显增强,特别是当使用方法链和数组这些函数式程序员最喜爱的程序流技术的时候。 惰性求值让人兴奋的一个优点是让无限序列成为可能。因为在它实在无法继续延迟之前,什么都不需要被真正计算出来。 它可以是这个样子:
// 理想化的JavaScript伪代码: var infinateNums = range(1 to infinity); var tenPrimes = infinateNums.getPrimeNumbers().first(10);
这为很多可能性敞开了大门,比如异步执行、并行计算、组合,这只列举了一点。
然而,还有个问题,Javascript本身并不支持惰性求值,也就是说存在让Javascript模拟惰性求值的函数库, 这是第三章的主题。