Generalizing inherited mutability

24 July 2012

I have been working on a change to the definition of mutability in Rust. This is a much smaller change than my previous thought experiments, which were aimed at achieving better parameterization (those are still percolating; I think the best approach is a modified version of the latest proposal where not all types have mutability but type parameters do…but that’s a problem for another day with many complications). The goal of these changes is to enable operations like “freeze” and “thaw”.

Some background on freeze and thaw

The idea of “freeze” is that it takes a mutable data structure and produces an immutable one. “Thaw” similarly takes an immutable data structure and produces a mutable one. These operations are somewhat challenging; Rust’s type system, like most, allows you to declare that a field or value is mutable, but doesn’t allow you to change that mutability back and forth.

One of the trickiest parts of freeze/thaw is that it requires a notion of time in the type system. That is, the type of a value effectively changes. Changing the type of a value in a way that invalidates the old type—sometimes called a “strong update”—is tricky because you have to rule out aliases. To understand what I’m getting at, imagine that you have two pointers, p and q, both of which point at the same mutable hashtable. Now you freeze p, so its type states that the hashtable is immutable. Now you’re in a pickle, because the q pointer can still be used to modify the supposedly immutable contents of p. So basically the type of p is a lie (or the type of q is, depending on your point of view).

I don’t have a magic solution to this problem. The approach to freeze/that that I’m going to discuss here only allows freezing and thawing of values that are owned by the current stack frame (in that case, the compiler can track the aliases precisely). I’ll discuss some of the options for accommodating aliasable values later.

As an aside, my PhD work on intervals also aimed to solve this problem (among others). What I did there was basically to integrate the time span in which the hashtable was mutable into the type from the beginning. So the type of the hashtable never changed from mutable to immutable. Instead, when you made an object, you specified a time interval stating how long the new object would be mutable for (which might be forever). Once that interval had expired, the type system would treat the object as deeply immutable, which meant that it could be shared between threads and so forth. This is a powerful idea but quite beyond Rust’s type system, and I am interested in exploring solutions that lead to similar expressiveness while avoiding the quagmire of dependent types.

More background: inherited mutability

Currently in Rust, when you declare a record you specify which fields are mutable. For example, you might write:

type character = {
    name: ~str,
    mut hitPoints: uint
};

Here, the field hitPoints was declared as mutable. The field name was not. You might think that this means that the field name is immutable, but it turns out that, as a consequence of by-value types, this is not strictly true. In fact, the mutability of the field name is best described as “inherited” rather than “immutable”. To see what I mean, consider this function:

fn adjust_character() {
    let mut character = {name: ~"Richard Seifer", mut hitPoints: 22};
    ...
    character = {name: ~"Zedicus Zoorander", mut hitPoints: 5};
    ...
}

Here a mutable local variable character was declared of record type. Now, if the field character.name were truly immutable, that would mean that the value character.name could never change. But, because the record can be overwritten as a whole, this is not true. As you can see in the example, the value character.name can in fact change from "Richard Seifer" to "Zedicus Zoorander". What has happened, in effect, is that the fields which were not declared as mutable inherited mutability from the location in which the record was stored (in this case, a mutable local variable).

In fact, this inherited mutability is recognized by the Rust compiler. This means that adjust_character() could also be written as follows:

fn adjust_character() {
    let mut character = {name: ~"Richard Seifer", mut hitPoints: 22};
    ...
    character.name = ~"Kaylin";
    ...
}

Here we assigned directly to the field name, even though it was not declared as mutable. This is allows only because the record is stored in a mutable local variable. If we changed the program so that the record is stored in an immutable location, say in an immutable managed box (the name de jour for @T), we get an error:

fn adjust_character_in_imm_managed_box() {
    let mut character = @{name: ~"Richard Seifer", mut hitPoints: 22};
    ...
    character.name = ~"Kaylin"; // Error, modifying immutable box.
    ...
}

The reason is that now we are not modifying the interior of a mutable variable but rather the interior of a box, and the box was not declared as mutable. We could shift the mutability into the box if we liked, which would then make the program legal again:

fn adjust_character_in_mut_managed_box() {
    let character = @mut {name: ~"Richard Seifer", mut hitPoints: 22};
    ...
    character.name = ~"Kaylin"; // OK, managed box is mutable.
    ...
}

Foreground: Inherited mutability for unique pointers

So, we’ve seen that in Rust mutability is inherited for interior values, but not when traversing into a managed box. There are many ways to think about this rule, but I find it most helpful to think about the rule in terms of ownership. Interior values are exclusively owned by their container, meaning that when the container is freed or goes out of scope, those interior values are also freed. To put it another way, the only way to reach those interior values is to go through the owning container, and hence it is safe to free the interior value once the container is freed. So in this case mutability is inherited from the owner value to the owned value.

Managed boxes, on the other hand, are co-owned: that is, they may have many equal owners. So the fact that one such owner (the local variable character, in this case) is mutability does not imply that the data in the box should be mutable, as there may be other owners that are not mutable. Similar reasoning applies to borrowed pointers: if you have a mutable pointer of type &character, that does not imply that the character which is pointed at ought to be mutable, since borrowed pointers never imply ownership.

Unique boxes, however, do imply exclusive ownership. So if we follow this line of reasoning that a mutable owner implies mutable contents, it should be possible to write a function like the following:

fn adjust_character_in_mut_managed_box() {
    let mut character = ~{name: ~"Richard Seifer", mut hitPoints: 22};
    ...
    character.name = ~"Kaylin"; // Currently an error, but should it be?
    ...
}

Today, however, this is not the rule. Today, mutability is never inherited when you pass through a pointer. My proposal is basically that we change this rule so that mutability always follows exclusive ownership.

How does this interact with freeze/thaw?

One consequence of this rule is that when data structures are implemented using only exclusive ownership, they can easily be frozen and thawed. In fact, this kind of freezing and thawing doesn’t even require special operations, it just “falls out” of the current checking for borrowed pointers. Over the weekend, I experimented with building a hashtable in this style. I want to share a bit of the results of this experiment.

The hashtable definition looks something like this (I will use the new struct notation for named records that should be coming soon):

struct hashmap<K,V> {
    hashfn: ~pure fn(K) -> uint,
    eqfn: ~pure fn(K, K) -> bool,
    size: uint,
    buckets: ~[option<bucket<K,V>>]
}

struct bucket<K,V> {
    hash: uint,
    key: K,
    value: V
}

As you can see, this is an open-addressed hashtable, so there is a single array storing the various buckets. There is no reason that one could not design a hashtable that uses chains per bucket as well, however. I just wanted to experiment various open-addressing schemes and pit them against a chained version.

Two things you’ll notice (and this is important) is that

  • no data in the hashmap are declared as mutable, and
  • all values are exclusively owned (either interior or unique).

As a consequence of this, if I have a pointer of type &mut hashmap<K,V>, then I can use that pointer to mutate any field I like. Similarly if I do &hashmap<K,V> then I know that the map is deeply immutable. Finally &const hashmap<K,V> means that the map is read-only but potentially mutable. Basically, the outermost qualifier is inherited down to all the data of the hashmap, which makes it very easy to “switch” the map between mutable and immutable (freeze/thaw).

When writing code for the hashmap, you end up with separate implementations based on the level of mutability required. For example, methods like insert() and remove() are implemented for a mutable hashmap:

impl<K,V> for &mut hashmap<K,V> {
    fn insert(k: K, v: V); // take K, V by value so that we own them
    fn remove(k: &K);      // use borrowed ptr, do not need to own
}

Methods like get_copy() and find_copy(), which copy out of the hashmap, can be implemented for a const pointer. Similarly, we could write an iterator like each_value_copy() which copies value out. Note that V must take a copy bound for these methods (but not the others!) since we will be copying V:

impl<K, V: copy> for &const hashmap<K,V> {
    fn get_copy(k: &K) -> V;
    fn find_copy(k: &K) -> option<V>;
    fn each_value_copy(op: fn(V) -> bool);
}

Finally, methods like each() and find_ptr()—which create borrowed pointers into the (owned) interior of the data structure—are implemented using an immutable pointer. This guarantees that, for example, the hashtable is not modified during iteration.

impl<K,V> for &hashmap<K,V> {
    pure fn each(op: fn(&K, &V) -> bool);
    pure fn find_ptr(k: &K) -> option<&self/V>;
}

The method find_ptr() is particularly interesting. It is able to return a pointer into the bucket itself, so there are no copies at all. Interestingly, this pointer may even be into the interior of unique boxes owned by the hashtable. This is safe because we are guaranteed that the hashtable is immutable for the lifetime self, and so those unique boxes in turn cannot be freed for at least that lifetime (note: the current implementation does realize this, have to fix issue #2979). This is a really powerful idea that I didn’t originally realize would be possible. It basically falls out of the design.

Using these maps: the good side

These dual-mode data structures work great when they are stored in local variables. You can write code where the map goes between mutable and immutable without any problem:

fn insert_iterate() {
    let map: hashmap<K,V> = new_hashmap();
 
    while some_condition_holds {
        map.insert(...);
        map.insert(...);
        map.insert(...);
        
        for map.each |k, v| {
            // any attempt to call insert() in here reports an error
        }
        
        // iteration is done, I can use insert again
        map.insert(...);
    }
}

You can take a mutable map and share it between threads:

fn make_arc() {
    let map: hashmap<K,V> = new_hashmap();
    ...
    let shared = arc::create(move map);
    // shared can be sent freely
}

Or perhaps just share a frozen copy locally using managed data:

fn make_managed() -> @hashmap<K,V> {
    let map: hashmap<K,V> = new_hashmap();
    ...
    @(move map)
}

Using these maps: the bad side

Things get messier, however, when you want to use borrowed pointers or managed data. Right now, that means that to factor code between functions, you end up having to thread the maps around so that it is always local to each function. Here I show the insert_iterate() function from before but with the calls to insert() factored into a new function insert()

fn insert_iterate() {
    let map: hashmap<K,V> = new_hashmap();
    
    while some_condition_holds {
        map = bar(move map);
        for map.each |k, v| {
            // any attempt to call insert() in here reports an error
        }
    }
    
    ....
}

fn insert(map: hashmap<K,V>) -> hashmap<K,V> {
    map.insert(...);
    map.insert(...);
    map.insert(...);
    ret map;
}

It would be nice if there were a way to borrow a local variable in a unique way (i.e., to guarantee that there are no other borrowed pointers to the same variable). That is not possible today, sadly, and to add such a thing would probably amount to a fourth pointer type. Hmm. Unappealing.

Dual-mode data structures also do not work so well with managed data. For example, if I define a @mut hashmap<K,V>, I will not be able to use the method find_ptr() without purity, because the type system can’t prove that the map is not aliased and then modified.

There are various solutions to the problem of aliasing. The most limitation option is to use an option to “swap out” the hashmap from the managed data so you are always working on a local copy:

fn process(managed_map: @mut option<hashmap<K,V>>) {
    let mut map = none;
    map <-> *managed_map;
    let map = option::unwrap(map); // convert some(map) to map
    
    // use map however you like
    
    *managed_map <- some(map);
}

This can of course be wrapped up in a library. It works reasonably well except that it will fail dynamically if you attempt to use the managed pointer to the hashmap recursively, even if that use “ought to be” legal (for example, a find_ptr() from inside an insert()).

Another more flexible option is to create a trusted library which allows you to temporary make the managed map read-only (basically, the owned copy of the map is replaced with an unsafe, immutable pointer for the duration of the borrow). This will still fail dynamically if you try to perform a mutating operation during an immutable one (e.g., if you try to call insert() while iterating), but it does let you do immutable or const operations.

I see no other options besides adding type-based alias analysis or something similar to borrowck, which will let it check managed boxes with more (but still limited) precision. In academic terms, this means adding something like Vault’s focus() operation.

I am actually satisfied with the swapping-based solutions, particularly the second one. It’s an improvement on Java’s fail-fast iteration, for example, in that it is more precise and non-optional—in practice, fail-fast was always good enough for me anyhow. My only concern though is that it cannot be wrapped in a library in a generic way that works across types, you must still create wrappers for each method basically. Maybe we can use macros.

Also, we’ll probably want to have a variety of hashmaps, some of which are based on managed data and hence inherently local. I don’t imagine they’ll be able to support borrowed pointers into the interior and other such fancy tricks though.