GroovyによるDCIアーキテクチャのサンプル実装で明らかになった2つの問題点をScalaによって解決する。
導入
前回のエントリでは、DCIアーキテクチャの構想に従った実装を示しつつ、言語的な制約から来る問題点を2つ提示しました。以下に再掲します。
- ミックスインしたロールクラスにキャストすることができないため、ロールクラスを使用する際に型の安全性が確保されていない。追記:asTypeを使用することで、ミックスインしたクラスへのキャストが可能です。uehaj様より指摘を頂きました。(2010/06/04)
- ロールクラスが定められたふるまいを実行するためにはデータ構造についての知識が必要だが、それを静的に保証することができていない。
これらはいずれも型の安全性に関する問題で、アーキテクチャの問題というよりは言語仕様の問題です。そこで今回はScalaを使用し、これらの問題をトレイトと抽象メンバによって解決することにします。
また、実装の置き換えに合わせて、以下の問題についてもリファクタリングを行います。
PercentagePie
とAmountPie
の間でコードの重複が多い。AmountPie
のincrease
/decrease
で、処理のシンメトリーが崩れている。
なお、トレイトを使うことに伴う変更が1点あります。Groovyではインスタンスに対して別のクラスをMixinすることができましたが、Scalaでトレイトを注入できるのは型の宣言時かインスタンスの生成時のみとなります。今回のサンプルでは「DCIアーキテクチャ」に習い、インスタンス生成時にトレイトを注入することとします。ただし、実際には既に値のあるデータオブジェクトに対してミックスインを行いたいケースも多いと思いますので、常にデータオブジェクトのインスタンス生成が必要になるとすると、データの受け渡しに関する考慮が別途必要になるでしょう。
説明に使用するサンプルの要件は、前回のエントリで使用したものと同じです。前回はDCIアーキテクチャの概要についても解説していますので、まだお読みでない方は先に進む前に読んで頂くことをおすすめします。なお、今回はScalaを利用する目的を型の安全性の確保に置いており、「関数型」のパラダイムは重視していません。随所に手続き的な処理が出てきますがご了承下さい。
サンプル:ローンシンジケート(Scala)
モデル
基本的な概念レベルではモデルに変更はありません。ただし、SharePie
をトレイトに変更している点、またトレイトのミックスインのためにFacility
クラスのフィールドloan
を最初nullで初期化している点に違いがあります。
class Facility(val id:Int) { var limit:BigInt = 0 var loan:Loan = null var shares = Map[Int, Share]() } class Loan { var shares = Map[Int, Share]() } class Share( val ownerId:Int, var amount:BigInt) { }
ロール
ロールクラスをトレイトとして実装している点が今回のポイントです。まずはコンテキストクラスにおけるインスタンス生成部分を示します。
class LoanSyndicate { private var _facility:Facility = null private var percentagePie:PercentagePie = null private var lender:Lender = null private var amountPie:AmountPie = null def buildFacility(id:Int):LoanSyndicate = { var facility = new Facility(id) with Lender with PercentagePie var loan = new Loan with AmountPie facility.loan = loan _facility = facility percentagePie = facility lender = facility amountPie = loan this }
Facility
クラスとLoan
クラスのインスタンス生成時にwithキーワードを使って各ロールをミックスインしています。Loan
クラスのインスタンス生成もコンテキストで行い、ミックスイン後のインスタンスをFacility
クラスのフィールドに設定しています。ここでのポイントはロールクラスを宣言しているフィールドです。
// ロールクラスを宣言しているフィールドを抜粋 private var percentagePie:PercentagePie = null private var lender:Lender = null private var amountPie:AmountPie = null
それぞれをロールクラスの型で宣言することができているため、以降の処理はタイプセーフに記述することができます。
さて、ロールクラスであるPercentagePie
とAmountPie
はどちらも「会社ごとの分け前をデータとして保持し、そのデータに対して必要なふるまいを実行する」という性質を共有していたので、両者をトレイトに変更したSharePie
のサブクラスとして実装することにしました。このSharePie
クラスの一部を示します。
trait SharePie { var shares:Map[Int, Share] def totalAmount:BigInt = { var sum:BigInt = 0 shares.values foreach { share => sum += share.amount } sum }
メンバとして宣言されているshares
は抽象メンバです。この段階では実際の値を持ちませんが、トレイト内ではsharesはタイプセーフに利用できます。同時にこの抽象メンバはミックスインされる先との契約となり、ミックスイン先がこのメンバを持っていない場合はコンパイルエラーとなります。
以上により、冒頭に提示した型の安全性に関する2つの問題はクリアすることができています。
リファクタリング:処理のシンメトリー
AmountPie
クラスのGroovyコードを再掲します。
public class AmountPie { void increase(int ownerId, BigInteger amount) { shares.get(ownerId).amount += amount } void decrease(BigInteger amount) { BigInteger totalAmount = shares.totalAmount() shares.each { Share eachShare = it.value eachShare.amount -= amount * ( eachShare.amount / totalAmount) } }
ご覧の通り、「会社を指定して増やす」「減らす時には各会社に内部で振り分ける」と処理が対称ではありません。これは「引出時はローン出資割合に従って各会社からのローンが増え、返済時は貸し出している金額に応じて各会社のローンが減る」という非対称の要件を引きずってしまったものですが、この非対称性は「貸し手」のロジックであり、抽象的な分け前クラスとしてはもう少し中立的にふるまうべきでしょう。そこで、「基準となる分け前に従って分配する」という考え方を取ることにしました。
trait AmountPie extends SharePie { def increase(amount:BigInt, criterion:SharePie) = { val currentTotalAmount:BigInt = criterion.totalAmount criterion.shares.values foreach { share => shares(share.ownerId).amount += criterion.allot(share.ownerId, amount, currentTotalAmount) } } def decrease(amount:BigInt, criterion:SharePie) = { val currentTotalAmount:BigInt = criterion.totalAmount criterion.shares.values foreach { share => shares(share.ownerId).amount -= criterion.allot(share.ownerId, amount, currentTotalAmount) } } }
その上で、どういう時にどの分け前を使うかは貸し手が判断します。
trait Lender { var limit:BigInt def draw(amount:BigInt, amountPie:AmountPie, percentagePie:PercentagePie) = { amountPie.increase(amount, percentagePie) decreaseLimit(amount) } def pay(amount:BigInt, amountPie:AmountPie) = { amountPie.decrease(amount, amountPie) increaseLimit(amount) }
SharePie
の責務がはっきりした所で、ロールモデルの責任分担もかなり明確になったと思います。データモデルの世界とロールモデルの世界を合わせて図示すると概ね以下のようになります。
この2重のモデルは確かにメンタルモデルに合致したものです。このモデルに忠実に従ってデータとロールを実装しつつ、コンテキストにおいて両者を自然に結びつけることができている点が、DCIアーキテクチャの実装言語としてScalaが極めて優れている点でしょう。
まとめ
「型の安全性を保ちながらデータとふるまいを分割すること」は、Scalaを用いることにより「抽象メンバを宣言したトレイト」という形で実現することができます。しかし、メンタルモデルという観点からすれば、ここまででまだ半分です。処理を細かい粒度に分割すること、分割した処理を何らかの単位にまとめること、この2つをメンタルモデルと整合した一定の抽象的な観点から行われなければ、結局はコンポーネントが散逸する混沌としたアーキテクチャになってしまいます。DCIアーキテクチャは思想的に「ロール」という概念を導入することによってふるまいをモデリングする際の指針を与えています。メンタルモデルの実装というDCIアーキテクチャが本来目指している観点からすると、この点が最も重要なものであるということを強調しておきたいと思います。
なお、今回使用したサンプルはすべてこちらで公開しています。
関連リンク
追記(2010/06/06)
せっかくScalaで実装しているので、クロージャや畳み込みを使用してSharePie関連クラスのリファクタリングを行いました。最終的なコードを示します。
trait SharePie { var shares:Map[Int, Share] def totalAmount:BigInt = (BigInt(0) /: shares.values) ((sum, share) => sum + share.amount) def add(share:Share) = shares += ( share.ownerId -> share ) def allot(ownerId:Int, amount:BigInt, totalAmount:BigInt):BigInt = amount * shares(ownerId).amount / totalAmount def transfer(from:Company, to:Company, amount:Int) = { assert(BigInt(amount) <= shares(from.id).amount) shares(from.id).amount -= amount shares(to.id).amount += amount } } trait PercentagePie extends SharePie { def validate():Boolean = { BigInt(100) == totalAmount } } trait AmountPie extends SharePie { def increase(amount:BigInt, criterion:SharePie) = allotToEachShare(amount, criterion, (share:Share, allot:BigInt) => share.amount += allot) def decrease(amount:BigInt, criterion:SharePie) = allotToEachShare(amount, criterion, (share:Share, allot:BigInt) => share.amount -= allot) private def allotToEachShare(amount:BigInt, criterion:SharePie, operate:(Share, BigInt) => Unit) = { for { thatShare:Share <- criterion.shares.values currentTotalAmount:BigInt = criterion.totalAmount } operate(shares(thatShare.ownerId), criterion.allot(thatShare.ownerId, amount, currentTotalAmount)) } }