The Relicans

loading...

Little Lessons: Type Restricting Crystal Generics

Kirk Haines
I've had a 30+ year career across spectrum from System and DevOps to SRE type work to (a lot of) Software Engineering, and I love helping others to grow in their careers.
・5 min read

There you are, fingers flashing over your keyboard while the sweet sound of those mechanical switches soothes the fire inside you. You are in the zone, and the code couldn't be sweeter.

And then it happens. You run your specs, and....

In v.cr:2:3

 2 | @num : Number
     ^---
Error: can't use Number as the type of an instance variable yet, use a more specific type
Enter fullscreen mode Exit fullscreen mode

sigh

Your class does things with numbers. It doesn't care what kind of number it is. It can be an Int8 or a Float64 or a UInt128 or even a BigInt. All that you want is for your class to have an instance variable that is a number, of any type. Is this too much to ask?

It isn't. And in fact, Crystal is OK with using the Number type for arguments to methods, like this:

class Foo
  def bar(x : Number)
    puts "I got a number. It is: #{x}"
  end
end

f = Foo.new
f.bar(123)
Enter fullscreen mode Exit fullscreen mode

It can't do that with an instance variable, however. So, you might be thinking, "Why not just define a big type union that captures everything?" Consider this class:

class Foo
  @num : Int8 | Int16 | Int32 | Int64 | Int128 | UInt8 | UInt16 | UInt32 | UInt64 | UInt128 | Float32 | Float64 | BigInt | BigFloat | BigRational | BigDecimal

  def initialize(@num)
  end

  def square
    @num ** 2
  end
end
Enter fullscreen mode Exit fullscreen mode

That huge type union is an eyesore, right? Fortunately, Crystal has a little help for that. The int.cr and float.cr code provides some aliases to save our fingers and our eyes:

# This is in int.cr
alias Signed = Int8 | Int16 | Int32 | Int64 | Int128
alias Unsigned = UInt8 | UInt16 | UInt32 | UInt64 | UInt128
alias Primitive = Signed | Unsigned

# This is in float.cr.
alias Primitive = Float32 | Float64
Enter fullscreen mode Exit fullscreen mode

Along with these, number.cr combines those into a single type union, Number::Primitive.

These do not include the Big number types - BigInt, BigFloat, BigRational, or BigDecimal - so one must still add them all manually, but it is an improvement:

class Foo
  @num : Number::Primitive | BigInt | BigFloat | BigRational | BigDecimal

  def initialize(@num)
  end

  def square
    @num ** 2
  end
end
Enter fullscreen mode Exit fullscreen mode

Fantastic. Let's run it!

/usr/bin/ld: I-nt128.o: in function `**':
/usr/share/crystal/src/int.cr:290: undefined reference to `__muloti4'
/usr/bin/ld: /usr/share/crystal/src/int.cr:292: undefined reference to `__muloti4'
collect2: error: ld returned 1 exit status
Error: execution of command failed with code: 1: `cc "${@}" -o /home/wyhaines/.cache/crystal/crystal-run-u.tmp  -rdynamic -L/usr/bin/../lib/crystal/lib -lgmp -lpcre -lm -lgc -lpthread /usr/share/crystal/src/ext/libcrystal.a -levent -lrt -ldl`
Enter fullscreen mode Exit fullscreen mode

Oh, well....what? It turns out that this is a know bug with clang and libgcc. So, what is a person to do? Write the huge type union and omit the 128 bit integers?

Crystal has a more elegant option. Crystal's type system has the concept of a generic type. These use a variable, expressed as a capital letter like a constant, that represents any type. The standard library uses this to allow a person to indicate the type of a class or data structure that itself must be type restricted, like Hash and Array. If you look at the the start of the hash.cr library, you can see it in action:

class Hash(K, V)
  include Enumerable({K, V})
  include Iterable({K, V})
Enter fullscreen mode Exit fullscreen mode

Even if you aren't building something which requires the sort of specific type restrictions that a Hash or an Array require, you may still use generics to much more cleanly type restrict your class than through the runtime use of type unions.

class Foo(U)
  @num : U

  def initialize(@num : U)
  end

  def square
    @num * @num
  end

end
Enter fullscreen mode Exit fullscreen mode

This is an example of a class which uses a generic to define the type of the class, and of the instance variable that it contains. If one follows the example of a Hash or an Array, the syntax to use it might be something like this:

foo = Foo(Int32).new(123)
foo.square
Enter fullscreen mode Exit fullscreen mode

And indeed, that works. It's pretty ugly, though. Having to explicitly provide type like that everywhere is not a user friendly feature. Also, what happens if we pass something that can not be multiplied into that code?

foo = Foo(String).new("x")
Enter fullscreen mode Exit fullscreen mode

That will compile, and will run without any error. If one actually tries to use it to call the #square method, though, one will get a compile time error about a missing overload, which does at least provide the clue that there is a typing issue of some sort here.

In s.cr:10:10

 10 | @num * @num
           ^
Error: no overload matches '*' with type String

Overloads are:
 - String#*(times : Int)
Enter fullscreen mode Exit fullscreen mode

It would be nice, though, if our class could handle this a little more elegantly, since we know up front that the class is only intended to handle Number types.

class Foo(U)
  @num : U

  def initialize(@num : U)
    {% raise "TypeError: Foo only accepts numbers." unless U < Number %}
  end

  def square
    @num * @num
  end

end
Enter fullscreen mode Exit fullscreen mode

Macros are an extremely powerful part of the Crystal language. They let us write code that has a level of introspection into our code, at compile time, that is not possible at runtime, and they let us write code that writes code, making our runtime better.

So, consider this line:

{% raise "TypeError: Foo only accepts numbers." unless U < Number %}
Enter fullscreen mode Exit fullscreen mode

That line is macro code. It gets executed at compile time. What it does is to check whether U descends from Number. All of the Crystal numeric types, including all Integer types, all Float types, and all Big types, descend from Numeric. If U does not descend from Number, then Crystal will raise an exception at compile time which can specifically describe the error that is occurring.

Also, because Crystal's type inference extends to generic types, the previous, more verbose code can be rewritten like this:

foo = Foo.new(123)
foo.square
Enter fullscreen mode Exit fullscreen mode

This works, and it is a lot more elegant than wrestling with large type unions, when we need to restrict something like an instance variable to a subset of types for which we may not be able to directly write a type restriction.

# These all work:
puts Foo.new(4.5).square
puts Foo.new(4).square
puts Foo.new(BigInt.new(99)).square

# But this fails with a clear, compile time error:
puts Foo.new("stuff").square
Enter fullscreen mode Exit fullscreen mode
In v.cr:19:10

 19 | puts Foo.new("stuff").square
               ^--
Error: TypeError: Foo only accepts numbers.
Enter fullscreen mode Exit fullscreen mode

So, the next time you need a type restriction on an instance variable that applies to an entire category of types, consider whether it might be cleanest to implement it using a generic along with a simple macro to do a runtime check of the type. It might do a lot to improve your code clarity.

Discussion (0)