I think the "return an error code" approach is just as unreadable as nested or multiple try/catch blocks when the number of failure states grows large. I think the "Maybe Monad" approach of functional languages and Scala, combined with pattern matching in the language tends to look cleaner, e.g.
some_expression match {
case Some(x) => do_something_with(x)
case None => no result (e.g. null in C/Java/etc)
case Error(x) => handle error x
}
One of the nice things about the monad approach is they can be combined together. Consider the following chain of function calls which may return null at any point:
int val = foo().bar().baz().blah();
To deal with this with if/else statements, you need 3 nested if-statement for a single line of expression. In a language like Scala, you can just write:
for (a <- foo();
b <- a.bar();
c <- b.baz()) c.blah();
I think there is an inherent tension between explicitly detailing everything and boilerplate in terms of readability and writeability. I haven't seen enough Go code or written much of anything to say anything about it, except that it worries me. I do think the multi-return stuff can limit the amount of accident ignoring of error conditions, but the Go language itself doesn't provide any high level syntactic constructs to make dealing with the errors less painful, whereas with the Monad approach (which essentially is multi-return), the pattern matching tends to look cleaner IMHO.
And as Go lacks both exceptions and Maybe/Option type, chaining or computation expressions will look more clumsy. I am still a Go beginner and am wondering if idiomatic Go designs programs in a different way to make up for this.
The comparison is against Go, not against languages with exceptions. There are other languages with the Elvis operator (.?) which can safely dereference nulls as well. Go has eschewed exceptions in favor of checked multiple returns, and I think this is more boilerplate ladden and less readable than alternatives.
Exceptions to handle null deferencing have other issues. NullPointerExceptions are not checked exceptions in most languages, and therefore, you do not see people surrounding chained method calls with potentially nullable intermediate results with try/catch blocks, it's exceedingly rare.
Some languages have nullable types which can be checked by the compiler, and indeed, even Java has adopted @Nullable/@NotNull, but the late adoption of this in Java, and non-existence of it in Javascript/Perl/etc all mean that for the most part, chained method calls, which a lot of people have adopted for fluent APIs/'DSL's tend to go unchecked, and any exceptions simply bubble to the top of the program.
In this regard, Rob Pike is right, and a null in the middle of a a().b().c() call likely represents a null that the programmer should have handled, not as an exceptional condition, but as a recoverable one (e.g. findCustomer() didn't find the customer).
In many cases, returning null I think is the wrong design anyway (I see lots of Java code where a search() returns null instead of EMPTY_LIST if it finds nothing), but null or false as a catch-all error code just seems entrenched.
That's why I like the Maybe Monad approach, because Maybe(boolean), Maybe(number), Maybe(Customer) are different types, compared to using integers and booleans as arbitrary error codes.
> NullPointerExceptions are not checked exceptions in most languages, and therefore, you do not see people surrounding chained method calls with potentially nullable intermediate results with try/catch blocks, it's exceedingly rare.
I won't want checked NullPointerException. That will be so common that people will end up having a "throws NullPointerException" at the top defeating the whole purpose of having it. For many cases, exceptions enforce a cleaner flow.
Connection con = DriverManager.getConnection(...)
If I am trying to obtain a connection, the interface is "returns a connection" or "throws an exception".
> In many cases, returning null I think is the wrong design anyway (I see lots of Java code where a search() returns null instead of EMPTY_LIST if it finds nothing),
In Ruby, seq.select {|x| x.some_pred? }.map {|x| x.some_attr }.sort {|a, b| a.some_attr <=> b.some_attr } works with [] because of high level Enumerable interface implemented by [].
A Java Person.findAll() returning an empty array won't be very useful as you won't be able to chain.
The first two examples you gave are very different. The compiler ensures that you don't forget to check the null case when using the Maybe monad, while you can forget to handle the null value and get a NullPointerException later.
Also, the semantics of catching a NullPointerException in a chain of method calls are different from chaining Maybe results together. If any of the method executions inside raise an unhandled NPE, you'll catch it outside, which is probably not what you wanted to do.
Agreed on both points. I was mostly pointing out that Maybe functionality will need some work from developer side, but can be easily done if you want it that way.
> The compiler ensures that you don't forget to check the null case when using the Maybe monad, while you can forget to handle the null value and get a NullPointerException later.
Though I use mostly dynamic languages with "everything an object"(or close approximation) where everything is effectively a Maybe, I do see the value in enforcing checks before using Maybe. In practice, I try to minimize the nullable types, as it is (too easy to get lazy | not know about implementation) and forget the check. Sometimes I use exceptions for flow control rather than returning null.
> If any of the method executions inside raise an unhandled NPE, you'll catch it outside, which is probably not what you wanted to do.
Yes. That will be bad. In an ideal world, given foo().bar().baz(), there shouldn't be a NullPointerException inside foo/bar/baz given how I am using them. If there can be one, I shouldn't be chaining them that way. But it's hardly an ideal world.
some_expression match { case Some(x) => do_something_with(x) case None => no result (e.g. null in C/Java/etc) case Error(x) => handle error x }
One of the nice things about the monad approach is they can be combined together. Consider the following chain of function calls which may return null at any point:
int val = foo().bar().baz().blah();
To deal with this with if/else statements, you need 3 nested if-statement for a single line of expression. In a language like Scala, you can just write:
for (a <- foo(); b <- a.bar(); c <- b.baz()) c.blah();
I think there is an inherent tension between explicitly detailing everything and boilerplate in terms of readability and writeability. I haven't seen enough Go code or written much of anything to say anything about it, except that it worries me. I do think the multi-return stuff can limit the amount of accident ignoring of error conditions, but the Go language itself doesn't provide any high level syntactic constructs to make dealing with the errors less painful, whereas with the Monad approach (which essentially is multi-return), the pattern matching tends to look cleaner IMHO.