The paradox of Result and explicitness


#1

We love Result because it is explicit, right? You can see all of the places where your function can fail, marked right there in the source code, and you can see how all of the errors are handled. All in perfect alignment with the virtues of Rust.

Except… allow me to pose a rhetorical question.

The following is the full body of a function.
How many of the function calls in it can fail?

{
    let prim = poscar::read(File::open("PPOSCAR")?)?;
    let prim_ops = read_symmetry("sym.yaml")?;

    let factor = lattice_divide(structure.lattice(), prim.lattice())?;

    supercell_ops(&structure, &prim_ops, &factor)
}
In case my rhetoric is lost...

The function contains 4 places where results are explicitly handled/forwarded, but there are actually five places where it can fail.


I don’t know about you, but I’ve begun writing my Result<...> function bodies as

{Ok({
    ...body here...
})}

Not only does it fix the issue of explicitness above; but it also gets rid of that pesky Ok(()). :angel:


#2

I think it’s pretty obvious, and I’m writing similar code all the time. The function returns Result, and the last statement is not an explicit Ok, so obviously it can be an Err.

Wrapping everything in Ok makes is somewhat harder to read, but can be convenient in many cases.


#3

I agree and wish that something like throwing functions would be supported at some point, that would make your example:

throws {
    let prim = poscar::read(File::open("PPOSCAR")?)?;
    let prim_ops = read_symmetry("sym.yaml")?;

    let factor = lattice_divide(structure.lattice(), prim.lattice())?;

    supercell_ops(&structure, &prim_ops, &factor)?
}

and all fallible function calls would be annotated with ?

EDIT: It also fixes the extra Ok(()) as the normal implicit () return value works. And it also fixes explicit early returns which wrapping in Ok({ ... }) doesn’t.


#4

I’m not a fan of the Ok({ ... }) , I reckon it’d add unnecessary rightward drift and that trailing })} is way too easy to mess up.

In comparison, straight-line code like your first example is fairly easy to read. Although I might pull that File::open(...) into a temporary variable just so you don’t have the nested calls, but that’s just me.


#5

Totally agreed! See this (controversial, postponed) RFC that had similar motivation:

https://github.com/rust-lang/rfcs/pull/2107


#6

if possible i like method chaining more as to created a temp variable with get only used once.

this would turns the first line into this:

let prim = File::open("PPOSCAR").and_then(poscar::read)?

#7

and_then would be alright if I could actually use it once in a blue moon, but most of the time it feels like the entire world is working against it:

  • Oftentimes the second function takes an &T when the first result has a T.
  • Oftentimes I need to supply more than one argument.
  • Even on those rare occassions when neither of the above two points apply, I often end up having to change it to .map_err(MyError::from).and_then(function) anyways, at which point I realize that I’m basically reinventing ?.

#8

you still can use and_then here but you have to pass a closure

File::open("PPOSCAR").and_then(|file| poscar::read(&file, arg2))?

true either map_err(Into::into) or if you use error_chain

File::open("PPOSCAR")
    .chain_err(|| "unable to open file \"PPOSAR\"")
    .and_then(|file| poscar::read(&file, arg2))?

i don’t mind either of them


#9

Certainly this is true, but then I’m not sure what the chaining buys me other than having to do more hand acrobatics to type a closure. It doesn’t feel right that such small differences in the problem require such vast differences in the syntax.

I would vastly prefer to solve the chaining problems with tools like higher order functions or currying, and we just don’t have anything like this. I mean, we can almost kinda simulate these tools with macros, but…


#10

Hm, interestingly, I counted 4, because I missed a question mark :).

I have to say that I never run into many issues with that - I rarely look for places where a function can fail. With Results (and subsequently failure) being integrated into the normal value flow of the language, it’s a question that doesn’t become that strong. All “?” marks is early returns, everything else has normal control flow.


#11

See also this open question about auto-wrapping behavior for catch blocks:

https://github.com/rust-lang/rust/issues/41414


#13

So I looked over the RFCs again (1, 2). It seems that no satisfactory solution has really been put forward to the problem of making function bodies work like catch, but I’m also not sure what the big deal is, since one can just write their bodies as

fn foo() -> Result<T, E>
{ do catch {
   ...
}}

which IMO is apples to apples with all of the kinds of “explicit throwing function signature annotations” that people are suggesting in the comments. I really hope that these concerns don’t ultimately result in do catch being stabilized without the ok-wrapping.


#14

The difference shows up when return is involved, as catch isn’t a barrier to returns. (Part of why it exists, as otherwise catch { A } could just be spelled (||{ Ok(A) })().)

But writing it very similarly to the above was proposed in the discussion:

fn foo() -> Result<T, E> catch {
    ...
}

#15

I tend to write the last line as:

Ok(supercell_ops(&structure, &prim_ops, &factor)?)

Sometimes the called function returns an other kind of result than the calling function, then this takes care of any needed conversion. If it is not needed for conversion, I often write it like this anyway, for consistency.