Skip to end of metadata
Go to start of metadata

Contents

Update

2017-11-08: the section on double assignment was added.

Introduction

Errors are typically indicated by a non-zero integer value. Within a program/library, which is the main focus here, error return values are negative. For a program, error exit values are positive. In both cases, a zero value indicates success.

Integers do not carry much information. And what information they do carry often needs to be looked up in a table (e.g., what is errno 11?). On the other hand, a (good) string is explanatory and can be tailored to specific situations. This is why error messages are often output to stderr, even when a non-zero exit code is returned. But error strings are not just useful for end users. Even with libraries, error strings can be useful for logging. But this is more than a discussion of error strings or not.

One of the alternatives to integer-based errors in Python is to use exceptions. This can be useful, but still, it is an exception rather than just an "error".  The exception vs error debate is not addressed here, but see LBYL and EAFP for more on Python's philosophy re: errors and exceptions.

The point of this article is to present an Error class in the spirit of Go error handling and consider its use/application in Python from a personal perspective.

Error Class

I have used the following in a number of projects:

errors.py
class Error:
    def __init__(self, msg):
        self.msg = msg

    def __str__(self):
        return str(self.msg)

    def __repr__(self):
        return """<Error msg="%s">""" % str(self.msg)

Error allows me to:

  • indicate an error situation
  • provide an arbitrary, but useful, text description of the error
  • pass it around as an error object rather than an undifferentiated integer

Use Cases

The first case is when the callable acts like a subroutine (no value is returned explicitly; in which case Python returns None):

err = myfunc()
if err:
    ...

This is easy to use because a None return value will always be treated as success, as anything else will be an Error.

The second is when the callable acts like a function (a value is returned):

val = myfunc()
if isinstance(val, Error):
	...

This situation requires a bit more effort to use, but Python makes it easy because val can be any value, including Error.

A third use case is really just a different flavor of the above:

val, err = myfunc()
if err:
    ...

This one is very familiar to Go programmers and avoids the overloading of the single return value case. The main difference is that Go provides a more efficient way to write this:

if val, err = myfunc(); err !=  nil {
	...
}

Combining all of the above in the form of "double assignment":

val = err = myfunc()
if isinstance(err, Error):
    ...
# use the result bound to "val"

There are some clear benefits of this approach:

  1. the code is succinct,
  2. that the function/method returns a non-error or an error result is documented by the double assignment,
  3. the result (error or not) can be accessed using a suitably named variable, as the code warrants.

The only potential drawback is the cost of the double assignment. But given the use cases of Python, this likely has minimal overall performance impact while the readability and understandability of the code increases.

Extending Error

Of course, the Error class can be extended to provide a collection of classes to more precisely indicate the type of error. E.g.,

class ErrorOverflow(Error):
    pass

class ErrorUnderflow(Error):
    pass

These can then be tested for using isinstance() to match Error or the specific subclasses.

err = myfunc()
if isinstance(err, ErrorOverflow):
	...
elif isinstance(err, ErrorUnderflow):
    ...
elif ininstance(err, Error):
    # catch all
    ...

It is also possible for a subclass of Error to be augmented with other information such as an exit code (!), stack information, other context-specific information. E.g.,

class ErrorCode(Error):
    def __init__(self, msg, code):
        Error.__init__(self, msg)
        self.code = code

    def __str__(self):
        return "%s (%s)" % (Error.__str__(self), self.code)

    def __repr__(self):
        return """<ErrorCode code=%s %r>""" % (self.code, Error.__repr__(self))

But the core ideas are that errors 1) are more than just integers, and 2) have a useful string representation.

Some Pros and Cons

One of the main drawbacks is that Error is unique to my package. It would be nice if there was a canonical Error (as there is an Exception). However, it may not be as much of a problem as it first appears. If errors from one layer need to be passed to another, a conversion between different, unrelated Error implementations can be done trivially:

err = myfunc()
if err:
    err = other.Error(str(err))
    ...

After all, the programmer should be aware of interfaces/interactions between layers and take care of checking and returning appropriate values. And, as presented, Error always provides a simple text string.

A perceived drawback is that this approach encourages a lot of error testing, a practice which Go programmers typically defend as being necessary for code to be good: errors should be checked.

The primary benefit of Error over a simple error code is that useful, contextual information can percolate up from anywhere in the code and, if appropriate, be used to inform the user. Too often, a helpful error code is converted to a generic error code and returned, thus losing information. Code-wise, the Error type is useful; people-wise, the text is useful.

Conclusion

Even in Python, exceptions are not always the best way to do things. Sometimes returning an error code or similar is appropriate. In such situations, wherever one lands on the question of using something like Error instead of integer return values for errors, the approach is worth considering. Error is not always appropriate, but it does make one think about the merits of how and why things are done as they are.