Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

gccrs#4722: Support coercions and intrinsics for unsized ADTs

Link: Rust-GCC/gccrs#4722

Note

This is important

Objective

This PR introduces support for Dynamically Sized Types (DST) within Algebraic Data Types (ADTs). It enables basic ADT-to-ADT unsized coercions (e.g., array-to-slice conversions like [T; N] to [T]) and implements the unsize and coerce_unsized lang items.

Changes Made

Previously, the compiler incorrectly embedded DST fields directly into the ADT memory layout, completely breaking unsize coercion rules. With this patch:

  • The compiler now correctly identifies unsized ADTs, preserves their static layout by avoiding direct embedding, and dynamically generates fat pointers (data pointer + metadata) for references to these ADTs.

  • The compiler correctly identifies valid array-to-slice coercions and injects the appropriate Adjustment::UNSIZE tags.

  • Refactored the monolithic coerce_unsized function into smaller, maintainable helper functions.


To understand the zero-cost thin-to-fat pointer coercion, let’s examine how memory is structured during these conversions:

#![allow(unused)]
fn main() {
let t = [1, 2, 3]; // this is an array '[i32; 3]'

// coercion to unsized slice
let t1 = &t as &[i32]; // &[i32; 3] -> &[i32]
}
t is [i32; 3] - (12 bytes)
├────────────────────┤
│                    │ <- t[0] (4 bytes)
├────────────────────┤
│                    │ <- t[1] (4 bytes)
├────────────────────┤
│                    │ <- t[2] (4 bytes)
├────────────────────┤


&t is &[i32; 3] - (8 bytes) ────────────────────────────────┐
├────────────────────┤                                      │
│                    │ <- address of t[0] (8 bytes)         │
├────────────────────┤                                      │
                                                            │
                             zero-cost thin-to-fat coercion │
t1 is &[i32] - (16 bytes) <─────────────────────────────────┘
├────────────────────┤
│                    │ <- address of t[0] (8 bytes)
├────────────────────┤
│                    │ <- length information (8 bytes)
├────────────────────┤

#![allow(unused)]
fn main() {
let s = TailStruct { a: 10, tail: t, }; // this is a normal struct

// coercion to unsized ADT
let s1: &TailStruct<[i32]> = &s; // &TailStruct<[i32; 3]> -> &TailStruct<[i32]>
}
s is TailStruct<[i32; 3]> - (16 bytes)
├────────────────────┤
│                    │ <- s.a (4 bytes)
├────────────────────┤
│                    │ <- (s.tail)[0] (4 bytes)
├────────────────────┤
│                    │ <- (s.tail)[1] (4 bytes)
├────────────────────┤
│                    │ <- (s.tail)[2] (4 bytes)
├────────────────────┤

&s is &TailStruct<[i32; 3]> - (8 bytes) ────────────────────────────────┐
├────────────────────┤                                                  │
│                    │ <- address of s (8 bytes)                        │
├────────────────────┤                                                  │
                                         zero-cost thin-to-fat coercion │
d is &TailStruct<[i32]> - (16 bytes) <──────────────────────────────────┘
├────────────────────┤
│                    │ <- address of s (8 bytes)
├────────────────────┤
│                    │ <- length information of s.tail (8 bytes)
├────────────────────┤

#![allow(unused)]
fn main() {
let d = TailStruct { a: 10, tail: 10 } // this is a normal struct

// coercion to unsized ADT
let d1: &TailStruct<dyn TraitA> = &d; // &TailStruct<i32> -> &TailStruct<dyn TraitA>
}
d is TailStruct<i32> - (8 bytes)
├────────────────────┤
│                    │ <- d.a (4 bytes)
├────────────────────┤
│                    │ <- d.tail (4 bytes)
├────────────────────┤

&d is &TailStruct<i32> - (8 bytes) ───────────────────────────────────────────┐
├────────────────────┤                                                        │
│                    │ <- address of d (8 bytes)                              │
├────────────────────┤                                                        │
                                               zero-cost thin-to-fat coercion │
d1 is &TailStruct<dyn TraitA> - (16 bytes) <──────────────────────────────────┘
├────────────────────┤
│                    │ <- address of d (8 bytes)
├────────────────────┤
│                    │ <- address of d.tail's vtable (8 bytes)
├────────────────────┤

Note: For the above coercion to work, i32 must implement TraitA.

As you may have noticed, all these coercions happen exclusively through references (or smart pointers). This is because we never actually transform the underlying data.

So, why do we need unsized coercions in the first place?

In Rust, [i32; 3] and [i32; 2] are entirely distinct types. Imagine designing an API that takes an array of integers as input. If you lock the signature to &[i32; 3], you would need to write a different function for every possible array length. Since Rust does not support function overloading, you would end up writing foo2, foo3, and so on. This is because array lengths are strictly compile-time constructs.

This is exactly where Dynamically Sized Types (DSTs) and unsized coercions shine. By defining the function signature to accept a slice &[i32], we create a unified interface. Under the hood, the compiler takes the starting address of the array (a thin pointer), pairs it with the array’s length (the metadata), and passes them together as a fat pointer. We achieve a zero-cost abstraction for all array sizes, simply by attaching size information to a pointer, without ever mutating the actual data in memory.

This philosophy extends directly to trait objects. If you need a function to dynamically handle distinct types (e.g., both [i32] and [u32]), you define their shared behavior via a trait (e.g., TraitA). By setting your function to accept &dyn TraitA, the compiler performs another unsized coercion: this time, it attaches a vtable to the thin pointer instead of a length.

Ultimately, the true power of unsized coercions is not about transforming data structures; it is about abstracting away memory layouts to avoid code duplication, enable dynamic dispatch, and design clean, unified APIs.

Why is this needed for alloc?

Data structures in alloc like Box<[T]>, Rc<dyn Trait>, and Arc<[T]> are built around ADTs wrapping unsized types. Without properly generating fat pointers and preserving the static layout of unsized ADTs, dynamic allocation of slices and trait objects is impossible.

Test Case

pub struct TailStruct<T: ?Sized> {
    pub a: i32,
    pub tail: T,
}

pub trait TraitA {
    fn dummy(&self) -> i32;
}

impl TraitA for i32 {
    fn dummy(&self) -> i32 {
        0
    }
}

impl TraitA for [i32; 3] {
    fn dummy(&self) -> i32 {
        0
    }
}

pub fn sovt(s: &TailStruct<dyn TraitA>) -> usize {
    size_of_val(s)
}

pub fn sov1(s: &[i32]) -> usize {
    size_of_val(s)
}

pub fn sov2(s: &TailStruct<[i32]>) -> usize {
    size_of_val(s)
}

pub fn sov3(s: &TailStruct<TailStruct<[i32]>>) -> usize {
    size_of_val(s)
}

fn main() {
    let t = [1, 2, 3];
    let t1 = &t as &[i32];
    let s1 : TailStruct<[i32; 3]> = TailStruct { a: 10, tail: t, };
    let s2_tail: TailStruct<[i32; 3]> = TailStruct { a: 10, tail: t, };
    let s2 : TailStruct<TailStruct<[i32; 3]>> = TailStruct { a: 20, tail: s2_tail };

    let a = sov1(t1);
    let b = sov2(&s1);
    let c = sov3(&s2);
    let d = sov1(&s1.tail);
    let e = sov2(&s2.tail);

    let s3 = TailStruct {a: 10, tail: 10_i32, };
    let s4 = TailStruct { a: 20, tail: t };

    let r1: &TailStruct<dyn TraitA> = &s3;
    let r2: &TailStruct<dyn TraitA> = &s4;

    let f = sovt(r1);
    let g = sovt(r2);

    let slice_ok = a == 12 && b == 16 && c == 20 && d == a && e == b;
    let trait_ok = f == 8 && g == 16;

    if slice_ok && trait_ok {
        println!("ok.");
    } else {
        println!("wrong!");
        println!("slice_ok: {slice_ok}");
        println!("trait_ok: {trait_ok}");
    }
}

Note

Also, you can see in The Rust Reference