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

As I posted I totally agree those Ok(()) at the end looks overly verbose and boilerplate-y. What I (and some other people) asked is why you think .void() is better than ; Ok(()) which is not answered yet.

I agree that it looks slightly out of place if immediately preceded by ?; lines:

foo()?;
bar()?;
bar()?;
zoo().void()

Although the effect of this would be lessened for large functions with effects/comments/other code in between ?; lines and the function-final .void().

Otherwise, I appreciate minimalism in code. If we can "win a line" back from the demons of complexity, I'd say that's a good thing. There's also the historical connection with other languages that have a void and use it in such a way, which is good for education and skill transfer.

If you're coming from Haskell and see Result as a monad, I can see why you would see Ok(()) as redundant.

In Rust, Result is just a return type, not a modifier on the whole function. Thus, it isn't a monad where the implicit () becomes a monadic-wrapped m (). It's still just a function, and needs a matching return value, which Ok(()) supplies.

You can use something like try to change that; try does Ok-wrapping, so it implicitly turns a () at the end of the try block into Ok(()).

fehler does the same thing at the function level. But as you observed, it also adds non-local behavior and makes the return value less obvious. A hypothetical try fn, if we had one, would reduce but not eliminate that issue. And, in fact, that exact issue is part of why many people (myself included) don't want try fn.

2 Likes

LOC isn't complexity, though it is a form of minimalism I suppose. I find a trailing Ok(()) more simple than anything else suggested. It's a stand-alone expression and recognizable at a glance. And I don't have to mentally fill in the ? that void() would swallow.

If it's just about saving lines though, here's a more general method than void().

impl Result<T, E> {
    fn three_birds_one_stone<U: Default, F: From<E>>(self) -> Result<U, F> {
        match self {
            Ok(_) => Ok(Default::default()),
            Err(e) => Err(From::from(e)),
        }
    }
}
2 Likes

I think you'll find that most people will agree with you in principle here. So this isn't really the issue. The issue is that sometimes it's not worth doing, and reasonable people will disagree about what is or isn't worth doing because we weigh the costs and benefits differently. For example, I find the cost of writing Ok(()) to be so marginal as to be close to non-existent, and find the benefit of something like void() to be similarly marginal.

5 Likes

Just to add my two cents here, I too have thought that Ok(()) looks rather weird (even coming from never programming in ML or Haskall) but I would have preferred the solution to be Ok() (with no args) to mean Ok(()).

1 Like

Ok(()) looks no more weird to me than Some(0) or HashMap::new(), neither of which I'd want to be implicit. The reasons for those to be explicit are exactly the same reasons for Ok(()) to be explicit.

4 Likes

Thanks for the input everyone.

I personally don't see the difference in terms of what is being represented (say, mathematically, conceptually) underneath. :thinking: It has been very helpful to imagine ?; as representing monadic binding in an error context (Monad), but we generally don't use the M-word in Rust, so I've enjoyed the pattern as a little secret. I actually find ?; more ergonomic than the usual way of approaching such things in Haskell. Am I missing something?

I'm also not a fan of extra syntax/keywords to accomplish what functions can already do. I think we should endeavour to be as allergic to new syntax as is reasonable.

Surely they're correlated. And certainly Ok(()) is clear in its intent when eye-balling between the final line in a function and its return type in the function signature. I'm beginning to wonder if function-final Ok(()) is culture at this point :thinking:

Cheers to disagreeing civilly on the internet! :beers:

I hadn't considered that.

Am I also very much against implicit behaviour (I wrote Scala for years). My motivation for void (both for its error-handling effect and its dropping-to-()) is that it has historical precedent elsewhere, and I've needed it in real production code.

The error-handling issue seems to be getting the most traffic, but I'd like to return to the original motivation. At present I'm basically satisfied with the pattern offered by fehler and will consider it. Are there otherwise any strong objections to a pub fn void(self) -> Result<(), E> that doesn't otherwise involve From or error handling? There have been mentions of map(drop), but the idea here is to have a common pattern documented with examples.

And since it hasn't been touched on as much: is anyone offended by Option::void?

The name void too is of course open to debate. The void naming is historical.

Thanks again for the great discussion.

That's kind of what I was saying above. I'm not particularly in favor of it. Also, I don't like the name either because of its confusability, but I didn't want to bring that up because I see that as an easily distractable topic that only makes sense to invest energy into if we think we'll actually add the method to std.

In terms of process, you don't need to open an RFC for this. For small API additions like this, you can just open a PR. If at least one person from the libs-api team likes it, then it can get merged as an unstable API. After it bakes for a couple releases, anyone on the libs-api team may propose it for stabilization. At that point, a consensus among the libs-api team is required before stabilizing it.

From a cursory skim, I count 3 members of the libs-api team (myself included) that have chimed in here with a weakly-negative reaction to this. That's not to say you shouldn't proceed to the next step of opening a PR, but it might give you an idea of what the feeling is going into it.

3 Likes

I completely agree that Ok(0) or Some(0) or HashMap::new() don't look weird at all. But my reasoning was that the double set of brackets, just feels strange imo. It never was a big annoyance though.

Ah, after reading the RFC process docs I was under the impression that API changes required an RFC ahead of time.

The inner () aren't brackets, they're the unit type. It may be a quirk of Rust that it's spelled () rather than unit (or void, which probably a better name for enum Void {},) but it is what it is.

I know it is a expression and technically the rust language community could have situated itself on some enum Unit {} instead of the empty tuple as the "default" empty type. But from a reading perspective it does look odd.

Not that I am really advocating for Ok() to mean Ok(()), just my thought pattern.

1 Like

If only Rust had generic constants, so that one could use OK as the trailing expression in functions returning Result<(), E>

pub const OK<E>: Result<(), E> = Ok(());

.

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

It only looks odd if you misparse it. One could also say -> looks odd because it looks like "minus greater", or ..= looks odd because it looks like "range assignment". You need to know how to parse the syntax to understand it, and that only comes with experience.

In general, languages are tools for negotiating meaning, and part of that negotiation is accepting compromise. There are plenty of things I don't like about Rust's syntax, but I accept them because it's more important for me to be able to communicate consistently and write long-term maintainable code (Ie., code whose syntax isn't declared obsolete in 6 mos) than it is to write in the most elegant language that matches my intuition perfectly. (And learning is always more about changing your intuitions to suit the subject, than changing the subject to suit your intuition.)

2 Likes

() and enum Unit {} are very different types and not at all interchangeable. The fact that C confused them together is a horrible historical wart in programming. () is a type with one value: () (it just happens to be spelled the same, as with most ZST instances). enum Unit {} is ! and cannot be (meaningfully) returned from a function. It also cannot be accepted as an argument (as there is no way to create a value which inhabits the type).

10 Likes

Just a quick crash course in some type/category theory to clarify things further.

A useful way to see the difference is to remember that tuples (and structs) are multiplicative types. As such, the "base" case is 1 so that the multiplication works. There is no way to get 0 valid instances in such a setup without making a 0 in some other way. That way involves enum which is an additive type. Each variant adds one more way to create values of that type (if that variant has multiple values, that is added). So for example:

struct One; // Has one valid value
enum Zero {} // Has no valid values
struct OneByAdd { value: () } // Isomorphic to `One`
enum TwoByAdd { First(One), Second(One) } // Has two values (1 + 1)
struct TwoByMult { one: One, two: TwoByAdd } // Has two values (1 x 2)
struct ZeroByMult { one: One, zero: Zero } // Has no values (1 x 0)
enum OneByAddZero { First(One), Unconstructible(Zero) } // Has one value (1 + 0)
struct FourByMult { add: TwoByAdd, mult: TwoByMult } // Has 4 values ((1 + 1) x (1 x 2))
struct FourByAdd { Add(TwoByAdd), Mult(TwoByMult) } // Has 4 values ((1 + 1) + (1 x 2))

In such a way, any type with the same count of valid values is isomorphic to another with the same count. That is to say, every value of type A can be converted to a unique value of type B and back without losing information. A more useful example is that i8 and u8 are isomorphic there exists a one-to-one correspondence of values (in fact, there are 256! (~8.5e506) such unique correspondences).

1 Like

Sorry yes I meant

enum Unit {
    unit,
}

I do know the difference (though I haven't followed ! enough to know why it isn't stable beyond there are regressions). I just was thinking.

Fair enough. As I said above I didn't really think it should be a thing anyway.

1 Like

Wouldn't it be

pub const OK: Result<(), !> = Ok(());

and rely on this being silently assignable to anything that fills in the ! in the same way you can assign ! to () (e.g. let x: () = panic!();)?

1 Like