Is it inefficient to pass the whole model from function to function?

I’ve seen it said before — can’t remember where — that passing the whole model from function to function is inefficient, and that we should only pass to a function what it actually needs.

But this is difficult to do because, when you start writing a function, you don’t necessarily know what it will need in the end. So you just pass the whole model every time functionA model =, and plan to re-write it later to something like functionA model.partA model.partC model.partD = ....

I’m considering making this less onerous by re-factoring my model into big-stuff (data loaded from a database) and small-stuff (everything else). So then, most functions will be functionA model.smallStuff, and those that need the database data will become functionB model.smallStuff model.bigStuff.

Hope this makes sense.
Thoughts, ideas and experience, much appreciated.

2 Likes

I believe sending the whole model from function to function is mostly fine (doesn’t result in any deep copying etc.) - I wouldn’t worry about it unless somebody comes with performance benchmarks saying otherwise.

1 Like

I don’t think it’s about performance.

It’s more about writing functions that don’t do too much (like SOLIDs S - Single Responsibility Principle) and about having interfaces (function-signatures) that should be as small as possible.

This way it’s easier to refactor/generalize and probably it will help you find good names for functions.


You can for example do this by starting with no arguments (or only the arguments you know you absolutely need) and adding them as you go, or by not writing a signature till you are done (I consider each field in a record as a single argument here)


breaking your model down in parts might help (although the Elm community usually is vary of this and prefer flat models as it’s easier to work with them - personally I don’t - I think the Handler pattern and components can be a valuable part when writing Elm apps)

I’d recommend not doing this not so much for data/remote and local stuff but more for domain-boundary stuff - but here I don’t really have hard facts aside from me being happier with my code this way (so DDD vs. n-layer if you want to see it that way)

3 Likes

If I remember correctly, Richard Feldman talk about this at Oslo Elm Day conf in 2019 Richard Feldman - Exploring elm-spa-example - YouTube

If you talk about view functions and use Html.Lazy it quickly becomes a performance problem.
If you render a long list in the view and have the time updated every second in your model, or have any mouseMoveEvent, this list will be recalculated/rendered on every mouse-move.
If you only pass the data needed to render the list(and use Lazy), it will not rerender when time changes or on mousemove, only when that exact passed down part in the model changes.

For update functions, probably not an performance issue.

7 Likes

In Scaling Elm Apps Richard also talks about using extensible records for this:

Essentially on the call side you would still pass the whole model, but the function can limit which fields it actually “sees”:

type alias Model =
    { field1 : Int
    , field2 : String
    , field3 : Value
    }

-- Lowercase `model` to refer to any record with a matching field1 and field2
f : { model | field1 : Int, field2 : String } -> String
f { field1, field2 } =
    String.fromInt field1 ++ ". " ++ field2

view model =
    Html.text <| f model

Of course, if you know that there are groups of fields you always need together, it might make sense to define a type alias for it and use a nested field - usually you will also have a bunch of functions operating on that new type already, which could be extracted to a separate module then.

Starting with extensible records makes it easier to find those patterns in your model, since they are a type-level-only mechanism on that specific function, so can postpone refactoring your entire app until later :slight_smile:


Aside from that, it is really cheap to pass values to functions. Since all values in Elm are immutable everywhere, Elm does not actually have to perform any copies when it calls a function to make sure nothing can be changed. Semantically, there is no difference between making a copy or not, so Elm just skips doing that work.

Only when you construct a new model value ({ model | field1 = 5, ... }), the model needs to be (shallow) copied. Again, this only copies a bunch of references instead of the real data, so I’d consider that by itself basically free as well. You should only notice it if you copy thousands of nested objects (e.g. by appending to a large list).

To optimize model update performance, you would probably group your data into a tree with 32 fields at each level, but please never ever actually do that!

3 Likes

Hi Alan,

The architecture that we ended up with is a bit like what you describe: treat “the Model as if it was a DB or cache”, then create getter functions “as if they were query”. It’s super fine, until you run into List.filter or List.sort. To mitigate this scenario, we started to modify how we fetch / parse / store data from the server, to do either indexing or predefined operations that relieve the view function.

Although it makes the Lazy.map* functions harder to use, so it’s to be avoided for anything games/user interactions intensive apps (like drawing or maps).

cheers

If by “innefficient” you are talking about the compute efficiency?

In a pure and strictly evaluated FP language like Elm, there is no real difference from the programmers perspective between pass-by-value and pass-by-reference. Contrast that with languages where there is mutability - it makes a difference because updating a reference and updating a local value are different. Updating the reference on the heap is visible to the rest of the program (and other threads). The local update is confined by the call stack, and lost upon return.

Any structure (larger than the basic values Int, Float, Bool, String, …) is going to be passed by reference, since this is more efficient and has no disadvantages due to being indistinguishable from pass-by-value. So calling a function with it will always be the same regardless of the size of the structure (Model). So you do not need to worry that passing a larger model from function to function will take longer the larger the model gets.

But perhaps you mean “innefficient” in a different sense.

2 Likes

Since Elm is pure and immutable, I don’t think there is a performance penalty for passing the whole model. But I think your code will be harder to read and what a function depends on should be more explicit.

If you find yourself having to change the interface (adding parameters) too often and having too much parameters, maybe your problems lies in other spaces:

  • your function may be too big (with too much reponsability);
  • your types too small (maybe you should create types with a broader scope?)

In the case of extensible records, does it suffer from this performance issue?

Many thanks for all these excellent replies. Been trying to get my head round it all.
I’ve marked further questions and assertions with bold numbering.


Nope, that’s exactly what I meant; you got me first time :slight_smile: Very clear explanation, thank you.


@Atlewee Brilliant — I’d forgotten Html.lazy.

1) So, in the view, we need to avoid passing references to stuff that changes frequently — whether using lazy or not — because the DOM is updated every time that something passed to a view function changes, whether relevant to the function or not. And we then use lazy where input values change rarely, to avoid unnecessary virtual DOM updates as well.

2) So, would it be true to say that, Html.lazy viewFunction model, is the worst of all worlds? Because Html.lazy has to build a model sized ‘memo’ that will never be used because there’s always something changing in the model?


3) So, in

viewFunction : Model -> Html Msg
viewFunction model =

we are not actually passing model to the function, but instead we are making ‘model’ available to the function.

Then, by using an extensible record (great description here) the function definition limits what it can see/access, and therefore what it is responsible for: if a function can’t access a value then

  • it can’t be responsible for errors in the value, and
  • the value can’t be (and isn’t required to be) part of the function’s tests

4) The consensus seems to be that, generally, passing around a reference to the whole model isn’t a problem, but that it’s better to have functions that reference only values that they use so as to limit their scope of responsibility and make them easier to test, and easier to re-use.


5) My original question stemmed principally from view functions.

If we have a deep view structure like this:

         mainView
            |
  -------------------------------
  |         |         |         |
view1     view2     view5     view6 
  |         |         |  
view3     view4     view7  
            |
          view8

The function view2 must carry references to everything required by view4 and view8. So, for simplicity, we end up passing a reference to the whole model down each branch from function to function.

Given that we should avoid passing more to a function than it needs for it’s own purposes, this suggests that a flat view structure is desirable.

         mainView
            |
  --------------------------------------------------------------------
  |         |         |         |         |        |        |        | 
view1     view2     view3     view4     view5    view6    view7    view8

6) An interesting question, if I understand it correctly.

So, if we have a model that is { field1 : Int, field2 : String, fieldTimePulse : Posix }
and a function defined as viewFunction model = ...
and we call it with viewFunction model
any change in any value in model will cause a recalculation (virtual DOM) and repaint (DOM) of viewFunction.

But what happens if we call it with
viewFunction { model | field1 : Int, field2 : String }
or with
lazy viewFunction { model | field1 : Int, field2 : String }
Are either/both of these function calls affected by the multiple changes in fieldTimePulse ?