The following GC API has one main goal, which is to provide safe access to garbage-collected objects.
The definition of "safe access" here specifically means that the garbage collector would consider the object to be reachable if a garbage collection cycle were to take place while the reference to it is still live. As a consequence, it should also be safe to run garbage collection cycles while there are live references on the stack.
Any tracking required for the API to be safe must be as cheap as possible. We avoid heap allocation, and reference-counting schemes. A reference to a garbage collected object is a simple pointer stored on the stack.
It is impossible for the API to be as simple and ergonomic as regular heap allocation in Rust. That being said, ergonomics are still extremely important. The API
#[gc::object]
struct Foo {
value: u32,
}
#[gc::object]
struct Bar {
name: String,
#[ref]
foo: Gc<Foo>,
}
#[gc::object]
struct Baz {
#[ref]
bar: Gc<Bar>,
}
gc::object
ensures that for a given T
:
Drop
is not implemented forT
.- every field of
T
is either unmanaged, and not marked with#[ref]
, or managed and marked with#[ref]
. T
is fully'static
.
(1) Adds an empty Drop
impl which will conflict with any user Drop
impls.
(2) Checks that for each field, if the type is a GcRef
, it must be marked #[ref]
.
If the type is not a GcRef
, it must not be marked #[ref]
.
This removes the need for every type in the universe to implement Trace
1.
(3) Asserts that T: 'static
via an unused fn.
On top of that, it implements:
gc::Trace
, for visiting all fields during the marking phase.- An
__object_init
method, which is used to produce an initializer from its parts. This initializer may be passed to a root, which will construct the object in place, and allocate it on the GC heap.
It also outputs a macro_rules
macro with the same name as the type, to produce
its object initializer.
fn mutator(gc: &GcCtx) {
// Roots are stack-pinned locations tracked by the GC.
// An object may only be accessed if it is rooted.
let_root!(in gc; bar = Bar! {
name: "test".into(),
foo: Foo! {
value: 10,
},
});
// Once an object is rooted, it can be safely accessed
// at any point. The syntax is a little unwieldy, because
// we must track accesses that go through the GC as borrowing
// from the GC.
println!("{}", bar.deref(gc).foo.deref(gc).value);
// Parts of an object can be rooted separately to give them
// their own lifetime:
let_root!(in gc; foo);
let foo = bar.deref(gc).foo.root(foo);
// A rooted object can be used as a field in another object
// during initialization.
let_root!(in gc; baz = Baz! { bar });
}
Given that it is not possible to directly return roots, any function which wishes to return an already-rooted value must receive an uninitialized root as an argument. The root can be returned once it has been initialized.
fn producer<'root>(out: UninitRoot<'root, Foo>) -> Rooted<'root, Foo> {
out.init(Foo! {
value: 100,
})
}
fn consumer(gc: &GcCtx) {
let_root!(in gc; foo);
let foo = caller(gc, foo);
}
Constructing roots is relatively cheap, and should be done with the smallest scope possible. It is not completely free however, so roots may also be declared mutable, which makes them "reusable":
// `GcVec` is a special kind of `Vec` that implements `Trace`, and
// may only hold gcrefs.
fn looping<'root>(gc: &GcCtx, list: GcRef<'root, GcVec<Foo>>) {
let_root!(in gc; list);
let_root!(in gc; mut item);
for i in 0..list.len() {
let item = list.get(i).root(&mut item);
println!("{}", item.deref(gc).value);
}
}
Footnotes
-
We know which fields are gcrefs, so we only trace those. This is safe, because it is not possible to place a gcref onto the non-GC heap, only references to gcrefs, and references to roots of gcrefs. ↩