1. ホーム
  2. javascript

JavaScriptの関数型プログラミングを平易に理解できる

2022-02-16 16:19:44
<パス

関数型プログラミングについていろいろと読んできましたが、そのほとんどは理論的なレベルのもので、Haskellのような純粋な関数型プログラミング言語についてのものもあります。この記事は、私がJavaScriptにおける関数型プログラミングとして見ているものについて話すことを目的としています。"what I see"となっている理由は、私が話すことはあくまで私の意見であり、厳密な概念の一部と衝突する可能性があるからです。

この記事では、形式的な概念の束を飛ばして、JavaScript において関数型コードが実際にどのようなものか、関数型コードは通常の記述とどう違うのか、関数型コードは私たちのために何ができるのか、そして一般的な関数型モデルのいくつかは何かということに焦点を当てます。

私の理解する関数型プログラミング
関数型プログラミングは、一般的な表現を分解して抽象化するために、関数を主な手段として使うプログラミング方法として理解できると思います。

そうすると、命令型に比べてどんな利点があるのでしょうか。主なものは以下の通りです。

より意味的に明確
再利用性の向上
保守性の向上
範囲が限定され、副作用が少ない
基本的な関数型プログラミング
次の例は、具体的な関数の具現化です。

Javascriptのコード

// Each word in the array, first letter capitalized  
// Generally written  
const arr = ['apple', 'pen', 'apple-pen'];  
for(const i in arr){  
  const c = arr[i][0];  
  arr[i] = c.toUpperCase() + arr[i].slice(1);  
}  
  
console.log(arr);  
  
  
// function write one  
function upperFirst(word) {  
  return word[0].toUpperCase() + word.slice(1);  
}  
  
function wordToUpperCase(arr) {  
  return arr.map(upperFirst);  
}  
  
console.log(wordToUpperCase(['apple', 'pen', 'apple-pen']));  
  
  
// Functional writing method 2  
console.log(arr.map(['apple', 'pen', 'apple-pen'], word => word[0].toUpperCase() + word.slice(1))));  


さらに複雑な状況になると、いくつかの問題に遭遇しながら式を書いていくことになる。

表現が自明でなく、次第にメンテナンスが困難になる
再利用性が低いため、コード量が多くなる
多くの中間変数が生成されます
これらの問題を解決するには、関数型プログラミングが有効です。まず、関数型プログラミングⅠをご覧ください。これは、関数のカプセル化を利用して、(一意でない粒度で)関数を分解して別の関数にカプセル化し、呼び出しの組み合わせで目標を達成するものです。これにより、思想が明確となり、保守、再利用、拡張が容易になる。第二に、配列の走査にfor...ofではなくArray.mapという高次の関数を使用し、中間変数や演算を減らしていることである。

関数スタイル1と関数スタイル2の大きな違いは、その関数がその後再利用可能かどうかを検討し、可能でなければ後者が望ましいという点です。

連鎖の最適化
上記のFunction Writing IIからわかるように、水平方向の拡張、つまり何重ものネストを引き起こすような形で関数型コードを書くことは簡単ですが、ここではより極端な例として、次のようなものを紹介します。

Javascriptのコード

// Calculate the sum of numbers  
  
// The general way to write  
console.log(1 + 2 + 3 - 4)  
  
  
// written as a function  
function sum(a, b) {  
  return a + b;  
}  
  
function sub(a, b) {  
  return a - b;  
}  
  
console.log(sub(sum(sum(1, 2), 3), 4);  


This example only shows the more extreme case of horizontal extension, where the readability of the code drops dramatically as the number of nested layers of functions increases, and it is also easy to generate errors. 

In this case, we can consider a variety of optimizations, such as the following chain optimization . 
// Optimization Writing (Well, you read it right, this is the lodash chain writing method) 

Javascript code 


const utils = {  
  chain(a) {  
    this._temp = a;  
    return this;  
  },  
  sum(b) {  
    this._temp += b;  
    return this;  
  },  
  sub(b) {  
    this._temp -= b;  
    return this;  
  },  
  value() {  
    const _temp = this._temp;  
    this._temp = undefined;  
    return _temp;  
  }  
};  
  
console.log(utils.chain(1).sum(2).sum(3).sub(4).value());  


このように書き換えると、全体的に構造が明確になり、チェーンの各ループが何をしているのかがわかりやすくなる。ネストされた関数とチェーンの組み合わせのもうひとつの良い例は、コールバック関数とPromiseのパターンです。

Javascriptのコード

// Request two interfaces in sequence  
  
  
// Callback functions  
import $ from 'jquery';  
$.post('a/url/to/target', (rs) => {  
  if(rs){  
    $.post('a/url/to/another/target', (rs2) => {  
      if(rs2){  
        $.post('a/url/to/third/target');  
      }  
    });  
  }  
});  
  
  
// Promise  
import request from 'catta'; // catta is a lightweight request tool that supports fetch,jsonp,ajax with no dependencies  
request('a/url/to/target')  
  .then(rs => rs ? $.post('a/url/to/another/target') : Promise.reject())  
  .then(rs2 => rs2 ? $.post('a/url/to/third/target') : Promise.reject());  


コールバック関数はネストレベルやシングルレベルの複雑さが増すと肥大化し、メンテナンスが困難になります。一方、Promiseの連鎖構造は複雑度が高くても垂直方向にスケールし、階層的な分離が明確です。

一般的な関数型プログラミングモデル
クロージャ(Closure)

参考文献

ローカル変数が解放されないようにするコードのブロックをクロージャと呼びます。

クロージャの概念はもう少し抽象的ですが、この機能は誰もがある程度知っていて、使っているのではないでしょうか

では、クロージャは具体的にどのような効果をもたらすのでしょうか。

まず、クロージャの作り方を見てみましょう。

Javascriptのコード

// Create a closure  
function makeCounter() {  
  let k = 0;  
  
  return function() {  
    return ++k;  
  };  
}  
  
const counter = makeCounter();  
  
console.log(counter()); // 1  
console.log(counter()); // 2  


makeCounter のコードブロックは、返された関数内でローカル変数 k への参照を作成し、関数の実行終了時にローカル変数がシステムによって回収されるのを防ぐため、クロージャを作成しています。このクロージャの効果は、内部関数が呼び出されたときに再利用できるようにローカル変数をquot;hold" することです。

つまり、クロージャは、実際には、関数のプライベートな変数であるquot;persistent variable"を作成します。

つまり、この例から、クロージャを作るための条件は

内側と外側の関数の存在
内側関数は外側関数のローカル変数を参照します。
クロージャの使用法
クロージャの主な用途は、キャッシュや中間的な計算量などに使用できる範囲限定の永続変数を定義できるようにすることです。

Javascriptのコード

// A simple caching tool  
// The anonymous function creates a closure  
const cache = (function() {  
  const store = {};  
    
  return {  
    get(key) {  
      return store[key];  
    },  
    set(key, val) {  
      store[key] = val;  
    }  
  }  
}());  
  
cache.set('a', 1);  
cache.get('a'); // 1  


上の例は、キャッシュツールの簡単な実装で、無名関数がクロージャを作成し、常に参照できるストアオブジェクトを再利用しないようにしたものです。

クロージャの欠点
永続変数は正常に解放されず、メモリ空間を占有し続けるため、メモリの浪費につながりやすく、一般に何らかの手動によるクリーンアップ機構が追加で必要になります。

高次関数

参考文献

ある関数を受け取るか返す関数は高階関数と呼ばれる

JavaScriptは高階関数をネイティブにサポートする言語です。JavaScriptの関数は、他の関数の引数としても戻り値としても使用できる一級市民だからです。

JavaScriptでは、Array.map、Array.reduce、Array.filterなどのネイティブな高階関数をよく見かけます。

mapを例にとって、どのように使われるかを見てみましょう。

マップ

参考文献

マッピングは集合のためのもので、集合の各項目を取り出して同じ変換を行い、新しい集合を生成します

mapは、関数の引数をmapのロジックとする高階関数である

Javascriptのコード

// Add one to each item in the array to form a new array  
  
// The general way to write  
const arr = [1,2,3];  
const rs = [];  
for(const n of arr){  
  rs.push(++n);  
}  
console.log(rs)  
  
  
// map rewriting  
const arr = [1,2,3];  
const rs = arr.map(n => ++n);  


上記の一般的な書き方では、for...ofループを使って配列を走査すると、追加の操作が発生し、元の配列が変更される危険性があります。

Map関数は必要な操作をカプセル化するので、マッピングロジックの関数実装にだけ気をつければよく、コード量と副作用のリスクを減らすことができます。

曲芸

引用

ある関数の引数のいくつかが与えられたとき、他の引数を受け付ける新しい関数を生成する。

この言葉はあまり聞きなれないかもしれませんが、undescoreやlodashを使ったことがある人なら誰でも見たことがあるはずです。

コロカライズの実装である魔法の_.partial関数が存在する

Javascriptのコード

// Get the relative path of the target file to the base path  
  
  
// The general way to write  
const BASE = '/path/to/base';  
const relativePath = path.relative(BASE, '/some/path');  
  
  
// _.parical rewrite  
const BASE = '/path/to/base';  
const relativeFromBase = _.partial(path.relative, BASE);  
  
const relativePath = relativeFromBase('/some/path');  


これは、path.relativeを呼び出すのと同じで、最初の引数をデフォルトでBASEに渡し、それ以降の引数は後から順番に渡します。

この場合、本当に行いたい操作は、BASEからの相対パスを1つずつ取得することであり、どのパスからの相対パスでもないことです。Curryingを使用すると、関数の引数の一部だけを気にすることができ、何のための関数かが明確になり、呼び出しがより簡単になります。

組み合わせる(コンポジット)

引用

複数の機能の能力を統合し、新しい機能を作る

繰り返しになりますが、おそらく最初に見たのはlodashのcomposeメソッド(現在はflowと呼ばれています)でしょう

Javascriptのコード

// Capitalize each word in the array, do Base64  
  
  
// General writing (one of these)  
const arr = ['pen', 'apple', 'applypen'];  
const rs = [];  
for(const w of arr){  
  rs.push(btoa(w.toUpperCase()));  
}  
console.log(rs);  
  
  
// _.flow Rewrite  
const arr = ['pen', 'apple', 'applypen'];  
const upperAndBase64 = _.partialRight(_.map, _.flow(_.upperCase, btoa));  
console.log(upperAndBase64(arr));  


_.flowは、大文字への変換とBase64への変換の機能を組み合わせて、新しい関数を生成します。パラメータ関数として、あるいはその後の再利用に使いやすい。

独自の見解
私のJavaScriptの関数型プログラミングに対する理解は、おそらく多くの従来の概念とは異なっています。高階関数だけを関数型プログラミングと考えるのではなく、それ以外の通常の関数の結合呼び出しや構造の連鎖なども、関数を主要な手段として使う限りは、関数型プログラミングと考えます。

そして、関数型プログラミングは必須でもなければ、義務でもなければ、必要条件でもないと思います。オブジェクト指向や他の考え方と同じように、方法のひとつなのです。概念にとらわれることなく、むしろいくつかを組み合わせたものであるべきだと思います。

参考文献

https://lodash.com/
http://reactivex.io/