(This document was kindly provided by Pierre Avital. Our part 1 design meeting on this was 2024-07-24.)
RFC 3633 seeks to make the core::marker::Freeze
trait available publically available in trait bounds, allowing the static-promotion of references to values containing generics again, which was broken in 1.78
.
As a reminder, Freeze
is an auto-trait implemented by the compiler for all types whose memory representation can't change through an immutable reference; although it may contain references (or other indirections) to memory that could.
The RFC is currently stuck due to the lack of consensus on its remaining unresolved questions:
PhantomData<T>
behave regarding FreezeWhile the renaming question has been the center of debate (which colour would you like your bikeshed?), this document proposes reviewing the other aspects first in the interest of time, and will keep using the current name for simplicity.
For simplicity, this document is written in the voice of the RFC author: instances of "I" or "my" refer to the author's opinions, and not to any already established/growing consensus.
Freeze
being an auto-trait, its public introduction could be viewed as a new Semver hazard, implying that tools should be provided to library authors to guard against their type being "accidentally Freeze
".
A common strategy to do so with other marker traits (such as Send
/Sync
) is to add a !Send
ZST (such as PhantomData<*const ()>
) typed private field to structs to strip them from their Send
implementation even if all of its other fields are Send
.
However, while there are some ZSTs that are !Freeze
(currently, UnsafeCell<()>
or [UnsafeCell<u8>; ()]
), debates exist on whether these ZSTs should stay !Freeze
. This means there isn't any ZST that stably fulfills the need for such a marker field for Freeze
.
This can be solved by the introduction of a PhantomNotFreeze
marker, which would be guaranteed to always be !Freeze
and not to affect the layout of a type containing it.
Another useful addition may be PhantomFreezeIf<T>
: a ZST similar to PhantomData<T>
, but which would only impl Freeze where T: Freeze
; where PhantomData<T>
currently is always Freeze
, which we'll explore in the next section. This would allow types to be conditionally Freeze
depending on their generic parameters.
If PhantomFreezeIf
were introduced, PhantomNotFreeze
may be provided as a type alias of PhantomFreezeIf<UnsafeCell<u8>>
.
My position is that both markers are very valuable: PhantomNotFreeze
as an easy tool for library authors to escape an auto-trait to avoid future breaking changes; PhantomFreezeIf<T>
as a tool to carry over Freeze
ness when T
is carried by a type in an obfuscated manner (for example, if T
was represented by an appropriately laid out byte-array).
This does leave the question of scheduling: does this new marker need to be introduced a version before Freeze
is stabilized, leaving time for library authors to addorn their types with it?
My position is that delaying Freeze
in public bounds is unnecessary, given that the SemVer hazard isn't actually new; it can indeed be triggered by the following code on stable, which can also be argued to be the most likely way in which a type losing its Freeze
bound would affect consuming crates.
// old version of the crate.
mod v1 {
pub struct S(i32);
impl S {
pub const fn new() -> Self { S(42) }
}
}
// new version of the crate, adding interior mutability.
mod v2 {
use std::cell::Cell;
pub struct S(Cell<i32>);
impl S {
pub const fn new() -> Self { S(Cell::new(42)) }
}
}
// Old version: builds
const C1: &v1::S = &v1::S::new();
// New version: does not build
const C2: &v2::S = &v2::S::new();
However, I believe the new marker should be provided in the same release as Freeze
's stabilization. I don't believe new marker ZSTs should have any operational semantics: the exception of "mutation is allowed through shared references" should be kept with UnsafeCell
.
PhantomData<T> where T: !Freeze
stay Freeze
?Currently, Freeze
is an exception with regard to Send
and Sync
in that PhantomData<T>
is always Freeze
regardless of T
. It would be more consistent if PhantomData<T>
required T: Freeze
for itself to be Freeze
.
Earlier in this document, PhantomFreezeIf<T>
is proposed as a way to conditionally mark a type as Freeze
if and only if T: Freeze
: this role could be fulfilled by PhantomData
if its behaviour regarding Freeze
was changed.
Doing so, however, would be a breaking change: a cursory search on github shows a number of projects (admittedly niche ones) use the following pattern when implementing pointer-like types:
struct Pointer<T> {
ptr: OpaquePointerLike, // may be an array index, may be a raw ptr...
_marker: PhantomData<T> // carries the type information
}
The chocolate_milk bootloader is the most starred project I found using the pattern I found through this search.
One could argue that these projects should be using PhantomData<Ptr<T>>
instead, where Ptr<T>
would be *const T
, &'static T
or even Box<T>
depending on what better fulfills their need; but the code pattern does exist in the wild in multiple instances.
I am slightly in favour of switching PhantomData
's behaviour: while the change is technically breaking (the best kind of breaking), code that would rely on static-promoting references to such pointer-types (most of which can't be const-constructed) is likely to be either non-extant, or exhibit nonsensical behaviours under scrutiny.
It stands to reason that nothing would actually break by doing so, and we would avoid introducing a strange twin to PhantomData
, as well as a myriad of internet arguments about whether PhantomData
or PhantomFreezeIf
should be used by users that may not fully understand the nuance between them.
The most bikesheddy aspect of this RFC has been the question of renaming the trait, as it clashes with LLVM's freeze
intrinsic.
I don't believe the clash to be all that relevant: LLVM's freeze
(which is itself jargoney) could become a method of MaybeUninit
, with namespacing providing sufficient distinction between the features. Renaming the trait could also cause confusion when approaching documents that mention Freeze
, as I doubt they'd all get updated; and Freeze
has been a part of the Rust jargon for a few years now.
While debating this, it's been generally agreed that the two properties that a new name should hint at are:
Immutable
from the list of options.Here is a list of propositions collected as exhaustively as possible. My opinions on each will be surrounded by braces to keep a more compact style:
ShallowImmutable
(a popular option, it fulfills both roles; though "shallow" may be confused for a negation of "nested", this ambiguity should, if anything, lead to a higher likelihood of people looking to disambiguate by reading the docs)LocalImmutable
(cut from the same cloth as ShallowImmutable
)InlineImmutable
(again, similar structure)CellFree
(may be a bit too focused on implementation)ValueImmutable
(I dislike that one as it may get mistaken for "this is immutable unless indirected", or even "this value cannot be mutated, ever")DirectImmutable
(like ShallowImmutable
, once more)InteriorImmutable
(while less eggregious than Immutable
, it's the most verbose option yet while not hinting at the restriction at all).An additional argument in favour of Freeze
is the general convention that traits are named after verbs (eg. Clone
and not Cloneable
): conceptually, Freeze
allows the obtention of a "frozen reference" which guarantees that the memory it points to cannot be modified as long as the reference lives, not even through interior mutability:
trait Freeze {
/// Returns a `&frozen` reference to `self`, which is distinct
/// from a common reference in that the bytes from `&self as *const Self`
/// to `(&self as *const Self).add(1)` are guaranteed to stay the
/// same value for the entire lifetime of the frozen reference.
fn freeze(&self) -> &frozen Self;
}
This conceptual method is simply omitted because T: Freeze
already provides the above stated guarantee for &T
, and therefore &T
and &frozen T
are identical for T: Freeze
.
While I still prefer keeping Freeze
to all of those options, on the grounds that it has already settled in the Rust jargon and that the suggested names haven't seemed to spark joy either during debates, I'd emphasize that apart from those I have justified a dislike for, I'd happily support any name that gets Freeze
to finally get stabilized.
Well written doc, congrats to the author. Generally speaking I am in favor of PhantomData<T>
changing behavior to match Send
/Sync
. We have a lot of precedent that "phantom data becomes 'as if' a value of type T
were declared inside the struct" (e.g., it impacts drop rules too) and I'd like to continue that. The breaking change is interesting but I personally would call it a bug fix.
(I can see an argument for the reverse, namely, phantom-data acts "as if you own a value of type T
" but not with respect to layout, and since freeze is 'shallow', you could make a case that T
should be exempted, but I think it overall doesn't feel as nice that way.)
One question that was not discussed which I am interested in is "shallow vs deep". I can certainly see arguments for both of them. It has some bearing on the name, I think.
Pierre: It's not clear how useful deep would be. The value may not change but &self
methods can still do mutation through thread-local data, indexes into hashmaps/vectors, etc.
Josh: This doesn't seem possible to enforce, yeah.
On the topic of semver: This is always tricky. I've come to the conclusion over time that low-level systems programming sometimes requires taking advantage of weird details (e.g., layout) that would be hidden from you in a higher-level language (e.g., Java). I think sometimes we can't do much better than "you shouldn't rely on these properties for types out of your control but we can't stop you" (and there are occasionally valid reasons to do so!). And if you're going to do something hacky like that, it's better to get errors when those properties are violated upstream than not.
I think this doc is great and agree with its conclusions. I'd like to explore the potential semver hazard more. It's hard to say with much confidence that this will or won't become a problem in the ecosystem; I think it's unlikely to become one, but would like to enumerate the use cases people have for using Freeze
bounds and marking a type !Freeze
for future compatibility.
Ah, yes, the evils of promotion strike again.
It already being breaking is a good point that it's de-facto already exposed, so as long as we have the correct semver controls available (I do like the impl ?Freeze
conversations, though dunno if they're blocking) exposing this sounds plausible to me.
Seconding/Nthing that this is very well done and makes our evaluation much easier.
In response to the three questions:
I think we should provide some way to opt out of this. I don't think it's critically important to do so at the same time as the stabilization. (Also, see question below about impl !Freeze
and impl ?Freeze
.)
We should change PhantomData<T>
to only be Freeze
if T: Freeze
, if we can do so non-disruptively. We should check the crates that would be affected by the change, and see if they would actually break or just be able to observe the change.
Freeze
isn't.Verb
(with a Verb::verb
method), but I don't think Freeze
is a self-explanatory verb for that, because I don't think people will think of this as an operation they need to perform; it's something that's already true. and we don't have or need a freeze
method (at least, not one that means this). And I do think there's potential for conflict with the LLVM concept (which we've debated also exposing in Rust). So, I think it's reasonable to ignore the "verb" convention for the name.On each of the questions:
One, we should provide a PhantomNotFreeze
when stabilizing the bound. We should have PhantomNotX
for all of our X
things, as it's better for people to be able to express these directly rather than having to implicitly rely on the behavior of our existing types by writing, e.g. PhantomData<Cell<()>>
or whatnot.
Two, if we can finesse it, I'm in favor of changing PhantomData<T>
to be Freeze
when T
is Freeze
. I'm curious to hear if anyone can find a strong motivation for having the exception here.
Three, I think we should name this Freeze
. At our best, we name traits as verbs. The verb here is an action that the compiler does, as described:
trait Freeze {
/// Returns a `&frozen` reference to `self`, which is distinct
/// from a common reference in that the bytes from `&self as *const Self`
/// to `(&self as *const Self).add(1)` are guaranteed to stay the
/// same value for the entire lifetime of the frozen reference.
fn freeze(&self) -> &frozen Self;
}
My view is that whoever originally named Freeze
probably thought long and hard about it, and that it's tough to do better than this. This concept goes far back in Rust. Maybe not everyone knows what it is, but a lot of people do, and there is some cost to change. We shouldn't do it unless we can do a lot better, and it's not clear to me that we can.
Given that we're going to need things like PhantomNotX
, it'd be nice too if X
were not so long, which Freeze
is not, but many of the alternatives are.
If we ever did want a DeepFreeze
, setting aside the obvious difficulties in actually doing that, I actually think that's a great name for it (though Pure
might be also).
This sounds generally okay to me. Of the suggested alternative namings, DirectImmutable
seems to capture the meaning the best, as it's things that are immutable as long as you don't traverse another point. But, I find the argument that Freeze
is already accepted Rust jargon pretty compelling. As with most bikesheds, I support any name that lets us stabilize this, but my preference would be to keep the status quo with Freeze
.
My initial instinct is that changing PhantomData<T>
to be Freeze
if T
is Freeze
rather than unconditionally seems like a reasonable decision.
tmandry: I'd like to explore the potential semver hazard more. It's hard to say with much confidence that this will or won't become a problem in the ecosystem; I think it's unlikely to become one, but would like to enumerate the use cases people have for using Freeze
bounds and marking a type !Freeze
for future compatibility.
The Motivation section of the RFC is useful here.
Pierre: Given that the point where Freeze
is most likely to be relevant (static promotability) is also already exposing it indirectly, I don't think allowing the bound actually raises a new semver hazard. Most motivations for using Freeze
tend to impose other constraints on the types that need the Freeze
property.
tmandry: Yeah, zerocopy still requires a derive to check that the type has no padding. This is a property that we might want for types to opt in to deriving anyway. Whereas freeze-ness is pretty common and unlikely to change for a type.
tmandry: As we increase the number of autotraits, we increase the risk of odd interactions. If you have really deep types that embed a lot of other types, it could become a problem with more more autotraits. We can see this with Send
in particular being a problem with async
. I don't want to block stabilizing on this, but we should have a lang discussion about new auto traits.
While it's unlikely for an individual type to change freeze-ness, send-ness, etc., types which encapsulate a lot of other types recursively expose themselves to more risk, which is multiplied by each new auto trait.
tmandry: Do people agree with this?
NM: I'm not sure. On the one hand, "yes, duh, risk goes up as you add more things". On the other hand, they're maybe not tied together. The same set of actions tend to create problems for auto traits regardless of what they are: the auto traits of a type do not change independently.
NM: Freeze tells you "the bytes will not change". It's a "physical" property that we're talking about.
(TC: As an aside, that you called it a "physical" property made me smile, in the sense of calling it "freeze", as it's literally a physical property.)
NM: I wonder if we're thinking about semver a bit too narrowly. It seems to me that it should by fiat not be considered a semver-breaking change to flip from freeze to not freeze, which makes me wonder if you should get a lint when you rely on it for external types. Maybe with a way to explicitly impl Freeze and make it a promise.
tmandry: Adding and removing private fields to a type is considered as non-SemVer breaking. So that's an example of where you'd definitely want to opt-in as a trait author.
TC: It's worth noting that adding a private field can already be a breaking change, because you could be adding a field that is not const Destruct
, and that has visible effect.
NM: Should it be an auto trait? Did we explore that?
Pierre: I would be fine by stabilizing an "opt-in" version of Freeze
, a subtrait you could implement for any type that is Freeze
. Arguably still the static promotion leakage of the semver hazard. So essentially Freeze
is not a new auto-trait, it's always been there, but better hidden.
Pierre: We made point earlier that b/c Rust is a system prog lang we tend to expose things that wouldn't be exposed in other languages. I think there's a need for people to use semver better in the sense that because we're a systems language, ABI can become part of your API. Most crates don't want that but there are crates that do. For any networking protocol your layout is part of what can be a breaking change. There are several dimensions to the interface to a crate and that are not reflected for semver at all. I agree that Freeze
should not be considered a semver-major property unless crate opts in. Might be a need to education on stating contracts as being part of your semver and assuming that some contracts shouldn't be part of your semver unless you explicitly said the opposite.
TC: Alternative is that Freeze
be like Copy
, right? Manually implemented but checked by the compiler? Does it work? Is it even possible?
Pierre: Should be easy to have a subtract of Freeze
that you implement specifically.
// Crate A
struct Foo {
}
// Crate B {}
NM: That creates another semver hazard as proposed. Probably for this to work, it'd need to be exactly like Copy
, where it goes all the way down.
NM: What I wonder is that you pointed out that there's a semver hazard around statics and promotion. I wonder what would happen if we change that to stable Freeze
. I.e., you're relying on this thing to be Freeze
, but you haven't promised it.
tmandry: I like your point NM that people are doing system stuff and relying on the physical properties of a type. I think it's reasonable to trust users here. We could always give users a different escape hatch. I really like this idea, and I want to do it for Send
also.
// #[warning_visibility(pub Freeze)]
pub struct HappensToBeFreeze;
// In other crate:
static THING: HappensToBeFreeze = HappensToBeFreeze;
// ^WARN: HappensToBeFreeze does not publicly implement Freeze, and this may change in future versions of the crate.
For Send we could do…
pub async(Send) fn foo() {}
TC: So it'd be like Copy
, but a warning rather than a hard error. It's in the same headspace as #[must_use]
then. Here, it's affecting a kind of "soft visibility".
tmandry: I could imagine something like this:
pub(Send) async fn foo() {}
TC: It's sounding like this is something we could do later, because we'd do this separately and apply it to both Freeze
and Send
(and maybe others).
tmandry: Indeed, probably we could do it later.
PhantomNotFreeze
if we have negative/question trait impls?Josh: If we had impl !Trait for Type
and impl ?Trait for Type
, could we use those (the latter in particular) instead of needing PhantomNotFreeze
? That generally seems like a clearer solution to many uses of PhantomData
today.
Pierre: I'd argue ?Trait
would probably make PhantomNotFreeze
obsolete (if my interpretation of impl ?Trait for Type
as "Type doesn't implement Trait" is correct). !Trait
wouldn't be quite the same, as it would require a major to then state that Type
is Trait
(again, if we have the same interpretation of it).
scottmcm: impl ?Freeze
is "doesn't currently, but without making a semver promise to that effect", as opposed to impl !Freeze
, which is a semver promise to never be Freeze
(and which will likely in future allow where T: !Freeze
in bounds).
NM: I think negative trait impls are unblocked at this time, now that the new trait solver is being used for coherence. The next step is probably an RFC.
TC: This still does not obsolete PhantomNotFreeze
, because the negative trait impls are negative, not ?
.
NM: True.
scottmcm: Inspired by the above question, I wonder if any of the naming could be simplified by flipping it. The "put in readonly memory" check could be done by checking for something not implementing a trait as well, so I wonder about bounding things on where T: !ShallowInteriorMutability
would make it easier to find a good name.
Pierre: I kind of agree, but that does mean barring stable code from using Freeze
until negative bounds become exposed… :(
scottmcm: Maybe some types people know the status of that? I guess it's probably waiting on the new solver? (Agreed it would be unfortunate to block this behind that.) impl<T> TemporaryName for T where T: !EventualName
might be a workaround, but probably wouldn't make types any happier about doing it without a stabilizable implementation.
nikomatsakis: I actually think we could implement this. The new solver is now stable in coherence, I believe the impl is unblocked. I think the next step was "Write an rfc" which was probably waiting on me.
Pierre: The useful properties of Freeze
are on Freeze
itself. If we inverted it, the only places it'd be useful would be when you place a negative bound on it. So it would tie this to negative bounds, which may not be coming soon.
NM: That's a good point, and when I said negative impls are coming, I didn't mean negative bounds.
Pierre: There's a second concern which is about how traits are usually derived. They're usually derived by checking all fields implement the trait. So if we flip it, you'd be having to check for any field that does not.
NM: Yes, you'd be deriving the negative.
tmandry: I'm convinced we should not do the reversed polarity.
TC: +1.
Josh: As an observation, eholk and several others have said (here and elsewhere):
I find the argument that
Freeze
is already accepted Rust jargon pretty compelling.
How did Freeze
become "Rust jargon"? Because it existed as an unstable feature, and acted as its own precedent. While it is certainly the case that we sometimes want to intentionally establish a new term and define the meaning of it, rather than try to use something existing or self-explanatory, I also think this is a good case study in how the specific chosen bikeshed paint-color of an unstable feature can create "facts on the ground" and "momentum" towards whatever path it uses.
tmandry: The fact is there is no better name that we've come up with. At the end of the day, what this means is that it's inherently jargony in a way. So the thing that we've been using as jargon should just be the jargon. If there were a more self-descriptive name for this, then we should use it, but I don't think there is one, at least that we've seen.
NM: I wouldn't weigh heavily the existing use of the jargon.
TC: If we had a much better name, then we should use it. So I don't think the existing use of the jargon is tying us down here, and I wouldn't want to block landing new experiments on this sort of thing. The trouble here is just that it's hard to do better than the original name, and based on how hard that's been, I have to imagine that whoever originally named Freeze
probably spent a lot of time trying to find a good name.
NM: I wouldn't want to tie the change about auto traits to this proposal. I'd be happy to move forward, mentioning that we don't consider this a library/semver change, and we can consider future work around the things we've mentioned.
TC: I propose updating the RFC with all the conclusions you present in this document, remove the open questions, and ping me. Then I will propose FCP merge.
tmandry: Plus document that it's not a semver breaking change to go from Freeze
to !Freeze
unless your type is documented as being Freeze
. And I'd like to add/contribute a "Future work" section about how we can add lints for relying on this and allow types to opt in to guaranteeing it.
NM: In other words, you shouldn't rely on something being Freeze
in another crate unless you know it's not going to change or are willing to pick it up when it does.
Josh: Given the use case of this, of making sure that the data put into const
doesn't have interior mutability, I'm wondering about variations similar to ValueImmutable
or ImmutableValue
that perhaps use a clearer term than Value
. For instance, ImmutableBytes
, or other things that use Bytes
to mean "the bytes of the type" (as opposed to what it might point to or index). (That still doesn't capture the idea of "immutable via a &
, but still mutable if you have &mut
, though.)")
Also, given that there's no way to do direct interior mutability without some kind of Cell
, I do actually think NoCells
or similar is not a bad name. It's jargon, but it's established jargon, and it helps reinforce the concept of what cells are used for.
Josh: No bikeshed argument is worth blocking the RFC over. Let's lay out rough positions and have a time-bounde async discussion on Zulip.
tmandry: I don't hate NoCell
but I'm also perfectly happy with Freeze
.
TC: Let's do a table to see where we are.
"Other" here represents "all other options see to-date".
- | Freeze |
Other | NoCell |
ImmutableBytes |
---|---|---|---|---|
nikomatsakis | +0.5 | -0 | +0 | -1 |
tmandry | +1 | +0.5 | ||
scottmcm | ||||
josh | -0 | +0.5 | +1 | +1 |
TC | +1 | -0.5 | -0.5 | -0.8 |
Pierre | +1 | -0.5 | -0 | +0.5 |
- | - | - | - | - |
Total | +3.5 | -0.5 | +1 | -0.3 |
nikomatsakis thoughts: I like short, memorable names (duh). I agree this name is kind of jargon-y but it still seems better to have a name that is memorable even within a niche. It's a bit confusing whether it's direct or not etc but it seems like even the 'clear' names I've seen wind up being kind of, well, unclear when you think too hard about them.
(Re: NoCell, I don't particularly like names that begin with "no" but also it sounds to me like Mutex
would count for that… it was perhaps unwise to have cell be both the name of a specific type and the general concept…)
NM: After pondering, feeling negative about ImmutableBytes
because it is only true that they are immutable when shared and it just kinda… I don't know. Doesn't sing for me. :) It's long and ponderous.
TC: My strong preference is to lean into naming these traits as verbs where possible, which is what we leaned into e.g. with CoercePointee
(a derive macro in that case), so names in adjective form just point in the wrong direction for me. That said, NoCell
is the best of "other".
Pierre: TC's mention of derive makes me even more in favour of Freeze
, #[derive(NoCell)]
or #[derive(ImmutableBytes)]
doesn't feel quite as good as #[derive(Freeze)]
.
TC: Also, I just can't help but feeling that this is "like Copy
". It's not as important as Copy
; it's not as common as Copy
. That's true. But it still just feels like Copy
, in that it's this very physical property, and so it just seems like the name should rhyme with that.