* Go is "simple" (the language has a "small surface area", if you will)
* Go has a broad and deep standard library that is generally well-documented
* Go comes with a reasonably good general-purpose build tool (it builds, formats code for you, runs tests, etc.)
Some cons:
* Go's structural subtyping is a pretty terrible approach to handling the problem it's meant to solve. It's not quite as bad as a runtime failure when a 'type' doesn't implement a method (since that would be caught at compile time in Go), but it leads to what I consider an abusive over-reliance on ad-hoc interfaces that just get in the way of understanding the code (runtime or test code)
* Go's error handling method is inefficient and generally awful. I'm not talking about the verbosity (it's a relatively small issue, in my opinion), I'm talking about that in Go errors are just strings with a gross, inefficient library and system for adding context
* Go's generics implementation is, to put it mildly, inadequate; doing anything mildly interesting with it will be a cumbersome chore
* Go's handling of dependencies...leaves something to be desired
>it leads to what I consider an abusive over-reliance on ad-hoc interfaces that just get in the way of understanding the code
I actually like it, from the architectural angle. Say, I have an entity (class, service etc.) which does only one thing X and it does it well. For a certain scenario, it wants also to do Y, and it wants to delegate it to a different entity, because it's not its responsibility. It doesn't really care how it's done and who will do it, it wants to just delegate it. Why should it know or care if there's an existing interface in some package? That's an implementation detail. The consumer specifies what it wants by declaring an ad hoc interface close to its own definition, and as a result, there's no explicit dependency on a different package. Sure, there will be duplication if several entities in different packages want similar interfaces but, as they say, duplication is cheaper than the wrong abstraction.
>in Go errors are just strings with a gross, inefficient library and system for adding context
Not quite true: errors are interfaces, there's a common pattern to construct an error from a string (because most of the time, that's all you need), but no one stops you from using other ways to construct an error. What is inefficient about it? It's no different from constructing an exception in Java/C# etc.
>Go's handling of dependencies...leaves something to be desired
"duplication is cheaper than the wrong abstraction" is only true if the duplication: a) isn't also an abstraction (which, in go, it is in this case) and b) is "contained" (i.e. not overly used), which it often is not in non-trivial go code.
Go's errors package is riddled with `reflect` and other inefficient code constructs. Printing an error (e.g. to stdout or the log) is fine. But if you want to actually do anything with the error in the code (e.g. to discriminate/branch based on the error), you have to resort to bloated types that implement a poor interface or else rely on string inspection. In either case, you are using the underlying errors package. In small scale applications it's fine, but when you're doing anything at even modest scale and your application encounters errors, it's going to introduce measurable performance degradation.
>only true if the duplication: a) isn't also an abstraction (which, in go, it is in this case)
Can you give an example? I don't follow. I generally don't like abstractions for the sake of abstractions. I use Go's interfaces for a very specific reason: when I want dynamic dispatch, but with nice static typing guarantees. Interfaces in languages without structural typing force you to design a rigid, unflexible hierarchy/ontology well in advance, which only gets in your way when requirements change.
>is "contained" (i.e. not overly used)
Can you show why "containment" is necessary and why "overuse" is a bad thing?
We have a lot of Go services in production and errors have never been a performance issue. In the happy path, when there're no errors, errors are basically no-op. We don't use errors for control flow, though; only for exceptional situations, which aren't triggered often. If we want to branch based on an error, we use errors.Is. I don't remember ever having to inspect an error's string, that sounds like a hack. Usually, branching on an error's type is a rare scenario, even if it uses reflection, you usually just bubble up the error. In practice, at runtime, Go's error handling is just a bunch of TEST RAX, RAX instructions. Do you have benchmarks to show otherwise?
* Backwards compatibility is a priority so you can be pretty sure code you wrote yesterday will work tomorrow
More Cons:
* Unused variables and imports are a compilation error which is INCREDIBLY annoying during development (number one frustration with the language)
* If you work with a team or a legacy project, you will almost certainly encounter panics from a nil dereference at some point (or in my experience basically all production bugs were the result of one)
* If you work with a legacy project, early lack of generics encouraged copy/paste spaghetti piles
* Due to early lack of real generics, many popular libraries (such as ent) used codegen to make generic behavior possible. Generated code balloons PRs (unless you take care to quarantine them to their own commit and share commit ranges for diffs), and gets in the way of understanding code you care about.
>Unused variables and imports are a compilation error which is INCREDIBLY annoying during development
It's easily solved with:
_ = unused_variable
_ "unused_import"
Sure, it's annoying, but not in an incredible way :) Before I knew about this trick, I used to temporarily delete or comment out all related code, which is indeed incredibly annoying.
I know about it. Still incredibly annoying. I shouldn't need to do anything, it should just be completely ignored unless I run a linter or compiler in pedantic mode.
Unused variables are an indication that the author hasn't completed their thought, so to speak, in the best case. In the worst case, it's a mistake and indicates the code is likely implemented in a way that it does something other than what it intended. I think making it a compiler error is the right way to do it. Other languages should adopt it.
The thing about software in development is that it isn't complete, practically by definition. I don't litter my code with unused imports and variables, I just have some stuff hanging out below while fixing it above. This is what linters are for, and unused variables and imports weren't the thing making software unmaintainable. Could even have a compiler flag that errors on unused for prod builds. There are a bunch of ways to skin a cat.
> Go's handling of dependencies...leaves something to be desired
Can you give specifics? I think publishing module versions as entire subtrees is very verbose and can be cumbersome but otherwise enjoy everything about Go modules. I find most peoples complaints are that its not like <npm|pip|maven>
That it's not like npm, pip, or maven is a plus in my view: I despise all three of them.
I don't like the proxy system at all. It is not, in fact, easy to set up a private proxy that "just works." Also, it is quite trivial to have "sync" issues with the go.mod and go.sum files that manage/"lock" dependencies. `go mod tidy`, `clean -modcache`, etc., are required far too often. And it has the same problem with dependencies-of-dependencies that python does. It leads to bloat and sometimes inconsistent behaviors in applications.
> And it has the same problem with dependencies-of-dependencies that python does. It leads to bloat and sometimes inconsistent behaviors in applications.
I haven't ran into that first hand. Is there a dependency management tool that prevents this? Cargo? (I don't have much experience with Rust yet FWIW)
I don't have a ton of go experience but it seems like there isn't a way to declare tool/binary dependencies that aren't imported anywhere. We ran into this problem for codegen libraries.
I recommend you consider Rust if you can. I'm not going to lie: it's definitely harder to learn than Go, but the benefits are immense as Rust is serious about C-level performance and encompassing the ample capabilities of C++ while bringing some very commendable goals (ie safety) into the binary-compiled language arena.
And believe me: once you pass the first slope of learning (borrowing, lifecycles...), everything clicks just right and writing code becomes a very delightful experience, similar to what you do with [pick a trendy dynamic language of your choice].
And I think it's safe to say that Rust in 2023 can be considered mature and non-niche.
If you'd like sheer enthusiasm to help you make your mind, I recommend this guy's YT series on Rust: