Writing multicomponent functions
If your function executes multiple non-trivial steps and these steps look like they could be encapsulated into separate functions on their own - then they almost always should. This is a bit vague instruction but it’s hard to put it more precisely without formalizing too many things thus making it practically unusable.
Basically we have to keep in mind the ultimate goal here - we want to have functions that are easy to maintain. Fixing a bug in a function which does ten things is much easier when each of these things is isolated and implemented as a separate sub-function. This helps very much with narrowing down the problem and it makes writing tests much easier. Instead of having complex tests for the big function, you could do simpler checks for the sub-functions.
Let’s have an example. Say, you have a functions which given a model calculates several kinds of summaries, A, B and C and combines them into a single output. Then code for each of the summaries should be encapsulated into a separate function unless it’s already as simple as a call to a function. Schematically this could be described as follows:
create_complex_model_summary <- function(model, ...) {
summ_a <- calculate_summary_a(model, <params a>)
summ_b <- calculate_summary_b(model, <params b>)
summ_c <- calculate_summary_c(model, <params c>)
# code to combine summaries
}
If your complex function works rather quickly and does not have multiple side effects, it is completely acceptable, and even preferable, to keep as many input checks inside sub-functions as possible.
So, referring to the previous example, each of the sub-functions may check the
validity of the model and corresponding inputs. The main function may contain
only checks for some general parameters controlling the details of the combined
output.
But what if it takes your function some significant time to execute and/or it has multiple side effects? It could then be very valuable to detect potential problems as early as possible. Nobody likes waiting for several minutes only to find that some of the minor parameters were set up incorrectly. And if your function creates multiple side effects it may get even more terrible.
Let’s consider a hypothetical function which executes the following steps:
- Switches to a new git branch
- Creates a new folder and initializes it with a set of template files.
- Creates an S3 folder
- Updates an already existing index file.
- Calls an external API
Each of the steps results in a side effect. Knowing about an issue with a later step may save you a lot of time you’d need to spend to revert all the side effects created by previous steps.
So how do we detect problems early? The answer is straightforward, by executing checks for all of the potentially problematic sub-functions before executing any of the steps themselves. Sub-functions will be regarded individually at least in tests, so it is not the best idea to completely remove checks from them and only have checks in the main function. And given that a set of checks of a sub-function is in some sense a coherent module on its own that is going to used in at least two places (sub-function and main function) it makes sense to have this module encapsulated into a separate helper function.
Let’s get back to our example of a function with multiple side effects. The last module, calling an API may require some simple checks on parameter format. But an API call may also fail if you don’t have the right credentials stored in your environment. This is also something that’s good to check before actually running anything. And this is how we are going to organize it:
call_api <- function(param_1, param_2, ...) {
check_call_api(param_1, param_2, ...)
# code to actually call the API
}
check_call_api <- function(param_1, param_2, ...) {
# check param_1
# check param_2
# ...
# check credentials
}
And now, having this set of checks encapsulated into a function it’s easy to reuse it in the main function. If we include all the steps, the main function will schematically look as follows:
my_fun <- function(...) {
check_switch_branch(...)
check_init_local_folder(...)
check_init_s3_folder(...)
check_update_index(...)
check_call_api(...)
# ---------
switch_branch(...)
init_local_folder(...)
init_s3_folder(...)
update_index(...)
call_api(...)
}
To keep everything aligned please use this naming convention – name of the check
module should begin with check_ prefix which is then followed by the name of
the corresponding function.