Ramda.js を使って合成関数を作成してみる


こんにちは、フロントエンドエンジニアの辻です。
前回の記事では「Ramda.js」を通してカリー化に触れてみました。
今回は Ramda.js の pipe() を使って、関数を合成していきます!

Ramda.js の pipe()

以前の記事にて pipe() を紹介しましたが、改めて説明します。

Ramda.js の pipe() は、引数の関数を合成して 1 つの関数(合成関数)にまとめる関数です。
pipe() は実行時に関数を引数にとります。その引数のうち、第一引数の関数を起点にして、順次 関数を実行する関数を生成します。

const piped = R.pipe(
  (i) => i + 20, // はじめに実行される
  (i) => i * 3, // 2番目に実行される
  (i) => i * i // 3番目に実行される
)
piped(10) // 8100 = ((10 + 20) * 3) ** 2

pipe() は連続した処理に効果的

pipe() によって生成される関数が威力を発揮する場面は多々あります。
特に「特定のデータに連続した処理を実行する」場面では、非常に心強いです。

例えば、TypeScript コードにおいて以下のような Student 型があるとします。

type Student = {
  name: string
  age: number
  score: {
    math: number
    english: number
    chemistry: number
    physics: number
    biology: number
  }
}

Student 型のデータに次の 5 ステップを実行するプログラムを書くとしましょう。

  1. score の math に 10 加算
  2. score の english から 10 減算
  3. age が 20 以下なら score の physics に 5 加算
  4. score の math が 50 以下なら chemistry に 20 加算
  5. age が 20 以下 かつ score の chemistry が 70 以上なら biology に 10 加算

まずは手続き的な関数を書いてみます。

const handleStudent = (student: Student) => {
  // 1.
  student.score.math += 10
  // 2.
  student.score.english -= 10
  // 3.
  if (student.age <= 20) student.score.physics += 5
  // 4.
  if (student.score.math <= 50) student.score.chemistry += 20
  // 5.
  if (student.age <= 20 && student.score.chemistry >= 70) student.score.biology += 10

  return student
}

さて、ステップ 1 〜 5 の処理を実行できる handleStudent 関数は作成できたものの、どうも使いづらさが残ります。

ここで「handleStudent 関数のステップ 1 〜 5 のうち、3 のみを変更した関数がほしい」との要望があったとします。
手っ取り早い方法は、handleStudent 関数をコピーして新しい関数を作成し、ステップ 3 の処理だけ調整する方法ですね。
もしくは handleStudent 関数に真偽値の引数を 1 つ設けて、ステップ 3 の実行を切り分けても良いでしょう。

…では、もしステップ数が 10・20 あるとしたら、どうでしょう?
一部のステップだけでなく、複数のステップを変更する必要があるとしたら?
ステップの順番を入れ替える必要があるとしたら?

単純に handleStudent 関数を改変し続けていけば、重厚長大なプログラムが出来てしまうでしょう。どこかで戦略を練る必要があります。

ここで pipe() の出番です!
handleStudent 関数を Ramda.js の pipe() を使って改善してみます。
まずは ステップ 1 〜 5 を、1 つの関数に分解していきいます。

// 1.
const addMath = (student: Student) => {
  student.score.math += 10
  return student
}
// 2.
const subEnglidh = (student: Student) => {
  student.score.english -= 10
  return student
}
// 3.
const addPhysics = (student: Student) => {
  if (student.age <= 20) student.score.physics += 5
  return student
}
// 4.
const addChemistry = (student: Student) => {
  if (student.score.math <= 50) student.score.chemistry += 20
  return student
}
// 5.
const addBiology = (student: Student) => {
  if (student.age <= 20 && student.score.chemistry >= 70) student.score.biology += 10
  return student
}

次に分解した 5 つの関数を pipe() の引数にいれて関数を合成します。

import * as R from "ramda"

// pipe で合成した関数
const pipedHandleStudent = R.pipe(
  addMath,
  subEnglidh,
  addPhysics,
  addChemistry,
  addBiology
)

// Equal? true
console.log(
  "Equal? ",
  R.equals(
    handleStudent(R.clone(studentA)),
    pipedHandleStudent(R.clone(studentA))
  )
)

これではじめに作成した handleStudent 関数と同じ処理を実行できる pipedHandleStudent 関数を合成できました。

さて、ここで「ステップを追加した処理を新たに作成したい」との要望があったとします。
対応方法はシンプルです。
これまでと同様、小さな関数を必要な分だけ作成して、pipe() の引数に追加するだけで OK です。

pipe() により生成される関数は、引数の設定時に実行内容が決定づけられます。
したがって、pipedHandleStudent 関数を残したままに、新しい関数 pipedNewHandleStudent を作成できます。

import * as R from "ramda"

// pipe で合成した関数
const pipedHandleStudent = R.pipe(
  addMath,
  subEnglidh,
  addPhysics,
  addChemistry,
  addBiology
)

// pipe で新しい関数をもう 1 つ作成
const pipedNewHandleStudent = R.pipe(
  addMath,
  subEnglidh,
  addPhysics,
  addChemistry,
  addBiology,
  newStepFunc1, // 追加
  newStepFunc2, // 追加
  newStepFunc3, // 追加
  newStepFunc4, // 追加
  newStepFunc5, // 追加
  // …
)

また、別の要望があったとしましょう。
要望の内容は「はじめに作成した pipedHandleStudent() 関数の実行順を逆転させた処理が新たに欲しい」とします。

どうすれば対応できるか…は明白ですね。
pipe() の引数の順番だけ変えれば OK です。
(引数の順番をそのままで compose() を使っても、同じ関数が得られます。)

import * as R from "ramda"

// pipe で合成した関数
const pipedHandleStudent = R.pipe(
  addMath,
  subEnglidh,
  addPhysics,
  addChemistry,
  addBiology
)

const pipedNewHandleStudent = R.pipe(
  // … 略
)

// pipedHandleStudent() の実行順を逆転させた関数
const pipedReverseHandleStudent = R.pipe(
  addBiology,
  addChemistry,
  addPhysics,
  subEnglidh,
  addMath
)

このようにpipe() 関数を使うと、小さな関数を合成して、大きな処理をこなす関数を作成できます。
ステップの 1 つ 1 つを小さな関数で定義できるので、単体テストも行いやすく、保守性・可読性もあがります。
また、pipe() の引数の順序を組み替えるだけで、簡単に実行順序を変更できるので、改修しやすくもなります。

まとめ

今回は Ramda.js の pipe() を使って、関数を合成してみました!
pipe() は Ramda.js を代表する強力な機能ですので、ぜひ機会があれば使ってみてください。

Gaji-Labo は Next.js, React, TypeScript 開発の実績と知見があります

フロントエンド開発の専門家である私たちが御社の開発チームに入ることで、バックエンドも含めた全体の開発効率が上がります。

「既存のサイトを Next.js に移行したい」
「人手が足りず信頼できるエンジニアを探している」
「自分たちで手を付けてみたがいまいち上手くいかない」

フロントエンド開発に関わるお困りごとがあれば、まずは一度お気軽に Gaji-Labo にご相談ください。

オンラインでのヒアリングとフルリモートでのプロセス支援にも対応しています。

Next.js, React, TypeScript の相談をする!

投稿者 Tsuji Atsuhiro

フロントエンドエンジニア。 DTP・Webデザイナーを経験した後、フロントエンドエンジニアに転向。HTML/CSS/JavaScriptを中心にWeb開発を担当してきました。 UI・UXに興味があり、デザイン・コーディング両面から考えられるデザインエンジニアを目指しています。 普段はマラソンやボクシングなどで体を動かしてます。