Generic Types: Adding Math Puzzles To Your Code


In this formula, x is the bound variable, a is
the free variable and e is constant.

Most modern, statically typed languages allow us to use generic types. We write a function once with generic type syntax, and then the compiler can apply the same code over and over again to different actual, concrete types. Hence the name generic.

This is a powerful language feature, but generic code is often confusing and hard to read. For me, generic code resembles something from my high school algebra textbook. I see small math puzzles sprinkled around my computer program. Why do this? Why add math problems to your code? Computer programming is already hard enough; why make it even more complicated?

Generic types allow us to get the best of both words: the safety and performance of static types, with the flexibility and simplicity of a dynamic, typeless language.

But this comes at a steep price: Using generic types force you to write two programs, in a sense. Your normal code for runtime, and a second, parallel type-specific program that runs at compile time. To see what I mean, let’s take an example from the Crystal standard library and explore how the Crystal team used generic type syntax to implement their array class.

Array#uniq in Crystal

Last time I looked at the Crystal standard library, specifically at how Crystal removes duplicate elements from an array in Array#uniq. I discussed a couple of optimizations the Crystal team used to implement uniq for small or empty arrays.

But what about the general case? How does Crystal remove duplicate elements from large arrays? If I remove the small array optimizations, the Crystal implementation of Array#uniq reads like this:


class Array(T)

  def uniq
    # Convert the Array into a Hash and then ask for its values
    to_lookup_hash.values
  end

  protected def to_lookup_hash
    to_lookup_hash { |elem| elem }
  end

  protected def to_lookup_hash(& : T -> U) forall U
    each_with_object(Hash(U, T).new) do |o, h|
      key = yield o
      unless h.has_key?(key)
        h[key] = o
      end
    end
  end

end

This is almost easy to read. Admittedly, I’m a Ruby developer, so the Ruby-like syntax makes perfect sense to me. However, most developers will be able to figure this out without much effort.

Crystal identifies the unique elements of the array by converting it into a “lookup hash:”


As you know, hash keys are unique. By converting the array into a hash, Crystal has quickly identified the unique elements of that array.

Converting An Array To A Hash At Runtime

But if you read carefully, you’ll see that Crystal converts the array to a hash twice: once at compile time and then later again at runtime. Let’s look at the second, runtime program first, working our way from the inside out.

First, the unless clause inside the loop checks whether the hash already contains a given element. If the element isn’t already in the hash, Crystal adds it:


unless h.has_key?(key)
  h[key] = o
end

This is the crux of the unique algorithm. Crystal won’t insert a given key value twice. (Although the unless clause is technically unnecessary; saving a repeated value would be harmless, overwriting the previous copy in the hash.)

Looking up one line, we can see the to_lookup_hash function accepts a block, and calls it to calculate the a key value for each array element:


key = yield o
unless h.has_key?(key)
  h[key] = o
end

And reading farther up, we can see another definition of to_lookup_hash passes in such a block:


protected def to_lookup_hash
  to_lookup_hash { |elem| elem }
end

Since the block { |elem| elem } just returns whatever was passed into it, the keys and values of the lookup hash will be the same:


This block adds a bit of flexibility to the code. The Crystal team might want to reuse this function someday with a different block and set of keys.

Finally, let’s look at how Crystal iterates over the array:


each_with_object(Hash(U, T).new) do |o, h|
  key = yield o
  unless h.has_key?(key)
    h[key] = o
  end
end

Crystal calls each_with_object on the array and provides a single argument: Hash(U, T).new. Here’s our first example of generic type syntax. I’ll come back to that in a moment. For now, I can guess that Hash(U, T).new creates a new, empty hash.

Next, each_with_object loops over the receiver (the array), and calls the block do |o, h| … end for each element. It sets o to each element’s value as it iterates, and h to the hash created with Hash(U, T).new. As we saw above, the block inserts each value o into the hash h, skipping duplicates.

Finally, after the iteration completes, Crystal returns the values from the new hash, a new array containing only the unique elements from the original array:`


to_lookup_hash.values

Converting An Array To A Hash At Compile Time

But there’s more to this program than meets the eye. Crystal actually runs part of this code, the generic type syntax, earlier at compile time. And, curiously, that code also converts the array into a hash but in a different way. When the Crystal team wrote Array#uniq, they had to write two programs, not one!

What exactly do I mean by “converting an array to a hash at compile time?” How and why does this happen? And what’s the second program here?

The answer has to do with the Hash(U, T).new expression we read above. Crystal needs to convert the type Array(T) into the type Hash(U, T). Let’s step through the second, type-level mirror program to find out how this works.

You can imagine the Crystal compiler processing the generic type code like this:


The first line is actually the most important:


class Array(T)

This line means the Crystal team is not implementing a simple array of elements. Instead, they are implementing an array that must contain elements of the same type. And here on this line they name that type in a generic way: the type variable T.

Specifying an array element type provides two important benefits: First, it is a nice safety net for us, the developers using Crystal arrays. Crystal’s compiler will prevent us from accidentally inserting elements from a different or unexpected type into the array. And the best part is that Crystal will tell us about our mistake at compile time, before our code ever runs and does any harm with real data. It’s annoying and difficult to write a second compile-time program using types, but the compiler might - and probably will - find some of our mistakes and tell us before the program even runs.

Second, because Crystal knows that all of the elements of the array have the same type, it can emit more efficient code that takes advantage of this knowledge. The machine language code the compiler produces can save and copy array elements faster because it knows how much memory each element occupies.

And by using generic type syntax to write Array#uniq, the Crystal team gives us these benefits regardless of what kind of elements we add to our arrays. The Crystal compiler automatically maps the type we happen to choose in our code to the variable T.

Next, take a look at the to_lookup_hash function declaration:


protected def to_lookup_hash(& : T -> U) forall U

What in the world does this mean? What is forall U referring to?

The first thing to note here is Crystal’s block parameter syntax: & : T -> U. Crystal has borrowed the & character from Ruby to indicate the following value is a block or closure, not a simple value. But in Crystal, the block parameters and the block’s return value all must have types. And here in this code those types are generic types: T and U. The arrow syntax tells us that the block takes a single parameter of type T, and returns a single value of type U.

But what do T and U mean? Where are they defined?

This is our math puzzle for the day. Just like in a limit, integral or infinite series from Calculus, this formula contains bound and free variables:


& : T -> U forall U

Since the enclosing class statement above declared the class to be Array(T), Crystal binds the T type variable here to be the same type as above. In order words, the type of values passed into this block must be the same as the type of the elements in the array.

But what about U? What type is that?

The forall U clause declares the U type to be a free variable. That means that U, unlike the type T, is not bound to any known type value. forall tells the Crystal compiler that the following code should apply equally well to any type U, “for all” possible types U.

The Crystal compiler solves this math puzzle using the yield statement we saw above:


key = yield o

Here Crystal knows the value o has a type of T. How? Because Crystal knows that all of the elements of the array have a type T, (this is the Array(T) class) and Crystal knows the variable o was set earlier to an array element by each_with_object.

Next, the Crystal compiler can determine that U == T, that both types are the same. How? When Crystal compiles the block’s code above:


to_lookup_hash { |elem| elem }

…Crystal notices that the return value of the block is the same as the value passed into the block, that elem == elem. Then, the Crystal compiler maps this return value to the block declaration: & : T -> U. Because Crystal knows elem == elem in the block code, it deduces that the types of these values are also the same, that U == T.

Finally, let’s return to the line above that iterates over the array:


each_with_object(Hash(U, T).new) do |o, h|

Now the Crystal compiler can use the knowledge that types U == T when it creates the lookup hash. In Crystal when you create a hash, just as when you create an array, you have to provide a type parameter for all the values. And for hashes you also need to provide a type for the keys. Hash(U, T).new means: Create a new hash which has keys of type U and values of type T.

Armed with the knowledge that the keys of the lookup hash all have type U, and that U == T, the Crystal compiler can emit the correct, optimized code for finding and inserting hash values when it produces machine language instructions for this passage:


key = yield o
unless h.has_key?(key)
  h[key] = o
end

Crystal knows that both o and key have the same type T, which allows the has_key? and []= methods to run quickly and correctly.

Are Generic Types Worth It?

It’s a good thing the Crystal compiler is good at solving math puzzles! Crystal, like many other modern languages (Haskell, Swift, Rust, etc.) is able to determine the actual, concrete types for variables like T and U, and for all values in your code, using type inference. The Crystal compiler can deduce what the type of each variable is based on the usage of that variable and the context of the surrounding code.

But the problem is that I, a user of the Crystal language, have to be good at math puzzles also. As we’ve just seen, in order to write code using Crystal or any language with a modern type system, I have to write my code twice: once to solve the problem I actually want to solve, and a second time to prove to the compiler that my code is consistent and mathematically correct at a type level.

Are the added performance and added safety worth it? That depends entirely on what code you are writing, how fast it needs to run, how many times and for how long that code will be used - and most importantly, how much time you have to solve math problems.