概念モデリングの古典『アナリシスパターン』(以下アナパタ)では多くの「モデリングの原則」が紹介されているが、その中でも「知識レベルと操作レベルの分離」は特に有名で、ドメイン駆動設計にも取り入れられている。
概念のモデルなので実装の手法にはいろいろあるが、今回は Scala + shapeless を使った型レベルプログラミングを試してみる。
お題
アナパタ掲載の各種パターンには、随所に「知識レベルの分離」が取り入れられているが、ここでは最初に知識レベルが導入される「責任関係(Accountability)」(図2.9)を実装する。
特にコメント部分の制約の表現に、shapeless の CoproductとSelectorを活用する。
バージョンと動くコード
- Scala: 2.13.0
- shapeless: 2.3.3
- ソース(scala worksheet). 1
パーティ型 - PartyType
知識レベルのパーティ型は、PartyTypeクラスとその派生クラスで表現する。これらは型パラメータとしてのみ現れ、コンパイル時に整合性がチェックされるが、インスタンスが作られることはない。
sealed trait PartyType
sealed trait Region extends PartyType
sealed trait Division extends PartyType
sealed trait Doctor extends PartyType
sealed trait Team extends PartyType
sealed trait Patient extends PartyType
Java などの感覚だと、Classクラスや類似のメタクラスでパーティ型を表現し、そのインスタンスの集合を通常のコレクションに入れて包含関係を実行時にチェックしたり、あるいはリフレクションを使って継承関係を調べたりしたくなるかもしれないが、ここではその方式は採らない。
パーティ - Party
操作レベルでパーティ型に対応する**パーティ(Party)**は、以下のように書いた。
型パラメータとして付与されたT <: PartyTypeは、Partyクラスの中で使われることもなく、コンパイルが通れば消えてしまうファントムタイプとなるが、こうした方式でも、図2.9の「あるパーティについてパーティ型が一つだけ決まる」という対応関係は表現できる。
case class Party[T <: PartyType]()
以下のようにインスタンスを作る。
val johnSmith = Party[Patient]()
val renalUnitTeam = Party[Team]()
val markThursz = Party[Doctor]()
val bostonRegion = Party[Region]()
val cappuccinoDivision = Party[Division]()
責任関係 - Accountability
主役の**責任関係(Accountability)**は操作レベルに置かれ、委任者(commissioner)の分と責任者(responsible)の分と、それぞれ一つずつパーティを参照する。後述の責任関係型との対応は型パラメータで与えられるが、これもファントムタイプになる。
trait Accountability[AT <: AccountabilityType, C <: PartyType, R <: PartyType] {
val commissioner: Party[C]
val responsible : Party[R]
}
※ ActionとTimePeriodは割愛した。
責任関係型 - AccountabilityType
**責任関係型(AccountabilityType)**は知識レベルで定義され、委任者の型の集合(Commissioners)と責任者の型の集合(Responsibles)を参照する。
上述の通り、パーティ型を表すメタな型は定義しない趣向なので、通常のコレクションのようなデータ構造は使わない。その代わり shapelessのCoproductを用いて、型として型の集合を表現する。
trait AccountabilityType {
type Commissioners <: Coproduct
type Responsibles <: Coproduct
def instance[C <: PartyType, R <: PartyType](
aCommissioner: Party[C],
aResponsible: Party[R]
)(implicit
sc: Selector[Commissioners, C],
sr: Selector[Responsibles, R]
): Accountability[this.type, C, R] =
new Accountability[this.type, C, R] {
val commissioner: Party[C] = aCommissioner
val responsible: Party[R] = aResponsible
}
}
メソッドinstanceは、与えられた委任者と責任者からAccountabilityインスタンスを生成するが、このとき 図2.9 の制約がコンパイラにチェックされる。
- $x.commissioner.type \in x.type.commissioners$
- $x.responsible.type \in x.type.responsibles$
ここで、型の集合の要素であることを表現するために、shapeless のSelectorを使っている。
AccountabilityType自体のインスタンス生成コードでも、(1)委任者/責任者に一つ以上の型が含まれていて(IsCConsを使用)、(2)それがパーティ型の派生クラスでなければならない(Unifierを使用)といった制約が、コンパイル時にチェックされる。
object AccountabilityType {
def apply[C <: Coproduct, R <: Coproduct] = new {
def apply[PC <: PartyType, PR <: PartyType]()(
implicit consC: IsCCons[C],
consR: IsCCons[R],
unifierC: Unifier.Aux[C, PC],
unifierR: Unifier.Aux[R, PR]
) = new AccountabilityType {
type Commissioners = C
type Responsibles = R
}
}
}
Accountabilityの生成時にもAccountabilityTypeの生成時にも、implicit パラメータで与えられたオブジェクトそのものは使ってはいないが、コンパイラによる implicit の解決を、制約条件が充足されていることの証明(proof)として利用している。
インスタンスは以下のように生成する。サンプルとして委任者={地域}、責任者={部門}となるような地域構造型と、委任者={患者}、責任者={医師, チーム}となるような患者同意型を定義した。
// 地域構造型
val regionStructureType = AccountabilityType [
Region :+: CNil,
Division :+: CNil
]()
// 患者同意型
val patientConsentType = AccountabilityType [
Patient :+: CNil,
Doctor :+: Team :+: CNil
]()
以下のような責任関係型の定義は、implicit が見つからない、つまり制約が満たされないためコンパイルに失敗する
// 委任者が空
// val ngType1 = AccountabilityType [CNil, Division :+: CNil]()
// 責任者に PartyType でないものが含まれている
// val ngType2 = AccountabilityType [Region :+: CNil, String :+: CNil]()
責任関係インスタンスを得るには、上述の責任関係型のinstanceメソッドを用いる。
// 患者同意型: 患者 John Smith から医師 Mark Thursz への同意
val pc1 = patientConsentType.instance(johnSmith, markThursz)
// 患者同意型: 患者 John Smith から腎単位チームへの同意
val pc2 = patientConsentType.instance(johnSmith, renalUnitTeam)
// 地域構造型: カプチーノ部門はボストン地域に含まれる
val r = regionStructureType.instance(bostonRegion, cappuccinoDivision)
以下の例は、制約を満たさないためコンパイル時にエラーとなる。
// 患者から患者への同意
// val ng1 = patientConsentType.instance(johnSmith, Party[Patient])
// 部門が地域を含む
// val ng2 = regionStructureType.instance(Party[Division], Party[Region])
所感
- 当然ながら静的な制約に限られる手法になるが、静的に表現できる、あるいはすべき場合には、メタクラス+リフレクションを使った実行時制約チェックからの例外送出などよりも安全確実に書けそう。
- というか、実行時にメモリ上の何かを調べて制約を満たしているか否かをチェックする方法(HOW)を記述する命令型な考え方とちがって、コンパイラでも整合性をチェックできるような型として対象が満たすべき論理的制約(WHAT)を記述しているわけだから、大幅に宣言型なプログラムになる。
- もちろん DDD が称揚する declarative style にもつながる。
- 工夫すれば、型の包含関係にとどまらず、もっと複雑な知識レベルの制約も型で表現できそう。
- 制約を満たさない場合のコンパイルエラー時のメッセージは、若干わかりにくい。
-
IntelliJ で動かすときは Run Type を REPLでは なく Plain に設定 ↩
