[pre-rfc] Stable features for breaking changes

I don’t agree. We can change the meaning of a syntax by doing a ‘pivot’ - first we ban it and replace it with a more explicit form (‘dyn Trait’ in this case). This is a mechanical change.

Then at some point after the epoch has started, when most code is moved over, we introduce a new meaning for the syntax.

Even if we didn’t wait, running rustfix would still be a mechanical change (because rustfix knows what epoch you’re in, so it knows what that syntax meant). There’s just a risk if you try to do it manually.

I… wasn’t disputing any of that. My point was that it’s not the sort of thing they tend to do in new versions of the C or C++ standards.

That might be true, but to me the difference isn’t really “what C++ has done” vs “what Python has done” but “will people dread upgrading across this?” If its a purely mechanical change, the answer to me seems to be no.

There are a few things I’d like to have clarified to make sure we’re all on the same page.

1. Will each crate specify its own epoch, or will the root project specify the epoch and its dependencies inherit it?

So far, I’m assuming the former, because the latter would imply that libraries would have to be written in such a way that they are compatible with every epoch that they decide to support. If it’s the former, then…

2. What kind of changes can and cannot be made in a epoch?

or the collorary:

2a. If a library updates its epoch, is it (allowed to be) a breaking change for the library (i.e. does the library need to bump its major version)?

If the answer is yes, then library authors need to be aware of this. Bumping the major version means that applications on an older epoch will be able to continue using the older major version of a library without fearing that cargo update will update the library to a version that is incompatible with the application’s epoch.

I think we already have this issue today with Rust releases: if a library starts using Rust 1.y features without a major version bump, and an application that uses that library is stuck on Rust 1.x, where x < y, then cargo update will update the library to a version that is not compatible with Rust 1.x and it will not compile. If we eventually logic to cargo update to not upgrade a library to a version that requires (based on an attribute added in Cargo.toml, perhaps) a newer version of Rust than the currently installed version, then we could do something similar for epochs to avoid imposing major version bumps on library.

The answer to this question will have an impact on the answer to the next question (specifically scenario 3.2):

3. What is the interaction between crates using different epochs?

There are two scenarios. Let’s suppose application A uses library B.

3.1. Application A uses a newer epoch than library B. 3.2. Application A uses an older epoch than library B.

In scenario 3.1, library B might be using features or syntax that has become deprecated or that changed semantics in the epoch that application A uses. If these changes don’t affect the library’s API (e.g. the proposed change to match), there’s no issue. But if they do, then in order for this scenario to work, there needs to be an alternate feature or syntax that application A can use in order to use library B.

For example, let’s consider the semantic change to &Trait. Suppose that library B defines these traits:

pub trait Foo {
    pub fn foo(self, x: &Bar);
}

pub trait Bar {}

If application A wants to implement Foo for one of its types, it will be able to, but it will have to use the new syntax:

impl Foo for MyFoo {
    pub fn foo(self, x: &dyn Bar);
}

(If it used &Bar, it would fail to compile because the declaration wouldn’t be compatible with the trait’s.)

This might be a problem if library B exports a macro that expands to an impl Foo for $x: presumably, the macro would be written with the old syntax. If you use that macro in application A, which epoch should the expanded code be compiled under? In order to maintain compatibility, we’d have to compile the expanded code with library B’s epoch. I don’t know if that’s easy of even feasible.

Scenario 3.2 is essentially scenario 3.1 backwards, but now instead of worrying about forward compatibility, we’d be worrying about backward compatibility. If new epochs are allowed to introduce features that code using an older epoch cannot consume, then it may force applications to upgrade their epoch in order to keep using the library. Coming back to question 2, if a library updates its epoch and has the consequence of forcing downstream crates to update their epoch, then that’s a breaking change. Now, if the library doesn’t use any of the features that are backwards-incompatible, then it’s not a breaking change. If the library then evolves and adds a new feature that depends on a feature only available in the new epoch, it will force downstream crates to upgrade only if they want to use that feature.

5 Likes

Random thoughts from IRC:

I would like our current epoch, the default epoch for code without epoch markers, to keep updating at a reasonable pace. I propose that every LTS is a good pace; perhaps we switch the epoch over on LTS releases, so that people can use a stable compiler with the latest standard.

1 Like

So Rust doesn't currently have a Long Term Support version planned, and from what I understand of this proposal, epochs are not the same as LTS releases either. Is that correct? Would the rust compiler (or anything in the ecosystem) ever get patches affecting an older epoch? (I would assume yes because bugs happen, but how would this work and be communicated?)

If we're supporting every 1.x version on every epoch on every platform, what does that do to the CI build matrix over time? Or rather, obviously it adds another dimension, but is that sustainable?

Is having an LTS version under consideration currently as well, and if so, how do yinz envision LTS and epochs interacting?

I think LTS and epochs are separate ideas. LTS is about maintaining older version of the compiler; epochs are about making breaking transitions in new compilers without losing compatibility with older code. The LTS compiler doesn’t need to worry about any features/epochs that came after its release, and the new compilers should continue supporting old code regardless of the existence of LTS.

1 Like

Yeah, this says nothing about LTS. I'm still very interested in an LTS release.

So, with the "epochs == flags" idea, older epochs would still be getting patches, that is, there's still only one compiler, and it knows how to build all epochs. So any fixes for stuff not specific to epoch-related flags is gonna get improved on all epochs. Stuff that new flags replace, on the other hand...

I think this is a good, and very important question.

1 Like

https://isocpp.org/files/papers/p0636r0.html

Here’s what’s different from C++14 -> C++17. Note removed or deprecated features, for example.

C++ standard explicitly permits implementations to support removed library features (and language features too in some cases), which the implementations are surely going to do for years. Rust, unlike C++, is an implementation, so the strategy taken by C++ is not of much help to it.

4 Likes

Part of the proposal is that newer Rust compilers would continue to support older epochs, otherwise “epochs” would be no different from a major version bump (it’s even been suggested that Rust would never need a major version bump if epochs happened), so C++'s strategy is very relevant.

There are uses for stable features besides breaking changes too. You might want to disable non-lexical lifetimes for teaching purposes, for example.

Ah, finally found the epochs thread. Overall, I’m very glad this is happening—breaking changes to the surface langauge are inevitable. The use of years not versions strikes me a bit as a marketing gimmick, but I always avoided python and thus don’t have scars from it’s 2 to 3 transition.

One detail, that I assume will be fleshed out but would like to kick off discussing now, is the pipeline of unstable features into epochs. Currently all our unstable features are non-breaking, but with epochs we can start have breaking unstable features. I’m thinking the nightly should have a “next epoch” to queue up breaking unstable features we are considering stabilizing.

Less clear to me is how this works with the “all errors were warnings” rule. Do we need two next epochs? Separate warn and err versions of breaking unstable features?

Put differently, maybe it’s better to keep things simple on stable, but nightly will have to do much of what this RFC proposes either way.

One other question worth bringing up soon-ish is how libraries are supposed to support multiple epochs, if at all. I can’t think of any option besides the simplistic #[cfg(epoch=...)] answer, but we clearly need to decide if this is the sort of code we want people to write when impl Trait finally stabilizes:

trait MyTrait {
    // speaking of marketing gimmicks...
    #![cfg(epoch = "hermit-crab")]
    fn foo() -> MyIterType {}

    #![cfg(epoch = "mantis-shrimp")]
    fn foo() -> impl Iterator {}
}

We should probably make an actual epochs thread at this point.

This seems preferable to the current situation: I released a version of my library that uses features that are only in Rust 1.17, but the errors that people get when they compile my crate with earlier versions are cryptic and it's far from obvious that the solution is to upgrade the rust toolchain.

For this reason, maybe it would be better that this mechanism isn't used just for breaking changes, but is instead made available for all features. That is, when a feature is stabilized, don't remove the #[feature] flag for it.

Ah right. This is the cricuial reason why we need versions not years: it’s important to track a minimum version whether or not that minimum version is a breaking chance from what came before. If we track breaking and non-breaking versions alike, well, we might as well use semver as we do already.

1 Like

As I mentioned in a comment on the cargo schema RFC, I think that epochs and version numbers are two distinct things. That is, you may want to upgrade to a specific rust version number because of a bug fix, or to get access to some library routines. You may do this even though your code is still "old epoch" code.

In other words, it is not just a "marketing gimmick" (as you suggested earlier) to mark an epoch separately from a release number -- there is a real technical distinction being drawn here. The rust compiler is still in the 1.X release because older code works just fine, without changes. If we called the new epoch 2.X, it might lead you to think that you cannot upgrade your compiler to Rust 2.1 just because you haven't converted your library to the new epoch yet (but that would not be true).

(Nonetheless, you could imagine using version numbers for both the rustc release and epochs. For example, iirc, Java's compiler allows you to compiler older code with a --version flag (e.g., if you use enum as an identifier, you would compile with --version 1.4 or something). There would still be two distinct concepts, presumably, but both would be versioned with a semver: the compiler in use and the "compile as of" version. The concern here is that this will give the impression of incompatibility where none exists; I am not sure if this is true or not!)

1 Like

Right I was proposing something like your last paragraph. Certainly compiler version and surface langauge version can be distinguished—I’m arguing in that last post that the language version badly needs the richer compatability partial order that semver offers. (Ironically rustc, if we never break stable CLI or drop support for old versions of the langauge, doesn’t, but semver and the ever-present leading 1 don’t hurt.)

To me, since we already say “Rust 1.x”, not “rustc 1.x” the current version is felt to primarily denote the version of the langauge, and only secondarily the version of the tools. So it seems to me we already have epochs, and a new version should instead be forking off a separate compiler version.

Now, insofar that the compiler version is uninteresting because it induces a trivial compatability partial order, and the teams expressed a preference for fewer versions, one could just not bother with a seperate compiler version—call it “rustc for Rust 1–x.y” or something. Basically this a choice between pruning useless but valid extra information and countering “Rust 2” FUD. I’m happy either way.

1 Like

I’d also like to add to @carols10cents explanations that the epoch-concept might severely hamper the development of competing compilers, as these may have to implement multiple language versions for ecosystem compatibility.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.