判定式をいい感じに関数化して再利用、合成可能にしたい!
はじめに
プログラムの制御フローの最小単位ともいえる「条件分岐」、そしてこれを駆動させるのが「判定式」(str === ""やnum > 0など)。これは多くの場合、例示したような単純な比較ロジックであり、それゆえにベタ書きしがちです。しかし、こういうプリミティブで重要なロジックだからこそ関数化して再利用を促進すると、より柔軟で堅牢な実装ができるんじゃないかと最近思っています。
判定式をどう関数化するか
例えば以下のような判定式があったとして、それらを単純に関数化するとこんな感じになるんじゃないかと思います。
str === ""user.age > 18x === y
const isEmptyString = (str: string) => str === ""
const isGreaterThan18 = (num: number) => num > 18
const equals = <A>(x: A, y: A) => x === y
こうして判定式に名前を付けて、再利用可能な関数にするだけでも良いのですが、複数の判定式をandやor相当のロジックで組み合わせたりできるようにもしたいですよね。その場合、以下のように判定関数用の型を作ってあげる必要があります。
type Predicate = ...
declare const and: (p1: Predicate, p2: Predicate) => Predicate
declare const or: (p1: Predicate, p2: Predicate) => Predicate
Predicateの引数をどうするかですが、型はジェネリクスを使うとして、引数の数についても統一された形にしておかないと、合成や変形といった処理を一般化できません。
複数の引数を取る関数(例えばequals(x, y))をそのまま合成しようとすると、どの引数を主語(評価対象)として扱うかが曖昧になってしまい、関数同士の接続が難しくなります。
そこで、以下のように「評価したい対象をひとつだけ受け取る」という形に制限することで、判定関数の構造が統一され、合成・変形が非常にやりやすくなります:
interface Predicate<A> {
(a: A): boolean
}
こうするとandやorはこう実装出来ます:
const and = <A>(p1: Predicate<A>, p2: Predicate<A>): Predicate<A> => {
return (a) => p1(a) && p2(a)
}
const or = <A>(p1: Predicate<A>, p2: Predicate<A>): Predicate<A> => {
return (a) => p1(a) || p2(a)
}
ここまでで一旦Predicate<A>の定義と、合成用のユーティリティの実装は出来ましたが、結局引数が2つのequals(x, y)はどのように適合させつつ実装したらよいのでしょうか?
これは前述したようにどの引数を主語(評価対象)として扱うか(扱いたいか)という部分に着目して考えて、equals(x, y)は「x(主語)がyと等しい(述語)か」を判定する関数ととらえることができます。そうした場合、実装は以下のようなものが考えられます:
const equalTo = <A>(y: A): Predicate<A> => {
return (x) => x === y
}
これは先に「yと等しい」という条件を作るのに必要なコンテキスト値を受け取り、それをもとにPredicate<A>を導出する、というインターフェースになっています。こういった命名、インターフェースで実装をしておくと、例えばArray.prototype.filterに渡す関数をつくるときに以下のように書けます:
const filtered = [1, 2, 3, 2, 3].filter(equalTo(2))
そう、Array.prototype.filterやArray.prototype.someなどの配列を走査して判定を行う高階関数は(a: A) => booleanという形式の関数を引数に取るので、今回定義したPredicate<A>がピッタリなんです。そういう点でも、Predicate<A>そのものは引数がひとつの単純な関数にしておくと取り回しが良くなるんですね。
Predicate<A>の変形
判定式をPredicate<A>という形で統一して関数化することでandやorによる合成だけでなく、入力値やロジックを変形して意味的に新しいPredicateを導出することもできます。
例えば任意のPredicate<A>の条件を否定をした形のものを導出するならこんな感じ:
const not = <A>(p: Predicate<A>): Predicate<A> => {
return a => !p(a)
}
他には、引数を変化させた派生Predicate<A>をつくるcontramapなんていうのもあります。(参考: fp-ts)
実装はこんな感じ:
const contramap = <A, B>(f: (a: B) => A, p: Predicate<A>): Predicate<B> => {
return b => p(f(b))
}
これは例えば、このような実装があったとして
type User = {
name: string
age: number
isActive: boolean
}
export const greaterThan = (n: number): Predicate<number> => {
return (a) => a > n
}
Userのageが18より大きい時にtrueを返すisAdult: Predicate<User>を作りたい!みたいな場合に、既に実装されているPredicate<number>を再利用しながらPredicate<User>を導出するのに便利です。
const isAdult: Predicate<User> = contramap(u => u.age, greaterThan(18))
contramapの第一引数にUserからnumberに変換する(Userからageを取る)処理、第二引数にage(number)の検査を行うPredicate<number>を渡すことで、新たなPredicate<User>を導出している、という感じですね。
Predicate<A>とユーティリティを使ったリファクタ
これまで出てきた要素を踏まえて、最後にこういうリファクタが考えられるよ、という紹介
// Before
function xxxx(user: User): ... {
if (user.age > 18 && user.isActive && user.name !== "") {
// ...
}
// ...
}
// After
const andAll = <A>(...ps: readonly Predicate<A>[]): Predicate<A> => {
return (a) => ps.every(p => p(a))
// return ps.reduce(and, () => true)でも可
}
const greaterThan = (n: number): Predicate<number> => {
return (a) => a > n
}
const isEmpty: Predicate<string> = s => s === ''
const identity = <A>(a: A) => a
const isAdult: Predicate<User> = contramap(u => u.age, greaterThan(18))
const isFilledName: Predicate<User> = contramap(u => u.name, not(isEmpty))
const isActive: Predicate<User> = contramap(u => u.isActive, identity)
const isValidUser: Predicate<User> = andAll(isAdult, isActive, isFilledName)
function xxxx(user: User): ... {
if (isValidUser(user)) {
// ...
}
// ...
}
Afterは一見コード量が増えてるだけに思えるかもしれませんが、関数外の実装はすべて副作用のない純粋関数として定義されています。
こうした実装は命名と名前空間の管理さえしっかりしていれば、共通ユーティリティとして切り出して使い回すことができ、ロジックの重複やコピペによる保守コストの増大を防ぐことができます。
また、判定ロジックを個別の関数として切り出しておくことで、テストの粒度を細かく保ちながら、「意味のある単位で組み合わせる」というアプローチが可能になります。
「ベタ書きの条件式」から「意味付けされたPredicateの合成」へ。この構造の変化によって、コードは多少冗長に見えても保守性・拡張性・可読性の三拍子が向上するというのが、関数化の価値なのだと思います。
おわりに
今回紹介した Predicate<A> という型、そしてその活用方法はfp-tsから刺激を受けて作成したものになります。
最近関数型プログラミングを社内でも推しているのですが、大きくて複雑なロジックを細かい関数に分離して、それを組み合わせて最終的な実装を組み上げるフローは、ドメイン知識やビジネスロジックを適切なサイズに凝集、あるいは分離させるきっかけになっています。
これによって頭の中とコードが整頓されていくのを日々感じているので、今後も大事にしていきたいなと思っています。
この記事が、読んでくださった方のリファクタや実装設計のヒントになれば嬉しいです!
Discussion