モデル更新ビューパターンと依存型





Model-Updater-Viewは、主にユーザーインターフェイスを開発するためにElm言語で使用されている機能的なパターンです。 これを使用するには、プログラムの完全な状態を表すモデルタイプ、プログラムが反応する環境イベントを記述するメッセージタイプ、状態の変更、古い状態とメッセージからプログラムの新しい状態を作成するアップデータ関数、およびビュー関数を作成する必要があります。プログラムの状態に応じて、メッセージタイプのイベントを発生させる必要な環境影響を計算します。 このパターンは非常に便利ですが、小さな欠点があります。特定のプログラムの状態にとって意味のあるイベントを記述することはできません。



State OOパターンを使用すると、同様の問題が発生します( 解決されます )。



Elm言語はシンプルですが非常に厳密です-アップデータ機能が少なくとも何らかの形でモデル状態とメッセージイベントのすべての可能な組み合わせを処理することをチェックします。 そのため、通常はモデルを変更せずにおくために、些細ではありますが追加のコードを記述する必要があります。 Idris、Scala、C ++、Haskellなど、より複雑な言語でこれを回避する方法を示したいと思います。



ここにあるすべてのコードは、実験のためにGitHubで入手できます。 最も興味深い場所を検討してください。



イドリス



Idrisは、依存型をサポートする言語です。 つまり、1つの変数の型は別の変数の値に依存する可能性がありますが、その中でコンパイラは型付けを監視できます。 イドリスのは、Haskellの一般化された代数型に似ています。 これは、型パラメーターのリストと一連のコンストラクター(この型のオブジェクトを作成する関数)によって記述されます。 Haskellとは異なり、型パラメーターは他の型や型クラスだけでなく、関数などの値でもあります。



Model-Updater-Viewパターンを使用した単純なアプリケーションのタイプについて説明しましょう。



data Application : (model:Type) -> (msg: model -> Type) -> (vtype : Type -> Type) -> Type where MUV : model -> (updater : (m:model) -> (msg m) -> model) -> (view : (m:model) -> vtype (msg m)) -> Application model msg vtype
      
      





ここでは、パラメータ化されたアプリケーションデータタイプについて説明します。 そのパラメーターはモデルタイプ 、モデルタイプをプログラムの特定の状態で発生する可能性のあるイベントタイプに変換するmsg関数、およびイベントタイプによってパラメーター化されるビュータイプです。パラメータータイプから単純タイプへの関数として解釈できます。



高種類のタイプに関する叙情的な余談
これは、型パラメーターが使用される唯一の場所であり、それ自体に型パラメーターがあります。 この機能はすべての言語で提供されているわけではありません-Elmを含め、利用できません。 ただし、この例では、ビューはアプリケーションタイプパラメータに配置され、より「美しさのため」に-パターンのコンポーネントであることを示しています。 Elmのように振る舞うことができます-固定のパラメーター化された型をViewとして使用します(ElmではHtml msgです)。



HKTは依存型を使用するために必要ではないことに注意してください-これらはラムダキューブの異なるエッジです



msg関数は異常です-値ではなく型を返します。 実行時には、値のタイプについては何もわかりません-コンパイラ不要な情報すべて消去します。 つまり、そのような関数はコンパイル段階でのみ呼び出すことができます。



MUVはコンストラクターです。 パラメーターを取ります:model-プログラムの初期状態、updater-外部イベントで状態を更新する機能、view-外部ビューを作成する機能。 アップデータおよびビュー関数のタイプはモデル値に依存することに注意してください(タイプパラメーターからmsg関数を使用)。



このアプリケーションを実行する方法を見てみましょう



 muvRun : (Application modelType msgType IO) -> IO a muvRun (MUV model updater view) = do msg <- view model muvRun (MUV (updater model msg) updater view)
      
      





外部ビューとして、入力/出力操作を選択しました(イドリスでは、Haskellのように、入力/出力操作はファーストクラスの値であるため、実行するには追加の手順を実行する必要があり、通常はこのような操作をメイン関数から返します)。



IOについて簡単に説明します
タイプ(IO a)の操作を実行すると、空の可能性がある外部の世界に何らかの影響があり、タイプaの値がプログラムに返されますが、標準ライブラリの関数は、タイプIO bの新しい値を生成することによってのみ処理できるように設計されています。 したがって、純粋な関数は副作用のある関数から分離されます。 これは多くのプログラマーにとって珍しいことですが、より信頼性の高いコードを書くのに役立ちます。


muvRun関数は入力/出力を生成するため、IOを返す必要がありますが、完了することはないため、操作のタイプは任意です-IO



次に、使用するエンティティのタイプについて説明します。



 data Model = Logouted | Logined String data MsgOuted = Login String data MsgIned = Logout | Greet total msgType : Model -> Type msgType Logouted = MsgOuted msgType (Logined _) = MsgIned
      
      





ここでは、2つのインターフェイス状態が存在することを反映して、モデルタイプについて説明します。ユーザーはログインしておらず、String型の名前を持つユーザーがログインしています。



次に、モデルのさまざまなバージョンに関連する2種類のメッセージについて説明します。ログインしている場合は特定の名前でのみログインでき、ログインしている場合はログアウトするか挨拶することができます。 Idrisは強く型付けされた言語であり、異なる型を混同する可能性はありません。



そして最後に、メッセージタイプに一致するようにモデル値を設定する関数。



関数はtotalとして宣言されます-つまり、落下またはフリーズしてはなりません。コンパイラーはこれをトレースしようとします。 msgTypeはコンパイル段階で呼び出されます。つまり、この関数の実行がシステムリソースの枯渇につながることを保証することはできませんが、全体としては、エラーのためにコンパイルがハングしないことを意味します。

また、署名にIOがないため、「rm -rf /」を実行しないことが保証されています。



アップデーターについて説明します。



 total updater : (m:Model) -> (msgType m) -> Model updater Logouted (Login name) = Logined name updater (Logined name) Logout = Logouted updater (Logined name) Greet = Logined name
      
      





この関数のロジックは明確だと思います。 もう一度全体に注意したい-それは、イドリスコンパイラが、型システムで許可されているすべての代替案を検討したことを検証することを意味する。 Elmもそのようなチェックを実行しますが、まだログインしていない場合はログアウトできないことを知らず、条件の明示的な処理が必要になります



 updater Logouted Logout = ???
      
      





Idrisは、追加のチェックで型の不一致を検出します。



次に、ビューを開始しましょう。通常のUIでは、これがコードの最も難しい部分になります。



 total loginPage : IO MsgOuted loginPage = do putStr "Login: " map Login getLine total genMsg : String -> MsgIned genMsg "" = Logout genMsg _ = Greet total workPage : String -> IO MsgIned workPage name = do putStr ("Hello, " ++ name ++ "\n") putStr "Input empty string for logout or nonempty for greeting\n" map genMsg getLine total view : (m: Model) -> IO (msgType m) view Logouted = loginPage view (Logined name) = workPage name
      
      





ビューは、型がモデル値に再び依存するメッセージを返すI / O操作を作成する必要があります。 次の2つのオプションがあります。「Login:」というメッセージを表示するloginPageは、キーボードから行を読み取り、ログインメッセージで囲み、パラメータusernameでworkPageを表示し、挨拶を表示して異なるメッセージを返します(ただし、同じタイプ-MsgIned)ユーザーは空または空でない文字列を入力します。 ビューは、モデル値に応じてこれらの操作の1つを返し、コンパイラは、それが異なるという事実にもかかわらず、型をチェックします。



これで、アプリケーションを作成して実行できます



 app : Application Model Main.msgType IO app = MUV Logouted updater view main : IO () main = muvRun app
      
      





ここで微妙な点に注意する必要があります-muvRun関数は、aが指定されていないIO aを返し、メイン値はIO()型です。ここで()は、通常Unitと呼ばれる単一の値を持つ型の名前です。 () 。 しかし、コンパイラはこれを簡単に行います。 代わりに()を代入します。



Scalaおよびパス依存型



Scalaは依存型を完全にはサポートしていませんが、参照されるオブジェクトのインスタンスに依存する型があります(パス依存型)。 依存型の理論では、それらはシグマ型の変形として説明できます。 パス依存型を使用すると、異なるベクトル空間からのベクトルの折りたたみを禁止したり、誰と誰にキスできるかを記述できます 。 しかし、より単純なタスクにはそれらを適用します。



 sealed abstract class MsgLogouted case class Login(name: String) extends MsgLogouted sealed abstract class MsgLogined case class Logout() extends MsgLogined case class Greet() extends MsgLogined abstract class View[Msg] { def run() : Msg } sealed abstract class Model { type Message def view() : View[Message] } case class Logouted() extends Model { type Message = MsgLogouted override def view() : View[Message] .... } case class Logined(name: String) extends Model { type Message = MsgLogined override def view() : View[Message] .... }
      
      





Scalaの代数型は、継承によってモデル化されます。 型は特定のシールドされた抽象クラスに対応し、各コンストラクターはそれからケースクラスを継承します。 これらを代数型として正確に使用し、すべての変数を親のシールされた抽象クラスに属するものとして記述しようとします。



プログラム内のクラスMsgLoginedおよびMsgLogoutedには共通の祖先がありません。 特定のタイプのメッセージにアクセスするには、ビュー関数をモデルのさまざまなクラスに分散させる必要がありました。 これには、OOの支持者が高く評価する利点があります。コードはビジネスロジックに従ってグループ化され、1つのユースケースに関連するものはすべて近くにあることがわかります。 しかし、ビューを別の機能に分割し、その機能を別の人に移したいと思います。



次に、アップデーターを実装します



 object Updater { def update(model: Model)(msg: model.Message) : Model = { model match { case Logouted() => msg match { case Login(name) => Logined(name) } case Logined(name) => msg match { case Logout() => Logouted() case Greet() => model } } } }
      
      





ここでは、パス依存型を使用して、最初の値から2番目の引数の型を説明します。 Scalaがそのような依存関係を認識するためには、関数をカリー化された形式で、つまり、2番目の引数の関数を返す最初の引数の関数として記述する必要があります。 残念ながら、Scalaはこの時点で多くの型チェックを実行しません。そのため、コンパイラーは十分な情報を持っています。



次に、モデルとビューの完全な実装を示します



 case class Logouted() extends Model { type Message = MsgLogouted override def view() : View[Message] = new View[Message] { override def run() = { println("Enter name ") val name = scala.io.StdIn.readLine() Login(name) } } } case class Logined(name: String) extends Model { type Message = MsgLogined override def view() : View[Message] = new View[Message] { override def run() = { println(s"Hello, $name") println("Empty string for logout, nonempy for greeting.") scala.io.StdIn.readLine() match { case "" => Logout() case _ => Greet() } } } } abstract class View[Msg] { def run() : Msg } object Viewer { def view(model: Model): View[model.Message] = { model.view() } }
      
      





ビュー関数によって返される型は、引数のインスタンスによって異なります。 しかし、実装のために、彼女はモデルに目を向けます。



このように作成されたアプリケーションは、このように起動します



 object Main { import scala.annotation.tailrec @tailrec def process(m: Model) { val msg = Viewer.view(m).run() process(Updater.update(m)(msg)) } def main(args: Array[String]) = { process(Logouted()) } }
      
      





したがって、ランタイムシステムのコードはモデルの内部構造とメッセージのタイプについて何も知りませんが、コンパイラーはメッセージが現在のモデルと一致することを確認できます。



ここでは、パス依存型によって提供されるすべての機能は必要ありませんでした。 たとえば、マルチエージェントの世界をシミュレートするときなど、Model-Updater-Viewシステムの複数のインスタンスを同時に操作すると、興味深いプロパティが表示されます(ビューは、世界に対するエージェントの効果であり、フィードバックを受け取ります)。 この場合、コンパイラは、すべてのエージェントが同じタイプであるという事実にもかかわらず、メッセージが意図されたエージェントによって処理されていることを検証しました。



C ++



C ++は、定義がすべて1つのファイルで作成されている場合でも、定義の順序に依然として敏感です。 これにより、不便が生じます。 アイデアを示すのに便利な順序でコードを提示します。 コンパイル用に注文されたバージョンはGitHubで見ることができます。



代数型はScalaと同じ方法で実装できます-抽象クラスは型に対応し、特定の子孫は代数型のコンストラクター(通常のC ++コンストラクターと混同しないように「コンストラクタークラス」と呼びましょう)に対応します。



C ++はパス依存型をサポートしていますが、コンパイラは、関連付けられている実際の型を知らない限り、この型を抽象的に使用できません。 したがって、Model-Updater-Viewを使用して実装することはできません。



ただし、C ++には強力なテンプレートシステムがあります。 型のモデル値への依存は、エグゼクティブシステムの専用バージョンのテンプレートパラメーターで非表示にできます。



 struct Processor { virtual const Processor *next() const = 0; }; template <typename CurModel> struct ProcessorImpl : public Processor { const CurModel * model; ProcessorImpl<CurModel>(const CurModel* m) : model(m) { }; const Processor *next() const { const View<typename CurModel::Message> * view = model->view(); const typename CurModel::Message * msg = view->run(); delete view; const Model * newModel = msg->process(model); delete msg; return newModel->processor(); } };
      
      





必要なすべてを達成し、次の反復に適した新しい実行システムを返すという唯一の方法を備えた抽象実行システムについて説明します。 具象バージョンにはテンプレートパラメーターがあり、各モデルコンストラクタークラスに特化されます。 ここで重要なのは、特定の型パラメーターを使用したテンプレートの特殊化中にCurModel型のすべてのプロパティがチェックされることであり、テンプレート自体のコンパイル時にはそれらを記述する必要はありません(ただし、 型クラスを実装するための 概念または他の方法を使用することは可能です)。 Scalaにはパラメーター化された型のかなり強力なシステムもありますが、パラメーター化された型のコンパイル時にプロパティ型のパラメーター型チェックを実行します。 このようなパターンの実装は困難ですが、型クラスのサポートのおかげで可能です。



モデルについて説明します。



 struct Model { virtual ~Model() {}; virtual const Processor *processor() const = 0; }; struct Logined : public Model { struct Message { const virtual Model * process(const Logined * m) const = 0; virtual ~Message() {}; }; struct Logout : public Message { const Model * process(const Logined * m) const; }; struct Greet : public Message { const Model * process(const Logined * m) const; }; const std::string name; Logined(std::string lname) : name(lname) { }; struct LoginedView : public View<Message> { ... }; const View<Message> * view() const { return new LoginedView(name); }; const Processor *processor() const { return new ProcessorImpl<Logined>(this); }; }; struct Logouted : public Model { struct Message { const virtual Model * process(const Logouted * m) const = 0; virtual ~Message() {}; }; struct Login : public Message { const std::string name; Login(std::string lname) : name(lname) { }; const Model * process(const Logouted * m) const; }; struct LogoutedView : public View<Message> { ... }; const View<Message> * view() const { return new LogoutedView(); }; const Processor *processor() const { return new ProcessorImpl<Logouted>(this); }; };
      
      





「すべて独自の」モデルの「デザイナークラス」-つまり、それらは、それらに特化したメッセージクラスとビュークラスを含み、また自分自身のための実行システムを作成する方法を知っています。 ネイティブタイプのビューには、すべてのモデルに共通の祖先があり、より複雑な実行システムを開発する場合に役立ちます。 メッセージタイプは完全に分離され、共通の祖先を持たないことが重要です。



アップデーターの実装は、モデルのタイプを完全に記述する必要があるため、モデルとは別です。



 const Model * Logouted::Login::process(const Logouted * m) const { delete m; return new Logined(name); }; const Model * Logined::Logout::process(const Logined * m) const { delete m; return new Logouted(); }; const Model * Logined::Greet::process(const Logined * m) const { return m; };
      
      





次に、モデルの内部エンティティを含む、ビューに関連するすべてのものをまとめましょう



 template <typename Message> struct View { virtual const Message * run() const = 0; virtual ~View<Message>() {}; }; struct Logined : public Model { struct LoginedView : public View<Message> { const std::string name; LoginedView(std::string lname) : name(lname) {}; virtual const Message * run() const { char buf[16]; printf("Hello %s", name.c_str()); fgets(buf, 15, stdin); return (*buf == 0 || *buf == '\n' || *buf == '\r') ? static_cast<const Message*>(new Logout()) : static_cast<const Message *>(new Greet); }; }; const View<Message> * view() const { return new LoginedView(name); }; }; struct Logouted : public Model { struct LogoutedView : public View<Message> { virtual const Message * run() const { char buf[16]; printf("Login: "); fgets(buf, 15, stdin); return new Login(buf); }; }; const View<Message> * view() const { return new LogoutedView(); }; };
      
      





そして最後に、メインを書きます



 int main(int argc, char ** argv) { const Processor * p = new ProcessorImpl<Logouted>(new Logouted()); while(true) { const Processor * pnew = p->next(); delete p; p = pnew; } return 0; }
      
      





そして再び、Scala、すでに型クラスがあります



構造上、この実装はほぼ完全にC ++バージョンを繰り返します。



同様のコード
 abstract class View[Message] { def run(): Message } abstract class Processor { def next(): Processor; } sealed abstract class Model { def processor(): Processor } sealed abstract class LoginedMessage case class Logout() extends LoginedMessage case class Greet() extends LoginedMessage case class Logined(val name: String) extends Model { override def processor(): Processor = new ProcessorImpl[Logined, LoginedMessage](this) } sealed abstract class LogoutedMessage case class Login(name: String) extends LogoutedMessage case class Logouted() extends Model { override def processor(): Processor = new ProcessorImpl[Logouted, LogoutedMessage](this) } object Main { import scala.annotation.tailrec @tailrec def process(p: Processor) { process(p.next()) } def main(args: Array[String]) = { process(new ProcessorImpl[Logouted, LogoutedMessage](Logouted())) } }
      
      





しかし、実行環境の実装では、微妙な問題が発生します。



 class ProcessorImpl[M <: Model, Message](model: M)( implicit updater: (M, Message) => Model, view: M => View[Message] ) extends Processor { def next(): Processor = { val v = view(model) val msg = v.run() val newModel = updater(model,msg) newModel.processor() } }
      
      





ここに、新しい神秘的なパラメーターが表示されます(暗黙のアップデーター:(M、Message)=> Model、view:M => View [Message]) 。 implicitキーワードは、関数(より正確にはクラスコンストラクター)が呼び出されると、コンパイラーがコンテキストで暗黙的とマークされた適切なタイプのオブジェクトを探し、それらを適切なパラメーターとして渡すことを意味します。 これはかなり複雑な概念であり、そのアプリケーションの1つは型クラスの実装です。 ここでは、モデルとメッセージの特定の実装に必要なすべての機能を提供することをコンパイラに約束します。 今、この約束を守ってください。



 object updaters { implicit def logoutedUpdater(model: Logouted, msg: LogoutedMessage): Model = { (model, msg) match { case (Logouted(), Login(name)) => Logined(name) } } implicit def viewLogouted(model: Logouted) = new View[LogoutedMessage] { override def run() : LogoutedMessage = { println("Enter name ") val name = scala.io.StdIn.readLine() Login(name) } } implicit def loginedUpdater(model: Logined, msg: LoginedMessage): Model = { (model, msg) match { case (Logined(name), Logout()) => Logouted() case (Logined(name), Greet()) => model } } implicit def viewLogined(model: Logined) = new View[LoginedMessage] { val name = model.name override def run() : LoginedMessage = { println(s"Hello, $name") println("Empty string for logout, nonempy for greeting.") scala.io.StdIn.readLine() match { case "" => Logout() case _ => Greet() } } } } import updaters._
      
      





ハスケル



主流のHaskellには依存型はありません。 また、ScalaおよびC ++でパターンを実装するときに基本的に使用した継承もありません。 ただし、(依存型の要素を持つ)単一レベルの継承は、多かれ少なかれ標準の言語拡張機能であるTypeFamiliesとExistentialQuantificationを使用してモデル化できます。 子OOPクラスの一般的なインターフェイスの場合、依存する「ファミリ」型が存在する型クラスが作成され、子クラス自体は別の型として表され、単一のコンストラクタで「存在する」型にラップされます。



 data Model = forall m. (Updatable m, Viewable m) => Model m class Updatable m where data Message m :: * update :: m -> (Message m) -> Model class (Updatable m) => Viewable m where view :: m -> (View (Message m)) data Logouted = Logouted data Logined = Logined String
      
      





アップデータとビューを可能な限り広めようとしたため、2つの異なるタイプのクラスを作成しましたが、今のところうまくいきませんでした。



アップデーターの実装は簡単です



 instance Updatable Logouted where data Message Logouted = Login String update Logouted (Login name) = Model (Logined name) instance Updatable Logined where data Message Logined = Logout | Greeting update m Logout = Model Logouted update m Greeting = Model m
      
      





IOをビューとして修正する必要がありました。 抽象化を非常に複雑にし、コードの一貫性を高めようとする試み-モデルタイプは、使用するビューを知る必要があります。



 import System.IO type View a = IO a instance Viewable Logouted where view Logouted = do putStr "Login: " hFlush stdout fmap Login getLine instance Viewable Logined where view (Logined name) = do putStr $ "Hello " ++ name ++ "!\n" hFlush stdout l <- getLine pure $ if l == "" then Logout else Greeting
      
      





まあ、ランタイムはイドリスで同じとほとんど異なりません



 runMUV :: Model -> IO a runMUV (Model m) = do msg <- view m runMUV $ update m msg main :: IO () main = runMUV (Model Logouted)
      
      






All Articles