💾 Archived View for yujiri.xyz › software › crystal.gmi captured on 2023-09-28 at 15:53:53. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2023-09-08)
-=-=-=-=-=-=-
This story's a lot longer than my story for most languages, but hopefully interesting.
My dive into Crystal came after learning Rust. I liked Rust a lot and planned to use it for Didact, a project which would involve generalizing my own website's code for use by others (I could've just done that, but I wanted to also switch to a statically typed language for several reasons, and such a rewrite would be the only good time for a language switch). So that meant I needed to find Rust replacements for every Python library I was currently using.
(This was back when my website had a comment and account system, and I was bent on using an ORM for it.)
Unfortunately I couldn't. There were passable API frameworks (Rocket or Actix-web), but the only usable Markdown library, Comrak, would be a big downgrade from what I was currently using (Mistune), and there was no replacement at all for Pygments. Also, the only usable ORM, Diesel, would be a tremendous downgrade from SQLAlchemy. It didn't even have lazy loading! Needing several copies of each model definition was a deal-breaker to me, and worse, the lead dev has stated that it's a deliberate design choice.
I was frustrated because I really wanted to do this in Rust. But then I read an article with some impressive praise for Crystal:
https://dev.to/jgaskins/performance-comparison-rust-vs-crystal-with-redis-1a17
I remembered hearing about Crystal a long time ago, early in my search for a better language that led me to Haskell. I had crossed it off back then because I thought I read that it had no polymorphism except through inheritance. But I looked into it a little more and heard that it was *duck-typed*. *statically duck-typed*. That was exactly the fantasy I had wondered about a while ago and people on the internet had told me it wasn't possible! I must've misunderstood when I looked into it the first time. So I changed plans and investigated if it was feasible to write Didact in Crystal.
The language is even younger than Rust, so I wasn't surprised to find the ecosystem not much better. But it did have an ORM, Granite, that seemed to at least solve the model duplicate problem with Diesel. That was enough that for the second modern, well-designed language I was looking at, I wasn't gonna give up that easily. I got the idea of using something not written in Crystal to replace Pygments. After all, Crystal boasted its FFI abilities.
I found one. GNU source-highlight looked like it would work as a replacement for Pygments. (This also cleared that obstacle for Rust but I still didn't want to suffer through Diesel.)
That left only the markdown library as a major obstacle. Markd was a Commonmark implementation just like Comrak. I figured I'd have to fork it to make it the way I wanted, but by now, I was willing to.
I forked Markd and dove into its cryptic, poorly-commented source to figure out what the hell was what. I was lucky there was a great test suite. I started by ripping out a bunch of Commonmark features that I had no idea why anyone would consider them desirable behavior, like interpreting HTML entities. That one alone involved a 2000 line hash literal that accounted for almost half of Markd's source! I was thrilled to see my fork's line count drop under 2000.
This project made me realize just how much I disagreed with the Commonmark authors, and even John Gruber, on what Markdown should be. I believe in the Zen of Python: "There should be one - and preferably only one - obvious way to do it", and the original Markdown spec violates that in several ways and Commonmark adds several more. I ended up founding Sanemark as an alternative spec (now abandoned because I decided I just don't like Markdown).
I did finish Didact. GNU source-highlight turned out unsuitable after all, but by the time I realized, I would rather give up the feature than give up the project. I also ended up changing my ideas about what features Didact should have, and today it has no comments and uses no ORM.
So that's how I met Crystal. By now I've had enough experience to review. Since Crystal is basically statically typed Ruby (I don't know much Ruby) to the point that a simple file often works as input to either, probably everything I say except the type system will also apply to Ruby.
Crystal is both duck- and statically typed, which is awesome. Hats off to the Crystal devs for proving it's possible.
Whereas Rust uses algebraic data types and pattern matching, Crystal uses union types and a `case` statement that can check types as well as values, which is mostly equivalent in the end. I'm not really sure if either of these implementations have any real advantages over the other.
Something you can do in Crystal but not in Rust (and which would probably horrify the Rust devs) is "re-open" a class or module with a second block that looks like its definition to modify it, even things from different modules. I've seen it used to great effect in the Kemal web framework, which modifies the stdlib's `HTTP::Server::Context` to provide parameters (it does *not* export a new class, it changes the definition of the stdlib one).
Crystal has just one gaping omission from the set of fundamental types: no static arrays. Crystal's "Arrays" are Rust's Vecs: heap-allocated, growable lists. As a Rustacean, what I consider true "arrays" are stack-allocated and fixed-size, because their length is part of the type which has benefits for safety. Crystal has no such thing.
Also, slice syntax copies (like Python) instead of creating a reference (like Rust, which I prefer), but there is a true Rust-like Slice class, it's just not the default behavior.
Crystal has type inference, but it's pretty finicky. It doesn't apply to empty array or hash literals, and sometimes its inability to see the obvious can be quite irritating, like when it makes you add `.not_nil!` to *every* access to something even though it wold raise on the first one if it was `nil`. (This may have been improved since 1.0; I haven't used the language since then.)
There are two syntaxes for type annotations. Normal type annotations use `:` after the identifier, but array and hash literals have their own for some reason: `nums : Array(Int8) = []` is an error, you need `nums = [] of Int8`. Hash literals are similar: `attrs = {} of String => Value`. This was really confusing to me.
Crystal leans hard into "object-oriented" hype, and I think the efforts to shoehorn that paradigm into every aspect of the language has led to a lot of confusing and ugly results.
From the Crystal docs on modules:
https://crystal-lang.org/reference/syntax_and_semantics/modules.html
Modules serve two purposes:
* as namespaces for defining other types, methods and constants
* as partial types that can be mixed in other types
Indeed, modules function both as namespaces and as mixin classes even though those are completely different things.
Crystal expects everything to be a class, even things that make no sense to think of as a type of object. Here's another example: *Classes* within a module are accessible from outside with `ModuleName::Class`, but functions are not; `ModuleName::function` is a *parse error*. The workaround is to use `extend self` within the module, which makes `ModuleName.function` work because it (I guess?) makes the module function as a mixin class for itself. Wait, what? Why don't we just have namespaces?
The idea of a function outside of a class is an afterthought. The docs even say "the program is a global object ..."
https://crystal-lang.org/reference/syntax_and_semantics/the_program.html
There aren't *structs* in the sense that non-OO languages like Rust and Go have; there's a `struct` keyword, but it just declares a class whose objects are passed by value instead of by reference. Crystal doesn't have a real concept of *fields*; there are only getters and setters and a family of `property` macros to define them, but even with that, you don't get the ability to pass them to the constructor. To get the basic functionality of fields you need:
class Person # struct vs class is the same for this purpose property name : String, age : Int8 def initialize(@name, @age) end end
That `@` syntax in the constructor is a sugar to make it automatically assign the argument to the instance variable of the same name. But we need all this; if you just use the constructor definition without the `property` declarations, you get no getters or setters so you can only access them internally, and if you only use the `property` declarations, you can't pass them as constructor arguments.
Being based on Ruby, it uses the same exception system, including the `ensure` clause and multiple `rescue` clauses to handle different error types differently. And since it's not dynamically typed, there are no name errors to speak of. Compared to Python though, it's annoying that there's always library code in your stack traces, usually *on both sides* because you see several frames from inside the runtime before your code is even called, making it difficult to pick out the interesting frames.
Why do all the dynamic languages catch error messages by default?
While I appreciate not having semicolons, I'm not a fan of the use of `end` compared to Python's implicit block termination because:
1. It occupies vertical space on my screen with something semantically insignificant. As a Pythonista using a language that *looks* like a significant indentation language, this makes me so uncomfortable that it really makes me want to find ways to refactor to use suffix conditions (see below), despite me not liking those either.
2. It leads to the same problems with error messages that braces do: when I forget an `end`, I usually get an error message pointing to the end of the file. It's really surprising how much time that's costed me.
Another thing I'm not a fan of is suffix conditions instead of prefix conditions. Instead of `if cond: statement`, Crystal uses `statement if cond`. While this might reflect how we speak more accurately, it's hardly a win for clarity to have a line start with something that may not actually be executed, especially if the statement is long.
I'm also not a fan of the `unless` keyword. `unless` is an alias for `if !(...)`, but it confuses me all the time because that's not the full meaning of the Engilsh word 'unless'. 'Unless' implies an exceptional case; native English speakers don't say "unless the normal thing happens". But Crystal users do, and it only took one day for me to lose count of the number of times it's misled me.
A deeper issue I have is that function calls don't need parentheses (just `func arg1, arg2`), which means *there is no way to reference a function without calling it*. You can work around it by defining a proc that calls the function, but that feels so inelegant. It also makes it unclear while reading whether something is a function or a variable.
I really like that `continue` is called `next`; It's a way clearer name. I don't know what idiot decided to call that `continue`, but it's nice to see someone breaking from the tradition.
One thing that bugs me is the number of language-level features that are either obscure, seemingly useless, or don't make sense with other features: "symbols", namedtuples, several different syntaxes for string literals, heredocs, etc. The trio of default arguments, keyword arguments, and variadic arguments have confusing interactions that lead to some really puzzling error messages.
This is pretty annoying: Crystal doesn't support global variables. When you need global mutable state, the workaround is to name it as if it's a constant even through it isn't (talk about a hack), and make a wrapper class if it's an immutable type (like a simple counter) because constants *can* have mutating methods called, they just can't be reassigned. Rust has a better excuse for its issues with global state and the issues are much less annoying.
Crystal's "procs" seem to be basically Javascript arrow functions, which get around the "any reference to a function calls it" problem by being objects with a `call` method. Blocks are a (maybe? I'm still not entirely sure what I think of this concept) more empowered version that also serves as the equivalent to Python's iterators.
Crystal has a macro system which serves as an idiomatic way to get around the limitations of static typing. They're used for ORM libraries and such to define type-safe, high-performance SQL functionality for arbitrary classes. I love having this instead of runtime reflection!
An ugly wart is that macros have a completely different iteration syntax from normal Crystal. Normal Crystal doesn't have `for`; it uses Ruby-style `.each` and blocks. In macros, you use `for..in`. Huh.
ECR is interesting. Being static, Crystal can't support a real equivalent to something like Mako, since the templates *have* to be compiled in. (There is Crinja, a Crystal port of Jinja which allows dynamic templates in Crystal, but that's not the same since it doesn't support arbitrary expressions.) But ECR shows that the benefits of a static template system can compete with those of a dynamic one. It's quite nice to have your templates type-checked too, and compiling in means the binary has less strings attached, less things that can go wrong with deploying and running it - not to mention the obvious performance benefits. Despite the frustrations I had finding a way to make ECR work for Didact (I did), I learned to like it and honestly, if I could trade its benefits for something like Mako, I'm not sure I would do it.
The compiler error messages tend to be really cryptic to non-Crystal gurus, unlike in Rust. It's got colored output, but doesn't show as much context as Rust and only ever shows one problem at a time.
It does have a nice formatter tool with an unusual but charming style choice: two-space indentation.
It has the same documentation story as Rust - a tool to generate HTML docs, but no command-line viewing.
There is no built-in linter, but there is a popular and very useful one, Ameba:
https://github.com/crystal-ameba/ameba
The ecosystem is small because it's young, but the stdlib is great. There's a solid core of essentials like common array operations and modules for a lot of common things like JSON, CSV, Regex and HTTP. Not as expansive as Python's stdlib, but good enough that you can actually do some things without third-party packages.
As expected, the good stdlib has led to an ecosystem that's *healthy* despite being young. Lots of packages don't have dependencies and few have more than a couple, so you can actually understand what you're depending on. Most projects I've seen have an impressive source line count for what they do. I'd take the Crystal ecosystem over the Rust, Haskell or Javascript ecosystem any day.
Unlike those three, Crystal doesn't have its own package repository. `shard.yml` pulls dependencies from any git URL or filesystem path. Now, not having a central repository does have a notable downside: there's no place where you can go to find the documentation for third-party libraries, and a common frustration for me is that shards don't have their full API documentation generated and hosted anywhere. I have to download them and generate them myself and view them locally. But I still love not having a central repository, on principle.
Final opinion? Crystal, like Python, is a very "pragmatic" language. A lot of it feels ugly to me but it sure does let you write software efficiently.