Hacker News new | ask | show | jobs
by stouset 3080 days ago
I wish I could upvote this comment a dozen times.

Much of go’s “simplicity” is a Faustian bargain that comes at the cost of unnecessary complexity in each and every project that winds up being written with it.

2 comments

This is called trade-off. The reality world is never perfect.
Okay, but a trade-off that forces complexity into hundreds of thousands of programs to avoid complexity in one is poorly-conceived.
Care to illustrate your point with an example? I’m wondering what kind of “unnecessary complexity” you’re talking about.
I mean, generics are kind of go's whipping boy. Lacking generics means you end up with copy/pasted code for utility functions that should be part of the go stdlib in virtually every project. It also means copy/pasted code for any data structure you might want to use that's fancier than an array.

Go's "simplicity" of error handling (read: lack of any actual error handling abstractions) means you don't get useful things like stack traces and have to manually grep through code for nested error messages. It also makes go code difficult to read at a glance, since virtually every statement winds up wrapped in repetitive error-handling code that doubles or even triples the amount of code in the happy path.

The error-handling pattern of using tuples, but no syntactical ability to operate on data within a tuple means you almost never have the ability to chain function calls like `a.b().c().d()`. Instead you have to manually unwrap the value and error, return if there's an error, call the next function, manually unwrap the value and error, ad nauseam. The "idiom" of gift-wrapping error messages is absurd — you are replacing machine-based exception handlers with expensive, slow, error-prone, and less-capable meat-based exception handlers.

Having a half-baked type system means you end up having to frequently write type-switches which are checked at runtime to do any sort of generic code. There's no functionality in the language to ensure that all possible options for that type switch are exhausted, so you are virtually guaranteed to get runtime bugs when a new type gets written and later is passed in.

Speaking of type switches, they interact poorly with go's indefensible decision to have interfaces implemented implicitly rather than explicitly. I have seen types get matched to the wrong typeswitch in producion code because a new method implemented on one type caused it to accidentally "implement" an interface used elsewhere in a typeswitch. Good luck ever catching this before it hits you in production.

Go's concurrency primitives are useful, but the lack of ability to abstract over them means that you have "advanced go concurrency patterns" dozens of lines long and involving multiple synchronization primitives for what amounts to `a | b | c` (https://gist.github.com/kachayev/21e7fe149bc5ae0bd878). God help you if you want to implement something like parallel map. God help you if you want to implement something like parallel map for n > 1 types.

Go requires you to manually remember to release resources you've acquired with `defer`, instead of sanely having There is no capacity in the language to enforce that you've done so, and it is virtually impossible to find e.g., a missing `defer fd.Close()` in a large code base. God help you if you leak file descriptors and need to track down the source.

Go's inability to perform any meaningful abstractions also means that you have to know all the details of code you import. It's difficult to make code a black box. Case in point: to do something as painfully simple as reading a file, you need to import bufio, io, io/util, and os.

During the course of writing this post, I forgot more examples than I listed — I literally could not remember them all in my head as I was writing them down. This isn't simplicity, this is utter madness.

> you end up having to frequently write type-switches which are checked at runtime to do any sort of generic code.

This baffles me. I think I basically never use any type-switches, with the exception of interfaces being used as a sum-type - in which case the problems you mention with type-switches just don't come up.

> Case in point: to do something as painfully simple as reading a file, you need to import bufio, io, io/util, and os.

I don't know what you mean here. `ioutil.ReadFile` reads the whole file, done. Even if you prefer linewise-scanning, you still only need `bufio` and `os`.

But even if you'd need all those packages to read a file: So what? Like, I honestly don't understand what's the problem with that.

The problem with that is I have to care how `file` works.

Here's how to read a whole file then loop over the lines:

    file, err := ioutil.ReadFile("data.txt")
    // some error handling
    for _, line := range strings.Split(file, "\n") {
        fmt.Println(line)
    }
Here's how to stream a file one line at a time:

    file, err := os.Open("data.txt")
    // some error handling
    defer file.Close()
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        fmt.Println(scanner.Text())
    }
    if err := scanner.Err(); err != nil {
        // more error handling
    }

Here's how I, at least, would like it to work:

    fileContents := file.Read("data.txt")
    for _, line := range fileContents  {
        fmt.Println(line)
    }
    if fileContents.Err() {
      // some error handling
    }

    fileContents := file.ReadStreaming("data.txt")
    for _, line := range fileContents  {
        fmt.Println(line)
    }
    if fileContents.Err() {
      // some error handling
    }
The critical point is that I don't want to care whether `file` is a byte slice or a byte buffer, and Go doesn't let me not care. I want to be able to write code that deals with "enumerable data of some sort", once, and then works no matter how the caller decides to provide that data.

Go (intentionally) makes it exceedingly difficult to obscure how a piece of code works from the rest of the codebase, and I personally think that's a fatally poor design decision. In my experience, it makes it difficult to decouple modules since code often has to be at least somewhat aware of quite a few implementation details of a library in order to use it correctly. It makes it really difficult to build higher level abstractions that don't leak. I have a much harder time in Go getting away from thinking in `int`s and `floats` and staying terms of the domain objects that I actually do care about.

"How" a piece of code functions is at best the third, and probably only the fourth most important question (behind "why", "what", and probably "when" if you use any concurrency at all), but Go forces it to be front and center at all times.

You forgot the part where, for the sake of simplicity, their stdlib directly invokes syscalls (rather than going through libc). Which breaks on platforms where syscalls are not considered a stable API, like the BSDs and macOS:

https://github.com/golang/go/issues/16606

I don't think its for simplicity, Cgo has real overhead.
It really sounds like you might like Rust. Have you looked into it?

/snark

But seriously, for a long time before Rust was fully baked, I kept wishing it would be done, so that people hating on Go could go use Rust instead. Now it's fully baked, which is awesome.

Rust is an example of a phenomenally well-designed language.

When I first started go, I was incredibly excited to start learning it. From everything I'd heard, it was everything I was hoping to find in a language. The more I started using it, the more and more its poor design decisions and the hollow defenses of these decisions by its community started to grate on me.

Rust, on the other hand, I was loathing learning. I'd already just learned go and was extremely disappointed with it. I really didn't want to learn something else in this language space that I assumed overlapped so much, and I really entered into it hesitatingly. But I am exceedingly glad I did — unlike with go, every day I used Rust I came to appreciate its design more and more. Features of the language seem to have been designed to coordinate and work with one-another, instead of all being bolted on separately without regard to how they'd interact.

On top of that, Rust has some of the most friendly, dedicated, and talented developer community I've ever seen.

Thanks for writing this up; it was something of a wake-up call from a recent bout of fanboyism, and I found myself agreeing with most of your examples. I'm still learning and I haven't found any of these things to be dealbreakers yet, but they're absolutely questionable design decisions when you consider that other languages already solved many of these issues (with good reason). I think there's a lot to like about Go but I'm worried that the language will die young because of religious inertia.
That's the trap of Go. None of these are deal breakers. Yet.

It's notable that the vast majority of the above are complaints by experienced developers working on large projects that need to be maintained and expanded for years. They're not the sort of thing you notice over a weekend of tinkering on something fun, and they're not the sort of issues that beginners will run into under any normal circumstances.

All of that adds up to a honeypot for newcomers. The language has some deep issues, but they're not the kind of things you're likely to notice until you already have 200k lines written and it's too late to switch to something with a steeper learning curve. Saving two weeks of confusion when you're learning ends up causing a lifetime of headaches down the road once you're an expert.

In other words, Go is PHP for systems programmers.

I wouldn't call it madness, just limits Go usage somewhat, especially in domains involving a lot of concurrency.

I once had hopes for Go. But the team working on it decided not to fix any of its flaws that became obvious over time and even outright denied their existence. So, not much Go for me anymore, but I'm still looking forward to see what such approach can bring in Go 2.

In the same spirit of my above comment, I think you're conflating the terms simple and easy.
Yes, it is a tradeoff. Why do you think it's a good tradeoff?
I think the point he's trying to make is that it's a poorly-justified trade-off.
The cost of complexity in a programming language is paid not only by the developers of the language but also by the programmers that use it.
The cost of poorly designed complexity maybe.

This is the same mentality as people who throw up their hands and say government is broken, so we should deprive it of resources to make it as small as possible. Doing this just winds up makes the problem worse, when there’s plenty of evidence that well-funded governments can work well.

It’s also the same broken mentality behind schemaless databases. Schemas are hard, so let’s get rid of them. This backfires because you haven’t actually rid yourself of schemas, they’re just implicit and now you lack any tools to operate on them meaningfully.

“Hard problems are hard, so let’s just avoid dealing with them” is not a sustainable solution in the long term. Sometimes they’re really hard and ignoring them makes it worse. Sometimes they’re only hard because we haven’t thought about them in the right context. And sometimes hard problems can be sidestepped entirely with a bit of cleverness. But outright ignoring them and hoping they go away just punts the hard problems to others.