Maybe Functions
blog.benwinding.com
blog.benwinding.com
1. Parse, don't validate (https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...)
2. Pipeline-oriented programming (https://fsharpforfunandprofit.com/pipeline/)
In my experience, the "best" code (defining "best" as some abstract melange of "easy to reason about", "easy to modify", "easy to compose", and "easy to test") ends up following the characteristics outlined by the sum of these three essays — strictly and rigorously elevating exceptions/failures/nulls to first-class types and then pushing them as high in the stack as possible so callers _must_ deal with them.
But I've found that while "everything is relative and should be situated in the context of the problem you're trying to solve" is a useful truism, it makes for poor praxis. It's hard to improve existing code or develop newer engineers without _some_ set of compasses and heuristics for what "good code" is, and once you develop that set the patterns and strategies for implementing "good code" naturally follows.
Parsing is determining whether you should do it or not- it's about setting up a boundary from which you never attempt something that would be a maybe.
Unfortunately, a lot of languages make it difficult to have the compiler enforce exhaustiveness.
* Monads naturally arise out of many problems in programming.
* But I don't want my language to support monads.
* So here's something you can do to stay in denial about how much you need monads.
At least this example only involves writing hard-to-analyse code and doesn't lead to you trying to invent green threads.
I’m not wedded to stuff like monadic state, I think that might be a bridge too far for regular programming (and besides which, it doesn’t really generalise anyway) but that still leaves a large family of issues that we’re all aware of but trying to dodge.
It is false that getUser being a “maybe function” forces the other functions like getFriends to be maybe functions. Don’t let them take null in their arguments. Force the caller to deal with the null when it is returned by getUser.
I've worked on codebases where people were so allergic to the "billion dollar mistake" of nulls, that they created empty objects to return instead of returning null. This bit us in the ass a couple of times, e.g., when caller code was mistakenly passing the wrong ID variable into a fetch method, and just happily continued working and writing garbage into the DB, because it did not realize that its fetch had actually failed. It took data from the empty result object and happily continued its computation with it.
You could easily argue we should have just presented this exception to the user in all cases but this is where we landed. It’s probably the only case this pattern was beneficial for me.
It feels like the most likely thing to happen is that the `getUser()` call would throw a Null Pointer Exception?
I think the author is avoiding the pitfall of the NullObject pattern applied incorrectly with solution #1 because they're not masking the 'null-ness' in the code further down, they're just assuming that `null` will never get passed as a value. If it is, code blows up & then gets patched.
You can remove the null checks and the software will raise a null pointer exception. In the first example, could raise a NotLoggedInException.
It's still a maybe function, but you have a mechanism for expressing the why-notness of the function run, as opposed to returning a generic null.
As an aside, I prefer the "Unless" model of thinking vs the "Maybe" model of thinking. It's biased towards success. It presumes that the function is most likely to do something unless a precheck fails. filterBestFriendsUnless vs maybeFilterBestFriends. getUserUnless vs maybeGetUser. If we go this far down the rabbit hole, we can assume there's always an "unless". Programs run out of memory, stacks have limited depth. There are maybe conditions for which we can not account.
But you still have to rely on the docs to tell if a function can abort execution (say, by calling std::optional<T>::value() when there's no value). And an unhandled exception would abort just the same. Where do you see there being a difference?
> and which and when.
Maybe types don't tell you that either, you still need documentation for that.
Even worse, Maybe types cannot tell you that unless they're leaf-ish functions. Because they may call opaque functions (such as your own callbacks) for which they have no such knowledge to begin with. Thus they have to support propagating some type-erased error type... which is exactly what exceptions do.
So, again: how is the situation different?
The exception actually occurs when you call next() on a generator which cannot return any more values, or is finished, in which case `StopIteration` is usually raised.
>>> next(iter([]))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
>>> next(iter((lambda: (yield 5) if False else None)()))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIterationHere's a quick description from somewhere on the interwebs: An error is an issue in a program that prevents the program from completing its task. In comparison, an exception is a condition that interrupts the normal flow of the program.
Then there is StopIteration, which does not fit well into either of the two above. It's a wart I've learned to treat as a beauty mark.
You could signal errors via eg returning None or False or throwing an exception. But not throwing an exception could also be an error. (Eg if your for-loop never ends, that might be an error. And that would be synonymous with StopIteration never being thrown.)
[0] Eg for a program like 'cat' it would normally be considered an error, when the file being read doesn't exist. But perhaps in my particular usage, that's expected to occur quite often, and is a normal part of my operation. You can translate this example to Python: FileNotFound might be an error, or a normal condition.
Surely that's simpler than specifying those conditions before every call to show this dialog, resulting in plenty of duplicated code. And if those conditions change, there is only one place I need to update it.
Of course I can make a single operation to check those conditions like "shouldShowReminder", but that too is doubling the surface area of this code.
I see the merit of the argument here but disagree with the absolutist stance against "maybe" functions.
if ( shouldShow() ) /* state changes here where it should not show */ doShow();Also, you have to deal with developer mistakes and what happens when they call incorrectly. This can be something as simple as getting the first element of a collection. What happens when the collection is empty? You can adopt the C++ approach of “undefined behavior” but it turns out to be dangerous.
Monads provide a nice disciplined way to dealing with this and composing together functions that can potentially fail.
Thankfully, newer languages such as providing support for monads and older languages are evolving features/libraries for monadic error handling.
There is only one safe(ish) way to deal with programmer errors: crash. Hopefully loudly and early enough so it gets discovered in testing.
Predicting every possible failure reason for a function is impossible. Every function is a maybe function.
getUser: Option[User]
getFriends(u: User): Seq[Friend]
bestFriends(f: Seq[Friend]): Seq[Friend]
renderFriends(f: Seq[Friend]): Option[UI] // Unit or type UI or HTML or ...
Only `getUser` actually returns an option and is explicit about it. `renderFriends` could arguably do without.To call, we can do
bestFriends: Option[Seq[Friends]] = getUser.flatMap(u: User => renderFriends(bestFriends(getFriends(u))))
The render function could either gracefully render an empty list or error check as part of the `flatMap`, which takes the form of flatMap[B](f: A => Option[B]): Option[B]
I really, really dislike it when functions signatures are lying to me, since `User` is clearly != `Option[User]` and `null` will not fit the type semantics of `User`, whatever those are.And if you don't _call_ it mondads (but rather something more approachable), it's not that wild and scary sounding a concept all of a sudden.
That way, your compiler error checks null-type scenarios for you, your type signatures are clean, don't lie, and your compiler forces you to explicitly do something like `runSafely` (or `runUnsafe` etc.), usually a single point of failure.
Bonus, `MonadError`-type constructs are awesome too, since I get
handleErrorWith[A](fa: F[A])(f: E => F[A]): F[A]
type functions (this is from cats in scala) to deal with errors explicitly.And if you are going to write a sum type do it properly. If the language doesn't provide sum types but does have function types just use the category theory definition:
function maybeWithUser<T>(withUser: User => T, default: () => T): T {
if (!loggedIn) return default()
return withUser(fetchUser());
}
Wrap this in a class if you really want to, but the idea is the same. This then results in pretty much the code he lists in example 1, exactly because most of the functions are just regular functions: function Page() {
const bestFriends = maybeWithUser(user => {
const friends = getFriends(user);
const bestFriends = filterBestFriends(friends);
return render(bestFriends);
}, null);
return <>
{bestFriends}
</>;
}
Of course sometimes it's better to just use what you have rather than try to use language features that aren't quite there. It helps if you can recognise what's going on though.The "maybe" style has the inconsistency embedded in the type system; it's impossible to have an invocation to getFriends and then not handle the resulting possibility of not being logged in.
Shifting it up to the caller just means that you're going to have to remember to ensure the user is logged in before calling getFriends otherwise you'll get some kind of error, which might give you more control, but now there's no guarantee in the type system that you've handled the case where the user isn't logged in.
Writing ifs everywhere to handle failure conditions might be a bit of a pain, but that's more of a failing of the language than the style.
1) functions which do hidden things outside of their contract and/or whose implementation doesn't properly line up with their types.
2) functions which (by necessity) cannot always return the desired value and must return something else instead.
> The maybe function is a subtle monster that spreads it’s tentacles across the code-base.This applies to both points 1 and 2.
> It’s alternating functionality of “does/doesn’t do something” makes code hard to understand, maintain and debug.
This only applies to point 1.
> They seem to be trivial to add, but difficult to remove. But hopefully this illustrates the concern and ways to fix it.
This cannot apply to point 2, because you cannot take a function which might return a user and `fix` it to make it always return a user.
> Solution 2 - Monads
This is not a monad.
He has implemented the Maybe Functor. runSafely most likely corresponds to map in whatever library you're using, not flatMap.
This is visible from its type signature:
> runSafely(fn: (val: T) => V): Maybe<V> // map
which should be > runSafely(fn: (val: T) => Maybe<V>): Maybe<V> // flatMap
And it's also visible in the example function: function getUser(): User {
if (!loggedIn) {
return null
}
return fetchUser();
}
... which still suffers from points 1 and 2. Because it's the same function which was highlighted as bad code at the top. function maybeGetUser(): User | null {
if (!loggedIn) {
return null;
}
return fetchUser();
}
I believe this is an error. The code sample I took from the article is about getting a user, not the user's friends.Since that function will return a list, an empty List might work.
(mostly waiting for this in JS and Go)
[1]: https://doc.rust-lang.org/reference/expressions/operator-exp...
Given these two function signatures:
pub fn getUser() -> User
pub fn getMaybeUser() -> Option<User>
This code won't compile, because `getUser()` cannot return a `None`: pub fn foo() -> Option<String> {
let user = getUser()?;
return user.name
}
But this code will compile: pub fn foo2() -> Option<String> {
let user = getMaybeUser()?;
return Some(user.name)
}
(rust playground link: https://play.rust-lang.org/?version=stable&mode=debug&editio...)Solution3 for their example:
function maybeRenderBestFriends() {
const user = maybeGetUser();
if(user!=null){
const friends = maybeGetFriends(user);
const bestFriends = maybeFilterBestFriends(friends);
return render(bestFriends);
}
return null;
}There's an urge to return Optional<Object> but now you must check Optional.isPresent AND object != null.
Two remarks:
• the render function is omitted, this pattern as a huge impact on application behaviors, if not for display-as-you-load issues, on DOM hidden state (things like focus, animations, etc…) for web apps.
• App's do have a global state, with self-consistency, scattering it in a mixed match of loading cache and self contained components just make it hard to work with. I think it's better to have a centralized upper level parent component that manage the transitional initialization states and consistency, not necessarily for the whole app, but at least for the whole displayed UI content.
If you have to constantly check for null/undefined it gets annoying and you naturally think about narrowing the state space so entire sections of your program don’t have to think about those possible states.
It should also become obvious when you have a possible null/undefined state and it’s super unclear what that piece of your program ought to do about it other than alert the parent (such as throwing an error). If a component doesn’t have a role to play when null, maybe it shouldn’t ever be seeing null as a possible state.
As Sandy Metz is used to say « Nothing is Something »[0]
> This is highly related to the "Null Object Pattern", but I thought I would explain it from the perspective of functions.
The real use case is when it really is maybe. (Network call, error handling). Then it's about forcing people to deal with that in a typesafe way and not hiding that it really is maybe.
In rust for example, this is trivially handled with the questionmark postfix operator — which is just sugar for match — whereas in languages like JS and Java, stacking Optionals and so on can be rather painful as all this sugar is done manually.
Or function getFriends(user: User): Friend[] { return fetchUser(); } The body of the function is wrong.
function maybeRenderBestFriends() {
const user = maybeGetUser();
if (!user) {
return null
}
const friends = maybeGetFriends(user);
const bestFriends = friends ? filterBestFriends(friends) : null;
return bestFriends ? render(bestFriends) : null;
}Maybe functions that don't have maybe in their name and just silently don't do something without informing the caller.
This is extremely common and the source of many bugs. If your function is a maybe function, name it accordingly.
I'm all for a perfy shortcut / early return but this maybe just seems like an abstraction on a non-issue.
A monad needs some structure in addition to fmap, namely bind and return. These allow you to take a function T -> F(S) and a function S -> F(U) and compose them together to a function T -> F(U).
If you have multiple steps then the advantage is that you never have to unpack "in the middle" and you don't have to care - and the compiler has your back.
Classical example: show the street number of the user or show <None> if there is no street number. There can be multiple things missing on the way and multiple transformations might happen on the way. E.g. the user might not even have an adress saved alltogether.
In that case, you only have to "check whether those Maybes contain values or not" once at the very end.
getUser :: Maybe User
getUser = ...
getFriends :: Maybe [User]
getFriends = do
user <- getUser
let friends = getFriendsForUser user
return friends
bestFriends :: Maybe [User]
bestFriends = do
friends <- getFriends
let besties = filter isBestFriend friends
return besties
render :: IO ()
render = do
let bffs = getBestFriends
case bffs of
Just besties -> renderBestFriends besties
Nothing -> renderNoFriends
The Maybe monad itself contains the equivalent of runSafely from the article, and the syntax propagates the failure case transparently from getUser down to the choice of render function used. All without either the hassle of handling null cases, or the danger that you might forget to handle them and the code crash. Without syntax level support, its not obviously an improvement to meBut it did do something, it checked if the user logged was logged in first.
Procedures should do something. Functions should return something.
https://en.wikipedia.org/wiki/Command%E2%80%93query_separati...
By the way, I have never understood the practice of using a verb in the name of a (pure) function; naming the function after its result using a noun or adjective phrase makes much more sense.