
Model-Updater-View is a functional pattern that is successfully used in the
Elm language mainly for developing user interfaces. To use it, you need to create a Model type that represents the full state of the program, a Message type that describes external events to which the program should react, changing its state, the updater function, which from the old state and message creates a new state of the program and the view function, which calculates, as a program, the required effects on the external environment, which generate Message events. The pattern is very convenient, but it has a small flaw - it does not allow describing which events make sense for specific program states.
A similar problem occurs (and
is solved ) when using the OO pattern State.
')
The Elm language is simple, but very strict - it checks that the updater function at least somehow processes all possible combinations of the model-state and message-events. For this, you have to write extra, even if trivial - as a rule, leaving the model unchanged, the code. I want to demonstrate how this can be avoided in more complex languages ​​- Idris, Scala, C ++ and Haskell.
All the code here is available on
GitHub for experiments. Consider the most interesting places.
Idris
Idris is a language that supports dependent types. That is, in it the compiler can monitor the correct typing, although the type of one variable may depend on the value of another. The types in Idris are similar to the
generalized algebraic types in Haskell. It is described by a list of type parameters and a set of constructors — functions that create objects of this type. Unlike Haskell, type parameters can be not only other types and type classes, but also values, including functions.
We describe on it the type of a simple application using the Model-Updater-View pattern.
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
This describes the parameterized data type Application. Its parameters are the
type model, the msg function that converts the
value of the model type into an event type that can occur in a given program state, and a view type that will be parameterized by the event type — it can be treated as a function from the parameter type into a simple type.
Lyrical digression about High Kind TypesThis is the only place where the type parameter is used, which itself has type parameters. Such an opportunity is not provided by all languages ​​- it is not present, including in Elm. But in this example, the view is placed in the parameters of the type of application more “for beauty” - to show that it is part of the pattern. One can proceed as in Elm — use a fixed paramterized type as a View (in Elm, this is Html msg).
I want to point out that HKTs are not necessary for using dependent types - these are different edges of a
lambda cube The msg function is unusual - it does not return a value, but a type. During execution, nothing is known about the types of values ​​- the compiler performs
erasing of all unnecessary information. That is, such a function can only be called at compile time.
MUV is a constructor. It takes the parameters: model - the initial state of the program, updater - the function of updating the state when an external event occurs, and view - the function of creating an external view. Note that the type of the updater and view functions depends on the value of the model (using the msg function from the type parameters).
Now let's see how this application is run.
muvRun : (Application modelType msgType IO) -> IO a muvRun (MUV model updater view) = do msg <- view model muvRun (MUV (updater model msg) updater view)
As an external view, we chose an input / output operation (in Idris, as in Haskell, input / output operations are first class values, so that they are completed, additional actions are required, usually returning such an operation from the main function).
Briefly about IOWhen performing an operation of type (IO a), there is some impact on the outside world, possibly empty, and a value of type a is returned to the program, but the functions of the standard library are arranged so that it can be processed only by generating a new value of type IO b. Thus, pure functions are separated from functions with side effects. This is unusual for many programmers, but it helps to write more reliable code.
Since the muvRun function generates I / O, it must return IO, but since it will never complete, the type of operation can be any - IO a.
Now we describe the types of entities we are going to work with.
data Model = Logouted | Logined String data MsgOuted = Login String data MsgIned = Logout | Greet total msgType : Model -> Type msgType Logouted = MsgOuted msgType (Logined _) = MsgIned
It describes the type of the model, reflecting the presence of two states of the interface - the user is not logged in, and the user is logged in with the name of the String type.
Next, we describe
two different types of messages that are relevant for different variants of the model - if we are logged out, we can only log in under a certain name, and if already logged in, we can either log out or say hello. Idris is a strongly typed language that will not allow the possibility of mixing up different types.
And finally, the function that sets the model value to the message type.
The function is declared total - that is, it should not fall or hang, the compiler will try to follow this. msgType is called at compile time, which means its totality means that the compilation does not freeze due to our error, although it cannot guarantee that the execution of this function will lead to the exhaustion of system resources.
It is also guaranteed that it will not execute “rm -rf /” because there is no IO in its signature.
We describe updater:
total updater : (m:Model) -> (msgType m) -> Model updater Logouted (Login name) = Logined name updater (Logined name) Logout = Logouted updater (Logined name) Greet = Logined name
I think the logic of this function is clear. I want to reiterate the totality - it means that the Idris compiler will check that we have considered all alternatives allowed by the type system. Elm also performs such verification, but he cannot know that we cannot log out if not already logged in, and will require an explicit treatment of the condition.
updater Logouted Logout = ???
Idris, on the other hand, will find an inconsistency in the types.
Now we will start view - as usual in UI it will be the most difficult part of the code.
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
The view should create an I / O operation that returns messages, the type of which again depends on the value of the model. We have two options: loginPage, which displays the message “Login:”, reads a line from the keyboard and wraps it in the message Login and workPage with the parameter user name, which displays a greeting and returns various messages (but of the same type - MsgIned) depending on In addition, the user enters an empty or non-empty string. view returns one of these operations depending on the value of the model, and the compiler checks their type, even though it is different.
Now we can create and run our application.
app : Application Model Main.msgType IO app = MUV Logouted updater view main : IO () main = muvRun app
A subtle point should be noted here - the muvRun function returns
IO a , where a was not specified, and main is of type
IO () , where
() is the name of the type usually called
Unit , which has a single value, also written as empty
() But the compiler does it easily. substituting a () instead.
Scala and path dependent types
Scala does not have full support for dependent types, but there are types dependent on the object instance through which path dependent types are referenced. In the theory of dependent types, they can be described as a variant of the sigma type. Path-dependent types allow you to forbid folding vectors from different vector spaces, or to describe
who you can kiss with . But we apply them for simpler tasks.
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] .... }
Algebraic types in Scala are modeled through inheritance. A
sealed abstract class corresponds to a type, and each designer inherits a
case class from it. We will try to use them exactly as algebraic types, describing all variables as belonging to the parent
sealed abstract class .
The classes MsgLogined and MsgLogouted within our program have no common ancestor. The view function had to be spread over different classes of the model in order to have access to a specific type of message. This has its advantages, which supporters of OO will appreciate - the code turns out to be grouped in accordance with the business logic, everything connected with one use case is near. But I would have liked to select the view into a separate function, the development of which could be transferred to another person.
Now let's implement updater
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 } } } }
Here we, using path-dependent types, describe the type of the second argument from the value of the first. In order for Scala to perceive such dependencies, functions have to be described in a carrirored form, that is, as a function of the first argument, which returns a function of the second argument. Unfortunately, Scala in this place does not perform many type checks for which the compiler has enough information.
Now we will give the full implementation of the model and view
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() } }
The type returned by the view function depends on the instance of its argument. But for implementation, it turns into a model.
The application created in this way is launched.
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()) } }
The code of the runtime system, therefore, does not know anything about the model's internal structure and message types, but the compiler can verify that the message matches the current model.
Here we did not need all the possibilities provided by the path-dependent types. Interesting properties will manifest themselves if we work in parallel with non-multiple instances of Model-Updater-View systems, for example, when simulating a multi-agent world (view would then be the agent's impact on the world and receiving feedback). In this case, the compiler checked that the message is processed by the agent for which it is intended, despite the fact that all agents are of the same type.
C ++
C ++ is still sensitive to the order of definitions, even if they are all made in one file. This creates some inconvenience. I will give the code in convenient for demonstration of ideas. An ordered version for compilability can be viewed on
GitHub .
Algebraic types can be implemented in the same way as in Scala - the abstract class corresponds to the type, and the concrete heirs - to the designers (let's call them "constructor classes" that would not be confused with the usual C ++ constructors) of algebraic type.
In C ++, there is
support for path-dependent types, but the compiler cannot use this type abstractly without knowing the actual type with which it is associated. Therefore, it is impossible to implement Model-Updater-View with their help.
But C ++ has a powerful template system. The dependence of the type on the model value can be hidden in the template parameter of the specialized version of the executive system.
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(); } };
We describe an abstract executive system, with the only method - to do everything that is required and return a new executive system suitable for the next iteration. The competitive version has a template parameter and will be specialized for each “constructor class” of the model. It is important here that all properties of the CurModel type will be checked during the template specialization by a specific type parameter, and at the time of compiling the template itself they are not required to be described (although it is possible using
concepts or
other ways of implementing type classes ). Scala also has a fairly powerful system of parameterized types, but it checks the properties of the types of parameters it performs during the compilation of the parameterized type. There, the implementation of such a pattern is difficult, but possible, thanks to the support of type classes.
We describe the model.
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); }; };
“Constructor classes” models “carry everything with them” - that is, they contain classes of messages and view that are specialized for them, and they also know how to create an executive system for themselves. Own types of View are common to all models of the ancestor, which may be useful when developing more complex execution systems. Basically, the message types are completely isolated and have no common ancestor.
The updater implementation is separated from the model, since it requires that the type of the model be already fully described.
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; };
Now let's put together everything related to the view, including the internal entities of the models.
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(); }; };
And finally, write main
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; }
And again Scala, already with classes of types
By structure, this implementation almost completely repeats the version in C ++.
Similar part of the code 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())) } }
But in the implementation of the execution environment there are subtleties.
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() } }
Here we see the new mysterious parameters
(implicit updater: (M, Message) => Model, view: M => View [Message]) . The implicit keyword means that the compiler, when calling this function (more precisely, the class constructor), will search for the context marked as implicit objects of the appropriate types and pass them as the corresponding parameters. This is a rather complicated concept, one of whose applications is the implementation of type classes. Here they promise the compiler that for specific implementations of the model and the message all the necessary functions will be provided by us. Now fulfill this promise.
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
There are no dependent types in mainstream Haskell. It also lacks inheritance, which we essentially used in the implementation of the pattern in Scala and C ++. But single-level inheritance (with elements of dependent types) can be modeled using more or less standard extensions of the language -TypeFamilies and ExistentialQuantification. For a common interface of child OOP classes, a type class is started up, in which the dependent “family” type is present, the child classes themselves are represented as a separate type, and then wrapped into an “existential” type with a single constructor.
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
I tried to smash the updater and view as far as possible, so I created two different types of types, but so far this has not worked out well.
The implementation of updater is simple
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
As View it was necessary to fix IO. Attempts to make it more abstract greatly complicated and increased the coherence of the code - the Model type must know which View we are going to use.
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
Well, the executable environment is not much different from the same in Idris
runMUV :: Model -> IO a runMUV (Model m) = do msg <- view m runMUV $ update m msg main :: IO () main = runMUV (Model Logouted)