[Pre-RFC] `Result::void()` and `Option::void()`

Hi there, before opening an RFC I thought I solicit some pre-feedback here.

For some time I've missed having a particular fundamental primitive function that sees frequent use in Haskell, void:

void :: Functor f => f a -> f ()

In other words, "ignore the contents of your container/context", which is especially useful for chaining operations together.

Hey now, wait a minute, we already have something in Rust for "ignoring results": the ; "operator". We use this all the time in conjunction with ? to write what a Haskeller would see as Monadic code in the only two "monads" that really matter in life, Result and Option. So we often see lines line:

fn foo() -> Result<(), Error> {
    let a = bar()?;
    let b = baz()?;
    work(a, b)?;
    Ok(())
}

Assuming some composite Error type, and that each ?'d line is actually returning a different underlying error.

So from day-1 of my Rust journey, Ok(()) struck me as code smell. Indeed Haskell linters will warn you if you end a function in the equivalent pure (), citing that void should be called instead on the previous line.

Further, for some strange reason, we're unable to write:

fn foo() -> Result<(), Error> {
    let a = bar()?;
    let b = baz()?;
    work(a, b)?
}

The last line will error. And if work returns an actual value inside Result (as Command::status, etc, do!), we can't "drop it to ()" using ; in the usual way, since ; forces the entire line to be () (i.e. it doesn't respect the "context"). Thus we must write an extra Ok(()) below that or do the verbose:

work(a, b).map_err(|e| ... manual lifting here! ...).map(|_| ())

If we're not working with a composite error type, then a last line of work(a, b) works fine, but that's not an option here.

To summarize, the issues are:

  1. If the final call returns a non-() value, but we want (), we must write an extra Ok(()).
  2. If we want to lift to a composite error type, we must use ?;, and thus an extra Ok(()) below it.
  3. We often want to do both (1) and (2) at the same time.

Hence as a reduction of boilerplate, I propose the following method be added to Result:

pub fn void<R: From<E>)(self) -> Result<(), R> {
    match self {
        Ok(_) => Ok(()),
        Err(e) => Err(From::from(e)),
    }
}

This void kills two birds with one stone; it ignores the result value and lifts our error. This enables:

fn foo() -> Result<(), Error> {
    let a = bar()?;
    let b = baz()?;
    work(a, b).void()
}

Here are some examples in real code:

/// Search the Pacman log for a matching string.
pub(crate) fn search(path: &Path, term: String) -> Result<(), Error> {
    let (search, args) = misc::searcher();
    Command::new(search)
        .args(args)
        .arg(term)
        .arg(path)
        .status()
        .void()
}
/// Open a given URL in a browser.
pub(crate) fn open(url: &str) -> Result<(), std::io::Error> {
    webbrowser::open(url).void()
}

^ Here we don't lift the error, we just want (). void turns it into a one-liner, which probably helps inlining?

let clones: Validated<(), &str> = packages
        .par_iter()
        .map(|p| {
            let pkg = p.as_str();
            aura!(fll, "A-w", package = pkg);
            clone_aur_repo(None, &p).ok_or(pkg).void()
        })
        .collect();

^ Here we're using void in a lambda to drop to (), so that Validated can smash them all together via collect.

The void in the code above is implemented as a trait:

pub(crate) trait ResultVoid<E, R> {
    fn void(self) -> Result<(), R>
    where
        R: From<E>;
}

impl<T, E, R> ResultVoid<E, R> for Result<T, E> {
    fn void(self) -> Result<(), R>
    where
        R: From<E>,
    {
        match self {
            Ok(_) => Ok(()),
            Err(e) => Err(From::from(e)),
        }
    }
}

But this forces an extra import statement, and void should really be part of Result directly (or Rust should get HKTs and Functors). And while less useful since there's no Error lifting, a similar method could be added to Option for the convenience of dropping to () when we need to.

Now I'm not by any means attempting "import Haskell" into Rust just for the sake of it, but void as I've described here seems to me to be a clean solution to the outlined problems.

Thanks for any input you can offer.

7 Likes

On nightly with try blocks, your example can be written as:

fn foo() -> Result<(), Error> {
    try {
        let a = bar()?;
        let b = baz()?;
        work(a, b)?;
    }
}

(There have also been proposals for “try functions” which would allow the same to work without the extra level of braces.)

7 Likes

One advantage of explicit Ok(()) or try blocks over void is that it is easier to modify code, since the syntax of a call doesn't change depending on whether it is the last one in the function. For example, suppose you want to add a new statement after the call to work.

With explicit Ok(()), you can just insert a line:

 fn foo() -> Result<(), Error> {
     let a = bar()?;
     let b = baz()?;
     work(a, b)?;
+    log!("work finished");
     Ok(())
 }

Same with try:

     try {
         let a = bar()?;
         let b = baz()?;
         work(a, b)?;
+        log!("work finished");
     }

But with void, the change becomes more complicated:

 fn foo() -> Result<(), Error> {
     let a = bar()?;
     let b = baz()?;
-    work(a, b).void()
+    work(a, b)?;
+    log!("work finished");
+    Ok(())
 }

Similar things happen if you delete a line at the end of a function.

20 Likes

I would say that the error lifting is a secondary benefit to avoid boilerplate, and that the "dropping to ()" is more the chief sell of void, since that's useful in more places than just the last line of a function.

How would the Command example from above be rewritten with try blocks?

pub(crate) fn search(path: &Path, term: String) -> Result<(), Error> {
    try {
        let (search, args) = misc::searcher();
        Command::new(search)
            .args(args)
            .arg(term)
            .arg(path)
            .status()?;
    }
}

“Dropping to ()” is done just by ending with a semicolon, similar to a non-try block.

Thanks for that. Hm, not sure if I'm a fan of the extra nesting + additional syntax to accomplish this. :thinking:

You can also spell .void() as .map(drop) in the cases where you don't need error conversion. I like this in certain cases because it makes it clear that it's discarding a return value.

35 Likes

IMO, the name is unintuitive. When I read the title, I assumed either something related to C FFI or the never type.

I'd prefer to have a method named consume_ok or drop_ok, that converts Result<T, E> into Result <(), E> and then use map_err(F::from) to convert Result<(), E> to Result<(), F>. Merging 2 functions doing 2 clearly separate things should only be done, if there is a significant gain from it. I don't see that here, yet.

5 Likes

Relevant: GitHub - withoutboats/fehler: Rust doesn't have exceptions

#[fehler::throws(i32)]
fn foo(x: bool) -> i32 {
    if x {
        0
    } else {
        throw!(1);
    }
}

Personally I'd like to have implicit Some(())/Ok(()) built-in, but I understand that's not a particularly popular position.

1 Like

I support this of idea, void is not an appealing name though I did had the same idea some time ago. I wrote a crate "ignored" (unpublished) inspired from ignore in F#.

For me void or ignore type of functions results in clearer code.

Like

1..20.map(|i| side_effect_func(I).ignore());

Instead of

1..20.map(|i| {side_effect_func(i);} );

But I thinks it's highly subjective debate, and I understand why this isn't maybe beneficial for everyone, even if I think so.

Neither a fan of blindly adding Ok(()) at the end here, but I believe .void() at the end is worse. For example consider this code:

fn foo() -> Result<(), Error> {
    a()?;
    b()?;
    c().void()
}

Why should we treat c() differently with a() and b() because it just happened to be placed at last? These three lines do same things - call the function, return if error, discard if ok - so they should have similar shape. Otherwise it would be the code smell due to the inconsistency.

16 Likes

Yes please. This .void() - as per RFC - is so ergonomic.

I agree with both of these. I used to often write functions where I omitted the ?; on the last call, and let its return value become the function's return value. I often found myself fiddling with that if the return type changed (e.g. to a more general error type), or if I needed to add another statement, generating unnecessary diff noise. Now, I try to always write ?; on every line, and then Ok(()) afterwards if I have nothing to return.

I feel like try offers better symmetry, by allowing every line to be written with ?; at the end. And in the absence of a try block, it doesn't seem excessive to write Ok(()); that's the return value of the function.

14 Likes

And by not having the same shape they end up with different semantics, too -- a and b would be doing error conversion, but c wouldn't.

That can be a big deal if foo wants to return anyhow::Result<()> or similar.

1 Like

I see the merit in Result::void(), but I don't think Option::void() should happen. A Result<(), ErrorType> return type means "this function is ran for its side-effects, but we acknowledge that it can fail". An Option<()> return type, on the other end, means "this function either returns a value (which just happens to be a unit value) or returns that that value does not exist". The return in such a function should stand out more than the one in the former function.

I'm not a fan of Result::void(). It feels unnecessarily terse and beginner unfriendly compared to foo.map(drop).map_err(Into::into), where you can easily tell exactly what the semantics are just by reading the set of method calls.

I definitely lean more towards try blocks. My only complaint for them is the extra indentation but I'm hoping we can also get function equals or some other form that allows us to collapse the blocks easily.

fn foo() -> Result<(), Error> = try {
    // ....
}
6 Likes

That's not a "strange reason". The ? operator unwraps unconditionally, so its type is not Result<T, E>, but simply T.

That last line should just be written without the question mark. It doesn't even need try blocks.

Hi everyone, thanks for the input. Let me restate the intent and address a few comments.

Intent #1: Ignoring the return value that occurred in a "Context"

The only two contexts (re: Monads) we care about in Rust are Option and Result. It is common to want to ignore the result of some operation that returned in a context, as shown above with Command::status.

It has been shown above that there are currently two ways of doing this already: .map(|_| ()) and .map(drop). However, neither of these are the best approach in my mind, as the former is "code smelly" and the latter is "scary". Further, since neither is a method call, there is no documentation as to their intent. void (or similarly named) would have docs and examples.

One might be inclined to say that void is so trivial that it's a waste to implement it, but there are plenty of such one-liner methods in std that this shouldn't be a reason not to add it.

re: not adding Option::void. My gut still says it would be useful, likely in the case of the "switcheroo" FromIterator instance for Option/Result.

Intent #2: Improve ergonomics when void is called on the last line

The auto-lifting of the Error type could be optional here. I added it here to reduce overall boilerplate that is otherwise incurred by (arguably underpowered) ? and ;.

Talking to some peers, we speculated that in an ideal world, ; would respect context, and not just evaluate to () regardless. If anyone else likes that idea, we could open a new thread to discuss it and let void just be concerned with "dropping to ()".

Otherwise, I'm not a fan of try blocks, as the goal was to reduce code length, and I'm generally allergic to the addition of syntax to accomplish what is already possible with functions (I'm looking at you, Golang). Further, try blocks were added to nightly five years ago. They may never be merged, and I'm looking for a sustainable solution for Stable Rust.

I would suggest not over-indexing on this.

Anything that changes ; is also going to be a complex issue, especially since it's affecting everything that already exists.

There has actually been some progress towards try stabilizing -- see Resolving `Ok`-wrapping for `try` blocks · Issue #70941 · rust-lang/rust · GitHub and try_trait_v2: A new design for the ? desugaring by scottmcm · Pull Request #3058 · rust-lang/rfcs · GitHub, for example -- so I think it's closer than it's ever been.

5 Likes