[Closed, with a new one] Checked exception simulation in Rust( 2nd version )


#1

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 from enumx 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:

  1. 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.

  2. 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:

  1. Using of type aliased Result effectively hide the actual error types, confusing programmers(including the author) when reading code or debugging.

  2. Using of a fat enum as the Err for all functions adds unnecessary paths in error-handling, causing potentially inefficiencies.

  3. 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 ),
    },
};
  • Annotations for distinguish between functions returning plain errors and ones returning checked exceptions.

    1. Use a postfix ~ to propagate a plain error to a cex function. For exameple, the statement let mut f = std::fs::File::open( filename )?; will be rewritten as let mut f = std::fs::File::open( filename )~?;

    2. Use a postfix ~~ to propagate checked exceptions to a cex function. For exameple, the statement let a = read_u32( file_a )?; will be rewritten as let a = read_u32( file_a )~~?;

  • Unconditionally throw/rethrow

    1. Use throw!() to do early exit with a plain error in cex functions. Instead of return Err( error ), we will write throw!( err ).

    2. Use rethrow!() to do early exit with checked exceptions in cex functions. Instead of return Err( error ), we will write rethrow!( err ).

    3. Use throw_log!() and rethrow_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 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

  • 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

  1. If other libraries do not use cex, their error types ar considered as plain errors. Use throw/~ and similar constructs to deal with them.

  2. If other libraries use cex, their error types ar considered as checked exceptions. Use rethrow/~~ and similar constructs to deal with them.

  3. 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


[Pre-RFC] The error-handling approach in manner of static dispatch, using enum exchange
#2

This seems very similar to past proposals for enum impl Trait, except for all the verbiage of checked exceptions. To be honest, all the talk of checked exceptions (and related but separate issues like logging and backtraces) seems like such a massive distraction from the actual benefit of making one-off error types more convenient that I’m not even sure I understand your proposal.

In particular, how is the Enum! macro supposed to work? I couldn’t find anything in your post that clarifies which parts of all this are supposed to be pure library code and which are compiler magic. I believe all similar past proposals involved new syntax like enum impl Trait because it seemed clearly impossible to do this purely within the existing macro system. Did we miss something?


#3

Perhaps this looks similar with some previous proposals, but it does have a significant difference: it have been implemented in enumx, without any compiler magic ( although the magic could help if available).

Enum!() will be translated to predefined Enum1, Enum2,…, according to the amount of its arguments. All details can be found in enumx.

Perhaps I need more words explaining the ad-hoc enum implementation, but to focus on the error handling topic and keep this artical not too long, they have been omitted. I will add them later, and thanks for your response!


#4

Looks like the key thing I missed was that there’s also a #[cex] proc macro on the whole function, in addition to Enum!.

I guess now my point of confusion is: Are you announcing that this macro library exists, or are you proposing a change to the language? Or proposing adding this macro library to std?


#5

Usually an introduction about an existing crate should be posted in user forum. But this particular project is highly relevant with some topics that are suitable for internals forum, sevaral of which you have mentioned.

Two purposes:

  1. This project is a proof-of-concept of anonymous enum and checked exception in Rust. The people who are interested in these topic and want to do proposals in the future could reference it for more information.

  2. Currently this project does have some limitations and I want to know if I could do better, with or without compiler magic. And if the magics are helpful, what are they? :grin:

By the way, #[cex] does not help implementing anonymous enums. All it does is interpreting ??/??? as native Rust operators.


#6

This is a large about of text that I will probably comment on more later, but I’ve one note:

If you would like to integrate a similar system into Rust proper you’ll want to find some way to avoid the necessity of ??/???. I’m not exactly sure what the difference is, actually.

T -> Enum!(.., T, ..) can trivially generate a From, though if it’s a language feature rather than a library feature, there’s some resistance since that’d make From special where it still isn’t today. As a language feature it could actually know if types overlap, as a library feature it’d have to assume (and enforce via the From impl disjointness).

I suppose From for “rethrows” with Enum!(.., T, ..) -> Enum!(.., T, ..) is harder. I think frunk::Coproduct can support that, though. frunk-level metaprogramming takes me ages to wrap my head around, though.

If you can make your “checked exception” anonymous error enum work with just ?, it’s got a decent chance of wider usage (and potentially language adoption). This might benefit from a custom Try carrier as well, so you can express semantics. But using just Result is also good to integrate with code not using your checked exception error types.

TL;DR the idea is sound, needs a bit more polish, and probably a better guide-level walkthrough.


#7
  1. So if this idea will be implemented in Rust (natively, without external crates), I have to wrap every call to external crate’s functions because they can throw some exception (if author of that crate was to lazy to write code with regular error handling)? I can’t say I’m happy about this perspective. We have already “panic”, what is the difference?

  2. How you pronounce “cex”?


#8

As stated in the article, expr?? or expr??? are just syntatic sugar for expr.may_throw_log( log!() )? or expr.may_rethrow_log( log!() )?. You can completely avoid it and the #[cex] attribute. This project is ready for use under stable Rust.

Perhaps I need to reorganize the article to make this fact more clear.


#9

The idea for function syntax sugar has been discussed before, e.g. try fn

and


Note that previous proposals considered OK-Wrapping as an important feature.

The ?? syntax can’t be used, because it’s taken already: Ok(Ok(2))??; is valid today.

This seems to be very Result-specific. Currently ? is generic and supports other type like Option. Any “throw” syntax should probably be a lower-level concept that could also be mapped to custom enums, compose with async functions, etc.

Some form of ad-hoc enum may be useful, but it should also be a separate RFC. Rust tries to have orthogonal simple features that are building blocks for other features, not special-case ones (e.g. Rust has enum that allows making Option as a library feature, as opposed to nullable types in Swift which are built-in magic).

And logging is a completely separate thing. While logging is a commonly used functionality, I’m not sure if we’ve reached the point where we’re certain enough of its implementation to cement it in the standard library. It’s probably best to leave it out. It seems like a thing that can be easily added with traits without being built-in into the language.


#10

Yeah, I think there’s four of five different proposals mixed up in there. In addition to the aforementioned enum impl Trait and try fn, some other proposals/features that I think are either buried somewhere in your original post or just tangentially relevant include:

The key thing is that there doesn’t seem to be any need to smush all these features together into a single proposal, so we probably should keep them cleanly separated.


#11

This checked exception simulation provides a way of rewriting the functions already returning Result<T,E> but without all errors listed in E. If a function does not return Result, no change will happen.

And, does cex seem to be pronounced the same as “sex” :sweat_smile: ? I would suggest pronounce it as “checks” instead.


#12

Why is there a requirement for “handwritten boilerplate code”? Structured errors have a purpose, and that’s not something the compiler could/should figure out. And for getting rid of eventual boilerplate code, we can use macros; that’s one of their primary use cases. They work wonders. (There’s even a derive proc-macro in the failure crate that makes it possible to write custom structured errors with what is perhaps literally the minimal input information and typing possible.)

Accordingly, I don’t see why we have to simulate the whole “traditional” exception handling mechanism for making matters more convenient. There are several other feasible approaches, including:

  • ad-hoc sum types
  • adding generic error types (possibly in std, but before that, in a crate) that could be used as a fallback if one doesn’t want to write a specific custom error type (should be consumed responsibly; probably only outside libraries)

Introducing two (!) new operators would feel an overkill even if they actually added significant value; but, as others have already pointed out, they are not really necessary given that ? cooperates nicely with From. (Even if they technically don’t affect the core language, in case they become idiomatic, they would still need to be learnt, and if they don’t, they would lead to fragmentation. Neither option sounds desirable.)


#13

I am noticing some progress both in API and document, updated in the main thread, as the 2nd version.

Sorry for late responses:

In fact it works well with ?, just needs some annotation like ~/~~, due to lacking of language support( the chicken or the egg problem? )

It is a natural refining method of existing "wrapping errors" style in error-handling. Nothing to do with panic.

I believe that Ok-Wrapping could be implemented in 200~300 lines of code, but wonder if it is really an important feature.

Thanks for your sugguestion, now CeX uses ~/~~ instead of ??/???.:sunglasses:

I am not aware of the issues you mentioned about Option or async, would you plz explain them in details? Currently I don’t think CeX has limitations in these domains.

In fact, CeX looks like checked exception, but is in spirit of Rust’s error-handling. The proposal is not questioning on Result/?, but on the fat enum Error and the type alias pub type Result<T> = std::result::Result<T, Error>.


#14

Forgot to mention: the features described in the 2nd version have been implemented as a library, CeX


#15

Thanks for your response. I still think “exception handling” is itself not in the spirit of Rust at all. Error handling with Result is nice because there is nothing “exceptional” to it from the point of view of the language. It’s just a type implementing a trait for an operator, and its usage has got solid conventions around it. Consequently, it doesn’t need special handling, and that’s exactly where its beauty comes from.

For example, no “generics” (for the lack of a better word) around “throws/rethrows” are necessary when accepting functions as arguments in higher-order functions. That is a massive pain point in languages with checked exceptions (Swift comes to mind), and it either discourages people from writing higher-order functions at all (which is a sad regression from modern, functional programming practice), or the less experienced, forgetting to add “rethrows” annotations, write higher-order functions that are impossible to use with arguments throwing exceptions.

I don’t get what’s wrong with a type alias for Result; personally, I find it perfectly convenient to write and just as easy to read.

The readme of CeX reads:

The error types are not accurate

Again, since ? calls From::from(), if you don’t like an enum of all possible errors, this is almost trivially resolved by always returning the exactly accurate error type and implementing From (or just propagating the same type up the call chain). Even so, personally, I do like an enum of all possible errors. For one, it means that functions continue to be allowed to return any error, or, more precisely, to extend the set of errors they return, without a change in the signature. This saves users precious backward compatibility budget (and some complexity for the author of the code, although that’s not nearly as important).

Furthermore, errors from the same unit of code (say, from the same crate) tend to be somehow related, after all, and consumers can think of them as a group. In fact, I’ve seen and written lots of code that goes like this:

  1. Call high-level API of crate Foo
  2. High-level function in Foo’s API calls into lower-level functions, accumulates any errors through a single error type
  3. The caller switches on the result’s error type once, to find out what kind of error, if any, happened.

Although read_u32() does not do calculating at all, we need to deal with the Error::Calc branch and write unreachable!() code.

No, we don’t need to write an unreachable!(). In fact, I would consider that poor style. If there is an error that you don’t want to handle yourself, you should propagate it rather than optimistically asserting that it can’t happen. This also helps the “extend the set of errors to return after the fact” approach work nicely.


However, I still do appreciate the effort you put into the implementation of CeX. It does seem well-fleshed out based on your ideas. I think those who prefer exception-style error handling should definitely check it out. I just don’t believe it should be the standard, idiomatic way of error handling in Rust.


#16

In fact Error::Calc is not an error that I don’t want to handle, but an error that is impossible for fn read_u32( /**/ ) -> Result<u32> { /**/ } to return. Unfortunatelly this fact is unknown to the compiler due to the type alias Result<u32>. A propagated impossible error is still an impossible error. Even worse, if read_u32() was a public API, now client code dealing with its Err must dealt with Error::Calc, which is unnecessary, or propagates it, which does not solve the problem.

You may argue that we should change read_u32()'s signature, not to use Result<u32> to address this, as you had suggested:

"if you don’t like an enum of all possible errors, this is almost trivially resolved by always returning the exactly accurate error type

This is an either-or game. You can not get both advantages of them.

  • Pick up Result<T> and you get briefness, but losing accurateness, which confuses the compiler, and the programmer on debugging.

  • Define the exact accurate error type and its impls or propagating.

CeX is a balance between the two. It is more accurate/verbose than Result<T> but as accurate as/more brief than deliberate definition of the accurate error type.


In a certain crate, some errors are related, but some are not. Those related errors are usually concerned with the domain and may be grouped together. CeX does not stop users from doing so. It just provides a mechanism to gather unrelated errors.

fn foo<T>(/**/) -> T throws DomainErr(crate::Error),  IO(std::io::Error)
{/**/}

Thanks for all your positive and negative responses :grinning:.

The main pain point that CeX aims to address is throws in function signatures. Personally I regard it as a great tool on debugging. And I have noticed that a blog, Rust in 2022 posted by @nrc mentioned that:

I hope that you all did not get the impression that “@oooutlk wants to push CeX into Rust language cuz he thought he wrote a shining library” :sweat_smile:. I just got feelings that throws is valuable but largely ignored by Rust community, and it may be proved to be the standard, idiomatic way of error handling in the future.

At least it is worth a section in Rust by example:


#17

Sorry I do not get your point well. Would you please explain it in details, by demonstration code? Suppose we use CeX and want to do something related to higher-order functions.


#18

I do realize that in that case it’s impossible for that particular function to return that type of error. I argue, however, that this problem should be resolved by propagating that impossible error case anyway, instead of panicking on it, for reasons of robustness. It was more of a software engineering comment of mine, rather than a theoretical one.

I’m sorry but I disagree here. You can get most of the exactness with the From impls, which are, again, easily generated by a macro. I’m following this approach in some projects of mine, and error handling is the least painful aspect usually. It’s also an oft-needed pattern when I want to deal with (capture, wrap, and propagate) errors from external crates, so I would have to do it anyway.

Not at all – your motivation and your work is genuine. It’s just that I fundamentally disagree with the idea that this is the way to go, and I think the status quo of error handling in Rust is just fine (certainly a local, maybe a global optimum over the domain of all the languages I’ve encountered so far).

That could be the case; however, I largely see that Rust’s Result-based error handling turned out to be much more convenient, elegant, simpler, and just as useful in practice as exception handling in older languages. I think going back to exception handling would be considered a regression by many Rustaceans (in fact, one of the reasons I started using it was the exception-less error handling). I don’t, by any means, want to make it impossible to use a feature like this for those who do like it, though. It’s just that I wouldn’t like the language to change “under my feet”, so to speak, and downright reverse one of its current core idioms.


#19

Sure thing! The specific example in Swift that I was referring to looks like this (generics stripped and substituted by concrete types for simplicity):

func map(array: [Int], f: (Int) throws -> Int) rethrows -> [Int] { 
    var result: [Int] = [] 
    for item in array { 
        result.append(try f(item)) 
    } 
    return result 
} 

Here, the f functional parameter of the map higher-order function needs to have a throws annotation and map needs to be declared as rethrows in order to indicate that it will throw if and only if f throws.

This is a new generic dimension (basically a one-bit decision) in the type system, and it also makes it necessary to introduce a new special-case relation between types (namely, the throwing nature of the higher-order function and that of its functional argument(s)).

And now, in order to write good-quality generic code, this annotation should be added to basically any higher-order function, otherwise it will be impossible to call them with functions, that themselves throw, as arguments. At this point, this becomes noise and boilerplate that needs to be carried around and remembered without real benefits.


#20

This seems to be a non-issue for CeX.

#[derive( Debug, PartialEq, Eq )]
pub struct IsOdd(i32);

cex! {
    fn half( i: i32 ) -> i32
        throws #[derive( PartialEq,Eq )]
               Arg(IsOdd)
    {
        if i%2 == 0 { Ok(i/2) } else { throw!( IsOdd(i) ); }
    }
}

let data = vec![ 0, 2, 3 ];
assert_eq!(
    data.into_iter()
        .map( half )
        .try_fold( 0, |acc,i| i.map( |i| acc+i ))
        .map_err( |cex| cex.error ),
    Err( half::Err::Arg( IsOdd( 3 )))
);

Note that half() can work with map() well, with the help of try_fold() to do early exit. The signature of map() does not need change.

(A bit off topic: you could ignore the throws syntax changes, I just update some code in my local repo to support attributes in throws)