flooey.org

The Many Faces of Crystal

Jan 5, 2023

Last month I did 2022’s edition of Advent of Code, and, like every year, I did it in a language I had never used before. (Last year I did it in Common Lisp, for instance.)

This year’s choice was Crystal, a language the designers call “heavily inspired” by Ruby — it’s basically Ruby with precompilation and static types. I had never used Ruby before, so while I’m technically reflecting on using Crystal here, most of it would probably apply to Ruby as well.

Overall, the language is very well-suited to Advent of Code problems. It has built-in dictionaries and sets, garbage collection, a wide variety of handy methods on strings and arrays, and so on. I rarely had to consider modifying my algorithms or data structures in order to suit the language, the language was built to do what I wanted it to do.

However, I don’t think I’ll be spending any more time with it myself in the near term. The language is fine and productive, but it’s not anything more than that, and it has just enough minor quirks and annoyances that it left me wanting. Practically every area had some kind of weirdness, and there’s nothing in the language that distinguishes itself to make up for it like Rust’s borrow checker or Common Lisp’s development experience.

Types

Crystal’s headline feature is its addition of static types to Ruby, so we might as well start there. It’s a robust type system, including supporting more modern features like union types that I think should be table stakes at this point. Advent of Code problems don’t tend to require complex types, so I didn’t exercise this heavily, but it seemed rich enough to satisfy the needs of much larger programs.

The type inference is solid, so writing types is frequently unnecessary, but you can do so if you want to avoid bugs (eg, in method arguments). The only minor complaint there is having to specify the type for empty arrays; it’d be convenient if it inferred a type for them from type of elements added to them the way TypeScript does, but that’s definitely in nice-to-have territory.

The major issue I ran into here is the alias keyword, which I encountered when trying to define a recursive type (specifically, a dictionary that can contain an object of that same type as a value). Despite the docs saying “Aliases are useful … to be able to talk about recursive types”, they are not. They just don’t work for recursive types.

For example, if you define a recursive type with an alias, you can’t then actually construct one in the normal way:

icr:1:0> alias Normal = Hash(Int32, Int32)
=> nil
icr:2:0> Normal.new
=> {}
icr:3:0> alias Recursive = Hash(Int32, Int32 | Recursive)
=> nil
icr:4:0> Recursive.new
error in line 1
Error: undefined method 'new' for Recursive.class

If you do manage to construct one, you can’t use it:

icr:1:0> alias Recursive = Hash(Int32, Int32 | Recursive)
=> nil
icr:2:0> a, b = {} of Int32 => Int32 | Recursive, {} of Int32 => Int32 | Recursive
=> {}
icr:3:0> a[0] = b
error in line 1
Error: instantiating 'Hash(Int32, Hash(Int32, Recursive | Int32) | Int32)#[]=(Int32, Hash(Int32, Hash(Int32, Recursive | Int32) | Int32))'

... lots of error spew ...

 1999 | def initialize(@hash : UInt32, @key : K, @value : V)
                                                 ^-----
Error: instance variable '@value' of Hash::Entry(Int32, Hash(Int32, Recursive | Int32) | Int32)
must be (Hash(Int32, Recursive | Int32) | Int32), not Hash(Int32, Hash(Int32, Recursive | Int32) | Int32)

None of this seems to work even a little bit. I’m honestly not sure why that sentence is present in the docs, and indeed there’s a GitHub issue suggesting that they should be removed from the language entirely.

Everything Is An Object

Everything in Crystal is an object, down to integers and booleans and nil. I think this is a nice philosophy, as it lets you put methods on everything and make Crystal code have a consistent style, and presumably Crystal’s compiler is (or could be made) smart enough to alleviate any performance penalty. Being able to just call my_thing.to_s without having to care about what kind of thing you have is just pleasant.

And on top of that, Crystal allows operator overloading, so you can make all your objects work like built-in objects. (See also one of my favorite talks.)

However, the implementation of some of these methods in the standard library leaves me scratching my head. As a simple example, + on Int32 is defined to return an Int32, and + on an Int64 is defined to return an Int64. This means that if you perform Int32 + Int64 you get a different type of value than Int64 + Int32. And even worse:

icr:1:0> a = 2_i64**32
=> 4294967296
icr:2:0> a + 1
=> 4294967297
icr:3:0> 1 + a
Unhandled exception: Arithmetic overflow (OverflowError)

This came up surprisingly often, because several of the Advent of Code problems fit into 32-bit ints in part 1 and then require you to switch to 64-bit ones in part 2. If you miss adding one _i64 suffix, then your code often failed with an overflow exception some distance away from the incorrect value, because you happened to add things in the “wrong” order somewhere.

Blocks

Crystal has the concept of blocks, which is basically lambdas, and once you get used to them they fit into the language really nicely. They’re used for all kinds of looping, and indeed in many places where I used a while loop early on, I later figured out I should have used a block:

i = 0
while i < 10
  # do something
end

versus

(0...10).each do |i|
  # do something
end

I confess that until writing this post, though, I really had no idea how they worked. I previously had thought there was no way to stop an each, expecting it to work like a lambda in other languages like JavaScript, and had used a while loop in those cases where I needed an early stop. This turns out not to be the case; you can break from inside a block just as well, and it will break the each loop as if it were a while loop, and similarly use next to skip to the next iteration.

Even without that knowledge, though, I found the block style of programming a very nice one, and I think it’s even nicer with it.

Tooling

Crystal’s tooling is slick, for the most part. As is typical for a modern language, it comes with a single command (crystal) that houses all the tooling, and that tooling is reasonably extensive. Most notably, it supports both compiling code into a binary (including various optimization levels and such) and just running a source file directly without having to compile it yourself first.

There’s a REPL (accessed via crystal i), which is a highly appreciated feature for a precompiled language and not at all typical. The REPL goes beyond just the basics to provide some fancy features as well, such as syntax highlighting and automatic indentation. Clearly some real work has gone into making it friendly. Unfortunately, it doesn’t support any kind of readline-like functionality, which means using it for anything beyond the most basic kinds of what-does-this-function-return is pretty much a non-starter.

One big failing of the compiler is that it can only produce a single error at a time. This really puts a kink in the editing cycle. Not having any idea how many roundtrips to the compiler I was going to have to make before I had working code (or what the next one might be) was quite frustrating.

Punctuation

Crystal has a punctuation problem. There are a lot of different uses of punctuation, and a bunch of them are inconsistent or conflict with each other. This is a constant low-level annoyance when writing code. A few examples:

Also, I expect this is probably controversial, but I’m unconvinced about the ability to omit parentheses from method calls. It makes pattern matching when reading code error prone, because there’s very little syntactic distinction between different constructs. puts a might be printing the value of a variable named a or the return value of calling a function named a, and you don’t know which; func a, b (call func with two arguments) and func a b (call func with the single argument a(b)) look very similar. I’m not sure the benefit in terms of ease of writing makes up for it, and I expect it would only get worse if I was trying to write programs more than 100 lines long.

Conclusion

Overall, I found Crystal a pleasant language to use, but with enough annoyances that I don’t find it likely I return to it anytime soon.