Saying farewell to a beloved language
Published on
In the last few months, I have realized I have not actively been developing software in Racket. I came to think about why this was, and I am going to talk about what makes me want to say goodbyte to it.
For anyone who knows me, I am a Racket user. I have been writing in the language for probably close to 6 or 7 years now. I have code dating all the way back to when I was a beginner and didn't know anything. I have code that I started last year that shows I have a little more insight into the Racket environment.
However, I realized come the last few months, I have not been using Racket actively. I have been seeking out deploying solutions in Zig, or in other instances, Haskell. As someone who writes code both at a professional level and a fun hobbyist level, Racket is no longer something of interest to me. I'll go over why I think this is the case.
For the most part, I want to talk about what makes the Racket language special, and why it's so alluring. Racket by itself is a virtual machine that is cross-platform, and comes with quite a number of libraries out of the box. GUIs, networking, JSON, bitmap rasterizing, and probably a couple more somewhere, but the Racket toolkit is pretty extensive (and large in binary size to install).
Despite all these cool things you get, in my honest opinion, the language has not evolved in years, and hasn't grown to met real developer needs. In fact I think the Racket management has even gone as far as ignoring the cries of developer needs and solely focuses on their tiny universe. The Racket user group isn't very large, but I think since many of it's members are university staff, they tend to forget that programming languages might actually be used outside of college.
Racket's macro system by design is there to allow users to write miniature languages within Racket, otherwise known as Domain-Specific Languages (DSLs). A DSL can be written with Racket, and when another user uses it, they're using a language that gets converted to Racket on the underside. This is great for writing languages that allows you to target a fairly stable virtual machine.
However, it's my belief that the macro system is obtuse by design. There are many ways to write macros (define-syntax
, define-syntax-rule
, etc) but what sets them apart is at times unintuitive, and you need a lot of expertise to even begin writing macros yourself that are actually useful.
Racket is designed to give you a bare-minimum environment that you can do whatever you like with, but if you would like some language improvements that reduce clutter and make work easier, you would be often met with resistance and told "write a macro for that". They aren't wrong, but forcing users to write common macros is a bit of a redundancy: you can simply supply users with the macros that they'll be busy scouring the internet for.
The |>
pipe operator in F# is a mechanism in place to pass results of functions into another to make it easy to compose results. This would be akin to the compose
function, but lacks the flexibility. An example of this in F# would be:
let data = [1..100]
|> List.filter odd
|> List.map square
|> List.foldl + 0
You can pass functions partially and allow the pipe operator to flow the free variable to flow through all of them. This works when the tail-position argument is missing and the result of the previous call is used. A way of doing this in Racket, without extensions, is... Less enjoyable.
(define (filter-odd arr)
(filter arr odd?))
(define (map-square arr)
(map arr square))
(define (sum arr)
(foldl + 0 arr))
(define data
((compose filter-odd map-square sum)
(range 1 101)))
There's no partial function application by default, unless you chose to use the racket/functions
tool curry to curry a function by making a new one from it's partial set of arguments. I do this with a code snippet I have from 2017 when I made a process supervisor years ago to keep Discord bots running.
Because of how annoying and tedious this would be naturally, a pipe operator macro exists in the Threading library that allows you to implement the F# behavior easily; but Racket has not made this a de-facto thing in their language, despite this package existing for over, oh I don't know... Seven years?
The latest trend that has been used in the Rust programming language for years now are the concepts of sum types and product types. A sum type is a way of expressing data backed by a tagged union, whereas the product type is a Cartesian product of multiple different types in one unit.
In Rust, we're able to do things that allow us to effectively typedef
types into easier names, and carry data that we would define necessary to the implementation. It's useful for writing state machines and carry results or errors through a program.
pub enum VType {
Null,
Fail(i32),
Success(i32, i32),
}
pub fn handle(v: &VType) i32 {
match *v {
VType::Success(_, count) => count,
VType::Fail(line_err) => line_err,
else => 0,
}
}
In this imaginary program, a program success gives us two numbers indicating that we have some successful operation somewhere with two distinct values important to the program. It could be a memory address read location with a number of bytes, or whatever you can think of. A fail case is included which can be a negative value, telling us that maybe something failed. A null case is included just to show that a Rust enumeration variant doesn't even need to have a value at all, and can simply be a name with no types held.
This is compile-time checked, meaning it would be impossible to write a program with a malformed VType
unless you changed the VType
enumeration yourself. Similar stuff exists in Haskell as well.
By default, there is no concept of a sum-type or product-type in plain Racket, it only exists in typed/racket
, the lesser known and more difficult to use Racket. I say it's difficult to use, because I have had zero interest in ever using it, since the minute you move to typed/racket
, you can break a lot of your existing code simply because libraries you may or may not use have zero type bindings associated to them.
Typed Racket isn't exactly easy on the eyes either, and adds a lot of syntactic "bloat" to existing Racket code. Here is their snippet for a polymorphic list, where the :
colon operator is the type binding needed.
#lang typed/racket
(: sum-list (-> (Listof Number) Number))
(define (sum-list l)
(cond [(null? l) 0]
[else (+ (car l) (sum-list (cdr l)))]))
The type binding assures us that the list passed to this function will only ever work on a list of numbers, meaning there is some form of compile-time check guaranteed. But what is number? Is it positive, negative, fixed-point, floating-point, irrational, whole, imaginary? The answer is yes: it's a number. The Number
type is roughly similar to that of a Haskell typeclass that covers all "numbers", but if you wanted to get more specific, you would need to play around a bit and figure out where/why each value is. From their own docs:
> 0
- : Integer [more precisely: Zero]
> -7
- : Integer [more precisely: Negative-Fixnum]
-7
> 14
- : Integer [more precisely: Positive-Byte]
> 3.2
- : Flonum [more precisely: Positive-Float-No-NaN]
> 7.0+2.8i
- : Float-Complex
For whatever reason, a negative seven is a "negative fixed number", while a positive 14 is a "positive byte", but both can be bytes! Both fit even inside an integer of 8-bits! And why does floating point utilize Postiive-Float-No-NaN
? Floating points by design can be positive or negative, so it seems like the Racket designers decided to add these ambiguous types to add additional compiler overhead, even though in my eyes there's no need for it; it just obscures the types collection even more. Also why does there have to be a Zero
type? And does that cover 0.0
or 0.0+0.0i
?
With Typed Racket, you can write a Union type which has flexibility to cover all types in that binding. Recursive types exist by referring to a name in the same binding (think trees, lists, etc).
(define-type VType
(U (Pair Number Number)
Number
Null
)))
(: handle VType Number)
(define (handle v)
(cond
[(pair? v) (cdr v)]
[(number? v) v]
[else 0]))
So while yes, this is valid typed Racket code, it also doesn't need to be typed, because the typed layer doesn't add anything new here. It might check all types that flow through handle
via a compile-time contract, but it doesn't have special naming conventions binded to an enumeration. pair?
and number?
are predicate functions, not functions unique to a sum/product type.
Have you ever wanted to build a graph data structure? Sure you have, because you might need to do a lot of common computer science stuff like writing AIs, computational geometry, network analysis, etc. I've built several graphs in Racket by hand because it's built-in option is less optimal and doesn't have a whole lot of immutability.
Let's just write a struct
containing our Graph definition.
(struct Graph (nodes edges))
(define (init-graph) (Graph '() '()))
Very simple, but how do we go about adding information to it?
(define (add-node G n)
(define old-nodes (Graph-nodes G))
(if (= #f (member n old-nodes))
(Graph (cons n old-nodes) (Graph-edges G))
G))
(define (add-edge G a b)
(define old-edges (Graph-edges G))
(define pair (cons a b))
(if (= #f (member pair old-edges)
(Graph (Graph-nodes G) (cons pair old-edges))
G)))
Okay... Seems a little tedious. Each time you want to add information to a struct
you end up having to use the member access functions to get the data, then return a new struct
instance via it's self-named method. Or you can return the original copy if no new info is required.
This pattern is very common, and a macro is included to make struct
copying easier, but it doesn't necessarily help us in this case because struct-copy
, the macro, copies old fields and adds new values where needed, but doesn't give us easy access to the old values.
(define (add-node G n)
(define old-nodes (Graph-nodes G))
(if (= #f (member n old-nodes))
(struct-copy Graph [nodes (cons n old-nodes)])
G))
It didn't give us a shorter line, and in fact made it even longer. There's no way struct-copy
can give us a temporary name binding for an old field, but struct
's are at times incredibly frustrating due to how boiler-plate-y they feel.
Hashes are key-value mappings in Racket, and frankly they are incredibly awkward to work with. In almost all my Racket projects I start, I spend a little bit of time writing a hash macro library to make it easier to work with hashes. Why?
In almost all hash-based code, a hash either has the key, or it doesn't. In the case it doesn't have a key, that leads you to most of the time want to add the key and bind some data to that key. Is there any simple way of doing this in Racket? No, of course there isn't, because that wouldn't be practical.
(define (example h)
(if (hash-has-key? h 'test-key)
(hash-set h 'test-key 5)
h))
There's no real issue with hash-set
, and you can have opinions about whether or not that this is the correct path, but I'm already smelling boiler plate. Let's see what happens if we want to fetch the key now.
(define (fetch-key h)
(if (hash-has-key? h 'test-key)
(hash-ref h 'test-key)
(error "No key")))
Okay, well... Yeah. If you do hash-ref
on a hash with a key that isn't there, you literally generate an exception. No questions asked, no nil
value or whatever. You just straight get an exception, which would terminate your program. So instead it makes sense to check if the key exists first, but then... What do you do after? Here I just make another exception.
This seems a bit... Silly to me. In JavaScript, accessing a key that doesn't exist on an object doesn't generate an exception at all, and instead gives you an undefined
value. Racket doesn't really have that notion, but should a hash be yielding exceptions like this? I don't really agree with it. Even Python's dict.get
function doesn't yield an exception on keys not present (but direct-indexing with dict[k]
will).
I'll go back to JSON; you deal with a blob of data that can come from anywhere. It's going to be missing data. It's not fair to put the burden of a missing key on the developer who has to now handle every possible edge case of a missing key in a JSON blob. Should he instead leave it to the exception handler function with-handler
? No, that's a trash way of handling missing keys. Using exceptions is so old-school Java and so unnecessary.
Instead, I have a macro called Hash:get
which is akin to Python's dict.get
method when supplied with two values. If you supply a key that doesn't exist, you get the 2nd argument as the "default". In fact, I wrote it to even support variadic argument counts so it's flexible. If Racket puts this in the default library, you can thank me later.
(define-syntax Hash:get
(syntax-rules ()
[(_ H) (raise-syntax-error "Hash:get: no key provided!")]
[(_ H K)
(if (hash-has-key? H K)
(hash-ref H K)
(error (format "Hash:get: key '~a' does not exist" K)))]
[(_ H K E)
(cond
[(hash-has-key? H K) (hash-ref H K)]
[else E])]))
I know I just said "exceptions bad", but I needed to include the case the user forgot to supply a default case and the key didn't exist. I have an updated version that allows multi-key recursion, but that one's a little more fancy and less full-proof because it's not fully fleshed out.
Now if keys are missing from a JSON blob, you can use Hash:get
to patch in splotchy keys that may be missing from your payload.
(define (parse-json JSON)
(for-each displayln (Hash:get JSON 'names '(Harry Joe))))
This version sure beats out the non-macro version...
(define (parse-json JSON)
(for-each displayln
(if (hash-has-key? JSON 'names)
(hash-ref JSON 'names)
'(Harry Joe))))
I have a few other macros in place for cases of updating the previous hash values, or maybe even appending values to a list inside a hash table, but those are all in the above link included if you want to look.
I stopped using Racket within any Continuous Integration pipeline because Racket took forever. Racket is huge, and causes a ton of wasted minutes on freemium GitHub/GitLab CI minutes. I respect both hosting providers and don't want to abuse my available free minutes, and as such I stopped using Racket Docker images entirely.
The "minimal install" of Racket that they provide is still also at times still a bit too large compared to other language runtimes. Here's what I just pulled right now.
REPOSITORY TAG IMAGE ID CREATED SIZE
python slim 9012e93183ab 5 days ago 128MB
ruby slim 02aa4efae811 9 days ago 175MB
node slim 91d9ac08181c 12 days ago 248MB
racket/racket latest e6332a349df3 2 months ago 236MB
Python is half the size, Ruby is a little larger, and Racket is nearly as large as Node's slimmest edition. I can't even fathom pulling a quarter-gigabyte download each time I want to run a Racket CI pipeline of some sort if no artifacting existed. I imagine to some extent, GitLab is smarter and keeps old pipeline images, but I imagine that isn't the greatest strategy, as Docker builds can change so quickly the old images won't matter if they change a language from Python to Ruby randomly for example.
Also, this Racket Docker image isn't even technically "official" and is ran by one Racket power-user named Jack Firth.
The developer experience outside of the DrRacket editor is pretty much unsupported on all counts. There are no official bindings for Emacs, Vim, Visual Studio Code, and I doubt there ever will be. Any and all language support that I get in Emacs is purely thanks to racket-mode ran by Gregg Hendershott, who I happily helped in the past with his project Frog porting it forward a Bootstrap version.
While there are mentions to syntax plugins for Vim and Emacs, there's really not much else. Every person I have shown DrRacket thought I was seriously off my rocker when they first saw it. It works, but it is a far cry from what we need. I have been comfortably programming Racket with Emacs for a number of years now, and wouldn't want to ever use DrRacket seriously.
I have been interested in language server protocol with editors recently, and frankly I don't think that will ever be considered official either. The last version of the current Racket LSP is here, but it's nearing being almost 3 years since it's last update.
Performance in Racket can range from great to "eh". Racket is a virtual machine, garbage collected language, so performance should be similar to that of Java. But it isn't. You can see that here on the benchmark game webpage that is ran to determine the maximum speed of problem solutions each written in their respective languages.
The numbers here are a bit of a stretch, because the level of Racket used in many of these programs is Racket you may never write in your entire life writing Racket. I'll grab a random snippet.
(define-sequence-syntax unsafe-in-fxrange
(lambda () #'in-fxrange/proc)
(lambda (stx)
(syntax-case stx ()
[[(d) (_ nat)]
#'[(d)
(:do-in ([(n) nat])
#f
([i 0])
(unsafe-fx< i n)
([(d) i])
#t
#t
[(unsafe-fx+ 1 i)])]])))
I can't even begin to tell you what this actually does. But it's tail value relies on something called unsafe-fx+
which sounds like a call to what would be considered an "unsafe" operation, meaning something that isn't memory-checked and could possibly constitute undefined behavior inside the Racket VM.
Unsafe code, similar to Rust, needs to be treated with care, as the runtime enforces certain safety measures like contracts or out-of-bounds detections. Lifting those away raises performance, but by throwing safety out the window, you don't know what would happen in real-world applications. Therefore, these benchmarks should be taken with a grain of salt, as they were written with performance in mind, and not safety.
So for a language that has to throw away and sacrifice all it's safety mechanisms in the name of performance doesn't exactly mesh well with my ideas. It's like, why bother with safety if the only way to increase performance is by throwing away everything the language stands for? You might as well write in a language that either compile-time enforces safety bounds, or is better suited for heavy-duty performance.
There are many languages that offer better performance specs, and you can shop around on the above benchmarks game website, but even something like Common Lisp SBCL offers better performance, and it's a Lisp.
Compiling Racket programs for distribution surmounts to nothing more than "compiling" user Racket code into optimized Racket VM bytecode. But what comes after the bytecode? Distributing OS-specific Racket runtime executables, then appending your user bytecode on top of it.
Yes, that's pretty much all there is to it. There's no built-in mechanisms of converting Racket code to something like LLVM IR or boiling things down to the Assembly level. If you cross-compile for macOS, Racket will fetch a macOS runtime executable and pack it with your code.
So yes, you can "cross-compile", but it's not really anything special. It doesn't even happen entirely on your CPU, it still has to netfetch. So while it's dead-simple and not extremely complex, it doesn't really "compile" much more than the bytecode and strap on a native Racket VM executable. I have used this a handful of times, not because it didn't work, but because the cross-platform nightmare of porting Racket code to work on different OSes required too many different changes, and became too much busywork.
A while back, Racket management decided to switch their Racket VM and libraries to use a Chez Scheme backend. I don't have anything wrong with this, but it feels almost like a step in the wrong direction in my eyes. I get that nobody wants to maintain a boring C codebase, but in my eyes, that was the best shot we had at getting something like Rust or Zig to be the language Racket could be built upon. Zig could have even compiled the current Racket codebase and swapped out other C compilers.
Whether or not Chez Scheme was better for Racket performance, I find it somewhat lazy that instead of trying to stay on top of everything, they defer the Racket codebase to instead use Chez Scheme as the backend. They could have used something a bit more modern that people with working, professional knowledge could contribute to and help out, and chose to go with Chez Scheme which sounds like the laziest route. It will no doubt attract less help now because Chez Scheme is not popular, and maybe a handful of people can actually work on the Racket codebase now.
Then there was the announcement of a possible "sequel" to Racket, which was previously known as Racket 2, but now it's called Rhombus. And then we also have Zuo, which is the scripting language used to build Racket now (because the compilation story was already so good to start with, here's another language).
I have nothing against Rhombus, I think it's good that there may finally be a new language Racket users could use and maybe find new reasons to love. That's exciting. But for how long it's taking it feels like there's almost no reason to use Racket in it's current state, knowing that a new language will inevitably take it's place and have new benefits and syntax. It's like they acknowledged there's no reason to fix/add new things to the current language, so they want to up and make a new one. I find that a bit annoying.
My complaints aren't exaclty going to get addressed anytime soon, and that's probably okay, but I don't see the need to continue writing Racket myself.
I have always been someone who advocated for Racket, as it is the quickest way to get new language features; all you have to do is make them yourself. But that do-it-yourself attitude is destructive and at times would be considered yak-shaving by reinventing the wheel so often. Racket is such a big language, but unknown, and because it's unknown and unpopular, it's hard to find others to work with who would go about publishing extra libraries to share code with.
All third-party Racket code should be considered unsafe, because the only person validating any code is the author themself. But the way of turning untrusted code into trusted code is by having Racket move code from third party libraries into first-party, standard library code. That would be nice, but it is so far from reality I do not see it happening anytime soon. threading-lib
should be moved into the Racket main library, but the author themselves left the Racket world long ago unfortunately.
That story is unfortunately a repeat one. I've seen a lot of people maintain Racket projects, only for them to leave after a number of years. People write websites, servers, seriously good use-case libraries, only for them to fall apart over time and become broken or inactive over time. Racket doesn't want to standardize code by moving it into standard library, which I imagine demotivates serious Racket fans, so Racket fans are leaving Racket entirely.
I don't know how to fix Racket's problems, nor do I think anyone else would view Racket has having any problems. Maybe it's all in my head or maybe it's all burn-out, but I haven't used it in a while, and I haven't really missed it in particular. I have a few projects still in Racket, and may eventually switch them over to something I find more enjoyable.
I think perhaps, Racket will always be fun to look at from the outside, but the time to master the language itself far outweighs any I've seen. It's developer experience isn't amazing, it's community doesn't lend itself much to change, and I feel like at times it might be stuck in academia a bit too much. There's some hidden toxicity to the Racket community upper-management that I fear hasn't ever been addressed, and from my knowledge, not everyone is all that friendly there.
I wish so much to be part of something larger, but the more time I spend with Racket, the further I feel I stray from real computer science and real-world software. I recently fell in love with a lot of Zig, and have been enjoying that as my hobbyist hacking language since it comes with WebAssembly as a target.
If for any reason any of this is wrong, I would love to be corrected, but in my eyes it isn't necessarily worth arguing. I think I am pretty done using Racket as a language. Maybe it'll pop up on my radar once Rhombus is out, but at this current point in time I do not think I'll continue my usage of it.
Thanks for all the fish, Racket.