1,117 karma · joined November 17, 2022
I would so much rather read and write:
let x = """
a
multiline string
example
"""
than let x =
//a
//multiline string
//example
;
In this particular example, zig doesn't look that bad, but for longer strings, I find adding the // prefix onerous and makes moving strings around different contexts needlessly painful. Yes, I can automatically add them with vim commands, but I would just rather not have them at all. The trailing """ is also unnecessary in this case, but it is nice to have clear bookends. Zig by contrast lacks an opening bracket but requires a closing bracket, but the bracket it uses `;` is ambiguous in the language. If all I can see is the last line, I cannot tell that a string precedes it, whereas in my example, you can.Here is a simple way to implement the former case: require tabs for indentation. Parse with recursive descent where the signature is
(source: string, index: number, indent: number, env: comp_env) => ast
Multiline string parsing becomes a matter of bumping the indent parameter. Whenever the parser encounters a newline character, it checks the indentation and either skips it, or if is less than the current indentation requires a closing """ on the next line at a reduced indentation of one line.This can be implemented in under 200 lines of pure lua with no standard library functions except string.byte and string.sub.
It is common to hear complaints about languages that have syntactically significant whitespace. I think a lot of the complaints are fair when the language does not have strict formatting rules: python and scala come to mind as examples that do badly with this. With scala, practically everyone ends up using scalafmt which slows down their build considerably because the language is way too permissive in what it allows. Yaml is another great example of significant whitespace done poorly because it is too permissive. When done strictly, I find that a language with significant whitespace will always be more compact and thus, in my opinion, more readable than one that does not use it.
I would never use zig directly because I do not like its syntax even if many people do. If I was mandated to use it, I would spend an afternoon writing a transpiler that would probably be 2-10x faster than the zig compiler for the same program so the overhead of avoiding their decisions I disagree with are negligible.
Of course from this perspective, zig offers me no value. There is nothing I can do with zig that I can't do with c so I'd prefer it as a target language. Most code does not need to be optimized, but for the small amount that does, transpiling to c gives me access to almost everything I need in llvm. If there is something I can't get from c out of llvm (which seems highly unlikely), I can transpile to llvm instead.
One obvious cost to code that is removed in production is that there is now a divergence between what your source code says it does and what it actually does. I now need to keep in mind the context the program is running in. There is a cost to that. It might be worth it, but it isn't zero.
But just because syntax is superficial doesn't mean that it isn't important. If a language has such poor syntax that I feel the need to write my own compiler to work around its syntax, I have to seriously question the skills and/or motivations of the author. If I am capable of writing a compiler at the syntactic level, why not just go all in and write my own compiler that implements _my_ desired semantics? A language that I find subjectively distasteful at the syntactic level is nearly guaranteed to be filled with semantic and architectural decisions that I also dislike. Consider Rust, I do not think that its syntax and abysmal compilation times can be decoupled. I would rather write my own borrow checker than subject myself to writing rust. And the reason is not the syntax, which I do strongly dislike, but the semantic properties of the language, such as horrible compilation times and compiler bugs (if a language has more than 100 open issues on github, I consider it broken beyond repair).
You then construct the parser by combining unambiguous parsers from the bottom up. The result ends up unambiguous by construction.
This high level algorithm is much easier to implement without a global lexer. Global lexing can be a source of inadvertent ambiguity. Strings make this obvious. If instead, you lex in a context specific way, it is usually easy to efficiently eliminate ambiguities.
Local variables are also not necessary to have reasonable ergonomics, even in mathy contexts. Instead, the language can define special operators that access fields on the stack by type name and move or copy them to the top of the stack. For some reason, in the math example, the author deliberately added an unnecessary parameter that had to be dropped and put the arguments in the wrong order. Cleaning up the signature, there are several reasonable ways to solve the problem in a concatenative language with the features I've described.
type n number
type y n
type x n
def op_math_example [(y x) -> (n)] \\x * \\y \\y * + \y abs -
where \FOO moves a value of type FOO to the top of the stack and \\FOO copies the value to the top, leaving the original where it was. Yes, the \\ is a little ugly, but these should generally be used sparingly.Even if we restore the original signature and use the author's syntax, there is a much cleaner solution to the problem without the move/copy operators.
def sq dup *
def op_math_full drop swap sq over sq + swap abs -
This is extremely cryptic. I suspect most forth programmers would have added a stack signature comment, but it is even better if the compiler statically verifies the signature for us, which is how the language I am describing differs from vanilla forth (where stack comments also have the risk of being wrong in an evolving system). If we restore the type system, it becomes: type n number
type x n
type y n
type z n
def sq [(n) -> (n)] dup *
def op_math_impl [(y x) (n)] sq over sq + swap abs -
def op_math_full [(x y z) -> (n)] drop swap op_math_impl
Of course it is still not as obvious to the reader what op_math_full actually does as (y * y) + (x * x) - abs(y). But in practice it would have some name like projection_size so once it has been written correctly, it doesn't really matter that it is a bit obscure to read at a glance. You also get really good at simulating the stack in your head and they type signature makes this much easier. Still, when you are confused, you can always add stack debugging like so: def projection_size [(x y z) -> (n)] drop swap PRINT_STACK sq over sq + swap abs -
1 2 3 projection_size
and the output would be something like: ({ y 2 } { x 1 })
Writing programs in this style is very nice so long as you have enough tooling to facilitate it.I have only used concatenative languages that I have implemented myself, however. I have read quite a bit about forth, but for me, there are clear problems with vanilla forth that are solved for me with the meta operators like [, [[, \ and \\ as well as good debug tools like PRINT_STACK above. Forth was an incredible discovery for its time. We can do a lot better now.
Everything is done for a reason. Unfortunately, often the reason is the author didn't know what they we were doing.
People talk a lot about the productivity gains of ai, but fixing problems like this at the language level could have an even bigger impact on productivity, but are far less sensational. Think about how much productivity is lost due to obscure but detectable bugs like this one. I don't think rust is a good answer (it doesn't check overflow by default), but at least it points a little bit in the vaguely correct direction.
In the meantime, we thankfully have agency and are free to choose not to use global variables and shared memory even if the platform offers them to us.
So, to begin with, testing is rarely prioritized. But most developer orgs eventually realize that centralized testing is necessary or else everyone is stuck in permanent "works on my machine!" mode. When deciding to switch to automated ci, eng management is left with the build vs buy decision. Buy is very attractive for something that is not seriously valued anyway and that is often given away for free. There is also industry consensus pressure, which has converged on github (even though github is objectively bad on almost every metric besides popularity -- to be fair the other larger players are also generally bad on similar ways). This is when the lock in begins. What begins as a simple build file starts expanding outward. Well intentioned developers will want to do things idiomatically for the ci tool and will start putting logic in the ci tool's dsl. The more they do this, the more invested they become and the more costly switching becomes. The CI vendor is rarely incentivized to make things truly better once you are captive. Indeed, that would threaten their business model where they typically are going to sell you one of two things or both: support or cpu time. Given that business model, it is clear that they are incentivized to make their system as inefficient and difficult to use (particularly at scale) as possible while still retaining just enough customers to remain profitable.
The industry has convinced many people that it is too costly/inefficient to build your own test infrastructure even while burning countless man and cpu hours on the awful solutions presented by industry.
Companies like blacksmith are smart to address the clear shortcomings in the market though personally I find life too short to spend on github actions in any capacity.
There exists a much smaller subset of jank that is capable of compiling itself. I am quite sure that you could write a bootstrap compiler in clojure (but restricted to the same subset) that translates to clojure (or some other language you prefer targeting). My guess, from experience writing small compilers, is it should be doable in ~5000 lines or even less.
Once you had that, you could then write a backend that translates to c. This gives you a native compiler for this small subset of jank. Applied to its own source code, you get a native compiler binary for free and cut the cord from clojure.
Now you have a native compiler and a very small, tight language. Because it is so small, it is easy both to check correctness and optimize. You can add new backends relatively easily. For the repl, you can compile functions into their own hash-addressed shared libraries using a c compiler and dlopen them. TCC is very fast for this purpose even if the generated code isn't quite as good as optimized gcc or clang.
All of the future vision could be implemented on top of this foundation. The current approach is likely going to just bog the author down, dealing with accidental complexity from c++ and llvm especially. It is also better to have fewer features implemented completely and correctly than more features incompletely or incorrectly implemented.
Global errno is a bad design that is a consequence of bad design in c. If c supported tagged unions and pattern matching, there would be no way to access the result or error value without first checking what type of value it is. But since c doesn't support this, all these apis require programmer discipline that cannot be relied upon. As a consequence, it becomes almost inevitable that you encounter some inscrutable bug that someone else wrote due to their lack of skill and/or discipline. That doesn't make sense to me though I do understand why it is the way it is.
I have found that while there is a learning curve to programming using only recursion for looping, code quality does go significantly up under this restriction.
Here is why I personally think tail recursion is better than looping: with tail recursion, you are forced to explicitly reenter the loop. Right off the bat, this makes it difficult to inadvertently write an infinite loop. The early exit problem is also eliminated because you just return instead of making a recursive call. Moreover, using recursion generally forces you to name the function that loops which gives more documentation than a generic for construct. A halfway decent compiler can also easily detect tail recursion and rewrite it as a loop (and inline if the recursive function is only used in one place) so there need not to be any runtime performance cost of tail recursion instead of looping.
Unfortunately many languages do not support tail call optimization or nested function definitions and also have excessively wordy function definition syntax which makes loops more convenient to write in those languages. This conditions one to think in loops rather than tail recursion. Personally I think Lean would be better if it didn't give in and support imperative code and instead helped users learn how to think recursively instead.
Let's take this change as an example. If I already wrote a program that used the old apis and meets my needs what is the benefit of this change for me? Now I have to go back and rewrite my old code and I might introduce a new bug in the migration, especially if I don't understand all of the nuance in the difference between the apis. Even if I agree that the new apis are better, the cost of migration might outweigh the benefits, but I am forced in to the migration or forking the compiler, which both might be bad choices for me.
It is not necessary to do this. They could, for example, have versioned stdlib and then maybe all I need to is pin my stdlib version. One complaint is that having multiple standard libraries causes more maintenance burden, but why should that be the primary concern? What is the cost to the maintainer vs. the cost to the community. If you make 1000 users spend an hour migrating their code, are you really going to save 1000 hours of maintenance burden?
Moreover, if the zig team wrote code with the assumption that they can never get rid of it, perhaps they wouldn't make so many design mistakes that all of these breaking changes become inevitable.
If I wrote a program in zig, I would feel obligated to also learn how to bootstrap it so that I wouldn't be subject to unwanted breaking changes. But then I see that bootstrapping involves some bizarre wasm hack instead of the tried and true bootstrapping approach of writing a simple first pass compiler in c (or some other language) and I am reminded again why I would never choose this language.
Given how annoying parking is, I'll bet that there are also many business owners who would trade some profit for their own ease of parking. Especially given that they have the power to squeeze their employees rather than bear the full cost themselves.
This type of engineer is often misunderstood and underappreciated by management. Management is often motivated by immediate short term goals. Instead of cherishing the work of the engineers who build the foundational systems that will enable the long term success of the org, they complain about them missing arbitrary short term goals and accuse them of doing engineering for engineering sake instead of real work.
Management will celebrate the coder who whips up a buggy, but cool, feature in a week and will look the other way at the fact that the feature will always be a little bit broken because of its shoddy construction and instead will allocate some lesser engineers to maintain it. If instead the feature had been built correctly from the start, it may have been launched a bit later, but the overall cost will be much lower. Moreover, the poor engineers who are forced to maintain it (and let's be honest, the people who quickly churn out shoddy but shiny work almost never have to maintain it themselves) will not only be wasting their time, they will be actively internalizing the anti-patterns present in the code. This both inhibits their growth and understanding of good design principles and teaches them the bad lesson that crap is good and unless they have a uniquely strong character or good mentors, they will tend to either become demoralized (hurting their productivity and value to the company) or they will internalize the incentive to get out of maintenance work and build shoddy features of their own to pass down to the next poor soul.
The truly great engineer is the one who breaks this cycle by building extendable systems that are correct by design and takes accountability for everything they ship. They raise up the entire org both by their work and example. In the long run, they will be far more productive and positively impactful than the sloppy cowboy coder.
Unfortunately, the industry writ large celebrates and incentivizes cowboy coding so doing the right thing is very much against the grain. Indeed, the people who rise up the org chart tend to be the cowboys so they cannot even see the value of the other way and will often actively antagonize or undermine those who do the right thing (and threaten their dominant position in the org).
The trouble of course is that there is always money to be made debugging. There is almost no incentive in industry to truly eliminate bugs and, indeed, I would argue that the incentives in the industry actively encourage bugs because they lead to lucrative support contracts and large dev teams that spend half their time chasing down bugs in a never-ending cycle. If a company actually shipped perfect software, how could they keep extracting more money from their customer?