Threads for matklad

    1. 12

      It is not a good formatter, though, because it cannot enforce a maximum column width.

      You can fight me, but this is exactly what makes gofmt excellent, and rustfmt very annoying. zig fmt is best though, there, the user controls line breaks via stuff like trailing commas.

      1.  

        I see from the upvotes there’s a perspective here, and I’d like to understand it.

        For contrast, here’s my perspective, from which rustfmt is good and gofmt is bad.

        • I like to view code on a vertical monitor, because seeing ~two screen-fulls of text at once is much better than just one. Or, if I’m using a horizontal monitor, I like to have windows side by side. Either way, this (together with having relatively big text) limits my screen width to a little more than 100 characters of width. This isn’t unique to me, lots of people like to limit line widths.
        • Soft-wrapped lines are awful. Hopefully this isn’t a contentious point. (By soft wrapping I mean when you have e.g. a line of length 150 and a screen width of 115, so 35 characters get shown on the next row.)
        • Therefore it’s very valuable to me to ensure that all lines are no longer than 100 characters (or 120, or 80, or whatever).
        • This leaves two options: (A) have a tool like rustfmt pick the line breaks, or (B) have developers pick the line breaks, like gofmt has them do. Option (B) allows artisinal hand-crafted code formatting, with three downsides: (i) it requires more effort by developers that to me feels like busywork, I’d rather focus on the code itself, (ii) it’s going to be less consistent than what an automatic code formatter gives you, and (iii) realistically not everyone is going to obey any particular line length limit, and there’s going to be soft wrapping.

        What’s the other perspective?

        1.  

          I am not sure, but I think the original comment might be complaining more about “over-canonicalization”, which we discussed last year.

          • rust fmt is basically a syntax tree pretty-printer, from what I understand. It clobbers your formatting.
          • go fmt is not. It fixes certain things about your formatting, rather than clobbering it.

          So I think this part of the article is wrong:

          Formatters are source-transformation tools that parse source code and re-print the resulting AST in some canonical form that normalizes whitespace and optional syntactic constructs

          It is leaving out the go fmt / clang-format / yapf / scala fmt type of formatter, which we also discussed. They do not re-print the syntax tree; they use some kind of tree to fix the text.

          And I’d claim that go fmt is the tool that made formatting “universal” among working programmers (although I don’t use Go myself, so I haven’t really used it).

          I guess in the Lisp / ML / Haskell worlds there were people using pretty printers before that, but they weren’t common for Python/JS/C++/Java/shell etc.


          Personally I use clang-format and yapf, and they both do line wrapping, but they are not canonicalizers.

          They’re not perfect – I tend to disagree with yapf fairly often. But I just use them both, because it’s better than no formatting!

          I think if go fmt had line wrapping, it would be fine, so in that sense I disagree with the grandparent comment.

          Line wrapping tends to be where people “disagree” more though. A “bad wrap” may cause programmers to abandon a given formatter :-)

          1.  

            Oh and to be clearer:

            • If your only input is some kind of AST, then basically you must auto-wrap.
              • Otherwise everything would be on the same line. The worst case is if you have an S-expression - the whole program would be one line :-)
            • If you’re fixing the text with an AST, then you don’t have to auto-wrap. You can just use what was there.

            So whether line wrapping occurs is related to whether the formatter is a “canonicalizer”, but it’s not identical.

            Again clang-format and yapf do line wrapping, but they are not canonicalizers. (and clang-format is the best formatter I’ve used)


            Now that I think about it, similar to the AST/CST/lossless syntax tree discussion below, I think some different terms would help. I think “pretty printer” and “formatter” are overloaded.

            I think of Wadler’s algorithm not as “pretty printing”, but “line wrapping”. You set up constraints with the groups, compute the width of leaf nodes, and give it a max width. And then it outputs a wrapped program.

            But there are other aspects of formatting that are separate, like aligning comments to the same column, or sorting imports alphabetically, which clang-format does.

            And also you might want to apply line wrapping separately to code and comments! The comments would have a slightly different algorithm, so line wrapping is more like a “subroutine”

          2.  

            (B) have developers pick the line breaks, like gofmt has them do.

            While I’ve never tried gofmt, I mislike how ambiguous this can be. The reason I use formatters is because they enforce consistency throughout a code base, which makes it easier to read and avoids needless bikeshedding across multiple authors.

            To anyone who has used gofmt, have you run into issues where the column width is inconsistent when collaborating on code? Is it a real problem, or does it never come up in practice?

          3.  

            My hot take is column width should be enforced after the initial indentation with the goal of aiding reading. Humans prefer reading around 65 chars per line (which is why book are book-sized), but where that line starts shouldn’t matter.

            1.  

              The recommendation of about 65 characters per line (or 45–90 characters according to another source) was probably based on studies of humans reading prose. But code, unlike prose, is much less often read thoroughly from top to bottom. It is often important to be able to skim code and quickly identify which part of a file is relevant, or to be able to look back and forth between two distant lines to compare them.

              Because of these extra requirements, the 65-character guideline might not apply to code. Fitting more characters on each line will reduce the need to scroll to see more code, and though it will be at the expense of the ease of reading the code from top to bottom, that might be a worthwhile tradeoff.

          4. 82

            The TypeScript dev lead posted this response about the language choice on Reddit, for anyone who’s curious:

            (dev lead of TypeScript here, hi!)

            We definitely knew when choosing Go that there were going to be people questioning why we didn’t choose Rust. It’s a good question because Rust is an excellent language, and barring other constraints, is a strong first choice when writing new native code.

            Portability (i.e. the ability to make a new codebase that is algorithmically similar to the current one) was always a key constraint here as we thought about how to do this. We tried tons of approaches to get to a representation that would have made that port approach tractable in Rust, but all of them either had unacceptable trade-offs (perf, ergonomics, etc.) or devolved in to “write your own GC”-style strategies. Some of them came close, but often required dropping into lots of unsafe code, and there just didn’t seem to be many combinations of primitives in Rust that allow for an ergonomic port of JavaScript code (which is pretty unsurprising when phrased that way - most languages don’t prioritize making it easy to port from JavaScript/TypeScript!).

            In the end we had two options - do a complete from-scrach rewrite in Rust, which could take years and yield an incompatible version of TypeScript that no one could actually use, or just do a port in Go and get something usable in a year or so and have something that’s extremely compatible in terms of semantics and extremely competitive in terms of performance.

            And it’s not even super clear what the upside of doing that would be (apart from not having to deal with so many “Why didn’t you choose Rust?” questions). We still want a highly-separated API surface to keep our implementation options open, so Go’s interop shortcomings aren’t particularly relevant. Go has excellent code generation and excellent data representation, just like Rust. Go has excellent concurrency primitives, just like Rust. Single-core performance is within the margin of error. And while there might be a few performance wins to be had by using unsafe code in Go, we have gotten excellent performance and memory usage without using any unsafe primitives.

            In our opinion, Rust succeeds wildly at its design goals, but “is straightforward to port to Rust from this particular JavaScript codebase” is very rationally not one of its design goals. It’s not one of Go’s either, but in our case given the way we’ve written the code so far, it does turn out to be pretty good at it.

            Source: https://www.reddit.com/r/typescript/comments/1j8s467/comment/mh7ni9g

            1. 77

              And it’s not even super clear what the upside of doing that would be (apart from not having to deal with so many “Why didn’t you choose Rust?” questions)

              People really miss the forest for the trees.

              I looked at the repo and the story seems clear to me: 12 people rewrote the TypeScript compiler in 5 months, getting a 10x speed improvement, with immediate portability to many different platforms, while not having written much Go before in their lives (although they are excellent programmers).

              This is precisely the reason why Go was invented in the first place. “Why not Rust?” should not be the first thing that comes to mind.

              1. 6

                I honestly do think the “Why not Rust?” question is a valid question to pop into someone’s head before reading the explanation for their choice.

                First of all, if you’re the kind of nerd who happens to follow the JavaScript/TypeScript dev ecosystem, you will have seen a fair number of projects either written, or rewritten, in Rust recently. Granted, some tools are also being written/rewritten in other languages like Go and Zig. But, the point is that there’s enough mindshare around Rust in the JS/TS world that it’s fair to be curious why they didn’t choose Rust while other projects did. I don’t think we should assume the question is always antagonistic or from the “Rust Evangelism Strike Force”.

                Also, it’s a popular opinion that languages with algebraic data types (among other things) are good candidates for parsers and compilers, so languages like OCaml and Rust might naturally rank highly in languages for consideration.

                So, I honestly had the same question, initially. However, upon reading Anders’ explanation, I can absolutely see why Go was a good choice. And your analysis of the development metrics is also very relevant and solid support for their choice!

                I guess I’m just saying, the Rust fanboys (myself, included) can be obnoxious, but I hope we don’t swing the pendulum too far the other way and assume that it’s never appropriate to bring Rust into a dev conversation (e.g., there really may be projects that should be rewritten in Rust, even if people might start cringing whenever they hear that now).

              2. 34

                That is great explanation of “Why Go and not Rust?”

                If you’re looking for “Why Go and not AOT-compiled C#?” see here: https://youtu.be/10qowKUW82U?t=1154s

                A relevant quote is that C# has “some ahead-of-time compilation options available, but they’re not on all platforms and don’t really have a decade or more of hardening.”

                1. 8

                  That interview is really interesting, worth watching the whole thing.

                  1. 9

                    Yeah Hjelsberg also talks about value types being necessary, or at least useful, in making language implementations fast

                    If you want value types and automatically managed memory, I think your only choices are Go, D, Swift, and C# (and very recently OCaml, though I’m not sure if that is fully done).

                    I guess Hjelsberg is conceding that value types are a bit “second class” in C#? I think I was surprised by the “class” and “struct” split, which seemed limiting, but I’ve never used it. [1]

                    And that is a lesson learned from the Oils Python -> C++ translation. We don’t have value types, because statically typed Python doesn’t, and that puts a cap on speed. (But we’re faster than bash in many cases, though slower in some too)


                    Related comment about GC and systems languages (e.g. once you have a million lines of C++, you probably want GC): https://lobste.rs/s/gpb0qh/garbage_collection_for_systems#c_rrypks

                    Now that I’ve worked on a garbage collector, I see a sweet spot in languages like Go and C# – they have both value types deallocated on the stack and GC. Both Java and Python lack this semantic, so the GCs have to do more work, and the programmer has less control.

                    There was also a talk that hinted at some GC-like patterns in Zig, and I proposed that TinyGo get “compressed pointers” like Hotspot and v8, and then you would basically have that:

                    https://lobste.rs/s/2ah6bi/programming_without_pointers#c_5g2nat


                    [1] BTW Guy Steele’s famous 1998 “growing a language” actually advocated value types in Java. AFAIK as of 2025, “Project Valhalla” has not landed yet

                    1. 5

                      and very recently OCaml, though I’m not sure if that is fully done

                      Compilers written in OCaml are famous for being super-fast. See eg OCaml itself, Flow, Haxe, BuckleScript (now ReScript).

                      1.  

                        If you want value types and automatically managed memory, I think your only choices are Go, D, Swift, and C#

                        Also Nim.

                        1.  

                          Also Julia.

                          There surely are others.

                          1.  

                            Yes good points, I left out Nim and Julia. And apparently Crystal - https://colinsblog.net/2023-03-09-values-and-references/

                            Although thinking about it a bit more, I think Nim, Julia, (and maybe Crystal) are like C#, in that they are not as general as Go / D / Swift.

                            You don’t have a Foo* type as well as a Foo type, i.e. the layout is orthogonal to whether it’s a value or reference. Instead, Nim apparently has value objects and reference objects. I believe C# has “structs” for values and classes for references.

                            I think Hjelsberg was hinting at this category when saying Go wins a bit on expressiveness, and it’s also “as close to native as you can get with GC”.


                            I think the reason this Go’s model is uncommon is because it forces the GC to support interior pointers, which is a significant complication (e.g. it is not supported by WASM GC). Go basically has the C memory model, with garbage collection.

                            I think C#, Julia, and maybe Nim/Crystal do not support interior pointers (interested in corrections)


                            Someone should write a survey of how GC tracing works with each language :) (Nim’s default is reference counting without cycle collection.)

                            1.  

                              Yeah that’s interesting. Julia has a distinction between struct (value) and mutable struct (reference). You can use raw pointers but safe interior references (to an element of an array for example) include a normal reference to the (start of the) backing array, and the index.

                              I can understand how in Rust you can safely have an interior pointer as the borrow checker ensures a reference to an array element is valid for its lifetime (the array can’t be dropped or resized before the reference is dropped). I’m very curious - I would like to understand how Go’s tracing GC works with interior pointers now! (I would read such a survey).

                              1.  

                                Ok - Go’s GC seems to track a memory span for each object (struct or array), stored in kind of a span tree (interval tree) for easy lookup given some pointer to chase. Makes sense. I wonder if it smart enough to deallocate anything dangling from non-referenced elements of an array / fields of a struct, or just chooses to be conservative (and if so do users end up accidentally creating memory leaks very often)? What’s the performance impact of all of this compared to runtimes requiring non-interior references? The interior pointers themselves will be a performance win, at the expense of using an interval tree during the mark phase.

                                https://forum.golangbridge.org/t/how-gc-handles-interior-pointer/36195/5

                        2.  

                          It’s been a few years since I’ve written any Go, but I have a vague recollection that the difference between something being heap or stack allocated was (sometimes? always?) implicit based on compiler analysis of how you use the value. Is that right? How easy it, generally, to accidentally make something heap-allocated and GC’d?

                          That’s the only thing that makes me nervous about that as a selling point for performance. I feel like if I’m worried about stack vs heap or scoped vs memory-managed or whatever, I’d probably prefer something like Swift, Rust, or C# (I’m not familiar at all with how D’s optional GC stuff works).

                          1.  

                            Yes, that is a bit of control you give up with Go. Searching for “golang escape analysis”, this article is helpful:

                            https://medium.com/@trinad536/escape-analysis-in-golang-fc81b78f3550

                            $ go build -gcflags "-m" main.go
                            
                            .\main.go:8:14: *y escapes to heap
                            .\main.go:11:13: x does not escape
                            

                            So the toolchain is pretty transparent. This is actually something I would like for the Oils Python->C++ compiler, since we have many things that are “obviously” locals that end up being heap allocated. And some not so obvious cases. But I think having some simple escape analysis would be great.

                            1.  

                              Yes, the stack/heap distinction is made by the compiler, not the programmer, in Go.

                            2.  

                              Why did you leave JS/TS off the list? They seem to have left it off too and that confuses me deeply because it also has everything they need

                              1.  

                                Hejlsberg said they got about 3x performance from native compilation and value types, which also halved the memory usage of the compiler. They got a further 3x from shared-memory multithreading. He talked a lot about how neither of those are possible with the JavaScript runtime, which is why it wasn’t possible to make tsc 10x faster while keeping it written in TypeScript.

                                1.  

                                  Yeah but I can get bigger memory wins while staying inside JS by sharing the data structures between many tools that currently hold copies of the same data: the linter, the pretty-formatter, the syntax highlighter, and the type checker

                                  I can do this because I make my syntax tree nodes immutable! TS cannot make their syntax tree nodes immutable (even in JS where it’s possible) because they rely on the node.parent reference. Because their nodes are mutable-but-typed-as-immutable, these nodes can never safely be passed as arguments outside the bounds of the TS ecosystem, a limitation that precludes the kind of cross-tool syntax tree reuse that I see as being the way forward

                                  1.  

                                    Hejlsberg said that the TypeScript syntax tree nodes are, in fact, immutable. This was crucial for parallelizing tsgo: it parses all the source files in parallel in the first phase, then typechecks in parallel in the second phase. The parse trees from the first phase are shared by all threads in the second phase. The two phases spread the work across threads differently. He talks about that kind of sharing and threading being impractical in JavaScript.

                                    In fact he talks about tsc being designed around immutable and incrementally updatable data structures right from the start. It was one of the early non-batch compilers, hot on the heels of Roslyn, both being designed to support IDEs.

                                    Really, you should watch the interview https://youtu.be/10qowKUW82U

                                    AIUI a typical LSP implementation integrates all the tools you listed so they are sharing a syntax tree already.

                                    1.  

                                      It’s true that I haven’t watched the interview yet, but I have confirmed with the team that the nodes are not immutable. My context is different than Hejlsberg’s context. For Hejlsberg if something is immutable within the boundaries of TS, it’s immutable. Since I work on JS APIs if something isn’t actually locked down with Object.freeze it isn’t immutable and can’t safely be treated as such. They can’t actually lock their objects down because they don’t actually completely follow the rules of immutability, and the biggest thing they do that you just can’t do with (real, proper) immutable structures is have a node.parent reference.

                                      So they have this kinda-immutable tech, but those guarantees only hold if all the code that ever holds a reference to the node is TS code. That is why all this other infrastructure that could stand to benefit from a shared standard format for frozen nodes can’t: it’s outside the walls of the TS fiefdom, so the nodes are meant to be used as immutable but any JS code (or any-typed code) the trees are ever exposed to would have the potential to ruin them by mutating the supposedly-immutable data

                                      1.  

                                        To be more specific about the node.parent reference, if your tree is really truly immutable you need to replace a leaf node you must replace all the nodes on the direct path from the root to that leaf. TS does this, which is good.

                                        The bad part is that then all the nodes you didn’t replace have chains of node.parent references that lead to the old root instead of the new one. Fixing this with immutable nodes would mean replacing every node in the tree, so the only alternative is to mutate node.parent, which means that 1) you can’t actually Object.freeze(node) and 2) you don’t get all the wins of immutability since the old data structure is corrupted by the creation of the new one.

                                        1.  

                                          See https://ericlippert.com/2012/06/08/red-green-trees/ for why Roslyn’s key innovation in incremental syntax trees was actually breaking the node.parent reference by splitting into the red and green trees, or as I call them paths and nodes. Nodes are deeply immutable trees and have no parents. Paths are like an address in a particular tree, tracking a node and its parents.

                            3. 7

                              You are not joking, just the hack to make type checking itself parallel is well worth an entire hour!

                              1. 10

                                Hm yeah it was a very good talk. My summary of the type checking part is

                                1. The input to the type checker is immutable ASTs
                                  • That is, parsing is “embarassingly parallel”, and done per file
                                2. They currently divide the program into 4 parts (e.g. 100 files turns into 4 groups of 25 files), and they do what I’d call “soft sharding”.

                                That is, the translation units aren’t completely independent. Type checking isn’t embarassingly parallel. But you can still parallelize it and still get enough speedup – he says ~3x from parallelism, and ~3x from Go’s better single core perf, which gives you ~10x overall.

                                What wasn’t said:

                                • I guess you have to de-duplicate the type errors? Because some type errors might come twice, since you are duplicating some work
                                • Why the sharding is in 4 parts, and not # CPUs. Even dev machines have 8-16 cores these days, and servers can have 64-128 cores.

                                I guess this is just because, empirically, you don’t get more than 3x speedup.

                                That is interesting, but now I think it shows that TypeScript is not designed for parallel type checking. I’m not sure if other compilers do better though, like Rust (?) Apparently rustc uses the Rayon threading library. Though it’s hard to compare, since it also has to generate code


                                A separate thing I found kinda disappointing from the talk is that TypeScript is literally what the JavaScript code was. There was never a spec and will never be one. They have to do a line-for-line port.

                                There was somebody who made a lot of noise on the Github issue tracker about this, and it was basically closed “Won’t Fix” because “nobody who understands TypeScript well enough has enough time to work on a spec”. (Don’t have a link right now, but I saw it a few months ago)

                                1.  

                                  I’m not sure if other compilers do better though, like Rust (?) Apparently rustc uses the Rayon threading library.

                                  Work has been going on for years to parallelize rust’s frontend, but it apparently still has some issues, and so isn’t quite ready for prime time just yet, though it’s expected to be ready in the near term.

                                  Under 8 cores and 8 threads, the parallel front end can reduce the clean build (cargo build with -Z threads=8 option) time by about 30% on average. (These crates are from compiler-benchmarks of rustc-perf)

                                  1.  

                                    Why the sharding is in 4 parts, and not # CPUs. Even dev machines have 8-16 cores these days, and servers can have 64-128 cores.

                                    Pretty sure he said it was an arbitrary choice and they’d explore changing it. The ~10x optimization they’ve gotten so far is enough by itself to keep the project moving. Further optimization is bound to happen later.

                                    1.  

                                      I guess this is just because, empirically, you don’t get more than 3x speedup.

                                      In my experience, once you start to do things “per core” and want to actually get performance out of it, you end up having to pay attention to caches, and get a bit into the weeds. Given just arbitrarily splitting up the work as part of the port has given a 10x speed increase, it’s likely they just didn’t feel like putting in the effort.

                                    2.  

                                      Can you share the timestamp to the discussion of this hack, for those who don’t have one hour?

                                      1.  

                                        I think this one: https://www.youtube.com/watch?v=10qowKUW82U&t=2522s

                                        But check the chapters, they’re really split into good details. The video is interesting anyway, technically focused, no marketing spam. I can also highly recommend watching it.

                                  2. 5

                                    Another point on “why Go and not C#” is that, he said, their current (typescript) compiler is highly functional, they use no classes at all. And Go is “just functions and data structures”, where C# has “a lot of classes”. Paraphrasing a little, but that’s roughly what he said.

                                  3. 8

                                    They also posted a (slightly?) different response on GitHub: https://github.com/microsoft/typescript-go/discussions/411

                                    1.  

                                      Acknowledging some weak spots, Go’s in-proc JS interop story is not as good as some of its alternatives. We have upcoming plans to mitigate this, and are committed to offering a performant and ergonomic JS API.

                                      Yes please!

                                  4. 5

                                    So apparently a subset of MathML was extracted, named “MathML Core”, and is now generally available for use! This is news to me! I’ve been looking at MathML every couple of years, and walked back as I wasn’t a fan of adding heavy runtime polyfils like mathjax. But it seems now you can just use the thing?

                                    What is currently the recommended shorthand syntax for authoring math and lowering it to MathML Core subset?

                                    1. 1

                                      Afaik https://katex.org is still the most popular

                                      I’d love to see a https://asciimath.org backend translating directly to mathml tho

                                      1. 3

                                        For my CMS I use Temml since Katex had a bunch of bugs with MathML only mode.

                                        1.  

                                          From the project description of Temml it seems that it’s basically the code for MathML export ripped out and improved upon, so that makes sense that it works a bit better.

                                    2. 24

                                      I was hoping this article would compare if err != nil to more modern approaches (Rust’s ?) and not just Java-style exceptions, but unfortunately it doesn’t.

                                      I’d be more interested to read an article that weighs the approaches against each other.


                                      One point the article misses is how value-based error handling works really nicely when you don’t have constructors (either in your language, or at least your codebase, in case you’re using C++.)

                                      1. 8

                                        I’ve been pretty disappointed by Rust’s approach to error handling. It improves upon two “problems” in Go which IMHO are not actually problems in practice: if err != nil boilerplate and unhandled return values, but making good error types is fairly hard–either you manually maintain a bunch of implementations of the Error trait (which is a truly crushing amount of boilerplate) or you use something like anyhow to punt on errors (which is generally considered to be poor practice for library code) or you use some crate that generates the boilerplate for you via macros. The latter seems idyllic, but in practice I spend about as much time debugging macro errors as I would spend just maintaining the implementations manually.

                                        In Go, the error implementation is just a single method called Error() that returns a string. Annotating that error, whether in a library or a main package, is just fmt.Errorf("calling the endpoint: %w", err). I don’t think either of them do a particularly good job of automating stack trace stuff, and I’m not sure about Rust, but at least Go does not have a particularly good solution for getting more context out of the error beyond the error message–specifically parameter values (if I’m passing a bunch of identifiers, filepaths, etc down the call stack that would be relevant for debugging, you have to pack them into the error message and they often show up several times in the error message or not at all because few people have a good system for attaching that metadata exactly once).

                                        A lot of people have a smug sense of superiority about their language’s approach to error handling, which (beyond the silliness of basing one’s self-esteem on some programming language feature) always strikes me as silly because even the best programming languages are not particularly good at it, or at least not as good as I imagine it ought to be.

                                        1. 9

                                          a bunch of implementations of the Error trait (which is a truly crushing amount of boilerplate)

                                          you usually just need to impl Display, which I wouldn’t call a “crushing” amount of boilerplate

                                          or you use some crate that generates the boilerplate for you via macros.

                                          thiserror is pretty good, although tbqh just having an enum Error and implementing display for it is good enough. I’ve done some heavy lifting with error handling before but that’s usually to deal with larger issues, like making sure errors are Clone + Serialize + Deserialize and can keep stacktraces across FFI boundaries.

                                          1.  

                                            It’s pretty rarely “just” impl Display though, right? If you want automatic conversions from some upstream types you need to implement From, for example. You could not do it, but then you’re shifting the boilerplate to every call site. Depending on other factors, you likely also need Debug and Error. There are likely others as well that I’m not thinking about.

                                            1.  

                                              #[derive(Debug)] and impl Display makes the impl of Error trivial (impl Error for E {}). If you’re wrapping errors then you probably want to implement source(). thiserror is a nice crate for doing everything with macros, and it’s not too heavy so the debugging potential is pretty low.

                                              One advantage of map_err(...) everywhere instead of implementing From is that it gives you access to file!() and line!() macros so you can get stack traces out of your normal error handling.

                                              1.  

                                                thiserror is a nice crate for doing everything with macros, and it’s not too heavy so the debugging potential is pretty low.

                                                I’ve used thiserror and a few other crates, and I still spend a lot more time than I’d like debugging macro expansions. To the point where I waffle between using it and maintaining the trait implementations by hand. I’m not sure which of the two is less work on balance, but I know that I spend wayyy more time trying to make good error types in Rust than I do with Go (and I’d like to reiterate that I think there’s plenty of room for improvement on Go’s side).

                                                One advantage of map_err(…) everywhere instead of implementing From is that it gives you access to file!() and line!() macros so you can get stack traces out of your normal error handling.

                                                Maybe I should try this more. I guess I wish there was clear, agreed-upon guidance for how to do error handling in Rust. It seems like lots of people have subtly different ideas about how to do it–you mentioned just implementing Display while others encourage thiserror and someone else in this thread suggested Box<dyn Error> while others suggest anyhow.

                                                1.  

                                                  The rule of thumb I’ve seen is anyhow for applications and thiserror or your own custom error type for libraries, and if thiserror doesn’t fit your needs (for example, needing clone-able or serializable errors, stack traces, etc). Most libraries I’ve seen either use thiserror if they’re wrapping a bunch of other errors, or just have their own error type which is usually not too complex.

                                          2. 8

                                            a smug sense of superiority about their language’s approach to error handling

                                            Surprisingly, you don’t see people mention Common Lisp’s condition system in these debates

                                            1. 3

                                              That’s too bad, I genuinely enjoy learning about new (to me) ways of solving these problems, I just dislike the derisive fervor with which these conversations take place.

                                            2. 6

                                              You discount anyhow as punting on errors, but Go’s Error() with a string is the same strategy.

                                              If you want that, you don’t even need anyhow. Rust’s stdlib has Box<dyn Error>. It supports From<String>, so you can use .map_err(|err| format!("calling the endpoint: {err}")). There’s downcast() and .source() for chaining errors and getting errors with data, if there’s more than a string (but anyhow does that better with .context()).

                                              1.  

                                                Ah, I didn’t know about downcast(). Thanks for the correction.

                                              2.  

                                                One source of differences in different languages’ error handling complexity is whether you think errors are just generic failures with some human-readable context for logging/debugging (Go makes this easy), or you think errors have meaning that should be distinguishable in code and handled by code (Rust assumes this). The latter is inherently more complicated, because it’s doing more. You can do it either way in either language, of course, it’s just a question of what seems more idiomatic.

                                                1.  

                                                  I don’t think I agree. It’s perfectly idiomatic in Go to define your own error types and then to handle them distinctly in code up the stack. The main difference is that Rust typically uses enums (closed set) rather than Go’s canonical error interface (open set). I kind of think an open set is more appropriate because it gives upstream functions more flexibility to add error cases in the future without breaking the API, and of course Rust users can elect into open set semantics–they just have to do it a little more thoughtfully. The default in Go seems a little more safe in this regard, and Go users can opt into closed set semantics when appropriate (although I’m genuinely not sure off the top of my head when you need closed set semantics for errors?). I’m sure there are other considerations I’m not thinking of as well–it’s interesting stuff to think about!

                                                  1.  

                                                    Maybe “idiomatic” isn’t quite the right word and I just mean “more common”. As I say, you can do both ways in both languages. But I see a lot of Go code that propagates errors by just adding a string to the trace, rather than translating them into a locally meaningful error type. (E.g.,

                                                    return fmt.Errorf("Couldn't do that: %w", err)
                                                    

                                                    so the caller can’t distinguish the errors without reading the strings, as opposed to

                                                    return &ErrCouldntDoThat{err} // or equivalent
                                                    

                                                    AFAIK the %w feature was specifically designed to let you add strings to a human-readable trace without having to distinguish errors.

                                                    Whereas I see a lot of Rust code defining a local error type and an impl From to wrap errors in local types. (Whether that’s done manually or via a macro.)

                                                    Maybe it’s just what code I’m looking at. And of course, one could claim people would prefer the first way in Rust, if it had a stdlib way to make a tree of untyped error strings.

                                                    1.  

                                                      But I see a lot of Go code that propagates errors by just adding a string to the trace, rather than translating them into a locally meaningful error type

                                                      Right, we usually add a string when we’re just passing it up the call stack, so we can attach contextual information to the error message as necessary (I don’t know why you would benefit from a distinct error type in this case?). We create a dedicated error type when there’s something interesting that a caller might want to switch on (e.g., resource not found versus resource exists).

                                                      AFAIK the %w feature was specifically designed to let you add strings to a human-readable trace without having to distinguish errors.

                                                      It returns a type that wraps some other error, but you can still check the underlying error type with errors.Is() and errors.As(). So I might have an API that returns *FooNotFoundErr and its caller might wrap it in fmt.Errorf("fetching foo: %w", err), and the toplevel caller might do if errors.As(err, &fooNotFoundErr) { return http.StatusNotFound }.

                                                      Whereas I see a lot of Rust code defining a local error type and an impl From to wrap errors in local types. (Whether that’s done manually or via a macro.)

                                                      I think this is just the open-vs-closed set thing? I’m curious where we disagree: In Go, fallible functions return an error which is an open set of error types, sort of like Box<dyn Error>, and so we don’t need a distinct type for each function that represents the unique set of errors it could return. And since we’re not creating a distinct error type for each fallible function, we may still want to annotate it as we pass it up the call stack, so we have fmt.Errorf() much like Rust has anyhow! (but we can use fmt.Errorf() inside libraries as well as applications precisely because concrete error types aren’t part of the API). If you have to make an error type for each function’s return, then you don’t need fmt.Errorf() because you just add the annotation on your custom type, but when you don’t need to create custom types, you realize that you still want to annotate your errors.

                                                      1.  

                                                        This is true, usually you create a specific error type on the fly when you understand that the caller needs to distinguish it.

                                                  2. 3

                                                    I tend to agree that rusts error handling is both better and worse. In day to day use I can typically get away with anyhow or dyn Error but it’s honestly a mess, and one that I really dread when it starts barking at me.

                                                    On the other hand… I think being able to chain ‘?’ blocks is a god send for legibility, I think Result is far superior to err.

                                                    I certainly bias towards Rusts overall but it’s got real issues.

                                                    1. 5

                                                      There is one thing to be said against ?: it does not encourage the addition of contextual information, which can make diagnosing an error more difficult when e.g. it gets expect-ed (or logged out) half a dozen frames above with no indication of the path it took.

                                                      However I that is hardly unsolvable. You could have e.g. ?("text") which wraps with text and returns, and ?(unwrapped) which direct returns (the keyword being there to encourage wrapping, one could even imagine extending this to more keywords e.g. ?(panic)` would be your unwrap).

                                                      1.  

                                                        In a chain i’ll just map_err which as soon as the chain is multiline looks and works well. Inline it’s not excellent ha.

                                                        1.  

                                                          Oh yeah I’m not saying it’s not possible to decorate things (it very much is), just pointing out that the incentives are not necessarily in that direction.

                                                          If I was a big applications writer / user of type-erased errors, I’d probably add a wrapping method or two to Result (if I was to use “raw” boxed error, as IIRC anyhow has something like that already).

                                                  3. 4

                                                    I’ve often wondered if people would like Java exceptions more if it only supported checked exceptions. You still have the issue of exceptions being a parallel execution flow / go-to, but you lose the issue of random exceptions crashing programs. In my opinion it would make the language easier to write, because the compiler would force you to think about all the ways your program could fail at each level of abstraction. Programs would be more verbose, but maybe it would force us to think more about exception classes.

                                                    Tl;Dr Java would be fine if we removed RuntimeException?

                                                    1. 12

                                                      You’d need to make checked exceptions not horrendous to use to start with e.g. genericity exception transparency, etc…

                                                      It would also necessarily yield a completely different language, consider what would happen if NPEs were checked.

                                                      1. 1

                                                        consider what would happen if NPEs were checked.

                                                        Basically, Kotlin, so yeah, totally agree with you.

                                                      2. 6

                                                        No, Go has unchecked exceptions. They’re called “panics”.

                                                        What makes Go better than Java is that you return the error interface instead of a concrete error type, which means you can add a new error to an existing method without breaking all your callers and forcing them to update their own throws declarations.

                                                        The creator of C# explains the issue well here: https://www.artima.com/articles/the-trouble-with-checked-exceptions#part2

                                                        1. 3

                                                          You can just throw Exception (or even a generic) in Java just fine, though if all you want is an “error interface”.

                                                          Java’s problem with checked exceptions is simply that checked exceptions would probably require effect types to be ergonomic.

                                                      3. 4

                                                        Looks like it’s been updated:

                                                        Rust, for example, has a good compromise of using option types and pattern matching to find error conditions, leveraging some nice syntactic sugar to achieve similar results.

                                                        I’m also personally quite fond of error handling in Swift.

                                                        1. 2

                                                          Rust, Zig, and Swift all have interesting value-oriented results. Swift more so since it added, well, Result and the ability to convert errors to that.

                                                          1. 5

                                                            Zig’s not really value oriented. It’s more like statically typed error codes.

                                                      4. 95

                                                        No matter how many times Go people try to gaslight me, I will not accept this approach to error-handling as anything approaching good. Here’s why:

                                                        Go’s philosophy regarding error handling forces developers to incorporate errors as first class citizens of most functions they write. […] Most linters or IDEs will catch that you’re ignoring an error, and it will certaintly be visible to your teammates during code review.

                                                        Why must you rely on a linter or IDE to catch this mistake? Because the compiler doesn’t care if you do this.

                                                        If you care about correctness, you should want a compiler that considers handling errors part of its purview. This approach is no better than a dynamic language.

                                                        1. 12

                                                          The fact that the compiler doesn’t catch it when you ignore an error return has definitely bitten me before. doTheThing() on its own looks like a perfectly innocent line of code, and the compiler won’t even warn on it, but it might be swallowing an error.

                                                          I learned that the compiler doesn’t treat unused function results as errors while debugging a bug in production; an operation which failed was treated as if it succeeded and therefore wasn’t re-tried as it should. I had been programming in Go for many years at that point, but it had never occurred to me that silently swallowing an error in Go could possibly be so easy as just calling a function in the normal way. I had always done _ = doTheThing() if I needed to ignore an error, out of the assumption that of course unused error returns is a compile error.

                                                          1. 4

                                                            Does anyone know the reason why the Go compiler allows ignored errors?

                                                            1. 13

                                                              Because errors aren’t special to the Go compiler, and Go doesn’t yell at you if you ignore any return value. It’s probably not the most ideal design decision, but in practice it’s not really a problem. Most functions return something that you have to handle, so when you see a naked function call it stands out like a sore thumb. I obviously don’t have empirical evidence, but in my decade and a half of using Go collaboratively, this has never been a real pain point whether with junior developers or otherwise. It seems like it mostly chafes people who already had strong negative feelings toward Go.

                                                              1. 22

                                                                It’s similar to array bounds checks in c - not really a problem.

                                                                1. 6

                                                                  I hope this is sarcasm.

                                                                  1.  

                                                                    Is there a serious argument behind the sarcasm as to how this is comparable to array bounds checks? Do you have any data about the vulnerabilities that have arisen in Go due to unhandled errors?

                                                                2. 4

                                                                  Because the programmer made an intentional decision to ignore the error. It won’t let you call a function that returns an error with out assigning it to something, that would be a compile time error. If the programmer decides to ignore it, that’s on the programmer (and so beware 3rd party code).

                                                                  Now perhaps it might be a good idea for the compiler to insert code when assigned to _ that panics if the result is non-nil. Doesn’t really help at runtime, but at least it would fail loudly so they could be found.

                                                                  I’ve spent my own share of time tracking down bugs because something appeared to be working but the error/exception was swallowed somewhere without a trace.

                                                                  1. 13

                                                                    It won’t let you call a function that returns an error with out assigning it to something, that would be a compile time error

                                                                    This is incorrect: https://go.dev/play/p/k7ErZU5QYCu

                                                                    1. 2

                                                                      huh… til. I always assumed you needed to use the result, probably because of single vs multiple returns needing both being a compile time error. Thanks.

                                                                      1. 3

                                                                        To be fair I was just as certain as you that of course Go requires using the return values, until I had to debug this production bug. No worries.

                                                                    2. 11

                                                                      Because the programmer made an intentional decision to ignore the error.

                                                                      f.Write(s)
                                                                      

                                                                      is not an intentional decision to ignore the error. Neither is

                                                                      _, err := f.Write(s)
                                                                      

                                                                      Yet the go compiler will never flag the first one, and may not flag the second one depending on err being used elsewhere in the scope (e.g. in the unheard of case where you have two different possibly error-ing calls in the same scope and you check the other one).

                                                                      1. 4

                                                                        Yeah, I always thought the _ was required, I learned something today!

                                                                        I do have a few places with err and err2, it does kind of suck - I should probably breakup those functions.

                                                                        1. 2

                                                                          Yet the go compiler will never flag the first one, and may not flag the second one depending on err being used elsewhere in the scope (e.g. in the unheard of case where you have two different possibly error-ing calls in the same scope and you check the other one).

                                                                          _, err := f.Write(s) is a compiler error if err already exists (no new variables on left side of :=), and if err doesn’t already exist and you aren’t handling it, you get a different error (declared and not used: err). I think you would have to assign a new variable t, err := f.Write(s) and then take care to handle t in order to silently ignore the err, but yeah, with some work you can get Go to silently swallow it in the variable declaration case.

                                                                      2. 13

                                                                        Because they couldn’t be arsed to add this in v0, and they can’t be arsed to work on it for cmd/vet, and there are third-party linters which do it, so it’s all good. Hopefully you don’t suffer from unknown unknowns and you know you should use one of these linters before you get bit, and they don’t get abandoned.

                                                                        (TBF you need both that and errcheck, because the unused store one can’t catch ignoring return values entirely).

                                                                        1.  

                                                                          couldn’t be arsed

                                                                          Considering how much effort the Go team puts in basically everything, this language makes it very hard to to take you serious.

                                                                          1.  

                                                                            Considering how much effort the Go team puts in basically everything

                                                                            Yes, except for things that they decide not to be arsed about. I can confirm this as a very real experience of dealing with Go.

                                                                            1.  
                                                                              1.  

                                                                                Sure, but then it is equally fair to criticize them for it.

                                                                      3.  

                                                                        warn

                                                                        The go compiler doesn’t do warnings, only errors. Linters do warnings, and do warn about unchecked errors.

                                                                        1.  

                                                                          I don’t really care. Generally speaking, I would expect compilers to either warn or error on an implicitly swallowed error. The Go team could fix this issue by either adding warnings for this case specifically (going back on their decision to avoid warnings), or by making it a compile error, I don’t care which.

                                                                          1.  

                                                                            This is slightly more nuanced. Go project ships both go build and go vet. go vet is an isomorphic to how Rust handles warnings (that warnings apply to you, not your dependencies).

                                                                            So there would be nothing wrong per se if this was caught by go vet and not go build.

                                                                            What is the issue though, is that this isn’t caught by first-party go vet, and requires third party errcheck.

                                                                            1.  

                                                                              Meh plenty of code bases don’t regularly run go vet. This is a critical enough issue that it should be made apparent as part of any normal build, either as a warning or an error.

                                                                              1.  

                                                                                And that’s perfectly fine given that Go is pleasurable even for quick and dirt prototypes, fun side projects, and so on.

                                                                      4. 4

                                                                        If you care about correctness, you should want a compiler that considers handling errors part of its purview. This approach is no better than a dynamic language.

                                                                        I agree with you that it’s better for this to be a compiler error, but (1) I’ll never understand why this is such a big deal–I’m sure it’s caused bugs, but I don’t think I’ve ever seen one in the dozen or so years of using Go and (2) I don’t think many dynamic languages have tooling that could catch unhandled errors so I don’t really understand the “no better than a dynamic language” claim. I also suspect that the people who say good things about Go’s error handling are making a comparison to exceptions in other languages rather than to Rust’s approach to errors-as-values (which has its own flaws–no one has devised a satisfactory error handling system as far as I’m aware).

                                                                        The fact that these bugs seem so rare and that the mitigation seems so trivial makes me feel like this is (yet another) big nothingburger.

                                                                        1. 27

                                                                          The most common response to my critique of Go’s error-handling is always some variation on “this never happens”, which I also do not accept because I have seen this happen. In production. So good for you, if you have not; but I know from practice this is an issue of concern.

                                                                          I don’t think many dynamic languages have tooling that could catch unhandled errors so I don’t really understand the “no better than a dynamic language” claim.

                                                                          Relying on the programmer to comprehensively test inputs imperatively in a million little checks at runtime is how dynamic languages handle errors. This is how Go approached error-handling, with the added indignity of unnecessary verbosity. At least in Ruby you can write single-line guard clauses.

                                                                          I don’t really follow your dismissal of Rust since you didn’t actually make an argument, but personally I consider Rust’s Option type the gold standard of error-handling so far. The type system forces you to deal with the possiblity of failure in order to access the inner value. This is objectively better at preventing “trivial” errors than what Go provides.

                                                                          1. 4

                                                                            The most common response to my critique of Go’s error-handling is always some variation on “this never happens”, which I also do not accept because I have seen this happen. In production. So good for you, if you have not; but I know from practice this is an issue of concern.

                                                                            I’m sure it has happened before, even in production. I think most places run linters in CI which default to checking errors, and I suspect if someone wasn’t doing this and experienced a bug in production, they would just turn on the linter and move on with life. Something so exceedingly rare and so easily mitigated does not meet my threshold for “issue of concern”.

                                                                            Relying on the programmer to comprehensively test inputs imperatively in a million little checks at runtime is how dynamic languages handle errors

                                                                            That’s how all languages handle runtime errors. You can’t handle them at compile time. But your original criticism was that Go is no better than a dynamic language with respect to detecting unhandled errors, which seems untrue to me because I’m not aware of any dynamic languages with these kinds of linters. Even if such a linter exists for some dynamic language, I’m skeptical that they’re so widely used that it merits elevating the entire category of dynamic languages.

                                                                            I don’t really follow your dismissal of Rust since you didn’t actually make an argument, but personally I consider Rust’s Option type the gold standard of error-handling so far. The type system forces you to deal with the possiblity of failure in order to access the inner value. This is objectively better at preventing “trivial” errors than what Go provides.

                                                                            I didn’t dismiss Rust, I was suggesting that you may have mistaken the article as some sort of criticism of Rust’s error handling. But I will happily register complaints with Rust’s error handling as well–while it does force you to check errors and is strictly better than Go in that regard, this is mostly a theoretical victory insofar as these sorts of bugs are exceedingly rare in Go even without strict enforcement, and Rust makes you choose between the verbosity of managing your own error types, debugging macro expansion errors from crates like thiserror, or punting altogether and doing the bare minimum to provide recoverable error information. I have plenty of criticism for Go’s approach to error handling, but pushing everything into an error interface and switching on the dynamic type gets the job done.

                                                                            For my money, Rust has the better theoretical approach and Go has the better practical approach, and I think both of them could be significantly improved. They’re both the best I’m aware of, and yet it’s so easy for me to imagine something better (automatic stack trace annotations, capturing and formatting relevant context variables, etc). Neither of them seems so much better in relative or absolute terms that their proponents should express superiority or derision.

                                                                            1. 15

                                                                              Something so exceedingly rare and so easily mitigated does not meet my threshold for “issue of concern”.

                                                                              I don’t accept your unsubstantiated assertion that this is rare, so it seems we are at an impasse.

                                                                              1. 3

                                                                                Fair enough. It’s a pity things like this are so difficult to answer empirically, and we must rely on our experiences. I am very curious how many orgs are bitten by this and how frequently.

                                                                            2. 3

                                                                              personally I consider Rust’s Option type the gold standard of error-handling so far

                                                                              Couldn’t agree more (honourable mention to Zig, though).

                                                                              1.  

                                                                                Enabling a linter is different from doing “a million little checks at runtime”. This behaviour is not standard because you can use Go for many reasons other than writing production-grade services, and you don’t want to clutter your terminal with unchecked error warnings.

                                                                                I admit that it would be better if this behaviour were part of go vet rather than an external linter.

                                                                                The strange behaviour here is not “Go people are trying to gaslight me”, but people like you coming and complaining about Go’s error handling when you have no interest in the language at all.

                                                                                1.  

                                                                                  Enabling a linter is different from doing “a million little checks at runtime”.

                                                                                  You can’t lint your way out of this problem. The Go type system is simply not good enough to encapsulate your program’s invarients, so even if your inputs pass a type check you still must write lots of imperative checks to ensure correctness.

                                                                                  Needing to do this ad-hoc is strictly less safe than relying on the type system to check this for you. err checks are simply one example of this much larger weakness in the language.

                                                                                  The strange behaviour here is not “Go people are trying to gaslight me”, but people like you coming and complaining about Go’s error handling when you have no interest in the language at all.

                                                                                  I have to work with it professionally, so I absolutely do have an interest in this. And I wouldn’t feel the need to develop this critique of it publicly if there weren’t a constant drip feed of stories telling me how awesome this obviously poor feature is.

                                                                                  1.  

                                                                                    Your views about how bad Go’s type system is are obviously not supported by the facts, otherwise Go programs would be full of bugs (or full of minuscule imperative checks) with respect to your_favourite_language.

                                                                                    I understand your point about being forced to use a tool in your $job that you don’t like, that happened to me with Java, my best advice to you is to just change $job instead of complaining under unrelated discussions.

                                                                                    1.  

                                                                                      Your views about how bad Go’s type system is are obviously not supported by the facts, otherwise Go programs would be full of bugs (or full of minuscule imperative checks)

                                                                                      They are full of bugs, and they are full of miniscule imperative checks. The verbosity of all the if err != nil checks is one of the first things people notice. Invoke “the facts” without bringing any isn’t meaningfully different than subjective opinion.

                                                                                      Your comments amount to “shut up and go away” and I refuse. To publish a blog post celebrating a language feature, and to surface it on a site of professionals, is to invite comment and critique. I am doing this, and I am being constructive by articulating specific downsides to this language decision and its impacts. This is relevant information that people use to evaluate languages and should be part of the conversation.

                                                                                      1.  

                                                                                        If if err != nil checks are the “minuscle imperative checks” you complain about, I have no problem with that.

                                                                                        That you have “facts” about Go programs having worse technical quality (and bug count) than any other language I seriously doubt, at most you have anecdotes.

                                                                                        And the only anecdote you’ve been able to come up with so far is that you’ve found “production bugs” caused by unchecked errors that can be fixed by a linter. Being constructive would mean indicating how the language should change to address your perceived problem, not implying that the entire language should be thrown out the window. If that’s how you feel, just avoid commenting on random Go post.

                                                                              2. 9

                                                                                Yeah, I have seen it happen maybe twice in eight years of using Go professionally, but I have seen it complained about in online comment sections countless times. :-)

                                                                                If I were making a new language today, I wouldn’t copy Go’s error handling. It would probably look more like Zig. But I also don’t find it to be a source of bugs in practice.

                                                                                1. 5

                                                                                  Everyone who has mastered a language builds up muscle memory of how to avoid the Bad Parts. Every language has them. This is not dispositive to the question of whether a particular design is good or not.

                                                                                  1.  

                                                                                    but I have seen it complained about in online comment sections

                                                                                    The happy people are just happily working on solving their real problems. not wasting time complaining.

                                                                                  2. 8

                                                                                    Not seeing a problem as a bug in production doesn’t tell you much. It usually just means that the developers spent more writing tests or doing manual testing - and this is just not visible to you. The better the compiler and type-system, the fewer tests you need for the same quality.

                                                                                    1.  

                                                                                      Not seeing a problem as a bug in production doesn’t tell you much

                                                                                      Agreed, but I wasn’t talking about just production–I don’t recall seeing a bug like this in any environment, at any stage.

                                                                                      It usually just means that the developers spent more writing tests or doing manual testing - and this is just not visible to you.

                                                                                      In a lot of cases I am the developer, or I’m working closely with junior developers, so it is visible to me.

                                                                                      The better the compiler and type-system, the fewer tests you need for the same quality.

                                                                                      Of course with Go we don’t need to write tests for unhandled errors any more than with Rust, we just use a linter. And even when static analysis isn’t an option, I disagree with the logic that writing tests is always slower. Not all static analysis is equal, and in many cases it’s not cheap from a developer velocity perspective. Checking for errors is very cheap from a developer velocity perspective, but pacifying the borrow checker is not. In many cases, you can write a test or two in the time it would take to satisfy rustc and in some cases I’ve even introduced bugs precisely because my attention was so focused on the borrow checker and not on the domain problem (these were bugs in a rewrite from an existing Go application which didn’t have the bugs to begin with despite not having the hindsight benefit that the Rust rewrite enjoyed). I’m not saying Rust is worse or static analysis is bad, but that the logic that more static analysis necessarily improves quality or velocity is overly simplistic, IMHO.

                                                                                      1.  

                                                                                        Of course with Go we don’t need to write tests for unhandled errors any more than with Rust, we just use a linter.

                                                                                        I just want to emphasize that It’s not the same thing - as you also hint to in the next sentence.

                                                                                        I disagree with the logic that writing tests is always slower.

                                                                                        I didn’t say that writing tests is always slower or that using the compiler to catch these things is necessarily always better. I’m not a Rust developer btw. and Rust’s errorhandling is absolutely not the current gold-standard by my own judgement.

                                                                                        1.  

                                                                                          I just want to emphasize that It’s not the same thing - as you also hint to in the next sentence.

                                                                                          It kind of is the same thing: static analysis. The only difference is that the static analysis is broken out into two tools instead of one, so slightly more care needs to be taken to ensure the linter is run in CI or locally or wherever appropriate. To be clear, I think Rust is strictly better for having it in the compiler–I mostly just disagree with the implications in this thread that if the compiler isn’t doing the static analysis then the situation is no better than a dynamic language.

                                                                                          I didn’t say that writing tests is always slower or that using the compiler to catch these things is necessarily always better.

                                                                                          What did you mean when you said “It usually just means that the developers spent more writing tests or doing manual testing … The better the compiler and type-system, the fewer tests you need for the same quality.” if not an argument about more rigorous static analysis saving development time? Are we just disagreeing about “always”?

                                                                                          1.  

                                                                                            I mostly just disagree with the implications in this thread that if the compiler isn’t doing the static analysis then the situation is no better than a dynamic language.

                                                                                            Ah I see - that is indeed an exaggeration that I don’t share.

                                                                                            Are we just disagreeing about “always”?

                                                                                            First that, but it also in general has other disadvantages. For instance, writing tests or doing manual tests is often easy to do. Learning how to deal with a complex time system is not. Go was specifically created to get people to contribute fast.

                                                                                            Just one example that shows that it’s not so easy to decide which way is more productive.

                                                                                            1.  

                                                                                              Ah, I think we’re agreed then. “always” in particular was probably a poor choice of words on my part.

                                                                                    2. 3

                                                                                      Swallowing errors is the very worst option there is. Even segfaulting is better, you know at least something is up in that case.

                                                                                      Dynamic languages usually just throw an exception and those have way better behavior (you can’t forget, an empty catch is a deliberate sign to ignore an error, not an implicit one like with go), at least some handler further up will log something and more importantly the local block that experienced the error case won’t just continue executing as if nothing happened.

                                                                                  3. 8

                                                                                    Long post, many thoughts, but I don’t feel like doing a lot of editing, so apologies in front for unfiltered feedback! I don’t mean the tone I will use here :-)

                                                                                    The start of the article is 🤌, but it sort of does’t live to my expectations. I feel this is mostly about extensible, forward compatible enums, which is quite neat (I didn’t realize that “I want to add a field to all these enum variants” admits such an elegant solution), but I don’t think solves my problems with error handling facilities in languages.

                                                                                    Basically, this post feels like it attacks “make complicated things possible” part of the problem, and, sure, if you add across and along non-exhaustiveness, !__ for warnings, auto-delegation to turn an enum into struct, a capability system to track panics, you can solve everything.

                                                                                    But the problem I see with error handling is that we don’t know how to make simple things easy. That’s especially true in Rust, of course, but it seems that in every language a simple way to go about error handling leads to some pretty significant drawbacks, and the money question is not how can we add extra knobs to handle all of the requirements, but whether there’s some simple idea that kinda solves stuff?

                                                                                    Like, for example, the sled problem — we want every function to be precise about its specific error conditions, but, in practice, the stable equilibrium is one error type for library. Everr (fabulous name by the way, great job) suggest

                                                                                    The Everr language server has a code action for defining error types for a given function based on the error types of the functions that are called. It also can intelligently suggest modifications to error cases as the function body evolves over time, taking contextual rules such as access control into consideration.

                                                                                    But this sucks! With a nominal type system, having to name every function, and every function’s error type is very much not easy, and even if you add a bunch of tooling support, the result would still not be easy.

                                                                                    Another simple-things-are-hard problem in error handling is exposing details. If you write a library A, and it uses a library B, and B is an implementation detail, then a common API design pitfall is to leak B through your error types (either directly, by including B variants, or indirectly, by allowing downcasing to B). The problem here isn’t that it’s impossible to either expose or hide B properly. There’s a bunch of techniques available for that (but I belive that Everr makes them nicer and more powerful). The problem is that you need to decide what do you do, and that is hard. You need pretty high level of discipline and experience to even note that this is a problem.

                                                                                    Or another common pitfall of type-based error types, where

                                                                                    enum MyError {
                                                                                        Io(IoError)
                                                                                    }
                                                                                    

                                                                                    is often a bug, because the actual picture is

                                                                                    enum MyError {
                                                                                        FailedToReadConfigFile(IoError),
                                                                                        FailedToReadFromTCPSocket(IoError),
                                                                                    }
                                                                                    

                                                                                    That is, that the fact that you can aggregate errors based on types doesn’t mean that you should.

                                                                                    I have no idea how to handle errors in general! I just don’t have bullet proof recipes, every time it is “well, let’s look at your specific situation, shall we?”. Super annoying!

                                                                                    I don’t think that a lack of language mechanisms is my problem. What I lack is a gang-of-four book for patterns of error management (I mean, I have such a book at my head obviously, and I consult it often when writing code, but I can’t condense it to a single-paragraph to put into project’s style guide and call it a day).


                                                                                    Assorted smaller thoughts:

                                                                                    For an article about systems programming language, it is surprising that no space is dedicated to the ABI. How exactly do you raise in catch errors, in terms of which bytes go into which register, I feel is an unsolved problem. Returning values is allegedly slow. Unwinding is, counterintuitively, faster (see Duffy’s post & the recent talk on C++ exceptions in embedded (has anyone reproduced that result in particular?)). To avoid potential ambiguity: rust-style error handling, and Java-style exceptions differ on two orthogonal axis:

                                                                                    • whether you syntactically allocate expressions that throws (type system&syntax stuff)
                                                                                    • whether throwing happens by stack unwinding (by the way, what is the best one-page explainer, of how unwinding actually works? I am embarrassed to admit that unwinding is magic pixie dust for me, and I have no idea how landing pads work), or by “normal” return.

                                                                                    I am strictly speaking about the second one.

                                                                                    And than, there’s Zig, and than there’s this paper by Sutter of several years ago which says that “actually, you do want to return an integer” to be fast.

                                                                                    heap exhaustion

                                                                                    Heap exhaustion is not the central example of OutOfMemory error. The central example is someone passing you a malformed gif image whose declared size is 67PiB. That’s the sort of thing that you need to be robust to, a single rouge allocation due to a bug/malformed input.

                                                                                    It would also be interesting to know what sub-fraction of that group has tests for the out-of-memory error handling code path, and how good that test coverage is.

                                                                                    No these data here, but, anecdotally, eyeballing Zig the code that has both allocator parameter, try, and defer/errdefer usually tends to reveal errors.

                                                                                    Zig and Odin are different from other languages here; allocators are passed down ~everywhere as parameters

                                                                                    Such discipline is possible in C++, Rust and other languages to varying extents, but is less common. Rust has an unstable allocator_api feature, where the discussion originally started in

                                                                                    1. Rust also has a competing storage API proposal.

                                                                                    The Rust allocator API is very much not what Zig is doing. https://ziglang.org/download/0.14.0/release-notes.html#Embracing-Unmanaged-Style-Containers is not at all that.

                                                                                    A lint that prevents error values from being discarded using standard shorthands (e.g. _ = ), without an explicit annotation, such as a comment or a call to an earmarked function (to allow for ‘Find references’) etc.

                                                                                    I used to think the what Rust does, with must_use, is the right thing, and was hesitant of swift approach of requiring everything to be used. After using Zig, I am sold though, no need to other think this, a non-void function whose result is unused and is not _ = should be a compilation error. The amount of false positives is vanishingly small.

                                                                                    1. 3

                                                                                      “actually, you do want to return an integer” to be fast.

                                                                                      https://mcyoung.xyz/2024/04/17/calling-convention/ had an interesting idea. Change the abi so that the error/success payloads of Result are passed as out parameters, and then just return the Ok/Err tag. That seems like it allows the best of both worlds - effectively automating the common pattern used in zig and making it type-safe.

                                                                                      1. 2

                                                                                        Returning values is allegedly slow. Unwinding is, counterintuitively, faster

                                                                                        I think this is true in the common case where an error did not occur. Returning error information adds overhead to both the caller and callee, whereas catch/throw has the famous “zero overhead.” On the other hand, when an error does occur, unwinding the stack is significantly slower because a bunch of compiler-generated metadata has to be looked up and processed for each active stack frame.

                                                                                        the recent talk on C++ exceptions in embedded

                                                                                        The talk I watched (i don’t remember who gave it) was primarily about code size, not performance. The common wisdom being that using C++ exceptions bloats your code with all those compiler-generated tables annd extra code for running destructors during unwinding.

                                                                                        1. 2

                                                                                          Hey Alex, thanks for taking the time to read and share your thoughts. I always appreciate reading your blog posts, so thank you for the specific feedback on this post.

                                                                                          That’s especially true in Rust, of course, but it seems that in every language a simple way to go about error handling leads to some pretty significant drawbacks, and the money question is not how can we add extra knobs to handle all of the requirements, but whether there’s some simple idea that kinda solves stuff?

                                                                                          It would be helpful to have an operational definition of “simple” here with one or two examples before I attempt to answer this. 😅

                                                                                          For example, if there is a guideline that by default, an error should not expose structure, and just expose an interface like:

                                                                                          trait RichDebug: Debug {
                                                                                            type Kind: Debug
                                                                                            fn kind(&self) -> Kind
                                                                                            fn metadata(&self, &mut debug::Metadata<_'>) // similar to fmt::Formatter, but creates something like a JSON object instead of a string
                                                                                          }
                                                                                          

                                                                                          and the implementation for this were to be derived using a macro (or comptime machinery in Zig), would that be considered “simple”?

                                                                                          Like, for example, the sled problem

                                                                                          Thanks for linking that blog post, I hadn’t read it earlier. This point stands out to me in particular:

                                                                                          inside the sled codebase, internal systems were [..] relying on the same Error enum to signal success, expected failure, or fatal failure. It made the codebase a nightmare to work with. Dozens and dozens of bugs happened over years of development where the underlying issue boiled down to either accidentally using the try ? operator somewhere that a local error should have been handled, or by performing a partial pattern match that included an over-optimistic wildcard match.

                                                                                          This goes directly against the Rust conventions RFC, which recommends using panics for “catastrophic errors”. I’ve seen this similar tendency in Go codebases, where people will put every kind of error under error, even if it’s technically a serious invariant violation (like a bounds check failure, which does trigger a panic!).

                                                                                          Based on Duffy’s writing on Midori, it feels like a Midori programmer would probably be more likely to use “abandonment” (panic) than a Rust/Go programmer in this kind of situation, given the built-in Erlang-style fault-tolerant architecture.

                                                                                          we want every function to be precise about its specific error conditions, but, in practice, the stable equilibrium is one error type for library

                                                                                          Right, so with Everr’s type system, you could write your code as returning only MyLibraryError, and then the language server can refactor the functions which need specific error conditions to instead return MyLibraryError:union+[.Case1 | .Case2].

                                                                                          The central example is someone passing you a malformed gif image whose declared size is 67PiB. That’s the sort of thing that you need to be robust to, a single rouge allocation due to a bug/malformed input.

                                                                                          This is a fair criticism. In that section, I originally intended to describe a system for regions/different sub-heaps based on some of the research on Verona (and in that context, “heap exhaustion” would mean “this sub-heap is exhausted”, not “the process heap is quite high for the running system”), but then I punted on that because I didn’t feel confident in Verona’s system, so I moved that description to the appendix.

                                                                                          I will update this.

                                                                                          and was hesitant of swift approach of requiring everything to be used. After using Zig, I am sold though

                                                                                          I personally prefer Swift’s approach of warning instead of a hard error, given that iterating on code becomes more fiddly if you need to keep putting/removing _ (speaking from first-hand experience with Go).

                                                                                          However, the point you’ve quoted here is talking about something slightly different. It’s saying that using the same shorthand for discarding ordinary and discarding errors is itself error-prone. Discarding errors should require noisier syntax (in lint form), because an error being discarded is likely to carry higher risk than a success value being discarded.

                                                                                          I have such a book at my head obviously [..]

                                                                                          Perhaps a good idea for a IronBeetle episode? I’m slowly working my way through the list; maybe you’ve already covered this in one of them. 😄


                                                                                          For an article about systems programming language, it is surprising that no space is dedicated to the ABI. How exactly do you raise in catch errors, in terms of which bytes go into which register, I feel is an unsolved problem

                                                                                          I omitted this because:

                                                                                          1. Since this point is primarily about performance, it doesn’t make sense for me to speculate about designs without having concrete measurements. Performing any kind of realistic measurement would likely be a fair amount of work.

                                                                                          2. I didn’t realize it was an “unsolved problem” but rather my working assumption was that “for ABI, the small number of people working on it pretty much know what all their options are, so it doesn’t make sense for me to tell them that”. For example, if you only care about 64-bit machines, perhaps you’re fine with burning a register on errors specifically (like Swift). For larger errors, you could reuse the same register as an out-parameter (as described in mcyoung’s post linked by Jamie).

                                                                                          1. 1

                                                                                            Perhaps a good idea for a IronBeetle episode?

                                                                                            Good idea! I’ll do an error management episode this Thursday then!

                                                                                        2. 2

                                                                                          The 13% performance impact of bound check is much closer to what I’m used to (in audio with constant array access, more like 15-20%) compared to the research that found its impact at 0.3% : https://chandlerc.blog/posts/2024/11/story-time-bounds-checking/

                                                                                          It would be great to have some proper understanding of what exactly was benchmarked in the 0.3% case as to me, getting 13% performance impact on core workloads really means “we have to buy new computers” which is a decent chunk of our budget already.

                                                                                          1. 14

                                                                                            One thing there is that if you take a codebase that was written without bounds check in mind, and then forcefully enable bounds checking, than of course you’ll get a massive slow down. Bounds checking every access kills all vectorization, so any reasonably-performant bounds-checking solution necessary requires extra work to eliminate most bounds checks.

                                                                                            That is, you need not only enable bounds checks, but then spend some time looking at the places where the checks are not eliminated by compiler, and rewrite the code there to explain to the compiler that it is safe to hoist them (which is mostly “stupid” things like let xs = xs[0..n]; let ys = ys[0..n]), and then spend more time finding few selective places where you need unchecked indexing.

                                                                                            So, I’d say a more useful quantitive experiment would be to take some Rust codec that was explicitly optimized to lean heavy on compiler’s elimination of bounds checking, and then compile that with a patched version of the rust compiler that eliminates bounds checks.

                                                                                            The qualitative claim that in codes you can’t actually host the checks in interesting. I don’t have relevant experience here, but two things give me pause,

                                                                                            First:

                                                                                            where many of the memory areas have a runtime-determined size that would be difficult to track during the compile-time in order to hoist checks.

                                                                                            If this means to say what it actually says, then this is wrong. let xs = xs[0..n]; let ys = ys[0..n] is exactly how you hoist checks due to runtime-determined size. Given that the quote uses ‘hoist’ rather than ‘eliminate’ I have a hutch that it wants to say something different though?

                                                                                            Second:

                                                                                            Even if you can’t hoist checks from the core loop of the codec, my gut feeling that exploitable out-of-bounds happen in the auxiliary, cold code that you need to run before you get to your innermost loop. So, unchecked indexing in the hot loop + checked indexing everywhere else feels like it can make 99% of runtime access unchecked, and 90% of source-level accesses checked, which is exactly the right tradeoff.


                                                                                            Not claiming that the numbers stated in the article are wrong, just that I don’t personally know whether to believe or disbelieve them given the background I have!

                                                                                            1. 1

                                                                                              so any reasonably-performant bounds-checking solution necessary requires extra work to eliminate most bounds checks.

                                                                                              I just don’t see how this is realistic - people who write the tight math code that actually have to go fast absolutely do not have this experience. e.g. I know for sure that

                                                                                              That is, you need not only enable bounds checks, but then spend some time looking at the places where the checks are not eliminated by compiler, and rewrite the code there to explain to the compiler that it is safe to hoist them (which is mostly “stupid” things like let xs = xs[0..n]; let ys = ys[0..n]), and then spend more time finding few selective places where you need unchecked indexing.

                                                                                              will never happen 99.999% of the time, and then we’re all getting slower software than we should and have to spend more money and resources on buying more powerful computers.

                                                                                              Also, elimination of bound checks is only viable in release mode with optimizations enabled, but I’ve seen a fair amount of codebases where the performance in debug mode was also critical (as otherwise it’s just.. not possible to debug the code if it doesn’t work at a certain speed). E.g. people in C++ would instead of doing std::vector<foo> vec; ...; vec[i]; get the pointer to the data and operate directly through pointer arithmetic - ptr = vec.data(); ptr[i]; because just the cost of non-inlined operator[] is too much. Likewise, I have yet to see a successful use of e.g. doing a loop through

                                                                                                 for(auto v : ranges::iota(min, max)) { ... }
                                                                                              

                                                                                              due to the performance impact on a -O0 build thus everyone does classic openmp-friendly for(int i = 0; i < N; i++) loops.

                                                                                              1. 7

                                                                                                If the person writes tight code, but doesn’t have the skill to check that bound checks do not kill vectorization, they almost surely lack the skill to write unsafe code that doesn’t go out of bounds.

                                                                                                Performance is easy: you just benchmark&look at the asm. Not doing out of bounds is hard in C, as only maliciously crafted inputs trigger this behavior (and OOB is usually downstream of more subtle stuff like UB on integer overflow).

                                                                                                I think in most contexts, security is more important than speed, so this is a worthwhile trade.

                                                                                                Though, the scenario does seem unrealistic to me — in my experience, folks writing performance-relevant code generally know what they are doing.

                                                                                                Important fact to underline here is that the cost of bounds check is bimodal: every specific check is either virtually free (trivially predicted not taken branch) or makes code 5x slower (when it kills vectorization). To avoid the slowdown, you don’t need to be hyper-vigilant every time you do [], you only need to pay attention to vectorized inner loops.

                                                                                                The statements about debug performance are true.

                                                                                              2. 1

                                                                                                I think the difference is easier explained by the fact that bzip3 is a small, highly optimized library, that is being compared to a giant & old monolithic C++ codebase.

                                                                                                1. 2

                                                                                                  Unless I am misreading something, the cited 13% is from comparing a C++ project to itself, compiled with different flags.

                                                                                                  1. 1

                                                                                                    Yes, although I realize now I didn’t read your comment carefully. I thought you were also talking about the 0.3% number from retrofitting spatial memory safety for Google. But yes, the 13% is from comparing paq8l w/ and w/o flags.

                                                                                              3. 3

                                                                                                I take issue with the claimed performance penalty of bounds checking.

                                                                                                They did not enable bounds checking, they enabled assertions.

                                                                                                All of them.

                                                                                                So they were testing far more things than just bounds checks, and then looking at the “pag8l.cpp”s I find on the internet those assertions did not even appear to include significant bounds checking.

                                                                                                I’m not sure how seriously we should take such a roughshod approach to “how expensive are bounds checks?”

                                                                                                It reads like a text book case of “I know bounds checks are far too expensive, so if my ‘benchmark’ confirms that I won’t bother seeing if what I’m claiming to test is remotely accurate”.

                                                                                                All they’ve done is shown assertions are generally expensive.

                                                                                              4. 2
                                                                                                1. 24

                                                                                                  i try zed every couple of months because it surfaces in my feeds somehow. every time i end up rapidly stop using it because it presents itself as a multiplayer-first experience, and while you can use it without those features, there’s no way to “hide” them from your periphery. the “sign in” (to github, apparently? that’s ridiculous - i don’t use github unless i have to) button sits at the top right, the copilot at the bottom right.

                                                                                                  some people use these things, sure. but some people - and i’d argue despite the bubble we live in seeming otherwise, most people - do not. there has been vehement opposition to even adding the option to hide that malarkey. instead they want it to sit there like a badge implying you’re doing something incorrectly.

                                                                                                  until zed actually respects the user first i don’t really see the point in it. i’m not a foss or “offline, old ways” maximalist, i don’t use (neo)vim, i don’t use emacs, but i do appreciate the value in not trying to drag your user(s) around into what has still not been demonstrated to be anything other than garbage actively contributing in a meaningful way to climate concerns.

                                                                                                  these things could be plugins. another (proprietary, information security focused) rust-based app i use introduced llm functionality semi-recently, and i (and others) rather firmly told them that if it stayed in core, i wouldn’t, due to security concerns. it got shifted to a plugin within a couple weeks.

                                                                                                  i don’t expect everyone to share this opinion, because if there wasn’t demand for those features they probably wouldn’t have been implemented. i just really, really strongly disagree that a multiplayer notepad logged into github is “the future” of programming.

                                                                                                  if it is, that’s pretty sad.

                                                                                                  1. 4

                                                                                                    there’s no way to “hide” them from your periphery

                                                                                                    I don’t have anybody to multiplayer Zed with but I wouldn’t really call the features very obtrusive.

                                                                                                    1. 3

                                                                                                      Zed is my main editor and I don’t use any of those features…

                                                                                                      1. 2

                                                                                                        I think the occam’s interpretation of this is that their target user base does want these features, and folks who strongly disagree with it are not part of the target user base. And that’s okay. The solution doesn’t need to satisfy everyone.

                                                                                                        1. 6

                                                                                                          The nature of multiplayer is though that once a platform gains supermajority you may be forced to use it anyway because everyone else is there, like with discord or social media.

                                                                                                          1. 3

                                                                                                            you may be forced to use it anyway because everyone else is there, like with discord or social media.

                                                                                                            \o

                                                                                                            I use neither discord nor social media (excluding lobsters, which has a killer feature of not everyone being there).

                                                                                                            1. 3

                                                                                                              Sure, but the parent is quite correct.

                                                                                                              Very few people actually get to sit in a vacuum, as soon as you step outside and try interacting with the population: whatever software is the supermajority may force its way on you via network effects.

                                                                                                              This is why despite despising facebook for so many years I had to keep my account because for many people in my life they wouldn’t try to contact me outside of that platform. Nowadays I don’t care and there’s about 95% less interactions with people I know, but that’s fine- it was a conscious choice.

                                                                                                              If my OS embedded a “sign in to facebook” in the top left hand corner that was always visible though, I’d not enjoy such things, because I despise facebook and would feel like the cross functionality is interfering with my enjoyment of something unrelated.

                                                                                                              1. 1

                                                                                                                which has a killer feature of not everyone being there

                                                                                                                Ha, this is so funny, but so true. Every time I compare the discourse here to anywhere else I’m consistently so happy that I found this place and that it is what it is. I don’t even post my own content anywhere else at this point because I don’t care what anywhere else has to say outside of lobste.rs

                                                                                                        2. 15

                                                                                                          The C’s prefix syntax has been used for so long that .* looks weird, but a postfix operator is the right place for a dereference. It chains nicely, and if you squint, it’s consistent with field access syntax that is also a form of dereference.

                                                                                                          1. 11

                                                                                                            The “.” is weird, though — it makes it look like a struct field. Pascal’s postfix “^” operator is clearer.

                                                                                                            (An article explaining pointer dereferencing seems maybe a bit basic for lobste.rs…?)

                                                                                                            1. 6

                                                                                                              Think about it as just “field access, but with a wildcard”

                                                                                                              Instead of .field you do .* which matches all fields, and suddenly it feels more natural than another “special syntax”

                                                                                                              1. 3

                                                                                                                According to this mental model, .? should return arbitrary one field :D

                                                                                                                1. 2

                                                                                                                  It should return all fields whose names are one character :)

                                                                                                              2. 2

                                                                                                                I was actually coming here to say that I really appreciate the well-written, intro-level post. I’ve been programming for almost 15 years professionally but I’ve spent all that time in high-level languages and am still really “getting” pointers. Programming is such an enormous field, there’s always something to learn. 🙂

                                                                                                                1. 1

                                                                                                                  Do you feel the same way about calling methods with .?

                                                                                                                  1. 1

                                                                                                                    No, a method is an element of the pointed-to struct, just like a field access. It’s the use of “.” without any field that seems weird to me.

                                                                                                                    1. 2

                                                                                                                      Funny, I quite like it. If I ever create a language of my own I’d probably use that.

                                                                                                                      Maybe it is just from being exposed to Go, with it’s “ptr.(type)” and “ptr.(int)” forms for type switch and assertion which make it seem a bit more comfortable.

                                                                                                                2. 6

                                                                                                                  Adding to snej’s comment, in Pascal you write p^ to dereference a pointer and r.f to select a field from a record. If you have a pointer to a record, you can write pr^.f. It’s simple, orthogonal and logical.

                                                                                                                  The C language made the ergonomic mistake of using a prefix operator to dereference a pointer (*p). To get a field from a struct pointer, this means you write (*rp).f. That sucks so badly that C has a short-form for this: you can write rp->f instead. It’s an over-complicated bad design.

                                                                                                                  Zig collapses the C operators . and -> into a single . operator, and replaces C’s one-character prefix pointer-deref operator * with a two-character postfix operator .*. This is a bit more ergonomic than C, but it’s still a bad design, when compared to Pascal.

                                                                                                                  1. 4

                                                                                                                    Not to defend prefix dereference, but it’s not like you can’t fix C’s mistakes. Rust supports rp.f just fine by auto-dereferencing until it finds the right type and doesn’t need -> or an explicit deref for trivial accesses

                                                                                                                    The order of operations issue still exists, but to a much lesser extent.

                                                                                                                    1. 3

                                                                                                                      Huh, I wonder if that Pascal design is why the Haskell lens packages uses ^. as an alias for view. As in, record ^. foo . bar . baz = view (foo . bar . baz) record accesses a value nested through several layers of record.

                                                                                                                      1. 3

                                                                                                                        BCPL has two forms of the dereference operator, prefix unary and infix binary. The latter does double duty for structure access and array indexing.

                                                                                                                        C           BCPL
                                                                                                                        *ptr        !ptr
                                                                                                                        ptr->field  ptr!field
                                                                                                                        ptr[n]      ptr!n
                                                                                                                        

                                                                                                                        In pre-K&R C before the type safety of structures was improved, C’s ptr->field was a lot more like BCPL’s ptr!field, but with a nicer way to declare the field offsets and types. But like BCPL, the left hand operand could be anything. For example, in the 6th edition kernel there are expressions (after macro expansion) like 0177560->integ for accessing hardware registers.

                                                                                                                        Late 1960s versions of BCPL had the rather awkward lv and rv operators for taking addresses and indirection; they became @ and ! in the early 1970s. https://www.softwarepreservation.org/projects/BCPL/

                                                                                                                        The lv / rv thing was from Strachey’s idea of lvalues and rvalues. The CPL project seems to have struggled a lot with references, list processing, and record types. The whole effort fell apart before they worked out a solution.

                                                                                                                        Dunno how much users in the USA kept up with revisions to BCPL after Martin Richards returned from Cambridge Massachusetts to Cambridge England.

                                                                                                                      2. 2

                                                                                                                        I like how Nim uses p[] for dereference. It’s like indexing an array, but without an index.

                                                                                                                      3. 10

                                                                                                                        Personally I strongly prefer adding a prefix to all my commit messages. Usually it’s a small form namespace on what you are touching. This could be the library or module name.

                                                                                                                        Example from my own projects: https://github.com/Foxboron/ssh-tpm-agent/commits/master/

                                                                                                                        This is also commonly done in the kernel tree as well: https://web.git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/log/

                                                                                                                        This allows easy filtering of relevant commits and gives you a quick overview of relevant changes you are interested inn. You could also use conventional commits, but personally I don’t like them.

                                                                                                                        1. 5

                                                                                                                          Yeah, I think that makes sense for large projects and monorepos.

                                                                                                                          My feeling on prefixes and conventional commits is that people should do it if they find value in it, but they should do it thoughtfully and recognize the costs. I think for conventional commits especially, people adopt them without recognizing that it adds a burden for contributors to learn the project’s particular prefix rules, and it eats up scarce screen real estate in the commit title.

                                                                                                                          1. 3

                                                                                                                            I think it’s probably worth including a link to conventional commits ideas in the doc and present some arguments for and against. Personally I prefer not to use them in smaller projects, but I maintain a larger rust crate where we’ve chosen to use them and find that they’re generally useful. In particular, having a convention for when commits have breaking changes helps us automate that side of things in our changelogs.

                                                                                                                            My reasons for preferring not to use them come pretty much from the idea that they often artificially constrain the language in ways that tend to promote bad commit summaries in ways mentioned in your doc. E.g. fix: background color bug instead of “Change background to pink to fix color contrast’.

                                                                                                                            1. 3

                                                                                                                              Thanks for reading!

                                                                                                                              I originally did have a section on Conventional Commits and other style choices, but I felt like style discussion was bloating the scope of the article, so I trimmed it out.

                                                                                                                              For fun, here’s what I had:

                                                                                                                              1. 2

                                                                                                                                I use conventional commits for work, but for personal projects I don’t and I never though why, it was an unintentional decision.

                                                                                                                                I guess it’s because it’s more work, there’s no clear line between e.g. fix and refactor, it reads less natural for no apparent gain for me.

                                                                                                                                And I hate repeating the word “fix” twice.

                                                                                                                          2. 2

                                                                                                                            I favor it, but the extremely-short 50-character “limit” on the first line of the commit means that sometimes it has to go. If I did something to the TimestampedCircularBuffer type I’ve already blown half of my first-line budget. If I also add metricutil: to the head of the line that would leave me with 12 characters to say what I actually did.

                                                                                                                            1. 4

                                                                                                                              I believe the 50-character limit is fake. It is stated in man pages for git, but neither git, nor kernel follow it! Someone should send a PR removing in from manpages.

                                                                                                                              1. 2

                                                                                                                                Do you know software that actually requires the 50 character limit? I also see it highlighted in vim, but ignore it if my message has to be longer to be meaningful (I’d say I stay below 80 all the time, but go above 50 quite often). So far no problems. I think also git log displays the full line.

                                                                                                                                1. 2

                                                                                                                                  Nothing really requires it, but it looks bad in GitHub for example if you ignore it, and my editor forces a line break unless I try hard to override it.

                                                                                                                            2. -10

                                                                                                                              So it is okay to write such long annotations to one’s own postings? Okay, I will do so next time :)

                                                                                                                              1. 18

                                                                                                                                I think it’s not about yours/other’s, but rather the fact that this is a link to raw source code, not to a blog post about the source code.

                                                                                                                                That is, you shouldn’t contextualize something which already explains itself, but, if you are linking to a primary artifact, some explanation is in order!

                                                                                                                                1. 7

                                                                                                                                  I think this is mistagged, it should be in the “show” category, where it is common to actually write a contextualising comment.

                                                                                                                                2. 27

                                                                                                                                  A standard “worst case” for allocators is producer-consumer: in a loop, one thread allocates, sends the pointer to the other thread, which then deallocates. I think for this scenario the presented allocator will give infinite growth of memory usage?

                                                                                                                                  If I am reading the code correctly (which is not given!), in the producer-consumer scenario, producer will fill its slab, while consumer will fill its free list. Then, producer will rotate to the next Thread, and fill its slab. Eventually, producer will “steal” consumer’s thread (once both have the same thread index, they’ll contend for mutex and one will be edited to the next id), and re-use all entities from the free list.

                                                                                                                                  But then this repeats! Once stolen free list is exhausted, we allocate one more slab, and do a merry-go-round again!

                                                                                                                                  1. 20

                                                                                                                                    Yup, that’s the case that snmalloc and mimalloc tune for because improving it can double the throughput in a lot of transaction-processing workloads. Thread caching approaches are the worst here because the allocating thread always misses in its cache (no freed objects) and the freeing thread ends up having to walk its cache list periodically and properly free everything.

                                                                                                                                    It’s also worth noting that benchmarking allocators is incredibly hard. For example, we found that snmalloc was much worse than everything else on one microbenchmark. We eventually tracked the issue down to cache contention. Our allocation pattern meant that the metadata ended up in a small handful of cache associativity sets and so we were using a small fraction of the total cache. We added some extra logic to add some jitter to the displacements to avoid this. The benchmark got faster, but everything else got slower. What made the benchmark special? It wasn’t ever touching any of the memory that it allocated and so almost 100% of the cache was available for the allocator to use. Making the benchmark touch even 20% of each allocation eliminated the gap between snmalloc and the other fast allocators. In practice, if you’re allocating a load of memory and then not touching it, the right fix is not to make the allocator faster for that workload, it’s to not allocate the memory (outside a few niche use cases such as kernel memory that you need to preallocate so an interrupt handler can use it in a situation where it can’t acquire locks).

                                                                                                                                    1. 12

                                                                                                                                      This example, right?

                                                                                                                                      It’s a great example because I didn’t think of it until someone pointed it out to me.

                                                                                                                                      There is a performance data point of that example program in the readme file in that repository.

                                                                                                                                      Your analysis is almost spot-on, but consider that it first tries to rotate before allocating another slab. So it is more likely that the rotation finds another freelist, and continues reusing memory from there.

                                                                                                                                      At first I thought that adding rotation to the free function would improve the situation. When I tried it, I found that in the linked example it indeed reduced peak memory usage in exchange for performance, but in real world scenarios, it actually performed worse than the simple free implementation on all metrics.

                                                                                                                                      1. 6

                                                                                                                                        but consider that it first tries to rotate before allocating another slab

                                                                                                                                        Rrright! I got confused by this likely:

                                                                                                                                        https://github.com/ziglang/zig/blob/5ad91a646a753cc3eecd8751e61cf458dadd9ac4/lib/std/heap/SmpAllocator.zig#L151

                                                                                                                                        Feels like it should be unlikely, as enter there at most once.

                                                                                                                                        So, yeah, I think this actually fixes it! Because we rotate first, I think what’ll happen is producer thread “chasing” after consumer thread! Nifty!

                                                                                                                                        Still, I’d adjusted that example to say while (true) and left it running overnight, no make sure it doesn’t OOM eventually.

                                                                                                                                        1. 5

                                                                                                                                          Hm, thinking about this for another five minutes, I think this still breaks if you have, say, 4 producer threads and one consumer thread? In this situation, producer will be more likely to rotate over another producer. While sometimes we will steal consumer’s free list, we should still fall back to slab alllocation infinitely often? I’d run a benchmark, but I have a rule against laptops in bed :P

                                                                                                                                          1. 19

                                                                                                                                            Yup, there it is, O(∞) total memory usage for a program that O(1) memory at any given moment in time: https://github.com/andrewrk/CarmensPlayground/pull/1

                                                                                                                                            1. 4

                                                                                                                                              Great find. That prompts the question, is the next step to try to mitigate such problems in SmpAllocator, or keep it how it is, document its performance characteristics under different workloads, and create another allocator that is designed for mpsc? 🤔

                                                                                                                                              I’m leaning towards the latter, since choosing an allocator tailored to the application is something Zig is uniquely positioned to offer.

                                                                                                                                              1. 4

                                                                                                                                                My gut feeling answer is to just wait for some community member to write a cool pure-zig allocator that can become the default recommendation, but, at this point, I’ve been waiting for seven years for that to happen in Rust, and that didn’t quite materialize (to be fair, for a long time writing allocators in rust was hamstrung by slow threadlocals, but that has been fixed years ago as well).

                                                                                                                                                With my TigerBeetle hat on: never call free, problem solved :P

                                                                                                                                                1. 2

                                                                                                                                                  I think the default allocator should perform tolerably well in all situations. SmpAllocator performs very badly in the benchmark that matklad presented, so I don’t think it should be the default. I think it would be fine to document the performance characteristics of SmpAllocator and leave it as an option.

                                                                                                                                          2. 4

                                                                                                                                            I guess this can be said more simply: in a producer-consumer scenario, if producer and consumer have different thread_index, then slab allocation happens eventually. If they have the same index, contention happens eventually and the indexes become different.

                                                                                                                                          3. 2

                                                                                                                                            In the Tiger Style document you mention the importance of “performance sketches”, do you (or somebody else) have good resources on how you do them?

                                                                                                                                            1. 7

                                                                                                                                              https://github.com/sirupsen/napkin-math is a good resource for “how fast the things are actually”. But even before you get there, you should do the plain old big-O analysis: “to compute this here, we need to move this amount of data from there to here”, that sort of stuff. Before with rust-analyzer, and now with TigerBeetle, I see that the “secret” is in the super-high-level observations, the rest are just details to fill out. “Work should be proportional to the size of the result, and the size of the change, not the size of the codebase” is how rust-analyzer works. “Row locks held over the network kill performance for workloads with contention” explains TigerBeetle. To get these kinds of insights, it is helpful to get the end-to-end picture in your head of how everything fits together to do the data transformation that is the job of a particular piece of software.

                                                                                                                                              1. 1

                                                                                                                                                Thank you very much. My colleagues and I are also big fans of the mantra that most optimizations are algorithmic/big-O/come from big picture insights, but I was lacking a good resource for “napkin math”. Thanks for the link, and thanks for all the good work on rust-analyzer :-)

                                                                                                                                            2. 2

                                                                                                                                              The primary paradigm for OLGP is interactive transactions.

                                                                                                                                              Is this meant to say OLTP, or is OLGP an acronym I haven’t heard of?

                                                                                                                                              1. 2

                                                                                                                                                Good catch, GP stands for general purpose, clarified in https://github.com/tigerbeetle/tigerbeetle/pull/2796

                                                                                                                                              2. 15

                                                                                                                                                This is some random GitHub project’s internal readme, but I think it’s still a good read — most of the things in there were new to me two years ago!

                                                                                                                                                1. 3

                                                                                                                                                  I don’t think I saw any other system except maybe Tarantool use single commit thread.

                                                                                                                                                  Document mentions that single core can handle a lot - which is of course true. Still, did TigerBeetle experience any workloads where commit thread became the bottleneck?

                                                                                                                                                  1. 10

                                                                                                                                                    It is a bit too early to think in terms of specific workloads: we think we got performance architecture right, but there’s an abundance of low-hanging performance coconuts we are yet to pick to give the hard numbers from practice.

                                                                                                                                                    That being said:

                                                                                                                                                    • the commit/execute itself, this loop that handles double-entry bookkeeping, was not so far observed to be the main bottleneck
                                                                                                                                                    • but we are CPU-bound at the moment. The bottleneck is in the in-memory part of LSM tree, when we sort memtable before writing it out to disk. But this is a bottleneck for rather stupid reasons:
                                                                                                                                                      • the chief issue isn’t as much the CPU time spent there, but rather just the fact that we “block” the CPU for milliseconds, so no IO events can be handled in the meantime.

                                                                                                                                                      • so the obvious thing here is to make the sort “asynchronous”, such that it looks like any other IO operation — you toss a buffer into io_uring, you don’t touch the buffer until you get a completion, and you are free to do whatever else in the meantime. This will require one more thread, not to get more out of the CPU, but just to make the overall flow asynchronous

                                                                                                                                                      • then, we can pipeline compaction more. What we do now is:

                                                                                                                                                        loop {
                                                                                                                                                          prefetch().await;
                                                                                                                                                          execute();
                                                                                                                                                          compact().await;
                                                                                                                                                        }
                                                                                                                                                        

                                                                                                                                                        We can run prefetch&compact concurrently though!

                                                                                                                                                        loop {
                                                                                                                                                          prefetch().await
                                                                                                                                                          execute();
                                                                                                                                                          compact_join().await;
                                                                                                                                                          compact_start()
                                                                                                                                                        }
                                                                                                                                                        
                                                                                                                                                      • another obvious optimization is to use the right algorithm — we just sort there for simplicity, but what we actually need is a k-way merge

                                                                                                                                                      • and, of course, given that we have many LSM trees, we can sort/merge each one actually in parallel. But we want to postpone the step of using more than one core for as much as possible, to make sure we get to 90% of the single core first.

                                                                                                                                                    In other words, the bottlenecks we are currently observing do not necessarily reveal the actual architectural bottlenecks!

                                                                                                                                                    1. 16

                                                                                                                                                      But we want to postpone the step of using more than one core for as much as possible, to make sure we get to 90% of the single core first.

                                                                                                                                                      As Grandma TigerBeetle once said, “you can have another core once you’ve saturated the first one!”

                                                                                                                                                      1. 1

                                                                                                                                                        Thank you for a detailed answer!

                                                                                                                                                      2. 7

                                                                                                                                                        Personally, as someone who has worked on a multithreaded database -

                                                                                                                                                        Using multiple threads or processes to write to the same physical drive is usually just moving the single point of synchronization to the kernel / firmware. How fast that is / how well it works is going to vary from machine to machine, but the answer is almost always “orders of magnitude slower than the single threaded version”. It also means you’ve got to deal with risks like “OS update broke the performance”.

                                                                                                                                                        If I couldn’t get enough write performance out of a single thread, first thing I’d try is switching to user-space storage drivers (to ensure no other process was stealing my IO time and reduce kernel context switches). If that still wasn’t enough, I’d want to shard writes to separate drives.

                                                                                                                                                        1. 4

                                                                                                                                                          Specifically databases or in general? It is a very established pattern but everyone ignores it and rawdogs threads-everywhere-all-at-once-all-the time-no tooling-or-language-support-because I’m special and this is different.

                                                                                                                                                        1. 3

                                                                                                                                                          This is caused by rustup, which shims cargo, rustc, etc to redirect to the required toolchain. Which is usually controlled by the user but a rust-toolchain file allows the directory to override it, which in turn can cause automatic installing of new toolchains without user involvement.

                                                                                                                                                          Funnily enough, implicit toolchain install was removed in rustup 1.28.0 but there was a big outcry about it so it’s being restored by default. Maybe a less invasive change would be an easier pill to swallow (e.g. only official toolchains are installed implicitly).

                                                                                                                                                          1. 2

                                                                                                                                                            It’s not related to toolchain installs, just to path overrides, but otherwise yeah, this is how it works.

                                                                                                                                                            1. 1

                                                                                                                                                              This is a nice illustration of how errors arise out of combinations of known-good and tested products working by spec.

                                                                                                                                                              Welcome to STPA ;)

                                                                                                                                                              1. 2

                                                                                                                                                                Is it really an error or just the classic issue that some people want safety and other people want the convenience of being able to do whatever they want (e.g. arbitrary code execution)?

                                                                                                                                                                1. 1

                                                                                                                                                                  I mean, the STPA is basically the observation you make, taken to the extreme: systems of “good” components have emergent behaviour that may or may not be seen as an error. For that reason, e.g. it does model humans in the loop as important, because they can make these judgement call.

                                                                                                                                                          2. 24

                                                                                                                                                            Do not run any Cargo commands on untrusted projects.

                                                                                                                                                            Surely this reduces to: Do not run any commands on untrusted projects.

                                                                                                                                                            As soon as you run make, configure, meson, it’s all the same no matter what the project is implemented in.

                                                                                                                                                            1. 20

                                                                                                                                                              Yes, but it’s plausible that some people might have implicit trust in cargo due to the configuration presumably being all-declarative (unlike make/configure which are basically known to be equivalent to arbitrary code execution).

                                                                                                                                                              Misplaced implicit trust is a very significant factor.

                                                                                                                                                              1. 12

                                                                                                                                                                Surely this reduces to: Do not run any commands on untrusted projects.

                                                                                                                                                                Not completely. You can run “go build” on a Go project. It does not run any user-supplied code, only the compiler. Pretty neat. Such properties do not happen accidentally, it takes consideration/work (eg the toolchain fully handling builds).

                                                                                                                                                                Of course, next thing you often want to do is run whatever you’ve just compiled. Or run the tests, which can do anything.

                                                                                                                                                                Still, I like to access interact with untrusted code in a bubblewrap sandbox, using a simple script and config file that exposes just the essentials of my home dir.

                                                                                                                                                                  1. 1

                                                                                                                                                                    You have the go generate step before go build, that many projects use and can run arbitrary commands.

                                                                                                                                                                    1. 5

                                                                                                                                                                      go generate can for sure run arbitrary code, but so can any other code generation utility. Importantly, it’s never invoked by go build, which abides a stricter security model, as @mjl described above.

                                                                                                                                                                  2. 7

                                                                                                                                                                    I think that the regrettable and perhaps noteworthy part is that, unlike make or configure, Cargo is not entirely built around the ability to execute arbitrary code. The ability for projects to give you arbitrary code to run is used very rarely, almost everything works declaratively. With some minor tweaks, it could be a system that makes it safe to run certain subcommands on untrusted projects, and this would only break a small number of crates.

                                                                                                                                                                    1. 1

                                                                                                                                                                      …and there’s also a low-priority intent to support opt-in sandboxing for things like procedural macros and possibly build scripts, inspired by watt.

                                                                                                                                                                      1. 2

                                                                                                                                                                        Sandboxing macros doesn’t seem super useful if it’s opt-in. But I guess potentially there could be hope for this macro system taking off and becoming the mainstream one to the point where the dangerous macro system could be made opt-in?

                                                                                                                                                                        With Fennel we sandbox the macros, and it works great, but we started very early with that; like only about a year after the macro system was really created. Making it safe after the fact would be very difficult, but worth it to be able to use static analysis tools safely on untrusted code!

                                                                                                                                                                        1. 2

                                                                                                                                                                          The idea I see people calling for is to make it desirable to switch with things like:

                                                                                                                                                                          1. Badging sandboxed macros in the package index and thus making competing crates which don’t opt into sandboxing look less desirable
                                                                                                                                                                          2. If they’re implemented using Watt-style WebAssembly, then the option becomes available for Crates.io to offer reproducible builds of the source uploaded to them, similar to how Flathub works, as a way to improve from-scratch cargo build times.
                                                                                                                                                                  3. 21

                                                                                                                                                                    Something bugs me quite a bit about this comparison: very little space is dedicated to comparing the actual formats from the first principles, it’s almost 100% look at the derived artifacts — size&format of the spec, historical circumstances leading to creation, popularity, benchmarks. The closest to fundamentals’ analysis is itself second-order — reference to someone else’s summary opinion on HackerNews.

                                                                                                                                                                    Now, the derived stuff is hugely important, especially for serialization formats, which sit on the interoperability boundary, but it still feels very wrong to not look at them in the context of fundamentals. From the writing style, it does seem that the author knows what they are doing, and I guess I should update in the direction of CBOR a bit, but, still, I am surprised just how little I was able to extract from the article in terms of what a good serialization format should look like.

                                                                                                                                                                    1. 4

                                                                                                                                                                      I really really wanted to include more examples but this I had trouble justifying spending so much time on a “sidequest”. I’m hoping to include a deeper dive in my flat scraps documentation

                                                                                                                                                                    2. 2

                                                                                                                                                                      Phenomenal, I have a huge reading list now!