JavaScript関数型プログラミングの説明:Fusion&Transduction
###序章
融合と変換は、関数型プログラミングを勉強しているときに私が選んだ最も実用的なツールかもしれません。 これらは私が毎日使用するツールではなく、厳密に必要なものでもありませんが、ソフトウェアエンジニアリングにおけるプログラミング、モジュール性、および抽象化についての考え方を恒久的に、そしてより良いものに完全に変えました。
そして明確にするために、それは本当にこの記事のポイントです。FPを伝道したり、銀の弾丸を提供したり、現在よりも「優れた」魔法の秘密のソースを照らしたりしないでください。 むしろ、重要なのは、プログラミングについて思考のさまざまな方法に光を当て、日常の問題に対する可能な解決策の感覚を広げることです。
これらは流暢に使用するのは簡単なテクニックではありません。ここで何が起こっているのかを完全に理解するには、おそらく時間がかかり、いじくり回し、慎重に練習する必要があります。 ほとんどの場合、これはまったく新しいレベルの抽象化です。
しかし、時間をかけると、これまでにない機能の抽象化の最も鋭い感覚が出てくるかもしれません。
簡単な例
純粋関数の定義を副作用のない関数として思い出してください。与えられた入力に対して常に同じ値を返します。
純粋関数alwaysは、指定された入力に対して同じ値を返すため、それらの戻り値を他の関数に直接安全に渡すことができます。
これにより、次のような機能が可能になります。
// niceties
colorBackground(wrapWith(makeHeading(createTitle(movie))), 'div')), 'papayawhip')
ここでは、makeHeading
を使用して、movie
から文字列見出しを作成します。 この文字列を使用して、新しい見出しを作成します(makeHeading
はdocument.createElement
に委任します); この見出しをdiv
でラップします。 最後に、colorBackground
を呼び出します。これにより、要素のスタイルが更新され、papayawhip
の背景が設定されます。これはCSSの私のお気に入りのフレーバーです。
このスニペットで機能している構成について明確にしましょう。 パイプラインの各ステップで、関数は入力を受け取り、入力が完全に決定する出力を返します。 より正式には:各ステップで、パイプラインに別の参照透過性関数を追加します。 さらに正式には、papayaWhipHeading
は参照透過性関数の組み合わせです。
機能的な目が以下の可能性も見つけるかもしれないことを指摘する価値があります。 しかし、あなたは実例となるが、まだ考案された例のためにここにいるわけではありません。 あなたはfusionについて学ぶためにここにいます。
それらの前提条件の残りをダッシュして、配列メソッドの連鎖を見てみましょう。
マップとフィルター式の連鎖
map
の優れた機能の1つは、結果とともに配列を自動的に返すことです。
const capitalized = ["where's", 'waldo'].map(function(word) {
return word.toUpperCase();
});
console.log(capitalized); // ['WHERE'S', 'WALDO']
もちろん、capitalized
について特に特別なことは何もありません。 他のアレイと同じメソッドがあります。
map
とfilter
は配列を返すため、どちらのメソッドへの呼び出しも、それらの戻り値に直接チェーンできます。
const screwVowels = function(word) {
return word.replace(/[aeiuo]/gi, '');
};
// Calling map on the result of calling map
const capitalizedTermsWithoutVowels = ["where's", 'waldo']
.map(String.prototype.toUpperCase)
.map(screwVowels);
これは特に劇的な結果ではありません。このような連鎖配列メソッドは、JSランドでは一般的です。 ただし、次のようなコードにつながることは注目に値します。
// Retrieve a series of 'posts' from JSON Placeholder (for fake demonstration data)
// GET data
fetch('https://jsonplaceholder.typicode.com/posts')
// Extract POST data from response
.then(data => data.json())
// This callback contains the code you should focus on--the above is boilerplate
.then(data => {
// filter for posts by user with userId == 1
const sluglines = data
.filter(post => post.userId == 1)
// Extract only post and body properties
.map(post => {
const extracted = {
body: post.body,
title: post.title
};
return extracted;
})
// Truncate "body" to first 17 characters, and add 3-character ellipsis
.map(extracted => {
extracted.body = extracted.body.substring(0, 17) + '...';
return extracted;
})
// Capitalize title
.map(extracted => {
extracted.title = extracted.title.toUpperCase();
return extracted;
})
// Create sluglines
.map(extracted => {
return `${extracted.title}\n${extracted.body}`;
});
});
これはおそらく一般的なmap
呼び出しよりも数多いでしょう…しかし、filter
と一緒にmap
を検討すると、このスタイルははるかに信頼できるものになります。
map
およびfilter
への順次呼び出しで「単一目的」コールバックを使用すると、関数の呼び出しと「単一目的」コールバックの要件によるオーバーヘッドを犠牲にして、より単純なコードを記述できます。
map
とfilter
は、呼び出す配列を変更しないため、不変性のメリットも享受できます。 むしろ、毎回新しいアレイを作成します。
これにより、微妙な副作用による混乱を回避し、初期データソースの整合性を維持して、問題なく複数の処理パイプラインに渡すことができます。
中間配列
一方、map
またはfilter
を呼び出すたびにまったく新しい配列を割り当てるのは、少し手間がかかるようです。
上記で行った一連の呼び出しは、map
およびfilter
へのすべての呼び出しを行った後に取得する配列のみを気にするため、少し「手間がかかる」ように感じます。 途中で生成する中間配列は使い捨てです。
チェーン内の次の関数に、期待する形式のデータを提供することを唯一の目的として作成します。 生成する最後の配列に固執するだけです。 JavaScriptエンジンは、最終的に、構築したが必要のない中間配列をガベージで収集します。
このスタイルのプログラミングを使用して大きなリストを処理している場合、これはかなりのメモリオーバーヘッドにつながる可能性があります。 言い換えれば、私たちはメモリといくつかの付随的なコードの複雑さをテスト可能性と読みやすさのために交換しています。
中間配列の排除
簡単にするために、map
への一連の呼び出しについて考えてみましょう。
// See bottom of snippet for `users` list
users
// Extract important information...
.map(function (user) {
// Destructuring: https://jsonplaceholder.typicode.com/users
return { name, username, email, website } = user
})
// Build string...
.map(function (reducedUserData) {
// New object only has user's name, username, email, and website
// Let's reformat this data for our component
const { name, username, email, website } = reduceduserdata
const displayname = `${username} (${name})`
const contact = `${website} (${email})`
// Build the string want to drop into our UserCard component
return `${displayName}\n${contact}`
})
// Build components...
.map(function (displayString) {
return UserCardComponent(displayString)
})
// Hoisting so we can keep the important part of this snippet at the top
var users = [
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "[email protected]",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
},
{
"id": 2,
"name": "Ervin Howell",
"username": "Antonette",
"email": "[email protected]",
"address": {
"street": "Victor Plains",
"suite": "Suite 879",
"city": "Wisokyburgh",
"zipcode": "90566-7771",
"geo": {
"lat": "-43.9509",
"lng": "-34.4618"
}
}
}
]
問題を言い換えると、これにより、map
が呼び出されるたびに中間の「使い捨て」配列が生成されます。 これは、すべての処理ロジックを実行する方法が見つかった場合は中間配列を割り当てず、map
を1回だけ呼び出すことを意味します。
map
への1回の呼び出しで回避する方法のひとつは、1回のコールバック内ですべての作業を行うことです。
const userCards = users.map(function (user) {
// Destructure user we're interested in...
const { name, username, email, website } = user
const displayName = `${username} (${name})`
const contact = `${website} (${email})`
// Create display string for our component...
const displayString = `${displayName}\n${contact}`
// Build/return UserCard
return UserCard(displayString)
})
これにより中間アレイが排除されますが、これは一歩後退します。 すべてを単一のコールバックにスローすると、そもそもmap
へのシーケンス呼び出しを動機付けた可読性とテスト容易性の利点が失われます。
このバージョンの可読性を向上させる1つの方法は、コールバックを独自の関数に抽出し、リテラル関数宣言ではなく、map
の呼び出し内でそれらを使用することです。
const extractUserData = function (user) {
return { name, username, email, website } = user
}
const buildDisplayString = function (userData) {
const { name, username, email, website } = reducedUserData
const displayName = `${username} (${name})`
const contact = `${website} (${email})`
return `${displayName}\n${contact}`
}
const userCards = users.map(function (user) {
const adjustedUserData = extractUserData(user)
const displayString = buildDisplayString(adjustedUserData)
const userCard = UserCardComponent(displayString)
return userCard
})
参照透過性があるため、これは私たちが始めたものと論理的に同等です。 しかし、それは間違いなく読みやすく、間違いなくテストしやすいです。
ここでの本当の勝利は、このバージョンによって処理ロジックの構造がはるかに明確になることです。関数の合成のように聞こえますね。
さらに一歩進むことができます。 各関数呼び出しの結果を変数に保存する代わりに、各呼び出しの結果をシーケンス内の次の関数に直接渡すことができます。
const userCards = users.map(function (user) {
const userCard = UserCardComponent(buildDisplayString(extractUserData(user)))
return userCard
})
または、コードをもっと簡潔にしたい場合は、次のようにします。
const userCards =
users.map(user => UserCardComponent(buildDisplayString(extractUserData(user))))
構成と融合
これにより、map
呼び出しの元のチェーンのすべてのテスト可能性と一部の可読性が復元されます。 また、map
を1回呼び出すだけでこの変換を表現できたため、中間配列によって発生するメモリのオーバーヘッドを排除しました。
これを行うには、一連の呼び出しをmap
に変換し、それぞれが「単一目的」のコールバックを受け取り、map
への単一の呼び出しに変換します。この呼び出しでは、これらのコールバックの構成を使用します。
このプロセスはfusionと呼ばれ、map
へのシーケンス呼び出しのテスト容易性と可読性の利点を享受しながら、中間配列のオーバーヘッドを回避できます。
最後の改善点。 Pythonからヒントを得て、私たちが何をしているのかを明確にしましょう。
const R = require('ramda');
// Use composition to use "single-purpose" callbacks to define a single transformation function
const buildUsercard = R.compose(UserCardComponent, buildDisplayString, extractUserData)
// Generate our list of user components
const userCards = users.map(buildUserCard)
これをさらにクリーンにするためのヘルパーを作成できます。
const R = require('ramda')
const fuse = (list, functions) => list.map(R.compose(...functions))
// Then...
const userCards = fuse(
// list to transform
users,
// functions to apply
[UserCardComponent, buildDisplayString, extractUserData]
)
メルトダウン
あなたが私のようなら、これはあなたがmap
とfilter
をどこでも使い始める部分です、おそらくあなたがそれを使うべきではないものでさえ。
しかし、これは高値が長くは続かない。 これをチェックして:
users
// Today, I've decided I hate the letter a
.filter(function (user) {
return user.name[0].toLowerCase() == 'a'
})
.map(function (user) {
const { name, email } = user
return `${name}'s email address is: ${email}.`
})
Fusionは、一連のmap
呼び出しで正常に機能します。 filter
への一連の呼び出しでも同様に機能します。 残念ながら、両方のメソッドを含む順次呼び出しで機能しなくなります。 Fusionは、これらのメソッドの1つへのシーケンス呼び出しに対してのみ機能します。
これは、コールバックの戻り値の解釈が異なるためです。 map
は、戻り値を受け取り、それが何であるかに関係なく、それを配列にプッシュします。
一方、filter
は、コールバックの戻り値の真実性を解釈します。 コールバックが要素に対してtrue
を返す場合、その要素を保持します。 そうでなければ、それはそれを捨てます。
どのコールバックをフィルターとして使用する必要があり、どのコールバックを単純な変換として使用する必要があるかを融合関数に指示する方法がないため、融合は機能しません。
言い換えると、この融合へのアプローチは、map
およびfilter
への一連の呼び出しの特殊なケースでのみ機能します。
形質導入
これまで見てきたように、フュージョンは、マップのみ、またはフィルターのみを含む一連の呼び出しに対してのみ機能します。 これは実際にはあまり役に立ちません。通常、両方を呼び出します。 map
とfilter
をreduce
で表現できたことを思い出してください。
// Expressing `map` in terms of `reduce`
const map = (list, mapFunction) => {
const output = list.reduce((transformedList, nextElement) => {
// use the mapFunction to transform the nextElement in the list
const transformedElement = mapFunction(nextElement);
// add transformedElement to our list of transformed elements
transformedList.push(transformedElement);
// return list of transformed elements
return transformedList;
}, [])
// ^ start with an empty list
return output;
}
// Expressing `filter` in terms of `reduce`
const filter = (list, predicate) => {
const output = list.reduce(function (filteredElements, nextElement) {
// only add `nextElement` if it passes our test
if (predicate(nextElement)) {
filteredElements.push(nextElement);
}
// return the list of filtered elements on each iteration
return filteredElements;
}, [])
})
}
理論的には、これは、map
への呼び出しを置き換えてから、filter
への呼び出しをreduce
への呼び出しに置き換えることができることを意味します。 次に、reduce
のみを含む一連の呼び出しがありますが、これは、すでに使用しているのと同じマッピング/フィルタリングロジックを実装します。
そこから、フュージョンで見たものと非常によく似た手法を適用して、単一の関数構成の観点から一連の削減を表現できます。
ステップ1:mapReducerとfilterReducer
最初のステップは、map
およびfilter
への呼び出しをreduce
の観点から再表現することです。
以前は、map
とfilter
の独自のバージョンを作成しました。これは次のようになります。
const mapReducer = (list, mapFunction) => {
const output = list.reduce((transformedList, nextElement) => {
// use the mapFunction to transform the nextElement in the list
const transformedElement = mapFunction(nextElement);
// add transformedElement to our list of transformed elements
transformedList.push(transformedElement);
// return list of transformed elements
return transformedList;
}, [])
// ^ start with an empty list
return output;
}
const filterReducer = (list, predicate) => {
const output = list.reduce(function (filteredElements, nextElement) {
// only add `nextElement` if it passes our test
if (predicate(nextElement)) {
filteredElements.push(nextElement);
}
// return the list of filtered elements on each iteration
return filteredElements;
}, [])
})
}
これらを使用して、reduce
とmap
/ filter
の関係を示しましたが、reduce
チェーンでこれを使用する場合は、いくつかの変更を加える必要があります。 。
reduce
へのこれらの呼び出しを削除することから始めましょう。
const mapReducer = mapFunction => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList.push(transformedElement);
return transformedList;
}
const filterReducer = predicate => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements.push(nextElement);
}
return filteredElements;
}
以前、user
名の配列をフィルタリングしてマッピングしました。 これらすべてを少し抽象化しないように、これらの新しい関数を使用してロジックを書き直してみましょう。
// filter's predicate function
function removeNamesStartingWithA (user) {
return user.name[0].toLowerCase() != 'a'
}
// map's transformation function
function createUserInfoString (user) {
const { name, email } = user
return `${name}'s email address is: ${email}.`
}
users
.reduce(filterReducer(removeNamesStartingWithA), [])
.reduce(mapReducer(createUserInfoString), [])
これにより、以前のfilter
/map
チェーンと同じ結果が得られます。
これには、間接参照のかなりの数の層が含まれます。 先に進む前に、少し時間を取って上記のスニペットを確認してください。
ステップ2:折りたたみ関数の一般化
mapReducer
とfilterReducer
をもう一度見てください。
const mapReducer = mapFunction => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList.push(transformedElement);
return transformedList;
}
const filterReducer = predicate => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements.push(nextElement);
}
return filteredElements;
}
変換や述語論理をハードコードするのではなく、ユーザーがマッピングと述語関数を引数として渡すことができます。これは、mapReducer
とfilterReducer
の部分適用がクロージャのために覚えています。
このように、ユースケースに適したpredicate
またはmapFunction
を渡すことで、mapReducer
およびfilterReducer
を「バックボーン」として使用して任意のリダクションチェーンを構築できます。
よく見ると、これらのレデューサーの両方でpush
を明示的に呼び出していることがわかります。 push
は、2つのオブジェクトを1つに結合または縮小できる関数であるため、これは重要です。
// Object 1...
const accumulator = ["an old element"];
// Object 2...
const next_element = "a new element";
// A single object that combines both! Eureka!
accumulator.push(next_element);
// ["an old element", "a new element"]
console.log(accumulator)
このように要素を組み合わせることが、そもそもreduce
を使用することの要点であることを思い出してください。
考えてみれば、これを行うために使用できる関数はpush
だけではありません。 代わりにunshift
を使用できます。
// Object 1...
const accumulator = ["an old element"];
// Object 2...
const next_element = "a new element";
// A single object that combines both! Eureka!
accumulator.unshift(next_element);
// ["a new element", "an old element"]
console.log(accumulator);
書かれているように、私たちのレデューサーは私たちをpush
の使用に固定します。 代わりに、unshift
を実行したい場合は、mapReducer
とfilterReducer
を再実装する必要があります。
解決策は抽象化です。 push
をハードコーディングするのではなく、ユーザーが要素を結合するために使用する関数を引数として渡せるようにします。
const mapReducer = combiner => mapFunction => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList = combiner(transformedList, transformedElement);
return transformedList;
}
const filterReducer = combiner => predicate => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements = combiner(filteredElements, nextElement);
}
return filteredElements;
}
私たちはそれを次のように使用します:
// push element to list, and return updated list
const pushCombiner = (list, element) => {
list.push(element);
return list;
}
const mapReducer = mapFunction => combiner => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList = combiner(transformedList, transformedElement);
return transformedList;
}
const filterReducer = predicate => combiner => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements = combiner(filteredElements, nextElement);
}
return filteredElements;
}
users
.reduce(
filterReducer(removeNamesStartingWithA)(pushCombiner), [])
.reduce(
mapReducer(createUserInfoString)(pushCombiner), [])
ステップ3:形質導入
この時点で、最後のトリックとしてすべてが整っています。これらの変換を作成して、reduce
への連鎖呼び出しを融合します。 最初に実際の動作を確認してから、確認してみましょう。
const R = require('ramda');
// final mapReducer/filterReducer functions
const mapReducer = mapFunction => combiner => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList = combiner(transformedList, transformedElement);
return transformedList;
}
const filterReducer = predicate => combiner => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements = combiner(filteredElements, nextElement);
}
return filteredElements;
}
// push element to list, and return updated list
const pushCombiner = (list, element) => {
list.push(element);
return list;
}
// filter's predicate function
const removeNamesStartingWithA = user => {
return user.name[0].toLowerCase() != 'a'
}
// map's transformation function
const createUserInfoString = user => {
const { name, email } = user
return `${name}'s email address is: ${email}.`
}
// use composition to create a chain of functions for fusion (!)
const reductionChain = R.compose(
filterReducer(removeNamesStartingWithA)
mapReducer(createUserInfoString),
)
users
.reduce(reductionChain(pushCombiner), [])
ヘルパー関数を実装することで、さらに先に進むことができます。
const transduce = (input, initialAccumulator, combiner, reducers) => {
const reductionChain = R.compose(...reducers);
return input.reduce(reductionChain(combiner), initialAccumulator)
}
const result = transduce(users, [], pushCombiner, [
filterReducer(removeNamesStartingWithA)
mapReducer(createUserInfoString),
]);
結論
ほとんどすべての問題には、誰もが列挙できるよりも多くの解決策があります。 あなたが出会う人が多ければ多いほど、あなたはあなた自身についてより明確に考えるでしょう、そしてあなたはそうすることをより楽しくするでしょう。
Fusion and Transductionに会うことで、興味をそそられ、より明確に考えることができ、野心的で、少なくとも少し楽しかったことを願っています。