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!