Sloppy to pass args map to another function needing subset?

I’m curious if people here generally feel comfortable passing a function’s whole map of named args to another function (called within the original) that may only need a subset of the named args.

Context: I often find I am calling another function with a subset of named args from the original function. I’ve gone back and forth on whether to just pass the second function the original function’s whole original arg map or to pull out the subset of args for the second function explicitly, typically by using select-keys.

An example might be clarifying:

;;This function needs a subset of keys from the one below
(defn auth-token
  [{:keys [user-id authf]}]
  (auth-provider/auth (authf (user user-id))))

;;This function calls the above with a subset of its keys
(defn foo
  [{:as args :keys [item-id price currency user-id authf]}]
  ;;... do some things
  ;;This (concise): 
  (auth-token args)
  ;;Or this (better to be more explicit?):
  (auth-token (select-keys args [:user-id :authf])))

For a long time I would use the first instance above, just passing the args map from one function to the next. At a certain point, though, I found I was losing track of what I was passing and sometimes passing args that caused problems down the line. For example if you have function a calling b calling c (a -> b -> c), it might work fine to pass the whole arg map from one to another until either 1> a changes to accept new arguments that cause c to behave in a new/unexpected way, 2> c changes to accept new arguments that cause the args map from a or b to be interpreted in an unexpected way.

On the other hand, today I was going through some code that uses the select-keys style and it seemed perhaps unecessarily verbose (particularly when you have (a -> b) and b is not calling anything else with named args). It occurred to me that if I clearly document the arguments for each function, and review this when deciding how to pass args, this will help avoid any issues with the more concise style.

Since Clojure has a principle of hash maps being open – i.e., they can include additional keys beyond the ones you care about – I tend to just pass the whole map around.

That way, if you need to change what a low-level function handles, you can just add the keys at “top” level where that control decision is made and those new keys will just pass all the way down through the chain without also requiring every single intermediate call to be changed.

7 Likes

Passing around maps and each function just using what is needed is my favorite way to program with Clojure. Just ensure all keyword keys are namespaced to ensure that you don’t get conflicts.

4 Likes

Let the values flow, man!

2 Likes

I’m going to disagree a little with the others. In my experience it can get messy to pass down maps, but the answer I think is slightly more nuanced than what you’ve described.

For me, it’s more about simplicity. Let’s remember what Rich Hickey considers simple, something is simple when it is untangled. That means it is an independent unit, where changes to other parts of the system do not affect it or break it and where if it changes, other parts of the system are also not affected or broken. It means you can move the unit from one place to another without needing to drag the whole system along. It means you can have multiple things depend and reuse the unit for different purpose without each user of the unit needing to be aware of all other users and their intricacies.

In that sense, you already hinted at some of the issues with passing a map down, all functions above it must make sure that they manipulate the map in ways that won’t break the functions below. If a function above is responsible for providing the correct key/values for a function below, than the chain of execution is coupled so that you cannot remove the above function without breaking the below functions.

You can get into messy scenarios where fn A adds key :a and fn B adds key :b and finally fn C expects :a and :b on the map where A calls B calls C. The whole chain is complected in this case by the map weaving its way along it.

So, for me, we have to go back and discuss the “unit”. If the map I’m passing down is a part of my application’s domain model, like say a user entity, or a database entity. And if the functions I’m passing this map down to are all logically operating on that entity, than it is fine to pass the entity map down to them as is. But this map being an entity means that I’ll have a strong model for it, probably a spec with the invariants for it at various point in time. When you do it that way, each function down is only coupled to the entity itself, not really the functions above, because the shape and invariants for the entity are not implicitly defined by the chain of functions above, they are defined in the application domain as a Spec for example. In that case, I have made the choice that those functions are not independent of my application domain model, but very much coupled with it, so the “unit” would be the entities + all the functions that operate over them.

If I’m not passing such domain entity down, then it depends on if you expect the below functions to simply serve as a way to factor the above function into something more readable and testable? Or do you expect the below functions to be reusable by other things and shared across many callers?

If the former, than it is fine to pass the map down, since the “unit” in this case is the full set of functions involved in the chain, the below functions are simply factored out chunks for the above function. On the other hand, for the latter, I would say it is not good to pass the map down, because you want each function to be its own independent unit.

Now I think there is a common use case where you might start with a domain entity, and as you execute your business logic, you generate new data or you acquire it from a database or another service, and your logic after needs this new data as well as the original data from the entity. It is tempting here to keep adding along this new data to the entity map, but I think this is often what eventually leads to messy code where you can’t understand as well what is where. My recommendation for this, and for a lot of the issues with passing down a map is simply to change how you code things so that your chains are as shallow as can be. Instead of having A add new data to the map it received as input and passing that to B, have an A' that takes the map and returns the new data, then have A be a function that calls A' and then calls B. If you do this enough, you end up with instead of having a chain of A->B->C->D->... you get a shallower topology of A->A', A->B, A->C, A->D, A->...

9 Likes

I don’t think you’re disagreeing at all – I think you’re just highlighting good design practices and some basic hygiene around how code is written which was perhaps just assumed in the OP’s question and the first few responses.

So your response is excellent and adds to the general cases of when it is fine to pass the whole map around – and that when it isn’t fine to do that, the code itself is probably “wrong” which I think also fits the “data-first” aspect of good Clojure design.

3 Likes

fwiw: yes, this, agree.

1 Like

If it’s a nested hash-map (a hierarchical data model) then I tend to call a function with just the subset. This is especially so if it’s a larger model defined in its own namespace

This is mainly done to simplify the access path to the context in which the data is relevant.
An example can be seen in the Practicalli landing page
practicalli.github.io/landing_page.cljs at live · practicalli/practicalli.github.io · GitHub and the data model is in the data namespace

If the hash-map is simpler or flat, then I just pass the hash-map as it is.

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