Circular references


While talking with some friends in Discord a while ago, one of them asked me how you'd create circular references in Rust. I'd never really felt the need for them before, so I went to the Rust playground to figure out what a bare-minimum circular reference would look like. I find that most things in Rust aren't as hard as I think people expect, surely this is no exception, right?

Rust

use std::cell::RefCell;
use std::rc::Rc;
use std::rc::Weak;

struct Friend {
  name: String,
  bestie: Weak<RefCell<Friend>>,
}

impl Friend {
  fn named(name: &str) -> Self {
    Friend { name: name.to_string(), bestie: Weak::new() }
  }
}

fn main() {
  // `Rc` handles reference counting, `RefCell` allows us to obtain a mutable reference
  let lilac   = Rc::new(RefCell::new(Friend::named("lilac")));
  let rattard = Rc::new(RefCell::new(Friend::named("rattard")));

  // Downgrading to `Weak` is important! Otherwise this memory will never be freed!
  lilac.borrow_mut().bestie = Rc::downgrade(&rattard);
  rattard.borrow_mut().bestie = Rc::downgrade(&lilac);

  // ...and then use it!
  println!(
    "my best friend's username is {}!",
    lilac
      .borrow()  // Borrow from my `RefCell`
      .bestie    // `Weak<_>`
      .upgrade() // `Weak` -> `Option<Rc<_>>`
      .unwrap()  // `Option<Rc<RefCell<_>>>` -> `Rc<RefCell<_>>`
      .borrow()  // Borrow from the `RefCell` of my bestie
      .name      // Get their name! Easy!
  );
}

Ok, that's not so bad! It's definitely a little bad, but I can still grok what's happening, and hopefully so can you. The simplest thing I could get to work was to create the circular reference through a Weak<RefCell<_>>. There are a few drawbacks to this design though...

It's kind of a lot of work to put into a solution that still has gotchas, but it made me curious: what does this problem look like in other languages?

Go

package main

import "fmt"

type Friend struct {
  Name   string
  Bestie *Friend
}

func main() {
  // Garbage collection means no need for reference counting, smart pointers, etc.
  lilac   := Friend{Name: "lilac"}
  rattard := Friend{Name: "rattard"}

  // Just grab some pointers! The garbage collector can handle this just fine!
  lilac.Bestie = &rattard
  rattard.Bestie = &lilac

  // Just read the properties! No nested layers of unfolding!
  fmt.Printf("my best friend's username is %s!", lilac.Bestie.Name)
}

It's essentially the same amount of code if you're going by "number of lines to set things up", but the lines of the Rust version are much denser. Go's garbage collection allows us to just have the values point directly at each other.

Zig

For another point of comparison, we can look at the same thing in Zig!

const std = @import("std");

const Friend = struct {
  name: []const u8,
  bestie: *Friend,
};

pub fn main() !void {
  // Bring your own heap allocator!
  var allocatorBackend = std.heap.GeneralPurposeAllocator(.{}){};
  const allocator = allocatorBackend.allocator();
  // We're being good noodles and doing some work to make sure we're not leaking memory.
  defer {
    const leaked = allocatorBackend.deinit();
    if (leaked == true) @panic("oh no!");
  }

  // We don't need wrappers here either, because Zig makes us manage our own memory.
  // We do however, need to allocate some memory ourselves, and then free it later.
  var lilac   = try allocator.create(Friend);
  var rattard = try allocator.create(Friend);
  defer {
    allocator.destroy(lilac);
    allocator.destroy(rattard);
  }
  lilac.*   = Friend{ .name = "lilac", .bestie = rattard };
  rattard.* = Friend{ .name = "rattard", .bestie = lilac };

  // The compiler will dereference the pointers for us!
  std.log.info("my best friend's name is {s}", .{lilac.bestie.name});
}

It's interesting how Zig shuffles around the complexity. I'd say it's about as complicated as the Rust version, the complexity is just in different places.

Rust, again

Circling back to Rust, if we're willing to forego Rust's memory safety, we can actually get a pretty comparable version.

use std::ptr::null;

struct Friend {
  name: String,
  bestie: *const Friend,
}

impl Friend {
  fn named(name: &str) -> Self {
    Friend { name: name.to_string(), bestie: null() }
  }
}

fn main() {
  // We're just using `Box` to allocate on the heap
  let mut lilac   = Box::new(Friend::named("lilac"));
  let mut rattard = Box::new(Friend::named("rattard"));

  // `&Friend` gets turned into `*const Friend`
  lilac.bestie = &*rattard;
  rattard.bestie = &*lilac;

  // It's a little messier than the Zig syntax, but I think it's ok for dangerous things
  // to look dangerous. (Also the dereference needs to be wrapped in an `unsafe` block.)
  println!(
    "my best friend's username is {}!",
    unsafe { &(*lilac.bestie).name },
  );
}

JavaScript

A "simplest" example might be JavaScript, which really still looks basically the same as the other languages, just with a lot of fluff pulled away.

let lilac = { name: "lilac" };
let rattard = { name: "rattard" };

lilac.bestie = rattard;
rattard.bestie = lilac;

console.log(lilac.bestie.name);

Remarks

I'm not entirely convinced that circular references are even a particularly interesting problem, or that they really solve any problem that wouldn't be better solved in another manner. I actually kind of like that doing this "the right way" in Rust is obnoxious, because hopefully that pain can discourage this sort of design, and push people towards something more servicable. In all of these languages, it either required interior mutability, or very low-level control over the allocation process in the case of Zig. There are many languages where that's just not accessible; like Gleam or Elixir.

Despite that, I think each of these examples makes for a great showcase of relative priorities between these languages. Go and Zig both aim to be "simple", and yet have remarkably different approaches to things. Rust tries to be approachable and exact at the same time, and sometimes those goals are in tension. There's a balance to be struck between so many factors, and every language lands in a slightly different spot.

tl;dr

Discussion

Thoughts? Comments? Questions? Reach out via email. I'd love to chat!
If you like my posts, consider supporting me on Github Sponsors