Smart pointer?

I’ve seen in the stdlib that algebraic types are sometimes emulated using a case union (e.g. Json::Any):

struct Any
 alias Type = Nil | Bool | Int64 | Float64 | String | Array(Any) | Hash(String, Any)

which is very similar to a sum type, with value semantics. This is fine, but there’s a twist: recursion is achieved through reference values (Hash and Array), which makes the recursion valid since the union has a fixed size. However if there was to be a recursion with a single child, there is no obvious way to represent it (except for Pointer(T) maybe, which is unsafe). So I wonder why there isn’t a kind of smart pointer like what follows:

struct Ref(T)
  @ptr : Pointer(T)
  def initialize(x : T)
    @ptr = Pointer.malloc(1, x)
    @ptr.value = x
  end
  
  forward_missing_to @ptr.value  # behave like the object, hence "smart"
end

record Foo, id : Int32, parent : Ref(FooBar)  # single child recursion
record Bar, s : String
alias FooBar = Foo | Bar


p! Foo.new(id: 0, parent: Ref.new(Bar.new(s: "hello").as(FooBar)))

Am I missing something?

Welcome to the Crystal forums, @c-cube :smile:

A lot of the time, when an idea doesn’t in the language or standard library yet it might not necessarily be an explicit decision to exclude it, but instead more along the lines of “nobody’s suggested that yet”. The JSON::Any type went through a couple iterations before they landed on that as a solution.

This is an interesting approach. It seems a lot like a compact implementation of a singly linked list — especially since you mention recursion with a single child. Is that the idea?

Also, it looks like it can be simplified a bit to remove the pointer, but requires converting Ref to a class so that it can contain a Foo:

class Ref(T)
  def initialize(@value : T)
  end
  
  forward_missing_to @value  # behave like the object, hence "smart"
end

record Foo, id : Int32, parent : Ref(FooBar)  # single child recursion
record Bar, s : String
alias FooBar = Foo | Bar

p! Foo.new(id: 0, parent: Ref(FooBar).new(Bar.new(s: "hello")))

The end result should be no change to the number of heap allocations since the struct was calling Pointer.malloc. I was surprised that you were able to call Pointer.malloc with a Struct type. I would’ve expected that to fail since structs aren’t allocated on the heap.

Good point, having the “smart pointer” itself be a class is simpler indeed. I’m not surprised structs can go onto the heap though, they’re just value types, right? The semantics is that they’re implicitly copied, but they should be able to live anywhere, right?

That makes sense. I just don’t know if they will ever get GCed. If you try to define a finalize method for a struct, for example, you get an error that explicitly says structs aren’t tracked by the GC: https://play.crystal-lang.org/#/r/7jqo

After looking at how Pointer.malloc(Int, T) works, it actually makes sense why it worked, but IIRC there are some other protections that keep you from using pointers with stack-allocated values. It might be worth adding a check to this method.

forward_missing_to doesn’t work well regarding captured blocks, so that’s one big reason I wouldn’t do it.

Another reason is that it feels too low-level. You can use a class instead of a Ref wrapping a struct. Or just a class wrapping another struct.

Do you have some specific use case in mind that you can’t do with the existing language constructs?

I don’t recall any issues. The GC will go through the all the stack of each fiber and navigate from there to detect unused heap memory and free it. The Pointer.malloc uses the GC.malloc at the end.


Regarding the usage of Ref(T) as a proxy of T. If there is a type restriction of : T, a Ref(T) will not satisfy it.

1 Like