Based on the code presented by @jhbrown at a recent Elm meetup, with accompanying slides, I decided to build a small app in an object oriented style using this technique. What intrigued me about this talk was the “hiding implementation types” idea, which immediately made me think “existential types in Elm?”. If I can hide the implementation type of something, then I can separate interface and implementation, and I can have multiple implementations of the same interface even if the implementations have different types. I did not think this was possible in Elm, but Jeremy showed how to sneak it past the type checker.
My code is here. This is a re-implementation of a little example I wrote to test my 2d camera package.. That explains why I have a black strip at the left (to leave space for a menu), and a transparent one on the right (for an overlay) - I was experimenting with setting up a 2d camera where the frame is offset from 0,0 and where the focus region of the camera does not necessarily fill the entire SVG region with the drawing in it.
The original non-OO version can be found in this repo under OldMain.elm
The interfaces for the OO version are defined in Scene/Spec.elm.
To get started with this I imagined doing something along the lines of an ECS or entity-component-system, so the drawings themselves I called Entity. In the end this wasn’t quite the right idea, but I stuck with the name Entity. A set of Entities are composed into a Scene, with this interface:
type alias SceneIF =
{ subscriptions : Sub Msg
, update : Msg -> ( Scene, Cmd Msg )
, view : () -> Html Msg
, add : EntityId -> Entity Msg -> Scene
}
And the Entities implement this interface (note move
and animate
not actually used in this example):
type alias EntityIF msg =
{ gestureHandler : Maybe (GestureIF msg)
, move : VScene -> Entity msg
, animate : Posix -> Maybe (Entity msg)
, position : PScene
, bbox : BLocal
, view : ViewContextIF msg -> List (Svg msg)
}
The gesture handlers are optional on an entity, and have this interface:
type alias GestureIF msg =
{ tap : Maybe (Pointer.PointArgs Screen -> UpdateContext msg -> UpdateContext msg)
, doubleTap : Maybe (Pointer.PointArgs Screen -> UpdateContext msg -> UpdateContext msg)
, drag : Maybe (Pointer.DragArgs Screen -> UpdateContext msg -> UpdateContext msg)
, dragEnd : Maybe (Pointer.DragArgs Screen -> UpdateContext msg -> UpdateContext msg)
}
The idea is that mouse or touch gestures are sent to the Scene through its subscription
or event handlers in its view
. Those events will contain the id of the entity that they take place on, and so the scene can trigger the gesture handler on an entity, provided it exists in the scene and has the relevant gesture handler.
Some interesting things about this:
-
The Entity has no idea what
Msg
type it is dealing with, it is parameterized overmsg
. This means it cannot create arbitrary side effects, it will be given an interface with functions in it to use to generate from a limited pallete of side effects (none in this example, although I did pass downnoop
just to show it can be done). An Entity is like a Program that I have specialized to fit a particular purpose - and unlike most Elm Programs, it is not a TEA. -
The Scene and Entity know nothing about each others implementations. In particular new entities can be added to the codebase without touching any other code. So each entity gives a bounding box - I do not need to update a case statement somewhere that dervies bounding boxes for all Entities, just adding this code to the new one is sufficient.
Having the implementations hidden, does make it a little awkward, since sometimes the Entities do need to know things about the Scene. For view
purposes, they need to know about the camera and frame and so on. For update
purposes, they need some kind of interface they can apply those updates to, in order to change the state of the Scene, for example to move the camera, add more entities to the scene, etc.
For the view we have a view interface:
type alias ViewContextIF msg =
{ noop : msg -- Example side-effect the view is allowed to create.
, zoom : Float
, frame : BScreen
, mousePos : PScreen
, camera : Camera2d Unitless Pixels Geometry.Scene
}
The update interface is a bit more complicated:
type alias UpdateContextIF msg =
{ frame : BScreen
, camera : Camera2d Unitless Pixels Geometry.Scene
, setCamera : Camera2d Unitless Pixels Geometry.Scene -> UpdateContext msg
, animateZoomToTarget : Point3d Unitless (ZoomSpace Pixels Geometry.Scene) -> UpdateContext msg
, updateEntity : EntityId -> Entity msg -> UpdateContext msg
, toScene : Scene
}
The update interface has a toScene
method on it. This is because this interface is for making changes to the Scene, and so must ulimately be able to produce a new Scene
, even though several updates can be applied to it to produce new UpdateContext
s.
I haven’t described everything but this is probably enough of a guide for now, in case anyone wants to take a closer look at the code.
==========
Here is a summary what I think about this way of working with Elm, good and bad:
-
I like the extensibility, new implementations can be added without changing any old code. Comparing that with using a custom type, where a new constructor gets added for each new thing, it feels nicely encapsulated.
-
I like the idea of building my own mini-Program types in Elm that are more restrictive than the general TEA pattern. More restrictive means more specific to some particular application, and this can be a nice way of designing and enforcing a design on an area of code.
-
Elm does not have dynamic code loading, so this cannot be used to dynamically load plug-ins at runtime. The lack of runtime extensibility somewhat negates the benefits of the technique.
-
It was a bit complicated to figure out, but perhaps that was just because its the first time I have done this. The
UpdateContext
was the hardest part to figure out. I could actually have avoidedUpdateContext
altogether, if I had not hidden theScene
implementation type, and I only have oneScene
implementation. -
There is one
Msg
type over the whole scene and entities, and the entities do not have their own internalMsg
types. If they did, they would need to expose it so that the parentupdate
could use a constructor on the parentMsg
type to group the child one under, as per nested TEA. This would stop the entities from all having the same type. On the other hand, I quite like the idea of passing a set of functions to the entityupdate
orview
offering a limited pallete of side-effects, it seems appropriate to using this technique for creating highly restricted Program types. -
Separation of interface and implementation has a lot of appeal for me. Opaque types and modules can be used to achieve this too, and are usually going to be a more appropriate way of doing so in Elm.
In short, its too fiddly to use for most Elm work, but I think it can have its uses in limited situations where writing software in an open-ended and extensible way is highly important to you.