25
100 Languages Speedrun: Episode 49: Crystal
Ruby is the best programming language created so far. The history of programming language design this century is mainly features from Ruby slowly diffusing into every other programming language, newly designed or existing. Hopefully someday a language better than Ruby will be created, but nothing's really attempted that yet.
Some languages like Python are really resisting this trend. Just about every Python release gets a few small things from Ruby, and it will likely continue getting small things from Ruby for as long as Python continues. For a recent one, it took decades of resistance for Python to finally give in and add string interpolation.
Other languages decided to embrace this obvious trend, and just wholesale copied big parts of Ruby as a starting point. Crystal took it the furthest. And it's great! Starting your language and getting 80% of it right by copying the best is an amazing idea. And the other 20%? Prepare yourself for a treat!
Let's write FizzBuzz in Ruby:
#!/usr/bin/env ruby
# FizzBuzz in Crystal. Also in Ruby.
(1..100).each do |n|
if n % 15 == 0
puts "FizzBuzz"
elsif n % 3 == 0
puts "Fizz"
elsif n % 5 == 0
puts "Buzz"
else
puts n
end
end
It turns out this code works just as well in Crystal:
#!/usr/bin/env crystal
# FizzBuzz in Crystal. Also in Ruby.
(1..100).each do |n|
if n % 15 == 0
puts "FizzBuzz"
elsif n % 3 == 0
puts "Fizz"
elsif n % 5 == 0
puts "Buzz"
else
puts n
end
end
But with Crystal you can also compile it:
$ crystal build fizzbuzz.cr
$ ./fizzbuzz
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
...
By default Crystal language generates reasonably optimized code, but doesn't go all the way to balance build speed and runtime performance. To get full optimizations we need to do crystal build --release
. This will take a while.
Most languages are dynamically typed. Of static typing systems, there are these broad categories:
- simple to understand, but prevent a lot of perfectly valid code (original pre-generic Java, Go)
- allow most code, at cost of type system being ridiculously complicated (Haskell, OCaml, Scala)
- fudge it both ways, accept not being fully correct (TypeScript)
If you're interested in why it's impossible to a static type system that doesn't suck in one of these ways, check out this talk or the associated paper.
As a side note, there's a huge divide between the real world and the academia here - the academia is in love with ridiculously overcomplicated type systems, as that lets them endlessly churn completely worthless papers about yet another type system extension. The real world either uses dynamic typing, or simple static type systems. Every single language with complicated type system failed the real world test.
Well, Crystal introduced a whole new category to this.
Let's give it a try:
def add(a, b)
puts "#{a} + #{b} = #{a + b}"
end
add 400, 20
add 6, 0.9
add "foo", "bar"
That works as expected:
$ crystal add.cr
400 + 20 = 420
6 + 0.9 = 6.9
foo + bar = foobar
Now let's try to write something incorrect:
def add(a, b)
puts "#{a} + #{b} = #{a + b}"
end
add 400, 20
add 6, 0.9
add "foo", "bar"
add "y", 2000
$ crystal add2.cr
Showing last frame. Use --error-trace for full trace.
In add2.cr:2:27
2 | puts "#{a} + #{b} = #{a + b}"
^
Error: no overload matches 'String#+' with type Int32
Overloads are:
- String#+(other : self)
- String#+(char : Char)
This isn't terribly complicated code. The thing is, every other statically typed language I can think of just fails. Not only they can't infer the correct type automatically, there's usually no reasonable annotation you can provide.
Most statically languages are completely hopeless and don't even try. I was wondering if I could maybe setup some type classes in Haskell, but even enabling a lot of language extensions, and writing far more type code than actually code, I couldn't get it to work, and errors from compiler were total gibberish. If anyone is up to the challenge, in any statically typed language, please let me know in the comments. I think it's possible to twist type classes to maybe support this, at least in something like Haskell, or Idris, or such, but it sure won't as easy as Crystal here.
Of course this is a total toy example, but all existing static typing systems get in the way all the damn time and can't even handle extremely simple code like this. There's zero hope that they'd just let you write more complex code without getting in the way even more.
So how is Crystal doing this?
The key is global type inference. When you do def add(a, b)
, Crystal doesn't immediately try to assign most generic possible type to it, like pretty much every other statically typed language. Instead, it parks that question, and when add
is actually used, it checks if it makes sense there.
If we want to see what ends up being created, it just happily creates three functions for these three situations, all of these variants are perfectly typed:
$ crystal build add.cr
$ objdump -t add | grep '\*add<'
0000000100012dd0 l d *UND* _*add<Int32, Int32>:Nil
0000000100012e70 l d *UND* _*add<Int32, Float64>:Nil
0000000100012ea0 l d *UND* _*add<String, String>:Nil
0000000100012e70 g F __TEXT,__text _*add<Int32, Float64>:Nil
0000000100012dd0 g F __TEXT,__text _*add<Int32, Int32>:Nil
0000000100012ea0 g F __TEXT,__text _*add<String, String>:Nil
With no templates or typeclass definitions, it Just Works.
If that wasn't magical enough, let's get to nil
s (or null
s in most other languages). nil
s are pretty much necessary evil. A lot of code really wants data to be uninitialized for a while, and it would require a lot of twisting it around to always prevent this situation. On the other hand, a lot of other code wants to guarantee that it never gets nil
s, just actual values.
Traditionally most languages have "nullable" and "non-nullable" values, and it's overall a total mess. How about Crystal? It can do what no other language can. Don't believe me? Check this out:
ary = [1, 2, 3, nil]
puts "Type of array:"
puts typeof(ary)
puts ""
puts "Type of each element:"
ary.each do |x|
puts typeof(x)
end
puts ""
puts "Type of each element:"
ary.each do |x|
if x
puts typeof(x)
else
puts typeof(x)
end
end
$ crystal run ./types.cr
Type of array:
Array(Int32 | Nil)
Type of each element:
(Int32 | Nil)
(Int32 | Nil)
(Int32 | Nil)
(Int32 | Nil)
Type of each element:
Int32
Int32
Int32
Nil
First we define ary
as array with some stuff in it. We ask Crystal what it thinks type of ary
is, and it correctly says it's Array(Int32 | Nil)
. Int32 | Nil
is union type. So far so good.
Then we iterate the array. Just to be clear - the typeof
operator is not any kind of hacky runtime check, it tells us what we know about the variable statically. So as all we know is that ary
is Array(Int32 | Nil)
, then Crystal can only tell us that each element is (Int32 | Nil)
.
So far so good. But what about the second loop? At if/else
, Crystal checks which types can possibly get into each branch. So it knows for a fact in the positive branch, only Int32
can get there, and in the negative branch, only Nil
! This of course isn't some magic, and only some kinds of syntax trigger this splitting, but it's totally amazing in practice.
This works for nil
s, but it also works for any other union types.
None of this is possible in any language with traditional (unification-based) static type system.
Let's have more fun, how about this program:
ary = [7, 210, "foo", nil]
puts "Type of array:"
puts typeof(ary)
puts ""
puts "Double each element:"
ary.each do |x|
puts "Double of #{x} (#{typeof(x)}) is #{x * 2}"
end
Crystal correctly sees that it cannot run:
$ crystal run ./union.cr
Showing last frame. Use --error-trace for full trace.
In union.cr:9:46
9 | puts "Double of #{x} (#{typeof(x)}) is #{x * 2}"
^
Error: undefined method '*' for Nil (compile-time type is (Int32 | String | Nil))
So what if we provide a default value:
ary = [7, 210, "foo", nil]
puts "Type of array:"
puts typeof(ary)
puts ""
puts "Double each element:"
ary.each do |x|
x ||= "unknown"
puts "Double of #{x} (#{typeof(x)}) is #{x * 2}"
end
crystal run ./union2.cr
Type of array:
Array(Int32 | String | Nil)
Double each element:
Double of 7 ((Int32 | String)) is 14
Double of 210 ((Int32 | String)) is 420
Double of foo ((Int32 | String)) is foofoo
Double of unknown ((Int32 | String)) is unknownunknown
Yeah, it knows that reassigned x
cannot be nil
, so it runs perfectly.
This is total magic. We have full static typing, with zero annotations so far, and the kind of expressiveness that's so far been only reserved for dynamically typed languages.
Does it come at a cost? Yes it does. Compilation times are definitely slower than languages with simpler type systems would be. Eventually you'll need some type annotations, even if these are generally very simple concrete types. And until last week, it didn't have a proper REPL, as this kind of back and forth type inference wasn't very compatible with REPL environment. Well now it does, but it's still in early stages.
Probably the most common case is empty arrays and such, which require type annotations, instead of trying to infer things from everything you put there:
ary = [] of String
ary << "foo"
ary << "bar"
puts ary.map(&.upcase)
$ crystal run ./ary.cr
["FOO", "BAR"]
Notice &.methodname
syntax for a trivial block - and it's part of language syntax. In Ruby it's &:methodname
(which started as a hack to turn Symbol
into Proc
).
If you define non-empty array, Crystal will infer type of the array from its initial elements, so if you want to support some more, you'd need to add them too.
For example if you did not use explicit annotation, Crystal would think ary
is Array(String)
and treat ary << nil
as a type error:
ary = ["foo"] of (String | Nil)
ary << "foo"
ary << nil
puts ary.map{|x| x || "unknown"}
Overall it's a sensible choice.
Absolutely yes!
The language has about Ruby levels of programmer friendliness, most expressive and completely revolutionary type system in existence, and performance in the same tier as Java or Go (so not quite as good as raw C, without a lot of additional work).
It's about the most exciting new language of the last decade, and it's infinitely much better than dumbed-down corporate crap Big Tech tries to shove down our throats. If you have a task for which Ruby is too slow, having Crystal as the first choice is a great idea.
To keep episodes short I focused only on Crystal's friendliness and type system, but it does a lot more really interesting things.
It's not completely without downsides, especially compilation times are not great, but Crystal is one of very few languages that get my full endorsement.
25