This discussion has been closed, with a new one:
Pre-RFC: the error handling approach in manner of static dispatch using enum exchange.
This thread may contain a mixed of different proposals, and will be splitted.
Changelog:
-
Adding a FAQ
-
Refactoring to get more clean APIs and better document to the 2nd version.
-
Split “structural enum types” proposal into its own thread: structural enum types.
The following is the 2nd version.
The features described in the 2nd version have been implemented as a library, CeX
Summary
Introduce checked exception simulation in Rust, for refining the style of
putting all error types in a whole enum then using type alias Result<T>
in
error-handling.
The new constructs are:
-
Cex
built upon structural enum fromenumx
crate, and macros/traits for:-
automatic error type convertion
-
throw point tracing and logging
-
-
An optional
cex!{}
macro for syntax support:-
throws
in function signatures -
shorthand notations
~
/~~
for.may_throw()
/.may_rethrow()
.
-
Motivation
A typical style of error-handling is so called “wrapping errors”.
The procedure is as follows:
-
Collect all possible error types in a crate and putting them in an enum, perhaps named
Error
defined inerror.rs
, as “the crate’s error type”. Usepub type Result<T> = Result<T,Error>
to simplify the function signatures. -
Implement
From
s for the crate’s error type, to do “up-casting” from actual error types; Maybe implementstd::error::Error
for those actual error types.
This method has some issues:
-
Using of type aliased
Result
effectively hide the actual error types, confusing programmers(including the author) when reading code or debugging. -
Using of a fat enum as the
Err
for all functions adds unnecessary paths in error-handling, causing potentially inefficiencies. -
Implementing
From
/Error
trait brings boilerplate code.
Features
The CeX project addresses all these issues listed above with features:
-
Enumerating all the possible error types in function signatures.
-
The users do not need to impl
From
s. If needed, a#[derive(Exchange)]
is enough. -
No mandatory traits for actual error types. They could be or be not an
std::error::Error
, etc. If the user do want mandatory traits, trait bounds are up to the job. -
Integrated well with
Result
adaptors and?
, though some sort of extra annotations may be requried, aka throw/rethrow,~
/~~
. -
throws
syntax in principles of ergonomics, with fallbacks to vanilla Rust, to get better IDE support. -
Working with stable Rust.
Overview
Example: wrapping errors
We will see a program written in the “wrapping errors” style. It reads 3 u32
values a,b,c from 3 files respectively, then checks if they satisfied the
equation a * b == c
.
use std::{ error, fmt, num, io };
use std::io::Read;
type Result<T> = std::result::Result<T, Error>;
#[derive( Debug )]
enum Error {
IO( io::Error ),
Parse( num::ParseIntError ),
Calc( u32, u32 ),
}
impl fmt::Display for Error {
fn fmt( &self, f: &mut fmt::Formatter ) -> fmt::Result {
match *self {
Error::IO( ref e ) => e.fmt( f ),
Error::Parse( ref e ) => e.fmt( f ),
Error::Calc( a, b ) => {
write!( f, "u32 overflow: {} * {}", a, b )
},
}
}
}
impl error::Error for Error {
fn description( &self ) -> &str {
match *self {
Error::IO( ref e ) => e.description(),
Error::Parse( ref e ) => e.description(),
Error::Calc( _, _ ) => "multiplication overflow",
}
}
fn cause(&self) -> Option<&error::Error> {
match *self {
Error::IO( ref e ) => Some( e ),
Error::Parse( ref e ) => Some( e ),
_ => None,
}
}
}
impl From<io::Error> for Error {
fn from( io_error: io::Error ) -> Error {
Error::IO( io_error )
}
}
impl From<num::ParseIntError> for Error {
fn from( err: num::ParseIntError ) -> Error {
Error::Parse( err )
}
}
impl From<(u32,u32)> for Error {
fn from( (a,b): (u32,u32) ) -> Error {
Error::Calc( a,b )
}
}
fn read_u32( filename: &'static str ) -> Result<u32> {
let mut f = std::fs::File::open( filename )?;
let mut s = String::new();
f.read_to_string( &mut s )?;
let number = s.trim().parse::<u32>()?;
Ok( number )
}
fn a_mul_b_eq_c(
file_a: &'static str,
file_b: &'static str,
file_c: &'static str )
-> Result<bool>
{
let a = read_u32( file_a )?;
let b = match read_u32( file_b ) {
Ok( value ) => value,
Err( error ) => {
if a == 0 {
0 // 0 * b == 0, no matter what b is.
} else {
return Err( error );
}
},
};
let c = match read_u32( file_c ) {
Ok( value ) => value,
Err( error ) => match error {
Error::IO( _ ) => 0, // default to 0 if file is missing.
Error::Parse( _ ) => return Err( error ),
Error::Calc( _,_ ) => {
unreachable!(); // read_u32 does not do calculating at all!
},
},
};
a.checked_mul( b )
.ok_or( Error::Calc(a,b) )
.map( |result| result == c )
}
Things worth noticing:
-
The possible error types are hidden by
Result
type alias.fn read_u32( /**/ ) -> Result<u32> { /**/ } fn a_mul_b_eq_c( /**/ ) -> Result<bool> { /**/ }
Programmers are not able to know the actual errors of a certain function by glancing over its signatures, unless they check the call chain recursively. In a real-word project, errors may be propagated through quite deep call stack, and manually checking the errors is infeasible for humans.
-
The error types are not accurate
Although
read_u32()
does not do calculating at all, we need to deal with theError::Calc
branch and writeunreachable!()
code.Err( error ) => match error { Error::IO( _ ) => 0, // default to 0 if file is missing. Error::Parse( _ ) => return Err( error ), Error::Calc( _,_ ) => { unreachable!(); // read_u32 does not do calculating at all! }, },
Even worse, any public API returning
Result<T>
will force the downstream users writing such code. -
Boilerplate code for trait
impl
s.
Example: checked exception
We will rewrite this program in “checked exception” style. The throws
syntax
and other syntatic sugar are utilized for demonstration purpose. However, the
users are free to pick vanilla Rust equivalents as a fallback.
- Introducing
throws
in function signatures:
fn read_u32( filename: &'static str ) -> u32
throws IO( std::io::Error )
, Parse( std::num::ParseIntError )
{ /**/ }
#[derive( Debug, PartialEq, Eq )]
pub struct MulOverflow( pub u32, pub u32 );
fn a_mul_b_eq_c(
file_a: &'static str,
file_b: &'static str,
file_c: &'static str )
-> bool
throws IO( std::io::Error )
, Parse( std::num::ParseIntError )
, Calc( MulOverflow )
{ /**/ }
We consider read_u32()
and a_mul_b_eq_c()
as functions returning checked
exceptions, aka “cex functions”, because they use throws
in signatures. Those
who don’t, such as std::fs::File::open()
, are returning plain errors.
- Pattern matching on a cex function’s result
As a cex function, read_u32()
returns a Result
of which Err
is
read_u32::Err
.
let c = match read_u32( file_c ) {
Ok( value ) => value,
Err( cex ) => match cex.error {
read_u32::Err::IO( _ ) => 0, // default to 0 if file is missing.
read_u32::Err::Parse( err ) => throw!( err ),
},
};
-
Annotations for distinguish between functions returning plain errors and ones returning checked exceptions.
-
Use a postfix
~
to propagate a plain error to a cex function. For exameple, the statementlet mut f = std::fs::File::open( filename )?;
will be rewritten aslet mut f = std::fs::File::open( filename )~?;
-
Use a postfix
~~
to propagate checked exceptions to a cex function. For exameple, the statementlet a = read_u32( file_a )?;
will be rewritten aslet a = read_u32( file_a )~~?;
-
-
Unconditionally throw/rethrow
-
Use
throw!()
to do early exit with a plain error in cex functions. Instead ofreturn Err( error )
, we will writethrow!( err )
. -
Use
rethrow!()
to do early exit with checked exceptions in cex functions. Instead ofreturn Err( error )
, we will writerethrow!( err )
. -
Use
throw_log!()
andrethrow_log!()
when you need to track the throw point or attach extra text in the log.
-
All the magic syntax support for throws
and ~
/~~
are provided by cex!{}
from cex_derive
crate.
Put it all together:
use cex_derive::cex;
cex! {
fn read_u32( filename: &'static str ) -> u32
throws IO( std::io::Error )
, Parse( std::num::ParseIntError )
{
use std::io::Read;
let mut f = std::fs::File::open( filename )~?;
let mut s = String::new();
f.read_to_string( &mut s )~?;
let number = s.trim().parse::<u32>()
.may_throw_log( log!( "fail in parsing {} to u32", s.trim() ))?;
Ok( number )
}
}
#[derive( Debug, PartialEq, Eq )]
pub struct MulOverflow( pub u32, pub u32 );
cex!{
fn a_mul_b_eq_c(
file_a: &'static str,
file_b: &'static str,
file_c: &'static str )
-> bool
throws IO( std::io::Error )
, Parse( std::num::ParseIntError )
, Calc( MulOverflow )
{
let a = read_u32( file_a )~~?;
let b = match read_u32( file_b ) {
Ok( value ) => value,
Err( cex ) => {
if a == 0 {
0 // 0 * b == 0, no matter what b is.
} else {
rethrow_log!( cex );
}
},
};
let c = match read_u32( file_c ) {
Ok( value ) => value,
Err( cex ) => match cex.error {
read_u32::Err::IO( _ ) => 0, // default to 0 if file is missing.
read_u32::Err::Parse( err ) => throw!( err ),
},
};
a.checked_mul( b )
.ok_or( MulOverflow(a,b) )
.may_throw_log( log!( "u32 overflow: {} * {}", a, b ))
.map( |result| result == c )
}
}
Desugaring ~
/~~
-
A
~
not followed by another~
, is short for.may_throw()
. -
A
~~
is short for.may_rethrow()
. -
may_throw_log()
/may_rethrow_log()
are similar functions in addition to support logging.
Desugaring throws
A function using throws
cex! {
fn foo( /**/ ) -> Type
throws SomeErr( SomeErrType )
, AnotherErr( AnotherErrType )
/*...*/
{ /**/ }
}
is desugared as
mod foo {
use super::*;
use enumx::prelude::*;
#[derive( enumx_derive::Exchange, Debug )]
pub enum Err {
SomeErr( SomeErrType ),
AnotherErr( AnotherErrType ),
/*...*/
}
}
fn foo( /**/ ) -> Result<Type, Cex<foo::Err>> { /**/ }
Things worth noticing:
-
Debug
trait is mandatory for actural error types when usingcex!{}
. -
throws
syntax is similar with enum variant definitions, with one limitation that all variant type should be “newtype form”.
Issues with throws
syntax
-
Assumes that a
mod
with the same name not defined. -
Potentially poor IDE support.
-
Implicit
Result
type.
Alternatives will be discussed in the following sections, to address these issues in situations that they really matter.
Named checked exception
While cex!{}
generates an enum definition for users, they have the chance to
define it themselves. For example, if the mod
with the same name as the cex
function has already be defined, the user should avoid using cex!{}
.
#[ derive( Exchange, Debug )]
enum ReadU32Error {
IO( std::io::Error ),
Parse( std::num::ParseIntError ),
}
fn read_u32( filename: &'static str )
-> Result<u32, Cex<ReadU32Error>>
{ /**/ }
let c = match read_u32( file_c ) {
Ok( value ) => value,
Err( cex ) => match cex.error {
ReadU32Error::IO( _ ) => 0, // default to 0 if file is missing.
ReadU32Error::Parse( err ) => throw!( err ),
},
};
However, the error types are not listed in signatures now. But the user can still be able to check the corresponding enum definition to get them.
The complete code is in [“named” test case] (https://github.com/oooutlk/enumx/blob/master/cex/src/test/named.rs).
Unnamed checked exception
If the users are not willing to write an enum definition, they can use predefined enums as an alternative.
fn read_u32( filename: &'static str )
-> Result<u32, Throws!( std::io::Error, std::num::ParseIntError )>
{ /**/ }
However, the pattern matching is less ergonomics because the users have to count the errors themselves. And pattern matching is subject to the order of error definition.
let c = match read_u32( file_c ) {
Ok( value ) => value,
Err( cex ) => match cex.error {
Enum2::_0( _ ) => 0, // default to 0 if file is missing.
Enum2::_1( err ) => throw!( err ),
},
};
The complete code is in [“adhoc” test case] (https://github.com/oooutlk/enumx/blob/master/cex/src/test/adhoc.rs).
For users not willing to use any macro, read_u32()
could be rewritten as:
fn read_u32( filename: &'static str )
-> Result<u32, Cex<Enum2< std::io::Error, std::num::ParseIntError >>>
{ /**/ }
Ok-wrapping
To address the issue of implicitly returning Result
, some kind of ok-wrapping
mechanism could be implemented in library. However, I wonder if it is really
helpful for end users and worth writing hundreds lines of code to implement.
Guildlines for interacting with other libraries
-
If other libraries do not use cex, their error types ar considered as plain errors. Use
throw
/~
and similar constructs to deal with them. -
If other libraries use cex, their error types ar considered as checked exceptions. Use
rethrow
/~~
and similar constructs to deal with them. -
Use checked exceptions as constriants in public API. Changes in checked exceptions returned by your APIs must break the downstream’s code to notice the changes. If backward compatibility should be guaranteed, use
#[non_exhausted]
with your enums to enforce an_
match arm in client code.
Future possibilities
A conservative syntax may be introduced as an alternative of throws
.
cex! {
fn foo( /**/ ) -> Result<Type, Throws< Bar(BarType), Baz(BazType)>
{ /**/ }
}