My thoughts after using Clojure for about a month
acdw.net
acdw.net
As everyone knows, you are not a true lisper until you have written your own static site generator.
It gave me such a great high with how easy it was to add my own "templating engine" on top, implemented all using macros. The downside is that the crash came hard; there is so much more to a good static site generator such as optimizing the output, supporting scoped CSS, server-side rendering of SPA framework components, and of course integration with the Node ecosystem (for better or for worse there is just so much useful stuff). I have since moved over to Astro. It's still fascinating how far I was able to push my own SSG all by myself though.
I think that part is quite normal. I use ruby for the same purpose, though the only difference is that the code I use is also to be used for dynamic websites at the same time (cgi, rack, sinatra, in theory ruby on rails but I just can't stand rails and DHH these days, so I am in the opposition crowd). Using static websites, though, always feel as if I have significantly less flexibility. I do generate some static .html files as well, but they feel less useful to me, aside from being displayed faster, of course.
For the most part, any Clojure code which doesn't use host interop will work on all dialects. Clojure also has support for conditional code, depending on the current dialect.
This is one of Clojure's superpowers.
There's also nbb if you're targeting node https://github.com/babashka/nbb
Currently, we have Clojure, ClojureScript, ClojureCLR, Babashka, Basilisp, Phel, and jank running the test suite.
I have only used Clojure, ClojureScript, and Babashka in production. But I am the creator of jank.
I just learned about basilisp from the parent comment, so I asked Claude to add Python support to the same .cljc files I have, and we finished the port in about 30mins, and then fixed Python specific test cases for another 30 mins, but now all of the existing tests are passing. That's impressive in several ways.
Portability is achieved by testing. You have to put the platforms you want to support into your test harness, and the earlier the better. A calculator is purely functional, so this is a fairly straight forward port and really easy to test for. I'm not sure about larger projects, but it seems like there is something seriously right about Clojure's design that makes this easy to do.
Correct me if I'm wrong, but isn't babashka's "host"... um.. "native", for lack of a better word? It's compiled with Graal VM native, no?
Yes, there is SCI (Small Clojure Interpreter) in the middle, but that's beside the point, no?
This is different from interop with the native world. It's different from the host runtime actually being native, rather than a baked down version of a whole VM.
Graal's native images blur the line between the JVM and native, I would not say Babashka has a native runtime. Perhaps borkdude would disagree. Might be an interesting discussion.
What do you see in Assets? Native executables, specific to an OS + CPU architecture combination, like Linux + AMD 64 or MacOS + AArch 64.
If he means navigating the AST, there is Parinfer: https://shaunlebron.github.io/parinfer/
Paredit / Parinfer ruined other languages for me. It lets you navigate up/down/in/out of the Clojure AST with keyboard commands and mutate those expressions, e.g. "Split" will split open the current data structure you're in: `(a| b)` =Split=> `(a)| (b)`, where | is caret. Join is the inverse, and it works for all data structures.
I find it a nice middle ground between the craziness of vim and the slowness of traditional cursor usage.
That is to say, all things start out clumsy. And people that are good at it, no longer feel that it is clumsy. Which is why a lot of people that have been working with this for any time just don't think of this much.
If a tool is clumsy, we try to improve it, that has been the case since the first stone artifacts created a million years ago.
Do you think that the (sort of) tree-based affordances that most modern code editors do support, like autoindentation and brace pairing/enclosing, are silly too? What about some slightly more advanced features, like the AST-based "extend selection" and "move statement up/down" features in JetBrains IDEs?
Or do you think that the status quo just somehow happens to be exactly right and going any further would be silly?
That is, I am not disagreeing that it can be a little bit clunky. But, a lot of the power that experienced users have in reading code is specifically that they have built a bit of automaticity in reading it. That is, the clunky aspects of fixing it is something you pretty much have to do. You just build speed at automatically doing it rapidly.
So, the status quo is to use the helper functions that you want to use. But usually after you get the experience in the clunky phase.
It's one of those things that, like visual programming, is absolutely and obviously The Right Thing™ until you try to implement it and use it.
That said, we have made progress in both areas, and maybe we will figure them out in the future.
Going back to code as bunch of carefully arranged ascii chars feels like a regression.
I too liked Clojure when I tried it some years ago (agreed on the composition and data structures; both are _great_). But the real value-add is in the runtime, not the syntax. Java has a solid runtime but it's not yet as good as Erlang's, maybe even not up to the standards of Golang -- I am talking concurrency / parallelism here (for memory management I have no doubts Java is very good). And I know: green threads and stuff. Well, call me when you can do what Erlang / Golang can do. Then I'll look again, very seriously too.
Programming language syntax scarcely matters. It does to some extent but we the programmers tend to over-romanticize it. The runtime and its properties are the much better thing to optimize for.
I always wished clojerl took off.
It has footguns, sure, but with library support and discipline it can get you very far.
To me it's embarrassing that PLs still tout syntax and various other goodies, completely glossing over runtime. I might be missing something. But faux humble statements aside, I feel many others are the ones who miss something -- and that's the fact that doing stuff in parallel is a fact of life for 20+ years now and it's time all popular PL runtimes finally wake up to that fact.
If not, I am simply not considering them. And I am not saying that arrogantly though it sounds that way; there are some PLs that I _really_ liked and was almost heart-broken that I had to abandon them and not work professionally with them. But I have enough experience to know that runtime choice matters, a lot.
For the record, Racket was one of those PLs I abandoned. I know they started working on parallelism some years ago but I had to make a decision next week back then so, Elixir + Golang + Rust it is for me.
Nothing wrong with that, it's a good thing that stuff is discovered anew [as opposed to being lost/forgotten], but it did bring a smile to me.
Pinning can still happen in some much more rare cases, same with go. For example, FFI.
The memory usage, performance, etc are all go like. You can spawn millions of virtual threads with hardly and memory requirements and without overburdening the OS with context switches. The JVM also enjoys faster GC performance with virtual threads.
I'm not sure I understand this argument. Java and Clojure share a runtime, but an idiomatic Java codebase is going to have a very different architecture and design to an idiomatic Clojure codebase. Conversely, a codebase written in Go may end up looking very similar to a codebase written in Java, despite using different runtimes.
As mentioned, I did like Clojure. I'd switch to it if it was running inside the Erlang runtime (like Elixir does).
F.ex. if you have an universal construct of green threads / fibers then 7 PLs could express it 7 different ways, yet underneath they'd all be the same.
So focusing on the runtime's guarantees doesn't seem like a practicality focused argument to me.
Furthermore, if you trace my comments, you'll see that I had to choose PLs years ago (12+ to be precise). Things were quite different at the time. Java might have almost caught up today; back then we couldn't even be certain `synchronized` is stable all the time. Just saying.
Scala did very well then, judging by your words. I could probably offer a loose analogy to Typescript as well; while it does compile to JS underneath, they added a stricter layer that makes programming in it more deterministic and stable. (Not the same thing because my main point was "runtime" but hey, show me a perfect analogy.)
You are free to say your last sentence. I am free to disagree. My practice has shown me that runtimes bleed into syntax almost always. Exceptions exist, sure.
Syntax determines what parts of a language are within easy reach, and therefore affects how programmers use the language. Tools that a syntax make easy are used often; tools that syntax makes hard are used infrequently. This indirectly impacts how a piece of software is designed.
But that really depends on what you're doing. For example if I'm not mistaken Amazon was run for a very long time on a Java backend. And so was GMail's backend (and back then GMail's frontend was, IIRC, Java converted to JavaScript using GWT).
And by "early Amazon" and "early GMail", we're already talking about massive scale. It's not as if the JVM got worse since then (as someone commented: a recent addition is that Clojure now use Java's virtual threads) and it's not as if it didn't scale.
So I'd say having Clojure on top of Java (for those using that Clojure: there's also ClojureScript, babashka, etc.) ain't really a problem, as long as you're fine with the occasional Java stacktrace and Java ecosystem (GP mentions that btw: that he's not familiar with Java and that, I think, can be a bit of an issue).
I'm not sure Clojure is about it's syntax: I like the focus on immutability / pure functions and I do really dig the REPL a huge lot. In addition to that something has to be said as to the incredible stability of the language and many of its libraries.
The big value add to me is that I can have a REPL and inspect, in dev (or in prod but that'd be wild), the app I'm working on. And manipulate it: redefining variables and functions etc. And it's not some hacky hot-reloading bolted on as an afterthought kludge: it's a real Lisp REPL. There's value in that IMO.
Elixir also offers LiveBooks i.e. you can create pre-made recipes with which you directly remote into your staging / prod and do stuff.
All that with immutability and potentially 6 digits of actors / green threads with a share-nothing architecture.
---
RE: early Amazon / Google, sure. They made do with what they had and it was and still is a heroic effort. But can we agree that they succeeded _despite_ the numerous warts and defects of the PLs and their runtimes at the time? Not _because_ of them?
I feel that people latch onto the misleading "they succeeded with language X and are big, hence the language X is great" thing way too often. No. It's not true. The only thing that follows from "big company A made it big with language X" is: "company A has an amazing engineering team". Nothing else.
[0]: https://en.wikipedia.org/wiki/LFE_(programming_language)
Love the idea of LFE but it needs a bigger ecosystem.
https://docs.oracle.com/en/java/javase/21/core/virtual-threa...
As I just posted in another comment (https://news.ycombinator.com/item?id=48384622), I'd probably drop Erlang/Elixir due to difficulties of employment and contracting -- if the more popular languages get those STM / share-nothing runtimes.
I like immutability too; I wish Java and Golang did more of it. It costs a lot in terms of unexpected copies in the BEAM though, there's less copy-elision optimization than you'd think. That especially bites if you're doing a ton of message passing, because of how process heaps are implemented and how garbage collection (traditional or ETS/ThreadProgress-based) works.
I think what I want is something like Golang but with goroutine-based ownership semantics (or Rust with the Go runtime and goroutines): en excellent scheduler for extremely light-weight green threads, no refcounting or reduction counting, and all the clever optimizations around channel sending and copy elision--but no ability to use a value after it's sent to a channel, and only channel-based access to shared global state. That'd get most of the benefits of process-local heaps but without the (copying, cache/memory fragmentation) drawbacks.
Elixir / Golang can do this very well. And they do. I have supervised, led and authored such projects that are in production to this day.
Rust too but it's lower-level and you kind of have to hand-roll OTP which of course will always fail.
if your accepting layer is abstracted away and implemented correctly, there is very little performance difference between different concurrency approaches and all you're exposed to as developer is implementation of your handler functions.
Admittedly that layer is almost always abstracted i.e. AWS / GCP and various other smaller hosted solutions that handle a good chunk of load balancing for us. In that landscape BEAM VM's strengths shine even brighter. I've seen firsthand that you can in fact bring a BEAM VM to its knees if you expose it just like that to the net. It's not pretty. Golang fares a touch better and Rust seems almost immune (provided one does not screw up their caching layer and don't do elementary N+1 query mistakes).
Take the example of some simple HTTP<->blob store service gets slammed with millions of requests when someone using the API does a backfill via some framework on their end that aggressively scales request volume up and out.
Something like, say, async Python/starlette with a coroutine per request is gonna perform slightly worse than Erlang, which in turn is gonna perform much worse than Go.
You're right that those differences are sometimes marginal when the latency of whatever IO the backend's doing dominates the equation. However, in my experience huge volume surges show issues with the runtime (the thing managing/launching multiplexed request handler routines) or the ecosystem (the backend IO libraries' ability to work with the runtime's IO multiplexing and make things like request coalescing easy or automatic) more often than you'd think.
It really takes surprisingly little volume to cripple a return-hello-world Phoenix app that indirects the "hello world" behind way too much middleware and message passing; it takes even less to kick over, say, a Gunicorn instance returning "hello world" at the bottom of the Django middleware stack. Golang with Gin, on the other hand, is surprisingly hard to cripple in the same way. And I say that as someone who likes Elixir and Python a lot more than I like Go!
I've personally rewritten one hobby and one professional projects from Elixir to Golang and loved the result; as you said, extremely difficult to bring down a Golang service to its knees.
One clarification: Phoenix server behind Caddy/nginx fairs better btw. But, details. Your point stands.
I am yet to see a Rust web/API service I wrote to _ever_ buckle under pressure and just crash. It was either an application bug (like the famous Cloudflare's `.unwrap()` error from the last weeks/months) or the Linux OOM killer. Literally never crashed. But I did witness it brutally murder a MySQL cluster because it couldn't serve it fast enough. That was both fun and terrifying to watch on the dashboards.
Haha yep. In my experience, everyone running CGI/process-per-request application servers is bullish on switching to a concurrent or cooperative runtime...until they realize they just removed the primary ratelimiter on downstream DB/service accesses.
The converse war stories are also amusing: people rewrite their whole app in a concurrent/asynchronous framework and nothing changes, because the DB driver is still farming out all queries to a tiny fixed-size threadpool of connections that was the bottleneck all along.
fair enough, although at this point we start talking about LB in front of the thing, consumption mechanics, autoscaling signals
i will still maintain that my simple advice for a dev worrying about scale, is that they should focus their efforts on ensuring downstream IO doesn't get overwhelmed (db read replicas, caching, etc) before optimizing runtime performance or autoscaling out unnecessarily.
All good advice, but the choice of runtime can affect the point at which autoscaling and load balancing even need to enter the conversation at all. Optimizing, say, a mostly in-memory cache service and writing it in Golang may yield results like "we can run a single instance of this and serve three orders of magnitude of business growth; slap it behind a DNSRR or a k8s NodePort for update/replacement/fast failover if it crashes, no complex load balancer needed", where writing the same thing in, say, PHP might require discussing orchestration/load balancing/memory/worker process recycling/autoscaling early on in the service's lifetime. Being able to skip those conversations (entirely or for a long time) is a very significant business benefit.
although this is a deliberate choice rather than some accidental defect. Clojure went with STM as its concurrency model, if you're not buying into that and you want an Actor-centric language it's not the right choice to begin with.
Clojure brings more than syntax though... there's an opinionated take on making all data structures immutable (as in, structural sharing [1]) by default. That's a huge difference in how you architect the program and debug it.
[1] https://softwarepatternslexicon.com/clojure/core-concepts-of...
LISP is much more than just a runtime syntax, such as its distinct evaluation model and metalinguistic core.
The JVM was chosen for Clojure because of its reach and vast ecosystem. People have ported Clojure to other runtimes, even Beam (Clojerl), where it enjoys decent success, too.
Still want Erlang's runtime though i.e. the many green threads with share-nothing architecture that can communicate with each other.
Info gets stale. Fair.
Certainly it matters much less in the modern era.
However, certain fundamental decisions of a language can be dealbreakers.
Requiring declarations on your functions and giving those declarations sigils so that they can be parsed quickly is an important syntax decision. Almost every modern programming language has converged to this idea.
Or take, for example, Lua. For me, personally, the 1-based-ness of Lua is simply a dealbreaker no matter how good anything else about it is.
For the "Lisps", I LOATHE the fact that you traverse lists and vectors in completely different ways--you can't just drop any container-ish thing into something that iterates/collects it. This is something that both Clojure and Racket seem to agree on--you have something that acts like a "collection" and you can walk across it the same way regardless of the specific type of collection it is. Of course, that is why a bunch of Lisp purists loathe Clojure and Racket while I like those languages. Shrug.
I find RAII (Resource acquisition is initialization) to be the source of all things evil if it infests a programming language. The popularity of C++ and Rust speaks to the quite large number of people who think my opinion is bullshit.
So, yeah, base syntax matters far less than it used to. But the engineering decisions that went into making that syntax correspondingly are far more important.
(I very much agree on Lua btw.)
Personally I am very disheartened. Surely algebraic data types should be universally a good thing and all PLs should gradually adopt them? But no, endless HN / Reddit threads bike-shedding.
Oh well.
Whether it is good for any kind of resource acquisition to look the same like an initialization is debatable.
On the other hand, I cannot see any counterargument to the principle that releasing any resources should not be done explicitly, but only implicitly, upon leaving a block (using reference counts for shared resources).
I doubt that you advocate for the use of explicit release commands for resources, which are a notorious source of bugs, so what is that you consider as not being the same as RAII?
RAII was a not very useful acronym that was just another form to say that the C or C++ programmers should never use the PL/I style of explicit free commands, despite the availability of functions like "free()" or "close()" in the standard library, but both memory and files and any other kinds of resources should be managed with automatic releasing.
I do not see how this sound principle can infest any language.
Obviously, I have seen examples of bad RAII implementations, like I have seen examples of misuse for any other programming principle.
Those are not the only choices.
Garbage collection is at one end of the spectrum--fully manually managed is at the other end of the spectrum. There is also another axis of acquiring/releasing allocation by object or acquiring/releasing object by pool. And, if you have it, there is the axis of allocate only at startup and never free until end of thread/application vs. allocate only at a frame of of time and then destroy them all at the next frame vs. allocate whenever and wherever.
RAII encourages the usage of lots of tiny individual objects allocated whenever and wherever all with their own lifetime cycles and makes understanding the memory usage of your application very difficult (this was the whole reason Rust was made--C and C++ made managing memory in Firefox ridiculously diffused and impossible to corral).
And, I'll be blunt, I think that Rust/Zig/C3 etc. are not the right direction in spite of the fact that I use Zig a lot. I think that the garbage collected languages cede far too much in terms of performance to the compiled ones and GC languages (like say OCaml) should be being used for systems programming more often.
For example, I think we would all be in much better shape against AI vulnerability scanners if more systems programs were in GC-type languages.
In Racket:
(for ([x xs]) (displayln x))
This works for `xs` being a sequence, which includes lists and vectors.Making the type explicit generates faster code:
(for ([x (in-list xs)]) (displayln x))
(for ([x (in-vector xs)]) (displayln x))The ergonomics of using a proper REPL and interactive programming is hard to beat.
won't lie, this is hilarious. you got me from nodding along to being the spitting out food meme guy in a span of couple seconds.
JVM runtime is undeniably the most well researched and optimized runtime in history of runtimes, specifically in realm of concurrency and parallelism, it literally carries like half the world on it's back.
not to throw any shade on erlang vm - i've been a fan for well more than a decade, but other than making some interesting, but limited in practice, tradeoffs with regard to concurrency architecture, it doesn't really offer much more.
go's runtime is just a different beast altogether designed with different goals in mind and with no baggage of backward compatibility with legacy.
one particular detail i'm very grateful to Clojure for, is exactly the ability to use JVM runtime without having to touch any Java.
> Programming language syntax scarcely matters
on the contrary, it matters quite a lot.
you might be drinking some of that AI koolaid, conflating our suddenly hypertrophied abilities to produce code regardless of our familiarity with the syntax or the APIs with ability to produce and deliver good quality products, but this delusion is getting reality check as we speak.
a realization is propagating through the industry that being able to produce more code than you're able to review, comprehend and internalize is actually not a great thing.
and that's where syntax matters - it has to be high signal/noise, it has to expose you to right abstractions and it has to be pliable to allow the codebase reflect the problem in a way that minimizes cognitive load both during production and during consumption.
LLMs are language models and syntax is a crucial part of any language.
Syntax does not matter simply because it's an extremely leaky abstraction of the runtime below, is my point.
Of course syntax must be high signal/noise ratio, I believe every reasonable programmer will agree. But many are making entire careers in PLs where that's not the case. Hence, in practice it does not seem to matter much, for the better or the worse.
RE: runtime, try and pay attention to the parameters given in my comments. I specifically acknowledged that the JVM is a great and mature runtime but it's lagging behind on STM / actor capabilities. Tearing down a straw man is not impressive and it comes across as you trying to gain visibility by deliberately misrepresenting your discussion opponent's arguments.
apologies, but maybe next time try to elaborate more on sweeping statements like "syntax doesn't matter", because in current context my assumption for why you would say that is not all that outrageous.
> Syntax does not matter simply because it's an extremely leaky abstraction of the runtime below, is my point.
that would be the reason why syntax does matter, wouldn't it? nobody wants leaky abstractions!
ironically, Clojure is a great example of a hosted language that does not leak much in terms of underlying runtime, as evidenced by the fact that it has been implemented on top of a variety of runtimes with decent control over cross-runtime code reuse.
> acknowledged that the JVM is a great and mature runtime but it's lagging behind on STM / actor capabilities
you're stating this as if it's a fact, but what is your evidence? afaik jvm has a very extensive actor model library (Akka) and clojure does include a solid STM implementation (https://clojure.org/reference/refs).
the reality is that both of these approaches to concurrency are simply not popular enough, so your grievances with JVM for (allegedly!) lacking some important features relevant to them are not in sync with the demand.
> Tearing down a straw man is not impressive and it comes across as you trying to gain visibility by deliberately misrepresenting your discussion opponent's arguments.
don't debate-bro me bro, there are no straw men and no misrepresentations of your messages. if there are invalid assumptions - it's because instead of turning this into a dozen-messages-deep interrogation of what you really meant, i'm taking shortcuts and assuming what i believe is most plausible interpretation.
Well I thought we were describing our current reality, not our _desired_ one? Yes nobody wants leaky abstractions and yes they are everywhere.
Syntax matters insofar as to discourage bad habits, is what I'd refine from my previous statements. Most programmers go for the default so defaults and syntax that steers you the right way to think and write matter a lot.
That being said, people write FP Rust (myself included) and have plethora of JS libraries where immutability and FP patterns are the default. Which is a sad state of affairs but much better than nothing -- as it's introducing programmers to immutability and FP and they otherwise would never know.
> as evidenced by the fact that it has been implemented on top of a variety of runtimes with decent control over cross-runtime code reuse.
That was my top 1 reason to try it btw; I was intrigued by the fact that people are interested in making it work universally in at least two very different runtimes. To me that signals good language design and good architecture. Which I already knew; Clojure and Racket are amazing on their own.
> you're stating this as if it's a fact, but what is your evidence? afaik jvm has a very extensive actor model library (Akka) and clojure does include a solid STM implementation (https://clojure.org/reference/refs).
As already said multiple times in the thread -- my info is stale (as claimed by multiple posters).
That being said, has Akka started making full use of JVM's new green threads? Has Java itself started introducing immutability and STM / share-nothing as first-class citizens? If not, then by the "programmers reach for the defaults first" rule above I'd think Java is not yet ready. OK Clojure has these amazing libraries, kudos. Has anybody rolled up their sleeves and said "Alright, BEAM VM's reign is over, I am making the same or better runtime as them in Java / Clojure!"? If not, I'll not yet revisit.
I just don't want to deal with the endless pit of determinism bugs that global mutability nets us. The gift that keeps giving.
If Akka / Golang's runtime / Rust's various actor-emulating libraries catch up to the OTP, I'll very likely drop Erlang/Elixir because it's a struggle to have a good stable employment (or even contracting lately) with them.
Even if the BEAM VM is slower and has a few annoying sharp edges, its strengths nullify its weaknesses due to the nature of my work (HA web / API servers and also API gateways and orchestrators).
> OK Clojure has these amazing libraries, kudos. Has anybody rolled up their sleeves and said "Alright, BEAM VM's reign is over, I am making the same or better runtime as them in Java / Clojure!"?
will akka use green threads? i'm sure it will when the developers behind it deem them useful.
will jvm add immutability, stm and share-nothing primitives that (i assume you allege) are missing? sure, i guess, when it becomes frequent enough ask.
you make it seem as if the world of software development is in some constant battle for supremacy, but it just isn't.
there is no "BEAM VM's reign". and if there was - it's unlikely that anybody would care to topple it. people just want to get their job done and they use whatever tools are available, familiar and convenient for them.
> I just don't want to deal with the endless pit of determinism bugs that global mutability nets us
and a lot of people agree with you. and there's a lot of tools that address that. and i can assure you, when working with clojure, even on the blasphemous mutable jvm runtime, that class of bugs is non-existent.
> you make it seem as if the world of software development is in some constant battle for supremacy
I could have misinterpreted other people in the past -- very possible. But I also stopped caring about "battles for supremacy" a long time ago and at this point in life and career I simply use my experience and brain to go where my work is more productive, deterministic and fulfilling. To me immutability, share-nothing actors, strong vertical scalability (where a lot of PLs and runtimes do well, not only my favorites) and DX (like live prod REPLs and generally trivial observability) are those axii along which I thrive.
You and a few others seem to have emotional reactions to this which, I assure you, are very unnecessary. I ain't threatening neither your livelihood nor preferences.
Amazing how it doesn't even cross your mind that there are trade-offs to those choices. Green threads are awesome, but guess what, they come at a cost. Same for share-nothing semantics.
> Has anybody rolled up their sleeves and said "Alright, BEAM VM's reign is over, I am making the same or better runtime as them in Java / Clojure!"?
You are again presupposing the BEAM has an absolute superiority over the JVM. "Better runtime" makes no sense on its own. Better is always relative to something. Better for whom? For what?
I'd bet that you work on a traditional CRUD enterprise software, and that IO(the database) is the real bottleneck of your app. In that case, sure, the BEAM is a solid choice(so is Python, Ruby and PHP nowadays). But let's please not pretend that is all there is to software engineering.
RE: your other similarly rude comment, I have not "appealed to authority" anywhere. I said that I have used multiple PLs / runtimes and made an informed choice... for me. I don't intend to add "...for me" after each sentence. It's redundant and obviously implied when it comes to tech because obviously people have made well-working prod systems with combinations of bash and Perl ages ago. So obviously people can make nearly everything work.
If you don't intend to discuss out of position of curiosity but want to jump on people then I am not interested.
> If you don't intend to discuss out of position of curiosity
I'm not the one making sweeping statements on the superiority of one piece of technology. Reading your other response, I think you are the one who have little to no curiosity in understanding how you might be wrong.
Your negative assumptions are tiring. Sorry that you got offended by what you quoted (and I said) but I'll drop here.
Regarding language syntax, it definitely matters. In the same way the vocabulary we use shapes our thoughts, the expression of a programming language shapes the implementation. Of course, as Clojurists know all too well, it's entirely possible to write Java in any language!
To me the strengths of Erlang BEAM VM and Golang's runtime nullify their weaknesses (and of course they do have weaknesses, some are pretty hard to swallow too). To me they sit on the positive side of "right tool for the job".
I just can't work with global mutability anymore. It's an endless hopeless pit of determinism bugs. I picked my battles. Respect to whoever wants to make a career out of chasing them but that's no longer me. I want to make measurable business progress when I work and not babysit defects that should have stopped existing two decades ago.
I can agree with other posters that the JVM has come a long way. I might reassess if I get the time. And I am not bashing on anything here. I am saying what my experience showed me. To me it's tiring to pretend that all languages and runtimes are equal and I'll keep claiming they are not.
As mentioned above: I don't think we necessarily disagree at all.
All Clojure (lisps) do is remove the stupidity of syntax.
Even if syntax is the minor thing, why wear a stupid, uncomfortable shirt while running when you could wear one so comfortable you scarcely feel it?
And I agree that most LISPs remove the stupidity of syntax. Very true.
Yes and no. From the discussion here I've learned about the existence of jank, which wouldn't have come up a year or so ago and might be an interesting solution to a problem for me as it evolves (that problem mainly being me not wanting to use C++ or any of the other directly supported languages in a plugin ecosystem). So these things are worth bubbling up every now and again just for the discussion to have a chance to play out.
I used Java, Golang, Rust, Elixir (so Erlang).
My opinion is informed. STM / share-nothing-actors lend themselves amazingly well to online services for many reasons, better explained by other people and documented elsewhere (and I did not come here to advocate but to express preference and offer the take of somebody who has been around).
I am not denying that the JVM might have almost caught up in the meantime. More than a decade ago it did not.
And yes the BEAM VM is absolutely and markedly _not_ a panacea. It has a few weird sharp edges. It's just that in my work I have found having to avoid them still worth it compared to the alternatives (global mutability and more primitive parallelism which was the case for the JVM for decades).
> I am not denying that the JVM might have almost caught up in the meantime. More than a decade ago it did not.
This presupposes that the JVM had something to catch up in the meantime. Again, this lacks nuance and brings nothing to the table. The JVM makes different trade-offs than the Erlang/Golang VM does, and has different strengths and weaknesses. Both of your comments completely ignores that.
> It's just that in my work I have found having to avoid them still worth it compared to the alternatives (global mutability and more primitive parallelism which was the case for the JVM for decades).
Clojure runs on the JVM and avoids mutability pretty well. It is amazing for writing concurrent software, and has been for many years(i.e more than a decade ago).
> Similar to the weird childish name-calling people do in Rust threads
I've seen people do similar things to the JVM.
The page you have tried to access is not available because the owner of the file you are trying to access has exceeded our short term bandwidth limits. Please try again shortly. Details:
Actioning this file would cause "www.acdw.net//clojure/" to exceed the per-day file actions limit of 80000 actions, try again later
I’m not quite sure what this means. How is it different/worse than all parens..?
fyi I use paredit and just hit ) and it moves me past any kind of paren/bracket. But even without that you can just hit left and right..?
But I was talking about like, when refactoring, I'll maybe change something from a list to a vector, and I have to change the delimiters at front and back. Or, where electric-pair does do the move-past-all-parens thing when I just spam ), it doesn't do that with ]}]}]]}}]})).
Have you ever tried paredit? It’s pretty much a lifesaver for this kind of thing.
It’s easier if everything is parens, just hit paren til the errors are gone.
That’s making me really thankful to be a paredit user.
I've been loving Clojure for hobby projects lately but my editing setup sucks for Clojure. The vi commands I'm used to using are bad in Clojure (eg copying by line almost always means unbalanced parens), and the autocomplete LLMs are very inconsistent about closing syntax correctly.
It seems super cool, I just haven't done it enough for my brain to reach for paredit keybinds instead of vim chords.
This is why I am found of the community, the symbiotic approach of two language communities working together.
Tcl: everything is a string
Lisp: everything is a list"
Python: {"everything":"dictionary"}
Can't even reply inline and have to reply in the main thread.
I found them informative.
Lots of Clojure haters here I guess?
Most models do not perform particularly well in Clojure, but OpenAI models fully utilize the power of the language. Subjectively, it kind of seems to match the personality. Data at https://gertlabs.com/rankings?provider=openai
In long horizon agentic coding evaluations, strong models fix the syntax and percentile and it becomes a direct comparison of which submissions per language performed the best on average. You can filter for that here: https://gertlabs.com/rankings?provider=openai&mode=agentic_c...
What would you say is missing from Clojure for large-scale OOP design? As I understand, Clojure gives you OOP a la carte. Objects (via maps/records/structs), polymorphic dispatch (via multimethods/protocols/case), types (via Malli/TypedClojure), inheritance (via derived, isa?, etc), some encapsulation (via defn-/^:private)...
For static (partial) typing, I instead use Malli schemas. I do this for every larger Clojure program I make, because there's always something that needs paranoia, or it's handy to generate example data.
An LLM is only as good as its feedback loop. If your LLM can actually test the code it writes, it's going to be much more effective. Static types are a form of feedback (if it can use the LSP), unit/integration tests are another.
Clojure has an exceptionally good repl. LLMs can eval any piece of any function. They can test out functions they aren't familiar with. They can fetch data, try out different arguments, try different approaches before committing to one. They can query a database (read-only connection, of course), look at the result, fetch data from an API, and stitch it all together. It can even hook into your running program and debug it from the inside out!!
It makes it so much more effective at using libraries or paradigms that it isn't trained on. In my experience, hooking an LLM up to the clojure repl lets it write WAY more complex stuff. I'm talking like 10x more complex programs with zero errors, cause it can literally try it out every little piece before putting it together. It's like watching a human programming. But like, really fast.
Sorry I get a little ranty when clojure + LLMs come up, because I don't think most people realize what they're missing out on. It's crazy stuff. It's also easy peasy if you use vscode. There's an extension called calva-backseat-driver that just hooks it all up for you. Gives copilot access to the repl, and I think it exposes an mcp if you want to give claude access too.
The beauty of Clojure shines through when you want to change something that cuts through a large part of a large project. If you are using mutable data, you may end up with many bugs from various pieces of code mutating objects inconsistently. With Clojure, if someone hands you data, you can't possibly break some distant piece of code by updating an object: it's just not possible because you only ever make fast, updated copies. The more complicated your codrbase gets, the more this benefit is realized.
I actually kind of think of it as an easier mechanism with similar outcomes to Rust's borrow checker. Only one piece of code ever owns the data so things end up much safer. However it is way easier to use IMHO because you just know that zero people own anything and everyone can read everything.
It also makes converting some code to be multi-threaded extremely easily and with some constraints guaranteeably correct.
Lots of dovetailing features neatly put together for both clarity and less bugs and more usable cores which are probably sitting idle.
I found this to be one of the more interesting talks I've watched.
Like you (I think) - I love functional languages.
But there's a problem I can't really figure out how to articulate where they reach a level where they stop "just working" imo. Maybe it's just me being too dumb.
Once you're more comfortable with it and want to try a typed functional programming language, I highly recommend checking OCaml (or SML, if you're into old school tech) and see how the Module Functors are applied, most software will look extremely over-engineered after you write a few functors. It's the feature I miss the most when coding in F# or Gleam, for instance.
At one point, I was the same. But after going functional in Clojure, I can’t imagine going back. Using maps nd just having common functions that transform data into different data is definitely the way to go. This is with your time: https://youtu.be/aSEQfqNYNAc
Do you have an idea as to why that is?
If I had to guess, two things lowering reliability:
A) Balancing parens might be tough on an LLM one-shot.
B) LLMs generate tokens sequentially, but s-expressions mean the first forms to be evaluated in a body are usually the last to be written, so the LLM has to sequentially generate layers of evaluation backwards.
From my point of view, Clojure is a very successful language. It has been in stable development for >10 years now, with no major breaking changes (!). I was able to start a business using it and now make a living from it, all of it possible largely because Clojure reduces incidental complexity so much.
Now, as to LLMs, I can see this discussion is mostly theoretical, so let me pitch in with data. I've been using LLMs for Clojure for a while now and it works fantastically, from what I read about other languages, quite a bit better for me than for others. Balancing parens was a problem for early LLMs without tools, Claude Opus with clojure-mcp tools doesn't encounter that problem at all.
Additionally, the ability to try things in the REPL means that LLMs are very effective: all hypotheses and solutions are immediately tested, with automatic feedback.
Overall I get great value from LLMs and I am able to solve large problems with them.
This also doesn't hurt the code from a human reader's point of view.
;; This is real syntax!
(loop for k being the hash-keys
using (hash-value v) of hash-table
...)
Still lisp. Although the blog author has a point -
clojure is probably cleaner lisp than common lisp.I think the issue is heavily due to syntax though. Naturally the (())()()()(), but I think even aside from the (), the syntax does not seem super-efficient to me. Perhaps I have spent too much time with ruby and python, but it feels as if lisp is a legacy regression, purely syntax-wise.
https://clojure.org/about/dynamic
Until you get better at not making mistakes that the training wheels of a static type system “protect” you from, lean into the REPL as a means to build up small correct expressions into larger ones.
It's not just about skill. It's about maintainability, ease of refactor, and modeling invariants in your code in a way that they can be checked by the machine (the compiler) without every single developer having to maintain them in their head.
Clojure even knows this is an issue and many people use `spec` to sort of retrofit static typing.
Dynamic typing was, is and always will be a mistake. There is nothing you can do with dynamic typing that you cannot do with a sufficiently powerful static type system - and it doesn't have to be something absurd like Haskell's. You basically just need structural typing and type inference and some type-level programming constructs.
The worst part about Clojure is the community. Rich Hickey has cult-like status and the only thing clojurians can do is parrot his inane commentary.
That said, you don't get static typing for free. As with many things it's a trade-off: you catch some errors at compile time in exchange for working within the confines of the type system. The ultimate hope is that the time you spend fiddling with types is going to be less than the time you spend debugging type errors.
> There is nothing you can do with dynamic typing that you cannot do with a sufficiently powerful static type system - and it doesn't have to be something absurd like Haskell's. You basically just need structural typing and type inference and some type-level programming constructs.
Haskell doesn't have a complex type system for no reason; it's necessary to encompass everything it wishes to do, and even then it's not as flexible as a dynamically typed language.
For instance, how would you statically type Clojure's `assoc` function? It's not at all trivial if you want to retain the type information of the keys and values.
In practice, what you want is something that allows you to do this safely for the concrete type you're working with. If you want an abstraction that covers all of it, there are ways to achieve this in a type-safe manner, such as traits/type classes. Even in clojure, you're not working with everything at once all the time. You are working with a record, or a vector, or whatever. The fact that you can use one function for all of them is mostly just needless cleverness. In Clojure, you have to keep the type of the data you are working with in your head at all times, because even though `assoc` "just works" for many cases, that's not true in all cases. It will happily insert an integer key into a record without issue, which may or may not be waht you want. But you can also try to insert an atom key into a vector, which then crashes loudly. This is clearly an asymmetry in the abstraction.
Moreover, pointing out that Haskell cannot do what you'd want to do in this case doesn't make a lot of sense. I mentioned Haskell precisely because its type system is extremely powerful and complicated to understand for a lot of people, but still doesn't achieve the kind of flexibility we are looking for - it lacks row polymorphism.
To answer your actual question: Typing a function like that for the individual cases is bordering on trivial in a language such as typescript. For the record case, you don't even need it, because in practice, you get the correct type inference for free by just spreading one object into another.
But that wasn't really my point. Even if we limit `assoc` solely to maps it would still be difficult to type effectively.
For instance, suppose we have some code like:
(let [m* (assoc m :number 3)]
(:number m*))
We can see that the return type of this expression is obviously an integer, but what is the type of m*? How do we type m* such that (:number m*) can be inferred to be an integer by the compiler?Most statically typed languages sidestep this problem: instead of using an open data structure like a map, a closed structure like a record or class is used instead, and these structures must be explicitly typed by the user.
The problem with this approach is that now every record is specific and bespoke. You lose access to all the general-purpose functions that operate on generic data structures, and as records and classes are closed, you also lose the ability to extend them.
This is the ultimate problem with static type systems: you're trading capability for safety. If you're programming within a static type system, there are options that are simply not available or feasible to use.
The asymmetry lies in the fact that it's an overloaded function that's supposed to do the right things every time, but in some cases, it does what is arguably the wrong thing, silently, and in others, it refuses to do the wrong thing and fails loudly. It's better that it fails loudly, of course, but the point is that the ergonomics of the abstraction is lessened because you can't just assume it will work. You effectively have to keep the types of all the things involved in your head and/or trace them to ensure that you don't run into a crash.
> We can see that the return type of this expression is obviously an integer, but what is the type of m? How do we type m such that (:number m*) can be inferred to be an integer by the compiler?
This is trivial in TypeScript. You can see it in action here: https://www.typescriptlang.org/play/?#code/MYewdgzgLgBAtjAvD...
const m = { name: "weavejester", active: true };
const mStar = { ...m, number: 3 };
// ^? const mStar: { number: number, name: string, active: boolean }
const x = mStar.number;
// ^? const x: number
> This is the ultimate problem with static type systems: you're trading capability for safety. If you're programming within a static type system, there are options that are simply not available or feasible to use.This is just not true. It's true for some certain specific static type systems, but not true in general, and that brings me back to my original thesis: You just need a sufficiently capable type system with the right properties - structural/row polymorphism, ish, plus type inference. And also my Haskell point: it doesn't have to be an incredibly complicated type system that is beyond mortal ken. TypeScript is already doing this and it's arguably one of the most used programming languages on earth.
You make this sound difficult, but in practice type errors are rare in Clojure and generally caught in the REPL or by tests, since the moment you go down a branch with a type error an exception is thrown.
Contrast this to errors caused via mutable state, which are usually far harder to track down, because the failure condition is more specific.
> This is trivial in TypeScript.
In the example you give you're omitting assoc entirely, which defeats the point. I'm using assoc as a minimal example, but the same principle applies to more complex functions, so replacing assoc with the equivalent expression doesn't tell us whether or not we can effectively type a function that deals with maps.
So lets try doing this properly. At minimum we need something like this:
type Assoc<M extends object, K extends string, V> =
Omit<M, K> & Record<K, V>;
function assoc<M extends object, K extends string, V>(
m: M, k: K, v: V): Assoc<M, K, V> {
return { ...m, [k]: v } as Assoc<M, K, V>;
}
(Note that we need to perform an explicit cast in order to inform TypeScript of the type of the key.)However, this produces some rather messy types consisting of nested Assocs. In order to get back to something a human can read, we can use an additional Simplify type to force the type system to reduce it back down into an typed object:
type Simplify<T> = {[K in keyof T]: T[K]} & {};
type Assoc<M extends object, K extends string, V> =
Simplify<Omit<M, K> & Record<K, V>>;
function assoc<M extends object, K extends string, V>(
m: M, k: K, v: V): Assoc<M, K, V> {
return { ...m, [k]: v } as Assoc<M, K, V>;
}
(The empty `& {}` intersection forces normalization, providing a cleaner reported type.)We're still not done, though, as if we want the same type checking that a class has, we need to ensure that a key cannot be overwritten with a value of a differing type. So we'll type the value argument as well to ensure it matches the type of an existing value within the map:
type Simplify<T> = {[K in keyof T]: T[K]} & {};
type Assoc<M extends object, K extends string, V> =
Simplify<M & Record<K, V>>;
type AssocValue<M extends object, K extends string, V> =
K extends keyof M ? (V extends M[K] ? V : never) : V;
function assoc<M extends object, K extends string, V>(
m: M, k: K, v: AssocValue<M, K, V>): Assoc<M, K, V> {
return { ...m, [k]: v } as Assoc<M, K, V>;
}
So this is possible to type in TypeScript (to its credit), but is it "trivial"? And is this type signature significantly less complex than one might find in Haskell?It’s not an arrogant take; it’s arrogant to think you know static typing is a requirement for developing software well.
There’s your problem. And static typing won’t save you either.
The skill is not that, it’s the ability to compose and evolve systems such that you don’t have to hold so much state in your head.
(Btw that property can hold for a million LOC codebase.)
> Too Many Requests
> The page you have tried to access is not available because the owner of the file you are trying to access has exceeded our short term bandwidth limits. Please try again shortly.
>
> Details:
> Actioning this file would cause "www.acdw.net//clojure/" to exceed the per-day file actions limit of 80000 actions, try again later
Eh? That's completely lifted from CL (https://www.lispworks.com/documentation/HyperSpec/Body/t_seq...). Same for AREF/NTH, there's ELT.
Other than that, I agree, CL is baroque yet needs some hole filling here and there.
> Lisp: everything is a list
But that's wrong. Not even a little. Unless you mean LISP 1.5...
> Too much syntax
Funnily, I'm mostly okay with the new vector/set/hash-table literals, my big problem and that of some other people is the use of vectors in macros/special operators instead of lists. `(let [a b] ...)` instead of `(let (a b) ...)` is _not_ okay.
Is (let (a b) …) even valid clojure?
It takes some getting used to, and I do wish Clojure would do something more like (let [[var1 val] [var2 val]] ... .. though of course then you'd have to figure something else out for destructuring.
it is however quite consistent, clojure uses vector form for most macros that require a "control" form before a "data" form: argument list in defn, names list in let, iteration descriptors in for, etc. you get used to consistency quite easily.
Clojure’s abstraction is a bit more far-reaching than Common Lisp’s SEQUENCE, implemented as interfaces rather than types.
A Clojure sequence is anything “seqable” (either implements the Seqable interface, or special case handling for host platform collections), not just lists and vectors. Hash maps, sets, Java Iterables, etc. are all seqable and work with the same standard collection functions.
e.g. you can `(map (fn [[k v]] …) {:a 1 :b 2})`, rather than needing a separate MAPHASH function.
[1] https://survey.stackoverflow.co/2019#technology-_-what-langu...
do a search for indeed jobs
Almost the only reason that python is popular and people do everything with it is synergy. The only reason that javascript is eating the world and any web dev has to know it, and people do everything with it is synergy.
A big reason for C# to have popularity is because it's for Microsoft (politics).
These are just some examples. Being used doesn't mean anything.
There were and still are a ton of PHP jobs. You tell me, is it because PHP is a great language and solves our problems? No, because it has synergy.
If you're good enough, you can pretty much choose what language/framework you work in.
What you can predict is that those employers for whom clojure (or any other minority language) is either acceptable or preferred are deciding that they don't want commodity, low-margin employees. It's a signal that they prefer not to buy the mass-market offering, and ought to expect to pay a premium.
What that means is that if your only way of finding jobs is to be one of the mass-market crowd, you're unlikely to find a premium-paying employer because that's not where they're looking.
if you limit yourself to a tiny almost invisible slice you are betting your business on finding candidates and relying on that small pool of talent. employees come and go and you would have to deal with this issue constantly.
And it's only true that "employees come and go" is a problem if 1. you assume turnover has to be high, and 2. you don't know where to find qualified people you need. Turnover will be high if you assume you need commodity devs and don't, for instance, invest in their skills (like teaching them minority languages if that's your thing).
If I see a company hiring for "python developers" or "java developers" at this point I absolutely know what sort of problems their codebases will have, because they're in the commodity market and treating development as a cost centre to be minimised. Which leads to lower salaries, which drives higher turnover.
It's all self-fulfilling.