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.
I have used the following in a number of projects (see repo for full implementation):
errors.py
:
#
# errors.py
#
import sys
class Error:
"""Base error object.
"""
def __init__(self, msg, ecode=None):
self.msg = msg
self.ecode = ecode
def __str__(self):
if self.ecode == None:
s = str(self.msg)
else:
s = "%s (ecode=%s)" % (self.msg, self.ecode)
return s
def __repr__(self):
return """<Error msg="%s" ecode="%s">""" % (self.msg, self.ecode)
def get_ecode(self, default=None):
if self.ecode == None:
return default
else:
return self.ecode
def __perror(erro):
sys.stderr.write("%s\n" % str(erro))
def is_error(erro):
return isinstance(erro, Error)
def perror(erro):
"""On Error, write string to stderr.
"""
if is_error(erro):
__perror(erro)
def perror_exit(erro, exitcode=1):
"""Call perror(). Exit on non-zero exitcode.
"""
if is_error(erro):
__perror(erro)
if exitcode != 0:
sys.exit(exitcode)
Notes:
Error
indicates an error situation.Error
provides an arbitrary, but useful, text description of the error.Error
provide an optional error code (should be an integer).Error
can be passed around as an error object rather than an undifferentiated integer.is_error()
is succinct and clear label for test.perror_exit()
supports commonly used functionality.The most basic case is when a callable acts like a subroutine (no value is returned explicitly; in which case Python returns None
):
erro = myfunc()
if erro:
...
This is easy to use because a None
return value will always be treated as success, as anything else will be an Error
.
When a callable acts like a function, a value is always returned. In such a case, an Error
might be one possible values.
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
.
This can also be written using is_error()
:
val = myfunc()
if is_error(val):
...
is_error()
cannot be a method of Error
since myfunc()
can return a non-Error
value.
Python can return multiple values from a callable:
val, erro = myfunc()
if erro:
...
This form 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 {
...
}
It is also possible to use a "double assignment":
val = erro = myfunc()
if is_error(erro):
...
# use the result bound to "val"
There are some clear benefits of this approach:
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.
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.
erro = myfunc()
if isinstance(erro, ErrorOverflow):
...
elif isinstance(erro, ErrorUnderflow):
...
elif isinstance(erro, Error):
# catch all
...
In the original article, Error
was subclassed to create ErrorCode
which took an exit code. This functionality has been incorporated into the base Error
class as an error code.
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:
erro = myfunc()
if erro:
erro = other.Error(str(erro))
...
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.
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.
Error
class has been enhanced to support an error code.is_error()
and perror_exit()
; other updates.err
to erro
to avoid possible name conflict.