The funny thing is, Mark, I went from using early return a lot, to much less as I picked up Scala and wrote more functionally, and then back again in nontrivial cases, because in practice it actually was harder.
(@Jasper-M – maybe you can imagine this?)
Let me give an example. Suppose you are writing a game server, and you get a message that says player Tobnoddy wants to send 100 river gold to Slayerrrz, encoded in JSON. You might write something like
def handleMessage(msg: String): Either[Err, Unit] =
msg.parseAsJson.flatMap{ j: Json =>
j("type").to[String].flatMap{
case "transfer" => j.parseAsTransfer().flatMap(_.handleTransfer)
case ... => /* other code */
}
}
Pretty reasonable, no nonlocal returns, and everything is cool. (A for-comprehension doesn’t help much because of the match statement smack in the middle.)
But if you had a magical method ?
that took the error case and returned it right away (and I’m labeling it ?
because Rust has exactly that), you might instead write it like this:
def handleMessage(msg: String): Either[Err, Response] = {
val j = msg.parseAsJson.?
val t = j("type").to[String].?
t match {
case "transfer" => j.parseAsTransfer.?.handleTransfer
case ... => /* other code */
}
}
Now, I don’t know about you, but I find this considerably clearer. All that visually cluttering nesting that is being used only to communicate that you’re repeatedly doing early returns of bad values is gone. Where the early returns are is clearly marked–with a smidgen of training you can pick them out–and are also easy to not pay attention to when you’re focusing on the logic of the correct path. It’s like a for-comprehension except more flexible.
This illustrates that early returns aren’t necessarily bad, but it doesn’t illustrate that they’re really good in more complex situations. Let’s try a fictitious handleTransfer
with both styles, where errors with the “from” user are just plain errors, but errors with user2 are actually responses that are sent to user1. First, the early-return way, because I just tried and can’t keep the logic straight on the other one without something to work from:
def handleTransfer(req: TransferRequest): Either[Err, Response] = {
val u1 = req.user1.lookup(db).?
val u2 = req.user2.lookup(db) match {
case Left(e) => return Response(s"Cannot find ${req.user2}: $e")
case Right(u) => u
}
val depositor = u2.checkAccess(u1) match {
case Left(e) => return Result(s"$u2 has not given you deposit access")
case Right(dep) => dep
}
val withdrawal = u1.withdraw(req.amount).?
val conf = depositor.accept(withdrawal).?
Result(s"You transferred $withdrawal to $u2, confirmation number $conf")
}
Trying to do this without early returns is considerably more painful, at least for me:
def handleTransfer(req: TransferRequest): Either[Err, Response] =
req.user1.lookup(db).flatMap{ u1 =>
req.user2.lookup(db) match {
case Left(e) => Response(s"Cannot find ${req.user2}: $e")
case Right(u2) =>
u2.checkAccess(u1) match {
case Left(e) => Response(s"$u2 has not given you deposit access")
case Right(depositor) =>
u1.withdraw(req.amount).flatMap{ withdrawal =>
depositor.accept(withdrawal).map(conf =>
Result(s"You transferred $withdrawal to $u2, confirmation number $conf")
)
}
}
}
}
The reason it’s painful isn’t that it’s much longer (it’s not); it’s that you aren’t allowed to forget that you are on some arm of some control flow, which continually raises the possibility that you forgot a branch; also, there’s noticeably more clutter in finding what variables are in scope (and you can accidentally shadow them). And unlike in the first case, you can’t just replace it all with a for-comprehension because the logic is more elaborate.
The second way isn’t awful. I have tons of code that looks like that. But I can read through the first code considerably faster, because whenever you don’t need to think about anything any more, it goes away.
So I am a big advocate for early returns in serious code. Having a utility method (needs to be a macro, actually, in Scala) like .?
makes it considerably better for error-handling, but as the methods grow larger even handling that by hand is a win, I find.
Again, if you’re doing 100% FP all the time, you have other ways to solve this problem, and even when you don’t have any alternatives it’s worth not having to think about as much stuff. But for anyone else, I think early return is really worth considering. I don’t use it flippantly; if (p) x else y
is clearer than if (p) return x; y
. But in cases where I need to clear out the cruft so I can focus on the relevant logic, it’s brilliant.