Go Concurrency Patterns Series: ← Graceful Shutdown | Series Overview | Distributed Tracing →
What Are Go Generics?
Go 1.18 introduced generics (type parameters), enabling type-safe, reusable code without sacrificing performance. This opens up new possibilities for implementing classic design patterns with compile-time type safety.
Key Features:
- Type Parameters: Functions and types that work with any type
- Constraints: Restrict type parameters to specific interfaces
- Type Inference: Compiler deduces type arguments automatically
- Zero Runtime Cost: No boxing/unboxing like interface{}
Real-World Use Cases
- Collections: Type-safe lists, maps, sets without reflection
- Algorithms: Generic sort, filter, map operations
- Data Structures: Stacks, queues, trees with any element type
- Caching: Generic cache implementations
- Functional Patterns: Map, filter, reduce with type safety
- Concurrent Patterns: Type-safe worker pools and pipelines
Generic Data Structures
Generic Stack
package main
import (
"fmt"
"sync"
)
// Stack is a generic LIFO data structure
type Stack[T any] struct {
items []T
mu sync.RWMutex
}
func NewStack[T any]() *Stack[T] {
return &Stack[T]{
items: make([]T, 0),
}
}
func (s *Stack[T]) Push(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.items = append(s.items, item)
}
func (s *Stack[T]) Pop() (T, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
var zero T
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}
func (s *Stack[T]) Peek() (T, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.items) == 0 {
var zero T
return zero, false
}
return s.items[len(s.items)-1], true
}
func (s *Stack[T]) Size() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.items)
}
func main() {
// Integer stack
intStack := NewStack[int]()
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
if val, ok := intStack.Pop(); ok {
fmt.Printf("Popped: %d\n", val) // 3
}
// String stack
strStack := NewStack[string]()
strStack.Push("hello")
strStack.Push("world")
if val, ok := strStack.Peek(); ok {
fmt.Printf("Peek: %s\n", val) // world
}
}
Generic Queue
package main
import (
"fmt"
"sync"
)
// Queue is a generic FIFO data structure
type Queue[T any] struct {
items []T
mu sync.RWMutex
}
func NewQueue[T any]() *Queue[T] {
return &Queue[T]{
items: make([]T, 0),
}
}
func (q *Queue[T]) Enqueue(item T) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
}
func (q *Queue[T]) Dequeue() (T, bool) {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.items) == 0 {
var zero T
return zero, false
}
item := q.items[0]
q.items = q.items[1:]
return item, true
}
func (q *Queue[T]) IsEmpty() bool {
q.mu.RLock()
defer q.mu.RUnlock()
return len(q.items) == 0
}
func main() {
queue := NewQueue[string]()
queue.Enqueue("first")
queue.Enqueue("second")
if item, ok := queue.Dequeue(); ok {
fmt.Println(item) // first
}
}
Generic Set
package main
import (
"fmt"
"sync"
)
// Set is a generic collection of unique elements
type Set[T comparable] struct {
items map[T]struct{}
mu sync.RWMutex
}
func NewSet[T comparable]() *Set[T] {
return &Set[T]{
items: make(map[T]struct{}),
}
}
func (s *Set[T]) Add(item T) {
s.mu.Lock()
defer s.mu.Unlock()
s.items[item] = struct{}{}
}
func (s *Set[T]) Remove(item T) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.items, item)
}
func (s *Set[T]) Contains(item T) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, exists := s.items[item]
return exists
}
func (s *Set[T]) Size() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.items)
}
func (s *Set[T]) Items() []T {
s.mu.RLock()
defer s.mu.RUnlock()
items := make([]T, 0, len(s.items))
for item := range s.items {
items = append(items, item)
}
return items
}
// Union returns a new set with elements from both sets
func (s *Set[T]) Union(other *Set[T]) *Set[T] {
result := NewSet[T]()
s.mu.RLock()
for item := range s.items {
result.Add(item)
}
s.mu.RUnlock()
other.mu.RLock()
for item := range other.items {
result.Add(item)
}
other.mu.RUnlock()
return result
}
// Intersection returns a new set with common elements
func (s *Set[T]) Intersection(other *Set[T]) *Set[T] {
result := NewSet[T]()
s.mu.RLock()
defer s.mu.RUnlock()
other.mu.RLock()
defer other.mu.RUnlock()
for item := range s.items {
if _, exists := other.items[item]; exists {
result.Add(item)
}
}
return result
}
func main() {
set1 := NewSet[int]()
set1.Add(1)
set1.Add(2)
set1.Add(3)
set2 := NewSet[int]()
set2.Add(2)
set2.Add(3)
set2.Add(4)
union := set1.Union(set2)
fmt.Println("Union:", union.Items()) // [1 2 3 4]
intersection := set1.Intersection(set2)
fmt.Println("Intersection:", intersection.Items()) // [2 3]
}
Generic Cache Pattern
package main
import (
"fmt"
"sync"
"time"
)
// CacheItem holds cached value with expiration
type CacheItem[V any] struct {
Value V
Expiration time.Time
}
// Cache is a generic thread-safe cache with expiration
type Cache[K comparable, V any] struct {
items map[K]CacheItem[V]
mu sync.RWMutex
ttl time.Duration
}
func NewCache[K comparable, V any](ttl time.Duration) *Cache[K, V] {
cache := &Cache[K, V]{
items: make(map[K]CacheItem[V]),
ttl: ttl,
}
// Start cleanup goroutine
go cache.cleanup()
return cache
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = CacheItem[V]{
Value: value,
Expiration: time.Now().Add(c.ttl),
}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, exists := c.items[key]
if !exists {
var zero V
return zero, false
}
// Check expiration
if time.Now().After(item.Expiration) {
var zero V
return zero, false
}
return item.Value, true
}
func (c *Cache[K, V]) Delete(key K) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.items, key)
}
func (c *Cache[K, V]) cleanup() {
ticker := time.NewTicker(c.ttl)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for key, item := range c.items {
if now.After(item.Expiration) {
delete(c.items, key)
}
}
c.mu.Unlock()
}
}
func main() {
// String -> User cache
type User struct {
ID int
Name string
}
cache := NewCache[string, User](5 * time.Second)
cache.Set("user1", User{ID: 1, Name: "Alice"})
cache.Set("user2", User{ID: 2, Name: "Bob"})
if user, ok := cache.Get("user1"); ok {
fmt.Printf("Found: %+v\n", user)
}
// Wait for expiration
time.Sleep(6 * time.Second)
if _, ok := cache.Get("user1"); !ok {
fmt.Println("Cache expired")
}
}
Generic Repository Pattern
package main
import (
"errors"
"fmt"
"sync"
)
// Entity is a constraint for types with an ID
type Entity interface {
GetID() string
}
// User implements Entity
type User struct {
ID string
Name string
Email string
}
func (u User) GetID() string {
return u.ID
}
// Product implements Entity
type Product struct {
ID string
Name string
Price float64
}
func (p Product) GetID() string {
return p.ID
}
// Repository is a generic CRUD interface
type Repository[T Entity] interface {
Create(item T) error
Read(id string) (T, error)
Update(item T) error
Delete(id string) error
List() []T
}
// InMemoryRepository is a generic in-memory implementation
type InMemoryRepository[T Entity] struct {
items map[string]T
mu sync.RWMutex
}
func NewInMemoryRepository[T Entity]() *InMemoryRepository[T] {
return &InMemoryRepository[T]{
items: make(map[string]T),
}
}
func (r *InMemoryRepository[T]) Create(item T) error {
r.mu.Lock()
defer r.mu.Unlock()
id := item.GetID()
if _, exists := r.items[id]; exists {
return errors.New("item already exists")
}
r.items[id] = item
return nil
}
func (r *InMemoryRepository[T]) Read(id string) (T, error) {
r.mu.RLock()
defer r.mu.RUnlock()
item, exists := r.items[id]
if !exists {
var zero T
return zero, errors.New("item not found")
}
return item, nil
}
func (r *InMemoryRepository[T]) Update(item T) error {
r.mu.Lock()
defer r.mu.Unlock()
id := item.GetID()
if _, exists := r.items[id]; !exists {
return errors.New("item not found")
}
r.items[id] = item
return nil
}
func (r *InMemoryRepository[T]) Delete(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.items[id]; !exists {
return errors.New("item not found")
}
delete(r.items, id)
return nil
}
func (r *InMemoryRepository[T]) List() []T {
r.mu.RLock()
defer r.mu.RUnlock()
items := make([]T, 0, len(r.items))
for _, item := range r.items {
items = append(items, item)
}
return items
}
func main() {
// User repository
userRepo := NewInMemoryRepository[User]()
userRepo.Create(User{ID: "1", Name: "Alice", Email: "[email protected]"})
userRepo.Create(User{ID: "2", Name: "Bob", Email: "[email protected]"})
if user, err := userRepo.Read("1"); err == nil {
fmt.Printf("User: %+v\n", user)
}
// Product repository
productRepo := NewInMemoryRepository[Product]()
productRepo.Create(Product{ID: "p1", Name: "Laptop", Price: 999.99})
productRepo.Create(Product{ID: "p2", Name: "Mouse", Price: 29.99})
products := productRepo.List()
fmt.Printf("Products: %+v\n", products)
}
Generic Builder Pattern
package main
import "fmt"
// Builder is a generic builder pattern
type Builder[T any] struct {
build func() T
}
func NewBuilder[T any](buildFunc func() T) *Builder[T] {
return &Builder[T]{build: buildFunc}
}
func (b *Builder[T]) Build() T {
return b.build()
}
// Fluent builder for complex types
type HTTPRequest struct {
Method string
URL string
Headers map[string]string
Body string
}
type HTTPRequestBuilder struct {
req HTTPRequest
}
func NewHTTPRequestBuilder() *HTTPRequestBuilder {
return &HTTPRequestBuilder{
req: HTTPRequest{
Headers: make(map[string]string),
},
}
}
func (b *HTTPRequestBuilder) Method(method string) *HTTPRequestBuilder {
b.req.Method = method
return b
}
func (b *HTTPRequestBuilder) URL(url string) *HTTPRequestBuilder {
b.req.URL = url
return b
}
func (b *HTTPRequestBuilder) Header(key, value string) *HTTPRequestBuilder {
b.req.Headers[key] = value
return b
}
func (b *HTTPRequestBuilder) Body(body string) *HTTPRequestBuilder {
b.req.Body = body
return b
}
func (b *HTTPRequestBuilder) Build() HTTPRequest {
return b.req
}
// Generic fluent builder
type FluentBuilder[T any] struct {
value T
}
func NewFluentBuilder[T any](initial T) *FluentBuilder[T] {
return &FluentBuilder[T]{value: initial}
}
func (b *FluentBuilder[T]) Apply(fn func(T) T) *FluentBuilder[T] {
b.value = fn(b.value)
return b
}
func (b *FluentBuilder[T]) Build() T {
return b.value
}
func main() {
// HTTP Request builder
req := NewHTTPRequestBuilder().
Method("POST").
URL("https://api.example.com/users").
Header("Content-Type", "application/json").
Body(`{"name": "Alice"}`).
Build()
fmt.Printf("Request: %+v\n", req)
// Generic fluent builder
result := NewFluentBuilder(0).
Apply(func(n int) int { return n + 10 }).
Apply(func(n int) int { return n * 2 }).
Apply(func(n int) int { return n - 5 }).
Build()
fmt.Printf("Result: %d\n", result) // 15
}
Generic Option Pattern
package main
import (
"fmt"
"time"
)
// Option is a generic option function
type Option[T any] func(*T)
// Server configuration
type ServerConfig struct {
Host string
Port int
Timeout time.Duration
MaxConns int
ReadTimeout time.Duration
WriteTimeout time.Duration
}
// Option functions
func WithHost[T interface{ Host string }](host string) Option[T] {
return func(c *T) {
c.Host = host
}
}
func WithPort[T interface{ Port int }](port int) Option[T] {
return func(c *T) {
c.Port = port
}
}
func WithTimeout[T interface{ Timeout time.Duration }](timeout time.Duration) Option[T] {
return func(c *T) {
c.Timeout = timeout
}
}
// Generic constructor with options
func NewWithOptions[T any](initial T, opts ...Option[T]) T {
for _, opt := range opts {
opt(&initial)
}
return initial
}
// Server-specific options
func ServerWithHost(host string) Option[ServerConfig] {
return func(c *ServerConfig) {
c.Host = host
}
}
func ServerWithPort(port int) Option[ServerConfig] {
return func(c *ServerConfig) {
c.Port = port
}
}
func ServerWithTimeout(timeout time.Duration) Option[ServerConfig] {
return func(c *ServerConfig) {
c.Timeout = timeout
}
}
func NewServer(opts ...Option[ServerConfig]) ServerConfig {
config := ServerConfig{
Host: "localhost",
Port: 8080,
Timeout: 30 * time.Second,
MaxConns: 100,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
for _, opt := range opts {
opt(&config)
}
return config
}
func main() {
server := NewServer(
ServerWithHost("0.0.0.0"),
ServerWithPort(9000),
ServerWithTimeout(60*time.Second),
)
fmt.Printf("Server config: %+v\n", server)
}
Generic Result Type
package main
import (
"errors"
"fmt"
)
// Result represents either a value or an error
type Result[T any] struct {
value T
err error
}
func Ok[T any](value T) Result[T] {
return Result[T]{value: value}
}
func Err[T any](err error) Result[T] {
var zero T
return Result[T]{value: zero, err: err}
}
func (r Result[T]) IsOk() bool {
return r.err == nil
}
func (r Result[T]) IsErr() bool {
return r.err != nil
}
func (r Result[T]) Unwrap() (T, error) {
return r.value, r.err
}
func (r Result[T]) UnwrapOr(defaultValue T) T {
if r.IsErr() {
return defaultValue
}
return r.value
}
// Map transforms the value if Ok
func (r Result[T]) Map(fn func(T) T) Result[T] {
if r.IsErr() {
return r
}
return Ok(fn(r.value))
}
// FlatMap chains operations
func FlatMap[T any, U any](r Result[T], fn func(T) Result[U]) Result[U] {
if r.IsErr() {
return Err[U](r.err)
}
return fn(r.value)
}
// Example usage
func divide(a, b int) Result[int] {
if b == 0 {
return Err[int](errors.New("division by zero"))
}
return Ok(a / b)
}
func main() {
// Success case
result1 := divide(10, 2)
if value, err := result1.Unwrap(); err == nil {
fmt.Printf("Result: %d\n", value) // 5
}
// Error case
result2 := divide(10, 0)
value := result2.UnwrapOr(-1)
fmt.Printf("Result with default: %d\n", value) // -1
// Chaining operations
result3 := divide(20, 2).
Map(func(n int) int { return n * 2 }).
Map(func(n int) int { return n + 5 })
if value, err := result3.Unwrap(); err == nil {
fmt.Printf("Chained result: %d\n", value) // 25
}
}
Generic Pipeline Pattern
package main
import (
"fmt"
)
// Pipeline represents a chain of transformations
type Pipeline[T any] struct {
stages []func(T) T
}
func NewPipeline[T any]() *Pipeline[T] {
return &Pipeline[T]{
stages: make([]func(T) T, 0),
}
}
func (p *Pipeline[T]) Add(stage func(T) T) *Pipeline[T] {
p.stages = append(p.stages, stage)
return p
}
func (p *Pipeline[T]) Execute(input T) T {
result := input
for _, stage := range p.stages {
result = stage(result)
}
return result
}
// Generic filter, map, reduce
func Filter[T any](items []T, predicate func(T) bool) []T {
result := make([]T, 0)
for _, item := range items {
if predicate(item) {
result = append(result, item)
}
}
return result
}
func Map[T any, U any](items []T, mapper func(T) U) []U {
result := make([]U, len(items))
for i, item := range items {
result[i] = mapper(item)
}
return result
}
func Reduce[T any, U any](items []T, initial U, reducer func(U, T) U) U {
result := initial
for _, item := range items {
result = reducer(result, item)
}
return result
}
func main() {
// Pipeline example
pipeline := NewPipeline[int]().
Add(func(n int) int { return n * 2 }).
Add(func(n int) int { return n + 10 }).
Add(func(n int) int { return n / 2 })
result := pipeline.Execute(5)
fmt.Printf("Pipeline result: %d\n", result) // 10
// Filter, map, reduce
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// Filter even numbers
evens := Filter(numbers, func(n int) bool { return n%2 == 0 })
fmt.Printf("Evens: %v\n", evens)
// Map to squares
squares := Map(evens, func(n int) int { return n * n })
fmt.Printf("Squares: %v\n", squares)
// Reduce to sum
sum := Reduce(squares, 0, func(acc, n int) int { return acc + n })
fmt.Printf("Sum: %d\n", sum)
}
Generic Worker Pool
package main
import (
"context"
"fmt"
"sync"
)
// Task represents a generic task
type Task[T any, R any] struct {
Input T
Result chan R
}
// WorkerPool is a generic worker pool
type WorkerPool[T any, R any] struct {
workers int
tasks chan Task[T, R]
process func(T) R
wg sync.WaitGroup
}
func NewWorkerPool[T any, R any](workers int, process func(T) R) *WorkerPool[T, R] {
return &WorkerPool[T, R]{
workers: workers,
tasks: make(chan Task[T, R], workers*2),
process: process,
}
}
func (wp *WorkerPool[T, R]) Start(ctx context.Context) {
for i := 0; i < wp.workers; i++ {
wp.wg.Add(1)
go wp.worker(ctx)
}
}
func (wp *WorkerPool[T, R]) worker(ctx context.Context) {
defer wp.wg.Done()
for {
select {
case task, ok := <-wp.tasks:
if !ok {
return
}
result := wp.process(task.Input)
task.Result <- result
close(task.Result)
case <-ctx.Done():
return
}
}
}
func (wp *WorkerPool[T, R]) Submit(input T) <-chan R {
resultChan := make(chan R, 1)
task := Task[T, R]{
Input: input,
Result: resultChan,
}
wp.tasks <- task
return resultChan
}
func (wp *WorkerPool[T, R]) Shutdown() {
close(wp.tasks)
wp.wg.Wait()
}
func main() {
// Integer -> String worker pool
pool := NewWorkerPool(3, func(n int) string {
return fmt.Sprintf("Result: %d", n*n)
})
ctx := context.Background()
pool.Start(ctx)
// Submit tasks
results := make([]<-chan string, 0)
for i := 1; i <= 10; i++ {
results = append(results, pool.Submit(i))
}
// Collect results
for i, resultChan := range results {
result := <-resultChan
fmt.Printf("Task %d: %s\n", i+1, result)
}
pool.Shutdown()
}
Best Practices
1. Use Constraints Wisely
// Too restrictive
func Sum[T int | int64](values []T) T { ... }
// Better: Use constraints package
import "golang.org/x/exp/constraints"
func Sum[T constraints.Ordered](values []T) T { ... }
2. Prefer Type Inference
// Explicit type arguments
result := Map[int, string](numbers, toString)
// Better: Let compiler infer
result := Map(numbers, toString)
3. Keep It Simple
// Overly complex
func Process[T any, U any, V any](a T, fn1 func(T) U, fn2 func(U) V) V { ... }
// Better: Multiple simpler functions
func Step1[T, U any](a T, fn func(T) U) U { ... }
func Step2[U, V any](u U, fn func(U) V) V { ... }
Performance Considerations
- Compile Time: Generics increase compile time slightly
- Runtime: Zero runtime cost - monomorphization at compile time
- Code Size: Can increase binary size due to type specialization
- Type Inference: Reduces verbosity but can slow compilation
Conclusion
Go generics enable type-safe, reusable patterns while maintaining Go’s simplicity and performance.
Key Takeaways:
- Use generics for data structures and algorithms
- Constraints provide type safety without sacrificing flexibility
- Generic patterns reduce code duplication
- Type inference makes code cleaner
- Zero runtime cost compared to interface{}
Next, explore Distributed Tracing in Go to learn about OpenTelemetry implementation.
Previous: Graceful Shutdown Patterns Next: Distributed Tracing in Go Series: Go Concurrency Patterns