Let’s dive deeper into some practical use-cases in Go, showcasing the elegance and efficiency of FP in action.
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.
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}]
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.
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}}
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.
Comments