Need a Second Opinion on Your Ruby Code? Ask Crystal
Running the Crystal compiler on your Ruby code
is like asking a second doctor for their opinion.
When it comes to your health, you don’t hesitate to get a second opinion. Doctors don’t always agree, and a second doctor’s appointment is always time well spent when it comes to staying healthy.
But what about your code? A code review is similar to going to see a doctor: Someone examines your code, looks for potential problems and hopefully gives you some advice you can take away. Sadly, however, we don’t always have the time or opportunity for a real code review.
Recently I’ve been learning about the Crystal programming language, a variation on Ruby syntax implemented on the LLVM platform. What’s interesting about Crystal is that it uses static types while at the same time retaining much of Ruby’s original elegance and natural feel. The two languages are so similar, in fact, it’s possible to use the Crystal compiler to parse your Ruby code after making just a few superficial changes. This can be a great way to get helpful feedback on your Ruby code, a free code review from a dramatically different perspective.
Using a compiler for one language on code from another sounds crazy. Will it really work? To find out, let’s look at a simple example.
Rock Stars
Here’s a Ruby class that represents the lead singer of a rock band, and a couple of methods that use it:
class Singer attr_reader :band, :first_name, :last_name def initialize(band, first_name, last_name) @band = band @first_name = first_name @last_name = last_name end end def lead_singer_for(band, singers) singers.find{|s| s.band == band} end def longest_last_name(singers) singers.map{|s| s.last_name}.max_by{|name| name.size } end
This is similar to Ruby code I write everyday: small classes containing a few instance variables, and short, simple methods. With some test data we can try out this code to see if it works:
lead_singers = [ Singer.new("The Rolling Stones", "Mick", "Jagger"), Singer.new("Queen", "Freddie", "Mercury"), Singer.new("The Doors", "Jim", "Morrison") ] singer = lead_singer_for('The Doors', lead_singers) puts "#{singer.first_name} #{singer.last_name}" # => Jim Morrison puts longest_last_name(lead_singers) # => Morrison
Everything works well. On a real project I’d express this as a series of Minitest expectations, and seeing green I’d go ahead and check it into Git on a branch and ask a colleague for a code review.
But what if no one is around or even awake in my time zone? Or what if I’m working alone on this? Well, I’d have to review my own code alone.
Code Reviewing Yourself
I believe in the medical world doctors have a legal or at least an ethical prohibition on treating themselves, for obvious reasons. And just as giving yourself a physical exam makes no sense, reviewing your own code doesn’t either. You don’t have perspective on what you wrote, especially just after you finish writing it. Usually a fresh pair of eyes will see mistakes that you can’t see.
But in this case I have no choice - I decide to review my own code before
checking it in. And right away I find a problem: I call find
but never consider
whether the return value could be nil
:
def lead_singer_for(band, singers) singers.find{|s| s.band == band} end
In my test, I happened to pick a band name that existed in the test data set, but if I misspell it or look for a different band, I would get an error:
singer = lead_singer_for('Doors', lead_singers) puts "#{singer.first_name} #{singer.last_name}" # => undefined method `first_name' for nil:NilClass (NoMethodError)
I make this sort of mistake quite often, actually. In fact, I do it so often
that checking for nil
after calling find
is part of my mental checklist for
code reviews.
Superficial Syntax Differences: Crystal vs. Ruby
But suppose I was tired or in a rush; I might not have noticed the call to
find
. And often forgetting to check for a nil
return value isn’t as obvious as
it is here in this example. What if there was a way to find code issues the
Ruby interpreter doesn’t report? Imagine if this code review could happen before
my code is ever deployed or used?
There is; we just need to run my Ruby code through the Crystal compiler:
$ cp lead_singers.rb lead_singers.cr $ crystal lead_singers.cr
What? Pat, this is nuts. Crystal, while superficially similar to Ruby, is a very different language. How in the world can I use a compiler written for one language on code written in another?
Well, you’re right. I run into a syntax error immediately:
$ crystal lead_singers.cr Syntax error in ./lead_singers.cr:27: unterminated char literal, use double quotes for strings singer = lead_singer_for('Doors', lead_singers) ^
The most common difference of all between Crystal and Ruby is that Crystal uses only double quotes for string literals, while Ruby allows either single or double quotes. (Some people think Ruby should limit us to double quotes also.) A quick search and replace solves this problem:
singer = lead_singer_for("Doors", lead_singers)
Let’s compile again:
$ crystal lead_singers.cr Error in ./lead_singers.cr:3: undefined method 'attr_reader' attr_reader :band, :first_name, :last_name ^~~~~~~~~~~
We’ve run into another difference: Crystal uses the property
keyword
(actually a macro) instead of attr_reader
, attr_writer
and attr_accessor
.
Easy enough to fix:
class Singer property :band, :first_name, :last_name def initialize(band, first_name, last_name) @band = band @first_name = first_name @last_name = last_name end end
Now let’s try again. Compiling my Ruby code using Crystal for a third time, I get:
$ crystal lead_singers.cr Error in ./lead_singers.cr:22: instantiating 'Singer:Class#new(String, String, String)' Singer.new("The Rolling Stones", "Mick", "Jagger"), ^~~ instantiating 'Singer#initialize(String, String, String)' in ./lead_singers.cr:6: Can't infer the type of instance variable '@band' of Singer The type of a instance variable, if not declared explicitly with `@band : Type`, is inferred from assignments to it across the whole program. The assignments must look like this: 1. `@band = 1` (or other literals), inferred to the literal's type 2. `@band = Type.new`, type is inferred to be Type 3. `@band = Type.method`, where `method` has a return type annotation, type is inferred from it 4. `@band = arg`, with 'arg' being a method argument with a type restriction 'Type', type is inferred to be Type 5. `@band = arg`, with 'arg' being a method argument with a default value, type is inferred using rules 1, 2 and 3 from it 6. `@band = uninitialized Type`, type is inferred to be Type 7. `@band = LibSome.func`, and `LibSome` is a `lib`, type is inferred from that fun. 8. `LibSome.func(out @band)`, and `LibSome` is a `lib`, type is inferred from that fun argument. Other assignments have no effect on its type. Can't infer the type of instance variable '@band' of Singer @band = band ^~~~~
Oh my God, I’ve made a mistake so terrible the Crystal compiler has given me an error message an entire page long! This is never going to work. As you might guess, I’ve fixed all of the superficial syntax issues. Now my Ruby code is essentially Crystal code. This error is telling me I haven’t picked a type for one of my instance variables, which I’ll do next.
But let’s stop for a moment to review what I’ve changed so far:
- First, I replaced single quotes with double quotes for all of my string literals.
- Then, I changed
attr_reader
toproperty
.
There are a few other superficial differences you’ll run into between Ruby and Crystal. Here are a few more I’ve come across:
include?
is calledincludes?
in Crystal. This reads better in English, but I suppose Crystal looses a bit of that charming Japanese style we’ve come to love in Ruby.- The
Symbol#to_proc
syntax doesn’t work in Crystal, for examplemap(&:method)
. Instead they’ve invented a new syntax for that idiom which doesn’t exist in Ruby:map(&.method)
. The Crystal team explains why on their blog. - Declaring an empty array
[]
or hash{}
requires a type definition, like this:[] of Int32
.
The syntax changes I had to deal with are quite small. In fact, it’s amazing the two languages are so similar. In just a few minutes I can change my code from Ruby, a dynamic language running with an interpreter, to Crystal, a statically typed language that compiles to LLVM byte code and later native machine language.
Think About Which Types to Use
Like an X-Ray, Crystal can find problems with
your Ruby code hidden underneath the surface.
Of course, now that I’m using a language with static types I have to pick types for my variables. If you've ever used an older, statically typed language like Java or C, you know how tedious and verbose this can be. In fact, avoiding static types is why many of us started to use Ruby in the first place.
But one of Crystal’s strengths is that it can guess which type to use for each value in your code based on a series of rules. I don’t have to explicitly write the type for every variable, method argument or return value in my code. This might even be a preview of how Ruby might work in the future.
However, in some cases Crystal can’t guess which type to use. That’s what
happened here. Take the time to read through the page-long error message; it’s
quite helpful. It explains all of the patterns the Crystal compiler looked for
in my code, @band = 1
, @band = Type.new
etc. But because my assignment @band = band
didn’t fall into any of these categories, Crystal couldn’t figure out what
type of value @band
represents:
in ./lead_singers.cr:6: Can't infer the type of instance variable '@band' of Singer
To fix this, I’ll just declare the type of my @band
variable right where I
declare it, along with my two other instance variables:
class Singer property band : String property first_name : String property last_name : String def initialize(band, first_name, last_name) @band = band @first_name = first_name @last_name = last_name end end
Notice here I use property
three times, specifying each variable’s
name and type. My three variables, band
, first_name
and last_name
are all
strings, so I just need to tell Crystal this using a more verbose declaration.
Now we should be good to go! Let's try compiling again:
$ crystal lead_singers.cr Error in ./lead_singers.cr:30: undefined method 'first_name' for Nil (compile-time type is (Singer | Nil)) puts "#{singer.first_name} #{singer.last_name}" ^~~~~~~~~~ ================================================================================ Nil trace: ./lead_singers.cr:29 singer = lead_singer_for("Doors", lead_singers) ^~~~~~ ./lead_singers.cr:29 singer = lead_singer_for("Doors", lead_singers) ^~~~~~~~~~~~~~~ ./lead_singers.cr:15 def lead_singer_for(band, singers) ^~~~~~~~~~~~~~~ ./lead_singers.cr:16 singers.find{|s| s.band == band} ^~~~ /Users/pat/bllvm/crystal/src/enumerable.cr:228 def find(if_none = nil) /Users/pat/bllvm/crystal/src/enumerable.cr:232 if_none ^~~~~~~ /Users/pat/bllvm/crystal/src/enumerable.cr:228 def find(if_none = nil) ^
Ugh; more trouble. Another page-long error message. Maybe I should just forget all about Crystal and go back to writing Ruby.
Understanding a Crystal Nil Trace
Instead, I decide to take some time to understand what Crystal is telling me. I focus at the beginning of the Crystal error message:
Error in ./lead_singers.cr:30: undefined method 'first_name' for Nil (compile-time type is (Singer | Nil)) puts "#{singer.first_name} #{singer.last_name}" ^~~~~~~~~~
This looks unfamiliar to me, a Ruby developer, at first. The message is similar
to the error I saw earlier in Ruby when I didn’t check the return value for
find
. Recall that was “undefined method `first_name' for nil:NilClass
(NoMethodError)”. Crystal seems to be telling me the same thing: “undefined
method 'first_name' for Nil.”
And it is. But instead of giving me a runtime exception, Crystal is giving me a
compile time error based on types. Ruby didn’t report the problem until I ran
my Ruby code, when Ruby actually tried to call the first_name
method on the
NilClass
class. But Crystal’s compiler has found the problem before my code was
ever run. It knows the Nil
class doesn’t have a first_name
method at compile
time.
But why does Crystal think there is a Nil
class in my code? I just told it my
three instance variables are strings:
property band : String property first_name : String property last_name : String
What the Crystal compiler did is quite interesting! While compiling my code, it
saw that I use the @band
instance variable in the lead_singer_for
method:
def lead_singer_for(band, singers) singers.find{|s| s.band == band} end
Internally, the Crystal compiler now has to decide what type lead_singer_for
returns. That’s obvious, isn’t it? It should return a Singer
. The call to find
returns a Singer
object, the first element of the singers
array which matches
the band, the element for which the block returns true
.
But what if the band name doesn’t match any singers? What if the block never
returns true
for any element in the array? As we know from Ruby, in that case
lead_singer_for
would return nil
. So lead_singer_for
might return nil
or it
might return a singer.
Crystal’s type system has a solution for this situation: a union
type.
Crystal decides lead_singer_for
returns a (Singer | Nil)
type, which it
mentions in the error message. Now when I use this return value, Crystal’s
compiler knows to check whether the first_name
and last_name
methods are
defined for every class in that union type: Singer
and Nil
.
The rest of the long error message is known as a “Nil trace.” To help us understand what is wrong, Crystal backtracks through the code starting from where the missing method was found to where the offending type was introduced. You can read the Nil trace above for yourself. It starts with:
./lead_singers.cr:29 singer = lead_singer_for("Doors", lead_singers) ^~~~~~
And reading down you can see where the Nil
type was actually introduced:
/Users/pat/bllvm/crystal/src/enumerable.cr:228 def find(if_none = nil)
As you can see, the Nil
type is a default value passed to the Enumerable#find
method, which I call in lead_singer_for
. Crystal’s standard library is
entirely implemented using Crystal. This means if I’m curious (and I am) I can
read how Crystal implements all of the Enumerable
methods. I could even go
and experiment with the language by modifying them.
In fact, the Crystal compiler itself is implemented with Crystal! Interested in learning about how a real world compiler works but don’t have time to learn C or C++? Read the Crystal source code.
Think Twice About Which Types to Use
Now back to my example. I’m done, right? Recall in my Ruby code I added a check
for the return value of lead_singer_for
:
singer = lead_singer_for("Doors", lead_singers) if singer puts "#{singer.first_name} #{singer.last_name}" else puts "Not found" end
The same fix will work for Crystal. The Crystal compiler is clever enough to
know that inside the first branch of the if-statement the type of singer
is
Singer
and not Nil
. And in the second, else branch it is Nil
and not Singer
. It
splits up the union type again depending on the syntax of my program. Amazing.
But before I declare victory, this business about the (Singer | Nil)
union type
has got me thinking… Crystal decided that a nil
value can be introduced by my
code in a certain scenario. But maybe nil
should be a valid value for one of my
variables? After all, I’m dealing with rock stars. Sometimes rock stars become
so famous they decide they don’t need a last name any more. What about lead
singers like Sting, Bono or Prince? How would I represent them in my test data
set?
The answer is obvious: their singer objects would have a nil
last_name
value. I
would create them like this:
Singer.new("The Police", "Sting", nil)
In Ruby, this would have worked just fine. But Crystal objects:
$ crystal lead_singers.cr Error in ./lead_singers.cr:26: instantiating 'Singer:Class#new(String, String, Nil)' Singer.new("The Police", "Sting", nil), ^~~ instantiating 'Singer#initialize(String, String, Nil)' in ./lead_singers.cr:10: instance variable '@last_name' of Singer must be String, not Nil @last_name = last_name ^~~~~~~~~~
What do I do now? How can I save a nil
last name in my Singer
class? The
instance variables are strings and cannot hold nil
.
The answer is I picked the wrong type for last_name
. To accommodate
super-famous singers, I need to use the same union type we saw earlier:
class Singer property band : String property first_name : String property last_name : (String | Nil) def initialize(band, first_name, last_name) @band = band @first_name = first_name @last_name = last_name end end
Now I can create the Sting object no problem:
Singer.new("The Police", "Sting", nil)
Finally, we’re ready to compile my Ruby and move on!
$ crystal lead_singers.cr Error in ./lead_singers.cr:37: instantiating 'longest_last_name(Array(Singer))' puts longest_last_name(lead_singers) ^~~~~~~~~~~~~~~~~ in ./lead_singers.cr:20: undefined method 'size' for Nil (compile-time type is (String | Nil)) singers.map{|s| s.last_name}.max_by{|name| name.size } ^~~~ ================================================================================ Nil trace: ./lead_singers.cr:20 singers.map{|s| s.last_name}.max_by{|name| name.size } ^~~~
Once again the Crystal compiler has stopped me in my tracks. When will I ever get this right? Is this another Ruby vs. Crystal difference? Another subtlety of the Crystal type system I need to learn about?
Static Types Reveal a Hidden Problem
No. Crystal has found a real problem with my Ruby code, a problem I never
noticed. Because Sting doesn’t have a last name, the longest_last_name
method
runs into a problem:
def longest_last_name(singers) singers.map{|s| s.last_name}.max_by{|name| name.size } end
The first call to map
returns an array of last names, which now will contain
nil
. Then I pass that array into max_by
which converts the names into
corresponding name lengths, and then returns the longest name.
Now that I know where to look, it’s easy to see the problem: max_by
will pass
nil
to the second block for Sting’s missing last name, and the block will then
try to call the size
method on nil
. Easy enough to fix:
def longest_last_name(singers) singers.map{|s| s.last_name}.compact.max_by{|name| name.size } end
Using compact
, I remove the nil
element from the array of names, meaning the
size
method will never be called on nil
. Of course, now that I’m thinking about
nil
values and the longest_last_name
method, I realize that maybe all the
singers are super-famous and have no last names, or possibly there were no
singers to begin with. I tighten up my code even more:
def longest_last_name(singers) singers_with_last_names = singers.map{|s| s.last_name}.compact unless singers_with_last_names.empty? singers_with_last_names.max_by{|name| name.size } end end last_name = longest_last_name(lead_singers) if last_name puts last_name else puts "Not found" end
Now everything works!
One interesting footnote here: Ruby allows me to get away without checking for
an empty array using unless
. In Ruby if I call max_by
on an empty array it
simply returns nil
, meaning there is no maximum value at all. But Crystal is
even more strict: It raises an runtime exception "Empty enumerable
(Enumerable::EmptyError)". In a sense this is going a bit overboard, because nil
seems to me a valid result in this case. But on the other hand, calling max_by
on an empty array might be an indication of other problems in my code. Crystal
brings that to my attention, but with a runtime exception not a compile error.
Crystal reports runtime errors for other cases as well, for example looking for
a value in a hash when the key doesn’t exist:
hash = { "a" => 123 } puts hash["b"] # => Missing hash key: :b (KeyError)
The Crystal compiler expects a higher level of quality and thoroughness in my code than Ruby does, it seems to me.
Conclusion
There are two important concepts I took away from this exercise. First, using Ruby we depend on the completeness of our test suite in order to find and avoid mistakes. Precisely which values you choose for your test data set is very important. If I had thought of using Sting when I originally wrote my tests, I would have found the missing last name problem right away. But I didn’t.
Second, the most tedious and time consuming part of converting from Ruby to
Crystal, choosing a type for each value in my code, is of course the most
valuable step in the process. It wasn’t until I tried using (String | Nil)
for
the @last_name
variable that the Crystal compiler found the missing last name
problem for me.
You still may not be convinced. This was obviously a very contrived example and using the Crystal compiler on real world Ruby code won’t be easy. I agree. It would be pointless to try to compile a large Rails application using Crystal.
But look over your code. I would guess there are a few important methods or classes which are central to your application’s behavior and logic. Take an hour or two and copy and paste those important lines of code into a separate file, stub out any dependencies, and run it through the Crystal compiler. Take the time to convert your code to use static types. Take the time to think carefully about which types of values your code should be able to handle.
Bring your important Ruby code to the Crystal compiler for a second opinion. You might be surprised by what Crystal finds.