I’ve been reading quite a few attempts to define Functional Programming recently. It’s hard because we’ve taken so many paths in the industry. There’s some nexus around immutability, referential transparency, and typing. Each language makes its choices, and — if we are looking at those three qualities — it’s clear that you don’t need all of them to have what we call “functional programming” but having at least two at a time working together is enough to give us Good Stuff™.
There is a different way to approach it, though, and I think I arrived at it through years of slogging though legacy code. The biggest issue in legacy code (all code really) is understandability. It’s hard to change things that you don’t understand — you can try, but you’ll often fail.
When I encounter a legacy system, the first thing I want to know is where the gotchas are, the things that break my understanding of the system. Invariably, they are the things that you can’t see at the interface. You call a function and you have no idea that it modifies some global variable, or surreptitiously saves to a database through a chain of calls. In other words, the function signature is a bit of a lie. We use the term side effect for these things, but really, they are the things that break the understanding that an interface could give you, if only it told you everything about how the system was affected when you call the function.
Programming without side effects is really about making signatures honest in that way. If you want to know whether IO is possible, look at the signature. It should tell you.
This isn’t typical in OO code. Sometimes I joke that if I were to rewrite Working Effectively with Legacy Code I’d call it Working Effectively with Object-Oriented Code. So many of the techniques around gaining testability involve parameterizing classes and methods so that all of the inputs and outputs are explicit and mockable under test. You don’t have an embedded call to, say, the file system in a class. You pass in a reference to that capability as a constructor or method argument. This makes an OO system, broadly functional. That is, to say, it is honest. You can look at the signatures and see what is possible. Maybe some mutation happens a function body. You increment a local variable rather than using a fold. That's ok. If it doesn't leak through the interface (the call), you have referential transparency at the call. The signature tells you the full story.
I don’t want to denigrate OO. It’s useful and it can be a decent way of structuring systems, but it is a little less explicit at times. It permits a lot and you have to be careful to use it well.
Pure FP, especially in the typed world, can be a tower of mechanism to make sure that interfaces are explicit. That comes at a price. Monads and effect systems make us think more, and even write more, but we have more support to give our code this quality.
That’s the goal, really. Simplify understanding. No surprises. Honest code.