Possible overuse of question mark operator?

I enjoy the straight-line happy path that ? offers so much that I tend towards using Result types in possibly procrustean fashion. An example recently was in a cli app, making an Error type for use within one module that included a 'user cancellation' variant. Within the app as a whole, user cancellation isn't considered an error. In fact when returning from the main public function of that module, that particular error variant gets converted to the the app-wide success type that models cancellation (Ok(Runtype::UserCancelled)).

I could rationalise this: what you consider an 'Error' is relative to the context, and I could consider user cancellation an error in this module. But I really don't - I just want to use ? because doing so clarifies the main flow of execution.

If there were something similar to ? that I could impl on an enum, I'd probably do so and get it to early-return on several variants representing different states that are atypical or anomalous, but not truly errors.

None of this matters a jot in the case of a binary crate that probably only I will ever see or use. But if this were a library I was planning to distribute - how problematically unidiomatic does this seem?

3 Likes

Eventually, the ? mark operator will be overloadable for your own custom types, no just Result, so you will be able to avoid the need to call the “short-circuiting / early-return” case “Err”. ( Try in std::ops - Rust )

In the meantime, if using ? works great for your use case, using Result instead, despite the slightly weird naming when you don't really have an “error” in the Err case, is probably the best option.


Edit: Glancing at the currently stable Try implementations, I'm noticing that ControlFlow in std::ops - Rust is another alternative that's available, i. e. should be stably usable and should support the ? operator. So if you happen to prefer the naming of the variants of that enum over Result, it might be an alternative. Of course, it has less API compared to all those convenience functions that Result offers.

8 Likes

Relative to the code it is propagating through, cancellation is very much a sort of error — “the user did not respond such that we are permitted to continue” is not structurally very much different from “file not found, so we cannot load the data”.

1 Like

Ah that looks interesting. The kind of situations I'm thinking of are mostly very shallow functions involving little data manipulation but lots of branching, so lack of the Result combinators is probably fine. I shall have a play with this, thanks.

Arguably yes, that's what I meant by my 'relative to the context' comment. Looking at the code again this morning, it looks more like a legitimate contextual error than it did last night. Though the conversion to a Result::Ok when leaving the module still looks a little odd.

[ edit - the more I worked on that module today, the less odd the Err seemed, and your description as to why is very apposite. The conversion now seems perfectly sensible also. Time & perspective .. ]

In any case I think I have come across other cases where I really am squishing things to fit into an Err just because I want to use ?. It's useful to know that there are possible present and future alternatives.

And that's the main reason it exists at all! @NoraCodes pointed out that try_fold was a poor replacement for Itertools::fold_while since having to use Err for "yup I found what I was looking for" was really awkward for readability. So ControlFlow::Break is thus agnostic to whether the iteration is stopping for a "good"/"bad" reason.

Whether that's what you want here, @closed, I don't know. If you're happy with Err, keep using it! But if you need something else, it's there.

5 Likes

If I may I'd like to question the approach here.

When one wants to do one thing, like read a file, there are two outcomes. Success and you have the files data or failure if something goes wrong. Handily modelled by a Result type with Ok and Err.

However if you want cancellation that is asking for two things, reading the file and reading some user input. Potentially both of these could fail.

In which case the result of calling read_file_cancellable, or whatever function/method, should return a Result of Err if something goes wrong but this time the Ok Result has two possibilities, either the files data or an indication of cancellation. Those two things can be modelled with an enum containing either Data or Cancelled.

I think what I am getting at is that rather mix up file reading and cancellation into some kind of extended Result enum with many variants we nest the actual expected results as an enum inside the Ok of the 'Result`. An extra layer of abstraction to handle the two things one has asked for.

If you see what I mean. Probably haven't expressed that well.

When you argue cancelled is legitimate user input, you also can argue all user input is. So a parse fail could be considered OK as you might be expecting to present a output that assists.

Real world grep implementations differ on exit code. Is not finding what is being looked for an error. Some say the software worked correctly while others give status that there are no match's.

I likely favour the most convenient to write. Stringly typing even can have its place.

Hmmm.... depends. I might argue that there are two distinct user inputs, the actual data expected from the user, or a different "cancel" input. In a GUI they may even be different input gadgets, a text box and a cancel button. "Cancel" is a legitimate user input, Just a different one than the actual data. Even in a TUI which only has keyboard input there are normal character keys and there is "Esc". So called because it is to be considered a different thing than the regular input characters.

In general I'm not at ease with this idea of the "happy" path. The general idea of the happy path is that there is some expected behaviour that one wants to express as clearly as possible, whilst considering errors and other rare happenings as some kind of mess to kept out of sight by sweeping it under the carpet as much as possible. Such "happy path" thinking leads us to exceptions and other such disasters.

Another, perhaps extreme, way of looking at the situation is that an error is just a result, with the same standing as any data that may result. Error responses do happen, that is why we are talking about them. When they happen they are likely very important, that is why we check them. The same important as the data. When they do happen we have to decide what to do about it, ignore it, retry the action, ask the user what to do, halt the machine, reboot, etc, etc.

As such, when putting data and error on the same footing, we might think that anything we do on an error is as significant as anything we do when we get data. What we do on error should not be kicked out of our code, it should be clearly shown, up front, right there. The handling of errors requires as much design effort as the rest of our project.

Perhaps I think like this having worked on safety critical systems so much, where errors are very important and designing around them is a big part of the job. Everything, normal operation and weird behaviour all have to be on the "happy path".

A reasonable approach in the abstract, but it avoids rather than solves the issue in this case which was preferring to use the early-return convenience offered by ?. This of course (a) assumes such convenience is a benefit, and (b) risks shoehorning the problem into the wrong shape.

It seems you're not sold on (a):

In the absence of real data (rare in so-called 'software engineering' which barely exists as a real world profession) this is largely a matter of taste and context. To fill out the latter a little - with this cli there are a few conditions to check before moving on to the operation - cancellation, whether the user has some defaults configured, whether the command has a -q flag (which suppresses interaction), etc. Dealing with all of that inline easily becomes a horrible, nested, order-dependent farrago beyond the capacity of an extremely average (euphemism) programmer like myself to keep organised [1]. There are a number of ways one could rearrange all this to make it clearer, and ? seems useful to me here. Rather than treat errors as a 'mess' it neatly separates their handling into a series of nominal types and their From impls.

[1] What prompted a refactoring was my introducing a bug by swapping two lines ;(

? in other words helps me to write Rust that doesn't look like golang.

On (b) yes it's the doubt I was raising by posting so I do take it seriously. I have convinced myself in this case, in part with @kpreid's prompting, that cancellation-as-Err fits quite well here. Just to fill this out a little - the module performs an operation (adding a bookmark), but actually the function in question doesn't. It builds a struct holding validated information (based on user input, config file, etc). Then a method on that new struct actually adds the bookmark. Cancellation-as-error fits because cancellation prevents the building of the struct. I hadn't pointed that out earlier.

I'm considering cancellation an error within this module, but globally (ie. to the cli) I don't - the module's public method converts the error to an Ok Result which ends up as a 0 exit code. I'm not sure what's the best approach from a unix purist/expert pov here, but to me if the user chooses to cancel, and the cli indeed cancels, that's success. Error to me is anything that turns out counter to the expectations of the programmer (and therefore user, assuming a functional UX).

Might be worth checking what the usual shell conventions are for this. I could also imagine that things like ./a && ./b might be happier if cancelling was an error, and thus the program didn't need to cancel both programs.

Thanks - for a more wholeheartedly unix cli, yes I'd have to bite that bullet. In this case it's a bit moot - it's intended equally for use on other platforms, it has a flag to turn off all interactivity (ie. for scripting), in which case anything that requires a user response causes a nonzero exit, and (the coup de grace!) it probably has 1 user (moi).

You could also use Option<T> which also supports using ? for early returns.

Whenever I find myself in this situation I do go for ControlFlow. I notice I rarely need any of the convenience functions on Result after the stabilization of let else (aside from map_err probably).

2 Likes

Oh yes I do use ? with Option. In this case I also needed Errors though. Sometimes I use Result<Option<T>>> though it didn't quite suit here, and I haven't read enough 'good' Rust code (ie. not mine!) to know whether that's considered decent practice.

ControlFlow is the most useful discovery from this thread for me. I've also made liberal use of let else though sometimes (and particularly with the often conceptually shallow logic involved in user interaction) the number of continue-to-build-state-vs-exit permutations within a function make me want to push some of the logic elsewhere.

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.