[pre-rfc] Stable features for breaking changes

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

This is a different kind of split. The split you are talking about can be resolved by upgrading rust to its newest version (and this continues to be true under the epoch proposal) -- moreover, it's unavoidable. The split that we are trying to avoid is that you cannot upgrade because you have code that relies on some outdated bit of syntax (e.g., how in some cases you might not be able to use Python 3 because libraries that you rely on are still targeting Python 2).

Note that a key part of epochs is that individual crates can be upgraded at will. So while, in the corporate world, you may be stuck with some old crate that cannot (for whatever reason) use newer features, that should not harm other crates.

In any case, I think it's pretty unlikely that having granular opt-in helps with this problem. If you have code that can't be upgraded, that will be because it is being used in some path where an older toolchain is in use -- either for fear of change or for some other reason. That older toolchain will also not be able to cope with new feature gates.

I think the idea would be that we would guarantee that:

If your old code compiles with no deprecation warnings, it will behave the same in the new Epoch (though it may get warnings).

So, just following compilation warnings -- without upgrading the epoch -- should actually be sufficient.

That's part of the beauty of the epoch being defined as a set of flags: you can always stay on the first epoch and only add in the flags you want. Done.

1 Like

I think that the Epoch idea is indeed very similar to what C/C++/Java does. I agree that people do sometimes avoid upgrading there, but I'm not sure if there is anything that could be done to alleviate that. Put another way, even if we didn't adopt any kind of epoch or stable feature proposal, those same people will still avoid upgrading rust, I promise you, because every update -- even those that are supposedly compatible -- introduces a certain measure of risk.

I do think it's worth paying attention to this, but I feel like it's best addressed with a different suite of tools. For example, we've talked a lot about opening up our "crater-like" infrastructure, so that people can use their CI to test with the latest versions of Rust, or to allow us to test PRs in advance on their code. Things like that might go a long way to avoided a fear of surprise interactions.

I think the previous paragraph also applies to the idea of ecosystem splits caused by the use of new features -- that is, it IS an important problem, but probably one that we have to address in other ways.

2 Likes

OK, my blizzard of comments stops with this one, but I just wanted to add: it might be worth linking this proposal to others intended to target "fear of upgrading" in all forms. Unclear.

(e.g., I think there is an interaction with the cargo schema RFC.)

1 Like

I feel like these two statements are at odds with each other. The first part, never dreading to upgrade the compiler, should be true for projects that have laid dormant for months. Imagine building a binary that fulfills its purpose, doesn't have any big crashes, and so you just put the binary in production or wherever and forget about it for a year. Then, you decide you need to add some feature. Should you worry that upgrading the compiler after so long will mean features have been removed? Hopefully not!

Coming from a server development position, I can say that in many places that I've worked at (even at Mozilla, as forward reaching with technology as we are), it's very common to not keep up with the latest version of a language. We actually specifically do not want to run the latest release in production. We want all horrible bugs and vulnerabilities to be patched out of new features before we ship that stuff. So, it's actually quite common for us to upgrade huge versions at a time, often times from one LTS to the next. It'd be sad if someone in our position felt we couldn't upgrade because then we'd need to do all this busywork fixing up our code for the newest compiler.

Calling these Epochs doesn't do much for me. You've just changed the name of the thing we call "versions". Instead of Rust v2.0, we just have Rust v2017. If Rust v2018 has features removed that existed in 2017, it's essentially 2.0 (or 3.0 or whatever) and not backwards-compatible.

JavaScript has seemingly moved away from single digit version numbers to years as well. We had ES5, and ES6, but now we're talking about ES2017 and ES2018. It doesn't really matter. What matters is that JavaScript I write now, in 2017, will still do exactly what I told it to with a browser that implements ES2025. That's backwards compatibility.

I care a lot about backwards compatibility of the libraries I write, perhaps more than many. While I appreciate the desire to get people to use the newer syntax, I'd dislike the compiler actively nagging me to use something that would make hyper no longer compile for users on older compiler versions. In this case, fixing the nagging requires me to completely remove support for the versions of the compiler that don't understand dyn Trait. I think that to make such a change, I'd need a way to tell the compiler "OK sure, on #[cfg(has_dyn_trait)], I'll use dyn Trait, but if not, please just use the older and keep working."

If it were possible for the compiler to process cfg attributes before trying to parse the syntax gated by them, then this scheme could work. I can make use of the rustc_version crate to conditionally make use of new libstd APIs, but not to make use of new syntax features.

4 Likes

There is some talk of how python handled incompatible changes via from __future import ... to allow opting into new features. That feature has been used for more than just the 2->3 transition (by my count, 3 of the 7 __future__ imports are for things that became default before 3.x).

One of the nice things about having granular control (and why a bunch of people were unhappy with the 2->3 transition) is that you can make the fixes for each change independently, and then test and deploy your code with just those changes. One of the big troubles with the 2->3 transition is that you had to fix your code for all of the changes at once.

I think that epochs make sense as well, having an ever-growing feature list doesn’t make sense, so every so often collecting all the stable features and defaulting them to on in a new epoch would be a good thing.

1 Like

One thing to note about the Haskell is that GHC (the de facto standard Haskell dialect’s compiler) has, in addition to adding language-level features behind {#- LANGUAGE -#} pragmas, also made major non-standard breaking changes in Haskell’s standard library in the past; e.g. implementing the [Applicative Monad Proposal][1] and the [Burning Bridges Proposal][2] two years ago (see also [GHC 7.10’s release notes][3]). For the former proposal, the compiler issued warnings for code that would be broken by it starting with [GHC 7.8][4] (which was about a year lead time). For the latter proposal, this wasn’t done, as it landed relatively late in their release cycle. This was not uncontroversial (see some advocacy against immediately including it [here][5]). In the end, they decided against intrucing a {-# LANGUAGE #-} pragma for it. I haven’t used Haskell in a some time and don’t know in which ways this affected their ecosystem, but I think it’s something worth investigating. [1]: https://wiki.haskell.org/Functor-Applicative-Monad_Proposal [2]: https://wiki.haskell.org/Foldable_Traversable_In_Prelude [3]: https://downloads.haskell.org/~ghc/7.10.3/docs/html/users_guide/release-7-10-1.html [4]: https://downloads.haskell.org/~ghc/7.8.4/docs/html/users_guide/release-7-8-1.html [5]: https://ghc.haskell.org/trac/ghc/wiki/BurningBridgesSlowly

It seems to me that this solved by having the ability to #![allow()] the deprecation warning. One shortcoming in our current system (something @wycats has pointed out again and again...) is that you want a more targeted way -- i.e., the ability to allow certain deprecations but not all.

Just to be clear, we are proposing to maintain the same standard of compatibility. From the rest of your message, it sounds as if you might think otherwise, which probably suggests that we need to work on how we explain the proposal!

This is indeed precisely why we pursued the "Epoch" naming instead of saying Rust 2.0. Calling something Rust 2.0 suggests that upgrading to it is a "major version bump" and hence means you may face incompatibility. But the idea is that when you upgrade to the latest Rust release, you are not required to upgrade your code to the latest epoch. The only reason you would ever have to upgrade is to take advantage of new features (e.g., the catch keyword).

In the same way, when you upgrade your browser, you are not limited to nice JavaScript that uses let, modules, and all the latest goodies. You can still run the old stuff.

(Also, just to be clear, even the JS committee makes breaking changes from time to time, but only if they are very confident they can get away with it -- e.g., because different browsers implement different behavior, and hence people are not relying on it. And yes, they test that this is true.)

1 Like

This is an interesting point. I do hope though that we can make the upgrade fully automated. Indeed, in each of the examples that I gave, it would be possible to do a fully automatic -- and 100% semantically faithful -- transition, although it may do things you might not have done had you transitioned by hand.

This is fairly clear for the first few examples. But it's also true for the match ergonomics example. In retrospect I didn't fully appreciate this while writing the post and hence I didn't emphasize it. But naturally if the compiler is unsure whether the destructor should run earlier or later, it could just conservatively insert a mem::drop into the match (i.e., to select the current semantics):

match opt_v {
    Some(v) => {
        println!("{:?}", v);
        mem::drop(v); // forces `v` to be moved, even under the newer proposals
    }
    _ => ... 
}

You could imagine the transition tool leaving behind markers when it makes conservative choices of this kind, that you could go and remove at your leisure. For example maybe it would generate:

match opt_v {
    Some(v) => {
        println!("{:?}", v);

        // rustfix: The following line could be removed,
        // if it is not important for the destructor of `v`
        // to execute early.
        mem::drop(v); 
    }
    _ => ... 
}
1 Like

Being able to put an attribute on a specific expression is probably enough. I suppose some expressions could be exercising multiple deprecated things at once, but if you really wanted to, you could probably separate the expression into multiple.

If that is the case, that sounds good. I feel that part of the error of understanding was on me, as part of my understanding came from noticing other people's pull quotes, and that some of those quotes in isolation made me feel that way. If I'm not the only one that felt this way, then maybe that using a new epoch or feature set is opt-in should be made to standout more, with bold and fireworks.

1 Like

This is a common sentiment, but I don't agree that its true.

First, C++ does make breaking changes & they're not just name clashes. Here's a list of breaking changes in C++11, and its not just the introduction of keywords but many subtle semantic changes.

The issue with Python 3 is, in my perception, not a subtle semantic change, but a blatant one - the core string type just completely changes its semantics in ways that are pervasive and difficult to upgrade across. This is different from match changes, where the breaking change is when exactly destructors run, and we can support a trivial 'explication' which gets you back to the form you had. (You refer to C++ making "a few small and reasonable fixes nobody will ever notice anyway", but that's exactly what the breakages in the match proposal are IMO).

Whatever we call them, I think we can't support making changes as deep as the changes to the string type. But all of the changes we've seriously considered are, in my opinion, in line with the kinds of changes that are made in upgrades to the C standard.

However, our current compatibility guarantee is much firmer than what C++ version upgrades provide; we can't even introduce new non-contextual reserved words. This makes sense - C++ upgrades every 3 years & we upgrade every 6 weeks. But its quite difficult when there's this misalignment in perception, where our 1.X upgrades are treated as equivalent to C++ upgrades, and what we've been talking about as "breaking changes" are treated as equivalent to Python 3, when they're really equivalent to C++11.

I think we need an approach which allows us to make changes of the sort C++ does on the same time frame that C++ does. And, like C++, we need to support compiling code before this switch. I think this epoch proposal is exactly the right approach to solving this problem.

Hopefully, we'll be able to have fewer epochs than C++ has standards because our rolling release model allows us to make non-breaking updates any time, so there isn't this pressure to 'release the new epoch' the way there is in C++.

10 Likes

I also think doing this granularly has significant downsides. Most notably, for every ‘epochal’ feature we support, if they can be mixed and match, we get an expontial growth in ‘rust versions’ that exist, and everyone will be in a different one. While making a granular transition is valuable, I think we can have a more targeted approach to that.

Its important that ‘during an epoch transition,’ the first version of the new epoch is a subset of the last version of the old epoch. That is, there is a subset of Rust that will compile in both epochs (and not a hobbled subset, like “if you never use Strings or integer division”). As we build toward a new epoch, we should release granular lints to help you identify what won’t compile in the next epoch, so that you can perform that granular transition. But these should just be lints, and you should still be ‘in the previous epoch’ until you make a switch, which turns on all of the hard errors across the board.

4 Likes

One way to (somewhat) address the combinatorial explosion is to mix granular features and epochs, and only allow features to be turned on if you are using the epoch where they were introduced.

For example, consider the following sequence of changes.

  • The epoch from the release of 1.0 to now is epoch 2015.
  • Three new incompatible features are released A, B, C.
  • Sometime later this year, epoch 2017 is defined, which makes A and B the default (and not able to be disabled in that epoch).
  • More new features get added: D, E.
  • Next year, epoch 2018 is defined, which makes C and E (in addition to A and B) the default.

My suggestions is that the following would be legal feature combinations:

  • Any combination of A, B and C could be used in epoch 2015.
  • Any combination of C, D and E could be used in epoch 2017 (and A and B would always be enabled).
  • E could optionally be used in epoch 2018 (and all of A-D would always be enabled).

And probably features like C and E that don't get made default in the next epoch shouldn't exist either.

This won't work. You want a crate and all its dependencies to compile with rust 1.x, but the epochs may differ between all crates involved, as long as the API stays compatible. A function fn foo(x: Trait) in Epoch 2 will be fn foo<T: Trait>(x: T) in Epoch 1. These are 100% compatible, but multiple compilers won't produce compatible binary artifacts.

Making the binary artifacts stable is not an option, because we'll want to add new features to MIR or other parts that are exposed. The standard library can remove a function in a new epoch, but it will still need to be available in older epochs. So the binary standard library needs to include everything, even things that have name collisions (resolved by the chosen epoch).

1 Like

I think it is theoretically possible to make something like this work, if we don’t relay on binary artifacts.

The prerequisite is that compiler can upgrade source code from epoch n to epoch n + 1 without a single fail (the code might not be pretty, but it must be equivalent). Then we can compose older compilers to upgrade source code (and source code is stable anyway) to the latest version, and then feed it to the compiler without old code paths :slight_smile: Totally not sure that this is practical :slight_smile:

EDIT: the stdlib problems can be solved by rewriting deprecated usages into equivalent non-deprecated.