【ITニュース解説】The Expression Problem and its solutions (2016)
2025年09月07日に「Hacker News」が公開したITニュース「The Expression Problem and its solutions (2016)」について初心者にもわかりやすいように丁寧に解説しています。
ITニュース概要
プログラムの拡張性における「式問題」とは、新しいデータ型と新しい操作の追加を、既存コードの修正なしに両立させるのが難しいという課題。オブジェクト指向と関数型で得手不得手が異なり、この問題を解決する様々な手法が存在する。
ITニュース解説
システムエンジニアを目指す上で、ソフトウェアをいかに柔軟に、そして将来にわたって変更しやすく設計するかは非常に重要な課題だ。ソフトウェアは一度作ったら終わりではなく、機能追加や改善、不具合修正といった変更が常に発生する。このような変更のしやすさを「拡張性」と呼ぶが、この拡張性を追求する中で、昔から開発者を悩ませてきた一つの問題がある。それが「Expression Problem(エクスプレッション・プロブレム)」と呼ばれるものだ。
Expression Problemとは、簡単に言えば、プログラムを構成する「データ型」と「操作」という二つの要素を、どちらも独立して拡張できるようにするのが難しい、という問題のことだ。ここで言う「データ型」とは、例えば「数値」や「文字列」、あるいは「足し算の式」や「掛け算の式」といった、プログラムが扱う情報の種類や構造を指す。一方「操作」とは、これらのデータ型に対して行う処理のことだ。例えば、計算式を実際に計算して結果を出す「評価」や、式を人間が読める文字列に変換する「表示」、あるいは式をより効率的な形に変換する「最適化」といったものが操作にあたる。
この問題の核心は、新しいデータ型(例えば、新しく「引き算の式」を追加する)を追加する際に、既存のすべての操作(評価、表示、最適化など)に変更を加える必要がないようにしたい、という要求と、新しい操作(例えば、新しい「圧縮」という操作を追加する)を追加する際に、既存のすべてのデータ型(数値、足し算、掛け算など)に変更を加える必要がないようにしたい、という要求を、同時に満たすのが難しいという点にある。どちらか一方の拡張性は比較的簡単に実現できるが、両方を同時に実現するのは困難な場合が多い。
この問題に対して、これまで様々なプログラミングパラダイムや設計パターンが挑戦してきた。まず、多くの人が学ぶであろう「オブジェクト指向プログラミング(OOP)」のアプローチを見てみよう。オブジェクト指向では、データ型を「クラス」として定義し、そのデータ型に対する操作を「メソッド」としてそのクラスの中に記述するのが一般的だ。例えば、「数値の式」クラスや「足し算の式」クラスを作り、それぞれに「評価する」メソッドや「表示する」メソッドを持たせる。
この設計の場合、新しいデータ型、例えば「引き算の式」を追加するのは比較的簡単だ。新しい「引き算の式」クラスを作り、その中に「評価する」メソッドと「表示する」メソッドを実装すれば良い。既存のクラスにはほとんど変更を加える必要がない。しかし、ここに新しい操作、例えば「最適化」という操作を追加したい場合、問題が発生する。「最適化」メソッドを既存のすべての「数値の式」クラス、「足し算の式」クラス、「掛け算の式」クラス…といった一つ一つのクラスに追加していかなければならない。これは、変更箇所が多岐にわたり、コードの保守性を損なう可能性が高い。
この問題を少しでも軽減するために考案されたのが、「Visitorパターン」と呼ばれる設計パターンだ。Visitorパターンでは、操作をデータ型から分離し、「訪問者(Visitor)」と呼ばれる別のオブジェクトとして定義する。各データ型は、自分自身を訪問者オブジェクトに「受け入れ(accept)」させるためのメソッドを持つ。これにより、新しい操作を追加する際は、新しいVisitorクラスを作るだけでよく、既存のデータ型クラスには変更を加える必要がなくなる。一見するとExpression Problemを解決したように見えるが、今度は新しいデータ型(例えば「引き算の式」)を追加する際に、既存のすべてのVisitorクラスに対して、新しいデータ型を「訪問する」ためのメソッドを追加する必要が生じる。結局、どちらか一方を拡張すると、もう一方に影響が出るという構造は変わっていない。
次に、「関数型プログラミング(FP)」のアプローチを考えてみよう。関数型プログラミングでは、データ型を「データ構造」として定義し、操作をこれらのデータ構造を受け取る「関数」として定義するのが一般的だ。例えば、数値、足し算、掛け算といった式の種類を、それぞれのケースを持つ一つのデータ構造として定義し、このデータ構造を受け取って評価する「評価関数」や、文字列に変換する「表示関数」を作成する。これらの関数の中では、「パターンマッチング」という機能を使って、受け取ったデータ構造の種類に応じて異なる処理を行う。
この設計の場合、新しい操作、例えば「最適化関数」を追加するのは簡単だ。新しい最適化関数を作り、その中で既存のすべての式の種類(数値、足し算、掛け算など)に対する最適化のロジックを記述すれば良い。既存の関数やデータ構造には変更を加える必要がない。しかし、ここに新しいデータ型、例えば「引き算の式」を追加したい場合、問題が発生する。新しい「引き算の式」の種類をデータ構造に追加した上で、既存のすべての操作関数(評価関数、表示関数、最適化関数…)に対して、「引き算の式」の場合の処理を追加していかなければならない。これもまた、変更箇所が多岐にわたり、保守性の問題が生じる。
このように、オブジェクト指向プログラミングも関数型プログラミングも、それぞれ得意な拡張の仕方があるものの、Expression Problemが求める両方の拡張性を同時に達成することは難しいのが現状だった。しかし、近年のプログラミング言語の進化、特に「ジェネリクス(Generics)」や「型クラス(Type Classes)」といった、より高度な型システムを持つ言語機能の登場により、この問題に対するより洗練された解決策が見出され始めている。
これらのアプローチでは、「データ型」と「操作」をそれぞれ独立した形で定義し、それらの間の「関連性」や「契約」を型システムを通じて表現する。例えば、Scalaのような言語では、「トレイト」(Javaのインターフェースに似た概念)を使って、式一般の抽象的な振る舞いや、評価や表示といった操作の抽象的な定義を行う。そして、具体的な数値の式や足し算の式は、これらの抽象的な定義を実装する。
型クラスという考え方を用いると、特定のデータ型が特定の操作の振る舞いを「持っている」ということを、データ型自身の定義とは別に、後から「宣言」できるようになる。例えば、「評価可能」という型クラスを定義し、「数値の式」や「足し算の式」がそれぞれどのように「評価可能」であるかを、個別に記述する。これにより、新しいデータ型(引き算の式)を追加したい場合は、引き算の式の定義と、それに対する「評価可能」な振る舞いを記述するだけで良い。既存の「評価可能」な型クラスの定義や、他の式の定義を変更する必要はない。同様に、新しい操作(最適化)を追加したい場合は、「最適化可能」という新しい型クラスを定義し、既存のデータ型(数値の式、足し算の式など)それぞれに対して、どのように「最適化可能」であるかを記述するだけで良い。既存のデータ型や他の操作の定義を変更する必要はない。
このように、高度な型システムは、データ型と操作という二つの軸を独立して拡張できる柔軟性を提供する。これにより、ソフトウェアはよりモジュール化され、将来の変更に対して強固な設計が可能になるのだ。システムエンジニアとして、このような設計の課題と解決策を理解することは、将来、大規模で複雑なシステムを構築し、長期間にわたって保守していく上で、非常に強力な武器となるだろう。Expression Problemは単なる学術的な問題ではなく、日々のソフトウェア開発において、いかに変更に強いコードを書くかという実践的な問いかけなのである。