💾 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
⬅️ Previous capture (2022-07-16)
-=-=-=-=-=-=-
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
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.
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.
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.
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.
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.