Moving mutability into the type
28 May 2012
I am dissatisfied with how mutability is treated in the Rust type
system. The current system is that a type is not prefixed mutable;
rather, lvalues are. That is, a type T
is defined like so:
T = [M T]
| @ M T
| & M T
| { (M f : T)* }
| int
| uint
| ...
M = mut | const | (imm)
Note that there is no type mut int
(a mutable integer). This is
logical enough; such a type has little inherent meaning: an integer is
a value, it is not mutable or immutable.
Mutability only appears whenever there is an lvalue: that is, a
location in memory that might potentially be overwritten. So you have
a type like @int
, which means a “pointer to immutable,
garbage-collected memory that contains an integer” (immutable is the
default). Similarly, @mut int
is a pointer to mutable, gc’d memory
that contains an integer.
This system is logical but it has several shortcomings in practice. I think I have a proposal that addresses them in a reasonable way. I’ll first go over the shortcomings I hope to address and then discuss my solution.
Shortcoming #1: Parameterizing over mutability is not possible
Because mutability is not part of the type, if I write a generic function like this:
fn each<A>(v: [A], f: fn(&A) -> bool) { ... }
I am in fact defining a function that allows iteration only over
immutable arrays. The type [A]
basically means “an array of
immutable instances of the type A
”. But this is probably not what I
want; I would like to allow iteration over any array, regardless of
whether it is mutable or not.
Of course I could write two functions (each
and each_mut
). But I
could also use the const
type. The const
qualifier, borrowed from
C++, effectively means “read-only”: that is, it may be used with
either mutable or immutable memory. So, to be safe, you can only use
a const
pointer for reading, but you can never rule out the
possibility that the memory that is pointed at might be modified by
somebody else.
const
types sound good but in practice they are insufficient. They
are particularly insufficient when we start combining vectors with
references. To see why, let’s look at how one might implement the
each()
function with const
:
fn each<A>(v: [const A], f: fn(&A) -> bool) {
let l = len(v);
let mut i = 0u;
while i < l {
if !f(&v[i]) { ret; }
i += 1u;
}
}
This function invokes the function f()
with a reference to each
element of the vector in turn. We use references to elements (that
is, the type &A
) rather than passing the element itself because it
avoids the need to copy the element; in a language like Java, where
everything is passed by pointer and garbage collection is ubiquitous,
copies are no problem, but in Rust one tries to avoid
them—particularly when operating over generic types, where the cost
of the copy is unknown (copying a unique pointer, for example, could
involve an deep clone and hence an arbitrary number of allocations).
This function looks simple, but it would be in fact be rejected by the
Rust type checker. The reason is that the type &A
in fact indicates
that the memory is immutable: but we don’t know that, we only know
that it is const
. To be correct, we have to write the following:
fn each<A>(v: [const A], f: fn(&const A) -> bool) {
// the rest is the same, basically
}
Note that the function f()
now expects a &const A
pointer: that
is, a pointer to const
memory. Now, this might not seem like a big
problem, but in fact a &const A
pointer is significantly less useful
than an &A
pointer.
This is because there are many operations that are only legal on immutable
memory. For example, if we have an &option<int>
, we can write code
like:
let x: &option<int> = ...;
alt *x {
none { ... }
some(y) { ... }
}
This is not especially safe with a const
pointer. The reason is
that the alt
construct in Rust does not copy data out of x
, as it
would in ML, but rather y
is a pointer directly into the interior of
x
. This is very efficient, but for a const
or mut
pointer it is
unsafe, because someone might overwrite *x
with none
, and then
this pointer is invalid. So, to alt
over mutable memory you need
to use copies:
let x: &const option<int> = ...;
alt copy *x {
none { ... }
some(y) { ... }
}
Similar concerns apply to unique pointers, which are freed as soon as they are overwritten.
So, what we would like, is a way to write a function that uses an immutable reference for an immutable vector, a mutable reference for a mutable vector, a const reference for a const vector.
Shortcoming #2: There is no way to convert mutability
One of Rust’s more powerful features is unique pointers. Unique
pointers are useful for sending things betwen tasks, but there is more
we could do with them. For example, if you have a ~mut int
, there
is no reason it could not be converted to a ~int
: after all, there
is only pointer to the memory in question, so we can change whether
the memory is mutable or not at will. We already use this for
vectors, which are currently unique, in that we have vec::from_mut()
and vec::to_mut()
. But we could make this a more general
transformation.
For example, the following program ought to be legal:
type rec = {mut f: int, mut g: int};
type rec_frozen = {f: int, g: int};
fn freeze(r: ~rec) -> ~rec_frozen { r }
In fact, this program could be legal even without the unique pointer, if the record is passed by value:
fn freeze(r: rec) -> rec_frozen { r }
Proposal
I think we ought to move the mutability qualifiers away from lvalues and into the types. This means that internally the structure of our types would be like this:
T = Q U
Q = mut | const | imm
U = [T]
| @T
| &T
| { (f : T)* }
| X
| int
| uint
| ...
Here, every type T
consists of one or more mutability qualifiers Q
and an unqualified type U
. Although qualifiers are made mandatory,
in the syntax that users enter, an unqualified type without a
qualifier would of course be shorthand for an immutable version, as
today.
Assigning vs subtyping
One of the tricky aspects of mutability/immutability is that clearly
there is no subtyping relation between mut int
and imm int
. And
yet you would like code like the following to work:
let vec: [mut int] = [mut 1, 2, 3];
let t: int = vec[0];
In the new scheme, after all, the type of vec[0]
is mut int
, but
the type of the variable t
is int
. So why is this assignment
legal?
The reason is that we distinguish between two types being assignable
and subtyping. This is already necessary for handling region
pointers. Basically, assignability would ignore interior and unique
mutability. This means that mut int
is assignable to int
and
vice-versa. But it also means that {f: mut int, g: mut int}
is
assignable to to {f: int, g: int}
and ~[mut int]
is assignable to
~[int]
(and vice versa). @mut int
would not, however, be
assignable to @int
. This addresses shortcoming #2.
Generic operations
Under this scheme, generic operations like those for vectors “just work”:
fn each<A>(v: [A], f: fn(&A) -> bool) {
let l = len(v);
let mut i = 0u;
while i < l {
if !f(&v[i]) { ret; }
i += 1u;
}
}
The mutability is no longer part of the vector but rather part of the
generic type A
, so this same routine works for mutable, const, or
immutable vectors.
Inference
The one tricky part to this scheme is inference. Consider code like the following:
fn deref(x: [mut int]) -> int {
let y = x[0];
ret y;
}
What type does y
have? Today it would have the type int
, and this
is what we would like it to have; but if we are not careful, it will
end up the type mut int
. After all, that is the type of the rvalue,
and the type of a variable is generally unified with the type of its
initializer. Basically, the assignability checks and unification
don’t mix so well, because they require knowing the type of the rvalue
being assigned and the type of the lvalue being assigned to, and one
or both of those may be unknown.
I think we can solve this in a reasonable, best-effort way, much like
we approach inference in the face of subtyping. We define the check
to operate over unqualified types U
. That is, we completely ignore
the mutability qualifier of the local variable and the rvalue. If the
types are specified, we’ll apply the full assignability relation, but
if there are type variables, we’ll fall back to requiring subtyping.
Here is a case where this would fail:
fn takes_mut_rec(x: {f: mut int}) {
let y = x;
wants_imm_rec(&y);
}
fn wants_imm_rec(x: &{f: int}) { ... }
Here, we will infer the type of y
to {f: mut int}
. This will then
generate an error because &{f: mut int}
is not assignable to the
type &{f: int}
expects by wants_imm_rec()
—that is, a pointer to
a record with mutable fields is not usable as a pointer to a record
with immutable fields.
This is a failure of type inference because the following program, which is the same except for an explicit type annotation, would be perfectly legal:
fn takes_mut_rec(x: {f: mut int}) {
let y: {f: int} = x;
wants_imm_rec(&y);
}
(The assignment is legal because we copying x
into the local
variable y
, so we can declare the mutability of this new memory to
be whatever we like.)
I think this is acceptable, though. Inference need not be perfect in all cases (indeed, it cannot be).