Demystifying Jeremy's Interfaces

Change #2: Redesigned Initialization

Happy new year! I’ll continue the story…


Rupert and Jeremy are having lunch, and obviously they also talk about interfaces. Rupert tells Jeremy:

You know that I have functions like

intCounter : Int -> Counter

and

listCounter : Int -> Counter

which take a starting value and create a Counter, appropriately backed by either an Int or a List (). But I noticed that the users of these functions don’t really need the starting value, because they always create counters starting from zero, yet. So I was thinking whether I could remove this parameter. In the current function implementations, the parameter isn’t declared explicitly, but if it would be, I would have to pass it as the last parameter to the init function of the Interface module:

listCounter : Int -> Counter
listCounter start =
    ...
        |> (\pipeline ->
                IF.init (\raise n -> raise (List.repeat n ()))
                    pipeline
                    start
           )

(I had to introduce the current value of the pipeline as an intermediate parameter.)

So I thought: what would I do, if I wanted to pass two or more parameters to the “initialization function” (the first parameter of IF.init), or no parameter at all?


Jeremy smiles and answers:

You know what, Rupert? Right after we changed the Interface module last time, I’ve been looking at the remaining code. The impl function looks OK to me, but I had the feeling that the init function could still be improved. Before I tell you what I’ve been thinking about, please do me a favour: could you briefly look at the usages of the init function in your code? I suppose that the “initialization functions” you pass to the init function all have the following shape:

  1. They create an initial value of the representation type. In my function signatures, it has the type r.
  2. As the last step, they pass that value to the “raise” function of type r -> t, which I pass as the first parameter to your initialization functions.

After lunch, Rupert skims through their code.

intCounter : Int -> Counter
intCounter =
    ...
        |> IF.init (\raise n -> raise n)
--                                    -
--   initial value of type Int: n
--   passed to "raise"


listCounter : Int -> Counter
listCounter =
    ...
        |> IF.init (\raise n -> raise (List.repeat n ()))
--                                     ----------------
--   initial value of type List (): List.repeat n ()
--   passed to "raise"


fifo : List (Node state) -> Buffer state
fifo =
    ...
        |> IF.init (\raise rep -> raise rep)
--                                      ---
--   initial value of type List (Node state): rep
--   passed to "raise"


shape bboxFn = 
    ...
        |> IF.init (\raise rep -> raise rep)
--                                      ---
--   initial value: rep
--   passed to "raise"

He calls Jeremy and tells him: You’re right. They all have the shape you supposed. How did you know that?


Jeremy answers:

The initialization functions, which you pass as the first parameter to the init function, have to have the signature (r -> t) -> i -> t (in my abstract type notation). This means that they have to return a value of type t, in the counter example a value of type Counter.

How can you create such a value? The only way to do this is by calling the magic “raise” function, which takes a value of the internal representation type r. So, in your initialization functions, you first have to create this internal value and then pass it to the “raise” function.

After I recognized this, I knew that I could remove this last step (calling the “raise” function) from the user-supplied initialization functions and move it into the Interface module. This would make the init function look like

init : (i -> r) -> W r t t -> i -> t
init ir rtrt i =
    let
        rt : r -> t
        rt r =
            rtrt rt r
    in
    rt (ir i)

Now the initialization functions have the simpler type i -> r. They don’t get the “raise” function anymore, and just have to create an r value from the given i value. I’ll then call the former “raise” function, internally called “rt”, in the init function.

We can go even further: why should the init function be responsible to take the i value from the user and then directly pass it to the user-supplied initialization function? I don’t do anything else with the i value. Why shouldn’t the user perform the ir i call, which I do on the last line, in her own code and only give me the resulting r value? This would reduce the init function to

init : r -> W r t t -> t
init r rtrt =
    let
        rt : r -> t
        rt r_ =
            rtrt rt r_
    in
    rt r

An example for its usage:

listCounter : Int -> Counter
listCounter start =
    impl Counter
        ...
        |> init (List.repeat start ())

But why stop here? Syntactically, all the init function does in the “impl |> init” pipeline is to get another parameter of type r. If the impl function would take this parameter itself, we wouldn’t need a pipeline at all! We could combine the code of the impl and the init functions into one.

Before:

impl : (o -> t) -> W r t o -> W r t t
impl ot rtro rt r =
    ot (rtro rt r)


init : r -> W r t t -> t
init r rtrt =
    let
        rt : r -> t
        rt r_ =
            rtrt rt r_
    in
    rt r

After:

impl : (o -> t) -> W r t o -> r -> t
impl ot rtro r =
    let
        rtrt : W r t t
        rtrt rt_ r_ =
            ot (rtro rt_ r_)

        rt : r -> t
        rt r_ =
            rtrt rt r_
    in
    rt r

If we inline the internal rtrt function and remove the outer “r” parameter (to reduce shadowing), we get

impl : (o -> t) -> W r t o -> r -> t
impl ot rtro =
    let
        rt : r -> t
        rt r =
            (\rt_ r_ -> ot (rtro rt_ r_)) rt r
    in
    rt

We can simplify the last line in the internal rt function to get the final version:

impl : (o -> t) -> W r t o -> r -> t
impl ot rtro =
    let
        rt : r -> t
        rt r =
            ot (rtro rt r)
    in
    rt

In fact, in the next version of the Interface module, I’ll even remove the W type alias, because in the meantime it is only used once. The signature of the impl function then will be

impl : (o -> t) -> ((r -> t) -> r -> o) -> (r -> t)

Oh, by the way, Rupert, did you notice that we don’t have a value of type i anymore in the signature? Coming back to your original question, this means that you are completely free to choose how you want to create the initial r value. You can take one value from your user as before, two or more values, or no value at all. Here’s a version of your listCounter function starting always at zero:

zeroListCounter : Counter
zeroListCounter =
    IF.impl Counter
        (\raise rep ->
            { up = \n -> raise (List.repeat n () ++ rep)
            , down = \n -> raise (List.drop n rep)
            , value = List.length rep
            }
        )
        []

Note how you now simply pass the initial [] value of type List () (representing the counter value “zero”) as the last parameter to the impl function?


Rupert nods and says: it always feels extremely satisfying if you manage to improve your code just by removing parts of it, doesn’t it?


You can find the actual code in this Ellie. In the next part, we’ll be adding some code again…

3 Likes