Description
Proposal Details
Problem Statement
When using a for-range loop to iterate over a slice of structs, the loop variable holds a copy of each struct.
This can lead to unexpected behavior and hard-to-catch bugs when attempting to modify slice elements within the loop.
The problematic code looks like this:
users := []User{{Name: "Alice", Age: 30}, {Name: "Bob", Age: 25}}
for _, user := range users {
user.Age++ // slice remains unchanged
}
The typical solution for this is explicit indexing. This is fine, but it can become very verbose, especially when multiple fields need modification:
for i := range users {
users[i].Age++
}
Another solution is to take the address of a slice item at the beginning of the loop body:
for i := range users {
user := &users[i]
user.Age++
}
Proposal
Introduce a new function slices.Pointers
that returns an iterator that yields pointers to the slice elements.
This solution provides a clear and concise way to iterate over and modify slice elements in-place, reducing the likelihood of errors related to value semantics in for-range loops.
The code would look like this:
for _, user := range slices.Pointers(users) {
user.Age++ // this now modifies the actual slice element
}
And the implementation:
func Pointers[Slice ~[]E, E any](s Slice) iter.Seq2[int, *E] {
return func(yield func(int, *E) bool) {
for i := range s {
if !yield(i, &s[i]) {
return
}
}
}
}
Maps
A similar problem exists for maps. However, this part of the proposal is more controversial for the following reasons:
- It's much less common to store mutable structs in maps
- It's not possible to take an address of a map entry
From a developer's perspective, the problem is the same as for the slices:
users := map[string]User{
"userA": {Name: "Alice", Age: 30},
"userB": {Name: "Bob", Age: 25},
}
for _, user := range users {
user.Age++ // map remains unchanged
}
The typical solution here is to do a read-modify-write operation:
for k, user := range users {
user.Age++ // map still remains unchanged
users[k] = user // now the change is written
}
The pattern above can be encapsulated in a function maps.Pointers
:
for _, user := range maps.Pointers(users) {
user.Age += 1
}
Such function name is not 100% fair since it yields pointers to temporary variables, rather than map entries, as seen in the implementation below:
func Pointers[Map ~map[K]V, K comparable, V any](m Map) iter.Seq2[K, *V] {
return func(yield func(K, *V) bool) {
for k, v := range m {
done := !yield(k, &v)
m[k] = v
if done {
return
}
}
}
}
Single-key map modifications
This is the most controversial part, since it abuses the iterators feature. For maps like above even single-key modifications has to be made using a read-modify-write operation:
// this does not compile
users["userA"].Age++
// this works, but looks too verbose
u := users["userA"]
u.Age++
users["userA"] = u
The idea is to introduce another function that "iterates" over a single key. Then the code above would look like
for _, user := range maps.Pointer(users, "userA") {
user.Age += 1 // changes age for the key userA
}
Conclusion
This proposal aims to address common pain points in Go programming related to modifying elements in slices. It's aimed at improving readability and reducing errors. The proposed solutions for maps, while addressing real issues, present more complex trade-offs that, I believe, worth discussing.