Pre-RFC: Catching Functions

If we want to go for a generic design, such that this mechanism is usable for both Option and Result, just how generic do we actually want it to be? What I mean is that, in it’s current design, the syntax only works for enums with two branches. But if this becomes available as a general mechanism, is it something that we would want to use for enums with three branches?

Or do we by ‘generic’ only mean the things that represent some kind of attempt (so Try basically)?

Since it’s in the category of error-handling convenience, we are only interested in Try.

FWIW this is the same idea I ended up with in one of the previous threads, and I still like it.

4 Likes

Pre-prefatory comments: this is long, and unedited. I don’t have time to edit it down, but it’s rambly and a great deal less coherently argued that I wish it were, because I think this is really important, and I’m sorry that it’s not sharper. I basically just brain-dumped before work this morning!

Prefatory comments

The first thing I’d like to say is: Please do not assume that a bunch of folks with time to comment in this thread this particular week means you have broader consensus. I’m deeply interested in this question, but I’m also particularly busy with other things right now – both professionally and personally.[1] I’m blocking out time I normally allocate to other things to drop in on this discussion now, and I probably won’t have time to come back to it! If I hadn’t happened to peek in on internals.rust-lang.org and caught @manishearth and @withoutboats chatting a bit on Twitter, I’d have had no idea this was an ongoing discussion at all!

The second thing I’d like to say is closely related: some of us have not been able to follow along with all the previous discussion of these ideas. I skimmed the prior RFCs in the last couple days, once I became aware of them, but I do not have the bandwidth to read every RFC out there. And this thread simply assumes the motivation is clear to everyone. But as I’ll note below, the motivation isn’t especially clear to me! So if we could see our way to adding some of that context for other folks just now coming to the discussion, that would be enormously helpful for someone like me!

1. Boilerplate

As far as I can tell, the motivation primarily seems to be interest in a reduction of boilerplate, and in particular for handling of scenarios where it’s felt to be inconvenient.

I’d like to show by example why the proposals raised here don’t actually significantly reduce the boilerplate, though!

Function signatures

Let’s start with function definitions for these, with the status quo at the end:[2]

fn do_a_fallible_thing() -> T throw E {}
fn do_a_fallible_thing() -> T throws E {}
fn do_a_fallible_thing() -> T catch E {}
fn do_a_fallible_thing() -> T catches E {}
fn do_a_fallible_thing() -> Result<T, E> {}

You’re looking at a change that yields at most 3 characters, and it doesn’t come for free. (You could get it further down with fail or error, but the point stands in my view.)

Function bodies

Internally, you get the nice benefits (but also, see below on the costs for reasoning) of not needing to wrap things in Ok() or Err()?. But that seems entirely orthogonal to the idea of adding this special syntax that really doesn’t gain you a lot, but does add complexity and mental budget. When you write -> T throws E, you’re still just returning a Result<T, E>, but now you have to internalize that reality and learn that even though this looks different from -> MyEnumType it isn’t. It’s complexity budget that doesn’t actually help you.

Again, let’s compare the proposal (setting aside bikeshedding) with what you’d write today:

fn can_fail(some_param: Option<String>) -> i32 throws String {
    let almost_done = if do_a_fallible_thing(some_param) {
        0
    } else {
        throw String::from("😱");
    };
    
    almost_done + 3
}

Today:

fn can_fail(some_param: Option<String>) -> Result<i32, String> {
    let almost_done = if do_a_fallible_thing(some_param) {
        0
    } else {
        return Err(String::from("😱"));
    };
    
    Ok(almost_done + 3)
}

Net characters: twelve fewer in the proposed syntax (2 from the function signature change) – a 5% reduction here, but it would be much smaller in most real-world functions, because there’s not much reason to write this kind of thing here as opposed to in a 30-line function instead of a <10-line function.

The main place I can see it being helpful to some degree is if you want to make an existing function fallible, in which case you change -> i32 to -> i32 throws String and add throw String::from("😱") in whatever spot. And that’s not trivial, but it still requires you to go update every place where can_fail is called to use either ? or throw itself.

In sum: In my view, this does not meaningfully reduce the boilerplate at the function level.

2. Teachability/Learnability

So what about the other side of this discussion: how does this work in the process of learning the language?

I believe it is a significant step backward for learnability of the language. (I recognize this opinion is not popular or widely shared in this thread, though a few people have given voice to it.) This is because it substantially increases the complexity of the language in one of its most central areas, and it does so both by special-casing Result (and in principle other Try types) and by adding syntax.

One of the things I’ve found most helpful in teaching Rust to people is that relative to its language complexity, it has a relatively small syntax, and its more complex functionality is nearly always built on simpler functionality. (The emphasized qualifier is important because Rust is by no means a small language! It’s not even a Python, much less a Lisp.) This means that it’s relatively straightforward to develop a clear mental model of the various pieces, and precisely because the more complex pieces are built on top of the less complex pieces.

In the context of error-handling specifically, I’ve been able to explain it extremely effectively both in person and on the podcast[3] by showing the problems with exceptions and the utility of having things like Option and Return be just another enum. The steps are very clear for building a mental model here:

  1. Functions have return types, signified by the thing on the right hand side of ->.
  2. Rust gives you enum types when you need to be able to represent data which represent alternatives, and you can carry data with it.
  3. When you have a function which might involve a failure of some kind, you can simply return a type which encapsulates that as an enum: a type to represent the extremely common alternative of success and failure.[4]

And historically, that was it!

Now, I note that we already have syntactical sugar for handling things that implement Try, and to be clear I’m (largely) on board with ? that way. So today that adds in:

  1. Discuss traits.
  2. Introduce the Try trait and explain that you can use ? as shorthand for early return-or-safe-unwrap for any type which implements it.

That wasn’t so bad, because it was easy to describe: ? (and before that, try!) is just a convenience tool for returning an error early and unwrapping the value – it’s sugar for match.

To that, we would now need to add:

  1. We also have syntax that indicates that a function returns a type which implements Try.
  2. Describe the behavior of the throw keyword in throw $expr position as sugar for return Err($expr), and T throws E in function return position as sugar for Result<T, E>.

The complexity – both syntactical and semantic – is substantially larger just to be able to understand what you’re seeing when you’re reading idiomatic Rust code. It’s fair to assume, I think, that if a proposal like this were accepted, most code in the next few years would look something like this:

fn can_fail(some_param: Option<String>) -> i32 throws String {
    if do_a_fallible_thing(some_param) {
        0
    } else {
        throw String::from("😱");
    }
}
  1. Most starting Rust users will (quite reasonably) thing this means it’s throwing an exception. This means that what-will-become-the-normal-syntax now requires extra explanation: “This may look like exception-throwing in languages like you’re used to, but it’s actually return an enumerated type, usually Result, which you need to handle not by catching it but by.” That’s compared to today where the function body return is just exactly the type it returns.
  2. Again, it’s not actually much of a gain in terms of typed characters!

3. On Ok wrapping in general

I get that some people feel that wrapping final values in Ok or using Err()? feels heavy to people, and it’s especially burdensome-seeming when your wrapped return type is (). So much to I understand this that I went out of my way when implementing Maybe and Result types in TypeScript that I made Ok() sugar for Ok(Unit)/Ok({}).

Insofar as we want to do something here to reduce (at least a perceived) boilerplate-y-ness, the analogy to C# and its handling with Task seems most relevant, not least since we expect to be doing something similar with async. In our own async/await story, though, we’re (delightfully, in my view!) using existing Rust concepts rather than adding totally new syntax, at least if my understanding is right (#[async] and await! are still the order of the day, right?).

4. Summary

In sum, my view of this feature as proposed is:

  • It adds complexity to the language, in a way that’s actively harmful to teaching it.
  • It doesn’t meaningfully reduce the boilerplate/number of characters typed/etc.
  • I’m still not really clear on what the motivation is for not needing to type Ok() or Err() in some spots (granted the qualification that Ok(()) is slightly annoying because OMG SO MANY PARENS).

Footnotes

  1. We’re closing on a newly built house in less than a week, and I’m kicking off the largest and one of the most important projects we’ve ever done with our UIs at work this month. Just a little busy!:leftwards_arrow_with_hook:
  2. Some of the other proposals floated in the thread (->? etc.) increase brevity substantially, but have significant challenges for searchability, etc.:leftwards_arrow_with_hook:
  3. I have consistently gotten extremely positive feedback on that particular episode, especially from programmers with no experience with union types before, e.g. Java or C# programmers.:leftwards_arrow_with_hook:
  4. And the same for Option, of course, though I tend to teach that not just via function return but as a way of modeling data.:leftwards_arrow_with_hook:
47 Likes

Edit: Sorry, I should have read more than the first few posts in the thread first. This has already been suggested.

If we want to add something like this with the bare-minimum of new syntax and concepts added to the language then we could:

  • Make catch wrap its final expression (which it should already IMHO). So catch { 22 } is exactly the same as Ok(22), at least when catching a Result
  • Allow the entire body of a function to be a catch block. So fn foo() -> T { catch { ... }} can be shortened to fn foo() -> T catch { ... }.

These two changes would get us 90% of the way to this RFC with very little conceptual overhead. All the throw stuff looks completely superfluous to me.

6 Likes

I couldn’t agree more with @chriskrycho. I especially dislike the change in the return type. It’s syntactic rat poison rather than syntactic sugar. Declaring that your function returns T whereas in reality it returns a Result<T, E> is just awkward, it looks wrong. It puts mental pressure on the reader of the code, having to JIT-translate the types in their head all the time. (I also agree that it causes syntactic non-uniformity, and although it may already be present in the language, I would still not recommend adding even more of it.)

Also, my experience is that generally those who haven’t written a lot of Rust code are the ones who oppose having to write an explicit Ok, Err, or Some. I remember saying to myself, when I was just about to start learning Rust: “Meh, do I really have to wrap every optional value in Some?” — and then after about 2 or 3 days, this feeling went away completely. Coming from other languages, we don’t initially think in the way Rust expects us to think, and it’s fine — but we should eventually try to adapt. After all, we are now programming in Rust, and not in another language. :slight_smile:

So instead of thinking about “nullables” and “exceptions”, we should just appreciate that Rust’s expressive type system gives us Option and Result, which don’t require any special casing/handling, they’re just regular enum types that can and should be treated in a regular manner. No throwing, no nulling, no bells and whistles — just construct an enum variant as you always would, and the rest is conventions.

And indeed, one of Rust’s main selling points is its simplicity. We shouldn’t add more and more syntax just for the sake of some slight convenience (which also isn’t necessarily considered to be convenient by the entire community, see this very thread). I probably said this already in the thread about translating Rust keywords to non-English languages. Just as I would not like to see non-English keywords in Rust, I would also not welcome non-Rustic concepts, such as “exceptions” sneak into it.

Rust has the unique feature that the original designers and the community managed to leave or weed out almost all bad pieces of language design which are so common in other languages. It’s basically the only systems programming language that I could honestly recommend to be used without subsetting. (No, not even C — while it is probably smaller than Rust, oh boy, does it have some horrible features that one just should avoid completely!) I would very much not like random syntactic and/or semantic additions popping into the language without a good reason — and shaving off 3 bytes (apparently) or making the language look like Java aren’t too good of a reason in my eyes.

18 Likes

Thanks @chriskrycho and @H2CO3, you pretty well communicated my own concerns.

@Centril

I'm sorry if this is the sense that I gave; I didn't intend to dismiss your suggestion or Haskellers at all.

I would throw my hat in with @hanna-kruppe's comment above (except that I would rather pass or wrap instead of Ok-wrapping).

I could get behind this. I too am pretty squarely in the camp of "leave the return value written the way the caller would expect it". I think it's a useful signal to readers (and learners!) that the transformation is "local" to the function; it also makes it clear why Rustdoc should just show Result as the return type. I don't think it's an accident that C# returns Task<int> and not int.

I am certainly mulling over @chriskrycho's post. I think there is a real ergonomic hurdle here, but I agree that if we are not careful, we might introduce learnability / readability hazards.

It seems like spelling out our motivations behind this idea very clearly would be helpful.

9 Likes

While I’m not sure either if we should do it, in the case we decide to do it this looks like a really good way.

For me the motivation to do it is too weak if all we get is no Ok(()) though.

@chriskrycho Thanks for your comment. I think it captures well the viewpoint of many people, which is that this adds some significant complexity that makes Rust harder to learn. My motivation is to make Rust easier to learn (and use in general), so its unsurprising that I don’t agree.

My experience watching new users and teaching them is that a large many of them already think about error handling in a very exception-oriented way. I’ve watched people repeatedly get “expected type Result<T, E>, found type T errors” and pause in confusion before realizing they had forgotten to Ok wrap their return type. This proposal is intended to encode this common intuition about how errors work in a first class syntax.

An analogous piece of syntax is the for loop. An argument of the same form as yours could be made here, that if all people need to learn is the Iterator interface, the language is very simple to explain. But because of for loops they have to learn about how this construct desugars into a while loop calling next, and it has this interaction with IntoIterator and so on. But in practice, I think there is widespread agreement that the for loop is not only very convenient for advanced users, but makes using the language while learning much easier.

I think this goes along the same lines of what I called “the dialectical ratchet” in a previous discussion (n.b. I think probably “dialectical lever” would have been a more correct metaphor). Users, especially with experience in languages that have exceptions (which will be almost every user with previous programming experience), could have a learning experience something like this:

  1. The intuitive: Error-handling in Rust is like exceptions, but I have to write ? on every function call that could throw an exception, so there’s no implicit control flow.
  2. The formal: “Catching functions” return a type called Result, which has two variants. This type can be destructured, and it has many useful method calls. ? is just sugar for match and throw.
  3. The synthesis: Most often, I’m just going to use ? to keep the error throwing up and go on with my business. But I have this toolbox of alternative approaches based on Result’s value-nature for when that isn’t the best choices.

Along the same lines, I think its a serious mistake to substitute “easy to explain how it works” for “easy to understand how to use it.” Taken to the extreme, there are a variety of languages that can be explained in their entirety on a sheet of paper, but which no one would ever use to write serious software because they are too hard to use well. Its certainly possible to introduce complexity that makes the language harder to explain and harder to use, but there are also cases where these are not correlated.

5 Likes

@withoutboats

This is exactly the sort of intuition that I find alarmingly wrong. Exceptions and Result/Try are nothing like each other in compile-time or dynamic behavior. Reinforcing an intuition from another language seems like it would be incredibly confusing in this case and leads to a wrong understanding of code behavior. This seems especially egregious in a systems language, where control of behavior is important.

To quote your "dialectical lever" comment:

First, you learn an intuitive, sugary concept that Does What You Mean in the 95% case. This gets you pretty far, and you are a comfortable early Rust user."

Except that in this case, throw err never does what anyone coming from Java or C++ or Python thinks it does. Nor does declaring fn foo() -> T throws E. Moreover:

Then, you learn a more explicit syntax, which introduces a whole framework for thinking about a problem. This syntax is harder to learn than the DWIM syntax, but can express more things, and gives you a more rigorous framework for thinking about how Rust works.

The "whole framework" in this case I take to be normal return -> R and match/?. However, I feel like these concepts are more primitive and easy to teach than catch/throw as described in this RFC. So the lever is missing a fulcrum.

In general, I'm fine with the dialectical lever idea, but I don't think it works in this case. Rather, I would like to see an explicit move away from the throw and catch keywords in any way that could be confused as exceptions. Rust does not have exceptions. It has a rocking type system. New users need to learn that early so they can take advantage of it IMHO.

15 Likes

I’m afraid the sort of catch currently implemented in Rust nightly is rater unintuitive for a Java developer. try/catch would have been more ok but catch alone? doing what it does?.. throw in place of a return Err(..) feels rather counter-intuitive as well after 14 years of Java day in and day out

The concern is that introducing features like this would force us Java guys fight our hard earned intuitions rather than leverage them… At the same time I wouldn’t mind pass/fail which don’t have this kind of baggage. Learning new is easy. Re-learning is hard.

3 Likes

Forgive me for going “back to basics”, but I still am not totally clear how Ok-wrapping would work. If a returned value doesn’t match the signature, would the compiler try to wrap the value with Ok() if-and-only-if that type checked? But you’d still be free to intermingle return foo and return Ok(foo) ? Or would implicit Ok-wrapper be limited (or even be required?) to functions declared using this proposed catch syntax?

So this is where the fundamental disagreement lies, I think. It seems (if I'm understanding you correctly) that you're wanting to eliminate the bump at the point (which I totally agree exists) by encoding roughly what they think is happening syntactically – they can basically just keep moving until at some later point they need to understand what's going on "under the hood" so to speak (where the hood is the syntactical sugar for the types). In that regard, your analogy to for makes sense, and it's certainly something we do regularly in the language.

I differ because I think it's actively helpful to hit that bump, normally relatively early, and then deeply internalize that errors are just like everything else – and accordingly have all the same machinery and tools (and a few extra niceties, as well!) as everything else. They're not a special case. I don't want to coat over that with sugar, because I think that bump is pedagogically useful! That difference has been an essential part of how I help people understand how Rust solves problems differently from (and, in many ways, better than) what they're used to.

(The existing solution (?) has the advantage of being something that clearly layers on top of those base semantics, rather than being an entirely alternative way of expressing the same concept – especially as regards return type. It analogizes clearly, and accurately, to people's experience with short-circuiting operators in other languages, whether the Elvis operator in C♯ or even further back to the old-fashioned ternary.)

Moreover, I think that syntactically encoding something that looks like what they expect from Python, Java, C♯, etc, but which has profoundly different semantics seems very, very likely to mislead – to get users thinking that errors map to a concept they already know, when in fact they map to something very different. Then you have unlearning to do.

In particular:

In my view, this is an intuitive understanding we should actively discourage, as people's assuming it will fundamentally mislead them. Error-handling in Rust is not like exception-based handling,[1] and we shouldn't encourage the idea that they're similar, even for the sake of smoothing the on-boarding process.

I also think there are really important differences between your example of for loops and Iterator and this proposal.

for loops in other languages are are also often implemented in terms of underlying iterator machinery. Similarly, the for loop in Rust, despite being implemented in terms of Iterator, has very much the same semantics as a for loop in C++ or Java or C♯ or Python. By contrast, the proposed mechanism here has no analogy I'm aware of in another language: the closest is probably Java's checked exceptions, but those certainly aren't return types, and the similarity between those and this proposal is syntactical and superficial. And unlike for, the semantics of throw as proposed here are totally unlike the semantics of throw in other languages – they have radically different implications for both control flow and what the actual value returned will be.

I want to be clear (tone in text being difficult) that despite my strong disagreement about this specific proposal, I'm with you on the ratcheting as a strategy in general, and I think we have very similar goals in the main; this may simply be a matter of preference in pedagogical strategy.

And that brings me to my closing question: are we trying to solve an ergonomics problem, a pedagogical problem, or both? To reiterate @nikomatsakis' comment, I think it's really important to be clear on exactly what the motivation is to get the solution right.

Note: I'll be watching this and may chime in briefly, but I've already spent more time than I could afford on this, so please understand that it's not personal to anyone if I don't respond in here again!


Footnotes

  1. An addendum from my own experience (granting fully that I'm a sample size of one, and I have… my own peculiarities :laughing: … so I won't lean too much on this; thus sticking it in a footnote). The idea that Result is just another type was one of the things that made me fall in love with Rust. It's literally something I had wanted for half a decade or more. I was sick of dealing with exception-based error handling, and had built my own terrible enum- and union-based solutions trying to work around it at times in the past in C; I'd experimented in Python and JavaScript with my own ad hoc class-based, non-exception-using solutions as well. I don't want other people not to get to have the joy of "OH IT'S JUST A VALUE!!!" that I got. :leftwards_arrow_with_hook:
25 Likes

I also want to add, as an elaboration on my agreement about the ratcheting/levering strategy, that even though we disagree on whether or how this proposal (or the others I've skimmed) works pedagogically, I could not agree more strongly with this paragraph. I'd like to staple it to programming language design discussions. :clap: As usual, the trick is finding the right tradeoffs for this language given all the other constraints, but I appreciate—deeply—the way you and the rest of the people in this thread and other RFCs are approaching it.

7 Likes

The first is a case where you'd want to be careful, though I think the fact that you were previously doing such an unidiomatic thing (in Rust) as returning integers to signify errors would be enough signal to you for review, and you don't really need the compiler to nudge you.

The case I was thinking of there was not "previously returning integers to signify errors", but rather, "previously handling errors internally and returning a meaningful default value, and now want to pass errors externally and let the caller do that".

For a concrete example, the function might be "is standard output a device I should write color codes to" (taking various factors and configuration into account), and you might previously have taken the approach of handling an error internally and returning "no, don't". Then you want to factor that into a library function rather than one specific to your program, so you introduce an error type and change that to return errors in those cases.

The third would just be a type error. If you go from Option<T> to T catch E, all your Nones are invalid return types.

Not if you go from Option<T> to Result<Option<T>, E> if Ok-wrapping exists.

In any event these are the sort of changes that would represent a complete refactor of the function, and my belief is that the compiler prompting you with type errors is not actually a necessary code review signal that we need to preserve highly.

Compiler-guided development is one of the things I find most precious and wonderful about Rust.

3 Likes

This is a good observation.

One thought on that: what if we improve the compiler, to recognize the case when you have return t (where t has type T) in a function with return type Result<T, E> or Option<T>, and explicitly tell you in the error message that you might want Ok(t) or Some(t) instead, respectively?

16 Likes

I'm not doing this for the sake of relitigation or repeating something over and over, but because the ? operator is highly relevant to the discussion: catch blocks are from the same RFC as the ? operator, and this proposal is very similar to the ? change as well. Putting past changes into context and reflecting on them is important.

I think this comment was poorly phrased:

By "like exceptions" I meant in terms of control flow: you early return on err with ? and then return the non-error case. It is my experience that the way I solve the "must use" on Result 90+% of the time is to call ? on it and forward it up to some larger error handling mechanism. This is the same control flow pattern encouraged in other languages.

But we put up this barrier right now where instead of just typing ? and getting on with it, you see the happy path returns are wrapped in Ok and you have to know the whole system up front.

I don't think it would be positive for users to think that there is unwinding involved - admitting my biases, my experience outside of Rust has mostly been Python and Ruby & I never had to think about how their raising of exceptions was actually implemented. When I say "like exceptions" I'm thinking about the design pattern that exceptions embody, not the implementation mechanism.

It's clear that other people very closely associate language like throw & catch with unwinding mechanisms. I wouldn't object at all to finding alternative language to throw and catch. What's much more relevant to me here is the encoding of this design pattern into a first class syntax, and I'd like if we could separate that question about the feature in its essence (which @chriskrycho for example mostly focuses on) from the question of whether or not the language should mirror exception-based languages.

I don't have another metaphor aside from throwing and catching but the components are:

  • Return on the happy path (return).
  • Return on the error path (throw).
  • Early return to open a result (?).
  • Close both return paths into a result (catch).

I'd love to see some alternative metaphors that don't also complect the question with the question of whether the happy path should use return (not saying that challenging the use of return for the happy path is invalid, just trying to flesh out the total space of possibilities).

Turning toward the question @chriskrycho raises, I think this is the quote that most succinctly expresses the idea:

A way in which the "dialectical ratchet" post was not well tuned to this discussion is that it discussed leverage which occurred over a long period of time. You can go months without having to really understand unelided lifetimes. I think the wording of that post, applied to my poor use of the phrase "like exceptions", suggests that I would think users would go months without learning about Result.

I'm imagining a much narrower period of leverage; the intuitive state gets you through your first PR, but pretty quickly you learn about the Result type. You maybe don't know what to do with Result for a couple more weeks, and keep to the safe zone of ?, but you gain this insight that this is all passing through a return value early on.

However, importantly, what I'm trying to achieve is that you can get much further in making edits to functions that return Result without fully comprehending how error-handling works in Rust.

Overall, our experience is that Rust is a beautifully designed system into which all pieces fit harmoniously, but that nothing makes sense until you learn everything, making it very hard to figure out how to begin. The goal of introducing these instances of dialectical sugar is to allow you to get started before you have understood the whole system.

I think many of us have had the experience of realizing how beautifully well all of the pieces of Rust fit together, and things like Result are a big part of that, but I think it is not urgent that these realizations precede productivity. In fact, I think it is urgent that they not precede it, that users can get things done even before they have realized that Rust is as good as we all think it is.


I actually agree with this. After all, something very similar to the proposal I'm making (but fancier) was in the original RFC by @glaebhoerl. My view is that we accepted half of a feature, and this proposal is an attempt to actualize ? into a holistic and complete system.

What I think Steve meant is that your post could be interpreted "Well, we shouldn't have accepted ?, so going further on that path would be a mistake." But we did accept ?, so even if it were a mistake that doesn't seem like a good argument to leave it half finished. And in contrast to any suggestion ? was a mistake, I've found it to be a resounding success both in my own code and with other users.

For example, I know of a major open source project adopting Rust which otherwise would want have a very conservative version restriction but has decided they absolutely must have at least the version with ?.

8 Likes