From a741c5417559d40099d44a6c09c0117ca08bc13b Mon Sep 17 00:00:00 2001 From: Keagan McClelland Date: Tue, 6 Aug 2024 15:20:50 -0700 Subject: [PATCH] fn: add compound slice functions --- fn/slice.go | 94 ++++++++++++++++++ fn/slice_test.go | 252 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 342 insertions(+), 4 deletions(-) diff --git a/fn/slice.go b/fn/slice.go index 3b25b32a1..4dd4a2c58 100644 --- a/fn/slice.go +++ b/fn/slice.go @@ -65,6 +65,34 @@ func Filter[A any](pred Pred[A], s []A) []A { return res } +// FilterMap takes a function argument that optionally produces a value and +// returns a slice of the 'Some' return values. +func FilterMap[A, B any](as []A, f func(A) Option[B]) []B { + var bs []B + + for _, a := range as { + f(a).WhenSome(func(b B) { + bs = append(bs, b) + }) + } + + return bs +} + +// TrimNones takes a slice of Option values and returns a slice of the Some +// values in it. +func TrimNones[A any](as []Option[A]) []A { + var somes []A + + for _, a := range as { + a.WhenSome(func(b A) { + somes = append(somes, b) + }) + } + + return somes +} + // Foldl iterates through all members of the slice left to right and reduces // them pairwise with an accumulator value that is seeded with the seed value in // the argument. @@ -318,3 +346,69 @@ func Unsnoc[A any](items []A) Option[T2[[]A, A]] { func Len[A any](items []A) uint { return uint(len(items)) } + +// CollectOptions collects a list of Options into a single Option of the list of +// Some values in it. If there are any Nones present it will return None. +func CollectOptions[A any](options []Option[A]) Option[[]A] { + // We intentionally do a separate None checking pass here to avoid + // allocating a new slice for the values until we're sure we need to. + for _, r := range options { + if r.IsNone() { + return None[[]A]() + } + } + + // Now that we're sure we have no Nones, we can just do an unchecked + // index into the some value of the option. + return Some(Map(func(o Option[A]) A { return o.some }, options)) +} + +// CollectResults collects a list of Results into a single Result of the list of +// Ok values in it. If there are any errors present it will return the first +// error encountered. +func CollectResults[A any](results []Result[A]) Result[[]A] { + // We intentionally do a separate error checking pass here to avoid + // allocating a new slice for the results until we're sure we need to. + for _, r := range results { + if r.IsErr() { + return Err[[]A](r.right) + } + } + + // Now that we're sure we have no errors, we can just do an unchecked + // index into the left side of the result. + return Ok(Map(func(r Result[A]) A { return r.left }, results)) +} + +// TraverseOption traverses a slice of A values, applying the provided +// function to each, collecting the results into an Option of a slice of B +// values. If any of the results are None, the entire result is None. +func TraverseOption[A, B any](as []A, f func(A) Option[B]) Option[[]B] { + var bs []B + for _, a := range as { + b := f(a) + if b.IsNone() { + return None[[]B]() + } + bs = append(bs, b.some) + } + + return Some(bs) +} + +// TraverseResult traverses a slice of A values, applying the provided +// function to each, collecting the results into a Result of a slice of B +// values. If any of the results are Err, the entire result is the first +// error encountered. +func TraverseResult[A, B any](as []A, f func(A) Result[B]) Result[[]B] { + var bs []B + for _, a := range as { + b := f(a) + if b.IsErr() { + return Err[[]B](b.right) + } + bs = append(bs, b.left) + } + + return Ok(bs) +} diff --git a/fn/slice_test.go b/fn/slice_test.go index 86c870ff8..fae9bd361 100644 --- a/fn/slice_test.go +++ b/fn/slice_test.go @@ -2,6 +2,7 @@ package fn import ( "fmt" + "math/rand" "slices" "testing" "testing/quick" @@ -413,6 +414,122 @@ func TestPropTailDecrementsLength(t *testing.T) { require.NoError(t, quick.Check(f, nil)) } +func TestSingletonTailIsEmpty(t *testing.T) { + require.Equal(t, Tail([]int{1}), Some([]int{})) +} + +func TestSingletonInitIsEmpty(t *testing.T) { + require.Equal(t, Init([]int{1}), Some([]int{})) +} + +// TestPropAlwaysNoneEmptyFilterMap ensures the property that if we were to +// always return none from our filter function then we would end up with an +// empty slice. +func TestPropAlwaysNoneEmptyFilterMap(t *testing.T) { + f := func(s []int) bool { + filtered := FilterMap(s, Const[Option[int], int](None[int]())) + return len(filtered) == 0 + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestPropFilterMapSomeIdentity ensures that if the filter function is a +// trivial lift into Option space, then we will get back the original slice. +func TestPropFilterMapSomeIdentity(t *testing.T) { + f := func(s []int) bool { + filtered := FilterMap(s, Some[int]) + return slices.Equal(s, filtered) + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestPropFilterMapCantGrow ensures that regardless of the filter functions +// return values, we will never end up with a slice larger than the original. +func TestPropFilterMapCantGrow(t *testing.T) { + f := func(s []int) bool { + filterFunc := func(i int) Option[int] { + if rand.Int()%2 == 0 { + return None[int]() + } + + return Some(i + rand.Int()) + } + + return len(FilterMap(s, filterFunc)) <= len(s) + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestPropFilterMapBisectIdentity ensures that the concatenation of the +// FilterMaps is the same as the FilterMap of the concatenation. +func TestPropFilterMapBisectIdentity(t *testing.T) { + f := func(s []int) bool { + sz := len(s) + first := s[0 : sz/2] + second := s[sz/2 : sz] + + filterFunc := func(i int) Option[int] { + if i%2 == 0 { + return None[int]() + } + + return Some(i) + } + + firstFiltered := FilterMap(first, filterFunc) + secondFiltered := FilterMap(second, filterFunc) + allFiltered := FilterMap(s, filterFunc) + reassembled := slices.Concat(firstFiltered, secondFiltered) + + return slices.Equal(allFiltered, reassembled) + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestTraverseOkIdentity ensures that trivially lifting the elements of a slice +// via the Ok function during a Traverse is equivalent to just lifting the +// entire slice via the Ok function. +func TestPropTraverseOkIdentity(t *testing.T) { + f := func(s []int) bool { + traversed := TraverseResult(s, Ok[int]) + + traversedOk := traversed.UnwrapOrFail(t) + + return slices.Equal(s, traversedOk) + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestPropTraverseSingleErrEjection ensures that if the traverse function +// returns even a single error, then the entire Traverse will error. +func TestPropTraverseSingleErrEjection(t *testing.T) { + f := func(s []int, errIdx uint8) bool { + if len(s) == 0 { + return true + } + + errIdxMut := int(errIdx) % len(s) + f := func(i int) Result[int] { + if errIdxMut == 0 { + return Errf[int]("err") + } + + errIdxMut-- + + return Ok(i) + } + + return TraverseResult(s, f).IsErr() + } + + require.NoError(t, quick.Check(f, nil)) +} + func TestPropInitDecrementsLength(t *testing.T) { f := func(s []uint8) bool { if len(s) == 0 { @@ -425,10 +542,137 @@ func TestPropInitDecrementsLength(t *testing.T) { require.NoError(t, quick.Check(f, nil)) } -func TestSingletonTailIsEmpty(t *testing.T) { - require.Equal(t, Tail([]int{1}), Some([]int{})) +// TestPropTrimNonesEqualsFilterMapIden checks that if we use the Iden +// function when calling FilterMap on a slice of Options that we get the same +// result as we would if we called TrimNones on it. +func TestPropTrimNonesEqualsFilterMapIden(t *testing.T) { + f := func(s []uint8) bool { + withNones := make([]Option[uint8], len(s)) + for i, x := range s { + if x%3 == 0 { + withNones[i] = None[uint8]() + } else { + withNones[i] = Some(x) + } + } + + return slices.Equal( + FilterMap(withNones, Iden[Option[uint8]]), + TrimNones(withNones), + ) + } + + require.NoError(t, quick.Check(f, nil)) } -func TestSingletonInitIsEmpty(t *testing.T) { - require.Equal(t, Init([]int{1}), Some([]int{})) +// TestPropCollectResultsSingleErrEjection ensures that if there is even a +// single error in the batch, then CollectResults will return an error. +func TestPropCollectResultsSingleErrEjection(t *testing.T) { + f := func(s []int, errIdx uint8) bool { + if len(s) == 0 { + return true + } + + errIdxMut := int(errIdx) % len(s) + f := func(i int) Result[int] { + if errIdxMut == 0 { + return Errf[int]("err") + } + + errIdxMut-- + + return Ok(i) + } + + return CollectResults(Map(f, s)).IsErr() + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestPropCollectResultsNoErrUnwrap ensures that if there are no errors in the +// results then we end up with unwrapping all of the Results in the slice. +func TestPropCollectResultsNoErrUnwrap(t *testing.T) { + f := func(s []int) bool { + res := CollectResults(Map(Ok[int], s)) + return !res.isRight && slices.Equal(res.left, s) + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestPropTraverseSomeIdentity ensures that trivially lifting the elements of a +// slice via the Some function during a Traverse is equivalent to just lifting +// the entire slice via the Some function. +func TestPropTraverseSomeIdentity(t *testing.T) { + f := func(s []int) bool { + traversed := TraverseOption(s, Some[int]) + + traversedSome := traversed.UnwrapOrFail(t) + + return slices.Equal(s, traversedSome) + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestTraverseSingleNoneEjection ensures that if the traverse function returns +// even a single None, then the entire Traverse will return None. +func TestTraverseSingleNoneEjection(t *testing.T) { + f := func(s []int, errIdx uint8) bool { + if len(s) == 0 { + return true + } + + errIdxMut := int(errIdx) % len(s) + f := func(i int) Option[int] { + if errIdxMut == 0 { + return None[int]() + } + + errIdxMut-- + + return Some(i) + } + + return TraverseOption(s, f).IsNone() + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestPropCollectOptionsSingleNoneEjection ensures that if there is even a +// single None in the batch, then CollectOptions will return a None. +func TestPropCollectOptionsSingleNoneEjection(t *testing.T) { + f := func(s []int, errIdx uint8) bool { + if len(s) == 0 { + return true + } + + errIdxMut := int(errIdx) % len(s) + f := func(i int) Option[int] { + if errIdxMut == 0 { + return None[int]() + } + + errIdxMut-- + + return Some(i) + } + + return CollectOptions(Map(f, s)).IsNone() + } + + require.NoError(t, quick.Check(f, nil)) +} + +// TestPropCollectOptionsNoNoneUnwrap ensures that if there are no nones in the +// options then we end up with unwrapping all of the Options in the slice. +func TestPropCollectOptionsNoNoneUnwrap(t *testing.T) { + f := func(s []int) bool { + res := CollectOptions(Map(Some[int], s)) + return res.isSome && slices.Equal(res.some, s) + } + + require.NoError(t, quick.Check(f, nil)) }