`if !cond { return ret; }` vs `ensure!(cond, ret)`

fn try_something(cond: bool) -> Result<(), &'static str> {
    if !cond { return Err("failed"); }
    // .....
    Ok(())
}

VS

fn try_something(cond: bool) -> Result<(), &'static str> {
    ensure!(cond, "failed");
    // .....
    Ok(())
}

What do you think? What is cleaner and easier to read?

Neither. Returning &str-typed errors is an antipattern. Such return types also don't compose with Rust's standard error-handling mechanisms, such as std::error::Error trait and the ? try operator. It also doesn't make any sense to repeat Err(..) in the return type of ensure!, it should do Err-wrapping (and possibly error type conversion) on its own. If you want a well-implemented ensure! macro, look at the anyhow crate.

Also, it's not a question about the design of Rust language itself, so it should be asked at users.rust-lang.org

2 Likes

Please try to respond to the best possible version of someone's question. String errors aren't the primary question at hand here; it seems like the primary question is between if !cond { return Err(...) } and ensure!(cond, Err(...)).

I do agree that if we had something like ensure! it should do Err-wrapping, so that it'd just be ensure!(cond, ...). But even then, I personally think if !cond { ... } is clearer, whether the ... is return Err(...) or bail!(...).

25 Likes

anyhow and eyre both have an ensure! macro, which works analogously to assert!. It works well for that purpose. It's taken a long time, but error handling in rust is still shaking out to a certain extent. If at some point one of the error handling crates made it into std I would hope that ensure! would come along with it, but in the meantime at least it can be used in a crate.

8 Likes

This is the kind of macro that libraries like anyhow or eyre should have. Oh, they do!

On the other hand, it feels like macros like bail!() (and perhaps this ensure!()) should have been in the stdlib the whole time.

It also seems that ensure!() is very related to ?, it's as if Rust's ? were too restricted and that's why we need additional macros. We already know what's the most generalized syntax for this: Haskell's do notation, but, is there a way to extend the power of ? without going all the way there?


This also seems very related to assertions, to the point where another name for this could be try_assert()! (but ensure()! is a better name). Note that it's still useful to have both: assertions are used for fundamental assumptions, where the program can't possibly recover, and ensure just means that if the assumption is violated it's an error the caller could either handle or bubble up.

However, the most ergonomic way to write assertions (IMO) is using a macro like contracts. I wonder if such attribute macro would be an useful companion for ensure!(). #[ensure] is a bad name though (it doesn't distinguish between preconditions and postconditions, that is, calling ensure!() at the beginning or at the end of the function)


Anyway, I like when the Error type can be left implicit. In most error libraries this is done by type Result<T> = std::result::Result<T, mylibrary::Error>; but there's at least one library where whatever Error type in scope is chosen implicitly: fehler.

In fehler, #[throws] means #[throws(Error)] and I think it is great. So perhaps ensure!(condition) should mean ensure!(condition, Error) for whatever Error type that is in scope. The stdlib could provide a more structured way to say "this library has an error type that should be used implictly", but I think the fehler approach is good enough.

If there's a companion attribute macro, it would be nice if Error could be left implicit there, too.

1 Like

When discussing such things in C++, there was discussion of this around contracts keywords. To me, ensure means the caller needs to do something but ensures means the function is doing something for me. This is similar for expects and expect or assumes and assume wording proposed there where a single letter completely flips around what is responsible for the conditions given to the keyword.

Rust's try!() has been changed to ? to make it postfix and to make it clear it affects control flow (macro hides a return, but ? is known to be returning).

The ensure! macro shares more with the old try! design. Perhaps it could be made more similar to the ? style of error handling? For example, it could be a method on bool, similar how there's ok_or on Option: cond.or_err("failed")?

But I think it's worth taking a step back, and question why is this feature for booleans. Boolean options can quickly get out of hand: do_foo(true, false, false, true). Rust also has different programming patterns that replace uses of booleans in other languages, usually with enums (e.g. instead of is_value_set: bool you have value: Option).

So you should probably use enums, even for bool-like options.

I also find this pattern common:

let foo = match foo_or_bar {
  Enum::YesItsFoo(foo) => foo,
  Enum::NopeSomethingElse(_) => return Err("fail"),
}

So I'd prefer syntax sugar for this case. Maybe foo_or_bar.get_foo().or("fail")?. Maybe guard let?

4 Likes

In C#, this has started to be covered by the ?? and ??= operators - C# reference | Microsoft Docs.

For example, in C# you'll see

this.foo = foo ?? throw new ArgumentNullException(nameof(foo));

We could certainly have something like

let value = array.get(i) ☃ yeet "Err";

in rust to simplify this.

A previous thread: Something for coalescing; aka generalized/improved `or_else`

Note that, for bool, one can do this:

b || do yeet "uhoh";

https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=e04f964455a0ea16b3d2f3b76d87486a

And it'll work great, though the linter doesn't like it.

1 Like

this looks very much like the nearly stabilized let-else:

let Some(value) = array.get(i) else { yeet "Err" };
7 Likes

Very true! I suppose a more interesting one would be something like

let value = array.get(i) ☃ optional_default ☃ yeet "Err";

but then maybe that's something that should just be let-chains + let-else.

Generally the ?? case is the unwrap_or family; a common example of postfix macros is allowing that to be written as

let value = array.get(i)
    .or(optional_default)
    .unwrap_or! { yeet "Err" };

but with let else this could be written as

let Some(value) = array.get(i)
    .or(optional_default)
else { yeet "Err" }

(so I guess postfix macros needs a new poster child)

:pained_smile:

Drop order is killing let-else and let-chains both

3 Likes

As (kind of) a data point, when I transitioned the MiniRust well-formedness checker (which is not quite written in Rust but close enough) to ensure, I think it became a lot more readable. Previously it felt kind of odd that some checks would be done by calling function()?, and others would be done by if !cond { bail!() }. Afterwards, it's always the ? that indicates some checking happens, and I can write the actual condition that needs to hold, rather than its negative. I think this looks a lot cleaner.

6 Likes

Likewise snafu and old error-chain have it; I think ensure! seems fairly universal among Rust error handling libraries newer than quick-error.

How about this macro?

#[macro_export]
macro_rules! ensure {
    ($cond:expr, $e:expr $(,)?) => {
        match $cond {
            true => Ok(()),
            false => Err($e),
        }
    };
}

Examples

fn try_something(cond: bool) -> Result<(), FooError> {
    ensure!(cond, FooError::Bar)?;
    // .....
    Ok(())
}
fn validate_simple_foo(cond: bool) -> Result<(), FooError> {
    ensure!(cond, FooError::Bar)
}

That doesn’t seem like it needs to be a macro. It’s essentially cond.then_some(()).or(FooError::Bar)?. I wonder if there’s a good name for a method directly on bool to do this, cond.or_err(FooError::Bar)?.

EDIT: and of course boolinator has a function for this.

1 Like

I think that cond: bool wasn't supposed to be taken at face value, because of course this would make more sense:

fn try_something() -> Result<(), &'static str> {
    // .....
}

if cond {
    try_something()?;
}

Probably just a lazy example.

Regardless, ensure is basically a recoverable version of assert and it's definitely more compact than an if, so I'd say it's a readability win in functions with more than one such condition. I have mixed feelings about things like cond.or_err("failed")?: it reads weird and looks a bit ugly:

(x > 0).or_err("failed")?

Sorry, I detest parentheses due to severe PTSD from LISP :stuck_out_tongue_winking_eye:

IMHO ensure has to be a macro to be kinda universal and easy on the eyes (playground):

Summary
macro_rules! ensure {
    ($cond:expr, $e:expr $(,)?) => {
        if $cond {
            core::result::Result::Ok(())
        } else {
            core::result::Result::Err($e)
        }
    };
    
    ($cond:expr) => {
        if $cond {
            core::option::Option::Some(())
        } else {
            core::option::Option::None
        }
    };
}

macro_rules! dbg_ensure {
    ($($args: tt)+) => { let _ = dbg!(ensure!($($args)+)); }
}

fn main() {
    let x = 10;
    dbg_ensure!(x > 5, "oops"); // Ok
    dbg_ensure!(x != 10, "oops"); // Err
    dbg_ensure!(9 <= x && x <= 11); // Some
    dbg_ensure!(x % 3 == 77); // None
}