A bug I've recently seen in production code gave me the idea for this blog post. Probably smarter people already wrote better things on this topic, so this is mostly for myself, to better summarise my own thoughts. Corrections are welcome, please leave a comment!
Let's say we have a somewhat standard API in Python or C++:
- a function (
init_t) to create/initialise our data type, which returns an object of type
- the object then has some properties or methods that we can call
Signalling failures to initialise the object can be done in two ways: either by raising an exception, or by returning a null/None result. There are advantages and disadvantages to both:
- if using exceptions, then you must be sure to catch and handle all possible exceptions that the initialisation function can raise (otherwise more of your code can be aborted than you want)
- if using None as return value, then you must check that the value is correct before using it, otherwise you get an exception or null pointer dereference
The None model can create latent bugs, for example in the following code:
ok = True for arg in input_list: t = init_t(arg) if t is None: ok = False continue t.process() return ok
The presence of the
continue statement there is critical. Removing
it, or moving it after some other statements which work with
result in a bug. So using value-based returns forces us to introduce
(manually) control points, without having the possibility to validate
the model by the compiler (e.g. in C++).
So it would seem that this kind of latent bugs pushes us to use the exception model, with its drawbacks.
Let's look at how this interface would be implemented in (IMHO)
idiomatic Haskell (where the
b types represent the input and
initT :: a -> Maybe T processT :: T -> b my_fn = … case initT arg of Nothing -> -- handle failure Just v -> processT v
Yes, this can be written better, but it's beside the main point. The
main point is that by introducing a wrapper type “around” our main
T), we are forced via the type system to handle the failure
case. We can't simply pass the result of
initT to a function which
T, because it won't type check. And, no matter what we do
with the result value, there are no exceptions involved here, so we
only have to think about types/values, and not control flow
changes. In effect, types become automatically-validated control-flow
instructions. Or so it looks to me ☺.
So using types properly, we can avoid the exception-vs-return-value debate, and have all advantages without the disadvantages of either. If that is the case, why isn't this technique used more in other languages? At least in statically typed languages, it would be possible to implement it (I believe), via a C++ template, for example. In Python, you can't actually apply it, as there's no static way of enforcing the correct decapsulation. I was very saddened to see that Google's Go language, which is quite recent, has many examples where initialisation functions return a tuple err, value = …, separating the actual value from the error, making it not safer than Python. It might be that polymorphic types are not as easy to work with, or it might be the lack of pattern matching. In any case, I don't think this was the last time I've seen a null pointer dereference (or the equivalent AttributeError: 'NoneType' object has no attribute …). Sadly ☹
You can even go further in Haskell and introduce more control flow structure via wrapper types. Please bear another contrived example: an HTML form that gets some input data from the user, validates it, saves it to the database, and echoes it back to the user. Without types, you would have to perform these steps manually, and ensure they are kept in the correct order when doing future modifications. With types, you only have to design the types correctly and export only smart contructors (but not the plain ones):
module Foo ( ValidatedValue , validateValue , RecordId , CommittedValue , commitValue , buildFeedbackForm ) where data ValidatedValue a = ValidatedValue a validateValue :: a -> Maybe (ValidatedValue a) data RecordId = … data CommittedValue a = CommittedValue a RecordId commitValue :: ValidatedValue a -> ComittedValue a buildFeedbackForm :: CommittedValue a -> HTMLDoc
From these types, it follows more or less that the only correct workflow is:
- get a value from the user
- validate it
- commit it, getting a transaction/record ID
- send the response to the user
In other words:
handleFeedbackForm = buildFeedbackForm . commitValue . validateValue
There are still issues here, e.g. the type
a is completely hidden
behind the wrapper types, and we can't recover some basic properties
(even if we use
newtype, unless we use
GeneralizedNewtypeDeriving). But it does offer a way to improve
control flow safety.
And that is my 0.02 currency unit for today.