I feel that incredible byproducts of effects are never discussed, and they are awesome. i’ll call two out:
-
reduced module coupling. if some module is intrinsically bound to either a) many modules or b) some heavy module or c) some wonky module, you can abstract those negative properties away with effects. In this fashion, you may erase all references/imports/etc to those modules, then allow your module to focus strictly on its core offering. Your effects consuming module focuses on its functional-core logic, and defers complexity of the outside world to effects designed to deal with the harsh reality of the outside world. Here’s an example.
(* non effects demo *)
module Git = struct
let is_dir_clean last_sha dir =
let files = ref [] in
Fs.walk ~dir (fun childpath -> files := Fs.open_stream childpath :: files);
let sha = List.fold digest !files in
sha = last_sha
end
There’s nothing intrinsically terrible about this module. However, you may observe that we’re coupled to some Fs
module, the List
module, and …wherever module digest
is coming from. Woe is us! What if we wanted to test a remote directory? What if we wanted to swap the hashing impl? Functor police, I hear you, but entertain me.
(* effects demo *)
module Git = struct
let is_dir_clean last_sha dir = perform (HashDirectory dir) = last_sha
end
What’s the difference? Now our is_dir_clean
is totally decoupled from implementation details. It’s more expressive, it’s more direct. Of course the lines removed are not actually removed, they are just hoisted up the stack somewhere, but that also brings benefit, such as in cases of testing. Even if we had done:
(* non effects demo, p2 *)
module Git = struct
let is_dir_clean last_sha dir = (Sha.of_dir dir) = last_sha
end
the coupling would still be present, albeit local magnitude of coupling reduced.
They effects syntax and semantics are probably not concise enough right now to make the effects based solution highly pragmatic, but they are close. Many here may suggest “you should have created a parameterized module!” or “Inject that wonky resource! It’s FP, composition is our jam!” This is all fine and good, and probably correct in this tiny demo. However, in some cases composition and functorization also has drawbacks. Parameterization complects module signatures, and forces work directly on consumers, where consumers may not have the the resources available. This scenario happens all of the time. Ultimately, developers find themselves threading resources deep through programs, some way or another. In this case, I needed a directory hasher, bound to a specific Fs type (local/remote) and some hashing algo. I posit that everyone here knows this feeling–“ah shoot, I need X, but X isn’t available here. Let me just refactor my whole (otherwise fine) program to transport resources to where they are needed.” I do this often in mid-to-large sized programs. In some cases, by deferring local complexity by means of effects, one may have a very freeing experience at the cost of some indirection (and likely a tiny bit of performance). The prod/release impl can implement one handler in an isolated space, and the testing impl can implement something simpler in the test harness. One then may ask, “so do I just effect
all of the things?”. No, certainly not. However, modules with high integration with other modules may be candidate. I’ve been dwelling on a pattern of exposing libraries that use effects, that conventionally also export a handler fn with sensible defaults. eio
somewhat does this now. Consider Eio_main
sets up all its needed handlers, then the other modules simply use effects, but the consumer is nonethewiser.
OCaml is highly readable, iff the function under study question reads top to bottom. However, it’s extremely common in OCaml to have a “bouncy” function body. E.g.
let foo a =
let f x = Bar.baz x in
let get_bars ys = List.map f ys in
let format_bars b = List.map Bar.fmt in
get_bars a |> format_bars
I’ve long wanted to write a “code-complexity”-like score for what I call “line bounce”. If follow the control flow of foo
, you first read top down, establishing functions, but as you follow your data through the function, you actually jump back up every line, then back down to the return. It’s the polar opposite of fluent programming. In this case, it’s not so bad. But it’s not uncommon for closures to be stacked on closures, auxillary rec
functions inlined, callbacks to be passed outside of the local function… . I don’t think it’s controversial to suggest that it’s not always the best. Yes, there are strategies to mitigate. effects
is one such strategy.
let foo a =
let bars = perform (GetBars a) in
perform (FormatBars bars)
This is admittedly a contrived and weak example, primarily because the “non-effects” version of foo
could be easily pipelined with strict top => bottom, left => right semantics. Nonetheless, one can observe that effects is another vehicle to get that t/b, l/r direct fn flow, which yield’s less bounce, with less interim noise. Less noise = more delight!