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:
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 from enumx crate, and macros/traits for:
-
An optional cex!{} macro for syntax support:
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 in error.rs, as “the crate’s error type”. Use
pub type Result<T> = Result<T,Error> to simplify the function signatures.
-
Implement Froms for the crate’s error type, to do “up-casting” from actual
error types; Maybe implement std::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 Froms. 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
the Error::Calc branch and write unreachable!() 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 impls.
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 ),
},
};
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 using cex!{}.
-
throws syntax is similar with enum variant definitions, with one limitation
that all variant type should be “newtype form”.
Issues with throws syntax
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)>
{ /**/ }
}
The original post has been archieved here