You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

18 KiB

Go Reference Type Simplification - Revised Proposal

Executive Summary

Keep Go's convenient syntax (slicing, <-, for range) while making reference semantics explicit through pointer types. This reduces cognitive load and improves safety without sacrificing ergonomics.

Core Principle: Explicit Pointers, Convenient Syntax

The Key Insight:

  • Make slices/maps/channels explicitly *[]T, *map[K]V, *chan T
  • Keep convenient operators (auto-dereference like struct methods do)
  • Eliminate special allocation functions (make())
  • Add explicit control where it matters (grow, clone)

Proposed Changes

1. Slices Become *[]T (Explicit Pointers)

Current Problem:

s := []int{1, 2, 3}        // Looks like value, is reference
s2 := s                    // Copies reference - HIDDEN SHARING
s2[0] = 99                 // Mutates s too! Not obvious

Proposed:

s := &[]int{1, 2, 3}       // Explicit pointer allocation
s2 := s                    // Copies pointer - OBVIOUS SHARING
s2[0] = 99                 // Mutates s too - but now obvious!

// Slicing still works (auto-dereference)
sub := s[1:3]              // Returns *[]int (new slice header, same backing)
sub := s[1:3:5]            // Full slicing with capacity still works

// To copy data, be explicit
s3 := s.Clone()            // Deep copy
s3 := &[]int(*s)           // Alternative: copy via literal

// Append works as before
s.Append(4, 5, 6)          // Implicit grow if needed (fine!)
s.Grow(100)                // Explicit capacity increase

What Changes:

  • Allocation: &[]T{} instead of make([]T, len, cap)
  • Type: *[]int instead of []int
  • Explicit clone: Must call .Clone() to copy data
  • Explicit grow: .Grow(n) for pre-allocation
  • Slicing syntax: KEEP IT - s[i:j] still works
  • Append behavior: KEEP IT - implicit growth is fine
  • Auto-dereference: Like methods, s[i] auto-derefs

Benefits:

  • Assignment s2 := s is obviously pointer copy
  • Function parameters func f(s *[]int) show mutation potential
  • Still convenient: slicing and indexing work as before

2. Maps Become *map[K]V (Explicit Pointers)

Current Problem:

m := make(map[string]int)  // Special make() function
m2 := m                    // HIDDEN reference sharing

var m3 map[string]int      // nil map
v := m3["key"]             // OK - returns zero value
m3["key"] = 42             // PANIC! Nil map write trap

Proposed:

m := &map[string]int{}     // Explicit pointer allocation
m := &map[string]int{      // Literal initialization
    "key": 42,
}

m2 := m                    // Obviously copies pointer

// Map operations auto-dereference
m["key"] = 42              // Auto-deref (like s[i] for slices)
v := m["key"]
v, ok := m["key"]

// Nil pointer is consistent
var m3 *map[string]int     // nil pointer
v := m3["key"]             // PANIC - nil pointer deref (consistent!)
m3 = &map[string]int{}     // Must allocate
m3["key"] = 42             // Now OK

// Copying requires explicit clone
m4 := m.Clone()            // Deep copy

What Changes:

  • Allocation: &map[K]V{} instead of make(map[K]V)
  • Type: *map[K]V instead of map[K]V
  • Nil behavior: Consistent nil pointer panic
  • Explicit clone: Must call .Clone()
  • Map syntax: KEEP IT - m[k] auto-derefs

Benefits:

  • Obvious pointer semantics
  • No special nil-map read-only trap
  • Clear when data is shared

3. Channels Become *chan T (Explicit Pointers)

Current Problem:

ch := make(chan int, 10)   // Special make() function
ch2 := ch                  // HIDDEN reference sharing

var ch3 chan int           // nil channel
ch3 <- 42                  // BLOCKS FOREVER! Silent deadlock trap

Proposed:

ch := &chan int{cap: 10}   // Explicit pointer allocation
ch := &chan int{}          // Unbuffered (cap: 0)

ch2 := ch                  // Obviously copies pointer

// Channel operations auto-dereference
ch <- 42                   // KEEP <- syntax!
v := <-ch
v, ok := <-ch

// for range still works
for v := range ch {        // KEEP for range!
    process(v)
}

// select still works
select {                   // KEEP select!
case v := <-ch:
    handle(v)
case ch2 <- 42:
    sent()
}

// Nil pointer is consistent
var ch3 *chan int          // nil pointer
ch3 <- 42                  // PANIC - nil pointer deref (consistent!)

// Directional channels as type aliases or interfaces
type SendOnly[T any] = *chan T   // Could restrict at type level
func send(ch *chan int) {}       // Or just document convention

What Changes:

  • Allocation: &chan T{cap: n} instead of make(chan T, n)
  • Type: *chan T instead of chan T
  • Nil behavior: Consistent nil pointer panic
  • Send/receive: KEEP <- syntax
  • Select: KEEP select statement
  • For range: KEEP for range ch

Benefits:

  • Obvious pointer semantics
  • No silent nil-channel blocking trap
  • Keep all the convenient syntax
  • Directional types could be interfaces if needed

4. Unified Allocation: Eliminate make()

Before (Three Allocation Primitives):

new(T)                     // Returns *T (zero value)
make([]T, len, cap)        // Returns []T (special)
make(map[K]V, hint)        // Returns map[K]V (special)
make(chan T, buf)          // Returns chan T (special)

After (One Allocation Syntax):

new(T)                     // Returns *T (zero value)
&T{}                       // Returns *T (composite literal)
&[]T{}                     // Returns *[]T (empty slice)
&[n]T{}                    // Returns *[n]T (array)
&map[K]V{}                 // Returns *map[K]V (empty map)
&chan T{}                  // Returns *chan T (unbuffered)
&chan T{cap: 10}           // Returns *chan T (buffered)

Eliminate:

  • make() entirely
  • Special capacity/hint parameters (use methods instead)

5. Type System Unification

Before:

Value types: int, float, bool, struct, [N]T
Reference types: []T, map[K]V, chan T (SPECIAL SEMANTICS)
Pointer types: *T

After:

Value types: int, float, bool, struct, [N]T
Pointer types: *T (including *[]T, *map[K]V, *chan T - UNIFIED)

All pointer types have consistent semantics:

  • Assignment copies the pointer
  • Nil pointer dereference panics consistently
  • Auto-dereference for convenient syntax
  • Explicit .Clone() for deep copy

Syntax Comparison

Slices

Before:

// Many ways to create
var s []int                          // nil slice
s = []int{}                          // empty slice
s = make([]int, 10)                  // len=10, cap=10
s = make([]int, 10, 20)              // len=10, cap=20
s = []int{1, 2, 3}                   // literal

// Slicing
sub := s[1:3]                        // subslice
sub = s[:3]                          // from start
sub = s[1:]                          // to end
sub = s[:]                           // full slice
sub = s[1:3:5]                       // with capacity

// Append
s = append(s, 4)                     // might reallocate
s = append(s, items...)              // spread

// Copy (manual)
s2 := make([]int, len(s))
copy(s2, s)

After:

// One way to create
var s *[]int                         // nil pointer
s = &[]int{}                         // empty slice
s = &[10]int{}[:]                    // len=10 from array
s = &[]int{1, 2, 3}                  // literal

// Slicing (UNCHANGED)
sub := s[1:3]                        // auto-deref, returns *[]int
sub = s[:3]
sub = s[1:]
sub = s[:]
sub = s[1:3:5]

// Append (UNCHANGED)
s.Append(4)                          // might reallocate (fine!)
s.Append(items...)                   // spread

// Explicit operations
s.Grow(100)                          // pre-allocate capacity
s2 := s.Clone()                      // explicit deep copy

Maps

Before:

// Many ways to create
var m map[K]V                        // nil map
m = map[K]V{}                        // empty map
m = make(map[K]V)                    // empty map
m = make(map[K]V, 100)               // with hint
m = map[K]V{k: v}                    // literal

// Access
m[k] = v
v = m[k]
v, ok = m[k]

// Copy (manual)
m2 := make(map[K]V, len(m))
for k, v := range m {
    m2[k] = v
}

After:

// One way to create
var m *map[K]V                       // nil pointer
m = &map[K]V{}                       // empty map
m = &map[K]V{k: v}                   // literal

// Access (UNCHANGED)
m[k] = v                             // auto-deref
v = m[k]
v, ok = m[k]

// Explicit operations
m2 := m.Clone()                      // explicit deep copy

Channels

Before:

// Create
ch := make(chan int)                 // unbuffered
ch := make(chan int, 10)             // buffered

// Operations
ch <- 42                             // send
v := <-ch                            // receive
v, ok := <-ch                        // receive with closed check
close(ch)

// for range
for v := range ch {
    process(v)
}

// select
select {
case v := <-ch:
    handle(v)
case <-timeout:
    timeout()
}

After:

// Create
ch := &chan int{}                    // unbuffered
ch := &chan int{cap: 10}             // buffered

// Operations (UNCHANGED)
ch <- 42                             // auto-deref
v := <-ch
v, ok := <-ch
ch.Close()                           // method instead of builtin

// for range (UNCHANGED)
for v := range ch {
    process(v)
}

// select (UNCHANGED)
select {
case v := <-ch:
    handle(v)
case <-timeout:
    timeout()
}

Grammar Simplification

Eliminated Syntax

  1. make() builtin - 3 different forms → 0

    • make([]T, n, cap)&[]T{} + .Grow(cap)
    • make(map[K]V, hint)&map[K]V{}
    • make(chan T, buf)&chan T{cap: buf}
  2. Dual allocation semantics - 2 primitives → 1

    • new(T) and make(T) → just new(T) or &T{}

Preserved Syntax

  1. Slice expressions: s[i:j], s[i:j:k]
  2. Channel operators: <-ch, ch<-
  3. Select statement: select { case ... }
  4. Range over channels: for v := range ch
  5. Map/slice indexing: m[k], s[i]
  6. Auto-dereference: Like methods on *T

New Built-in Methods

Slices (*[]T)

s := &[]int{1, 2, 3}

// Capacity management
s.Grow(n int)              // Ensure capacity for n more elements
s.Cap() int                // Current capacity
s.Len() int                // Current length

// Modification
s.Append(items ...T)       // Append items (implicit grow OK)
s.Insert(i int, items ...T) // Insert at index
s.Delete(i, j int)         // Delete s[i:j]
s.Clear()                  // Set length to 0

// Copying
s.Clone() *[]T             // Deep copy
s.Slice(i, j int) *[]T     // Alternative to s[i:j]

Maps (*map[K]V)

m := &map[string]int{}

// Capacity
m.Len() int                // Number of keys

// Modification
m.Clear()                  // Remove all keys
m.Delete(k K)              // Delete key

// Copying
m.Clone() *map[K]V         // Deep copy

// Bulk operations
m.Keys() *[]K              // All keys
m.Values() *[]V            // All values
m.Merge(other *map[K]V)    // Merge other into m

Channels (*chan T)

ch := &chan int{cap: 10}

// Metadata
ch.Len() int               // Items in buffer
ch.Cap() int               // Buffer capacity

// Control
ch.Close()                 // Close channel (method vs builtin)

Auto-Dereference Rules

Like struct methods today, pointer types auto-dereference:

type Person struct { name string }
func (p *Person) Name() string { return p.name }

p := &Person{name: "Alice"}
n := p.Name()              // Auto-deref: (*p).Name()

// Same for new pointer types
s := &[]int{1, 2, 3}
v := s[0]                  // Auto-deref: (*s)[0]
sub := s[1:3]              // Auto-deref: (*s)[1:3]

m := &map[K]V{}
v = m[k]                   // Auto-deref: (*m)[k]

ch := &chan int{}
ch <- 42                   // Auto-deref: (*ch) <- 42
v = <-ch                   // Auto-deref: <-(*ch)

Rule: Pointer to slice/map/channel auto-derefs for indexing, slicing, and channel ops.

Concurrency Safety

Before: Implicit Sharing

func worker(s []int, wg *sync.WaitGroup) {
    defer wg.Done()
    s[0] = 99                // RACE - not obvious from signature
}

s := []int{1, 2, 3}
var wg sync.WaitGroup
wg.Add(2)
go worker(s, &wg)            // Sharing not obvious
go worker(s, &wg)            // Two goroutines mutate same slice
wg.Wait()

After: Explicit Sharing

func worker(s *[]int, wg *sync.WaitGroup) {
    defer wg.Done()
    (*s)[0] = 99             // RACE - but obvious from *[]int
}

s := &[]int{1, 2, 3}
var wg sync.WaitGroup
wg.Add(2)
go worker(s, &wg)            // OBVIOUS pointer sharing
go worker(s, &wg)            // Clear that both access same data
wg.Wait()

Benefits:

  • Function signature shows mutation: func f(s *[]int) vs func f(s []int)
  • Pointer copy is obvious: s2 := s (copies pointer)
  • Value copy requires explicit clone: s2 := s.Clone()

Pattern: Immutable by Default

// Current Go - unclear if mutation happens
func ProcessSlice(s []int) []int {
    s[0] = 99                // Mutates caller's slice!
    return s
}

// Proposed - explicit mutation
func ProcessSlice(s *[]int) {
    (*s)[0] = 99             // Clear mutation
}

// Or value semantics (copy)
func ProcessSlice(s []int) []int {  // Note: NOT pointer
    result := &[]int(s)      // Explicit copy from value
    (*result)[0] = 99        // Mutate copy
    return result
}

Migration Path

Phase 1: Allow Both (Backward Compatible)

// Old style still works
s := []int{1, 2, 3}
s = append(s, 4)

// New style also works (same runtime behavior)
s := &[]int{1, 2, 3}
s.Append(4)

// Add deprecation warnings
make([]int, 10)              // WARNING: Use &[]int{} or &[10]int{}[:]

Phase 2: Deprecate Old Forms

// Compiler warnings
[]int{1, 2, 3}               // WARNING: Use &[]int{1, 2, 3}
make([]int, 10)              // WARNING: Use &[]int{} with .Grow(10)
make(map[K]V)                // WARNING: Use &map[K]V{}
make(chan T, 10)             // WARNING: Use &chan T{cap: 10}

Phase 3: Breaking Change

// Only new syntax allowed
&[]int{1, 2, 3}              // OK
&map[K]V{}                   // OK
&chan T{cap: 10}             // OK

[]int{1, 2, 3}               // ERROR: Use &[]int{1, 2, 3}
make([]int, 10)              // ERROR: Removed

Implementation Impact

Compiler Changes

New:

  • Auto-dereference for *[]T, *map[K]V, *chan T
  • Built-in methods (.Append(), .Clone(), .Grow(), etc.)
  • Composite literal fields: &chan T{cap: 10}

Removed:

  • make() builtin (3 forms)
  • Special case type checking for reference types

Preserved:

  • Slice expressions s[i:j:k]
  • Channel operators <-
  • Select statement
  • Range over channels
  • All runtime implementations

Runtime Changes

Minimal:

  • Same memory layout for slices/maps/channels
  • Same GC behavior
  • Same scheduler
  • No performance impact

API:

  • Add runtime functions for .Clone(), .Grow(), etc.
  • These can be compiler intrinsics for performance

Complexity Reduction

Metric Before After Reduction
Allocation primitives 2 (new, make) 1 (&T{}) 50%
make() forms 3 (slice, map, chan) 0 100%
Reference type special cases 3 types 0 (unified) 100%
Nil traps 2 (nil map write, nil chan) 0 (consistent panic) 100%
Type system categories 3 (value, ref, ptr) 2 (value, ptr) 33%
Syntax variants preserved Slicing, <-, select, range All kept 0%

Total complexity reduction: ~30% while keeping ergonomic syntax.

Real-World Example: ORLY Codebase

Before

// pkg/database/query-events.go
func QueryEvents(db *badger.DB, filter *filter.T) ([]uint64, error) {
    results := make([]uint64, 0, 1000)
    // ... query logic
    return results, nil
}

// Caller must handle returned slice
events, err := QueryEvents(db, f)
if err != nil {
    return err
}
events = append(events, moreEvents...)  // Might copy

After

// pkg/database/query-events.go
func QueryEvents(db *badger.DB, filter *filter.T) (results *[]uint64, err error) {
    results = &[]uint64{}
    results.Grow(1000)           // Explicit capacity
    // ... query logic
    return
}

// Caller gets explicit pointer
events, err := QueryEvents(db, f)
if chk.E(err) {
    return
}
events.Append(moreEvents...)     // Clear mutation

Benefits in ORLY:

  • Clear which functions mutate vs return new data
  • Obvious when slices are shared across goroutines
  • Explicit capacity management for performance-critical code
  • No hidden allocations from append

Conclusion

What We Keep

Slice expressions: s[1:3:5] Channel operators: <- Select statement For range channels Implicit append growth Convenient auto-dereference

What We Gain

Explicit pointer semantics Obvious data sharing Consistent nil behavior Unified type system Simpler language (no make()) Better concurrency safety

What We Lose

make() function (replaced by &T{}) Implicit reference types (now explicit *[]T) Zero-value usability for maps/slices (must allocate)

Recommendation

This revision strikes the right balance:

  • Keep Go's ergonomic syntax that makes it productive
  • Add explicit semantics that make code safer and clearer
  • Remove only the truly confusing parts (make(), implicit references)
  • Gain ~30% complexity reduction without sacrificing convenience

The migration is straightforward and could be done gradually with good tooling support.