Logo

Functional Programming: Where Bugs Become Elegant Puzzles (Part 2)

Let’s dive deeper into some practical use-cases in Go, showcasing the elegance and efficiency of FP in action.

avatar
Hung VoPublished on May 13, 2024
loading

In the previous article, we delved into the core concepts of Functional Programming (FP) and how they can transform the way we write software. Now, let’s dive deeper into some practical use-cases in Go, showcasing the elegance and efficiency of FP in action.

First up, let’s take a look at some examples.

Example 1: Mapping and Filtering

Given a list of users, we want to filter all male users. Here’s how we can achieve this in the imperative style by using a traditional loop:

type User struct {
   ID     int
   Name   string
   Gender string
   Age    int
}

users := []User{
 {1, "Alice", "F", 25},
 {2, "Bob", "M", 30},
 {3, "Charlie", "M", 35},
 {4, "Diana", "F", 40},
}

// Filter users by gender
var maleUsers []User
for _, user := range users {
   if user.Gender == "M" {
       maleUsers = append(maleUsers, user)
   }
}

fmt.Println(maleUsers) // [{2 Bob M 30} {3 Charlie M 35}]

In this example, we iterate over the list of users and filter. If we want to filter by female users or ages, we would need to find and change the condition in the loop. This approach can be error-prone and challenging to maintain.

Let’s rewrite the same logic using FP principles. First, we define a filter function that takes a predicate function as an argument:

func filter(users []User, predicate func(User) bool) []User {
   var filteredUsers []User
   for _, user := range users {
       if predicate(user) {
           filteredUsers = append(filteredUsers, user)
       }
   }
   return filteredUsers
}

Now, we can define a predicate function to filter users. In this case, we want to filter all male users:

func isMale(user User) bool {
   return user.Gender == "M"
}

Finally, we apply the filter function with the isMale predicate:

maleUsers := filter(users, isMale)
fmt.Println(maleUsers) // [{2 Bob M 30} {3 Charlie M 35}]

If we want to filter by felame users, we can define a new predicate function and reuse the filter function:

func isFemale(user User) bool {
   return user.Gender == "F"
}

femaleUsers := filter(users, isFemale)
fmt.Println(femaleUsers) // [{1 Alice F 25} {4 Diana F 40}]

We can take a step further and generalize predicate of gender by using higher-order functions:

func gender(g string) func(User) bool {
   return func(user User) bool {
       return user.Gender == g
   }
}

Now, we can create specific predicates

maleUsers := filter(users, gender("M"))
femaleUsers := filter(users, gender("F"))

In the real world, we rarely define a filter function from scratch. Instead, we can use functions like lo.Filter and lo.Map from the samber/lo package:

import "github.com/samber/lo"

func gender(g string) func(User, int) bool {
   return func(user User, _ int) bool {
       return user.Gender == g
   }
}

func incAge(user User, _ int) User {
   user.Age++
   return user
}

femaleUsers := lo.Map(lo.Filter(users, gender("F")), incAge)

fmt.Println(femaleUsers) // [{1 Alice F 26} {4 Diana F 41}]

[Try it on Go playground]

Example 2: Monads and Error Handling

Monads are a powerful concept in FP that can help manage side effects and error handling in a predictable and composable way. Let’s explore how we can use monads to handle errors gracefully in Go. Given a function that divides numbers:

func compute(a, b, c, d int) (int, error) {
   if a == 0 {
       return 0, fmt.Errorf("division by zero")
   }
   x := b / a
   if x == 0 {
       return 0, fmt.Errorf("division by zero")
   }
   y := c / x
   if y == 0 {
       return 0, fmt.Errorf("division by zero")
   }

   return d / y, nil
}

func main() {
   result, err := compute(2, 10, 5, 2)
   if err != nil {
       fmt.Println(err) // Handle potential errors
       return
   }
   fmt.Println(result) // Output: 2
}

In the imperative style, we would need to check for errors at each step and handle them accordingly. This can lead to verbose and error-prone code. Let's see how we can rewrite this logic using monads. We will use the mo.Result monad from the samber/mo package:

import "github.com/samber/mo"

func divide(a int) func(int) mo.Result[int] {
   return func(b int) mo.Result[int] {
       if b == 0 {
           return mo.Err[int](fmt.Errorf("division by zero"))
       }
       return mo.Ok(a / b)
   }
}

func compute(a, b, c, d int) mo.Result[int] {
   return mo.Ok(a).
       FlatMap(divide(b)).
       FlatMap(divide(c)).
       FlatMap(divide(d))
}

func main() {
   result := compute(2, 10, 5, 2)
   // Handle result
   if !result.IsOk() {
       fmt.Println(result.Error()) // Handle potential errors
   }

    fmt.Println(result.MustGet()) // Output: 2
}

As you can see, using monads simplifies error handling and makes the code more concise and readable. By leveraging monads, we can manage side effects and errors in a predictable and composable way, leading to more robust and maintainable code.

[Try it on Go playground]

Example 3: Server Options

Given the following server configuration:

type Server struct {
   opts options
}

type options struct {
   maxConn       int
   transportType string
   timeout       int
}

Let's see how we can create server options using functional options:

type ServerOption func(options) options

func MaxConn(n int) ServerOption {
   return func(o options) options {
       o.maxConn = n
       return o
   }
}

func Timeout(t int) ServerOption {
   return func(o options) options {
       o.timeout = t
       return o
   }
}

We're defining ServerOption as a function that takes options and returns options. This allows us to create different server options by composing these functions. Let's write our server constructor:

func NewServer(opts ...ServerOption) *Server {
   var o options
   for _, opt := range opts {
       o = opt(o)
   }
   return &Server{opts: o}
}

The NewServer function takes a variadic list of ServerOption functions, applies them to the default options, and returns a new Server instance. Here's how we can create a server with custom options:

server := NewServer(MaxConn(100), Timeout(30))
...
// &{opts:{maxConn:100 transportType: timeout:30}}

If we want to add more options in the future, we can define new ServerOption functions and compose them as needed. This approach allows us to create flexible and extensible APIs without cluttering the constructor with numerous parameters. For example, we can add a TransportType option:

func TransportType(t string) ServerOption {
   return func(o options) options {
       o.transportType = t
       return o
   }
}

server := NewServer(MaxConn(100), Timeout(30), TransportType("tcp"))
...
// &{opts:{maxConn:100 transportType:tcp timeout:30}}

[Try it on Go playground]

We have explored how functional programming concepts can be applied in real-world scenarios using Go. By leveraging pure functions, immutability, recursion, higher-order functions, and monads, developers can write elegant, efficient, and maintainable code. In the next article, we will try to implement a fully functional code using the IBM/fp-go package.

0
0
155
share post

Comments

avatar