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 it’s 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.
Note: This was originally published on my blog, the original post can be found here.
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 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, clarity 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 &:upcase
Crystal uses &.upcase
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 strongly 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
, wheremethod
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
, andLibSome
is alib
, type
is inferred from that fun.
8.LibSome.func(out @a)
, andLibSome
is alib
, 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 explicitly guard against the nil
case as shown in the last line of the example.
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 look at 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, Struct
s 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. Suits::Club
instead of Suits::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, 2
where Ruby uses Array
s 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 showcasing some of the more advanced features, like metaprogramming with macros, interfacing with C and concurrency. Stay tuned!