DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

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!

FizzBuzz

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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.

Type System

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"
Enter fullscreen mode Exit fullscreen mode

That works as expected:

$ crystal add.cr
400 + 20 = 420
6 + 0.9 = 6.9
foo + bar = foobar
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
$ 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)
Enter fullscreen mode Exit fullscreen mode

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.

Crystal Type System

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
Enter fullscreen mode Exit fullscreen mode

With no templates or typeclass definitions, it Just Works.

Nils

If that wasn't magical enough, let's get to nils (or nulls in most other languages). nils 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 nils, 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
Enter fullscreen mode Exit fullscreen mode
$ 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
Enter fullscreen mode Exit fullscreen mode

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 nils, but it also works for any other union types.

None of this is possible in any language with traditional (unification-based) static type system.

Union Types

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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.

Type Annotations

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)
Enter fullscreen mode Exit fullscreen mode
$ crystal run ./ary.cr
["FOO", "BAR"]
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

Overall it's a sensible choice.

Should you use Crystal?

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.

Code

All code examples for the series will be in this repository.

Code for the Crystal episode is available here.

Oldest comments (8)

Collapse
 
jstanley0 profile image
Jeremy Stanley

I have often wondered if it’d be possible to make a compiled Ruby. This is exciting! Thank you for this introduction!

Collapse
 
taw profile image
Tomasz Wegrzanowski

TruffleRuby tries to do high performance compiled Ruby. Crystal looks very Ruby-like for simple programs, and that's amazing, but it's not like you could really run Rails on it.

Collapse
 
jstanley0 profile image
Jeremy Stanley

lol, although I do Ruby on Rails for my day job, running Rails on Crystal was nowhere in my mind. Rails relies heavily on stuff like #method_missing? that could not exist in a compiled language.

I should have been clearer--I was thinking less about an actual compiled Ruby implementation and more about a compiled language that preserves Ruby's syntax and expressiveness as much as possible. Over the holiday break I used some Advent of Code problems to try and learn a few new languages, including Rust and Swift. I noticed places where these languages borrow from Ruby and I thought, why not just go all in? So I'm excited to see there exists a language that does just that.

Really enjoying this article series, btw!

Thread Thread
 
jstanley0 profile image
Jeremy Stanley

and I could be clearer again, by "compiled language" I mean one that outputs a standalone binary... JIT compilation like TruffleRuby feels more like an optimized interpreter to me - not one I was familiar with before, though, and it looks like they're doing a better job of optimizing performance than JRuby or MRI 3.

of course, now I'm remembering how you pointed out the apps on my phone are compiled from some form of bytecode into ARM64 machine code at runtime. so what even is a compiler anymore?

Thread Thread
 
taw profile image
Tomasz Wegrzanowski

Crystal can't support runtime method_missing, but it covers some cases with compile time macros.
It might be good enough for DSLs.

For things like ORMs, all Crystal web frameworks seem to use explicit column lists in the model.

The compiled vs interpreted distinction is really blurred as most languages use some sort of JIT, but some are a lot more compilation friendly than others. Like in Ruby Integer#+ can be redefined at any time while the program is runtime, that's really difficult to JIT efficiently, and who's actually doing it anyway, even in Ruby?

Collapse
 
marcellourbani profile image
Marcello Urbani

Hey Tomasz
Quite impressive type inference!

I think the issue with Haskell is that + is not a valid string operator.
Should you define a Num instance for [a] it works. I don't have to tell you it's a bad idea though

A more idiomatic analogue would be to define a Semigroup instance for Int, Double,...
Not sure whether you can define a single one for all numbers

PS: IMHO having "a" + "b" to return "ab" is a bad idea, expecially with dynamic languages

Collapse
 
taw profile image
Tomasz Wegrzanowski

Most languages, dynamically or statically typed, support "a" + "b" without it causing any issues, and that's very natural code. "a" + 7 is a potential problem, and it's usually disallowed.

Haskell's problem here is that it doesn't want String to be a primitive type, it's just [Char]. This is generally a poor design, and most language rightfully treat String as a basic type, not a list or array of anything.

Anyway, I tried to get Haskell to support this by defining some new type classes, with String being an instance, but Haskell doesn't want composite type like [Char] to be an instance, even with various language extensions enabled.

I'm not sure if there's any way to force this. I managed to do this for Scala type classes (Episode 57), but not for Haskell. Maybe someone else has better luck with this.

Collapse
 
marcellourbani profile image
Marcello Urbani

Yep, and mopst people use Text instead
My point on + to concatenate strings was not that it's unpopular, just that restricting it to numbers is a reasonable design choice