A Rubyist looks at Crystal

A closer look at Crystal, a new and interesting programming language, also known to be as fast as C and as slick as Ruby.

Currently there’s no shortage of new and interesting programming languages. It almost seems impossible to spend any time on Hacker News or Twitter and not see announcements of new languages on a weekly basis. Being a huge programming language nerd I generally read up on most of them, but few manage to hold my interest for long. Crystal however is one new language that did and the tag line on their BountySource page sure contributed to that:


Fast as C, slick as Ruby



I’ve been a Rubyist since sometime around 2004, and love the language and its expressiveness. And while it’s certainly not a racehorse, it’s generally fast enough for my needs. Still, the prospect of a syntactically similar language with great performance is entincing, so I decided to spend some time with Crystal and write down my impressions.

First impressions

The project’s web site states five goals for Crystal, the first of which is “hav[ing] a syntax similar to Ruby (but compatibility with it is not a goal)”.

Crystal sure looks like Ruby. In fact, it is possible to write (trivial) programs that will be accepted by both compilers. However, as stated compatibility is not a goal, and that’s probably for the better, since despite syntactic similarities the semantics of the languages are actually quite different.

The following class definition gives a first impression of Crystal:

class Person
  property age
  getter name
  
  def initialize(@name : String, @age : Int32)
  end
end

p = Person.new("Michael", 35)
p.name #=> "Michael" : String
p.age #=> 35 : Int32
p.age += 1 #=> 36 : Int32
p.name = "Other person" # undefined method 'name=' for Person

While this does look quite a bit like Ruby, there are some noticeable differences. The more readable property and getter replace attr_accessor and attr_reader. The initialize method uses a shortcut for directly assigning its arguments to instance variables and also uses type restrictions which due to Crystal’s very good type inference are not often necessary.

The following example showcases another big difference between the two languages, arity and type based method overloading, a feature I’ve often longed for in Ruby:

class Dog
  def greet
    "Woof! Woof!"
  end
  
  def greet(name : String)
    "Woof #{name}!"
  end
  
  def greet(times : Int32)
    greet * times
  end
end

d = Dog.new
d.greet #=> "Woof! Woof!" : String
d.greet("dear readers!") #=> "Woof dear readers!" : String
d.greet(3) #=> "Woof! Woof!Woof! Woof!Woof! Woof!" : String

Here we define three different implementation of Dog#greet and the compiler correctly dispatches to the version with the correct arity/type combination. Personally I find this much nicer than conditionals checking for the presence of optional arguments.

Another area where Crystal’s syntax beats Ruby’s is in the block short form (see Symbol#to_proc):

%w(foo bar).map(&.upcase) #=> ["FOO", "BAR"] : Array(String)
(1..5).map(&.+(2)) #=> [3, 4, 5, 6, 7] : Array(Int32)

%w(foo bar).map(&:upcase) #=> ["FOO", "BAR"]
(1..5).map(&2.method(:+)) #=> [3, 4, 5, 6, 7]

While Ruby uses &:method_name Crystal uses &.method_name which I find more intent-revealing. It also makes it possible to pass arguments to the invoked method, which is generally not possible in Ruby (or only with some trickery as in the example above).

There are some other minor syntactic differences, like strings always being enclosed in double quotes (single quotes denote character literals) or access modifiers being part of method declarations, but none of them should be overly confusing for Ruby developers (though I do sometimes find it hard to overcome muscle memory).

Types

Crystal is a statically typed language. However, this does not mean that your code needs to be littered with type annotations, the compiler generally does a great job at infering types. However, there are certain scenarios where the language needs your help, in which case it will provide you with a helpful error message.

Let’s look at an example:
class Foo
  def initialize(a)
    @a = a
  end
end

Foo.new(1)

This innocent looking example will not compile, but instead produce the following compile time error:
Can’t infer the type of instance variable ‘@a’ of Foo
The type of a instance variable, if not declared explicitly with
`@a : Type`, is inferred from assignments to it across
the whole program.
The assignments must look like this:
1. `@a = 1` (or other literals), inferred to the literal’s type
2. `@a = Type.new`, type is inferred to be Type
3. `@a = Type.method`, where `method` has a return type
annotation, type is inferred from it
4. `@a = arg`, with ‘arg’ being a method argument with a
type restriction ‘Type’, type is inferred to be Type
5. `@a = arg`, with ‘arg’ being a method argument with a
default value, type is inferred using rules 1, 2 and 3 from it
6. `@a = uninitialized Type`, type is inferred to be Type
7. `@a = LibSome.func`, and `LibSome` is a `lib`, type
is inferred from that fun.
8. `LibSome.func(out @a)`, 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 ‘@a’ of Foo

While this message is admittedly rather long, it gives a thorough explanation of how Crystal’s type inference works. The correct fix for the program shown above is a type restriction on the method argument as pointed out in 4.

def initialize(a : Int32)
  @a = a
end

# or shorter
def initialize(@a: Int32)
end

Why bother with static types at all I hear seasoned Rubyists ask at this point, and the question is not without merit. So let’s look at example where Crystal’s type inference and clever use of union types saves us from a runtime error.

found = %w(foo bar).find { "foo" }
typeof(found) #=> (String | Nil)
found.upcase # undefined method 'upcase' for Nil (compile-time type is (String | Nil))
found.upcase if found #=> "FOO" : String

Here Enumerable#find will either return a string or nil, which in Ruby would lead to a RuntimeError when no element was found and we try to call the upcase method on nil. However, the Crystal compiler here uses the union type String | Nil for found and will not compile this code since not all types in the union know how to respond to the upcase message. So to actually get this program to compile we need to explicitly guard against the nil case as shown in the last line of the code above.

This was of course a contrived and short example, but it shows how Crystal saved us from an error during the program’s execution without any extra work on our part.

Grab bag

To finish off this first look at Crystal, let’s quickly go over some more nice features that Ruby doesn’t offer.

Structs

Crystal offers more than one way to define classes. Instead of using the class keyword, they can also be defined with struct.

struct Point
  property x, y
  
  def initialize(@x : Int32, @y : Int32)
  end
end

p = Point.new(5, 3)
p.class #=> Point : Class

While the above also defines a class, Points will be allocated on the stack, not the heap and have pass-by-value semantics. Personally I think this is a neat addition for immutable types which gives you more control over your program’s memory footprint.

Enums

Enums
allow us to group related values:

enum Suit
  Spades
  Diamonds
  Clubs
  Hearts
end

Suit::Spades.value #=> 0

They are often used where Rubyists might use symbols, with the added advantage of type checking (i.e. Suit::Club instead of Suit::Clubs will lead to a compiler error, whereas :club may result in incorrect runtime behavior. Since enums can define their own methods just like classes and structs, they are very useful for grouping related values and associated behavior.

Tuples

Like Python Crystal has a tuple type. Tuples are defined with {element1, element2,...} and are great for temporarily grouping related values. Internally they are also used for multiple assignments like a, b = 1, 2where Ruby uses Array instead.

Summary

Crystal is a nice language that feels a lot like Ruby, while compiling to fast and efficient code via LLVM. I hope this short introduction managed to pique your interest in the language and maybe even motivated you to give it a try. In the next article I plan on demonstrating some of the more advanced features, like metaprogramming with macros, interfacing with C and concurrency. Stay tuned!
Like 213 likes
Michael Kohl
Share:

Join the conversation

This will be shown public
All comments are moderated

Comments

thoran
August 20th, 2020
Was there ever or is there ever going to be a "next article"?

Get our stories delivered

From us to your inbox weekly.