JavaScript関数型プログラミングの説明:Fusion&Transduction

提供:Dev Guides
移動先:案内検索

###序章

融合と変換は、関数型プログラミングを勉強しているときに私が手にした最も実用的なツールかもしれません。 これらは私が毎日使用するツールではなく、厳密に必要なものでもありませんが、ソフトウェアエンジニアリングにおけるプログラミング、モジュール性、および抽象化についての考え方を恒久的に、そしてより良いものに完全に変えました。

そして明確にするために、それは本当にこの記事のポイントです。FPを伝道したり、銀の弾丸を提供したり、現在よりも「優れた」魔法の秘密のソースを照らしたりしないでください。 むしろ、重要なのは、プログラミングについて思考のさまざまな方法に光を当て、日常の問題に対する可能な解決策の感覚を広げることです。

これらは流暢に使用するのは簡単なテクニックではありません。ここで何が起こっているのかを完全に理解するには、おそらく時間がかかり、いじくり回し、慎重に練習する必要があります。 ほとんどの場合、これはまったく新しいレベルの抽象化です。

しかし、時間をかけると、これまでにない機能の抽象化の最も鋭い感覚が出てくるかもしれません。

簡単な例

純粋関数の定義を副作用のない関数として思い出してください。与えられた入力に対して常に同じ値を返します。

純粋関数alwaysは、指定された入力に対して同じ値を返すため、それらの戻り値を他の関数に直接安全に渡すことができます。

これにより、次のような機能が可能になります。

// niceties
colorBackground(wrapWith(makeHeading(createTitle(movie))), 'div')), 'papayawhip')

ここでは、makeHeadingを使用して、movieから文字列見出しを作成します。 この文字列を使用して、新しい見出しを作成します(makeHeadingdocument.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について特に特別なことは何もありません。 他のアレイと同じメソッドがあります。

mapfilterは配列を返すため、どちらのメソッドへの呼び出しも、それらの戻り値に直接チェーンできます。

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への順次呼び出しで「単一目的」コールバックを使用すると、関数呼び出しと「単一目的」コールバックの要件によるオーバーヘッドを犠牲にして、より単純なコードを記述できます。

mapfilterは、呼び出す配列を変更しないため、不変性のメリットも享受できます。 むしろ、毎回新しいアレイを作成します。

これにより、微妙な副作用による混乱を回避し、初期データソースの整合性を維持して、問題なく複数の処理パイプラインに渡すことができます。

中間配列

一方、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]
)

メルトダウン

あなたが私のようなら、これはあなたがmapfilterをどこでも使い始める部分です、おそらくあなたがそれを使うべきではないものでさえ。

しかし、これは高値が長くは続かない。 これをチェックして:

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への一連の呼び出しの特殊なケースでのみ機能します。

形質導入

これまで見てきたように、フュージョンは、マップのみ、またはフィルターのみを含む一連の呼び出しに対してのみ機能します。 これは実際にはあまり役に立ちません。通常、両方を呼び出します。 mapfilterreduceで表現できたことを思い出してください。

// 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の観点から再表現することです。

以前は、mapfilterの独自のバージョンを作成しました。これは次のようになります。

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;
        }, [])
    })
}

これらを使用して、reducemap / 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:折りたたみ機能の一般化

mapReducerfilterReducerをもう一度見てください。

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;
}

ハードコード変換や述語論理ではなく、マッピングと述語関数を引数として渡すことができます。これは、mapReducerfilterReducerの部分的なアプリケーションがクロージャのために覚えています。

このように、ユースケースに適した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を実行したい場合は、mapReducerfilterReducerを再実装する必要があります。

解決策は抽象化です。 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に会うことで、興味をそそられ、より明確に考えることができ、野心的で、少なくとも少し楽しかったことを願っています。