[pre-rfc] Stable features for breaking changes

Seems like the “How we teach this” shouldn’t be snipped here. We’re recommending a way for people to think about the future and backward breaking changes. There’s some socialising/education aspect to this proposal we should explore.

1 Like

This is incorrect usage of cargo features. Cargo features must only be used for additive API additions. They should never change or remove anything. The only practical way to deal with this situation at the moment is to release a new major version of A with the rename.

EDIT: Unless you actually meant that A isn't providing a cargo feature, but rather it is a Rust language feature which ROOT is enabling which affects its dependencies which is typically what cargo features do, which is why I'm so confused. This really needs to get properly fleshed out with an actual concrete implementation idea that makes sense within the cargo ecosystem.

1 Like

This is snipped primarily because I have no idea what challenges would be. There’s a reason I wrote "pre-RFC" and "Note that this design is not as fleshed out as I would like it to be".


This pre-RFC does not deal with the "cargo" features, as explained on the IRC. Thanks for the insight about parallel-ity with the cargo features, though. It may be helpful to flesh out the design further.

Isn't what stable #[feature] opt ins are about that their behaviour doesn't change (being stable)? So shouldn't testing for warts rather happen in the pre-stabilisation phase?

Why not switch all to enabled? I mean, once a feature gets added to the stable language, it should be clear that its presence in Rust is wanted, no? Or should they be used for something else than for breaking changes (like this thread's title suggests).

I think that maintaining support for the old rust would be not beneficial, like the long time that the old python was maintained wasn't beneficial.

It might also make the rustc codebase grow in complexity over time.

About the general idea of the RFC, while I do like it, it can of course be misused. I think before starting to use it, we should gain consensus about how a Rust 2.0 (and succeeding breaking changes) should look like. Should such changes be able to introduce radical new concepts, like rewriting the entire standard library to use named args? Should they be able to remove language features, like the macros 1.0 system? Should they be some kind of bug-fix release with no more radical breaking changes than additions of keywords and making a better entry api for hash maps? Should they be used for something else than to avoid to make new features feel bolted on?

You kind of touch that topic when saying that stable features shouldn't be used light heartedly. But I'd like to have more rules about it, maybe even in a new RFC.

Some thoughts:

  • I think contextual keyword is not the most compelling example: I’d rather prefer union to always be contextual, because I use HashSet::union more often than the untagged union (but the catch problem is indeed more sever).

  • Instead of several #[feature] flags we can have one #[language_version] flag: that way, there won’t be an exponential number of dialects, and the users will be encouraged to actively update their code (“if you want this shiny new feature, please fix all old deprecations”).

  • In the case study section, I think it’s important to mention that although Python is a prime example of bad incompatible changes, in Rust they presumably may be less devastating, because Rust is a compiled language (i.e, you can’t load Python2 and Python3 modules together, but you can link Rust 1.0 and Rust 2.0).

  • I’ve heard that Swift manages to do some breaking changes with the help of automatic code update tool. It should be mentioned in the case study if I’ve heard right.

  • <rant> Upgrade tool is hugely important. Moreover, I think we need a proper library for manipulating Rust AST. libsyntax is to tied to the compiler internals last time I’ve checked (it’s been a while, maybe somethings changed). It’s insane that RLS does not have access to syntaxt data structure (or does it?), and that rustmft is build on top of syntex_syntax https://github.com/rust-lang-nursery/rls/issues/118 </rant> :slight_smile:

  • I think that if we want to do breaking changes in theory, we should not postpone it forever, but to actively experiment with ultra-minor breakages. That is, I’d release Rust 2.0 with just std::thread::scoped removed just to uncover potential problems with larger changes. This RFC is a very good step towards embracing the need for breaking changes, :thumbsup: :thumbsup: :thumbsup:

11 Likes

Thanks, @nagisa, for getting this conversation started! As I mentioned in IRC, the core team has also been thinking some about these questions, in large part because of the interaction with language design questions that are arising with the ergonomics initiative.

I want to make clear that these are early thoughts, and we are not yet to the proposal stage, let alone to any kind of decision. The core team has mostly been working to uncover constraints and otherwise explore the design space around versioning questions.

That said, let me spell out a few key points that have emerged in our discussions.

The primary goal should continue to be stability without stagnation. In detail:

  • “Stability” means you should never dread upgrading the compiler. It means that that code written for different versions of the Rust compiler and standard library should work seamlessly together; there should never be an ecosystem split.

  • “Without stagnation” means that the language can grow and evolve over time, with idioms shifting accordingly. Ideally, it also means that we can produce versions of Rust that, for example, essentially remove (or change) deprecated features.

Of course, there are some obvious tensions here! For one, stability cannot mean that Rust code must forever look and feel the same way; after all, even new features like ? have already shifted idioms, so that try! often looks a bit dated—despite it not being a breaking change.

But sometimes we want to make deeper changes to Rust, which on their face are backwards incompatible, like changing fn foo(Trait) to mean fn foo(impl Trait) instead of a trait object. We can start by deprecating the existing syntax, that’s easy. The harder question is how to do things like “remove” deprecated features, while retaining stability as stated above.

Epochs

The idea we’ve been tossing around in recent core team discussions for threading this needle has two components:

  • The Rust compiler stays forever on the 1.X series. From a semver perspective, the compiler never breaks existing code (module the usual caveats about changes to inference etc. which we’ve covered elsewhere, and hope to separately improve the story for). The basic experience for existing code is much like today, with painless upgrades and ecosystem-wide interoperation.

  • Separately, we introduce the notion of epochs, which correspond to calendar years like Rust 2018 (though not literally every year; see below). Somewhat like proposed in the pre-RFC of the original post, these epochs are basically compiler switches to opt in to a “newer version” of the language—not unlike with C++ compilers today. Crucially, cargo new would set up a project on the latest epoch, so that new code always defaults to the latest version. But you can also “upgrade” the epoch of existing projects if you want to keep up.

For the most part, what changes when moving to a new epoch is that previous deprecation warnings become hard errors, or otherwise change behavior. Thus, for example, we could warn on the current epoch that an identifier in use will become a keyword in the next epoch (and start with a contextual keyword), then make the actual switch in an opt-in fashion in the next epoch.

To put it more sharply, ideally we can satisfy the constraint that, when a new epoch is released, code that compiled without warnings on the previous epoch will compile without errors, and with the same meaning, when opting into the new epoch. That’s a very strong stability story.

Now, an immediate implication of the above is that the compiler, and likely other tooling, must continue to support arbitrarily old versions of Rust. So even if we introduced a new keyword in a new epoch, when compiling under old epochs the keyword would still need to parse as an identifier. That additional maintenance burden is the cost of achieving stability without stagnation.

Epoch releases would not come every year, but rather when enough changes to Rust (including new features) have stabilized that we want to put it all together and declare a… new epoch of Rust! That would likely happen through the roadmap process.

So, if we still have to keep old code paths around, what exactly is the benefit here?

There are two main benefits:

  • While deprecated styles are still permitted on old epochs, newcomers are immediately introduced to the new style, and the documentation can reflect the current epoch only. In this way, we can “clean up” aspects of the language, without breaking code.

  • Grouping changes into epochs gives people an easy way to talk about the chapters in Rust development. If you think about a version of Rust with stable impl Trait, ATCs, changes to match and so on—that feels like a major step from what Rust was at 1.0. Grouping these changes into a single epoch allows us to think about these changes in coherent units, to polish up and “release” a batch of changes to the world, and to lay out a more compelling story about how Rust is evolving.

I believe that the above model would also support changes like introducing dyn and repurposing bare Trait syntax for today’s impl Trait, by doing things gradually:

  • Introduce an stabilize dyn
  • Deprecate bare Trait in favor of dyn
  • In a new epoch, make bare Trait an error
  • Later in that epoch (or a later epoch), parse bare Trait as if it was impl Trait, and deprecate impl Trait.

Relation to this pre-RFC

I think the epoch idea shares many of the goals of the original pre-RFC post, but there are a couple of key differences:

  • Avoiding a major semver bump, emphasizing the fact that all of the changes are opt in (by changing your project’s epoch).

  • Avoiding feature flags on the stable compiler. While that means that certain kinds of changes can only happen in an epoch shift, it simplifies the mental model of what the stable language is at any point. The pre-RFC cites Haskell as an example, but (at least in my experience) feature flags in GHC Haskell do not provide a great experience in practice; most every project ends up with a different set of flags, some of which make substantial changes to the language, making it feel like there are a lot of different languages at play.

Note that opting, on stable Rust, into a future epoch is also not a solution, because over time the changes included in an upcoming epoch may shift—which would then break code on stable Rust.

There’s a lot more detail to cover here, but I’m out of time.

Wrapup

Ok, that’s all I have time to write down just at the moment, but hopefully others from the core team can help add in some of the pieces I didn’t have time to get to. I’m hoping @nikomatsakis, in particular, can talk a little bit about the interaction with possible match changes. This is a great discussion to have now, as I said, because the evolution model will impact how we design features this year.

18 Likes

This sounds very promising and I like the concept of a Rust epoch.

I have a couple of additional points I’d like to raise:

  1. Clearly every project would need to know the epoch it belongs to and so we’d need to add a field to Cargo.toml so that cargo can pass this to rustc. But, should we have an additional finer grained resolution as well? I’m thinking about code snippets in stackoverflow, forums, blogs, etc… We can formalize this with an annotation that the compiler recognizes and so, copy-pasting examples from the web will at least compile (perhaps with warnings). We can keep it informal as it is today where people just mention the compiler version used to compile the code.
  2. Should the epoch version be SemVer or not (e.g. Rust2018). I think it makes sense to decouple the language spec from the compiler version and if we use SemVer we could also be able to mark minor versions (e.g. additions of new backwards-compatible features). For example, we added the question mark operator but it won’t compile on compilers that predate the feature. If the epoch is not semver we’d need to keep two fields, the minimal compiler version that accepts the new syntax and the epoch version. it would be better to just have a Rust version so that other compilers could just support the exact spec version.
1 Like

@aturon That sounds really good!

I just want to note that it seems like an answer to a different (though much more important!) question than the one which sparked this thread, however; questions like “how can we add new keywords (e.g. catch) or otherwise evolve the language?”. Whereas this thread started out asking, “how can we get more testing and evaluation of unstable features, especially minor ones (e.g. loop-break-value) before committing to stabilizing them and setting their behavior in stone?”. It doesn’t seem like epochs have much to offer for this latter question, though maybe I missed something?

(Incidentally, it seems like epochs also offer a really nice and clean answer to what a “semver-major” bump would mean, should we ever want to do one: simply dropping support completely for one or more “really old” epochs.)

2 Likes

It looks like some wires might've gotten crossed? This thread is on a pre-RFC for breaking changes/stable feature flags, not the one about unstable features.

In any case, I'm glad the proposal resonates for you! And I agree about semver-major bumps.

You’re right, sorry! The wires which got crossed were that allowing some form of #[feature] on stable was a proposed answer to, apparently, both questions.

I feel slightly vindicated, as someone who's wanted this for years… For example, if an "epoch" or language versioning mechanism had existed in the past, I think there would've been a strong case to use it for the change to default object bounds shortly after 1.0, rather than a hard break. It's not that breaking changes aren't disruptive even with language versioning, but if you're going to make them anyway, it's much nicer if all existing crates continue to compile.

I agree with @matklad that an automated upgrade tool is essential.

Also, a reminder that a similar approach could theoretically be used for library breakage as well, including (especially) trivial breakage caused by ambiguity after adding new names or impls. The standard library already marks the minimum stable version of all public named things, and the compiler could hide things with a version later than the current target version (or just disfavor them in the case of ambiguity, with a warning). However, this would obviously require more fine-grained versions than yearly epochs and more frequent upgrades, which would be annoying and is a big downside. (It's also tricky to implement for impls, afaik.)

This would be an alternative to the commonly tossed-around suggestion of 'elaborated source', where before submitting a crate to crates.io (or another repository) the compiler could automatically transform the source by filling in types, trait names, and the like, making it truly stable, resilient to the aforementioned trivial breakage. Elaborated source could work, but it has the downside of not working with source control systems or other situations where you only have the original code.

I also think people underestimate the pitfalls it would have. rustc can't type check #[cfg]-ed out blocks, so for elaboration you'd have to compile with every possible outcome of all cfg expressions in the crate, including every supported target OS and every combination of features (though theoretically the compiler could be smart and notice when features are used orthogonally in the code). Even then, there may be permutations of target cfg options that don't currently correspond to any supported OS but might in the future. And there's no rule that a certain bit of code has to have the same types on every target. There are typedefs like c_long which expand to different types on different OSes: for the elaborated source to be portable, the compiler would have to figure out that the 'fully elaborated' form is the typedef rather than the underlying type. That doesn't sound too hard when there are explicit type annotations, but could get weird-to-impossible when type inference gets involved, or when the type determines trait selection…

It's not impossible to deal with all this and make it work in 99% of cases, but you'd have to accept the possibility of spurious failures, and it'd probably be pretty slow. Compared to that, I don't think explicit library versioning is all that unattractive.

Great post!

I think it should point out though that this process should not be abused for pointless churn, like the reverting the meaning of pub proposal.

Also, what about people staying on old epochs. Will they still be able to enjoy new features? If yes, will it be a more limited set of features?

This sounds like the worst of both worlds to me :-/ I'm worried about the perception that we're somehow trying to get around our stability guarantees, and instead of doing something like staying at 1.0 with epochs, either we should keep the old code paths around forever and stay at 1.0, or we should bump the major version. There's already a perception that lingers that we don't really guarantee stability, and I think this proposal would reinforce those perceptions.

I'm unclear on how this is different from what C/C++/Java do, where people do fear upgrading (and stay on, say, C89 forever because of that fear). This proposal sounds a lot like what those languages do, so how do we communicate clearly that this is different? That is, aren't we just moving the problem from "you should never dread upgrading a 1.x version of the compiler" to needing to ensure that "you should never dread upgrading to a new epoch" if we don't want stagnation?

Another thing I'm concerned about is how this plan interacts with cargo and crates.io... how could changes in a new epoch affect those tools? How would we keep them compatible with arbitrarily old versions of Rust?

Perhaps this is one of the things you didn't have time to get into, but I don't quite see how I'd be able to install and use libraries that have opted in to a newer epoch if my project is still targeting an older epoch, even if Rust supports compiling those crates together. If the response is "well this happens today already" (trying to use a crate created with Rust 1.16.0 in a project using Rust 1.0.0 won't necessarily work), then don't we already have a split ecosystem?

For example, how would I implement a trait from a library that is using the newer syntax and semantics around traits? Even if I can compile the crate alongside my project, what I would do currently to learn about a trait the crate provides is read the crate's docs. The crate's docs would be using the newer trait syntax. I would not be able to use the new syntax as a direct reference for implementing the trait in my crate using the old syntax... Would everyone staying with an older epoch have to know how to manually translate the newer epoch's code into the older epoch's?

12 Likes

Are there any fundamental differences between “we release new major versions of the language, but the rustc compiler will also be able to compile all older major versions and to “link” older crates into newer ones” and the epoch proposal?

So, this is a good place to list my thoughts, I think :smile:

I will admit that initially, I was extremely against this proposal. I actually said to @aturon "This is my worst nightmare." However, after working through some details, I'm coming around to the idea. And the reason why is the answer to @matklad's question.

Rust has a reputation of being a language that changes all the time. Yes, it's been two years of stability, but it was a lot more than that that we were breaking all the time, and people outside of Rust-world still have that impression. So, even talking about this topic introduces a degree of risk. I don't like it.

However.

When you actually look at how languages like C, C++, and Java implement their "we don't break ever" policies, it's actually different than that perception. That is, there are small incompatibilities that don't break the world, and even if they are, they offer flags so that you can get the exact semantics you want.

So, @matklad, this is the significant difference to me. This actually can be presented as an improvement of our stability story. Technically, the door for a Rust 2.0 is still open. I'd like to close that. This lets us say definitively: Rust will never have a 2.0. It's compatible with SemVer because your old code keeps on building and working the exact same way it always did. If you don't want to program with the latest epoch, fine. It should still all Just Work. That's a big deal.

On a technical level, I don't think there's a lot of significant difference, but this is just as much, if not more, of a social problem than a technical one.

+1000000

This is forward compatibility, not backward. Which still is a certain kind of problem, but one we already have. If you're on Rust 1.2 and an example uses ?, you still have to know how to use try! instead, and there are still some features you just couldn't use until you upgrade.

3 Likes

Okay, there have been enough of insightful comments that its plausible to begin composing this mega reply :slight_smile:.

I would like to reiterate that this is a pre-RFC (or call-to action) and not an actual RFC. As @aturon said:


@aturon

I think the epoch idea shares many of the goals of the original pre-RFC post, but there are a couple of key differences:

Okay, so to me the epochs idea sounds very similar to the idea of stable features. The major difference, in addition to the differences noted by yourself, between the two is the granularity. Whereas with epochs code’d specify an absolute point in time (or version) of the language the code targets and with the stable features proposal code’d specify the features it uses instead.


@glaebhoerl and @est31

As @aturon already stated testing features more is not a focus of this pre-RFC. It proposes an idea on how to deal with breaking changes in a way that could be done in a compatible way. Once a change is available via stable-#[feature], it retains all stability guarantees the same way regular code does.

I fully understand where such confusion may arise, though, and will blame it on the fact that I wrote the text in a distracting environment in a day and possibly didn't give it enough proofreading.


But we do not need to maintain the old code around either! Instead, it is entirely plausible to just ship multiple binaries/rustcs, and the driver would invoke the pipeline for requested "epoch"/"version"/etc. Sure, this approach would increase size of the binaries, but if balanced well with keeping some old code paths around, it can make the horrors of maintaining old versions a non-problem (i.e. whenever it starts becoming a problem, fork out a new "pipeline").


This is a fair point and I feel like as opposed to the epochs idea, the stable-features idea is better in this respect. IME the worst part of upgrading in C-like scheme is the fact that you have to port the whole code unit at once for all the features (a problem much amplified in rust, because code unit in C is a single file, unlike Rust). It is not enough to just fix compilation errors, because some code that was valid and changed could still be just as valid too! I feel like "epochs" would most likely share the same problem. Stable-features due to their granularity, would enable porting the codebase piecemeal, feature by feature, and likely on-demand.

Obviously making a tool that does it all automatically would make the point moot, but I fear that it might be plain impossible to handle all the cases (as the 2to3 (python) example shows)

1 Like

Right, which was kind of my point. We already have a split ecosystem in terms of forward compatibility, so it's too late to say "there should never be an ecosystem split"; the ecosystem is already split.

If we want to avoid splitting the ecosystem (further), we should make it as easy as possible to upgrade to the latest stable version and do the most we can to help everyone stay current, IMO, which means "never dreading an upgrade" should take priority over enabling a way for people to choose not to upgrade.

6 Likes

I’m not sure about “epochs”. I think I’d prefer more granular feature opt-ins/opt-outs.

In a corporate world you end up with legacy parts of code that will never be upgraded. It happens for various sad reasons, but often when a feature has lost its business value, there’s no budget to even remove it, as it costs developer time, testing time and creates a risk of breaking other things. So keeping unmaintained old code around forever is the default.

I can imagine myself being in a situation where some module of company’s “in-house framework” relies on a deprecated/removed feature, and I won’t be allowed to fix any warnings in that file, because another team is responsible for (not) maintaining it. I wouldn’t want that code to prevent me from using newer Rust features in other parts of the same project.

1 Like

So @aturon did a good job of saying the big points. I just wanted to walk through in a bit more detail what the options are with regard to upgrading. I was going to write this post just about match but in writing it I realized that a lot of the same things apply to many cases, so let me just go over a few examples. They start with the easiest and get progressively harder.

How would we introduce a new keyword like catch?

Leaving aside whether you like this keyword, one can assume we will sometimes want to introduce new keywords. Ultimately, this is the most straightforward case, though there are some subtle bits. In its simplest form, we can deprecate using catch as an identifier. At the change of epoch, we are then free to re-use that identifier as a keyword. By and large, this transition can be automated. Some form of rustfix can rename local variables etc to catch_ or whatever we choose.

Where things get a bit tricky is are things like bits of public API. In that case, there could be Epoch 1 crates that (e.g.) have a public type named catch that is not typeable in Epoch 2. This could be circumvented by introducing some form of “escape” that allows identifiers with more creative names (e.g., I think that scala uses backticks for this purpose).

So the overall flow here:

  • In Epoch 1:
    • we introduce a new feature for “escaped” usage
    • we deprecate catch used as an identifier (or type-name, whatever)
      • and supply a rustfix tool that renames local variables, suing the “escape” form for public APIs
  • In Epoch 2:
    • we repurpose the keyword

How would we transition meaning of bare trait?

Let’s suppose for a second that we want to repurpose “bare trait”, so that fn foo(Iterator) means fn foo(impl Iterator). Let’s leave aside for a second if this is a good idea (I think it’s unclear, though I lean yes), and just think about how we might achieve it without breaking compatibility.

To make this transition, we would do it as follows:

  • In Epoch 1, we deprecate Iterator as a type and suggest people migrate to dyn Iterator.
  • In Epoch 2, we can then change the meaning of Iterator to impl Iterator.

This all makes sense, but it does raise a question: what do we do with impl Iterator? If we stabilize that syntax in Epoch 1, then perhaps we will deprecate it in Epoch 2 and suggest people remove the (no longer needed) impl keyword. An advantage of this is that (a) people can use impl Trait sooner, which we obviously want and (b) some of those uses of Iterator as an object may well be better expressed with impl Iterator, and we can enable that.

The key components here:

  • We issued deprecation warnings for existing code in earlier epoch:
    • Whenever you issue a deprecation, we need to provide people with a way to fix the deprecation explicitly.
      • In this case, by adopting dyn Trait (or, perhaps, impl Trait).
    • We can readily automate the fix for these deprecations via some rustfix tool.
  • That deprecated code becomes illegal in the new epoch, so its meaning can change.
    • Interestingly, the explicit impl Trait form presumably also becomes deprecated.
    • So we would want to automate the fix for that too – but unlike before, these changes can’t be applied until the new epoch.

match

The idea of the match ergonomics RFC is basically to make it unnecessary to say ref x – instead, when you have a binding x in a match, we look at how x is used to decide if it is a reference or a move (much as we do with closure upvars). Again, leaving aside the desirability of this change, can we make this transition?

Changes to execution order. This change can have a subtle effect on execution order in some cases. Consider this example:

{
    let f = Some(format!("something"));
    match f {
         Some(v) => println!("f={:?}", v),
         None => { }
    }
    println!("hello");
}

Today, that string stored in f will be dropped as we exit the match (i.e., before we print hello). If we adopted the Match Ergonomics RFC, then the string will be dropped at the end of the block (i.e., after we print hello).

The reason is because binding to v today always trigger a move, but under that RFC v would be a move only if it had to be based on how it was used, and in this case there is no need to move (a ref suffices). (This is much like how closure upvar inference works.)

So clearly there is some change to semantics here. That is, the same code compiles in both versions, but it does something different. In this example, I would argue, the change is irrelevant and unlikely to be something you would even notice (my intuition here is that it is rare that dropping one variable has side effects relative to the rest of execution, and rarer still that someone was using a match to trigger an otherwise unnecessary drop). But you can craft examples where the change is significant (e.g., the value being dropped has a custom drop with observable side-effects, and it is important that those side-effects occur before hello is printed).

What makes this change tricky. A couple of things make this change tricky:

  • Hard to have a targeted deprecation
  • No clear canonical form in some cases

Let’s review those. Clearly, we can issue warnings for code whose semantics may change. But it’s hard to target those deprecations narrowly. Ideally, we’d only issue a warning if all three of these conditions hold:

  • There is a binding that, in Epoch 1, is a move, and in Epoch 2, is a reference.
  • The value in that binding has a Drop impl
  • Executing that Drop impl at a different time matters to the code

That last part cannot be fully detected. We can probably use a variety of heuristics to remove a bunch of false positives. But, if we are correct that this change in order will almost never matter, almost everything we do report will be wrong, which is annoying. And it’s a subtle problem to explain to the user in the first place.

The other problem is that there is no clear canonical form that we can encourage users to migrate to. In other words, suppose I get a deprecation warning, and I understand what it means. How will I modify my code to silence the warning? The Ideally, we’d have a way that is better than just adding a #[allow] directive.

There are really two cases. Either I want to preserve the existing execution order, or I don’t care. We believe the first one will be rare, but unfortunately it’s the easy case to fix. Once can force an early drop by adding a call to mem::drop(). As a bonus, your code becomes clearer:

{
    let f = Some(format!("something"));
    match f {
         Some(v) => {
             println!("f={:?}", v);
             mem::drop(v); // <-- added to silence warning
         }
         None => { }
    }
    println!("hello");
}

But if we don’t care about the drop, what should we do? Probably the best choice is to encourage people to change v into a ref binding – but of course that’s precisely the form that we aim to remove in Epoch 2! (This has some interesting parallels with impl Trait I think, where we might be encouraging people to use impl Trait, even though we aim to deprecate it.)

The other option, of course, would be to have some form of “opt-in” to the new semantics in Epoch 1 (e.g., something like the stable feature gates proposed here). That has the same set of previously discussed advantages/disadvantages (e.g., it muddies the water about what code means and this option is used frequently it raises the specter of there being many Rust dialects, rather than just Rust code from distinct eras).

Conclusion

Sorry this is long. I wanted to really work through all these issues, but for myself and for the record. I guess that the TL;DR is roughly this. First, we assume that we’re trying to repurpose some syntax in some way (as in both of these cases). This will generally be true, because if that is not happening, then there is no need to use an Epoch, we can just deprecate the old pattern and encourage the new pattern (e.g., try! into ?). In that case, the transition has the following form:

  • Some kind of deprecation in Epoch 1:
    • As targeted as you can make it, ideally with an automated tool that will make changes that preserve semantics.
    • Need to ensure that people can migrate to some new, preferred syntax:
      • catch keyword: new identifer or escaped form
      • bare trait: impl Trait
      • ergonomic match: mem::drop(x) or ref x
  • Deprecated code becomes illegal in Epoch 2, freeing up the existing syntax for a new purpose.
    • Sometimes, the new, preferred syntax from Epoch 1 becomes deprecated.
      • e.g., impl Trait or ref x
      • this transition can again be automated
      • perhaps this syntax is removed in the next Epoch (if ever)

I think the key questions to ask of any such transition:

  • To what extent can it be automated?
  • How targeted are the deprecations?

UPDATE: I realized that we can fully, but conservatively, automate the transition for match ergonomics. This may want to be a hard rule.

3 Likes

I think you are correct, there isn't a big technical difference, but the idea of moving things forward in a set feels important to me. First, because I think that it's helpful to think of changes together, but also because I don't want to wind up with people 'picking and choosing' which changes to apply (i.e., I don't want you to have to think "ah, this code has ergonomic match, but it doesn't have bare trait"). It just seems like it'll make everything more fragmented and confusing.

2 Likes