Like every language, there are plenty of 'problems' with Go. But many of these seem like off-the-cuff complaints instead of thought-out criticisms. To choose an arbitrary example:
nil is sometimes equivalent to an empty collection but sometimes causes a runtime crash.
var a []int // initialized to nil
_ = append(a, 1) // OK
var m map[int]int // initialized to nil
m[0] = 0 // panic: nil dereference
It’s not super clear to me whether there is some systematic rule governing when nil causes a crash when using a built-in operation. Are crashes specific to write contexts and non-crashes specific to read-only contexts? I don’t know.
The author is comparing two different operations. The fact that they're operating on nil variables is irrelevant.
The equivalent slice operation is not the builtin function "append", but rather assignment: a[0] = 1. This will panic in the same manner as m[0] = 0 (albeit with a different panic message)
A map-based operation that's equivalent to "append" would be a (hypothetical) builtin function like "func assign(m map[K]V, key K, value V) map[K]V".
The language could define define that map assignments auto-vivicate the map when necessary. But that would cause each map to incur an additional allocation, even if it's never assigned to. And then for consistency you'd need to apply the same thing to slices.
>A map-based operation that's equivalent to "append" would be a (hypothetical) builtin function like "func assign(m map[K]V, key K, value V) map[K]V".
But why isn't there an `assign` function? I've been writing Go for a long time, and from a design point of view, I really don't like append. It's magic, and as the OP has shown, it's trips up new programmers. If Go was consistent, there shouldn't be an append; but instead we got "generics for this one function". The problem isn't really `nil` that acts confusing (in this case), it's `append` acting like a function when it's really a magic compiler directive.
I personally think this criticism is a problem; zero-values, especially nil ones, can be a major footgun. I've had a couple experiences where a production service crashed because someone accidentally tried to use a nil interface even though they had already nil-checked it.
append is the opposite of magic. it's exposing the details of a realloc when 99% of the time you want to keep the same binding. That's probably also why map doesn't have an equivalent; in the case of hash tables it's more like 99.999%.
You cannot write the append function in pre-go 1.18. That is what I mean by magic. The reason it's given to you because it would be painful to build a function like append for every slice type. That's also why it gets to have blessed nil properties.
Well, you could, using unsafe. But you also couldn't write the index operator (without unsafe), and I don't think anyone would call that "magic". And in either case that has nothing to do with its treatment of nil.
It would be more magic if it somehow updated a hidden pointer, like maps do. (And this has also been my experience teaching Go, people grasp quickly that slice length/cap is immutable but often forget that non-nil maps are mutable and always "by reference".)
Some complaints also seem like a lack of experience using the language. For example
Initialization with make behaves differently for maps and slices:
m := make(map[int]int, 10) // capacity = 10, length = 0
a := make([]int, 10) // capacity = 10, length = 10 (zero initialization)
b := make([]int, 0, 10) // capacity = 10, length = 0
So not only is there an inconsistency, the more common operation has a longer spelling.
In my experience, the two-value (explicit capacity) form of "make" is significantly _less_ common than the single-value form. Indeed, gripping through the stdlib shows "make([]T, n) is much more common than "make([]T, n, m)".
I agree that appending to "make([]T, n)" is not an uncommon mistake. But, in general, you can avoid that problem by assigning to specific indices instead of using append.
I think the place I regularly use "make([]T, 0, n)" is when I'm collecting items from a
s := make([]K, 0, len(m))
for k, v := range m {
s = append(s, k
}
> Some complaints also seem like a lack of experience using the language.
> In my experience, the two-value (explicit capacity) form of "make" is significantly _less_ common than the single-value form. Indeed, gripping through the stdlib shows "make([]T, n) is much more common than "make([]T, n, m)".
I've written a fair bit of C++, where this pattern is very common. IME in 95%+ of the cases, what one wants is a vector with a capacity without initializing it, because it will be filled up right away.
I'd argue that make([]T, n) is more common in actual Go code precisely because it has the shorter spelling, not because it has the exact desired semantics.
I was curious what it looked like where I work, because I mostly encounter `make([]T, 0, n)` in codebases I've touched, so I did a quick grep...
And the results were roughly 6,000 cases of `make([]T, n)` vs 5,000 cases of `make([]T, 0, n)`, ignoring most generated files (afaict), allowing basically anything but `,` for `n`, and requiring `...)$` for regex simplicity. I didn't read all the results, but the couple hundred I did check in both looked reasonable, so it's probably not too inaccurate.
I'm not sure how representative that is of go code in general, but I think I can be reasonably confident in claiming that neither is a consistent preference.
So, my understanding is that: 1. Unless otherwise initialized, types have a zero value in go. For pointer types that zero value is nil. 2. It's encouraged for this to be meaningful. That's opposed to say java, python, or C++ where invoking a method on a null object is a runtime error (or undefined behavior). So for example, appending to a 'nil' slice is the rough equivalent of trying to add to a null List in java.
I don't see how the go approach makes anything simpler.
Value semantics are simply that the value of an object is all that matters. For example, two int objects '5' are the same from a value-semantic perspective. I guess this implies all objects have some value. This requirement can be satisfied by requiring they be explicitly initialized.
Equatability is a requirement of objects in java. Not all types of objects are equatable beyond their identity (some unique identifier) though. For example, how do you equate two functions? To me this parallels the decision in go to make zero values a thing beyond a runtime error.
> I don't see how the go approach makes anything simpler.
The "append" builtin doesn't "invoke" anything on the nil pointer. It's more or less (in Java-ish pseudocode):
static Slice<T> append(Slice<T> s, items ...T) {
if (s == null) {
Slice<T> newSlice = new Slice<T>(items.size())
newSlice.pushBack(items)
return newSlice
}
if s.hasEnoughCapcityFor(items.size()) {
s.pushBack(items)
return s
}
Slice<T> newSlice = new Slice<T>(s.size() + items.size())
newSlice.pushBack(s)
newSlice.pushBack(items)
return newSlice
}
which is a perfectly reasonable utility function in pretty much any language. See, e.g., realloc(3).
> Value semantics are simply that the value of an object is all that matters. For example, two int objects '5' are the same from a value-semantic perspective. I guess this implies all objects have some value.
>
> Equatability is a requirement of objects in java. Not all types of objects are equatable beyond their identity (some unique identifier) though. For example, how do you equate two functions? To me this parallels the decision in go to make zero values a thing beyond a runtime error.
In Go, having a "meaningful" zero value for a type means that you don't need to initialize the type before using it. For example:
var b bytes.Buffer
b.WriteString("hello, world")
instead of
b := bytes.NewBuffer(nil)
b.WriteString("hello, world")
Or, as another example:
type BinarySearchTree struct { ... }
func (b *BinarySearchTree) Contains(key string) bool {
if b == nil {
return false
}
[...]
}
Go makes this possible by not requiring constructors like other languages do (e.g., Java).
You can evoke a method on a nil object in Go because it handles methods as functions that have an extra first parameter. take a look here: https://go.dev/play/p/2SRU26mvfnL
The equivalent slice operation is not the builtin function "append", but rather assignment: a[0] = 1. This will panic in the same manner as m[0] = 0 (albeit with a different panic message)
A map-based operation that's equivalent to "append" would be a (hypothetical) builtin function like "func assign(m map[K]V, key K, value V) map[K]V".
The language could define define that map assignments auto-vivicate the map when necessary. But that would cause each map to incur an additional allocation, even if it's never assigned to. And then for consistency you'd need to apply the same thing to slices.