Have you ever intentionally use Result<(), ()> instead of bool?

What would be your reason to do so?

I'd imagine if you have a function where you want to early exit if some other function returns false, this could work (when using the ? operator). Although no reason to not write if !fun(....) { return ...; }.
I'd consider this as bad design since it does not express the intent clearly. A Result is used to indicate failure or success. In case of failure, it is expected, we have some data to indicate what went wrong. A bool is to, well, just indicate true or false.

7 Likes

Result<(), ()> isn't much better than a bool, but custom enum with meaningful names sometimes is. The reason is simple - self-documented code.

8 Likes

Result<T, ()> is bad as it doesn't give you any clue why the error happened. But having ZST error type is quite useful based on my experience.

#[derive(Debug, thiserror::Error)]
#[error("how unfortunate!")]
pub struct UnfortunateError; 

pub fn roll() -> Result<(), UnfortunateError> {
    if rand::random() {
        Ok(())
    } else {
        Err(UnfortunateError)
    }
}
18 Likes

One thing I really like is using ControlFlow instead of bool for things like Chain of Responsibility or multi-level visitors. Having a name is so much better than trying to remember whether true means "yes I handled it" or "yes you still have to handle it".

More in https://rust-lang.github.io/rfcs/3058-try-trait-v2.html#the-opscontrolflow-type. It works with ? too!

7 Likes

Fun fact: Result<ZST, ZST> appears to have size 1 and in fact exactly the same layout as bool, including the set of valid values: 0/1 or false/true (Playground):

let x: Result<(), ()> = Ok(());
let y: Result<(), ()> = Err(());

println!("{}", unsafe { transmute::<_, bool>(x) });
println!("{}", unsafe { transmute::<_, bool>(y) });

Needless to say: don't ever rely on this, the illustrative code example above is likely UB because the layout of enums is unspecified, but it's a fun exercise that shows the beautiful convergence of (type) theory and practice.

7 Likes

Does using transmute produces the same assembly as using .is_ok()?

Well, it should be is_err() because Ok, being the first variant, has discriminant 0, and Err has discriminant 1. But yeah, the is_err() call should likely be optimized down to just returning the discriminant itself. You can try it out on https://rust.godbolt.org for yourself (I'm on my phone right now and it doesn't work on mobile).

I use Result<(), ZST>, because it works well with the ? operator. A bool would require error propagation to be done manually like if !good {return false;}.

7 Likes

I wish the .retain()-like APIs had gone for that; having a true or false in the middle of a big retain closure doens't read at all as nicely as an enum would have.

More generally, the following grep.app search made me chuckle:

5 Likes

Thanks; that's amazing. I wish I'd though of that to put in the FCP for ControlFlow :slight_smile:

3 Likes

I used Result<(), ()> once. I don't remember the exact situation, but for some reason I didn't want to introduce a new error type and Result is much clearer than bool.

1 Like

Result<(), ()>? No, that seems obfuscating. But Option<()>? Yes, a few times, for control-flow reasons. It is essentially the same as bool or Result<(), ()>, but it allows to use ? for early return on failure, it doesn't carry a redundant error type and it is much more ergonomic to use inside closures (and thus iterator adapters and various combinators).

Personally I would like if bool directly implemented the Try trait and could be usable with ? short-circuiting, but it is a hard call. This can easily lead to more obfuscated call, e.g. because people may differ in opinion whether true or false should cause a short-circuit. It's a bit scary to make something as simple and innocuous as bool have extra complicated semantics.

boolean.then(||())
boolean.not().then(||())
:wink:
Is ||() a sluggish fish...?

4 Likes

I use Result<X,()> for many different types (including X=()).

I work on AI logic systems (imagine a Sudoku solver which works by doing deductions, then guessing when it gets stuck).

You end up with lots of "try to deduce some more of the Sudoku" functions. These functions can figure out the current problem is unsolvable, and when they do we want them to return "fail/error".

In previous C++ systems I worked on, these functions would return a boolean -- except that is dangerous, because if you forget to look at the return value, your system can not notice the problem is unsolvable and continue trying to solve it (which is REALLY hard to debug, because you will, in the end, get the right answer, you will just spend longer trying to solve it than you have to). Therefore we want to use Result (rather than Option or just a bool) so we get nice warnings about ignoring return values.

Why ()? Because while in some cases it might be nice to have an "explanation" for failure, we typically expect our system to reach a failure state 200,000+ times a second -- so we really don't want to waste time actually constructing an Error object, just to throw it away straight afterwards.

3 Likes

You might consider using a custom ZST for this instead (e.g. #[derive(Debug)] struct DeadEnd;), which at lest describes what conditions might produce the failure. Result<X,DeadEnd> will compile to exactly the same machine code as Result<X,()>, at least until you call a method on the error type.

10 Likes

I possibly should. I have old (I'm not saying justified!) paranoia about ZST, For a very long time g++ compiling C++ generated non-trivial code when ZSTs were passed to functions (it would treat them as a single byte, and copy that byte).

Well, C++ standard doesn't really allow for ZSTs, AFAIK - even the empty class will have non-zero size. Rust, on the other hand, have them as first-class citizen, so there shouldn't be any problem.

10 Likes

Depending how you think about things, consider also ControlFlow in std::ops - Rust. I don't know if it's a better mental model than Result for your case, but it also supports ?, and can be more readable in some situations.

One of the great things about Rust is that the "special" types really aren't that special.

So () is itself just a normal ZST. The compiler will automatically put in its value in a few places (like automatic else { () }) but the type itself is quite ordinary.

This extends to more things that you might have expected, too.

For example, this:

pub enum MyBool {
    MyFalse,
    MyTrue,
}

pub fn demo2(b: MyBool) -> MyBool {
    use MyBool::*;
    match b {
        MyFalse => MyTrue,
        MyTrue => MyFalse,
    }
}

Generates the same code for the function as

pub fn demo1(b: bool) -> bool {
    !b
}

Demo: https://rust.godbolt.org/z/vKeod54ba

3 Likes

ControlFlow is no good for me, because it isn't unused_must_use (and it's mainly that security I want over using a Boolean or Option).

I don't know if that is an oversight, or there are good reasons to ignore a ControlFlow?

2 Likes