JavaScriptのパターンマッチング入門

パターンは、入力データを変換するためのルールです。データを1つ以上の論理構造と比較し、構成要素に分解したり、様々な方法で情報を抽出するために使用されます。

セットアップ

JavaScriptには、パターンマッチングによる分解機能は組み込まれていますが、フィルタリング機能はありません。パターンを使用してプログラムの流れを制御することで、より宣言的でモジュール化されたコードを記述できます。この機能をサポートするstructural-comparisonライブラリをインストールしてください。structural-comparisonは、関数型プログラミングでよく使われる関数を含むライブラリで、GitHubのxp44mm/structural-comparisonリポジトリで公開されています。

npm i structural-comparison

使い方:

import { match } from 'structural-comparison'

match: (pattern:string) -> (input:any) -> boolean

matchはカリー化された関数です。パターン引数は文字列、入力引数は任意の値で、マッチ成功時にtrue、失敗時にfalseを返します。

具体例

パターンマッチングには、リテラルパターン、型テストパターン、識別子パターン、配列パターン、オブジェクトパターン、ORパターン、およびそれらを組み合わせたネストパターンがあります。

リテラルパターン

プリミティブな値をテストします。パターンはプリミティブ値のリテラルで、null、ブール値、数値、文字列などJSONでサポートされているすべてのリテラルを扱えます。文字列はJSONのダブルクォーテーション形式です。undefinedNaNInfinityなどのJSON非対応の値はサポートしていません。

test("value NULL", () => {
    let predicate = match('null')
    expect(predicate(null)).toEqual(true)
    expect(predicate(3)).toEqual(false)
})

上記のpredicate関数は以下と同等です:

let predicate = input => input === null

入力とパターンが同一(===)であることを確認します。

その他のリテラルパターンの例:

test("value boolean", () => {
    let predicate = match('false')
    let result1 = predicate(false)
    let result2 = predicate(3)
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

test("value number", () => {
    let predicate = match('123')
    let result1 = predicate(123)
    let result2 = predicate(3)
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

test("value quote", () => {
    let predicate = match('""')
    let result1 = predicate('')
    let result2 = predicate(3)
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

型パターン

データのデータ型をテストします。パターンはデータ型名で、引用符なしで指定します。booleanstringnumberfunctionが利用可能です。

test("value TYPE", () => {
    let predicate = match('number')
    let result1 = predicate(5)
    let result2 = predicate(true)
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

ここでのpredicate関数は以下と同等です:

let predicate = input => typeof input === 'number'

入力のtypeof値がパターンのデータ型名と一致するか確認します。

識別子パターン

有効なJavaScript識別子(ただし$は含まず、型名も不可)です。識別子と型名は大文字小文字を区別します(JavaScriptの構文と同様)。識別子パターンは常に任意の値にマッチします。

test("value ID", () => {
    let predicate = match('x')
    let result1 = predicate(5)
    let result2 = predicate({})
    expect(result1).toEqual(true)
    expect(result2).toEqual(true)
})

ワイルドカードパターン(識別子の一種)は、実際には破棄(discard)として機能し、使用しない値を単にプレースホルダーとして扱います。同じ名前が使われても競合は発生しません。

配列パターン

配列にマッチします。配列の要素数に基づき、厳密マッチと最短長マッチがあります。厳密マッチの例:

test("value array", () => {
    let predicate = match('[]')
    let result1 = predicate([])
    let result2 = predicate({})
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

test("array elements", () => {
    let pattern = '[1]'
    let predicate = match(pattern)
    let result1 = predicate([1])
    let result2 = predicate([{ x: 0 }])
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

test("elements elements value", () => {
    let pattern = '[1, 2]'
    let predicate = match(pattern)
    let result1 = predicate([1, 2])
    let result2 = predicate([null, 1])
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

省略記号(...)を含めると、任意の数の追加要素にマッチできます。最短長マッチ:

test("array ELLIPSIS", () => {
    let pattern = '[...]'
    let predicate = match(pattern)
    let result1 = predicate([1])
    let result2 = predicate({})
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})
test("array elements ELLIPSIS", () => {
    let pattern = '[null, ...]'
    let predicate = match(pattern)
    let result1 = predicate([null])
    let result2 = predicate([null, 0])
    let result3 = predicate([0])
    expect(result1).toEqual(true)
    expect(result2).toEqual(true)
    expect(result3).toEqual(false)
})
test("array ELLIPSIS elements", () => {
    let pattern = '[ ..., null]'
    let predicate = match(pattern)
    let result1 = predicate([null])
    let result2 = predicate([0, null])
    let result3 = predicate([null, 0])
    expect(result1).toEqual(true)
    expect(result2).toEqual(true)
    expect(result3).toEqual(false)
})

test("array elements ELLIPSIS elements", () => {
    let predicate = match('[1,2,...,4,5]')
    let result1 = predicate([1,2,3,4,5])
    let result2 = predicate([1,2,3])
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

配列構文では、連続カンマ(穴)や末尾カンマはサポートされません。イテレータもサポートしません。

配列パターンはおおむね次のようにコンパイルされます:

let predicate = input => Array.isArray(input) && every elements matched

オブジェクトパターン

オブジェクトにマッチします。省略記号を含めると、オブジェクトに任意の追加プロパティがあってもマッチします。独自プロパティ(Object.keys)のみをチェックし、プロトタイプチェーンは無視します。プロパティ構文では、特別な属性やショートハンドプロパティをサポートしますが、末尾カンマは使用できません。

test("value object", () => {
    let pattern = '{}'
    let predicate = match(pattern)
    let result1 = predicate({})
    let result2 = predicate({ x: 0 })
    
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

test("object ELLIPSIS", () => {
    let pattern = '{...}'
    let predicate = match(pattern)
    let result1 = predicate({})
    let result2 = predicate({ x: 0 })
    let result3 = predicate([])
    expect(result1).toEqual(true)
    expect(result2).toEqual(true)
    expect(result3).toEqual(false)
})

test("object properties", () => {
    let pattern = '{x}'
    let predicate = match(pattern)
    let result1 = predicate({ x: 0 })
    let result2 = predicate([null, 1])
    
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

test("object properties ELLIPSIS", () => {
    let pattern = '{x,...}'
    let predicate = match(pattern)
    let result1 = predicate({ x: 0, y: 1 })
    let result2 = predicate({})
    
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})
test("properties properties prop", () => {
    let pattern = '{x,y}'
    let predicate = match(pattern)
    let result1 = predicate({ x: 0, y: 1 })
    let result2 = predicate({})
    
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

test("prop key value", () => {
    let pattern = '{x:null}'
    let predicate = match(pattern)
    let result1 = predicate({ x: null })
    let result2 = predicate([null, 1])
    
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

test("key QUOTE", () => {
    let pattern = '{"1":null}'
    let predicate = match(pattern)
    let result1 = predicate({ '1': null })
    let result2 = predicate([null, 1])
    
    expect(result1).toEqual(true)
    expect(result2).toEqual(false)
})

オブジェクトパターンは次のようにコンパイルされます:

let predicate = obj => typeof obj === 'object' && obj && !Array.isArray(obj) && every props matched

ORパターン

ORパターンは、縦棒記号(|)で2つのパターンを接続し、いずれかがマッチすれば全体がマッチします。

test("OR", () => {
    let predicate = match('string|{...}')
    let result1 = predicate('')
    let result2 = predicate({})
    let result3 = predicate([])
    let result4 = predicate(x => x)
    expect(result1).toEqual(true)
    expect(result2).toEqual(true)
    expect(result3).toEqual(false)
    expect(result4).toEqual(false)
})

ORパターンは次のようにコンパイルされます:

let predicate = obj => pat1 matched || pat2 matched

ネストパターン

任意の深さのデータ構造にマッチできます。

結果のメモ化

上記のコード例では、match(pattern)を使ってキャッシュしていますが、実際にはif else条件文とパターンマッチを組み合わせることがよくあります。

    let handler = x => {
        if (match('[...]')(x)) console.log('配列')
        else if (match('{...}')(x)) console.log('オブジェクト')
        else if (match('string')(x)) console.log('文字列')
    }
    handler([1]) // 配列と出力

このコードはキャッシュされておらず、handler関数が呼ばれるたびにパターンが再解析され、パフォーマンスに悪影響を及ぼします。そこでキャッシュが必要です。

    let arrayPredicate = match('[...]')
    let objectPredicate = match('{...}')
    let stringPredicate = match('string')
    let handler = x => {
        if (arrayPredicate(x)) console.log('配列')
        else if (objectPredicate(x)) console.log('オブジェクト')
        else if (stringPredicate(x)) console.log('文字列')
    }
    handler([1]) // 配列と出力

上記のプログラムはパフォーマンス問題を解決していますが、中間変数が増えてコードが複雑になりがちです。そこで別の方法を使います。

import { match, cond } from 'structural-comparison'

let handler = cond([
        [match('[...]'), x => { console.log('配列') }],
        [match('{...}'), x => { console.log('オブジェクト') }],
        [match('string'), x => { console.log('文字列') }],
        x => {
            console.log('マッチしませんでした!')
        }
    ])

    handler([1]) // 配列と出力
    handler({})  // オブジェクトと出力
    handler(1)   // マッチしませんでした!と出力

cond関数は関数を返すコンビネータで、if else文を模倣します。配列を受け取り、各要素が条件文の分岐を表します。分岐には2種類あります。1つ目は、アサーション関数とアクション関数からなる配列で、アサーションが真の場合にアクションを実行してその結果を返し、偽の場合は次の分岐に進みます。2つ目は関数そのもので、その関数が真と評価される値を返す場合にその結果を全体の結果とし、偽の場合(undefinedなど)は次の分岐に進みます。condは各分岐を順に実行し、最初に真となった分岐の結果を全体の結果として返し、後続の分岐は無視されます。

タグ: structural-comparison javascript パターンマッチング 関数型プログラミング cond

6月18日 20:04 投稿