論文抄訳:Haskellを使ってソフトウエア・設計を教える

以下は,ユトレヒト大学情報・計算科学学科に在籍していた Alejandro Serrano 氏が発表した論文を個人的に抄訳したものである。原論文は2014年に発表されている。

Haskellのライブラリにはさまざまな種類があるが,どのライブラリが何の役に立つかは,一つひとつライブラリを見ていくか,あるいはネット上の記事や書籍などを探していく必要がある。Haskellの基礎を学んだ段階の者にとって,この作業はかなり大変であるため,どこかにまとまった情報があるかと探してみた所,少し古い論文ではあるが,ソフトウエア・アーキテクチャとHaskellのライブラリの対応関係を紹介している論文を見つけた。

日本語に翻訳するとともに,せっかくなので,Haskell Advent 2020にも投稿してみることにした。

魚野は計算機科学の学会や,コンピュータに関連する会社には属していないため,訳語は一般的に使われているものとは異なる場合がある。

なお,翻訳の間違いなどは,すべて魚野の責任に帰するが,内容については,最新の状況でも妥当であるといえるかどうかは保証するものではない。


1.初めに

本論文では,ソフトウエア・構造(アーキテクチャ)をいくつか取り上げる。現状では,教育現場で様々なソフトウェア・構造を全て詳しくとりあげることは不可能で,ダイアグラム図や説明文を書いたりするのがせいぜいである。

本論文では,Haskellをソフトウエアの構造設計教育の道具として使うというものである。Haskellを教室に持ち込むことで得られる幾つかの効用を紹介し,一般的な構造パターンの多くがHaskellのライブラリと対応していることを紹介する。

2. Haskellを使うことの効用

Haskellは純粋遅延評価関数型言語であり,パラメタ多相,型クラスや一般代数型を利用した臨時多重定義(ad hoc overloading)など多様な抽象化手法を備えている。こうした特徴から,Haskellは,簡潔で再利用性が高く,保守性の高いコードが生み出せると言われる。だが,こうした主張はソフトウエアの低次元,あるいは中次元での優位性しか言及できていない。Haskellを使ってより高い次元のソフトウエア・アーキテクチャを教えることによる効用を紹介する。

2.1 ソフトウエアの構造設計の習得

現在,Haskellを使う開発者は,Haskell用のパッケージ登録サイトHackageに掲載された大量のライブラリが利用可能であるが,そのうちのいくつかは,ソフトウエア・構造のパターンに直接的に対応した作りとなっている。

例えとして,stm (Software Transactional Memory)ライブラリを取り上げよう。簡単に言えば,STMは,トランザクション(一連のひとかたまりの処理)の際,アクセスが管理される特別な仕組みを持つ変数を利用して,同時に発生したアクセスが順次適切に処理され,値の変更などが,一蓮の処理が完了して最終的に書き込まれる際に確実に適用されることを保証する。こうした説明は,トランザクション・システムの核心を非常によく説明しているが,実は,この仕組は,共有データベース・アーキテクチャについての説明でも使えるのである!

Haskellを使ってソフトウエアの構造設計を教育すると,学ぶ者が実際に実行可能なシステムを作り,システムを使うことができるというだけでなく,Haskellが持つ表現の簡潔さにより見通しが良く,余計な情報が少ないことで構造の理解がしやくなる。

ソフトウエア構造の教程では,慎重な分析,それぞれのアーキテクチャスタイルの得失の熟考,一つ一つの要求に最もふさわしいシナリオが何かということを討議することが必要である。どのアーキテクチャ・パターンがふさわしいかを説明するような更に具体的な事柄については,実装を現実に見ることが材料を理解するのに有効と考えている。

2.2 パターンの証明

ソフトウエア・アーキテクチャのパターンを教育することの2つ目の効用は,Haskellのライブラリが少量の基本的要素とそれを合成して高次の機能を発揮させられる演算子の提供という特徴からもたらされる。基本的要素は,強い型づけがされ,正しい部品の組み合わせでしか合成できないようになっており,それによって適切にしか使用できないようにされている。このような特徴に合わせてHaskellが強い型システムであることから、形式的な証明が容易になる。

  • Haskellのプログラマは,しばしば,プログラム内で等式証明(?equational reasoning)をプログラム内で用いがち,あるいは用いるように強制される。このような方法がとられることにより,属性は内部の細かな関数の曲がりくねった証明ではなく,簡素な代数的表現を使って証明されることになる。
  • 多くの場合,アーキテクチャ・パターンがどのように実装されているかや,どのように模擬されているかを見るのは非常に興味深い。前述のstmライブラリが良い例である:このライブラリは,非限定(unbounded)FIFOチャネルTChanを,STMの基本的要素だけで実装している。この実装を見るだけで,アトミックや共用データベースだけが利用可能なシステムでチャネル機能が利用可能とできることを学ぶことができる。もちろん,この事実はHaskellだけに許されたものではない。しかし,強い型づけや等式証明(equasional reasoning)が,このような構成が確かに動作するという確信を得られる手助けとなっていることは前述のとおりである。我々は,このような属性を,別のパターンの公理からも証明することができるし,すべてがうまく動作するための追加の仮説を正確に導くこともできる。

上述のように,本論文で後ほど取り上げるライブラリは,基本要素の小さな集合の項をインタフェースで定義している。これにより,現実のシステムに必要とされる機能を合成できる(???)という効用がある。例えば,resourcetパッケージは,特定の基本要素によって,システムの資源の確保と開放を安全に行いたいという要求に答えている。

2.3 副作用:さらにHaskellを学ぶ

ソフトウエア・アーキテクチャ教程の一番の目標は,正しいソフトウエアの高次の構造を得るための知識を授けることである。しかし,Haskellを,アーキテクチャ・パターンを学ぶ道具として使うと,素晴らしい副作用をももたらす。Haskellを使いこなす能力を同時に高められるのだ。

今日,Haskellはプログラムレベルで教えられている:すなわち,基本的な関数の構成や,よく使われる抽象化技法,型クラスなどをどのように使うかということを,使いやすいライブラリなどのソースコードレベルで学ぶ。だが,トピックがソフトウエアのもっと大きな塊を扱うことだとしたら,あるいはシステム全体をあつかうことだとしたら,主たる道具はオブジェクト指向プログラミングであろう。我々は,このような大きなコードの塊となるような場合でも,あるいはユーザーにもとづいた機能が拡大していくような場合でも,Haskellが最適解であると強く信じている。


アーキテクチャ・パターンを探検する際にHaskellを使うことの優位点


  • パターンは実用的なコードの中に入れられている(? Patterns can be put in practice in code )
  • 基本的な要素のみにより,パターンの基本的部品が構成されている(?)
  • 強い型づけにより,不変性がエンコードされ,コードが正直(?)となる (? Strong typing encodes invariants and keeps code honest )
  • (ソフトウェア・構造のパターンのみならず)Haskellを学ぶことができる

3. 主たるパターン

読者はすでに,Haskellのライブラリを使うことがアーキテクチャ・パターンを吸収するうえで,より容易に,より構造的にしていることをすでに感じているであろう。しかし,我々の主張は,こうした吸収にとどまらず,このような考え方から主にもたらされる実際のパターンの便益や,我々の私見だが,それぞれのパラダイムに最も適合するライブラリであることに焦点を合わせたい。我々がライブラリを探すにあたって,2つの性質に特に留意した:

  • 選ぶライブラリは,相対的によく知られており,広く使われてること。それ故に,学ぶ者はHaskellを使って他のプロジェクトにも利用することができる
  • プログラミングの側面で,Haskell言語の基本的な知識以上の特別な要求事項を必要としないこと。それ故,Templete Haskellや,Template Haskellでよく使われるメタプログラムのような非常に優雅な方法を使っていないライブラリを選択する。

パターンを選ぶ際,我々はユトレヒト大学で行われているソフトウエア・アーキテクチャの教程を参照した。このようなパターンは,広く読まれているLen Bass らによる本(訳注:”Software Architecture in Practice” 3rd edition, Addison-Wesley Professional)に由来している。

3.1 緩和された階層 - モナド・トランスフォーマ

モナド・トランスフォーマは,Haskellの道具箱の中でも最もよく知られた要素の一つである。モナド・トランスフォーマで構築されたシステムでは,機能が(モナドで実装された)階層に分離されて提供され,それぞれの階層ではより低位層の機能を呼び出すことができるようになっている。それ故,アーキテクチャパターンの一つ,「緩和された階層(relaxed layers)」を見事に模倣している。

主要な仕掛けは,transformersパッケージ内のMonadTrans型クラスの中にある:

class MonadTrans t where
lift :: Monad m => m a -> t m a
— lift . return = return
— lift (m >>= f) = lift m >>= (lift .f)

liftを使うことにより,階層 mの計算を使って,その機能をより上位の階層 t mで活用することができる。liftを2つのコンポーネント(機能部品)の間のコミュニケーション効果と考えると,様々な階層の中でliftを使うことは,システムの中で多少の費用(犠牲)を伴うことから—実はこれは非常に優れた設計ではあるのだが,階層構造を使っているのだという実感をもたらすことであろう。

個々のトランスフォーマーの厳密な分離により,それぞれの階層が公的なインターフェースを通じて直下の低位層のみアクセスできることが確実になる。これを実現するのは,mtlパッケージの中の「モナド・クラス」という仕組みである。これは,Haskellを使っている際に,アーキテクチャ・パターンの抽象的属性(性質)が台無しにならないように,保証する具体的な方法である。最終的には,各階層は各モナド・トランスフォーマに対応するrunMonadT関数を使うことにより,各階層は具体化される(実行される)。この仕組みは,依存の注入(?dependency injection)のように働き,上位層からは,下位の詳細にアクセスできないことを確実にしている。まことかように,システム内の各階層は,分離された論理的,あるいは物理的機会であり,実際のシステムではアクセスは拒絶される。

我々は,モナド・トランスフォーマーは,厳密な階層ではなく,緩和された階層パターンを実現すると述べた。その理由は,いくつかliftを呼び出すことにより,ある階層にあるモナドスタックは,直下の階層にアクセスできるだけでなく,より下位の階層にもアクセスできるからである。厳密な階層システムでは,階層を飛び越えてアクセスできる機能はなく,どのような階層からの情報であっても,隣り合った階層からしか,アクセスできない。このようなパラダイムに則ったHaskellのライブラリを我々は知らない。一つだけ言えるのは,特定の階層のMonadTransインスタンスを隠してしまえば,そのような抜け道は利用不可能となるということだ。このコンポーネント間で媒介階層を介しない直接的な接続を許すことの得失を考えることは,システムの生産性と保守性の二律背反を学徒が理解するためには極めて有効である。

階層化システムは,一階層に複数のソフトウエア部品が含まれている。しかしながら,モナド・トランスフォーマは,上側の階層が下側の一つの階層を包み込むことしかできない,「垂直的な合成」しか許さない。もし,「水平的な合成」を用いたいのなら,モナド・コプロダクトがその間隙を埋めることとなろう。

3.1.1 非交換性とコントロール・フロー

モナド・トランスフォーマは,階層システムの興味深い視点を提供する:部品が独立であっても,一緒に使うことで全体の機能が増すのだ。もっと形式的な述べ方をするなら,これは通常,モナド合成の非交換性と呼ばれる。例としては,もしステート・モナドあるいはエラー・モナドを使っているとしたら,状態の階層としてエラーを包み隠すことができる。この場合,手続き型言語の例外と同様の文法形式を利用することができる。あるいは,逆の形式も利用可能である。つまり,元の状態に戻し,例外を返すことで,トランザクション(一連の処理)を模倣することもできる。

我々は,このような,システム内の部品同士が互いに独立していることを神経質なまでに仮定する性質は,学ぶ者に示すのに非常に重要であると信じている。加えて,モナドの合成に組み込まれた型を見ると,上述のように新しい概念を学ぶ際,ストリング型づけ(string typing; 強い型付け strong typing の誤記か:訳注)の力に焦点をあてれば,この問題は非常に明瞭となる。

伝統的な階層型システムでは,各部品は データ・フロー にのみ作用し,返り値を得ていた。モナド・トランスフォーマは,この概念を抽象化し,プログラムの制御フローにも作用することを可能にしている。例えとしてListモナドを見てみよう:Listの作用は,行くかの計算結果を生み出すという内容であり,これは非決定的な計算を極めて素晴らしい形でモデル化している。あるいは,例外モナドの場合,プログラムでエラー構造を利用できるプログラムを実現している。

モナド合成に関係する多くの実際的な問題は,データの操作よりも,より制御操作に多く由来する。我々は,ソフトウエア・アーキテクチャの教程でこの問題をより掘り下げることを提案する。なぜなら,これは,プログラムの制御フローに対して影響を与える重要な階層への影響を指し示すからである。

この場合に絶対必要な解決法は,各階層に,積み重ねを重ねていくのに先立って「(中身はともかく,特定の部品のみに依存している)内部状態」をあらかじめ保存できる仕組みをもたせることである。これは,monad-controlライブラリのMonadTrans型クラスで捉えられる。

class MonadTrans t => MonadTransControl t where
data StT t :: * -> *
liftWith :: Monad m => (Run t -> m a) -> t m a
restoreT :: Monad m => m (StT t a) -> t m a
-- liftWith . const . return = return
-- liftWith (const (m >>= f)) = liftWith (const m) >>= liftWith . const . f
-- liftWith (\run -> run t) >>= restoreT . return = t

type Run t = forall n b. Monad n => t n b -> n (StT t b)

このトピックスについてのさらに深い議論や,他のライブラリがいかにこの解決策を実現しようとしているかについては,layerパッケージに記述されている。このパッケージでは,制御の持ち上げ(lifting)についてより正しい実装を与えており,計算資源管理の問題にまで注意を払っている。


緩和された階層構造=モナド・トランス・フォーマ


  • 積み重ねられたそれぞれのモナドは,システム内の各階層を表しており,liftはコミュニケーションを表している。
  • 合成順序は,機能に影響を与える。
  • (例外のような)制御操作は,特別な配慮が必要

3.2 ブローカー と 効果

システム・アーキテクチャを階層構造とする代わりに,各部品を自立型とし,その間を要求を正しい部品に割り当てたり,返答することをつなぐ世話をする部品が仲介するというやりかたもある。この仲介部品は,通常,システムのブローカーと呼ばれる。

extensible-effectsパッケージは,transformersと同じ機能を,ブローカー・アーキテクチャを用いて提供する。前節のそれぞれのモナド・トランスフォーマは,ブローカー・アーキテクチャでは,対応するハンドラ効果( effect )に見える。通常,何かしらの働きがなされるべきとき,要求をブローカーに送り,ブローカーは結果を送り返す。しかし,このフレームワークでは,継続( continuation )を提供し,この継続は,対応するハンドラから呼び出される。

注意しておきたいのは,これは実装レベルのみでの話であり,その外側では,全ては従来の簡素なモナドと同じように見えるということである。例を見てみよう:

example :: Member (Reader Int) r => Eff r Int
example = do
v <- ask return ( v + 1 ) ``` 違いは,Effと,制約 Member (Reader Int) r を使うことだけである。このコードが動作するためには,Reader Int型の要求を処理する部品を必要としているということを示している。これは,runReaderで行う: ```haskell runReader :: Eff (Reader e :&gt; r) w -&gt; e -&gt; Eff r w
runReader m e = loop (admin m) where
loop (Val x) = return x
loop (E u) = handleRelay u loop (\(Reader k) -&gt; loop (k e))

newtype Reader e v = Reader (e -&gt; v)

この型記述(型シグネチャ)によれば,runReaderを使ってReader eが必要となった際は,この結果がハンドリングされ,ひとつ要求が減らされて結果が帰ってくるということになる。しかしもちろん,重要な場所は,実装の中にある。2つの関数が含まれている:

  • adminは,ブローカーに,要求がすでに用意されているかを尋ねる。
  • handleRelayは,要求がこの部品によりハンドリングされうるかを(引数の型を照合することによって)点検を行い,対応するコードを実行するか,あるいは新たな別のハンドラが取り扱う可能性をブローカーが調べられるよう,手を触れずにそのまま返却する。上記の例では,要求が正しい型であれば,環境を表す初期値eで与えられる継続kを得る。他の結果は,別の方法でkをハンドリングするだろう。

それぞれのハンドラがhandleRelayを呼び出す必要が生じるのは,ライブラリのアーキテクチャ的決定(?)に起因し,結果は拡張可能であることは指摘しておくべきだろう。それ故,ブローカーは,あらかじめどのメッセージがどの部品で処理されうるかは知り得る立場にはないので,尋ね回る必要がある。


ブローカー・アーキテクチャ


  • システムのそれぞれの部品は,何かしらの結果のハンドラである。
  • extensible-effectsのアーキテクチャは,部品セットの拡張を許容している。

3.3 パイプとフィルター - StreamsとPipes

パイプ-フィルター・システムでは,部品が直列に並べられている:ひとつの部品の出力は,列の次に並んでいる部品の入力となる。それぞれの部品は,受け取るデータの流れについて,篩がけをしたり,変換したり,新しいデータを作り出したりする。この計算のモデルは,コンピュータの端末でパイプ「|」を使う時のふるまいである。

Haskellの世界では,類似した構成が複数存在する(iteratees,pipes,conduitsなどがそれであり,その名前でライブラリがHackageに登録されている)。これらのライブラリは,パイプ-フィルター構成を実現するためだけでなく,Haskellの遅延入出力が抱える問題にも対処できるよう開発された。歴史的に言えば,このような問題に対応するために最初に登場したのがiterateeライブラリである。そして,最近,pipesが圏論を応用して開発され,conduitはより実用的な視点から開発された。我々の目的の一つは,証明を支援することであるから,pipesを基本に,conduitについてもコメントをしていく。

それぞれの部品は,型Pipe a b rの値である。このシステムの2つの基本的な演算として,型bの値を生成するyieldと,型aの情報の要素を消費するawaitがある。実行の最後には,Pipeは最終的に型rの値を返す。証明を簡単にするために,これらPipe型の関数については,より簡略化した型が用意されている:

-- Void is the empty type
await :: Consumer a a type Consumer a r = Pipe a Void r
yield :: a -&gt; Producer a () type Producer b r = Pipe Void b r

conduitライブラリの名前付けも非常に似ている:PipeやConsumer, Producerがそれぞれ,Conduitや Sink,Sourceに対応している。パイプ-フィルターシステムの部品の公のインタフェースは,この部品が生成・消費するデータであり,型に埋め込まれている。

部品を配置する際は,その合成が必要となる。これを行う主要演算子は,(>->)であり,その型は以下の通りと考えられる:

(&gt;-&gt;) :: Pipe a b r -&gt; Pipe b c r -&gt; Pipe a c r

型の別名を適用するなら,このようにも書ける:

(&gt;-&gt;) :: Producer a r -&gt; Consumer a r -&gt; r

これは,この(Pipes)ライブラリが入力生成部品と消費部品を組み合わせて,結果としてその値を得るということを確実にしている。conduitの世界では,演算子(>->)は(=$=)と呼ばれ,($$)という亜種の演算子が最終結果を得るために用いられる。

catという,受信したものをそのまま送り出すだけの関数を定義すると,話は更に面白くなる:

cat = forever $ await &gt;&gt; yield

この関数を使うと,モノイド則のような,Pipesを構成する規則が得られる。

cat &gt;-&gt; f = f f &gt;-&gt; cat = f
(f &gt;-&gt; g) &gt;-&gt; h = f &gt;-&gt; (g &gt;-&gt; h)

上記の文章は,pipesを使ってパイプ-フィルタアーキテクチャ・パターンを模倣する際の基礎を述べている。しかしながら,上記のライブラリは,更に,特定の条件では非常に有用な,ふたつの付加的機能を提供している:

  • すべてのPipeは,既定のyieldやawaitとは別の,特定のモナド mの操作としても動作可能である。例えば,ディスクに何かを書き込むようなパイプが考えられる。この場合,IOモナドを使うことになるが,副作用が存在する場合,演算子(>->)が満たす法則の重要性が更に増す。
  • パイプの「水平的合成」,つまり一つの出力ストリームが次の入力ストリームに接続する場合について述べた。しかし,このようなストリームに加えて,Pipesは,最終的な値を生成することもできる。Pipeのモナドインスタンスは,このような構成部品の「垂直的結合」も可能としている。

pipesとconduitの最も主要な違いについては,アーキテクチャ的な視点からコメントする価値がある。後者のライブラリ(conduit)は,独創的なleft over (使い残し)の概念を提供している。使い残しとは,列に並んだ部品が入力ストリームに,入手した情報を送り返すことを可能とする仕組みである。このような仕組みが重要となる場面に,例えばパーサーがある:ある場合,システム全体で構文解析(パースツリー)を完成させるために別の枝をたどる,バックトラック(後戻り)が必要となることがある。

使い残しを許容することは,コミュニケーションの流れが逆転し,要素を前のストリームに押し込み返すことをや,各部品がローカルなメモリを使って使い残しのデータを処理することが可能となることを意味する。この問題を学徒の間で検討させることはとても興味深いこととなろう。

前述の通り使い残しについて,conduitは基本的な操作で対応している。pipesの世界では,使い残しの対応は,pipes-parseライブラリで基本要素として対応している。実装は,モナディックな結果(effects)の中に加えられたStateT層をpipeに組み込む方法で実現している:

type Parser a m r = forall x . StateT (Producer a m x ) m r

それ故,この場合,アーキテクチャの決定は,ローカルメモリに追加した情報片という形で保存することである。覚えておいてほしいのは,pipes-parseライブラリはまた,(◎◎を可能にするライブラリの)レンズを使ったパースを支援する機能も含んでいるということであるが,ただし,これらの機能は,実際のアーキテクチャ・パターンとはかけ離れていると我々は考えている。


パイプと篩がけのアーキテクチャ=ストリームとパイプ


  • 部品の並びによるストリームの篩がけや変更は,Pipesによって模倣できる。
  • 基本操作は yieldとawait,および合成演算子の(>->)
  • 使い残しを使うことは,システムのアーキテクチャ・デザインに影響を与える。

3.3.1 pipesの力

アーキテクチャ・パターンを紹介するという本位からはやや外れるが,pipesライブラリは本論文に現れる多くのパターンを一般化する,興味深い設計を含んでいる(ので紹介しておこう?)。Pipes.Coreモジュールを御覧いただくと,現実の基底的な型が,Pipeではなく,Proxyで構築されていることに気づかれるだろう:

data Proxy a' a b' b m r

このProxyと通常のPipeとの違いは,前者が二方向ということにある:一方向には,入力としてaをとってa'を生成し,もう一方向では,b'をとってbを生成するが,内部の効果?モナドmの元でr型の最終的な結果を得る(という点は同じである)。加えて,もし,

e -&gt; Proxy a' a b' b m r

という関数があったとき,値eは,プロキシへのentraな(extraなの誤記?)入力とみなすことができる。

素晴らしいのは,別の方法で「内部の要素をつなぐ」ことによって,別のパターンが得られるということである。pipesを使うときは a と b のみが使われる:yieldは e を b に接続する際に,awaitは a から r に接続する際に使われる。さらに言えば,(>->)は b を次の a につなぐ。その他の演算子を使うと,クライアント-サーバーの関係を模倣できるほか,Unixのプル(引きこみ】型またはプッシュ(押しこみ)型のパイプも模倣できる。

3.4 データフローと MarReduce - IVars

多くの場合,システムを,情報の流れを表す枝で構成されたグラフと見なすことができる。それぞれの要素は,別の要素から情報が入力されるのを待って,適宜処理して新しいデータを作り出す。このような説明は非常に一般的(General)である:本章では,各要素は入力があるまで動作を停止させている場合を扱う。

こうしたパラダイムによくあうHaskellのライブラリは,monad-parである。名前が示しているように,このライブラリが開発された当初の理由は,並行動作を支援するためであるが,これまでに述べたように,見方を帰れば,情報の流れとみるモデルの枠組みに使うこともできる。

このライブラリの核であるParモナドは,操作の二つのモデルを支援する。一つ目のモデルが必要とするのは,futureというアイディア:他のプログラムと並行で発生し,後刻,計算の結果を問い合わせるために使う,futureという型の鍵(トークン)を返す何かしらの計算である。

class Monad m =&gt; ParFuture future m | m -&gt; future where
spawn :: m () -&gt; m ()
get :: future a -&gt; m a

IVarを使えば,更に洗練した設計とすることができる:これは一度限り書き込みができる箱であり,並行動作するプロセス間の通信に使われる。プロセスがIVarから読み込もうとすると,何かしら値が書き込まれるまで待たされる。

class ParFuture ivar m =&gt; ParIVar ivar m | m -&gt; ivar where
fork :: m () -&gt; m ()
new :: m (ivar a)
put :: ivar a -&gt; a -&gt; m ()

通常,futureはivarであることに注意されたい。それゆえ,getはIVarの値を得るための関数である。

これらをソフトウェア・アーキテクチャの視点から見ると,並行動作する各プロセスを生成することは,システムを構成する構成部品をそれぞれ初期化することに対応する。これらの構成部品は,お互いに,IVarという一度限りの書き込みが許された結びつきによって互いにコミュニケーションを行う。

(適合の)もう一つの可能性は,開発者が他のノードとコミュニケーションをspawn(産卵放出)することを可能とするアーキテクチャを探検(explore)することである。Haskellは,学徒に,システムが過剰または過小な計算を散乱放出させてしまうという問題を測定することを可能とする方法をいくつか提供している。

3.5 MapReduceの模倣

今日では,MapReduceパターンは多くのデータ量を扱う場合に非常に重要になってきている。主たるアイディアとしては,計算を二つの段階に分けるということである。Haskellの用語で言えば,mapという段階とその後のfoldという段階である。

monad-parパッケージも,MapReduceアーキテクチャを模倣することができる。必要なのは,入力の特定の部分を使う幾つかのプロセスを産卵放出し,必要な出力を計算させることである。主プロセスは,全てのデータが整うのを待って帰ってきた値を処理する。MapReduceアーキテクチャのパターンを使う時,分画をいつ止めるべきかを知ることが問題となる。

我々は,MapReduceはまさにHaskellの素晴らしさを駆使してうまく取り扱うことのできるアーキテクチャ・パターンの一つであると考えている。最初に,アーキテクチャの多くの部品を,Haskellの式ととらえる。これにより,多くの属性が派生する:例えば,協力関係にある関数の集合などの属性。加えて,一般的なMapReduceの枠組みは,大きくて設定が難しい。Haskellを使えば,学徒が,システムについてどのように振る舞うべきかを試す場を提供することができる。

3.5.1 格子(Lattice)を使ったデータフロー --- LVars

通常,部品同士でやり取りするデータは,次の計算が始まるまでに中身が完全にわかっていなければならないと考えられている。いくつかの場合,このようなデータフローのグラフでは一つの値を必要とし,パイプのような場合はストリームに含まれる次の値を必要とする。もし,あるアーキテクチャの部品が完全に走られていない,あるいは推測されたデータで動作することができるなら,多くの利得がある。我々は,通常この問題はソフトウェア・アーキテクチャの教程からは除外されていると感じているが,Haskellはこのような考えの枠組みを理解したり,説明するのに非常に簡潔な方法を提供している。

lvishパッケージは,このようなモデルを提供している。これを使うと,IVarは複数回の書き込みが許されているLVarに置き換えられる。LVarは単調増加,つまり値の格子?束?(lattice)よりも高次である。もしこの値の増加がよりよい,あるいはより正確な情報なら,多くの場面に適用可能となる。


データフロー・グラフとMapReduce = IVarとLVar


  • データフロー・グラフはIVarによってモデル化できる
  • MapReduceパターンは,IVarを待ついくつかの計算のばらまきにより模倣できる。
  • システムは,LVarを使うことで単調増加を生成することができる。

3.6 共有データベース - ソフトウェア・トランザクション・メモリ

別の便利なアーキテクチャ・パターンは,部品が情報をとってきたり,変更をすることのできる公共(?Common)データベースを必然的に伴う。部品同士の調整なしに,システムのデータ層で統合が発生する。しかしながら,共有データベースへのアクセスは,正しい方法,すなわちデータベースの標準に委任されているように,データが持続し,不整合がない状態が保たれる方法で,行わなければならない。

ソフトウェア・トランザクション・メモリは,簡素なプログラム変数にアクセスすることでこのような属性を実現する。基本的なアイディアとしては,もし,コード片がデータベースにアクセスして処理を失敗したとき,変更をもとに戻すことができれば,データの一貫性を保てるのではないかという考えである。もし操作が失敗することが許されているなら,システムは並行アクセスの間の衝突を避けることに楽観的な見方をすることができる。

このような制約のもとでは,Haskellは最良の選択である:なぜなら,純粋な関数は副作用をおこさないので,なにか失敗が起きたとしても,遠慮なしに再挑戦できるからである。stmパッケージが実際の実装を提供する:すべてのトランザクションは,STMモナドの内部で実装されなければならず,また,TVarと呼ばれる一つあるいは複数のトランザクション変数をアクセスすることがある。

newTvar :: a -&gt; STM (TVar a)
readTVar :: TVar a -&gt; STM a
writeTVar :: Tvar a -&gt; a -&gt; STM ()
modifyTVar :: TVar a -&gt; (a -&gt; a) -&gt; STM ()

一度,このような基本的な塊(?pieces)を使ってすべてのトランザクションを構築したら,返り値としては,STM b型の値を受け取る。しかし,その結果(?effect)はまだ利用不可能で,トランザクションを実行したいなら,atomiccallyを必要とする。

atomically :: STM a -&gt; IO a

トランザクションが,一貫性のない状態となってしまうこともあるだろうが,その場合,retryを使うことになる。その場合,トランザクションは新しい値がTvarに設定されるまで,動作を停止させられる。その他の選択肢として,最初のトランザクションが失敗した時に別のトランザクションの実行に挑戦するorElseを使うこともできる:

retry :: STM a
orElse :: STM a -&gt; STM a -&gt; STM a

上記を見ていただくと分かる通り,この場合のインタフェースは極めて簡素であり,単純な方法で学徒がアーキテクチャ・パターンの課題をこなすために十分である。


共用データベース・アーキテクチャ = ソフトウェア・トランザクション・メモリ


  • STMによって,ACID属性を満たす変数にアクセスすることができる。
  • TVarのセットを使うことで,部品間の共用データベースを模倣することができる。

3.7 1対1 - クラウドHaskellのActor

前述のアーキテクチャ・パターンでは,中央制御やデータバンク,あるいは同じ「カテゴリ」に属するいくつかの部品があった。1対1パターンは,システムに参加する,様々な役割を果たす多くの部品を分離することを可能とする。この分離された部品は,互いに,メッセージのみをやりとりする。このような部品はシステムの最終的な機能を提供するために互いに分散していながら協調して動作する。

Cloud Haskellは,私達に馴染みのある言語で,actorsを主要な抽象として(?),1対1のシステムを構築することを可能にさせる。distributed-processパッケージでは,上述のような概念が実装されているが,それぞれの役者(アクター)はProcess a型の値として表現され,ネットワーク内で重複のないProcessIDが割り当てられている。役者(アクター)がコミュニケーションを取る際は,sendまたはexpectを使うが,後者はその型を指定してメッセージを受け取る。

send :: Serializable a =&gt; ProcessID -&gt; a -&gt; Process ()
expect :: forall a. Serializable a =&gt; Process a

特定の役者(アクター)で一つ以上の型のメッセージが使われる場合,Cloud Haskellは,receiveWaitという関数を提供する。この関数は,入力として,メッセージに合致する可能性があり,どのように処理すればよいかを知っている要素のリストをとる。

receiveWait :: [Match b] -&gt; Process b
-- Build matches in several ways
match :: forall a b. Serializable a =&gt; (a -&gt; Process b) -&gt; Match x
matchIf :: forall a b. Serialize a
matchUnknown :: Process b - &gt;Match b

設定変更が優しいため,CloudHaskellは,最も広く使われているクライアント-サーバー・アーキテクチャとは全く違う1対1システムの実験には学徒にとって非常に都合の良い道具となろう。このようなシステムを実装するために,学徒が面するのはアクター間の情報の一貫性をどう保つのか,どのように資源を管理するのか,思わぬネットワークの機能停止やエラーをどう扱うのかである。

3.7.1 チャネル

完全なフリー・アクター・システムを動作させる際の問題の一つに,システム内でメッセージを送信する,あるいは受信するアクターは一つだけしか許されないというものがある。これ(この制約)は嬉しいものではないが,またしても型システムを使えば,この意図について正直になることができる。Cloud Haskellでのアイディアでは,チャネルを使う。チャネルは,特定の型のメッセージのみを受け付けるメッセージ取り扱いができるのだ。

newChan :: Serializable a =&gt; Process (SendPort a, ReceivePort a)
sendChan :: Serializable a =&gt; SendPort a -&gt; a -&gt; Process ()
receiveChan :: Serializable a =&gt; ReceivePort a -&gt; Process a

このチャネルをアーキテクチャの視点から見ると,このような方向なしリンクが典型的なシステムに有効であるかといったような教室での質問を討議することは興味深い。例えば,意思によって新しいチャネルを作成することの可否,あるいはシステム中の目的によってチャネルが識別された場合に実行される単純化の意味合いが何かについて。

この点において,ソフトウェア・アーキテクチャの教程でHaskellを使うという我々の提案の一つに戻ることができる:異なるアーキテクチャの概念の橋渡しが可能となる,たった一つの資源が利用可能であるとき,どうやって他の概念をそれで実現するかを考える,などである。この場合,焦点はstmライブラリのチャネルの実装にあてられる。TQueueデータ型の例を見てみよう。このライブラリは幸いオープンソース哲学を採用しているため,構築子(コンストラクタ)がどのように定義されているかを見ることができる:

data TQueue a = TQueue {-# UNPACK #-} !(TVar [a])
{-# UNPACK #-} !(TVar [a])

モジュールの残りの部分では,基本的なSTMの概念のもとでチャネルの実際の実装を提供しているので,学徒の視点で見ることは興味深いであろう。


1対1アーキテクチャ = Cloud Haskell


  • Cloud Haskellは,役者(アクター)抽象を使って1対1アーキテクチャを提供している。
  • 役者(アクター)は,型づけされていないメッセージまたは型づけされたチャネルを使ってコミュニケーションを行う。
  • stmは,共有データベースの文脈で,興味深いチャネルの実装を提供する。

3.8 発行と購読 - リアクティブ

通常,システム内の部品は,他の部品やデータベースからデータを引くのが一般的だが,多くの場合,その他の方法を考えることも有用である。具体的には,あるデータや特定の事象(イベント)に対して部品が反応するという考え方である。このような部品は,購読側がどんな者かを全く知らずに事象を発行し,かつ,購読側も同じ変更を聞いている他の部品がどんなものかを気にする必要はない。

このようなシステムをHaskellでモデル化するには,Functional Reactive Programmingという手法を用いる。このアイディアについては数年に渡って多くの種類が開発されてきたが,探索の良い材料としては,1がある。使いやすさの主観的な感覚という以上の意味合いはないが,例としてはsodiumライブラリを使用する。この場合の最も基礎的な関数は,イベントソースの生成を行う名前をnewEventという関数と,特定の発行者を聴取する名前をlistenという関数である:

newEvent :: Reactive (Event a, a -&gt; Reactive ())
listen :: Event a -&gt; (a -&gt; IO ()) -&gt; Reactive (IO ())

新しい要素をどうやって発行するかが気になっているなら,その答えは,newEventで帰ってくるタプルの2つ目の要素である関数を使うことだ。sodiumは,副作用があるイベントの場合にそれに対応する関数を使うことを強制するが,他のライブラリはFRPで副作用を許していない。

興味深い機能としては,イベントが第一級市民として扱われ,自由に合成できるということである。FRPはそのような関数を束ねて提供する傾向があり,いくつか例を以下に提示する:

merge :: Event a -&gt; Event a -&gt; Event a -- Any of them
once :: Event a -&gt; Event a -- Only the first
split :: Event [a] -&gt; Event a -- One event per element
filterE :: (a -&gt; Bool) -&gt; Event a -&gt; Event a -- Filter out

この一般化のよい一般化は,別の興味深いアーキテクチャ・パターンを示している。つまり,離散的に発生するイベントを,継続的に変化する値で置き換えているのだ。例えば,テキストボックスを考えてほしい:キー・ストロークのたびにイベントが発生するのではなく,いかなる瞬間についてもその時のテキスト値を示すBehaviourがある。コードでは,生成はイベントに似ている:

newBehaviourSource :: a -&gt; Reactive (Behaviour a, a -&gt; Reactive ()

しかしながら,コンピュータは離散的機械であるから,切れ目なく常に反応するということはできない。連続的に変化する振る舞いを扱う場合,これを変化のたびにイベントの発生に置き換えるという手法を使う:

update :: Behaviour a -&gt; Event a

ビヘイビア(連続的な振る舞い)は,非常に有力な抽象であり,多くのシステムをモデル化したり模倣する時に使われる。例えば,switchやswitchEは,時間に沿って変化し続ける振る舞いやイベント源を可能ならしめるが,これらはビヘイビア(連続的な振る舞い)そのものである:

switch :: Behaviour (Behaviour a) -&gt; Reactive (Behaviour a)
switchE :: Behaviour (Event a) -&gt; Event a

我々は,システムのアーキテクチャを主導するイベントやビヘイビアの導入は,学徒が,例えば関数の要求ー反応パターンや,クライアントーサーバーアーキテクチャに現れるシステムの情報の流れを概観する視点を獲得するのに役立つと考えている。


発行ー購読アーキテクチャ = FRP


  • イベントは,発行側が変化の購読側に情報を流すチャネルを内包している。
  • ビヘイビアは,時間に沿って連続的に変化する値をモデル化している

3.9 資源管理 - pipes-safe と resourcet

ソフトウェア・アーキテクチャの核となる話題としてはいえないが,資源管理はシステムをどのように設計するかを考える際,重要な制約となる。ファイルをどのようにアクセスするかや,接続をどのように仲裁するかといったことを検討すると,とても素晴らしいと思っていたアーキテクチャ・デザインが崩壊することもある。Haskellは,このような副作用を扱うことについて正直であることを強制するので,このような新しい要求を取り扱うにあたってアーキテクチャをどのように変更しなければならないかを検討する際の格好の道具となる。

興味深い事例としては,pipe-filterアーキテクチャ・パターンがある。この事例では,データが,別の部品が必要とするなどオンデマンドで要求されるとき,前段階では,次の段階ですべて消費すべき要素を使い終わったと確認できるまで,資源を確保しっぱなしにしておかなければならない。

Haskellの世界では,2つのライブラリ,pipes-safeとresourcet(これはconduitライブラリの開発者による)がこの問題の解決策としてよく似た方法を採用している。両方とも,それぞれ,SafeTとResourceTというモナド階層を一つ追加している。以下は,前者の場合の基本的操作である:

register :: m () -&gt; SafeT m ReleaseKey
release :: ReleaseKey -&gt; SafeT m ()

最初の操作は,ファイル・ハンドラやソケットの閉鎖など,(資源使用後の)かたづけをregister(登録)し,後ほど,SafeTモナド環境から離れる際に(このかたづけが)動作することを確実にする。かたづけ動作が行われる前に,register関数は,ReleaseKeyを用意する。このキーは,rileaseによって片付けを動作させる際に使われる。この流れは,文献[^11]で言及されている資源確保,資源活用,資源開放という資源管理の三段階に対応している。

資源管理の別のパターンについても,Hackageに対応ライブラリが掲載されている。例えば,resource-poolは,資源プールに対する簡単なインタフェースを提供している。紙幅の関係で,詳細は割愛する。


資源管理


  • 資源管理は,ソフトウェア・アーキテクチャの周辺的話題である。
  • Haskellは,いくつかのアーキテクチャ・パターンで,どのように資源管理層を追加するかを示している。

4. 結語

我々は,Haskellライブラリについての検討が,教室でのアーキテクチャ・パターンの説明をよりよくする例を多数示した:

  • 実装と実験の基礎を提供する。
  • 基礎の中心的な核部分や,それらが満足する属性を見ることに焦点を当てる。
  • さらなる機能を追加するために,どのようにアーキテクチャを拡大するかという質問が自然に出るようにする。

我々は,まだこうした相地派を実際の教室では試していないが,一般的に関数プログラミングは,そして特にHaskellは,この道において極めて有効であると自信を持っている。

文献


  1. Edward Amsden (2011): A Survey of Functional Reactive Programming, Independent Study in Functional Reactive Programming, Spring 2010-2011. Available at http://www.cs.rit.edu/~eca7215/frp-independent-study/Survey.pdf(訳注:現在,このアドレスに該当資料は存在しない)