Skip to main content

๐Ÿ” iter vs samber/ro

Go's iter package (introduced in Go 1.23) and samber/ro both provide ways to work with sequences of values, but they serve different purposes and follow different paradigms:

  • Go iter package: Pull-based - the consumer controls when to pull values
  • samber/ro: Push-based - the producer pushes values to consumers

This comparison explores the fundamental differences between Go's standard iteration and reactive streams.

Key Differencesโ€‹

Core Distinctions

Concurrency Modelโ€‹

  • iter: Synchronous by design
  • ro: Asynchronous and concurrent by nature

Data Flowโ€‹

  • iter: Sequential, one-time iteration
  • ro: Continuous streams with multiple subscribers

Error Handlingโ€‹

  • iter: None
  • ro: Error propagation, retry

The fundamental difference is in the data flow model - pull vs push - which affects everything from concurrency to error handling.

Code Comparisonโ€‹

Basic Iterationโ€‹

Go iter:

:::

The consumer controls when values are pulled using the standard range keyword.

package main

import (
"fmt"
"iter"
)

func main() {
// Define a sequence using iter.Seq
numbers := func(yield func(int) bool) {
for i := 1; i <= 5; i++ {
if !yield(i) {
break
}
}
}

// Pull values using range
for n := range numbers {
fmt.Println(n) // 1, 2, 3, 4, 5
}
}
Push-based Streams

samber/ro:

package main

import (
"fmt"
"github.com/samber/ro"
)

func main() {
// Create an observable stream
observable := ro.Just(1, 2, 3, 4, 5)

// Subscribe to receive pushed values
observable.Subscribe(ro.OnNext(func(n int) {
fmt.Println(n) // 1, 2, 3, 4, 5
}))
}

Values are pushed to subscribers automatically, creating a reactive flow.

Transformationsโ€‹

Manual Transformations

Go iter:

With iter, you must implement transformation functions manually.

// Map function for iter
func Map[V, W any](seq iter.Seq[V], f func(V) W) iter.Seq[W] {
return func(yield func(W) bool) {
for v := range seq {
if !yield(f(v)) {
break
}
}
}
}

// Filter function for iter
func Filter[V any](seq iter.Seq[V], f func(V) bool) iter.Seq[V] {
return func(yield func(V) bool) {
for v := range seq {
if f(v) && !yield(v) {
break
}
}
}
}

// Usage
numbers := func(yield func(int) bool) {
for i := 0; i < 10; i++ {
if !yield(i) {
return
}
}
}

evens := Map(Filter(numbers, func(n int) bool {
return n%2 == 0
}), func(n int) string {
return fmt.Sprintf("even-%d", n)
})

for result := range evens {
fmt.Println(result)
}

samber/ro:

// Built-in operators
observable := ro.Pipe2(
ro.Range(0, 10),
ro.Filter(func(n int) bool {
return n%2 == 0
}),
ro.Map(func(n int) string {
return fmt.Sprintf("even-%d", n)
}),
)

observable.Subscribe(ro.OnNext(func(result string) {
fmt.Println(result)
}))

:::

samber/ro provides a rich set of built-in operators for common transformations.

Async Operationsโ€‹

Synchronous Limitation

Go iter (synchronous only):

The iter package is designed for synchronous operations only.

// iter doesn't support async operations natively
func processData(data []int) iter.Seq[string] {
return func(yield func(string) bool) {
for _, item := range data {
// This blocks the entire iteration
result := expensiveSyncOperation(item)
if !yield(result) {
break
}
}
}
}

// Blocking iteration
for result := range processData([]int{1, 2, 3}) {
fmt.Println(result)
}
Async Native

samber/ro (asynchronous by default):

Reactive programming is inherently asynchronous, perfect for real-time applications.

var pipeline = ro.PipeOp2(
ro.Map(expensiveAsyncOperation),
ro.RetryWithConfig(RetryConfig{MaxRetries: 3}),
)

func main() {
observable := pipeline(ro.Just(1, 2, 3))

// Non-blocking subscription
_ = observable.Subscribe(ro.OnNext(func(result string) {
fmt.Println(result)
}))
}

Multiple Consumersโ€‹

Single Consumer

Go iter (single consumer):

Each iteration consumes the sequence, making it difficult to share data streams.

func generateNumbers() iter.Seq[int] {
return func(yield func(int) bool) {
for i := 1; i <= 5; i++ {
if !yield(i) {
return
}
}
}
}

// Each iteration consumes the sequence
seq := generateNumbers()

// First consumer
for n := range seq {
fmt.Println("Consumer 1:", n)
}

// Second consumer
for n := range seq {
fmt.Println("Consumer 2:", n)
}

// Both subscribers receive: 1, 2, 3, 4, 5

samber/ro (multiple subscribers):

:::

Multiple subscribers can receive the same data stream simultaneously.

// Hot observable - multiple subscribers get all values
observable := ro.Just(1, 2, 3, 4, 5)

// Multiple subscribers
observable.Subscribe(ro.OnNext(func(n int) {
fmt.Println("Subscriber 1:", n)
}))

observable.Subscribe(ro.OnNext(func(n int) {
fmt.Println("Subscriber 2:", n)
}))

// Both subscribers receive: 1, 2, 3, 4, 5

Advanced Featuresโ€‹

Error Handlingโ€‹

No Error Handling

Go iter:

Error handling is not built into the iter paradigm and requires manual intervention.

func riskyOperation() iter.Seq[int] {
return func(yield func(int) bool) {
for i := 1; i <= 5; i++ {
if i == 3 {
// Can't easily propagate errors through yield
panic("error")
}
if !yield(i) {
return
}
}
}
}
Built-in Error Handling

samber/ro:

func createRiskyStream() ro.Observable[int] {
return ro.Pipe2(
ro.Range(1, 6),
ro.MapErr(func(i int) (int, error) {
if i == 3 {
return 0, fmt.Errorf("error at %d", i)
}
return i, nil
}),
)
}

// Built-in error handling
createRiskyStream().Subscribe(ro.Observer[int]{
OnNext: func(n int) {
fmt.Println("Received:", n)
},
OnError: func(err error) {
fmt.Println("Error:", err) // Handles error at 3
},
})

Reactive streams have first-class support for error propagation and recovery.

Time-based Operationsโ€‹

Manual Implementation

Go iter (no built-in time operations):

Time-based operations require manual implementation with channels and goroutines.

// Manual time-based operations are complex and non-idiomatic
func timedSequence() iter.Seq[int] {
return func(yield func(int) bool) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()

counter := 0
for {
select {
case <-ticker.C:
counter++
if !yield(counter) {
return
}
}
}
}
}

samber/ro (native time operators):

:::

Built-in time operators make it easy to work with temporal data streams.

// Built-in time operations
observable := ro.Pipe3(
ro.Interval(time.Second),
ro.Take(42),
ro.Map(func(tick int) string {
return fmt.Sprintf("tick-%d", tick)
}),
)

observable.Subscribe(ro.OnNext(func(msg string) {
fmt.Println(msg) // "tick-1", "tick-2", etc. every second
}))

When to Use Whichโ€‹

Decision Guide

Use Go iter when:โ€‹

  • Working with synchronous sequences
  • Need standard library compatibility
  • Writing simple iteration logic
  • Memory efficiency is critical
  • No need for complex async operations

Use samber/ro when:โ€‹

  • Handling real-time events
  • Need async processing
  • Building reactive applications
  • Multiple subscribers required
  • Complex error handling needed

Consider your specific requirements for synchronicity, error handling, and concurrency when choosing between these approaches.

Performance Characteristicsโ€‹

Performance Considerations
AspectGo itersamber/ro
Memory UsageLow (lazy producing)Low (lazy producing)
LatencyZeromedium (small overhead)
CPU UsagePredictablePredictable
ConcurrencyNoneBuilt-in
BackpressureManualAutomatic

Both approaches offer lazy evaluation, but ro provides built-in concurrency and backpressure management.

Feature Comparisonโ€‹

Feature Matrix
FeatureGo itersamber/ro
Pull-based Iterationโœ…โŒ
Push-based StreamsโŒโœ…
Async ProcessingโŒโœ…
Error HandlingManualBuilt-in
Time OperationsโŒโœ…
Multiple SubscribersโŒโœ…
BackpressureโŒโœ…
Standard Libraryโœ…โŒ
Zero Dependenciesโœ…โŒ

Choose iter for standard library integration and simple iteration, or ro for reactive programming capabilities.

Migration Examplesโ€‹

Migration Guide

From iter to roโ€‹

Before (iter):

Converting from iter to ro typically involves replacing pull-based iteration with push-based streams.

func processItems(items []string) iter.Seq[string] {
return func(yield func(string) bool) {
for _, item := range items {
processed := strings.ToUpper(item)
if !yield(processed) {
return
}
}
}
}

for result := range processItems([]string{"a", "b", "c"}) {
fmt.Println(result)
}
Reactive Approach

After (ro):

Notice how the reactive approach simplifies the code and provides more flexibility.

func processItems(items []string) ro.Observable[string] {
return ro.Pipe2(
ro.Just(items...),
ro.Map(func(item string) string {
return strings.ToUpper(item)
}),
)
}

processItems([]string{"a", "b", "c"}).Subscribe(ro.OnNext(func(result string) {
fmt.Println(result)
}))

Go's iter package is excellent for synchronous iteration and sequences, while samber/ro provides powerful reactive capabilities for asynchronous, event-driven programming. Choose based on your specific use case and requirements.

Learn More