💾 Archived View for nanako.mooo.com › gemlog › 2022-07-10-a.gmi captured on 2023-01-29 at 03:16:29. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2022-07-16)

🚧 View Differences

-=-=-=-=-=-=-

Some Thoughts on the Crystal Programming Language

TL;DR: I love Common Lisp. I also love Crystal, but it's still a bit too rough

around the edges for me. I hope future versions change some things. I plan to

use Crystal mainly for stuff that touches the web, or small CLI programs.

Anyway...

I admit I'm in a somewhat privileged position. Programming is 100% nothing more

than a hobby for me these days. At one time I wrote programs for the place I

worked at (not officially as a "programmer", but as a system admin who wrote her

own tools and a few daemons), but anymore, it's just a hobby. And I honestly

prefer it that way. But one of the perks of it being a hobby? I can actually

be more picky about what language I use for my projects. And anymore, nine

times out of ten, things I write either end up written in Common Lisp (almost

always for SBCL) or Crystal.

The Crystal Programming Language

Steel Bank Common Lisp (SBCL)

I started learning and using Common Lisp sometime back in 2005 or so, and it was

primarily because of curiosity and for use at work (along with using a lot of

old-school C# at the time). So the academic side of using a Lisp, or focusing

on functional programming, was never really there for me. This seems to have

really shaped how I use the language, because to me, it's a language for getting

real-world things done. I use it for command line programs, bots, daemons,

whatever. The reason I've stuck with it so long is because it's so flexible and

powerful: I am able to express my program's design and structure much more

easily and quickly using it than almost any other language. Plus, maintenance

of my Lisp programs has proven to be much more enjoyable and easy than when I've

maintained things in other languages. In other words, it just works really well

for me and my way of approaching problems. Is it a perfect language? Nope, I

don't think there is a perfect one, and never will be. But it's about as close

to perfect as I can think of, especially considering the balance between power

and performance when using SBCL specifically.

Still, I've always tried to look at other languages to keep on top of things. ,

and just to add more languages to my repertoire. For a while I was really into

Go, up until they added modules (which I always felt was a botched/shoe-horned

implementation of them rather than something clean). I also looked into Ocaml

and Rust, but any ML-style language remains a sore spot for me, partially for

personal reasons, so I never stuck with them.

A few years ago, I came across Crystal, a language with a similar syntax to

Ruby, but statically typed and compiled to native code ahead of time. Before

then, I had never really written anything in Ruby, so the syntax was a bit

awkward at first. But oh boy, as soon as I started to actually learn it, I was

in love. I found myself being able to express myself _almost_ as well as in

Common Lisp, and found its syntax to be helpful rather than a hindrance. It

felt like I was writing code for a dynamic language, but still had the static

type checking to rely on for program correctness and safety. Plus the stuff I

was writing was FAST. Soon I was using it for non-trivial stuff, like a Discord

bot, a set of tools for manipulating game data for Doom (ported from Common

Lisp), and also my own Gemini server.

"That's how it's done in Ruby"

This is something I've seen come up quite a bit in the GitHub issues (which, for

some ungodly reason, I've taken to reading for fun as I go to bed). By itself,

it isn't a bad approach: Ruby is tried and tested. But I can't help but shake

that this approach is a bit too narrow minded, or possibly rather applied too

often, when it comes to Crystal. Ruby is a dynamic language, often used for

scripting. Crystal is a statically typed language, and (in my mind) about as

much of a scripting language as Nim or Ocaml are. AFAIK, Crystal is not meant

to be a replacement for Ruby, either. So why shouldn't Crystal stand on its own

when solving problems rather than fall back to copying how Ruby behaves? After

all, taking inspiration is one thing, and a good thing at that. But there is a

threshold (admittedly, a quite fuzzy one) where it becomes questionable. I

can't help but feel that Crystal approaches or crosses that threshold just a bit

too often.

Variable Type Problems

Then I started to write my own Doom source port (literally), where I started

porting Managed Doom (which is in C#) to Crystal. This is where the cracks

started showing. Due to how Crystal handles instance variables in classes, I

had to do a lot of clunky workarounds for code such as the menu code, and the

main `DoomProgram` class. Things like instance var initialization being

occasionally wonky in constructors (I had to either use a nillable class + a

method that checked it with `not_nil!`, or use `uninitialized`, which is

strongly discouraged since it's meant for C bindings; I went with

`uninitialized`). Or running into obscure compilation issues because I would

accidentally mistype a variable name and introduce a new variable (because there

is no dedicated `var`-like statement to introduce new variables). Or very

obscure compilation errors because I would unknowingly change a variable I

thought was typed as, say `Int32`, but suddently it's a union type of

`Int32|Float64|String|Nil` because of a typo. This last one came about

partially because static type declaration for variables is optional, and IMO,

was not well described at the time. Where I expected something akin to an

`auto` variable, it actually became a variable that acted a bit like a variable

in a dynamic language:

myvar = 42 # This is an int, probably Int32, as intended

# Assume these are typos
myvar = "foo" # myvar is now implicitly converted to an Int32|String
myvar = nil # myvar is now implicitly converted to an Int32|String|Nil
myvar = 0.0 # myvar is now implicitly converted to an Int32|String|Nil|Float64

# ... mode code ...

# Good lord I hope this is calling the #some_method that's specialized on Int32
myvar.some_method

Now I _could_ statically type it from the start, but typos negate that:

# Requires an Int32
def some_method(foo : Int32)
  puts foo
end

myvaar : Int32 = 42 # I made a typo here, two a's

# ... mode code ...

# Assume these are typos.  I'm expecting the compiler to catch these.
myvar = "foo" # myvar is now implicitly converted to an Int32|String
myvar = nil # myvar is now implicitly converted to an Int32|String|Nil
myvar = 0.0 # myvar is now implicitly converted to an Int32|String|Nil|Float64

# ... mode code ...

# This fails to compile, and I now have to track down the typo
myvar.some_method

The language's homepage specifically says:

Crystal is statically type checked, so any type errors will be caught early by the compiler rather than fail on runtime. Moreover, and to keep the language clean, Crystal has built-in type inference, so most type annotations are unneeded.

However, both of these situations are, IMO, bad design for a language that is

meant to be type safe as they imply. Introducing a dedicated `var` statement

would fix both of these, IMO. I also ran into both of these situations when

porting Doom and found these issues to be way, way harder to find because of the

compiler errors that get generated. The errors were unhelpful and led me to

look in many different places before I finally figured out what was going on.

Since then, I've gotten into the habit of ALWAYS declaring types for variables.

It's helped, but seems to not be the implied idea for Crystal.

I think the Crystal folks may be aware of this, because they've been adding type

declarations to methods in their standard library lately. We'll see what the

future holds here.

Unstated Ambiguity

One nice thing about Crystal is that it's very easy to re-open classes/modules.

For example, if I wanted to add a method to the standard `String` class, I could

do this:

class String
  def someMethod
    puts "called!"
  end
end

But here's where I'm starting to notice an issue. Let's say I have a Shard

(which is kinda like a library) that does exactly this. Call it "Shard1". Now

let's say I have another Shard, "Shard2", with this code:

class String
  def someMethod
    puts "Shard2 Called"
  end
end

This is an example of how easy it is to re-open and extend the String class.

But, let's say my main code has:

require "shard2"
require "shard1"

"foo".someMethod

Which `#someMethod` gets called? It turns out (and AFAIK, is not documented

anywhere) that the order of the `require` statements dictate which method gets

called. I cannot, as far as I can tell, specify which `someMethod`

implementation gets called. I also get zero indication that `someMethod` was

defined in one shard, then overridden in another when it's compiled. For a

trivial example like this, it's no real problem. But what about for more

complex, real world programs?

This came up as I was thinking about adding some extension methods to the

standard `IO` class in my personal standard library. Things I use often, like

an alternative way to read strings (always read X bytes, but ignore stuff after

the first null), convenience methods to shorten calls `#read_bytes` (like

`readInt32`), nicer ways to handle binary data, etc. That's when I thought,

"What if someone else writes a method with the same name, but with subtly

different functionality?" Of course, that's when I realized that extending the

standard `String` class might not be a good idea, and having my own module for

those methods would be just as good. But then I started thinking more: "What if

I re-open a class, add an instance var to hold state, and add a few methods that

work with that instance var? Then some other library re-opens the same class

and adds a method with the same name that does NOT update my version's internal

state?" This would be perfectly legal code, and would compile with zero

indication that some other library is also overriding my stuff when I go to use

it. And, again, I can't choose which version of the method I call.

An edge case? Absolutely, but one that has been addressed already in other

languages. C# lets you specify which version of an extension method you want to

use, should two share the same name. And this behavior is well documented.

When I reported this to the Crystal folks, asking for some documentation at the

very least regarding the behavior, I was met with "that's how Ruby does it."

OK, but I'm not a Rubyist. I only started to explore Ruby after I got into

Crystal, and almost exclusively for small scripts to help me do routine tasks.

From where I'm coming from, that this behavior lacks any sort of compiler note,

or even documentation, is extremely scary for a language like Crystal.

I can't help but wonder what other odd behaviors I'll run into.

Using Crystal

Please don't take this as a "Crystal sucks" post. It most certainly does _not_

suck. It's still one of the nicest languages I have ever used, and my second

favorite after Common Lisp. But it feels like it needs some more time in the

oven, and that the 1.0 release was possibly a bit too soon, at least in my

opinion.

I'll continue to use Crystal, though probably not as often. What Crystal seems

to be especially good for at the moment are programs that touch the web somehow,

but that's a domain I rarely ever work in. I'll probably use Crystal for

web-related things going forward (at least stuff that I don't already have time

invested in with Lisp), and for some command line utilities. For everything

else, I'll stick with my tried and true Common Lisp.