This is a summary of the thread. The original post is below the line at the end of this summary.
Let's use the following notations:
-
[T].typ
the semantics of the typeT
, also called the safety invariant (see Ralf's blog) -
[T].doc
the documented semantics of a particular occurrence of the typeT
, usually in the context of an unsafe function (Ralf's blog also calls this the safety invariant) -
[T].val
the validity invariant of the typeT
(see Ralf's blog) -
[T].opt
the semantics of the typeT
that the compiler is allowed to use
We have at least the following relations for compiler correctness:
-
[T].typ ⊆ [T].val
(see Ralf's blog) -
[T].doc ⊆ [T].val
(see Ralf's blog) [T].val ⊆ [T].opt
General observations:
-
[T].val
should only grow (otherwise UB may be introduced in existing well-defined programs) thus we should choose it as small as possible (see Ralf's blog) -
[T].opt
should only shrink (otherwise optimization may regress in existing programs)
My claim is that we want [T].doc ⊆ [T].typ
(and implicitly [T].typ = [T].opt
). This can be split in 3 parts:
-
[T].doc ⊆ [T].val
which is already true (see above) -
[T].typ = [T].val
which claims that we don't want to prevent optimizations -
[T].val = [T].opt
which claims that the compiler should be able to use as much information as possible
The counter-argument to [T].typ = [T].val
is that existing code already assumes [T].val
to be bigger than [T].typ
. For example, [&mut T].val
doesn't prevent invariants to refer to the pointed content without going through that pointer, i.e. it is not possible to repurpose the pointed content without calling drop first just based on the validity invariant.
The counter-argument to [T].val = [T].opt
is that we want to keep some wiggle room to adjust [T].val
higher if we realize existing code assumes even higher [T].val
than we were already aware of.
I'd say that both of those observations are the consequence of Hyrum's Law, the lack of a well-defined Rust semantics, and the lack of expressiveness in the type-system. Programmers just used the most simple to use type when writing unsafe, even if it was not a refinement of its semantics. And because the program was compiled to something sensible, they started relying on this behavior.
I'd say that for the types for which [T].typ ≠ [T].val
, we can't do anything. And we might eventually introduce new replacement types highT
and lowT
such that [highT].typ = [highT].val = [T].typ
and [lowT].typ = [lowT].val = [T].val
as well as the appropriate sub-typing rules to be compatible with legacy code. (This might be technically challenging, so might never happen in Rust, even future editions.)
For [T].val = [T].opt
, if we don't need to plan for existing misuse, then we can take this equality for free.
TLDR: Eventually, compilers will use as much type information as possible. Since they don't see documentation, if documentation extends the set of allowed behaviors compared to what the type allows, then the compiler may miscompile by assuming that those additional behaviors can't occur. In other words, documentation is not just for soundness in the source language, because types are used to optimize compilation, so to preserve the behavior from the source to the target language, the program behavior (defined by its documentation, not its type) should always be included in its type, and thus only be a refinement.
Theory
This post is about Rust, but to simplify we will work under some assumptions:
- We assume an arbitrary target language called Target.
- We assume a syntax for Rust. It is a set of constructs. A grammar describes how to combine constructs. Valid combinations form trees (e.g. expressions, types, statements, functions, structs, traits, modules, libraries, and eventually binaries).
- We assume a semantics for Rust. It associates a set of behaviors to syntax trees. Behaviors are deliberately left unspecified. We write
[a]
the semantics ofa
. - We assume a syntax and semantics for Target. We use the same notation for the Target semantics (since we assume the syntax of Rust and Target are distinguishable). We assume Rust and Target behaviors are the same (to avoid working modulo bisimulation).
- We assume a type system for Rust. A type system associates a typing rule for each syntax construct. A typing rule is a set of premises and a conclusion about the language construct of the rule. Premises and conclusions have the form of a judgment
a : A
wherea
is a syntax tree andA
is from the type system syntax (e.g.e : Γ ⊢ τ
). Judgments also have a semantics (e.g.a : A
means[a] ⊆ [A]
). - We assume a particular set of behaviors (e.g. programs that don't exhibit undefined behaviors). And we assume the Rust type system is sound wrt to that set, i.e. the semantics of well-typed programs is included in that particular set (e.g. well-typed programs don't exhibit undefined behaviors).
- We assume a compiler from Rust to Target. A compiler
c
translates a programp
correctly iff[c(p)] ⊆ [p]
i.e. it doesn't introduce new behaviors. A compilerc
is correct if it correctly translates all well-typed programs.
When writing Rust, there are 2 ways to express program behavior:
- Types: The advantage is that the compiler automatically checks them before translating, the programmer doesn't need to worry about proofs. The disadvantage is that only set of behaviors that are the semantics of a type can be expressed.
- Documentation: The advantage is that any set of behaviors may be expressed. The disadvantage is that the compiler doesn't understand them, so the programmer needs to prove them. Note that this is a restrictive meaning of the word documentation, as it only refers to the semantic aspect of documentation (i.e. the aspect that describes program behavior).
We'll consider 3 different semantics for syntax trees. When we have a : A
, i.e. a
of type A
, we'll write:
-
[a].typ
its semantics according to its type, i.e.[A]
-
[a].doc
its semantics according to its documentation (this is usually defined as a diff wrt[a].typ
) -
[a].opt
its semantics as seen by the compiler when translating (in particular when optimizing)
In the absence of documentation (i.e. [a].doc = [a].typ
), we want [a].typ ⊆ [a].opt
to avoid miscompilation due to aggressive optimizations. Similarly, in the presence of documentation, we want [a].doc ⊆ [a].opt
.
I argue that we actually want [a].doc ⊆ [a].typ
i.e. documentation only refines types. The first reason is that compiler correctness in the presence of documentation naturally follows from compiler correctness is the absence of documentation. The second reason is that compilers tend to use as much type information as possible to optimize. This reads as [a].opt
converging towards [a].typ
(being less and less an over-approximation) and thus not leaving much freedom for [a].doc
to extend [a].typ
. In particular, if ever [a].opt = [a].typ
then we actually need [a].doc ⊆ [a].typ
for compiler correctness.
One would then argue that enforcing documentation to only refine types hinders their advantage of expressiveness. But this is why Rust provides lower-level types. Those types extend the semantics of high-level types. For example *mut T
is the low-level type of &mut T
. When one wants to extend the semantics of &mut T
, they actually use *mut T
instead and restrict its semantics.
Practice
&mut T, NonNull<T>, and *mut T
I'm only consider the lifetime and non-null aspects of those 3 types for simplicity. The functions below are the identity function. They just change the type and documentation of their argument.
fn from(reference: &mut T) -> NonNull<T>;
// Here, we forget about the lifetime:
// [reference].typ ⊆ [result].typ
fn as_ptr(self: NonNull<T>) -> *mut T;
// Here, we forget that the pointer is non-null:
// [self].typ ⊆ [result].typ
/// Post: result is non-null.
fn as_ptr(self: NonNull<T>) -> *mut T;
// Here, we preserve the semantics thanks to the documentation:
// [self].typ = [result].doc ⊆ [result].typ
/// Pre: ptr is non-null.
unsafe fn new_unchecked(ptr: *mut T) -> NonNull<T>;
// Here, we preserve the semantics and make it fully apparent in the type:
// [ptr].typ ⊇ [ptr].doc = [result].typ
/// Pre: *self is not aliased for 'a.
unsafe fn as_mut<'a>(self: &mut NonNull<T>) -> &'a mut T;
// Here, we preserve parts (the actual lifetime could be longer) of the semantics and make it fully apparent in the type:
// [self].typ ⊇ [self].doc ≃ [result].typ
In all functions, the documentation is a refinement of the type. In particular we have the following hierarchy: [&mut T] ⊆ [NonNull<T>] ⊆ [*mut T]
. Using refinement we can document a *mut T
to make it look like a NonNull<T>
or even a &mut T
. We didn't do the opposite, i.e. document that a NonNull<T>
can actually be null, or that a &mut T
has a longer lifetime.
Note in the last examples that the unsafe
keyword is needed because the set of behaviors is filtered, and if this filtering is incorrect, the program might be unsound. It is okay (and preferred) to omit the unsafe keyword when only correctness is at stake when the filtering is incorrect.
Pin<&'static mut T> and &'static mut T
The functions below are the identity function. They just change the type and documentation of their argument.
fn static_mut(r: &'static mut T) -> Pin<&'static mut T>;
// Here, we forget that we can repurpose the memory before drop:
// [r].typ ⊆ [result].typ
/// Post: *result is dropped before its memory is repurposed.
unsafe fn get_unchecked_mut(self: Pin<&'static mut T>) -> &'static mut T;
// Here, we preserve the semantics but don't make it apparent in the type.
// [self].typ = [result].doc ⊇ [result].typ
In the second function, the documentation is not a refinement of the type. In particular we have the following hierarchy: [&'static mut T] ⊆ [Pin<&'static mut T>]
. There's a few ways to see why:
- The first function is safe.
- Let's try to define the semantics of
Pin<&mut T>
. We need to introduce a notion of memory and typing environment. So we actually define[Γ ⊢ Pin<&mut T>]
the semantics ofPin<&mut T>
under a given typing environmentΓ
which models among other things the notion of memory. We have that[Γ ⊢ &mut T]
is the subset of pointers of[Γ ⊢ Pin<&mut T>]
whose content are not referred by other invariants inΓ
, such that moving out of them doesn't break invariants that relied on that content. Reciprocally,[Γ ⊢ Pin<&mut T>]
is thus the set of pointers with correct alignment, aliasing, initialization, etc but without any requirement on other parts of the environment mentioning their content. (Note that this means that it should be possible to unpin a pointer given that its content is not referred to anymore, essentially meaning the program behavior doesn't depend on this memory being pinned.) - We can use the fact that if
[τ] ⊆ [σ]
then we could pass aτ
to functions that take aσ
. Let's assume by absurd that[Pin<&mut T>] ⊆ [&mut T]
, then we could pass a pinned pointer tomem::swap
. This is not allowed, so the assumption is indeed absurd. (Note however that it is also possible that[Pin<&mut T>]
and[&mut T]
are not comparable, i.e. neither is a refinement of the other.)
Similarly as before, the unsafe keyword is used on the second function because it filters out the set of behaviors. And doing this wrong has soundness implications. The difference with before, is that it filters out behaviors because of its post-condition instead of its pre-condition. This is a dangerous pattern.
This is probably not problematic currently (and might never be) if there's no optimization pass that rely on &mut T
being movable (that's probably related with the problem of Pin
on #[repr(packed)]
. But that's a risk increasing over time.
Suggestions
- Use
*mut T
instead of&mut T
as the return type ofPin::get_unchecked_mut
? Or possibly another low-level variant of&mut T
. - Document in the compiler that
[&mut T].opt
is actually trying to over-approximate[Pin<&mut T>].typ
and not[&mut T].typ
.