OO techniques in Elm

Hi, I have been exploring techniques that translate across from using Java for the last 21 years. Obviously Elm is quite a different beast to Java, but I still find there is OO stuff that translates well into Elm. Elm even makes these techniques better because - immutability.

Anyway, here is the first one - private fields.

In my Auth module I have:

type alias Model =
    { authApiRoot : String
    , innerModel : Private
    }

type Private
    = Private AuthState.AuthState

Where Model is exposed, but Private is not. This gives me a place to put fields that are publicly visible, and ones that are private and concealed from outside of the module. Implementation details that are irrelevant to a consumer of the module are hidden away in a single Private object, the consumer takes responsibility for passing this ‘session’ info around.

The Private object cannot be created, read or changed except through functions exposed by the Auth module. For example, there is Status type returned from the update function, which is a consumer readable object extracted from the Private object.

The implementation details can be changed without changing the API exposed to the consumer - as the Private object is opaque and used to conceal the implementation.

Used in this way roughly the same level of encapsulation that you get working with classes in OO; modules vs classes exist on approximately the same scale within code.

1 Like

Opaque types are a really great technique. This is especially true if you’re maintaining a package (where semantic versioning is enforced).

One word of caution though. Terminology is important. Private isn’t an “object”. I get that it’s analogous to an OO way of thinking but it’s probably more helpful to call it what it is. Private is a type. If it isn’t exposed from the module it is a hidden / internal type. If its name is exposed but not its constructor functions (the (…) part), then it is an opaque type.

3 Likes

A downside of having a public “Model” record is that if you ever add a new field to it, then it will be a major version bump for your package.

An alternative approach is:

type Private = -- though probably would use a more descriptive name in this case!
    Private
        { ... 
        }

{-| This is public -}
authApiRoot : Private -> String

The advantage here is that if you add more accessor functions in the future, it is only a minor version bump. It also gives you the freedom to change the internal representation of the publicly-available data.

2 Likes

I wouldn’t think of opaque types as something OO, every language that has a module system provides the means to hide implementation details, which is not a bad idea in any case :slight_smile:

1 Like

That is somewhat analogous to the Java way of doing things, which is a little bonkers and one of the reasons many people find Java not nice:

class MyClass {
    private String thing;
    public getThing() { return thing; }
    public void setThing(String this) { this.thing = thing; }
}

“I just want to add a field, why do I have to type so much?” asks the exasperated Python dev who we forced to write some Java.

But yes, the point is to be able to decide what is part of the API and what is not.

Here is the next one: Separating Interface and Implementation

Some of us will have first encountered this in C, with its “.h” header files defining data types and function call signatures, and the “.c” file with the implementing code in it. As a student I was made to learn Modula 3, which was really quite a pleasant language to use. It made this separation of interface and implementation more formal - every module had to have a corresponding interface. We did a group project where each member of the group first worked out what their interface was going to be, then everyone implemented their code using those interfaces, then we put the whole lot together and it mostly worked. Some time before that I got into C++, and it added classes to the .h files. You could spec out a class design for some particular behaviour you were trying to model, but that would permit alternative implementations of the details that your spec did not constrain. It was exciting to program in such an open ended way, but also sometimes distracting with all those clever patterns.

I tend to use ‘Interfaces’ quite a lot in Java code. For example, I wrote a network stack processor, and separated the network, codec, and session handling layers with interfaces. This made testing a lot simpler, as each layer could be plugged into a test harness which provided an alternative implementation of the interfaces the layer needed. At one point we even had an interface writing to a unix socket, that had a simulator running a hardware design for an off-loaded codec on the other end, and plugged the software session layer into that - made possible because of the use of interfaces up-front in the original software design.

Right now, I have a team developing software in the office where I work, and another team in another office in another country. The work has been divided up along the lines of: data analytics in this office where the subject domain experts are, cloud infrastructure for the analytics in the other office - also our lone wolf UI developer (he uses Angular not Elm, but he can also crank out UIs impressively fast). They need interfaces so they can agree on things up-front and then build those things, and feel fairly confident that they can bring their pieces together and make everything work. So interfaces in software are important in helping things scale, and they are needed in application code, not just in library code.

So where are the interfaces in Elm? First lets try a fairly direct translation of an interface in Java into Elm:

/** I need to put many data elements somewhere and get them back later, possibly in a different order. */
interface Buffer <E> {
    boolean add(E e); // throws IllegalStateException if item cannot be added.
    E remove(); // throws NoSuchElementException which is a runtime exception.
    E element(); // throws NoSuchElementException which is a runtime exception.
}

type alias Buffer e buffer =
    { add : e -> buffer -> buffer
    , remove : buffer -> Maybe ( e, buffer )
    , element : buffer -> Maybe e
    }

Java Buffer also has poll() and peek() methods but these use nulls when the buffer is empty. We don’t silently fail in Elm so I took the versions which throw exceptions and translated those into Maybes.

Something interesting happened, the Java version has only one type variable E, but the Elm version has two, e and buffer. The type variable buffer had to be added to provide somewhere to put the implementation.

Here are some alternative implementations of Buffer:

stack : Buffer e (List e)
stack =
    { add = \item list -> item :: list
    , remove =
        \list ->
            case list of
                [] ->
                    Nothing

                x :: xs ->
                    Just ( x, xs )
    , element = List.head
    }


queue : Buffer e (Array e)
queue =
    { add = Array.push
    , remove =
        \array ->
            Array.get 0 array
                |> Maybe.andThen
                    (\result ->
                        Just
                            ( result
                            , Array.slice 1 (Array.length array) array
                            )
                    )
    , element = Array.get 0
    }

With reference to the thread on Dethroning the List, you can see that a Collection e collection type alias could be defined. This could have List and Array implementations with different performance characteristics - code could be written to work with either by working through the Collection e collection interface. (Not that I am saying this should be done - something more built into the language will likely be more performant).

‘add’, ‘remove’ and ‘element’ are named functions. Together as the Buffer record type they form a set of functions for working with buffers. Higher order functions that can do stuff with buffers can then be written. When only 1 function is needed, it is usually passed as an anonymous lambda, such as mapping over Lists:

map : (a -> b) -> List a -> List b

The interface there is (a -> b), the most general one. So compared with Java, Elm is very nice in that it lets you deconstruct interfaces down to anonymous function types if you like, or to create records with named functions for larger structures.

In order to keep the interface and its implementation separate, the consumer of a module of code must be able to instantiate the implementation, but other than that should just depend on the interface. The implementation needs to depend on the interface too, in order to know its type. So you end up with a dependency graph something like this:

interface-implementation

The implementation of an interface must be able to be instantiated at runtime and multiple implementations must be possible. Understood that way, are Elms modules interfaces? No, because the interface as type annotations in a module must be placed next to the implementation. Also modules are not instantiated dynamically.

Note that a stack and a queue do not have the same type, although both unify with the Buffer type; I cannot have a list with both stacks and queues in it. But it is possible to write functions over the Buffer type that will work with both the stack and queue implementations. This is a difference with how Interfaces in Java work, as an Interface in Java sits at the top of a hierarchy of sub-types. Library code can be open-ended but applications must eventually declare the actual types and implementations.

It is not uncommon in Java to see downcasting to implementations or making use of the instanceof operator - and this is more strictly dealt with in Elm by the use of tagged union types and case statements.

A final difference to note is that in my queue.remove operation above I wrote Array.get 0 array rather than just call this.element which already had that as its implementation - this is not actually OO programming.

Interfaces can be used where:

  • Larger project needs some formal interfaces to separate areas of concern between programming teams.
  • To guide the larger structure of a program, even where work is not split between teams or even individuals. There can be a benefit to being able to compile structure before writing an implementation.
  • To plug-in variable functionality by substituting implementations where high flexibility yields a benefit.
  • To code in an open-ended way (dangerous).
  • In every day coding whenever we use higher order functions.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.