Implement Swift's Lazy Variable Syntax for Crystal


There are many situations where we have a property for an object that is expensive to compute. When that property is not used often, it makes sense to

  1. defer the computation until the first time we need the property, and
  2. store the value for subsequent references to avoid re-computing it.

Ruby and Crystal developers are familiar with this as “memoization” where the property is access by a method that computes and caches the value.

1
2
3
4
5
6
class Num
  # ...
  def square
    @square ||= @val.abs2  # memoize!
  end
end

Swift has a language feature for memoization using the lazy keyword to declate dynamic properties for an object.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Num {
  var value

  init(val) {
    value = val
  }

  lazy var square = {     // Swift hides the memoization
    return value * value
  }
}

This looks so intuitive that I wanted to figure out if I could implement this for my Crystal1 programs. Since Crystal is really good at not compiling unneeded code, and has great macro support2, I started to look into how I could make a lazy keyword available in Crystal.

The sugar

1
2
3
4
5
6
7
8
9
class Num(T)
  def initialize(@value : T); end

  lazy square : T { @value.abs2 }

  lazy twice : T do
    @value * 2
  end
end

The macro

The following lazy macro converts the lazy prop : T { ... } declaration into the boilerplate for memoization by generating

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
macro lazy(var, &valuator)
  {% if var.is_a? TypeDeclaration %}
    {% v_id = var.var %}
    {% v_typ = var.type %}
    @{{v_id}} : {{v_typ}}?
    def {{v_id}} : {{v_typ}}
      @{{v_id}} ||= _lazy_{{v_id}}_fetch
    end
    def _lazy_{{v_id}}_fetch
      {{valuator.body}}
    end
  {% elsif var.is_a? Call %}
    {% raise "Cannot infer type of `#{var.id}`, declare with type T like so:\nlazy #{var.id} : T #{valuator}\n\n" %}
  {% else %}
    {% raise "#{var.id} is #{var.class_name}, unsupported argument type" %}
  {% end %}
end

Error handling

Crystal’s type inference only goes so far. Declaring lazy sin { Math.sin(@value) } will not compile, so the lazy macro catches that otherwise very natural usage to provide a useful error.

For example, the following declaration of twice

1
2
3
  lazy twice do
    @value * 2
  end

… will result in the following compile-time error:

1
2
3
4
5
Error: Cannot infer type of `twice`, declare with type T as like so:

lazy twice : T do
  @value * 2
end

Availability

I’ve started a sugar.cr shard in GitHub. This macro is too small to be in its own shard; but I can foresee other similar sugar crystals 😉 that I think will just go into this repo.


  1. Crystal is a compiled statically-typed language with Ruby-inspired syntax. ↩︎

  2. Macros in Crystal are great, and the documentation here provides plenty of information to really understand and use them. ↩︎