1
0
mirror of https://github.com/IBM/fp-go.git synced 2025-11-23 22:14:53 +02:00
Files
fp-go/v2/IDIOMATIC_COMPARISON.md
Dr. Carsten Leue 909d626019 fix: serveral performance improvements
Signed-off-by: Dr. Carsten Leue <carsten.leue@de.ibm.com>
2025-11-18 10:58:24 +01:00

25 KiB

Idiomatic vs Standard Package Comparison

Latest Update: 2025-11-18 - Updated with fresh benchmarks after either package optimizations

This document provides a comprehensive comparison between the idiomatic packages and the standard fp-go packages (result and option).

See also: BENCHMARK_COMPARISON.md for detailed performance analysis.

Table of Contents

  1. Overview
  2. Design Differences
  3. Performance Comparison
  4. API Comparison
  5. When to Use Each

Overview

The fp-go library provides two approaches to functional programming patterns in Go:

  • Standard Packages (result, either, option): Use struct wrappers for algebraic data types
  • Idiomatic Packages (idiomatic/result, idiomatic/option): Use native Go tuples for the same patterns

Key Insight

After recent optimizations to the either package, both approaches now offer excellent performance:

  • Simple operations (~1-5 ns/op): Both packages perform comparably
  • Core transformations: Idiomatic is 1.2-2.3x faster
  • Complex operations: Idiomatic is 2-32x faster with significantly fewer allocations
  • Real-world pipelines: Idiomatic shows 2-3.4x speedup

The idiomatic packages provide:

  • Consistently better performance across most operations
  • Zero allocations for complex operations (ChainFirst: 72 B → 0 B)
  • More familiar Go idioms
  • Seamless integration with existing Go code

Design Differences

Data Representation

Standard Result Package

// Uses Either[error, A] which is a struct wrapper
type Result[A any] = Either[error, A]
type Either[E, A any] struct {
    r      A
    l      E
    isLeft bool
}

// Creating values - ZERO heap allocations (struct returned by value)
success := result.Right[error](42)  // Returns Either struct by value (0 B/op)
failure := result.Left[int](err)    // Returns Either struct by value (0 B/op)

// Benchmarks confirm:
// BenchmarkRight-16    871258489    1.384 ns/op    0 B/op    0 allocs/op
// BenchmarkLeft-16     683089270    1.761 ns/op    0 B/op    0 allocs/op

Idiomatic Result Package

// Uses native Go tuples (value, error)
type Kleisli[A, B any]  = func(A) (B, error)
type Operator[A, B any] = func(A, error) (B, error)

// Creating values - ZERO allocations (tuples on stack)
success := result.Right(42)         // Returns (42, nil) - 0 B/op
failure := result.Left[int](err)    // Returns (0, err) - 0 B/op

// Benchmarks confirm:
// BenchmarkRight-16    789879016    1.427 ns/op    0 B/op    0 allocs/op
// BenchmarkLeft-16     895412131    1.349 ns/op    0 B/op    0 allocs/op

Type Signatures

Standard Result

// Functions take and return Result[T] structs
func Map[A, B any](f func(A) B) func(Result[A]) Result[B]
func Chain[A, B any](f Kleisli[A, B]) func(Result[A]) Result[B]
func Fold[A, B any](onLeft func(error) B, onRight func(A) B) func(Result[A]) B

// Usage requires wrapping/unwrapping
result := result.Right[error](42)
mapped := result.Map(double)(result)
value, err := result.UnwrapError(mapped)

Idiomatic Result

// Functions work directly with tuples
func Map[A, B any](f func(A) B) func(A, error) (B, error)
func Chain[A, B any](f Kleisli[A, B]) func(A, error) (B, error)
func Fold[A, B any](onLeft func(error) B, onRight func(A) B) func(A, error) B

// Usage works naturally with Go's error handling
value, err := result.Right(42)
value, err = result.Map(double)(value, err)
// Can use directly: if err != nil { ... }

Memory Layout

Standard Result (struct-based)

Either[error, int] struct (returned by value):
┌─────────────────────┐
│ r: int (8B)         │  Stack allocation: 24 bytes
│ l: error (8B)       │  NO heap allocation when returned by value
│ isLeft: bool (1B)   │  Benchmarks show 0 B/op, 0 allocs/op
│ padding (7B)        │
└─────────────────────┘

Key insight: Go returns small structs (<= ~64 bytes) by value on the stack.
The Either struct (24 bytes) does NOT escape to heap in normal usage.

Idiomatic Result (tuple-based)

(int, error) tuple:
┌─────────────────────┐
│ int: 8 bytes        │  Stack allocation: 16 bytes
│ error: 8 bytes      │  NO heap allocation
└─────────────────────┘

Both approaches achieve zero heap allocations for constructor operations!

Why Both Have Zero Allocations

Both packages avoid heap allocations for simple operations:

Standard Either/Result:

  • Either struct is small (24 bytes)
  • Go returns by value on the stack
  • Inlining eliminates function call overhead
  • Result: 0 B/op, 0 allocs/op

Idiomatic Result:

  • Tuples are native Go multi-value returns
  • Always on stack, never heap
  • Even simpler than structs
  • Result: 0 B/op, 0 allocs/op

When Either WOULD escape to heap:

// Taking address of local Either
func bad1() *Either[error, int] {
    e := Right[error](42)
    return &e  // ESCAPES: pointer to local
}

// Storing in interface
func bad2() interface{} {
    return Right[error](42)  // ESCAPES: interface boxing
}

// Closure capture with pointer receiver
func bad3() func() Either[error, int] {
    e := Right[error](42)
    return func() Either[error, int] {
        return e  // May escape depending on usage
    }
}

In normal functional composition (Map, Chain, Fold), neither package causes heap allocations for simple operations.

Performance Comparison

Latest benchmarks: 2025-11-18 after either package optimizations

For detailed analysis, see BENCHMARK_COMPARISON.md

Quick Summary (Either vs Idiomatic)

Both packages now show excellent performance after optimizations:

Category Either Idiomatic Winner Speedup
Constructors 1.4-1.8 ns/op 1.2-1.4 ns/op TIE ~1.0-1.3x
Predicates 1.5 ns/op 1.3-1.5 ns/op TIE ~1.0x
Map Operations 4.2-7.2 ns/op 2.5-4.3 ns/op Idiomatic 1.2-2.1x
Chain Operations 4.4-5.4 ns/op 2.3-2.5 ns/op Idiomatic 1.8-2.3x
ChainFirst 87.6 ns/op (72 B) 2.7 ns/op (0 B) Idiomatic 32.4x ✓✓✓
BiMap 11.5-16.8 ns/op 3.5-3.8 ns/op Idiomatic 3.3-4.4x
Alt/OrElse 4.0-5.7 ns/op 2.4 ns/op Idiomatic 1.6-2.4x
GetOrElse 6.3-9.0 ns/op 1.5-2.1 ns/op Idiomatic 3.1-6.1x
Pipelines 75-280 ns/op 26-116 ns/op Idiomatic 2.4-3.4x

Constructor Operations

Operation Either (ns/op) Idiomatic (ns/op) Speedup Winner
Left 1.76 1.35 1.3x Idiomatic ✓
Right 1.38 1.43 ~1.0x Tie
Of 1.68 1.22 1.4x Idiomatic ✓

Analysis: After optimizations, both packages have comparable constructor performance.

Core Transformation Operations

Operation Either (ns/op) Idiomatic (ns/op) Speedup Winner
Map (Right) 5.13 4.34 1.2x Idiomatic ✓
Map (Left) 4.19 2.48 1.7x Idiomatic ✓
MapLeft (Right) 3.93 2.22 1.8x Idiomatic ✓
MapLeft (Left) 7.22 3.51 2.1x Idiomatic ✓
Chain (Right) 5.44 2.34 2.3x Idiomatic ✓
Chain (Left) 4.44 2.53 1.8x Idiomatic ✓

Complex Operations - The Big Difference

Operation Either (ns/op) Idiomatic (ns/op) Speedup Either Allocs Idio Allocs
ChainFirst (Right) 87.62 2.71 32.4x ✓✓✓ 72 B, 3 allocs 0 B, 0 allocs
ChainFirst (Left) 3.94 2.48 1.6x 0 B 0 B
BiMap (Right) 16.79 3.82 4.4x 0 B 0 B
BiMap (Left) 11.47 3.47 3.3x 0 B 0 B

Critical Insight: ChainFirst shows the most dramatic difference - 32x faster with zero allocations in idiomatic.

Pipeline Benchmarks (Real-World Scenarios)

Operation Either (ns/op) Idiomatic (ns/op) Speedup Either Allocs Idio Allocs
Pipeline Map (Right) 112.7 46.5 2.4x 72 B, 3 allocs 48 B, 2 allocs
Pipeline Chain (Right) 74.4 26.1 2.9x 48 B, 2 allocs 24 B, 1 alloc
Pipeline Complex (Right) 279.8 116.3 2.4x 192 B, 8 allocs 120 B, 5 allocs

Analysis: In realistic composition scenarios, idiomatic is consistently 2-3x faster with fewer allocations.

Extraction Operations

Operation Either (ns/op) Idiomatic (ns/op) Speedup Winner
GetOrElse (Right) 9.01 1.49 6.1x ✓✓ Idiomatic
GetOrElse (Left) 6.35 2.08 3.1x ✓✓ Idiomatic
Alt (Right) 5.72 2.40 2.4x Idiomatic
Alt (Left) 4.89 2.39 2.0x Idiomatic
Fold (Right) 4.03 2.75 1.5x Idiomatic
Fold (Left) 3.69 2.40 1.5x Idiomatic

Analysis: Idiomatic shows significant advantages (1.5-6x) for value extraction operations.

Key Findings After Optimizations

  1. Both packages are now fast - Simple operations are in the 1-5 ns/op range for both
  2. Idiomatic leads in most operations - 1.2-2.3x faster for common transformations
  3. ChainFirst is the standout - 32x faster with zero allocations in idiomatic
  4. Pipelines favor idiomatic - 2-3.4x faster in realistic composition scenarios
  5. Memory efficiency - Idiomatic consistently uses fewer allocations

Performance Summary

Idiomatic Advantages:

  • Core operations: 1.2-2.3x faster for Map, Chain, Fold
  • Complex operations: 3-32x faster with zero allocations
  • Pipelines: 2-3.4x faster with significantly fewer allocations
  • Extraction: 1.5-6x faster for GetOrElse, Alt, Fold
  • Consistency: Predictable, fast performance across all operations

Either Advantages:

  • Comparable performance: After optimizations, matches idiomatic for simple operations
  • Feature richness: More operations (Do-notation, Bind, Let, Flatten, Swap)
  • Type flexibility: Full Either[E, A] with custom error types
  • Zero allocations: Most simple operations have zero allocations

API Comparison

Creating Values

Standard Result

import "github.com/IBM/fp-go/v2/result"

// Create success/failure
success := result.Right[error](42)
failure := result.Left[int](errors.New("oops"))

// Type annotation required
var r result.Result[int] = result.Right[error](42)

Idiomatic Result

import "github.com/IBM/fp-go/v2/idiomatic/result"

// Create success/failure (more concise)
success := result.Right(42)           // (42, nil)
failure := result.Left[int](errors.New("oops"))  // (0, error)

// Native Go pattern
value, err := result.Right(42)
if err != nil {
    // handle error
}

Transforming Values

Standard Result

// Map transforms the success value
double := result.Map(func(x int) int { return x * 2 })
result := double(result.Right[error](21))  // Right(42)

// Chain sequences operations
validate := result.Chain(func(x int) result.Result[int] {
    if x > 0 {
        return result.Right[error](x * 2)
    }
    return result.Left[int](errors.New("negative"))
})

Idiomatic Result

// Map transforms the success value
double := result.Map(func(x int) int { return x * 2 })
value, err := double(21, nil)  // (42, nil)

// Chain sequences operations
validate := result.Chain(func(x int) (int, error) {
    if x > 0 {
        return x * 2, nil
    }
    return 0, errors.New("negative")
})

Pattern Matching

Standard Result

// Fold extracts the value
output := result.Fold(
    func(err error) string { return "Error: " + err.Error() },
    func(n int) string { return fmt.Sprintf("Value: %d", n) },
)(myResult)

// GetOrElse with default
value := result.GetOrElse(func(err error) int { return 0 })(myResult)

Idiomatic Result

// Fold extracts the value (same API, different input)
output := result.Fold(
    func(err error) string { return "Error: " + err.Error() },
    func(n int) string { return fmt.Sprintf("Value: %d", n) },
)(value, err)

// GetOrElse with default
value := result.GetOrElse(func(err error) int { return 0 })(value, err)

// Or use native Go pattern
if err != nil {
    value = 0
}

Integration with Existing Code

Standard Result

// Converting from (value, error) to Result
func doSomething() (int, error) {
    return 42, nil
}

result := result.TryCatchError(doSomething())

// Converting back to (value, error)
value, err := result.UnwrapError(result)

Idiomatic Result

// Direct compatibility with (value, error)
func doSomething() (int, error) {
    return 42, nil
}

// No conversion needed!
value, err := doSomething()
value, err = result.Map(double)(value, err)

Pipeline Composition

Standard Result

import F "github.com/IBM/fp-go/v2/function"

output := F.Pipe3(
    result.Right[error](10),
    result.Map(double),
    result.Chain(validate),
    result.Map(format),
)

// Need to unwrap at the end
value, err := result.UnwrapError(output)

Idiomatic Result

import F "github.com/IBM/fp-go/v2/function"

value, err := F.Pipe3(
    result.Right(10),
    result.Map(double),
    result.Chain(validate),
    result.Map(format),
)

// Already in (value, error) form
if err != nil {
    // handle error
}

Detailed Design Comparison

Type System

Standard Result

Strengths:

  • Full algebraic data type semantics
  • Explicit Either[E, A] allows custom error types
  • Type-safe by construction
  • Clear separation of error and success channels

Weaknesses:

  • Requires wrapper structs (memory overhead)
  • Less familiar to Go developers
  • Needs conversion functions for Go's standard library
  • More verbose type annotations

Idiomatic Result

Strengths:

  • Native Go idioms (value, error) pattern
  • Zero wrapper overhead
  • Seamless stdlib integration
  • Familiar to all Go developers
  • Terser syntax

Weaknesses:

  • Error type fixed to error
  • Less explicit about Either semantics
  • Cannot use custom error types without conversion
  • Slightly less type-safe (can accidentally ignore bool/error)

Monad Laws

Both packages satisfy the monad laws, but enforce them differently:

Standard Result

// Left identity: return a >>= f  ≡  f a
assert.Equal(
    result.Chain(f)(result.Of(a)),
    f(a),
)

// Right identity: m >>= return  ≡  m
assert.Equal(
    result.Chain(result.Of[int])(m),
    m,
)

// Associativity: (m >>= f) >>= g  ≡  m >>= (\x -> f x >>= g)
assert.Equal(
    result.Chain(g)(result.Chain(f)(m)),
    result.Chain(func(x int) result.Result[int] {
        return result.Chain(g)(f(x))
    })(m),
)

Idiomatic Result

// Same laws, different syntax
// Left identity
a, aerr := result.Of(val)
b, berr := result.Chain(f)(a, aerr)
c, cerr := f(val)
assert.Equal((b, berr), (c, cerr))

// Right identity
value, err := m()
identity := result.Chain(result.Of[int])
assert.Equal(identity(value, err), (value, err))

// Associativity (same structure, tuple-based)

Error Handling Philosophy

Standard Result

// Explicit error handling through types
func processUser(id int) result.Result[User] {
    user := fetchUser(id)  // Returns Result[User]

    return F.Pipe2(
        user,
        result.Chain(validateUser),
        result.Chain(enrichUser),
    )
}

// Must explicitly unwrap
user, err := result.UnwrapError(processUser(42))
if err != nil {
    log.Error(err)
}

Idiomatic Result

// Natural Go error handling
func processUser(id int) (User, error) {
    user, err := fetchUser(id)  // Returns (User, error)

    return F.Pipe2(
        (user, err),
        result.Chain(validateUser),
        result.Chain(enrichUser),
    )
}

// Already in Go form
user, err := processUser(42)
if err != nil {
    log.Error(err)
}

Composition Patterns

Standard Result

// Applicative composition
import A "github.com/IBM/fp-go/v2/apply"

type Config struct {
    Host string
    Port int
    DB   string
}

config := A.SequenceT3(
    result.FromPredicate(validHost, hostError)(host),
    result.FromPredicate(validPort, portError)(port),
    result.FromPredicate(validDB, dbError)(db),
)(func(h string, p int, d string) Config {
    return Config{h, p, d}
})

Idiomatic Result

// Direct tuple composition
config, err := func() (Config, error) {
    host, err := result.FromPredicate(validHost, hostError)(host)
    if err != nil {
        return Config{}, err
    }

    port, err := result.FromPredicate(validPort, portError)(port)
    if err != nil {
        return Config{}, err
    }

    db, err := result.FromPredicate(validDB, dbError)(db)
    if err != nil {
        return Config{}, err
    }

    return Config{host, port, db}, nil
}()

When to Use Each

  1. Performance Matters

    • Any production service (web servers, APIs, microservices)
    • Hot paths and high-throughput scenarios (>1000 req/s)
    • Complex operation chains (32x faster ChainFirst)
    • Real-world pipelines (2-3x faster)
    • Memory-constrained environments (zero allocations)
    • Want 1.2-6x speedup across most operations
  2. Go Integration

    • Working with existing Go codebases
    • Interfacing with standard library (native (value, error))
    • Team familiar with Go, new to FP
    • Want zero-cost functional abstractions
    • Seamless error handling patterns
  3. Pragmatic Functional Programming

    • Value performance AND functional patterns
    • Prefer Go idioms over FP terminology
    • Simpler function signatures
    • Lower cognitive overhead
    • Production-ready patterns
  4. Real-World Applications

    • Web servers, REST APIs, gRPC services
    • CLI tools and command-line applications
    • Data processing pipelines
    • Any latency-sensitive application
    • Systems with tight performance budgets

Performance Gains: Use idiomatic for 1.2-32x speedup depending on operation, with consistently lower allocations.

Use Standard Either/Result When:

  1. Type Safety & Flexibility

    • Need explicit Either[E, A] with custom error types
    • Building domain-specific error hierarchies
    • Want to distinguish different error categories at type level
    • Type system enforcement is critical
  2. Advanced FP Features

    • Using Do-notation for complex monadic compositions
    • Need operations like Flatten, Swap, Bind, Let
    • Leveraging advanced type classes (Semigroup, Monoid)
    • Want the complete FP toolkit
  3. FP Expertise & Education

    • Porting code from other FP languages (Scala, Haskell)
    • Teaching functional programming concepts
    • Team has strong FP background
    • Explicit algebraic data types preferred
    • Code review benefits from FP terminology
  4. Performance is Acceptable

    • After optimizations, Either is quite fast (1-5 ns/op for simple operations)
    • Difference matters mainly at high scale (millions of operations)
    • Code clarity > micro-optimizations
    • Simple operations dominate your workload

Note: Either package is now performant enough for most use cases. Choose it for features, not performance concerns.

Hybrid Approach

You can use both packages together:

import (
    stdResult "github.com/IBM/fp-go/v2/result"
    "github.com/IBM/fp-go/v2/idiomatic/result"
)

// Use standard for complex types
type ValidationError struct {
    Field string
    Error string
}

func validateInput(input string) stdResult.Either[ValidationError, Input] {
    // ... validation logic
}

// Convert to idiomatic for performance
func processInput(input string) (Output, error) {
    validated := validateInput(input)
    value, err := stdResult.UnwrapError(
        stdResult.MapLeft(toError)(validated),
    )

    // Use idiomatic for hot path
    return result.Chain(heavyProcessing)(value, err)
}

Migration Guide

From Standard to Idiomatic

// Before (standard)
import "github.com/IBM/fp-go/v2/result"

func process(x int) result.Result[int] {
    return F.Pipe2(
        result.Right[error](x),
        result.Map(double),
        result.Chain(validate),
    )
}

// After (idiomatic)
import "github.com/IBM/fp-go/v2/idiomatic/result"

func process(x int) (int, error) {
    return F.Pipe2(
        result.Right(x),
        result.Map(double),
        result.Chain(validate),
    )
}

Key Changes

  1. Type signatures: Result[T](T, error)
  2. Kleisli: func(A) Result[B]func(A) (B, error)
  3. Operator: func(Result[A]) Result[B]func(A, error) (B, error)
  4. Return values: Function calls return tuples, not wrapped values
  5. Pattern matching: Same Fold/GetOrElse API, different inputs

Conclusion

Performance Summary (After Either Optimizations)

The latest benchmark results show a clear pattern:

Both packages are now fast, but idiomatic consistently leads:

  • Constructors & Predicates: Both ~1-2 ns/op (essentially tied)
  • Core transformations: Idiomatic 1.2-2.3x faster (Map, Chain, Fold)
  • Complex operations: Idiomatic 3-32x faster (BiMap, ChainFirst)
  • Pipelines: Idiomatic 2-3.4x faster with fewer allocations
  • Extraction: Idiomatic 1.5-6x faster (GetOrElse, Alt)

Key Insight: The idiomatic package delivers consistently better performance across the board while maintaining zero-cost abstractions. The Either package is now fast enough for most use cases, but idiomatic is the performance winner.

Updated Recommendation Matrix

Scenario Recommendation Reason
New Go project Idiomatic Natural Go patterns, 1.2-6x faster, better integration
Production services Idiomatic 2-3x faster pipelines, zero allocations, proven performance
Performance critical Idiomatic 32x faster complex ops, minimal allocations
Microservices/APIs Idiomatic High throughput, familiar patterns, better performance
CLI Tools Idiomatic Low overhead, Go idioms, fast
Custom error types Standard/Either Need Either[E, A] with domain types
Learning FP Standard/Either Clearer ADT semantics, educational
FP-heavy codebase Standard/Either Consistency, Do-notation, full FP toolkit
Library/Framework Either way Both are good; choose based on API style

Real-World Impact

For a service handling 10,000 requests/second with typical pipeline operations:

Either package:     280 ns/op × 10M req/day = 2,800 seconds = 46.7 minutes
Idiomatic package:  116 ns/op × 10M req/day = 1,160 seconds = 19.3 minutes
Time saved: 27.4 minutes of CPU time per day

At scale, this translates to:

  • Lower latency (2-3x faster response times for FP operations)
  • Reduced CPU usage (fewer cores needed)
  • Lower memory pressure (significantly fewer allocations)
  • Better resource utilization

Final Recommendation

For most Go projects: Use idiomatic packages

  • 1.2-32x faster across operations
  • Native Go idioms
  • Zero-cost abstractions
  • Production-proven performance
  • Easier integration

For specialized needs: Use standard Either/Result

  • Need custom error types Either[E, A]
  • Want Do-notation and advanced FP features
  • Porting from FP languages
  • Educational/learning context
  • FP-heavy existing codebase

Bottom Line

After optimizations, both packages are excellent:

  • Either/Result: Fast enough for most use cases, feature-rich, type-safe
  • Idiomatic: Faster in practice (1.2-32x), native Go, zero-cost FP

The idiomatic packages now represent the best of both worlds: full functional programming capabilities with Go's native performance and idioms. Unless you specifically need Either[E, A]'s custom error types or advanced FP features, idiomatic is the recommended choice for production Go services.

Both maintain the core benefits of functional programming—choose based on whether you prioritize performance & Go integration (idiomatic) or type flexibility & FP features (either).