fluent: simple, readable FP for slices
Key Features
-
Type-Safe: fluent avoids reflection and the any type, ensuring compile-time type
safety.
-
Higher-order collection methods: fluent slices offer collection methods:
- Map:
To[Type] methods for most built-in types
- Filter: complementary
KeepIf and RemoveIf methods
- Each: as
Each
-
Fluent: higher-order methods chain since they return fluent slices. This avoids the
proliferation of intermediate variables and nested code endemic to the imperative style.
-
Interoperable: fluent slices auto-convert to native slices and vice-versa, allowing
them to be passed without explicit conversion to functions that accept slices. Fluent
slices can be operated on by regular slice operations like indexing, slicing and
ranging.
-
Concise: fluent harmonizes these features and others to keep lines of code and
extra syntax to a minimum.
-
Expressive: Careful method naming, fluency and compatibility with method
expressions make for beautiful code:
titles := posts.
KeepIf(Post.IsValid).
ToString(Post.Title)
Both IsValid and Title are methods on type Post.
-
Learnable: Because fluent slices can be used the same way as native slices, they
support ranging by for loops and other imperative idioms. It is easy to mix imperative
with functional style, either to learn incrementally or to use "just enough" FP and
leave the rest.
Method Expressions
Method expressions are the unbound form of methods in Go. For example, given
user := User{}, the following statements are automatically the same:
user.IsActive()
User.IsActive(user)
This means any no-argument method can be used as the single-argument function expected by
collection methods, simply by referencing it through its type name instead of an
instantiated variable.
Getting Started
Install FluentFP:
go get github.com/binaryphile/fluentfp
Import the package:
import "github.com/binaryphile/fluentfp/fluent"
Comparison with Other Libraries
Below is a comparison of fluent with the collection operations of other popular FP libraries
in Go. See ../examples/comparison/main.go for examples
with nine other libraries.
* as of 11/17/24
Comparison: Filtering and Mapping
Given the following slice where User has IsActive and Name methods:
users := []User{{name: "Ren", active: true}}
Plain Go:
for _, user := range users {
if user.IsActive() {
fmt.Println(user.Name())
}
}
Plain Go is fine, but readability suffers from nesting. Recall that for loops have
multiple forms, which reduces clarity, increasing mental load. In the form of loop shown
here, Go also forces you to waste syntax by discarding a value.
Using FluentFP:
users is a regular slice:
slice.From(users).
KeepIf(User.IsActive).
ToString(User.Name).
Each(lof.Println) // helper from fluentfp/lof
This is powerful, concise and readable. It reveals intention by relying on clarity and
simplicity. It is concerned more with stating what things are doing (functional) than how
the computer implements them (imperative).
Unfortunately, a rough edge of Go’s type system prevents using fmt.Println directly as an
argument to Each, so we’ve substituted a function from the lof helper package. It is an
annoyance that there are such cases with functions that employ variadic arguments or any,
but the end result is still compelling.
Using samber/lo:
lo is the most popular library, with over 17,000 GitHub stars. It is type-safe, but not
fluent, and doesn't work with method expressions:
userIsActive := func(u User, _ int) bool {
return u.IsActive()
}
toName := func(u User, _ int) string {
return u.Name()
}
printLn := func(s string, _ int) {
fmt.Println(s)
}
actives := lo.Filter(users, userIsActive)
names := lo.Map(actives, toName)
lo.ForEach(names, printLn)
As you can see, lo is not concise, requiring many more lines of code. The non-fluent style
requires employing intermediate variables to keep things readable. Map and Filter pass
indexes to their argument, meaning that you have to wrap the IsActive and Name methods
in functions that accept indexes, just to discard those indexes.
Usage
There are two slice types, Mapper[T any] and MapperTo[R, T any]. If you are only
mapping to one or more of the built-in types, Mapper is the right choice.
MapperTo[R, T] is for mapping to any type, usually either your own named type or one from
a library (a named type is one created with the type keyword). It is the same as Mapper
but with an additional method, To. To maps to R, the return type.
Creating Fluent Slices of Built-in Types
Mapper[T] is the primary fluent slice type. You can use the slice.From function to
create a fluent slice:
words := slice.From([]string{"two", "words"})
To allocate a slice of defined size, make accepts a fluent slice type:
words := make(slice.String, 0, 10)
You could have used slice.Mapper[string] rather than slice.String above, but
there are several predefined type aliases for built-in types to keep the basic ones
readable:
slice.Any
slice.Bool
slice.Byte
slice.Error
slice.Int
slice.Rune
slice.String
To create a slice mappable to an arbitrary type, use the function slice.To[R], rather
than slice.From. For example, to create a slice of strings mappable to a User type:
emails := []string{"user1@example.com", "user2@example.com"}
users := slice.To[User](emails).To(UserFromEmail) // UserFromEmail not shown
Creating Fluent Slices of Arbitrary Types
Creating a fluent slice of an arbitrary type is similar:
points := slice.From([]Point{{1, 2}, {3, 4}})
But there are no predefined aliases to use with make:
points := make(slice.Mapper[Point], 0, 10)
Filtering
KeepIf and RemoveIf are the filtering methods. They take a function that returns a
bool:
actives := users.KeepIf(User.IsActive)
inactives := users.RemoveIf(User.IsActive)
They come as a complementary pair to avoid the need for negation in the lower-order
function, otherwise the formerly-short inactives assignment above would have to look like
this:
inactives := users.KeepIf(func(u User) bool { return !u.IsActive() })
Mapping to Built-in Types
Mapper has methods for mapping to the basic built-in types. They are named To[Type]:
names := users.ToString(User.Name)
The following methods are available for mapping to built-in types. They are available
on both Mapper and MapperTo:
ToAny
ToBool
ToByte
ToError
ToInt
ToRune
ToString
There is also a method for a special case, Convert. It maps to the same type as the
original slice.
If you need a type not listed here, you can use the To method on MapperTo to map to an arbitrary
type.
As mentioned, method expressions are very useful. Any method of the following form on the slice's member type can be used for mapping, i.e. one with no arguments and only one return value:
func (t MemberType) MethodName() (singleReturnValue int) {} // no arguments
Mapping to Named Types
MapperTo[R, T] is used for mapping to named types. It has the same methods as Mapper,
plus a To method. Create one from a regular slice with slice.To:
drivers := slice.To[Driver](cars).To(Car.Driver)
Iterating for Side Effects
Each is the method for iterating over a slice for side effects. It takes a function that
returns nothing. Again, method expressions are useful here, this time ones that don't
return a value:
users.Each(User.Notify)
Patterns
These patterns demonstrate idiomatic usage drawn from production code.
Type Alias for Domain Slices
Define a type alias to enable fluent methods directly on your domain slice types:
type SliceOfUsers = slice.Mapper[User]
// Now you can declare and chain directly:
var users SliceOfUsers = fetchUsers()
actives := users.KeepIf(User.IsActive)
This avoids repeated slice.From() calls when working with the same slice type multiple times.
Method Expression Chaining
Chain method expressions for transform-then-filter pipelines:
// Normalize data, then filter invalid entries
devices := slice.From(rawDevices).
Convert(Device.Normalize).
KeepIf(Device.IsValid)
The method expressions Device.Normalize and Device.IsValid read as declarative descriptions of the pipeline.
Extract a single field from structs into a string slice:
macs := devices.ToStrings(Device.GetMAC)
This replaces the common pattern:
macs := make([]string, len(devices))
for i, d := range devices {
macs[i] = d.GetMAC()
}
Counting with KeepIf + Len
Count matching elements without intermediate allocation:
activeCount := slice.From(users).KeepIf(User.IsActive).Len()
This replaces:
count := 0
for _, u := range users {
if u.IsActive() {
count++
}
}
Standalone Functions
In addition to methods on Mapper and MapperTo, the slice package provides standalone functions for operations that return multiple values or different types.
Fold
Fold reduces a slice to a single value by applying a function to each element, processing left-to-right:
// sumFloat64 adds two float64 values.
sumFloat64 := func(acc, x float64) float64 { return acc + x }
// indexByMAC adds a device to the map keyed by its MAC address.
indexByMAC := func(m map[string]Device, d Device) map[string]Device {
m[d.MAC] = d
return m
}
// maxInt returns the larger of two integers.
maxInt := func(max, x int) int {
if x > max {
return x
}
return max
}
total := slice.Fold(amounts, 0.0, sumFloat64)
byMAC := slice.Fold(devices, make(map[string]Device), indexByMAC)
max := slice.Fold(values, values[0], maxInt)
Unzip2, Unzip3, Unzip4
Extract multiple fields from a slice in a single pass. More efficient than calling separate ToX methods when you need multiple fields:
// Instead of 4 iterations:
// leadTimes := slice.From(history).ToFloat64(Record.GetLeadTime)
// deployFreqs := slice.From(history).ToFloat64(Record.GetDeployFreq)
// ...
// One iteration:
leadTimes, deployFreqs, mttrs, cfrs := slice.Unzip4(history,
Record.GetLeadTime,
Record.GetDeployFreq,
Record.GetMTTR,
Record.GetChangeFailRate,
)
Zip and ZipWith (pair package)
The pair package provides functions for combining two slices element-by-element. Import separately:
import "github.com/binaryphile/fluentfp/tuple/pair"
Zip creates pairs from corresponding elements:
names := []string{"Alice", "Bob", "Carol"}
scores := []int{95, 87, 92}
// Create slice of pairs
pairs := pair.Zip(names, scores)
// Result: []pair.X[string, int]{{V1: "Alice", V2: 95}, {V1: "Bob", V2: 87}, {V1: "Carol", V2: 92}}
// printPair prints a name-score pair to stdout.
printPair := func(p pair.X[string, int]) {
fmt.Printf("%s: %d\n", p.V1, p.V2)
}
slice.From(pairs).Each(printPair)
ZipWith applies a function to corresponding elements:
// formatScore combines a name and score into a display string.
formatScore := func(name string, score int) string {
return fmt.Sprintf("%s: %d", name, score)
}
results := pair.ZipWith(names, scores, formatScore)
// Result: []string{"Alice: 95", "Bob: 87", "Carol: 92"}
Both functions panic if slices have different lengths (fail-fast behavior).
When Loops Are Still Necessary
FluentFP handles most slice operations, but these patterns still require traditional loops:
Channel Consumption
Ranging over channels has no FP equivalent:
for result := range resultsChan {
// process each result
}
Complex Control Flow
When you need break, continue, or early return within the loop body.