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

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

Iterator and Future are also very cared about monads, the latter has a whole language syntax dedicated to it, and adding some syntax to simplify the former has been widely discussed.

4 Likes

C void corresponds to Rust (), so that was my first association. :wink: The never type does not really fit. (But sadly, C void is often described as "containing no value", which of course is wrong as we can easily see in Rust where we have the value () inhabiting the type ().)

2 Likes

The naming is purely historical: I called it void here because that's the symbol name in Haskell and Scala (at least).

Sounds like you want fehler?

Thanks for the suggestion, but the main point of void is to "drop to ()", which is useful in a number of places. All the discussion involving error handling has to do with a void call being made as the last line of function.

That's what a semicolon is for. E.g. with fehler, your original example becomes:

#[throws(Error)]
fn foo() {
    let a = bar()?;
    let b = baz()?;
    work(a, b)?;
}
1 Like

Sorry, please reread the original posting and comments.

1 Like

Please don't assume that people who disagree with you haven't read what you've written.

I've read your entire original post, and I don't see the use case for .void(). For Option it never seems useful (Option<()> is rarely a useful type) and for Result it seems like a niche helper that might be helpful for the occasional closure argument but not sufficiently common to put in the standard library.

10 Likes

For what it's worth, here are your other examples rewritten to use fehler. Note that fehler has the same advantages as try blocks but without the disadvantage of additional nesting.

#[throws(Error)]
pub(crate) fn search(path: &Path, term: String) {
    let (search, args) = misc::searcher();
    Command::new(search)
        .args(args)
        .arg(term)
        .arg(path)
        .status()?;
}
/// Open a given URL in a browser.
#[throws(std::io::Error)]
pub(crate) fn open(url: &str) {
    webbrowser::open(url)?;
}
#[throws(&str)]
fn clone(p: &String) {
    let pkg = p.as_str();
    aura!(fll, "A-w", package = pkg);
    clone_aur_repo(None, &p).ok_or(pkg)?;
}
let clones: Validated<(), &str> = packages.par_iter().map(clone).collect();

Everyone's allowed to disagree, but it seemed to me that he hadn't understood the original motivation and discussion.

Thank you for those examples, I can see the appeal. Personally I wouldn't choose to rely on macros here though. My gut feeling is that the function signature should be the source-of-truth-at-a-glance.