You might have heard mentions of the Crystal programming language of late. It is a language that looks very similar to Ruby. In fact, many Ruby programs are also valid Crystal programs. However, it must be emphasized that this is a mere side effect of the syntax of the language and is not a goal of the project.
One of the most interesting things about Crystal is that it is a statically type-checked language, yet it doesn’t require the programmer to sprinkle types everywhere like Java. Crystal compiles down to efficient code, which means Crystal programs are much faster than Ruby programs.
In this article we will take a quick dive into Crystal. This is by no means a comprehensive walk-through of all the features in Crystal. Instead, we are going to develop a concurrent Chuck Norris joke fetcher with the lens of a Rubyist. This involves making HTTP GET requests and also some JSON parsing.
We will see how far that takes us, along with looking into the facilities that Crystal provides that makes things more convenient.
Getting Crystal
I will gently direct you to the installation guide since it would undoubtedly do a far superior job at covering installation for the various Linux distributions, Mac OSX, and other Unix systems. For the MS Windows users out there: Sorry! Crystal requires a Linux/Unix based system.
The Sequential Version
We begin with the sequential version first. First, we need an HTTP client to fetch the jokes. Our data is going to come from The Internet Chuck Norris Database. In order to retrieve a joke, you just need to call out to the URL such as:
http://api.icndb.com/jokes/123
This returns:
{
"type":"success",
"value":{
"id":123,
"joke":"Some people wear Superman pajamas. Superman wears Chuck Norris pajamas.",
"categories":[
]
}
}
Let’s create a new class and name it chucky.cr
. While we are at it, we shall implement Chucky#get_joke(id)
:
require "http/client"
require "json"
class Chucky
def get_joke(id)
response = HTTP::Client.get "http://api.icndb.com/jokes/#{id}"
JSON.parse(response.body)["value"]["joke"]
end
end
c = Chucky.new
puts c.get_joke(20)
Crystal comes built-in with an HTTP client and JSON parser. Let’s try running this in the terminal, which is done by passing the file name to the crystal
command:
$ crystal chucky.cr
The Chuck Norris military unit was not used in the game Civilization 4, because a single Chuck Norris could defeat the entire combined nations of the world in one turn.
Great success! So far, so good. Say we want to retrieve a bunch of jokes. Seems pretty straightforward:
class Chucky
...other methods...
def get_jokes(ids : Array(Int32))
ids.map do |id|
get_joke(id)
end
end
end
The first thing you’ll notice that’s different from Ruby is that the type of ids
is being explicitly defined as Array(Int32)
. This is read as “an Array
of Int32
s”. To be clear, we could have left this out. However, since I’m pretty sure that ids
are always Int32
s, I want to be extremely clear and avoid mistakes such as:
c = Chucky.new
puts c.get_jokes(["20"])
In fact, when you try running this, you’ll get a compile-time error:
Error in ./chucky.cr:50: no overload matches 'Chucky#get_jokes' with type Array(String) Overloads are:
- Chucky#get_jokes(ids : Array(Int32))
puts c.get_jokes(["20"])
^~~~~~~~~
The astute reader would point out that it makes way more sense to specify the type on the argument in Chucky#get_joke
instead, and she would be absolutely right. In fact, you can also specify the return type of the method:
class Chucky
def get_joke(id : Int32) : String
# ...
end
def get_jokes(ids : Array(Int32)) : Array(String)
# ...
end
end
c = Chucky.new
puts c.get_jokes([20]) # <-- Change this back to an Array(Int32)
Let’s try again:
% crystal chucky.cr
Error in ./chucky.cr:53: instantiating 'Chucky#get_jokes(Array(Int32))'
puts c.get_jokes([20])
^~~~~~~~~
in ./chucky.cr:20: instantiating 'get_joke(Int32)'
get_joke(id)
^~~~~~~~
in ./chucky.cr:24: type must be String, not JSON::Any
def get_joke(id : Int32) : String
^~~~~~~~
Whoops! The compiler caught something! So it seems like Chucky#get_joke(id)
doesn’t return a String
, but instead it returns a JSON::Any
. This is great, because the compiler has caught one of our bad assumptions.
Now we are left with two choices. Either we switch our Chucky#get_joke[s]
to return JSON::Any
or we continue using String
. My vote is for String
, because any client code shouldn’t care that the jokes are of JSON::Any
. Let’s modify the Chucky#get_joke(id)
. For good measure, we also handle the case where there’s some parsing error and simply return an empty String
:
class Chucky
# ...
def get_joke(id : Int32) : String
response = HTTP::Client.get "http://api.icndb.com/jokes/#{id}"
JSON.parse(response.body)["value"]["joke"].to_s rescue ""
end
end
Everything should run fine now. We have a slight problem, though. Try to imagine what happens when we do this:
c = Chucky.new
puts c.get_jokes[20, 30, 40]
In our current implementation, the jokes will be fetched sequentially. This means that the time take for Chucky#get_jokes(ids)
to complete is the total time taken to fetch all three jokes.
We can do better!
The Concurrent Version
Now that we have made it work, let’s make it fast. Crystal comes with a concurrency primitive called fibers, which are basically a lighter-weight version of threads. The other concurrency primitive are channels. If you have done any Golang, this is basically the same idea. Channels are a way for fibers to communicate without the headaches of shared memory, locks, and mutexes.
We are going to use both fibers and channels to concurrently fetch the jokes.
Here’s the main idea. We will create a channel in the main fiber. Each call to Chucky#get_joke(id)
will be done in a fiber. Once the joke is fetched, we will then send the channel the result.
class Chucky
# ...
def get_jokes(ids : Array(Int32)) : Array(String)
# 1. Create channel in the main fiber.
chan = Channel(String).new
# 2. Execute get_joke in a fiber. Send the result to the channel.
ids.each do |x|
spawn do
chan.send(get_joke(x))
end
end
# 3. Receive the results.
(1..ids.size).map do |x|
chan.receive
end
end
end
First, we create a channel. Note that we need to specify the type of the channel.
Next, we execute get_joke(id)
in a fiber. This is done in a spawn
block. Once we get a result from get_joke(id)
, we send the results to the previously created channel.
Sending a channel a value is only one piece of the puzzle. In order to get a value out of a channel we need to call Channel#receive
. Each time we call receive we get back one value. If there are not values (yet), it will block. Since we know the size of ids
, we just need to call Channel#receive
ids.size
times.
Try running the program again. This time, the total time taken is around the time taken for the longest request.
Crystal Yay’s
Crystal looks really impressive. I like that it is statically typed, yet the type system doesn’t get in the way, especially since you can mostly do without specifying the types.
Being a compiled language (in LLVM no less), it is not surprising that some of the performance benchmarks I’ve seen are very impressive.
I like fibers and channels as concurrency primitives. I hope there are more to come. There are a few other language features like macros and structs that make it stand out from Ruby.
Crystal Meh’s
There is no REPL. For a Rubyist, this feels almost unimaginable to do any Ruby programming without the faithful IRB (or Pry).
While there is concurrency, there is no parallelism yet. This means that a Crystal program only uses one core. However, it’s still a young language so this is not the final story.
Finally, the Crystal community has the uphill task of creating a thriving ecosystem, such as Rubygems.
Thanks for Reading!
I hope you enjoyed this quick dive into Crystal. If you are interested in learning more, I encourage you to read through the documentation.
Happy Crystalling!