Idiomatic error handling in `new()` and builder pattern?

I'm struggling to develop an idiomatic coding style in a new Rust project and am currently snagged on error handling. I can see how most idiomatic functions can return Result<T, E> and most callers can handle both returns. But there are are a couple of (also idiomatic) constructs where you can't return a Result:

  • "constructor" (or "struct instance") expressions, fn new() -> Self
    Here the return value is an instance, not a Result. Here's a prior question raising this issue: Constructor question, though the discussion veered away from answering this part.

  • "builder" pattern: self.add(foo).add(bar).add(baz).complete()
    where the return value from add() is expected to be the same &mut application instance that was an input.

For the "constructor", the return value actually could be Result without undue damage to the pattern (below). But I don't see any way to salvage error checking in the builder pattern without resorting to panic(). Perhaps wiser heads can offer guidance?

use anyhow::{anyhow, Result};

fn main() -> Result<()> {
    #[derive(Debug)]
    struct Foo {
        val: usize,
    }
    impl Foo {
        fn new(x: usize) -> Result<Self> {
            if x >= 20 {
                // detect error and take early return out of constructor
                return Err(anyhow!("must be less than twenty")); 
            }
            Ok(Self { val: x })
        }
    }

    let my_foo = Foo::new(42)?; // early exit if constructor erred
    
    println!("Foo is {:?}, val is {}", my_foo, my_foo.val);
    return Ok(());
}

Playground

1 Like

This is not true. You definitely can (and should) return Result from your constructor if it's fallible.

You can return Result from builder methods as well. You can then propagate the error at each step by writing

Builder::new()
    .foo()?
    .bar(config)?
    .quux(42)?
    .build()

or something like that.

10 Likes

Agree about constructor.
But compiler complains about builder pattern, .add()?.bar(config)?

   Compiling playground v0.0.1 (/playground)
error: expected `;`, found `println`
  --> src/main.rs:35:17
   |
35 |         .add(1)?
   |                 ^ help: add `;` here

In order to use the ? operator in any function, it must return a Result, and that includes main.

1 Like

new can be fallible too, for example NonZeroU32::new returns Option<Self>, not just Self.

That said, I'd definitely expect no-argument Foo::new() to be infallible -- if it's doing something interesting enough to need to report errors, it might be better to give it a different name.

5 Likes

The error message suggests that the next line is

   .add(1)?
   println!("some format", /* … */);

which is erroneous, but in the way you're interpreting it. As the error message says, add a semicolon at the end of the statement, before the println!. If this isn't, the case, show more context - we can probably help you understand what's gone wrong.

Builder and constructor functions aren't special, in any way, and the same rules about error handling apply to them as apply to any other. To me, that's one of the nice things about Rust's approach to value initialization: because it's not special, there are fewer things that I, as a programmer, need to remember when working with them.

1 Like

Thanks for your help! I fixed my syntax errors, now have a sample of the build pattern using ? after each build call, showing that an error return from that call causes immediate error return from the calling function, just like I want.

So I conclude the Result return value and the early exit on error is a pretty durable pattern I can rely on for most/all of the code I'm likely to write.

That said, ? is hard to understand, its behavior just has to be memorized. The RFC would have you think of it as a break, jumping to a lifetime label outside the containing function while carrying a value along. That's pretty magical for a statement, unique in my programming experience. So I'll just memorize the pattern and not worry my pretty little head.
.
Full example:

use anyhow::{anyhow, Result};

fn main() -> Result<()> {
    #[derive(Debug)]
    struct Foo {
        val: usize,
    }
    impl Foo {
        // fallible constructor
        fn new(x: usize) -> Result<Self> {
            if x >= 20 {
                return Err(anyhow!("must be less than twenty"));
            }
            Ok(Self { val: x })
        }
        fn add(&mut self, x: usize) -> Result<&mut Self> {
            println!("in add, arg is {}, self is {:?}", x, self);
            if x >= 10 {
                return Err(anyhow!("addend must be less than ten"));
            };
            self.val += x;
            if self.val >= 20 {
                return Err(anyhow!("accumulator must be less than twenty"));
            }
            Ok(self)
        }
    }

    let mut my_foo = Foo::new(1)?;
    println!("post init, Foo is {:?}, val is {}", my_foo, my_foo.val);
    
    // try builder pattern
    
    my_foo.add(8)?
        .add(8)?
        .add(3)?;  // can cause error exit by arg >= 3
                    // if error, skips print below.
    println!("post build, Foo is {:?}, val is {}", my_foo, my_foo.val);
    
    return Ok(());
}

Doesn't every language feature have to be memorized? And core APIs, too? I mean, if you start learning a new language, there's no way you just magically know everything about it without memorizing any new stuff.

What's weird about statements/expressions affecting control flow? break and continue do the same thing. So does return, await, and even function calls if we are being literal.

I don't really understand what "less magical" you are looking for or what deeper meening you are in search of. There is none. The expression result? in the simplest case of Result-based error handling is literally just the equivalent of

match result {
    Ok(value) => value,
    Err(error) => return From::from(error)
}

but with less noisy syntax. No magic here. In fact it used to be a macro called try! which did this exact thing without being a built-in operator of the language.

4 Likes

For better or for worse most so-called “mainstream” languages exploit bunch if the exact same [very old] ideas.

Even Python (which syntactically is very different from C++ or Java) is not too different in it's core ideas.

That's why Rust needed that “hey, I'm just a better C++” mimicry: even MIT no longer teaches Scheme, languages which both look and work differently from “mainstream” are considered impenetrable these days.

Same old in a new dressing. That's natural human desire.

The whole industries are exploiting that desire. Fashion industry, e.g.

Every year people spend billions in an attempt to “look different” — and to do that they purchase things similar to what they were shown.

Language designers also have to use this approach.

Of course there is! Today it's fashionable to throw exceptions and that's what people expect.

Thankfully Go is popular enough and it teaches people to get two results instead thus Rust's approach doesn't look totally alien these days.

That's all well and good, but you are thinking rationally. That's not what people are doing or expecting most of the time.

Spending efforts on rational thinking is not rational.

Most people, most of the time, use much older mechanism, emotions. These don't require as much energy as thinking.

This got me to grep the rust sources and find that there are only 23 "new()" functions that return a "Result" and they are all in one of the two directories "src/{test,tools}" .
In the standard library there are some "try_new" functions that allocate memory; box, rc, etc.
Then in std there are many "new()" functions that I believe can fail without giving a "Result". Straight to panic.

This looks like a "new()" that fails is idiomatic to panic, and if you want to check if your constructor works, call it "try_new()"

@ scottmcm thank you for pointing out that some "new()"s return an "Option".

The choice of which is better to return from a "new()" that can fail: "Result" or "Option" is probably controversial.

These two contradict. If you think that returning Option is OK, then you must think that panicking is not allowed, or vice versa – but not both.

IMO it's always better to return a type explicitly denoting fallibility unless you really-really can't for some reason. Panics can't be handled reliably, so by panicking, you take away the opportunity of graceful error handling/recovery from the caller.

2 Likes

In my own code: If something can fail in only a single obvious way, then I return Option, otherwise Result.

I'm trying to develop a coding style in Rust that works for server applications and embedded systems where simply giving up and going away isn't a good option, or for library crates where I can't assume my caller things it's OK to do so. So all those "infallible" ::new() methods that panic are just an additional failure mode that I'd like my coding style to be able to handle. (And "infallible" should really mean "can't fail due to Rust mechanisms"!)

First step with Rust seems to be local error handling via Result and (with the benefit of this thread) I can see how to use that in lots of cases.

Next step is reclaiming expensive resources even if I'm taking an early exit (which Go's defer statement makes elegantly simple). I see there's a scopeguard crate which looks pretty inviting...

Final step is handling panics and providing defined recovery behavior. This only covers unwinding panics, and there will always be non-unwinding panics as well, so this will forever be a best-effort mechanism.

But that seems a comprehensive and effective coding style to work towards!

This is often done through the Drop trait. Look at the MutexGuard of the standard library or File I believe. Though scopeguard is useful in some cases.

Why would you need scopeguard and what exactly you are trying to do? panic unwinds stack which means that all RAII-guarded resources must be freed automatically.

While this is 100% true, it could use some additional context.

I very rarely use Drop because almost everything I design takes full advantage of the ownership model. Even for a system resource like a DMA controller, the API enforces by contract that only one owner exists (e.g. Option::take() from a singleton when you want to use it) and when it is dropped (automatically, by going out of scope), the resource is freed naturally.

If I forget to return the resource to the singleton, well, that's my problem and the next take() call will panic, but it doesn't lead to resource leaks.

2 Likes

Was really interested about that…

Became confused here. How can you say that you don't use Drop if you explicitly use Drop?

What the first phrase was meant to say?

Yes, most people don't call Drop but expect that it would be called automatically when object goes out of scope and panic! does stack unwind, what's unusual about your use of it?

The Drop trait is not what frees memory.

Memory is just one resource Drop can handle.

In fact affine types were invented to handle things like open files, network sockets and so on.

And in functional languages, not Rust. Rust just adopted Drop to handle memory allocations, too.

I have no idea where you are going with this, or why picking it apart matters. The Drop trait is more or less a "callback" executed when the destructor is run. This trait can do additional cleanup (which is its purpose) but that does not mean you are under any obligation to implement it on your own types. Especially if you have no reason to run custom code in your destructors. Destructors are always run unless you are overriding the default behavior, e.g., with ManuallyDrop.