Great writeup. Some of the points are moot now that generics are being released, but some very valid concerns.
I would also add that Enums + Exhaustive Switch is a very very weak area in Go that would really benefit the language a ton. I've used those features in other languages and that's one of the things I miss the most, especially when dealing with a ton of web API's that have a defined set of values for properties.
Able to have the confidence that we're checking for every situation that could occur on an enum type across the codebase is one less thing I need to worry about.
> Able to have the confidence that we're checking for every situation that could occur on an enum type across the codebase is one less thing I need to worry about.
If you can write the indulgence (permission to sin) into the code it becomes self-documenting and that seems reasonable
e.g. #![allow(dinosaur::nonsense)] in Rust tells the tools that you know you're not supposed to do whatever dinosaur::nonsense might be, but you want to do it anyway in the following code and the hypothetical dinosaur linter shouldn't bother you about that.
When a maintenance programmer is staring at this block of code later, the fact you explicitly intended to do dinosaur::nonsense is right there, documented where it happens, and they can decide if the proper course of action is to leave that as it is, fix the code to not be dinosaur::nonsense, allow raven::stupidity because the new Raven linter is better but now warns about the same problem under a different name or what.
Golint is the worst precisely because it isn’t configurable, and some of the things it looks for are idiotic. “should replace i += 1 with i++” is probably the worst advice I have ever received.
I can see Go's point here though. Unlike C-style languages where i++ is an expression, in Go it's only a statement, it has no value, which eliminates some footgun opportunities.
It's a halfway house to a language like Swift or Rust where none of the assignments have value. So, in a context where you don't want a value, arguably i++ is a better choice by eliminating a footgun. I don't hate it.
Strongly agree here. I'd rather have this than generics, personally. Having to implement my own sum types every time is laborious (moreso than most boilerplate that people complain about) and it's also hard for users to understand (what are all of the permutations?) and extend (I added a new permutation; where are all of the sites I need to update?).
What a delight to read, even as somebody who has barely used Go. I really appreciate the author's self-awareness as demonstrated in things like "Expected problems that weren’t".
The bits about exceptions and typing remind me of an open question I have about Go: to me it mainly looks like a language for well-understood problems. Static typing and the lack of ability to do broad, high-level exception catching seem to me to be well-matched for problems where you already understand the solution pretty well. E.g., a port like this.
But how is it for poorly understood problems? E.g., You're doing a startup where the technical risks are low and the major unknowns are about user needs and the correct experiences to deliver. Or you're doing exploratory work for an art piece.
In those contexts, I've felt much more effective starting with vague/implicit typing and some broad, high-level exception-catching blocks. That lets me avoid a bunch of questions about the right types and structures until a more solid domain vocabulary emerges. At that point you can refactor/rewrite toward clarity. But I don't see a good way to be willfully vague/casual in Go.
I feel like strong typing _helps_ when exploring a new domain, as it forces you to really think about the data you're operating on. In a language like Perl or Python, it's so easy to just throw around a bunch of hashes/dicts and that can get messy really quickly.
I don't miss _exceptions_ in Go but after using Rust for a while I've come to really love Rust's `Option` and `Result` types. They're more ergonomic and expressive than Go's errors-as-values, and the use `Option` also eliminate `nil` entirely, which has been a regular source of runtime panics in every large Go codebase I've worked on.
Sum types are a thing I don't want to be without again. Not only the built in Option and Result, but once you're in the mindset you find they're the right fit for other problems too.
In Rust itself, writing stuff where I actually care whether my structure is 16 or 20 bytes because I need to fit hundreds of millions of them into RAM, I like that Sum types ensure Option<NonZeroUsize> is the same size as usize, by reasoning that 0 isn't a valid NonZeroUsize and so it can signal None. However on a language like Go I don't miss that - what I do miss is the fact that in Rust I can't mistakenly end up with None(actually_something) or both an error and the result that shouldn't be there if there was an error.
Python is strongly typed, not weakly typed. What you are talking about is static typing, which Python is not; Python is dynamically typed. Please do not confuse the two concepts.
It only helps to the extent that being forced to really think about the data is net useful.
In conditions of high volatility, that's not always the case. If I create some throwaway prototype to test a hypothesis based on talking to a few users and then test it on a dozen more users, I don't want to think a ton about the domain. The sheaf of possibilities is often quite large, and narrowing down the possibilities requires better understanding of the users and the things we're creating for them. That understanding is only available in the future, and we only get to that future by making something.
I really do love designing clear, expressive type systems. But the beginning of the project is when we know the very least about what will happen, so it's the worst time to invest in those type systems. It can be fine anyhow if the domain is stable and well-understood. Which I gather is Go's sweet spot, and I'm fine with that.
I’ve never once felt like strong typing has prevented me from solving unknown problems. I won’t deny that there have been occasions where I’ve had to redefine a type across a large code base and that has taken me half an hour to a hour. But I’d take that over type validation at run time.
For me, having strict typing helps with unknown problems because it gives me greater confidence that refactoring wouldn’t introduce subtle regression bugs.
For me the cost isn't just redefining a type. It's the continuous work of reifying types that could be implicit for a while and possibly forever, because concepts don't last long enough to need it.
For me, having written a lot of code in Java and Scala but also in Ruby and Python, typing is a nice adjunct to unit tests and operational monitoring, but not my fundamental way of ensuring correctness. And correctness often isn't the highest value in practice, especially early on in a startup's life.
Honestly, you don't notice it once you get into the Go mindset.
The exception handling looks onerous, and is a pain at the start. But it doesn't take long to get used to it, and then (for me anyway) it becomes second-nature. Everything returns an error, and you have to handle that error (even if only passing it up again).
Static typing is more "fun" when you're exploring new concepts, but interfaces are the key. Defining the interface at the beginning ("what do I need this thing to do?") and then implementing it with a struct/whatever, feels like the right way to do this.
This exactly. I haven't written enough Go to be really annoyed by the more subtle things in the post, but it'd really be nice if they could come up with some syntax sugar for that kind of like Rust's ? helper.
Maybe the Go ? is allowed within any function whose last return value is an error on any function call whose last return value is also an error. Call it with ? at the end and accept all but the last return value. At runtime, if the err is not nil, then it returns from the function, supplying the err value and nil for any other return values.
iferr is one of the worst warts of the language. I recently converted a Pulumi project from
golang to Typescript. Code size dropped by 30% just by removing all the iferr checks. The other thing is the compiler
doesn’t complain if you forget to check error returns - so it’s easy to make mistakes. This and lack of generics make golang incredibly verbose at times.
Explicit error handling isn't the complaint—3 vertical lines of error handling after every function call is. Rust, for instance, started this way and eventually introduced the `try!` macro and finally the `?` operator. It's still explicit, it just doesn't fill your screen with unuseful boilerplate.
The extra lines carry no significant cost -- it's not like reading them imposes a burden versus parsing a single line dense with semantic information. They expose the `return` keyword, which clearly signals a control flow point that is hidden by method chaining and `?`. And it doesn't grant the error control flow special status! These are virtues. I don't see this as unuseful at all. It's fine if you do, of course! But there's not an objective ruling, here.
In a sense, yes. But the outcome of this process is deliberate rather than mechanical. You choose how much information to expose to callers at each layer. You can display it to users, in a pinch.
Passing an error manually up several levels when it may only be a theoretical concern is a ton of expressive duplication. If I'm in the mindset, that would feel like valuable work, even though it isn't actually making things better for users.
It reminds me of one team I dealt with years ago. They were Java experts used to doing enterprise stuff; their overlords had put them on a scrappy, startup-like thing that was intended to be open source. As was in fashion for Java at the time, they had written things in many layers, the goal of which was to provide scaling cut-points. However valuable in theory, the project ever needing to scale was uncertain. What really mattered was finding something that served user needs. But the layer-by-layer duplication increased the cost of change significantly, lowering the odds we'd find the right product.
I'm not following. I can be explicit without being duplicative. The more I repeat an expression of a single concept, the harder I make refactoring when I discover the concept needs to change. That's why, e.g., copy-paste programming is such a bad idea for most projects.
Exactly. If the error-handling idioms of the language force me to be duplicative in ways that make useful work harder, then it sounds like it's not the right language for the project in question.
All depends on what’s important to you in that startup context. If you need a good mix of high productivity and low costs on the server side I think go is a good fit.
If you don’t care so much about costs and scale on the server side then something like Ruby on Rails might give you that extra productivity boost you need to verify the concept.
Yeah, given how computers keep getting cheaper, I only start caring about low server costs once I have a demonstrated cost problem that's going to be material to the business model or to the budget. Until then I'm very happy to burn CPU time to accelerate developers. Who keep getting more expensive!
And yes, the point of verifying the concept is key for me in this. Before product-market fit, I just don't have much confidence in any domain model. Once we have demonstrated that particular people are excited to pay for a particular thing, that changes. Then we can understand how those people think, and how we think about their behavior, such that we can do real domain-driven design. At that point I'm much more willing to lock down types.
One thing that the people saying "just use a struct for keyword arguments" are missing is that structs should signal intent, i.e. "this is a concrete concept in the system." A Repository, Commit, Message, Person, etc. struct are all concepts in the domain, whereas "the arguments for this particular function" is not. I think Go people are allergic to writing code that does anything other than functionally work.
> I think Go people are allergic to writing code that does anything other than functionally work.
I think that's what Go was designed for, as a language. To be readable, usable. It's uncaring for your personal programming philosophies. I think that's why it's been successful.
I'd agree that it was one of their design goals, but I wouldn't go as far as to say that it makes the language "readable and usable." That depends on what your values are.
Go's philosophy (which clearly flows from its creators being C-enthusiasts) is that the only thing that matters for reading, writing, and understanding a program is what it concretely does, i.e. what structures are created, where values are stored, how computations are performed etc. If that's also your philosophy, then of course it's going to jive with you.
But plenty of people also have different philosophies. Maybe you think the main thing that's important in crafting programs is developing a rich domain vocabulary that expresses concepts and how they interact. Maybe you think that what's important is formal proof of both logical and concrete correctness. In those cases, Go's rigorous opposition to abstraction (coming from its philosophy that what's important is concrete operations) will probably irritate and slow you down.
I couldn't say exactly why it got popular. I'd guess that some significant segment of programmers also share its philosophy, but I have no evidence to back that up. Certainly any reasonably uncontroversial language with a large suite of libraries backed by Google is bound to have some level of popularity.
I disagree. Go provides a low-runtime way of writing programs, like C, without having to resort to managing memory and threads super carefully. No VM, no interpreter, fairly straightforward to imagine what the compiler is doing.
You can do the same work in Java but you can't statically link the JVM. You can sort of do these in Python, but the compiler story is murky at best, and the language isn't as type safe.
No, Go literally doesn't have a VM or an interpreter. VMs and interpreters are runtimes, but not all runtimes are VMs or interpreters. Go executes native code.
Any language that performs work not directly specified by the user has a runtime. Go's runtime is minimal and concerned with two important aspects of the language: scheduler, and garbage collection.
Yes, I understand. I was responding to someone who was arguing that it was inaccurate to say that Go lacked a VM or interpreter. Yes, Go has a runtime, but that doesn't imply that it has a VM or interpreter (it doesn't).
I thought of Dart as a counterpoint, but if anything it's actually more proof. Dart kinda failed as a language in the browser because Google didn't really push it, and when they did it got pushback. Now that they've repurposed it for building mobile apps, it's surprisingly popular. Sure, not Go-levels of popular, but leaps and bounds more popular than if it were some scrappy OSS project. And it's in a similar camp to Go: reasonably uncontroversial (it's basically Java), large suite of libraries, backed by Google.
Google has never meaningfully "pushed" Go. From Google's perspective, Go is just a backend language that's a good fit for some internal Google applications. I don't think they care tremendously that other people use it, although they certainly don't mind. On the other hand, Google strategically wanted a robust frontend ecosystem (hence investing heavily in Dart and V8) because getting more applications off of PCs and onto the web meant more user data up to collect and more opportunity to serve ads.
In particular, I don't understand how Go is more Java-like than Dart.
Feature | Java | Dart | Go
-------------------+------+------+----
jit compilation | yes | yes | no
inheritance | yes | yes | no
classes | yes | yes | no
nominal subtyping | yes | yes | no
native binaries | no | no | yes
static artifact[0] | no | no | yes
static typing | yes | opt | no
value types | no | no | yes
What other features do Java and Go have in common that they don't also share with Dart?
[0]: For sanity's sake, we'll assume this means "are static artifacts common/default" and not "is it technically possible to produce a static artifact" because for some sufficiently broad definition of static artifact the answer can be yes for any language (e.g., Docker images).
Don't take the obvious flamebait. HN's favorite comment on any Go article is "it's designed for bad programmers", implying that if you like it, you're bad at programming. I'm good at programming and like Go, so that implication is clearly false.
I'm a Go enthusiast, but I think the charitable interpretation is that you don't have to expend lots of mental energy to read and write Go code. Even if you have a lot of mental capacity, you can put the excess toward interesting problems rather than reasoning about object lifetimes or hidden control flow or complex interactions between obscure features. If anyone uses "Go is designed for bad programmers" as an insult to Go programmers, they're only arguing against themselves (and lacking the cognitive faculties to notice).
> Go is one of the least expressive languages I've ever used
That is by design, and when it comes to "programming in the large" - a winning formula. I keep repeating this response: I worked on a Perl codebase with a medium-sized team. Perl is very expressive, and my teammates did not hold back. I can tell you that is a nightmare to debug or add a new edgecase to a "clever" Perl 1-liner, usually it involved making the code "less expressive". So, I'll take Go over the more expressive languages in a team setting any day.
It's a spectrum, though. A language can be between 0 expressiveness (Go) and 100 expressiveness (Perl, maybe Lisps), and claiming that the only way to avoid the mysterious evil team member who will wreck your codebase is to patronizingly limit them "for their own sake" is insulting
Often times the developers moaning about complex code basically learned if statements and for loops and then were done learning.
But we still have to write code that these people understand. It makes no damn sense.
Sometimes, sure, people write horribly complex code, but sometimes it's just developers who have stopped learning. They see something that isn't immediately familiar and discard it as too complex and make no attempt at trying to learn.
What I don't get is why we have to pander to these people.
This is a mischaracterization. We write simple code not for simple people, but because grokking gratuitously abstract code isn't a good use of anyone's cognitive resources--no matter how smart you are, you will have more mental capacity to put toward real problems if you're working in a simple codebase rather than an unnecessarily abstract codebase.
Not for their sake - for mine. I avoid people and places that make my life unnecessarily difficult, especially if I have to do support and can be called at 3am to resolve urgent issues.
My needs are pretty basic: I like code that is easy to understand and easy to change more than writing code that leaves a smug smile on my face. I read more code than I write, so YMMV.
The fewer surprises, the better for me, and so far, the collaborative codebases I've encountered the least number of surprises have consistently been in Go (the other languages I've been paid to work with are Javascript, Perl, Python, Java, and Scala).
Huh? What do you consider syntactic bureaucracy? Go has like 25 keywords and no sigils -- the least "syntactically bureaucratic" language I'm aware of!
> Go is one of the least expressive languages I've ever used.
This is definitely true. Of course expressiveness is not strictly a virtue!
I would assume "amount of syntax required to express a given concept," with the use of the word "bureaucracy" implying that some concepts require too much syntax relative to their complexity (something that depends on your values). The classic example being mapping over a slice.
func (w *Whatever) Read() uint64 {
var total uint64
for _, v := range w.values {
total += v
}
return total
}
The former is certainly fewer characters than the latter. But to me it represents _more_ syntactic bureaucracy, not less. There are more sigils, more language concepts I need to understand, more _types of syntax_ to express the same thing. It's 20% of the SLoC, but parsing it requires more implicit knowledge, and takes no less time, versus parsing the latter.
Ah, that's interesting! I'm not the person who used "syntactic bureaucracy" but I did interpret it roughly like that, yes. I'm not sure what phrase I would use to describe "more syntactical constructs" as you're saying, but it's interesting how we saw the term differently.
Similarly, even though I'm not a big fan of Rust, I would personally prefer to encounter the Rust snippet. The way I read code, I'm already building up mental models of things in my head, so adding more (e.g. what fold is) isn't that big of a deal to me. I think I'm a person who is able to look at a function call and not have the desire to dig into its source, though, which I don't think is the way everybody (and most certainly not the designers of Go) feels. Plus once I understand the concept, even if it's a lot less universally applicable than fold, I can reuse my understanding of it throughout the system and possibly throughout multiple systems.
Like you said, I think this is largely a subjective thing. I just find it objectionable when people, on either side of the fence, come in and say "abstracting over concepts and possibly making them first class is always better" or "...always worse."
One interesting thing about Go is that functions are more or less the only way to build abstractions and encapsulate computational complexity. So when you're reading code, if it's not a function call, you can more or less predict it's (fixed) cost. And good function signatures, good program design, where dependencies are explicit and side effects are minimized, does I think let you reliably make assumptions about functions without reading their source. But yeah I see your perspective!
Go doesn't particularly value readability or usability. For example, the short variable name convention makes it harder to read code you're unfamiliar with, and there are a number of noticeable usability shortcomings, some of which are mentioned in the article.
I think Go's design goals were really to (1) reduce compilation time, which explains why Go has human programmers do work that compilers do in other languages, (2) be statically typed and compiled, so you can use it conveniently for microservices, and (3) have syntax somewhat similar to Python.
I think of Go as the successor to Java. Go is to Python as Java was to C++. That, plus the integration with many libraries is why it's taken off in some niches.
Honestly the biggest selling point of Go to me was that it's simple and consistent. It enforces strong opinions that I may disagree with, but it means everyone does things generally the same way which is an important component in readability. With respect to short variable names, the convention is to only use them for very local scopes (e.g., using `i` as a loop index variable). In particular, I don't need to learn a new language or run a daemon just to compile code with a few dependencies and ship a static binary. Similarly, I don't need to configure CI pipelines just to publish packages or documentation. I don't need an IDE, I don't need to shop around for a test framework or an external web server process because they're built in. I don't have to think about what version of the runtime and/or dependencies are installed on my target system. Plus performance is good and the ecosystem is substantial. Personally from experience, I weight these kinds of concerns a lot higher than whatever bells and whistles are available inside of the language.
Not everyone is a Kingdom of Nouns purist. Even most OOP proponents that I've spoken with reject Kingdom of Nouns because it's pretty indefensible (in the worst case it leads to banana-gorilla-jungle problems[0] and in the best case it imposes arbitrary and unnatural restrictions on program design). Note also that "modern Java" and "modern C#" and "modern C++" and "modern Python" virtually all reject these kinds of designs and end up looking a lot more like Go with respect to their use of structs/classes.
Even if you're not doing OOP you're modeling your domain in one way or another. Whether those models are explicit and expressive or not, and how you define those qualities, is another question. In neither case are the arguments to a particular function typically a concept in your domain
> there's a purpose-built tool that doesn't overload an already existing concept
I'm not sure which came first, but structs are a ~60 year old concept. That ship has sailed.
Keyword arguments and structs are both named collections of values. Structs just happen to have broader uses. You can argue that we should use the more specialized one, but that's pretty silly--do we implement a general "add(a, b)" function or do we use more specialized add1(b), add2(b), add3(b) functions? Of course we use the more general tool even though a more specialized tool could exist.
IMHO, if the arguments to a function are just a bunch of heterogeneous values that cannot be sensibly grouped together, then that's usually a sign that the function needs to be refactored (e.g. because it's trying to do a bunch of unrelated things, or because it couples tasks together in an unnatural way).
Well, "Command to Create a Repository" could easily be a concept which contains all of the options necessary to create a Repository, which is a different concept.
Now, there is a problem that all of the struct fields have to have sane zero values for this to work, but I think that is a problem which would naturally arise with keyword arguments as well.
Sure, if you're pushing things through a command bus or something. Otherwise you're just stretching it. If you wouldn't use it in a sentence to describe a use-case, I'd say that it's probably not a concept.
Would it? Only if you allow default values, which is a separate discussion. And even then only if your only mechanism for default values doesn't let the definitions specify what, exactly, the default value is.
> Only if you allow default values, which is a separate discussion.
Keyword args practically require default values, I think? In any case, while this feature (like every feature) definitely delivers value, it's the considered position of the Go authors that this feature, over time, has a net negative impact on program maintainability.
Not really? I suppose what I meant by that was that, if you make a struct, it should represent a concept that makes sense outside of the context of passing it to one function in particular; you're signalling that this collection of data represents a concept in your program
Why isn't "the collection of input parameters to this function" a concept in my program? I understand it's applicable scope is smaller than a e.g. DTO, but does that matter categorically?
I suppose it could be, but imo it starts diluting the value of domain modeling if everything is given the same weight. A bundle of inputs to a function just doesn't have the same weight as a struct representing a repository or a commit or something, so modeling them as the same concept just doesn't sit right with me. It could be a personal thing, though.
Because concepts in programming languages mean things. A discrete, named entity (a struct) is a distinct concept from a way to increase readability of a function call (keyword arguments). Overloading them with the same language construct is compressing disparate concepts. It's the same thing when "Go has no set type" comes up and people say "map[T]struct{}!" You might implement a set using a map, but they're fundamentally different concepts
> Because concepts in programming languages mean things
Right, but a struct means "a collection of named parameters", not "a concept in your domain model" although a struct can be used to model the latter.
> A discrete, named entity (a struct)
Structs don't have to be named. E.g., `var person struct { Name string; Age int }`.
> It's the same thing when "Go has no set type" comes up and people say "map[T]struct{}!" You might implement a set using a map, but they're fundamentally different concepts
It's not the same thing at all. In your analogy, a map is not a set but the advice says to use the map instead of the set anyway. But we're not talking about using one thing as another, we're talking about using a struct as a struct--one such (common) use for a struct is passing named values into a function.
Reposurgeon was a pretty enticing option for us, as we're currently in the migration of SVN to Git, and the string of tools we have is kind of complicated to explain to newcomers.
Currently the workflow is svn-all-fast-export (SVN to Git) -> git filter-repo (trim content from the repository) -> Git LFS (store large files out-of-band), where each stage has to be manually tested and written, and then hopefully put into a shell script or Bash history or something.
Unfortunately, I have yet to get Reposurgeon working for us at all. The documentation was inaccurate, in that it referred to things which only existed in the Go version, and the Go version went OOM (on a server with 384 GB of RAM) on every repository I've tried it on. This includes just reading from an existing, pruned svndump file, so it's not resource contention either.
Basically, the tool is great conceptually, but it absolutely does not work for our use case. We had to stick with our existing, messy, multi-component solution, which has worked surprisingly well over the last two years, and since I'm the one doing most of the processing, having kind of a messy system is relatively acceptable for the time being.
We’d be interested in hearing more about your situation, so that we can further improve Reposurgeon. If you can profile it as it reads your svn dump (using the `profile start` and `profile save` commands), it could really help us out.
How many commits does your repository have? The largest one we’ve successfully converted from SVN to Git ourselves was 287k commits.
You can also force it to write all file content (blobs) out to temporary files rather than keeping them in memory by reading the stream from standard input:
reposurgeon "read -" … <dump.svn
That will be slower and use more disk space, but maybe it will fit.
Personally, I would have gone with Kotlin for a translation like this. It's a closer match to Python than Go, and I think his rule swarm [1] approach to semi-automated translation would have been effective. But then, he might not have liked the fact that, like Python 3 and unlike Go, the Java platform treats strings (particularly filenames) as a sequence of Unicode code points rather than bytes.
I like Kotlin, but I really think of Go as the most probable outcome of someone saying "I'd like a staticly typed, concurrent/parallel Python", and then mumbled "but I hate exceptions". Go is very close to python in many respects.
The article mentions that Go, OCaml, or a compiled lisp were considered for this project. I wonder why Rust wasn't on that list. It seems to cover every concern that is raised here except for keyword arguments (which are high on my list of desired Rust features too).
I guess maybe they were worried about the lack of GC, but my experience has been that's it's generally quite easy to port code from dynamic languages like Python/JS/PHP to Rust. So I suspect that may have gone into their "Expected problems that weren’t" section if they'd tried it.
Async wasn’t in yet at the time. He did investigate Rust to a certain extent, but bounced hard. His chosen program didn’t really show off Rust’s strengths, because he started by trying to call select and write essentially the same program that he would have written in C. That’s a pretty painful way to go.
Incidentally, I helped with the port to Go, and I spent some time polishing the code, and finding and fixing performance problems. When we were helping GCC convert their SVN repository to Git (a repository with 287k commits, btw), we reduced the memory usage by 50% (from over 250GB to under 128GB), and the run time by quite a lot as well (down to just around 2 hours to read in the SVN repository and convert it to a basic Git repository).
Now that we’ve done that work, Reposurgeon spends 50–60% of its cpu time scanning the heap for garbage. There is often garbage to find, but just as often there is not. GC is useful, but for Reposurgeon it has become a bottleneck.
My preliminary work on a Rust port shows that it is around 4× faster than the Go version. I personally think that Rust is the future, but I haven’t been able to put as much effort into the port as I would like.
Appreciate your thoughtful reply! One question. Regarding this bit:
> He did investigate Rust to a certain extent, but bounced hard. His chosen program didn’t really show off Rust’s strengths, because he started by trying to call select and write essentially the same program that he would have written in C. That’s a pretty painful way to go.
Is this stating that he was writing the Rust version similarly to how he would have written it in C because that was the most natural way to do it in Rust or simply because it was the first path he went down? In other words, was your point that Rust is a fundamentally poor fit for this problem, or was it that his lack of experience with the language took him down the wrong path?
He went down the wrong path, but at the same time Rust didn’t do anything to make async code easier to write. You just had to call libc::select and std::thread::spawn and whatnot, with all of the unsafe blocks that implies. These days there are about 47 different crates that can help you do it, all of them a lot nicer than that.
> A lot of Rustaceans don’t seem to grasp why, when the question is “where do I get feature X?” the answer “oh, there are 23 crates for that” is objectively terrifying.
This is exactly the impression I have gotten from the crates system every time I've looked. It happens for things as foundational as mmap. Also terrifying: The plethora of highly-recommended crates that have not yet committed to a stable API (ie, are still semantically on version 0.X).
I’m not sure that’s a great example. mmap(2) is a swiss army knife of a function. There are many crates that build all kinds of things on top of mmap, and they’re not all interchangeable. Allocators, file io, gpio, actual virtual memory mappings.
My view is that when you develop a product, you have to own everything. The users don’t care if the bug comes from code you wrote, or code in a library, or the language’s standard library, or the OS. You have to fix the bug no matter what caused it. It doesn’t matter if the code came from a vendor or a language designer or stack overflow; you are the one responsible for fixing it if something goes wrong. Non est salvatori salvator, etc.
With that perspective, I don’t think that 23 crates is terrifying. I’m going to look them all over and either pick one, or write the 24th crate myself. The result is the same either way.
I do agree with you about APIs though. It is nice to find a crate where the author has had the confidence to stabilize their API and declare the version to be 1.x instead of 0.x. But at the same time I recognize that getting to that point requires some real software to use the crate, to create the feedback loop. If nobody ever used a 0.x crate, no crate would ever get that feedback.
>My view is that when you develop a product, you have to own everything. The users don’t care if the bug comes from code you wrote, or code in a library, or the language’s standard library, or the OS. You have to fix the bug no matter what caused it. It doesn’t matter if the code came from a vendor or a language designer or stack overflow; you are the one responsible for fixing it if something goes wrong.
So then using a programing language that has a big standard library and a more rich and stable ecosystem is a big advantage.
If I want to do a network request, parse a json file, parse or format a date or some other trivial thing I prefer to have a good enough built in way to do it then evaluate 15 packages or write it myself. Otherwise you get in a shitty situation where you inherit some projects and it has 20 dependencies with vulnerabilities, 20 dependencies abandoned, some dependencies that are incompatible with some new version of the language/ecosystem causing issue if you would like to upgrade.
Sometimes. Using someone else’s code is only a benefit if it saves time overall, compared to writing it yourself. You have to count both the time to write it and the time to maintain it over however long your product is active. You have to assume that you’ll be maintaining it yourself either way.
Also, don’t forget that languages with big standard libraries, like Python or C# or Java, always end up with oodles of stuff in there that is deprecated or incompatible. That’s as bad a sign as anything.
But I do agree with you, a lot of the time you have inherited the mess rather than writing it yourself. Not much you can do about it, except to clean it up and then dodge better next time. Going back to Rust specifically, Cargo gives you some tools that make the mess easier to clean. The Rust language also helps, because you can safely use multiple versions of your dependencies, when it turns out to be necessary.
>Also, don’t forget that languages with big standard libraries, like Python or C# or Java, always end up with oodles of stuff in there that is deprecated or incompatible. That’s as bad a sign as anything.
This does not happen that much, and in Java and .Net world if some functionality is deprecated there is a replacement, I think I only remember some possible unsafe or ineeficient functions were deprecated so you use the better ones. I don't have experience with Rust , only with node/npm and is a hell , for some reason is decided to split stuff in super small pakcagtes of various quality, today I sepnd hours debugging a npm freeze caused by some package , in the end the cause is probably a shit npm/node implementation that crashes when some specific git version is installed , but debugging this I discovered that the unit testing packages the project uses(I inherited them) instead of beeing one or few packages are a few, and for some reason one of them had a weird dependency that it should not have(a dev only despondency ) and this dependency was also just pulling directly from GitHub ...shit in a few years when GitHub is gone lots of things will stop working (I also had issues with scripts because someone changed master into main because American politics...).
I personally prefer the Java or .Net model, a big standard lbirary and then for most important things you have a small number of options since the community did not wanted to do CV driven development, and most of the time you find all you need in a single library with no or few dependencies. But Rust does not have the Java or .Net money to hire devs to work on the boring stuff of correctly implementing standards like json,XML, date&time and keep maintaining it - (not sure how Python did it) and Rust community seems to be inspired byt node community a lot and this is bad.
> With that perspective, I don’t think that 23 crates is terrifying. I’m going to look them all over and either pick one, or write the 24th crate myself. The result is the same either way.
In a fine-grained ecosystem, the problem is transitive. You aren't just picking one from 20+ for each of your direct dependencies, you are also trusting that they in turn did just as much diligence as you did in picking their direct dependencies and so on. Curation and pruning would at least help mitigate the scope of the problem.
> ... or write the 24th crate myself.
This is a good example of the xkcd joke about standards proliferation. If I found myself in this situation, I'd prefer to keep the 24th private to my own package. DRY has its limits. Sometimes you've just got to specialize.
I'm aware of the irony in saying this in the context of mmap. But in this case, a thin wrapper that reflects the system call's semantics ought to be available in a common `posix` crate that others may freely rely upon to build their higher-level services. There's no good reason for e.g. ripgrep to have to pick and choose among a dozen flowers for it.
> In a fine-grained ecosystem, the problem is transitive.
Absolutely. You definitely have to own the whole stack. Ultimately, you may have to fix bugs at every level, from your own code all the way down to the OS. Big tech companies do this explicitly, with kernel development teams. Small companies do this by occasionally upgrading to the latest version of Ubuntu and hoping for the best.
> But in this case, a thin wrapper that reflects the system call's semantics ought to be available in a common `posix` crate that others may freely rely upon to build their higher-level services.
All the other crates on crates.io that you find when you search for “mmap” are higher level abstractions over the raw syscall. They have specific purposes, like file io, or creating circular buffers.
I agree, but at the same time things tend to stabilize over time as the community settles around the best packages. The real thing to fear is the relative instability of Rust at the present moment (for certain things, like async), but the overall trajectory seems promising.
It sounds that it was hard to learn and use for him, that's why he preferred Go and Python. It's a frequent complaint and the step from Python to Go is indeed easier than from Python to Rust. He also had some philosophical concerns about the crate system.
Go, OCaml and Lisp (depending on the Lisp) all have one other thing in common.. a fast compile/feedback loop. Rust doesn't have a good story here. This would be my guess as to why (at least in part). Particularly coming from a dynamic languages which have super fast compile times if they compile at all.
What I really missed was generic map-function-over-slice, which could be
handled by adding a much narrower feature.
If one graded possible Go point extensions by a figure of merit in which the
numerator is "how much Python expressiveness this keeps" and the
denominator is "how simple and self-contained the Go feature would be",
I think this one would be top of list.
So: map as a functional builtin takes two arguments, one x = []T and a
second f = func(T)T. The expression map(x, f) yields a new slice in
which for each element of x, f(x) is appended.
I think this is an excellent way of evaluating new features, and it represents a real missed opportunity for Go to explore new PL territory. Instead, we're getting full-blown user-defined generics, which increases the "denominator" far, far more than it increases the "numerator."
I also came from Python (15 years of experience) to Go and I think newcomers to Go index too hard on terseness. The for-loop equivalent for a map over a list is more characters, but it's really straightforward and easily recognizable in the code. In the general case, I'm glad that Go doesn't try to explore new PL territory, optimizing instead for things that are known to improve developer productivity. None of this is to say that Go has no room for improvement--only that "terseness" is not high on the list of improvements I'd like to see (for example, I'd rather have sum types with exhaustive pattern matching).
For me, it's not a question of terseness -- it's more about communicating intent, and not polluting the scope with incidental variables. Everyone knows what map/filter/reduce do. When reading new code, seeing "map" is better than seeing a for loop: you don't have to think about the underlying iteration at all, you can skip directly to the essence of the transformation. As a side effect of this, when you do see a for loop, you can safely assume that something about the iteration is non-trivial: maybe the loop exits early, for example. I wrote a bit more about this here: https://twitter.com/lukechampine/status/1463715093733122054
btw, by "new PL territory" I mean "reifying a small set of container operations, without supporting user-defined generics," which to my knowledge is not a position taken by any mainstream language.
> For me, it's not a question of terseness -- it's more about communicating intent, and not polluting the scope with incidental variables.
A mapping for loop doesn't pollute scope with incidental variables:
results := make([]Result, len(input))
for i := range input {
results[i] = callback(input[i])
}
^ This only adds `results` to scope, which is the same as `results := map(input, callback)`. In the for loop example, the loop variable `i` is scoped to the loop.
Moreover, if you don't care about terseness, you can always pull this out into a well-named function or annotate it with a comment.
> Everyone knows what map/filter/reduce do
In isolation, but for complicated chains of map/filter/reduce (especially with error handling logic in languages which return errors rather than raising them as exceptions) it's much easier for me to read the corresponding for loop equivalent. Even my colleagues at a Python shop had limits on the complexity of list comprehensions beyond which point they were required to rewrite into a for loop because while packing that complexity into a single expression is elegant and clever, it's not particularly readable or easy to understand.
I guess my view can be summarized as: for very simple cases, map/filter/reduce are a bit clearer, but for those same simple cases a for loop is still easily understood and a for loop's readability scales better with complexity.
ESR's proposal here is basically just another way of spelling Python list comprehensions or Perl 5 map (which is likewise a builtin, with special parsing rules). It's not "new PL territory" by any means.
I think the "new PL territory" was referring to a type-safe generic map builtin. Obviously Python and Perl don't have a concept of type safety. That said, I still wouldn't call that novel in the sense that a builtin version of a well-understood generic function doesn't seem like a feat of ingenuity.
I would argue that it is: it's explicitly a language designed "in the service of software engineering," which is something we're still figuring out how to do. It has implemented novel features that serve this end, and left out features that don't. PL design doesn't have to just be about dependent types and borrow checkers, after all.
> What I really missed was generic map-function-over-slice
Me too, and mapping a map, a channel, etc. With generics, things will be easier, we can have iterators, even lazy iterators, but there will still be no type inference in lambdas for arguments and return values and no easy currying, so things will still be awkward, but better.
> Keyword arguments
You can kind of do that by passing a struct as an argument, and represent the optionalness by having a pointer in the struct. That's still awkward, you can't easily make a pointer to an integer or string literal, you need a separate helper function for each primitive type. Overall it could be nicer.
> Annoying limitations on const
I think the Go constants are good, the fact that you can perform calculations on them in compile time also. But I wish there was some way to express immutability (and non-nilability) in the language. Programs are simpler with less moving parts.
> 14KLOC -> 21KLOC
That's to be expected, Go could be nicer if it was more concise, obviously that's a hard thing to balance, because you can end up with too obscure code, I just wish that there were some small improvements from time to time.
> Absence of sum/discriminated-union types
Yeah, I refer to it as Algebraic Data Types, where you can for example have your tree type to be a Node with children or a Leaf with value. And a function can accept a Tree that can be one of these. IIRC, the Go people claim that you can do kind of something like this with interfaces, but it's not very nice to use interfaces in this way and has some drawbacks.
> Catchable exceptions require silly contortions
I don't like exceptions, they disrupt the flow of the program - any function call could "return early", so you need to be always careful and account for that possibility. In C++ this was solved with RAAI, and it was a source of so many bugs that some companies just disallow exceptions internally.
> Aesthetic doubt
Yeah, it's there, some things in Go just come out ugly.
> Absence of iterators
Yes. Hopefully generics will enable implementing them.
> You can kind of do that by passing a struct as an argument, and represent the optionalness by having a pointer in the struct. That's still awkward, you can't easily make a pointer to an integer or string literal, you need a separate helper function for each primitive type. Overall it could be nicer.
I think using a builder is a better option in Go if you have more than 2-3 arguments.
> Yeah, I refer to it as Algebraic Data Types, where you can for example have your tree type to be a Node with children or a Leaf with value. And a function can accept a Tree that can be one of these. IIRC, the Go people claim that you can do kind of something like this with interfaces, but it's not very nice to use interfaces in this way and has some drawbacks.
Except you can't really do this with interfaces. If you need to get back to the concrete implementation of the interface, there's no way to know what all possible implementations might be. This is especially true since any type can implement an interface, not just the types you create initially. So someone else could implement `Tree` and you would not know to account for that in your function that accepts the `Tree` interface.
I don't think there's any substitute for proper sum types in Go.
> I think using a builder is a better option in Go if you have more than 2-3 arguments.
But then you need to write all the setters, and something that was supposed to be simple, a function, becomes a whole contraption with a bunch of auxiliary code. Personally I would avoid that, and I definitely wouldn't make it a rule of thumb to use builders in every function with more than 2-3 arguments (maybe you meant something else).
> But then you need to write all the setters, and something that was supposed to be simple, a function, becomes a whole contraption with a bunch of auxiliary code. Personally I would avoid that, and I definitely wouldn't make it a rule of thumb to use builders in every function with more than 2-3 arguments (maybe you meant something else).
Yeah, it's tedious, but that's Go for you ;)
Rust has the same issue, but there are macros libraries that will create the builder for you based on the struct definition, so it's _very_ trivial to create these builders.
You could do the same with codegen for Go, but this always feels much worse to me than using macros for a number of reasons like having to install a separate tool, checking in generated files, making people run `go generate` after some (but not all) changes, etc.
Apparently they decided that it would be too confusing to have variant types alongside interfaces for some reason, and they claim that interfaces handle a lot of the use cases of variants:
Yeah, "for some reason" is a good summary of many Go decisions.
Rust has both (traits are somewhat like interfaces) and I don't feel like it's too painful, though using the trait type in function signatures is a lot more involved than in Go, and I still don't fully understand all the nuances. But the complexity isn't because of any overlap between traits and sum types.
Rust trait objects are surprisingly painful. I've reached for them a lot where I would normally use Go interfaces, but ended up avoiding them. I suspect this has more to do with Rust's memory management model--having sum types and interfaces would almost certainly work out perfectly in Go.
Builders aren't a good pattern in Go, because it's difficult to express continuations, and they leave types in incomplete states. It's almost always better to use config structs.
Regarding your point on iterators, I typically use a channel with context; this lets you exit that range loop early and will automatically stop the related go routine. (assuming it’s context aware)
e.g.
func myFunc(cx context.Context) {
ctx, cancel = context.WithCancel(cx)
defer cancel()
for item := range iterator(ctx) {
}
}
The major problem I have with that is performance is atrocious [1] if the iterator isn't doing something very nontrivial. Since the vast majority of iterators amount to incrementing at most a handful of things and then returning something indexed by those things, you're paying the cost of channel communication per item but not getting that channel cost amortized over any significant costs in the iteration itself. If the thing using the iterator is also not doing anything significant (i.e., adding integers together), this has very bad performance implications.
As a rule of thumb, the amount of work transferred by any concurrency primitive should significantly exceed the cost of the concurrency primitive itself. I do have a couple of uses of this pattern where what is on the other side of the channel is something reading off a network and parsing lines of JSON into internal structs, in which case the overhead of the channel isn't necessarily too bad. (In one case, it even chunks the lines of JSON into a slice of several parsed structs, reducing channel overhead even more.) But it's a terrible solution in general; iterating over an array and doing any sort of very fast "thing" to each element that only costs a handful of assembler instructions, a very common use case, has terrible overhead.
It's a real pity, because the semantics of that solution are pretty close to the right answer. But it's a huge performance trap. Something as basic as iteration needs to not be a huge performance trap.
[1]: Relative to Go, anyhow. I haven't timed it directly but I wouldn't be surprised that a channel-based iterator like that would be comparable to Python's general iteration speed, or at least not off by a very large factor. It's just that "Python's normal level of performance" is "atrocious Go performance".
The problem with using channels is that these require multiple goroutines and locking for a problem that's inherently singlethreaded. Instead, you can define an iterator as a function that returns a function:
func intSliceIter(ints []int) func() (int, bool) {
i := 0
return func() (int, bool) {
if i < len(ints) {
ret := ints[i]
i++
return ret, true
}
return 0, false
}
}
iter := intSliceIter([]int{0, 1, 2, 3, 4})
for x, ok := iter(); ok; x, ok = iter() {
fmt.Println(x)
}
Of course, there's not much benefit to a SliceIter; this is a contrived example, but you can apply this pattern in more complicated cases as well. Similarly, you can define an iterator as an interface (which is similar to bufio.Scanner and a few others in the standard library—a closure is an object is a closure):
type IntIter interface {
Next() (int, bool)
}
type IntSliceIter struct {
Cursor int
Ints []int
}
func (isi *IntSliceIter) Next() (int, bool) {
if isi.Cursor < len(isi.Ints) {
ret := isi.Ints[isi.Cursor]
isi.Cursor++
return ret, true
}
return 0, false
}
It's not surprising that the code base grew when reimplemented in golang. It would have probably been even shorter had it been rewritten in Python itself.
Just the other day, I was able to condense over 15 lines of golang code into 3 lines (could also have been 2 lines) in a Python-like syntax, both reducing code length, and substantially increasing readability as it would make the underlying logic clearly stand out instead of having several loop and map constructs.
I think you have to be very careful before claiming readability gains from fewer lines of code. I've done a lot of Python programming and for simple things like mapping a single function over a list, a list comprehension or generator expression can indeed be a tiny bit more readable than a for loop, but combining that with some filtering or flattening and colleagues (and my future self) get frustrated. I've seen the same thing with overly fancy iterator chaining in Rust.
There's some temptation to think that terse==readable, but in practice this rarely extends beyond the simplest cases.
I didn't yet get around to writing Go in any significant capacity (it's not in an interesting spot for me) but I've had to read some and to me it seems like about half of all Go code does approximately nothing. It seems really weird to me that Go doesn't have a Result/Optional type (or sum types in general) and instead prefers multiple return values instead, and consequentially does not have error-handling operators but requires if err != nil repeated approximately 9000 times per file.
This seems like a real sticking point for a lot of people. Personally, I don't seem to think about code volume in units of "lines" but rather in units of "complexity" and "locality". I would like to see Result and Optional types, but in practice everyone understands that (nil, err) is the same as Result::Err and that (*T, nil) is the same as Result::Ok. Of course, you miss out on monadic properties, but after extensively using the language, that's a feature 99% of the time. I would say give it a shot; I was skeptical at first, but this is surprisingly not an issue IMO.
The real issues (IMO) with Go error handling are the clunky `errors.Is()` and `errors.As()` functions for catching specific error values and types respectively as well as annotating errors to get the requisite context (this may already have a solution which just hasn't yet become idiomatic across the ecosystem). In the meanwhile, I just wrap errors with extra context which isn't super satisfying but largely does the trick, e.g., `return fmt.Errorf("writing to database: %w", err)`. Rust seems to have similar problems with respect to different patterns and libraries for error handling, despite having a standard Result type.
One of the issues I had with Go was that even though functions can return pseudo-tuples (multiple return values), there are no tuples anywhere else in the language. So you can't chain functions which return (T, err), easily create a slice of their return values, feed their output to a channel, etc. Just made handling operations on slices so tedious
I didn't claim that terse is always more readable. It was in the example I gave, and practically every golang program could be written in a more terse, more readable, and less error prone way in Python/Java/C#/etc.
I understand that you didn’t claim this explicitly, but it’s a common misconception so I addressed it anyway. While I have no doubt that a given Go program could be written more tersely in Python, Java, or C# I’ve written and operated enough programs in those languages to know that the results are rarely if ever more readable or less error-prone. I think Go’s preferences for simplicity over cleverness and explicitness over terseness are significant factors in these readability and quality disparities. Newline characters aren’t where your bugs are coming from.
I would also add that Enums + Exhaustive Switch is a very very weak area in Go that would really benefit the language a ton. I've used those features in other languages and that's one of the things I miss the most, especially when dealing with a ton of web API's that have a defined set of values for properties.
Able to have the confidence that we're checking for every situation that could occur on an enum type across the codebase is one less thing I need to worry about.