When I’m writing some function, there’s a general tradeoff between how abstract the function is, and how flexible it is. That's rather vague, so let's be a little more specific. It should be noted that for the entirety of the article, I'll be considering only pure functions with no side effects (e.g. no randomness, no IO, just pure data manipulation).
I’m going to define how abstract a function is by the size of the set of all possible inputs. I’m going to say that if some function X can accept a larger set of inputs than function Y, than function X is more abstract than function Y. A function that takes in a list of anything is more abstract than a function that takes in specifically a list of booleans.
I’m going to define how flexible a function is by how many different possible implementations of it exist. The function
f1 :: Int -> Bool is more flexible than the function
f2 :: Bool -> Bool, because there's more ways to transform an integer into a boolean than there is to is to get a boolean out of another boolean.
Let’s go through an example or two. Take the following functions signature:
stringToBool :: String -> Bool
stringToBool takes in a string and returns a boolean. It accepts strings as its only parameter, so it’s not particularly abstract. However, it’s relatively flexible. An implementation of such a function could check if the string had a certain length, contained a certain letter or subsequence, or check if its levenshtein distance from your name was below some threshold. The possibilities are numerous.
Let’s take a second example, the classic id function:
myIdFunc :: a -> a
myIdFunc says “give me some object of type
a, I’ll give you back some object also of type
a. This is about as abstract as it comes; the set of possible inputs is the set of all possible values. You can give this bad boy literally anything, and he’ll proudly vomit it right back at you. I’m not sure how a function could possibly be more abstract, given our definition of abstractness.
myIdFunc is also ridiculously inflexible. It’s about as inflexible as they come; I'm pretty sure the only possible implementation is to return what was given to you. That’s it; there’s no other way that I can think of to write a function that satisfies this function signature.
But why can't
myIdFunc do anything else? Well, it’s because we don’t know anything at all about what we’re getting, all we know is that we have something. We can’t add or subtract or multiply anything to it, because we don’t know if it’s a number. We can’t compare it to anything, because we don’t know how to compare it. We can’t instantiate some other, new object of type
a, because we don’t know what
a is. The only thing we can really do is hold it briefly, then pass it on.
Going back to
stringToBool, the reason it’s so flexible is because we know a fair amount about our inputs. We know we’re getting a string, and we know that we can add to strings, slice strings, compare strings, do all sorts of things with strings! It is exactly because the function is less abstract that it is also more flexible. It is because
myIdFunc is so abstract that it is so inflexible.
Let’s take a middle ground here, with a third example:
myThirdFunc :: Show a => a -> Bool
myThirdFunc is very similar to
stringToBool except that it accepts some
a as its parameter instead of a string. The
Show a => part means that this function will only accept objects that can be passed into a special
show function, which will give us back a string. Said another way, we've added a constraint that whatever
a we get, we have the ability to get a string out of it via the special
What this gives us is a function that has the exact same flexibility as
stringToBool, but is far more abstract. We can pass it any object that can be turned into a string, which means we can pass it ints, floats, lists, bools, and anything that implements the
Show behaviour. Again, the flexibility remains identical to
myFirstFunc, because the only thing we know about
a is how to hold it, and how to turn it into a string.
myIdFunc, we can see that adding the constraint that the object we receive implements the
Show behaviour, we can increase our flexibility while sacrificing some abstractness.
The lesson I’m trying to convey here is that this tradeoff is inherent; any function that accepts a wide range of values is reduced to only being able to use the lowest common denominator of behaviours; the intersection of all possible behaviours across all input types. Here’s small table with some example behaviours to help illustrate (before you get all hot headed about not being able to multiply a list or hash a tuple, I’ve just pulled these pulled these answers from an imaginary made up language. It's the concept, and not the specifics, that matter here):
|Type/Behaviour||Get first?||Get length?||Hashable||Multiply by 3.7?|
Looking at the table, we can see that if we wanted to write a function that involved hashing the input, then immediately we won’t be able to accept tuples or arrays. For any function, each new ability that you need your inputs to have slices away from the space of possible input types. Said another way, the more flexible your function needs to be, the less abstract it becomes.
Also, if we wrote a function that specified that it could take in anything as long as it's hashable, than the only thing we can do is hash it! Even though we can take in strings, ints, and floats, we're reduced to only the behaviours that are common amongst them all. By making our functions abstract (only requirement of a parameter is being able to hash it), we've made our function far less flexible.
The more you know about something, the more you can do with it. I think of flexibility as going deep; the ability to deeply know what you're holding to dive right into it's inards to get messy. I think of abstractness as it's corrallary; going wide; the ability to scoop up a lot in your large, but ultimately shallow, net.
Im going to posit that, in the general case, functions call other functions that are more abstract. That is to say, abstractness flows down the stack, while flexibility flows upwards. This makes intuitive sense, since generally at the highest level of your code, you're dealing with the specific business logic, while the lowest level is generally some form of IO dealing in pure abstract bytes.
These two facets lend itself nicely to a pyramid visualization, where the base is wide and abstract, and the top is skinny but tall.
Abstractness and flexibility are very much a duality; to have more of one is to have less of the other.
This all sounds a little tautoligist; more abstract code is closer to the bottom of the stack, because code at the bottom of the stack is necassarily more abstract. However, I find value in realizing the connection and the mental model, and so I wrote about it.