Hidden Complexity

Cyclomatic complexity is a software metric (measurement), used to indicate the complexity of a program. It is a quantitative measure of the number of linearly independent paths through a program’s source code.

I was thinking about cyclomatic complexity in code as part of thinking about our codebase and other analytics we might derive from it. Since I’ve been writing a lot more Go recently than Python, I stopped to consider how the two languages differ, and how the explicit handling of errors in Go might influence cyclomatic complexity — after all, a regular complaint about Go is that the error handling is very verbose.

It led me to consider the following two snippets in the respective languages:

a  = myindex[x]

and

a, found := myIndex[x]
if !found {
return NotFoundErr
}

These two statements may or may not be the same — the Python version (top) may be guaranteed to succeed, may be caught as a KeyError (or Exception) somewhere in another calling function, or may just be unhandled. In some ways, the example is simply illustrative.

In the case where it’s absolutely guaranteed to have the item (or the zero value is an adequate sentinel), the code in Go could strictly be made more terse (while remaining explicit about the code flow) by either ignoring the error value or not capturing it at all:

a, _ := myIndex[x] // version 1
a := myIndex[x]    // version 2

I prefer the first version since it explicitly shows that a potential error is considered and ignored. I’d really like a linter for Go that enforces it, as well as preventing shadowing by default.

With Go, you know that the error is ignored and the code flow (regardless of the content of a being initialized or zeroed) is explicit.

Of course, Python has:

a = myindex.get(x, None)
if a is None:
return None

which solves the problem very explicitly (and is better practice when possible), while being very similar to the Go mechanism (as long as your sentinel value is well chosen). The try-except version would probably actually have a higher complexity in something like Sonar, which counts certain keywords:

try:
a = myindex[x]
except KeyError:
return None

We also have to consider that in a similar way, Go has the “panic” and “recover” statements, but I think it’s important to understand that in Go, panics should almost always kill your program. It’s not a standard code flow, and it’s a bit more exceptional, so perhaps it’s not generally as important to consider what may not happen because of it.

Errors detected during execution are called exceptions and are not unconditionally fatal — https://docs.python.org/2/tutorial/errors.html

While the case above is simple, it represents something that you have to consider when debugging in almost every Python call you could make. Without a coding standard, good documentation and confidence in others, it’s difficult to know what you might have to catch coming from any call you make, especially 3rd party libraries.

While exceptions that cause problems in Python should be caught and logged, there are a load of ways to mess this up, including over-broad exception handlers, misunderstanding the actual source of an exception, poorly written context managers that can swallow exceptions and more. However, it can be similarly difficult to anticipate all cases of errors in Go and handle them correctly (especially when dealing with the network).

In what cases would Go code have more error handling, where Python wouldn’t need it?

As we’ve seen, a considered and ignored error is pretty much the same, and a handled exception / error is similar.

The main case where Python is shorter is where an exception may be generated, but is entirely un-handled until higher up the callstack. This is precisely the case where the cyclomatic complexity should be higher, because there’s a very subtle new path through the code.

Another potential case is a multi-line try except block (catching exceptions from multiple statements), but this suffers from a similar problem because there are similarly multiple paths through it determined by where you might hit an exception.

In more functional side-effect-free (or weak) code — a failure is just a failure, and it’s just a question of how well you can and need to identify the cause and handle it correctly. You can see this pattern in the usage of NaN for floating point math, and the Error Writer pattern in Go, although you can also argue that the functions do not exit early, even if individual statements stop having an effect.

In general, I think the use of Exceptions as a standard part of control flow does increase the complexity of the code in a very subtle way, but the practical effect is smaller. It’s admirable to make it obvious what paths of execution exist.