[pre-rfc] Stable features for breaking changes

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