rust/data.md
Alexis Beingessner 9c6a46b0ee fiddlin'
2015-06-29 15:43:51 -07:00

9.0 KiB

% Data Representation in Rust

Low-level programming cares a lot about data layout. It's a big deal. It also pervasively influences the rest of the language, so we're going to start by digging into how data is represented in Rust.

The Rust repr

Rust gives you the following ways to lay out composite data:

  • structs (named product types)
  • tuples (anonymous product types)
  • arrays (homogeneous product types)
  • enums (named sum types -- tagged unions)

An enum is said to be C-like if none of its variants have associated data.

For all these, individual fields are aligned to their preferred alignment. For primitives this is usually equal to their size. For instance, a u32 will be aligned to a multiple of 32 bits, and a u16 will be aligned to a multiple of 16 bits. Composite structures will have a preferred alignment equal to the maximum of their fields' preferred alignment, and a size equal to a multiple of their preferred alignment. This ensures that arrays of T can be correctly iterated by offsetting by their size. So for instance,

struct A {
    a: u8,
    c: u32,
    b: u16,
}

will have a size that is a multiple of 32-bits, and 32-bit alignment.

There is no indirection for these types; all data is stored contiguously as you would expect in C. However with the exception of arrays (which are densely packed and in-order), the layout of data is not by default specified in Rust. Given the two following struct definitions:

struct A {
    a: i32,
    b: u64,
}

struct B {
    x: i32,
    b: u64,
}

Rust does guarantee that two instances of A have their data laid out in exactly the same way. However Rust does not guarantee that an instance of A has the same field ordering or padding as an instance of B (in practice there's no particular reason why they wouldn't, other than that its not currently guaranteed).

With A and B as written, this is basically nonsensical, but several other features of Rust make it desirable for the language to play with data layout in complex ways.

For instance, consider this struct:

struct Foo<T, U> {
    count: u16,
    data1: T,
    data2: U,
}

Now consider the monomorphizations of Foo<u32, u16> and Foo<u16, u32>. If Rust lays out the fields in the order specified, we expect it to pad the values in the struct to satisfy their alignment requirements. So if Rust didn't reorder fields, we would expect Rust to produce the following:

struct Foo<u16, u32> {
    count: u16,
    data1: u16,
    data2: u32,
}

struct Foo<u32, u16> {
    count: u16,
    _pad1: u16,
    data1: u32,
    data2: u16,
    _pad2: u16,
}

The latter case quite simply wastes space. An optimal use of space therefore requires different monomorphizations to have different field orderings.

Note: this is a hypothetical optimization that is not yet implemented in Rust 1.0

Enums make this consideration even more complicated. Naively, an enum such as:

enum Foo {
    A(u32),
    B(u64),
    C(u8),
}

would be laid out as:

struct FooRepr {
    data: u64, // this is *really* either a u64, u32, or u8 based on `tag`
    tag: u8, // 0 = A, 1 = B, 2 = C
}

And indeed this is approximately how it would be laid out in general (modulo the size and position of tag). However there are several cases where such a representation is ineffiecient. The classic case of this is Rust's "null pointer optimization". Given a pointer that is known to not be null (e.g. &u32), an enum can store a discriminant bit inside the pointer by using null as a special value. The net result is that size_of::<Option<&T>>() == size_of::<&T>()

There are many types in Rust that are, or contain, "not null" pointers such as Box<T>, Vec<T>, String, &T, and &mut T. Similarly, one can imagine nested enums pooling their tags into a single descriminant, as they are by definition known to have a limited range of valid values. In principle enums can use fairly elaborate algorithms to cache bits throughout nested types with special constrained representations. As such it is especially desirable that we leave enum layout unspecified today.

Dynamically Sized Types (DSTs)

Rust also supports types without a statically known size. On the surface, this is a bit nonsensical: Rust must know the size of something in order to work with it! DSTs are generally produced as views, or through type-erasure of types that do have a known size. Due to their lack of a statically known size, these types can only exist behind some kind of pointer. They consequently produce a fat pointer consisting of the pointer and the information that completes them.

For instance, the slice type, [T], is some statically unknown number of elements stored contiguously. &[T] consequently consists of a (&T, usize) pair that specifies where the slice starts, and how many elements it contains. Similarly, Trait Objects support interface-oriented type erasure through a (data_ptr, vtable_ptr) pair.

Structs can actually store a single DST directly as their last field, but this makes them a DST as well:

// Can't be stored on the stack directly
struct Foo {
    info: u32,
    data: [u8],
}

NOTE: As of Rust 1.0 struct DSTs are broken if the last field has a variable position based on its alignment.

Zero Sized Types (ZSTs)

Rust actually allows types to be specified that occupy no space:

struct Foo; // No fields = no size
enum Bar; // No variants = no size

// All fields have no size = no size
struct Baz {
    foo: Foo,
    bar: Bar,
    qux: (), // empty tuple has no size
}

On their own, ZSTs are, for obvious reasons, pretty useless. However as with many curious layout choices in Rust, their potential is realized in a generic context.

Rust largely understands that any operation that produces or stores a ZST can be reduced to a no-op. For instance, a HashSet<T> can be effeciently implemented as a thin wrapper around HashMap<T, ()> because all the operations HashMap normally does to store and retrieve keys will be completely stripped in monomorphization.

Similarly Result<(), ()> and Option<()> are effectively just fancy bools.

Safe code need not worry about ZSTs, but unsafe code must be careful about the consequence of types with no size. In particular, pointer offsets are no-ops, and standard allocators (including jemalloc, the one used by Rust) generally consider passing in 0 as Undefined Behaviour.

Drop Flags

For unfortunate legacy implementation reasons, Rust as of 1.0.0 will do a nasty trick to any type that implements the Drop trait (has a destructor): it will insert a secret field in the type. That is,

struct Foo {
    a: u32,
    b: u32,
}

impl Drop for Foo {
    fn drop(&mut self) { }
}

will cause Foo to secretly become:

struct Foo {
    a: u32,
    b: u32,
    _drop_flag: u8,
}

For details as to why this is done, and how to make it not happen, check out [TODO: SOME OTHER SECTION].

Alternative representations

Rust allows you to specify alternative data layout strategies from the default.

repr(C)

This is the most important repr. It has fairly simple intent: do what C does. The order, size, and alignment of fields is exactly what you would expect from C or C++. Any type you expect to pass through an FFI boundary should have repr(C), as C is the lingua-franca of the programming world. This is also necessary to soundly do more elaborate tricks with data layout such as reintepretting values as a different type.

However, the interaction with Rust's more exotic data layout features must be kept in mind. Due to its dual purpose as "for FFI" and "for layout control", repr(C) can be applied to types that will be nonsensical or problematic if passed through the FFI boundary.

  • ZSTs are still zero-sized, even though this is not a standard behaviour in C, and is explicitly contrary to the behaviour of an empty type in C++, which still consumes a byte of space.

  • DSTs, tuples, and tagged unions are not a concept in C and as such are never FFI safe.

  • The drop flag will still be added

  • This is equivalent to repr(u32) for enums (see below)

repr(packed)

repr(packed) forces rust to strip any padding, and only align the type to a byte. This may improve the memory footprint, but will likely have other negative side-effects.

In particular, most architectures strongly prefer values to be aligned. This may mean the unaligned loads are penalized (x86), or even fault (ARM). In particular, the compiler may have trouble with references to unaligned fields.

repr(packed) is not to be used lightly. Unless you have extreme requirements, this should not be used.

This repr is a modifier on repr(C) and repr(rust).

repr(u8), repr(u16), repr(u32), repr(u64)

These specify the size to make a C-like enum. If the discriminant overflows the integer it has to fit in, it will be an error. You can manually ask Rust to allow this by setting the overflowing element to explicitly be 0. However Rust will not allow you to create an enum where two variants.

These reprs have no affect on a struct or non-C-like enum.