Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow requiring "at least one feature" #3347

Closed
Binary file added resources/0000-at-least-one-feature.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
183 changes: 183 additions & 0 deletions text/0000-at-least-one-feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
- Feature Name: `at-least-one-feature`
- Start Date: 2022-11-11
- RFC PR: [rust-lang/rfcs#0000](https://github.com/rust-lang/rfcs/pull/0000)
- Rust Issue: [rust-lang/rust#0000](https://github.com/rust-lang/rust/issues/0000)

# Summary
[summary]: #summary

Allow packages to require that dependencies on them must specify at least one feature (the `default` feature counts).
This avoids backwards compatibility problems with `default-features = false`.

# Motivation
[motivation]: #motivation

A major use-case of Cargo features to take previously mandatory functionality and make it optional.
This is usual done in order to make the code more portable than it was previously, while not breaking existing consumers of the library.
Consider this example which shoes both work works and what doesn't:

1. Library has no features.

2. A `foo` feature is added, gating functionality that already existed.
It is on by default.
`no-default-features = true` can be used in which some dependencies only needed for `foo` can be avoided.
Yay!

3. A `bar` feature is added, gating functionality that already existed.

- Suppose it is off by default.
Oh no!
All Existing use-cases break because the functionality that depends on `bar` goes away.

- Suppose it is on by default, or depended-upon by `bar`.
Oh no!
Existing `no-default-features = true` are now broken!
They want a feature set of `{bar}` which would correspond to the old `{}`, but there is no way to arrange it.

In step two, we could "ret-con" the empty feature set with the `default` feature.
But this is a trick that can only be pulled once.
The second time around, we already have a default feature; we are out of luck.

The previous attempts attempted to make new default features, or migrate the requested feature sets.
But that is complex.
There is exactly a simpler solution: simply require that *some* feature always be depended-upon.

To see why this works, it helps to first see that step 2 was *already* broken.
Here's the thing, even though there previously were not any features, that *doesn't* mean there were not any `no-default-features = true` users!
Sure, it wouldn't do anything for crate with new features, but one can still use it.
Then when just `bar` is added, we already have a problem, because the `default` feature will no "catch" all the existing users ---
the mischievous users that were already using `no-default-features = true` will have their code broken!

This brings us to the heart of the problem.
So long as users are depending on "something", we can be careful to make sure those features keep their meaning.
But when users are depending on nothing at all with `no-default-features = true` and an empty feature set, we have nothing to "hook into".
The simple solution is just to rule out that problem entirely!

# Guide-level explanation
[guide-level-explanation]: #guide-level-explanation

Packages with
```toml
[package]
at-least-one-feature = true
```
are easier to maintain!
You don't need to worry about the `no-default-features = true, features = []` case anymore.
You can be sure that all consumers must dependent on either `default` or a regular named feature.
epage marked this conversation as resolved.
Show resolved Hide resolved

Whenever you want to make existing functionality more conditional, simply extend the set of features the code relies on with a new feature, and ensure existing features depend on that feature.

For example:
```rust
fn my_fun_that_allocates() { .. }

#[cfg(all(feature = "foo", feature = "bar"))]
fn my_weird_fun_that_allocates() { .. }
```
becomes:
```rust
#[cfg(feature = "baz")]
fn my_fun_that_allocates() { .. }

#[cfg(all(feature = "foo", feature = "bar", feature = "baz"))]
fn my_weird_fun_that_allocates() { .. }
```

And the corresponding `Cargo.toml`:
```toml
[features]
default = ["foo"]
bar = ["foo"]
```
becomes:
```toml
[features]
default = ["foo", "baz"]
foo = ["baz"]
bar = ["foo"] # no need to add "baz" because "bar" picks it up
```

# Reference-level explanation
[reference-level-explanation]: #reference-level-explanation

Depending on a
```toml
[package]
at-least-one-feature = true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if this could provide a way for existing crates to move over to the new system without breaking compatibility. Perhaps add a no-feature-selected = "feature" key that allows to specify the single feature that is activated when no others are?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it breaks compatibility. Because the solver knows to reject plans where the no-feature dep is mis-resovled, there are no issues. Not all downstream uses can upgrade, but all that can will.

In general I think it's good to think of version bumps as the residual of thing that the plan solver doesn't already know how to detect. Bumping a version bound is saying "sorry there is a problem I don't know how to directly say what it is, so I am going to insert this barrier instead".

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it breaks compatibility. Because the solver knows to reject plans where the no-feature dep is mis-resovled, there are no issues. Not all downstream uses can upgrade, but all that can will.

I don't exactly understand your language here. Do you mean that cargo will not update a crate's dependency to a version with at-least-one-feature = true, if the depending (not dependent) crate does not specify a feature for the dependency?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly! Thanks for saying that more clearly :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That still sounds like a breaking change upgrade, even if you are protecting people from doing the upgrade.

Copy link
Contributor Author

@Ericson2314 Ericson2314 Nov 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Periodically trying to address concerns in the comments and then opening new threads I think is a good way to keep the conversation manual. Perhaps my "at-least-one-feature = true is not a breaking change" explanation is all wrong, but I think the first-order problem is the RFC originally neglected to discuss the issue at all. Now at is at least discussed, and so problems with that discussion (and I think it's better to poke holes in the RFC version than the version in the comments) are to me follow-up concerns.

All that said, if you still rather continue the conversation OK I will accept it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me, the topic is breaking changes and spreading that out across multiple threads can make that harder to follow.

Copy link
Contributor Author

@Ericson2314 Ericson2314 Nov 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK Then, I'll keep it here.

And I don't think MSRV is an appropriate precedence. That is dealing with the build environment while this is dealing with the API.

I went with MSRV because it is an example of something we used to not have, so it is nice to illustrate the point about "residual" breakage. But it not the only option. (Also once rust-lang/cargo#9930 is added I don't think the "build environment" argument makes sense. Version bounds (only a lower bound because we assume there is no breaking changes in rustc) that effect version solving are bona fide dependencies, are they not?)

Is increasing a dependency bound by a narrow version a breaking change? I think the answer is also "no". Anyone stuck on the old version (private deps make this a bit harder to imagine, but public deps reveal it) will be unable to upgrade, but no breakage will occur.

Moreover, we can call this a breaking change, and that a breaking change, but does anyone benefit from bumping the version number? No. Those that already couldn't upgrade still can't. And those that previously could upgrade now also can't. "breaking" nomenclature aside, the practical difference is simply to rule at more perfectly good build plans.


See what I wrote in #3347 (comment). I think one thing worth taking from @tbu-'s RFC's is basically a one-off migration/ret-conning of default-features = false. Then we side-step is controversy entirely. The restriction in this PR ensures that the "one off" approach is sufficient --- since going forward there won't be new default-features = false users we don't have to worry about that cropping up again.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MSRV is a very different case that, just because we want to improve the experience for it, does not mean we take it as a license to do similar elsewhere.

but no breakage will occur.

does anyone benefit from bumping the version number

I would consider it a breaking change if a crate switched from foo = "1.0" to foo = "1.0;<1.3" which is effectively what we are talking about here.

This doesn't just affect "really old" crates where we can assume no one should upgrade any dependency. People upgrade dependencies piece meal. Some times they have a reason to hold back.

As I pointed out elsewhere, its not just a matter of gracefully being blocked from upgrade this dependency but being blocked from upgrading the dependency anywhere in the dependency tree. I can't depend on an unrelated crate that needs a newer version of the crate that made the switch over.

Incorporating my idea elsewhere to encourage people to use this feature via cargo new defaults and edition defaults at least improves this by moving this away from a "only enabled once someone needs it" to "its enabled defensively".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorporating my idea elsewhere to encourage people to use this feature via cargo new defaults and edition defaults at least improves this by moving this away from a "only enabled once someone needs it" to "its enabled defensively".

Yeah I do like that part, to be clear.

```
crate with an empty feature set is disallowed and invalidates the solution.
The `default` feature counts as a member of that set when `default-features = true`.

The solver shall avoid such solutions (so as not break old versions of libraries without `at-least-one-feature` being "discoverable").
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How? Is it supposed to just pick a feature and enable that?

I am also confused by the paragraph on why adding empty-features = false is not a breaking change, which is probably related to my confusion about this sentence.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just meant if no feature is enable than the plan is invalid, and it must throw it out. (If all plans don't enable any feature for a empty-features = false crate, then no plans are valid)

Is it supposed to just pick a feature and enable that?

Hehe I didn't even think of that. I suppose it is quite easy to take any such invalid plan and make it valid in this way, but yeah, ew.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I get it, so the reason this is semver compatible to add is that cargo will refuse to use the newer version because that would violate this condition -- so it is stuck with the old version?

That makes sense, but could be explained in a bit more detail to make it more clear.


## Provisional Theory

We can begin to formalize library compatibility with something like this
[commutative diagram](https://en.wikipedia.org/wiki/Commutative_diagram):
![commutative diagram](../resources/0000-at-least-one-feature.png)
[(generated from here)](https://q.uiver.app/?q=WzAsNCxbMCwwLCJcXG1hdGhjYWx7UF/PiX0oXFxtYXRocm17RmVhdHVyZXN9X3tcXG1hdGhybXtPbGR9fSkiXSxbMSwwLCJcXG1hdGhjYWx7UF/PiX0oXFxtYXRocm17RmVhdHVyZXN9X3tcXG1hdGhybXtOZXd9fSkiXSxbMCwxLCJcXG1hdGhybXtSdXN0fSJdLFsxLDEsIlxcbWF0aHJte1J1c3R9Il0sWzAsMSwiXFxtYXRocm17c2FtZVxcIG5hbWVzfSIsMCx7InN0eWxlIjp7InRhaWwiOnsibmFtZSI6Imhvb2siLCJzaWRlIjoiYm90dG9tIn19fV0sWzAsMiwiXFwjW1xcbWF0aHR0e2NmZ30oLi4uKV1fXFxtYXRocm17T2xkfSIsMl0sWzEsMywiXFwjW1xcbWF0aHR0e2NmZ30oLi4uKV1fXFxtYXRocm17TmV3fSJdLFszLDIsIlxcbWF0aHJte2BgdXBjYXN0XCJcXCBsaWJyYXJ5XFwgaW50ZXJmYWNlc30iXV0=)

- The old and new features form a partial order

- P_ω takes those partial orders to the partial order of their downsets.
(That is, sets of features with the implied features from feature dependencies "filled in", ordered by inclusion.)

- "same names" maps the old downsets to the new downsets, filling in any newly implied features as needed.

- `#[cfg(...)]` is the mapping of feature sets to exposed library interfaces

- "'upcast' library interfaces" forgets whatever unrelated new stuff was added in the new library version

The idea is that going from the old features directly to the old interfaces, or going the "long way" from old features to new features to new interfaces to old interfaces should yield the same result.

For the more part, features can be "interspersed" anywhere the old feature partial order to make the new feature partial order.
However, this is an exception!
The old empty downset becomes the new empty downset, which means nothing can be added below it.
This is the `default-features = false` gotcha!

When we disallow the empty feature set, we are replacing P_ω with the "free join-semilattice" construction.
We are enriching features with the ∨ binary operator but no ⊥ identity element.
There is no empty downset becomes empty downset constraint, and thus we are free to add new features below all the others all we want.

# Drawbacks
[drawbacks]: #drawbacks

I can't really think of a reason, it's much simpler than the prior attempts!

# Rationale and alternatives
[rationale-and-alternatives]: #rationale-and-alternatives
epage marked this conversation as resolved.
Show resolved Hide resolved

An alternative is not to ban the empty feature set, but ensure it always translates to the empty library.
I.e. to require that *all* items must be dependent upon *some* feature; everything needs a `cfg`.

Returning to our half-worked-out theory, instead of banning a notion of a ⊥ empty feature set that must be preserved from the old library to the new (removing a requirement), we are adding a *new* requirement that the ⊥ feature set must map to the ⊥ Rust interface.
We still have the restriction that new features can be added below, but this restriction is no longer a problem:
there is no point of adding such a new minimal feature because there is nothing left to `cfg`-out!

This solution is more mathematically elegant, but it seems harder to implement.
It is unclear how Cargo could require the Rust code to obey this property without new infra like the portability lint.

# Prior art
[prior-art]: #prior-art

This is a well-known problem.
See just-rejected [#3283](https://github.com/rust-lang/rfcs/pull/3283),
and my previous retracted [#3146](https://github.com/rust-lang/rfcs/pull/3146).

I think this is much simpler than the other two.

Ericson2314 marked this conversation as resolved.
Show resolved Hide resolved
# Unresolved questions
[unresolved-questions]: #unresolved-questions

It would be nice to completely work out the theory.

# Future possibilities
[future-possibilities]: #future-possibilities

The `default-features = false` syntax is clunky.
A new edition could say that an explicit feature list always means `default-features = []`, but that `default` can be used in feature lists.
With this change, "must depend on one feature, including possibly the default feature" becomes easier to explain:

- `[]` disallowed
- `["default"]` allowed
- `["foo"]` allowed