Concept Subspace :: sus :: error :: Error
template <class T>concept Error
requires(const T& t) {
// Required methods.
{
// auto display(const T&) -> std::string
ErrorImpl<std::remove_const_t<T>>::display(t)
} -> std::same_as<std::string>;
// Optional methods.
requires requires {
// auto source(const T&) -> Option<const DynError&>
{
ErrorImpl<std::remove_const_t<T>>::source(t)
} -> std::same_as<sus::Option<const DynError&>>;
} || !requires {
{ ErrorImpl<std::remove_const_t<T>>::source(t) };
};
}
Error
is a concept representing the basic
expectations for error values, i.e., values of type E
in
Result<T, E>
.
Errors must describe themselves through a display
method. Error messages
are typically concise lowercase sentences without trailing punctuation:
auto err = u32::try_from(-1).unwrap_err();
sus_check(fmt::to_string(err) == "out of bounds");
Implementing Error
To make an Error
type, specialize ErrorImpl<T>
for the error type T
and implement the required method:
static auto display(const T&) -> std::string
: An error message describing the error. fmtlib support is provided for all error types through a blanket implementation that usesdisplay
. Error messages are typically concise lowercase sentences without trailing punctuation:auto err = u32::try_from(-1).unwrap_err(); sus_check(fmt::to_string(err) == "out of bounds");
The following method may optionally also be provided:
static auto source(const T&) -> sus::Option<sus::error::DynError>
: Optional information about the cause of the error. A simple implementation would just returnsus::none()
.source
is generally used when errors cross "abstraction boundaries". If one module must report an error that is caused by an error from a lower-level module, it can allow accessing that error viasource
. This makes it possible for the high-level module to provide its own errors while also revealing some of the implementation for debugging.The
Error
object returned bysource
must be type-erased as aDynError
. See Type erasure for more.
Using Error
To use an Error
type, use:
error_display
to get the string description of the error.error_source
to get the next level deeper error for debugging.
All Error
types are implicitly formattable with
fmtlib, such as with fmt::format("ERROR: {}", error)
. Because we can't
provide a blanket implementation of operator<<
they are not implicitly
streamable, and fmt::to_string(error)
can be used to stream error
.
Avoid instantiating ErrorImpl<T>
yourself to call the static methods.
Type erasure
Working with Error
types directly requires templates
that knows the precise type. At times this is convenient but holding
different kinds of errors in a Result
requires a
single type, as does passing error types through virtual methods or dylib
ABI boundaries.
See DynConcept
for more on type erasure of
Error
types.
Opaque erasure
When an application just wants to return an error without exposing the
actual type behind it, use the DynError
type.
This can be useful for storing errors returned from other layers of an
application or external libraries to be given as the error source. Or when
you don't want or need the receivers of the error to introspect inside
them.
To do this, return Result<T, Box<DynError>>
. The Box
satisfies Into
<
Error
,
Box
<
DynError
>>
which
means the result can be constructed by sus::err(sus::into(error))
for any
error
that satisfies Error
.
This is similar to
Box<dyn Error>
when working with the Rust
Error
trait. However with DynError
, the error type can be printed/displayed but
no further information can be extracted from the error. Nonetheless this is
commonly sufficient, providing even more information than the prolific
bool
return value of legacy C/C++ code.
To store an error in order to report it as the source of another error, the
first error must be type-erased as a DynError
, usually in Box<DynError>
,
to be returned from the source
function in the
Error
implementation.
Note that both DynError
and
Box<DynError>
satisfy the
Error
concept.
Recovering the full error type
If an application wants to be able to recover the specific type of error, and structured data from within it, there are two choices:
Make all errors a subclass of a single class which we'll call
AppError
. It should satisfyError
and it can do so through virtual methods if needed. Then, returnResult<T, Box<AppError>>
to have the AppError subclass placed on the heap and type-erased to the base class, andResult
will display the error's description if it panics.This restricts errors to being class (or struct) types.
To get at the specific error type, use runtime-type-information (RTTI) to downcast, or provide a (TODO:
Downcast
) implementation fromAppError
to its subclasses.Place all application error types into a single sum type such as
std::variant
orChoice
. Then implementError
for your fully resolved sum type.This allows each error inside the sum type to be any type at all, and avoids type erasure, using type composition instead.
Examples
An enum error type:
enum class ErrorReason {
SomeReason
};
template <>
struct sus::error::ErrorImpl<ErrorReason> {
constexpr static std::string display(const ErrorReason& self) noexcept {
switch (self) {
case ErrorReason::SomeReason: return "we saw SomeReason happen";
}
sus_unreachable();
}
};
static_assert(sus::error::error_display(ErrorReason::SomeReason) ==
"we saw SomeReason happen");
An struct error type, which is backed by a string:
struct ErrorString final {
std::string reason;
};
template <>
struct sus::error::ErrorImpl<ErrorString> {
constexpr static std::string display(const ErrorString& self) noexcept {
return sus::clone(self.reason);
}
};
static_assert(sus::error::error_display(ErrorString("oops")) == "oops");
An example function that returns a
Result<void, Box<DynError>>
, allowing
it to return any error type:
auto f = [](i32 i) -> sus::result::Result<void, sus::Box<sus::error::DynError>> {
if (i > 10) return sus::err(sus::into(ErrorReason::SomeReason));
if (i < -10) return sus::err(sus::into(ErrorString("too low")));
return sus::ok();
};
sus_check(fmt::format("{}", f(20)) == "Err(we saw SomeReason happen)");
sus_check(fmt::format("{}", f(-20)) == "Err(too low)");
sus_check(fmt::format("{}", f(0)) == "Ok(<void>)");
An example error that reports a source
:
struct SuperErrorSideKick {};
struct SuperError {
sus::Box<sus::error::DynError> source;
};
template <>
struct sus::error::ErrorImpl<SuperError> {
constexpr static std::string display(const SuperError&) noexcept {
return "SuperError is here!";
}
constexpr static sus::Option<const DynError&> source(
const SuperError& self) noexcept {
return sus::some(*self.source);
}
};
static_assert(sus::error::Error<SuperError>);
template <>
struct sus::error::ErrorImpl<SuperErrorSideKick> {
constexpr static std::string display(const SuperErrorSideKick&) noexcept
{
return "SuperErrorSideKick is here!";
}
};
static_assert(sus::error::Error<SuperErrorSideKick>);
auto get_super_error = []() -> sus::result::Result<void, SuperError> {
return sus::err(SuperError{.source = sus::into(SuperErrorSideKick())});
};
if (auto r = get_super_error(); r.is_err()) {
auto& e = r.as_err();
sus_check(fmt::format("Error: {}", e) == "Error: SuperError is here!");
sus_check(
fmt::format("Caused by: {}", sus::error::error_source(e).unwrap())
== "Caused by: SuperErrorSideKick is here!");
}
An example of a custom error type hierarchy, which can allow for recovering the full error type by downcasting:
struct AnError {
virtual ~AnError() = default;
virtual std::string describe() const = 0;
};
struct Specific : public AnError {
std::string describe() const override {
return "specific problem has occurred";
}
};
template <> // Implement `sus::error::Error` for AnError.
struct sus::error::ErrorImpl<AnError> {
static std::string display(const AnError& e) { return e.describe(); }
};
sus::Result<void, sus::Box<AnError>> always_error() {
// Deduces to Result<Box<AnError>>
return sus::err(sus::into(Specific()));
};
int main() {
always_error().unwrap();
// Prints:
// PANIC! at 'specific problem has occurred',
// path/to/sus/result/result.h:790:11
}