Exploring Rust fat pointers

I’ll begin with a word of warning: relying on the particular way fat pointers are implemented is bad, and may break. How bad?

Except worse than that. What’s worse than a Velociraptor attack? Use your imagination. So beware that at any point the code here may stop compiling, segfault, and otherwise behave in weird ways, some of which involve Velociraptors.

Now that that’s out of the way, what is a fat pointer anyway? All pointers are the same right? Just a number indicating an address in memory. Well, yes and no.

use std::mem::size_of_val;
use std::fmt::Debug;

fn main() {
     let v = vec![1, 2, 3, 4];
     
     let a: &Vec<u64> = &v;
     let b: &[u64] = &v;
     let c: &Debug = &v;
     
     println!("a: {}", size_of_val(&a));
     println!("b: {}", size_of_val(&b));
     println!("c: {}", size_of_val(&c));
}

And the result…

a: 8
b: 16
c: 16

Well, maybe you already knew that, but otherwise, it’s a bit surprising that these numbers aren’t the same, isn’t it? The two bigger ones are, of course, fat pointers.

The first number is unsurprising. Eight bytes is 64 bits; this is running on x86_64, so a pointer should be 64 bits. The other two, however, are twice that. What are they doing with that extra space? Let’s take a look (warning: additional Velociraptors ahead).

use std::mem::transmute;
use std::fmt::Debug;

fn main() {
     let v = vec![1, 2, 3, 4];
     
     let a: &Vec<u64> = &v;
     let b: &[u64] = &v;
     let c: &Debug = &v;
     
     println!("a: {}", a as *const _ as usize);
     println!("b: {:?}", unsafe { transmute::<_, (usize, usize)>(b) });
     println!("c: {:?}", unsafe { transmute::<_, (usize, usize)>(c) });
}

Incidentally, we probably shouldn’t rely on the representation of tuples being like this. So there are better ways to do this, but we’ve already thrown “defined behavior” out the window, so why not make it worse? Anyway:

a: 140724582133608
b: (139665665552416, 4)
c: (140724582133608, 93910549713696)

As you might guess, the big numbers are all pointers. The 4, as you might have noticed, happens to be the same number as the length of v. And of course, that’s what it is. Any moderately experienced Rust programmer should be familiar with the idea that a slice has to contain a pointer and a length, but may not have realized that an & or &mut (or even *const and *mut) consequently is twice the size in that case. But then, how else could it work?

It’s also worth noting that two of these pointers are the same, while one is different. a and c both point to the same object. b does not. This makes sense if you understand that a Vec is essentially a length, capacity, and a pointer to a (heap-allocated) buffer where its actual contents is. When we convert the Vec to a slice, this calls Vec’s Deref trait, implemented in rust/src/liballoc/vec.rs:

impl<T> ops::Deref for Vec<T> {
    type Target = [T];

    fn deref(&self) -> &[T] {
        unsafe {
            let p = self.buf.ptr();
            assume(!p.is_null());
            slice::from_raw_parts(p, self.len)
        }
    }
}

So the pointer we see in b is the address of the vector’s self.buf, instead of the vector itself.

Okay, so that makes sense. But what about c, which is a trait object? This second big number looks like a pointer, but what does it point to?

This second pointer points to the virtual method table, or vtable, which makes dynamic dispatch possible. If this were C++ code, the vtable pointer would be added to every object of type Vec, but in Rust it instead is included in pointers. This has advantages and disadvantages. For one thing, it means there is no additional overhead when you don’t use trait objects.

At this point, I realize I may not have chosen a good trait to demonstrate this; Debug’s fmt() method takes a Formatter, but there isn’t a way to create one; you can only get one when the fmt method is called by parts of std. But we can write a wrapper struct that implements Debug; it’s ugly, but it should work.

use std::mem::transmute;
use std::fmt::{Debug, Formatter, Error};

struct FmtWrap<'a, T: 'a>(&'a fn(&T, &mut Formatter) -> Result<(), Error>, &'a T);

impl<'a, T> Debug for FmtWrap<'a, T> {
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
        self.0(self.1, f)
    }
}

fn main() {
     let v: Vec<u64> = vec![1, 2, 3, 4];
     let c: &Debug = &v;

     let (_, vtable) = unsafe { transmute::<_, (usize, usize)>(c) };
     let fmt = unsafe { &*((vtable as *const fn(&Vec<u64>, &mut Formatter) -> Result<(), Error>).offset(3)) };
     
     println!("{:?}", FmtWrap(fmt, &v));
}

And this outputs exactly what we would expect:

[1, 2, 3, 4]

And so it works! That is where the fmt method was hiding. Or at least, it works for me with the current version of Rust, when I test it. Your millage may vary.

Why the .offset(3)? It seems like fmt should be the only thing in the vtable, since it’s the only method the trait requires. To find out, we’ll have to look into Rustc’s source code. After some grepping (or rather, ripgrepping), the function get_vtable in rust/src/librustc_codegen_llvm/meth.rs seems to hold the answer. The relevant part of that function:

let (size, align) = cx.size_and_align_of(ty);
let mut components: Vec<_> = [
    callee::get_fn(cx, monomorphize::resolve_drop_in_place(cx.tcx, ty)),
    C_usize(cx, size.bytes()),
    C_usize(cx, align.abi())
].iter().cloned().collect();

if let Some(trait_ref) = trait_ref {
    let trait_ref = trait_ref.with_self_ty(tcx, ty);
    let methods = tcx.vtable_methods(trait_ref);
    let methods = methods.iter().cloned().map(|opt_mth| {
        opt_mth.map_or(nullptr, |(def_id, substs)| {
            callee::resolve_and_get_fn(cx, def_id, substs)
        })
    });
    components.extend(methods);
}

Ah, so there are three values (of size usize) in the vtable before any of the methods. We can peak at their values if we want:

use std::mem::transmute;
use std::fmt::Debug;

fn main() {
     let v: Vec<u64> = vec![1, 2, 3, 4];
     let c: &Debug = &v;

     let (_, vtable) = unsafe { transmute::<_, (usize, usize)>(c) };
     println!("{:?}", unsafe { &*(vtable as *const [usize; 3]) });
}

Which gives us this:

[94451413622320, 24, 8]

By looking at the code from get_vtable, we can see that the 24 here is the size of a Vec, and 8 is its alignment. Which makes sense; 24 is 3 times 8, and (on a 64-bit architecture) the Vec should consist of three 64 bit/8 byte values, and thus should also have an alignment of 8 bytes. Following the code a little more, the first pointer is apparently needed for std::ptr::drop_in_place(). This isn’t necessary in our example, since we’re only taking a reference, but if we had a Box<Debug> instead of &Debug, then Rust would need to know how to drop the Vec.

Hopefully someone learns something from this. How should it affect how you write Rust code? Probably not at all. You certainly shouldn’t actually use code like this. You could worry about the extra memory consumption from the size of fat pointers, or related low-level concerns, but probably in almost all cases you shouldn’t worry about that. But it’s nice to have some idea what’s actually going on, isn’t it?