Using ? on Option<T> in method without return type

I want to be able to use the ? operator to return early from a method with no return type, if the Option contains None.

Compare these three:

fn do_if_not_none(maybe_none: Option<u32>) {
    let definitely_u32 = match maybe_none {
        Some(exists) => exists,
        None => return,
    };
    do_things_with_u32(definitely_u32);
}

fn do_if_not_none(maybe_none: Option<u32>) -> Option<u32> {
    do_things_with_u32(maybe_none?);
    None
}

fn do_if_not_none(maybe_none: Option<u32>) {
    do_things_with_u32(maybe_none?);
}

They look decently similar in this highly simplified case but the first two can quickly grow, especially with multiple Option variables.

I understand why this wouldn't be great for Result - error swallowing should be explicit. Is there a reason this can't exist for Option?

3 Likes

You can use an Option<!> return type, that makes it zero-cost.

12 Likes

This will be kinda possible with either try blocks (playground example) or custom Try implementations (playground example)

This could also be written with the new let else expression:

fn do_if_not_none(maybe_none: Option<u32>) {
    let Some(definitely_u32) = maybe_none else { return };
    do_things_with_u32(definitely_u32);
}

(Playground)

Using a try block would be a clear winner here if it did not require a type annotation. This is called out as the last open concern in the tracking issue.

8 Likes

Yeah, sadly the try version right now is

#![feature(type_ascription)]
#![feature(try_blocks)]

fn do_if_not_none(maybe_none: Option<u32>) {
    (try {
        do_things_with_u32(maybe_none?);
    }: Option<_>);
}

which is really not fun.

2 Likes

why not using .map?

fn main(){
    do_if_not_none(Some(2));
    do_if_not_none(Some(3));
    do_if_not_none(None);
}

fn do_if_not_none(maybe_none: Option<u32>) {
    maybe_none.map(do_things_with_u32);
}

fn do_things_with_u32(a: u32) {println!("{}",a)}
7 Likes

I personaly run into this frequently (mainly for parsing) and I use closures for it:

macro_rules! mtry {
    ($e: expr) => {
        (|| -> Option<_> { Some($e) })()
    };
}

I've got a macro for this, and I'm hoping that macros in method position will make it usable as:

do_things_with_u32(maybe_none.opt_try!());
1 Like

TBH, this is my biggest concern with method-position macros. If they take a token tree, rather than a value, it seems rather unclear to me how far back it would go. For things like ? -- that takes the result only, not the expr -- it's fine, but for something like try{} that affects the block as a whole I think prefix is better. (See, for example, why I think async{} is right despite being strongly in camp .await.)

1 Like

I suppose let else will also help make the first version of this more concise.

let-else is still quite a bit more verbose than the ?-based early return. Compare

let Some(foo) = bar else { return };
let foo = bar?;

The former version has 3 extra words and 4 extra glyphs, totalling 22 extra symbols, which add absolutely nothing of value compared to the latter version.

Worse, let-else statements cannot be composed or chained, and need new explicit bindings for each early return. Compare

let matchBody = matchExpr.matchBody else { return };
let arm = matchBody.matchArmList.first() else { return };
let blockExpr = arm.expr.downcast_ref::<RsBlockExpr>() else { return };

with

let blockExpr = matchExpr.matchBody?
    .matchArmList.first()?
    .expr.downcast_ref::<RsBlockExpr>()?;

The code is an example from the Intellij Rust plugin, appropriately translated from Kotlin into Rust. The code happens inside an infallible function, which attempts to perform a transformation on the specific piece of code, but bails out if it doesn't apply in the provided context or if the code is malformed.

There are plenty of such constructs in that codebase. There are many things that one can do with an AST, but we must always be ready that the code will have an error, or that our transformations simply won't be applicable. Some of those functions could possibly return Option<()> instead of (), but for most that would just complicate the API for no good reason, since it's perfectly normal that some inspections or refactorings won't be applicable and there is nothing to do in that case other than skip them.

Some of those checks could be transformed into nested if-let blocks in Rust, but those are very unwieldy. Some of them could be significantly simplified using if-let chains, but certainly not all of them. It's also generally a good practice, from the code readability PoV, to bail early from a function, instead of deeply nesting conditionals. It keeps down both the nesting level and the cognitive complexity of the functions, since one doesn't need to track a stack of nested conditions in the head or wonder whether they have an else-branch somewhere down below.

Personally I would very much want it to be possible to use ? on options inside of functions which return () or bool.

With try this could be written as:

let Some(blockExpr) = try {
    matchExpr.matchBody?
        .matchArmList.first()?
        .expr.downcast_ref::<RsBlockExpr>()?
} else { return };

I don't know how that original kotlin snippet looked like, but if I were to retranslate your rust snippet to kotlin I would write something like:

val blockExpr = matchExpr.matchBody
    ?.matchArmList?.first()
    ?.exp as? RsBlockExpr
    ?: return

Which doesn't look that much better, but also doesn't return from the function at every ?, instead it waits for the entire expression to end, just like with the try block

1 Like

Indeed, try blocks would mostly solve the issue. I'm ok with paying an extra block level for that feature, even though Rust already uses many indentation levels for basic things (an impl with a function, which does match in a loop... oops, 4 indents already and we haven't even started to implement the logic!).

However, try blocks are an indeterminate time away and still have open issues, while an impl Try for () is a thing that could be trivially implemented today.

The original snippet looked as

val body = matchExpr.matchBody ?: return
val arm = body.matchArmList?.firstOrNull() ?: return
val blockExpr = arm?.exp as? RsBlockExpr ?: return

Which does, indeed, look closer to the let-else construct. The main benefits are early returns and avoiding null-coalescing operators for access to non-nullable fields.

Note that this syntax is much lighter than let-else. It's also composable: ?: is an expression which is used all over the place in Kotlin. You can nest it in other expressions, you can use the RHS to return some default value.