4 ms·
"However, this flexibility seems to be primarily used by language/framework implementers themselves. Not by application developers." Yes, but the question is w
by jerf 3y ago
"However, this flexibility seems to be primarily used by language/framework implementers themselves. Not by application developers."
Yes, but the question is whether that is because it isn't any good for application developers or because application developers are unaware that it is an extremely useful idea, and if done properly, doesn't even carry that much marginal cost.
I work primarily in imperative languages, like most people, but almost all the code I write now I separate into an "IO driver" and relatively pure code that uses the driver as a parameter. Like, everywhere, in every language I use now. The proximal reason is that testability skyrockets when you do this, because what makes tests hard is typically the intermixing of these two concerns. When you isolate them they are almost always vastly easier to test in isolation from each other, and this even contributes to integration level testing higher up.
I say it isn't that expensive because once you get over an initial period of practice and learning how to do this, the difference between (in some random psuedocode)
func DoUserInput():
x = userInputLine()
y = bigPureComputation(x)
outputResult(y)
and something like
interface IO:
inputLine() string
outputResult(string)
func DoUserInput(IO IO):
x = IO.inputLine()
y = bigPureComputation(x)
IO.outputResult()
type RealIO:
RealIO.inputLine = inputLine // the global version here
RealIO.outputResult = outputResult
really isn't that great; here you see all the overhead front and center but at scale this tends to be less of a big deal on a percentage basis than this would imply, and it pays off immediately in testability... yes, what I wrote implies that bigPureComputation must be easy to test, but now I can provide drivers that allow me to essentially push that purity up into DoUserInput and test it as if it were a pure function, because when I combine that with a testing implementation of IO the whole DoUserInput+TestIO essentially does become externally pure, even if you can look on the inside and see things like input being consumed and such. Testing RealIO is also easier because now it's all broken into its component pieces, and of course at real code scale all of these little snippets can expand into arbitrarily complicated and otherwise difficult-to-test functionality.
In my toolbelt, this technique is basic, fundamental, and desperately underutilized by application developers. It is sooo easy to write code up front in this style and such an amazing pain to refactor into it after the fact. Doable, absolutely, but an amazing pain.
- rmgk 3y agoFirst of, I do like that style. In the context of my argument its quite interesting because you essentially argue that one can get the benefits of separate runtime implementations without actually needing pure functions (in a very strict sense) or any of the IO encodings. What I had in mind when I said application specific handling, I meant things like the application developer writing a custom optimization pass on the returned IO values to make the execution more efficient for the specific application. Or adding custom way of handling/reporting errors. And I meant doing so while not making their own interfaces and instead working with whatever the common IO abstraction is.
- o_nate 3y agoI agree this is a great pattern and one I try to use in all my code. I believe it is often known as the "Functional Core, Imperative Shell" pattern.