How do you learn Go if you are intermediate in Scala - Part II

In the previous article we tackled user story 1, filtering out a list of integers based on a single condition. User story 2 and 3 say that we have to filter out the numbers which are odd and prime respectively. So I am not going to go deep into explaining this 2 stories. Now, let's delve deeper into user story 4, which involves combining multiple criteria for filtering. These user story require filtering for odd and prime numbers, respectively.

Filtering Numbers: Scala (Builder Pattern like way)

Let's find out how do we do in Scala? To achieve this task, we leverage Scala's filter method, not once but twice, creating a powerful chain of conditions:

  1. Filtering for Odd Numbers:

    • We first employ a filter to retain only odd numbers within the collection.

    • This initial filter sets the stage for the subsequent prime number check.

  2. Filtering for Prime Numbers:

    • We then apply a second filter,specifically targeting prime numbers.

    • This final filter ensures that only those odd numbers that are also prime remain in the resulting collection.

//NumberFilterTest.scala
import org.scalatest.funsuite.AnyFunSuite

class NumberFilterTest extends AnyFunSuite {
  // Test case for filterEven method
  test("oddPrimeNumbers should return only odd and prime numbers") {
    val inputNumbers = List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    assert(inputNumbers.oddPrimes == List(3, 5, 7)) 
  }

}


//NumberFiltering.scala
object NumberFiltering {

  implicit class IntOps(val n: Int) {
    lazy val isEven: Boolean = n % 2 == 0
    lazy val isOdd: Boolean = !n.isEven
    lazy val isPrime: Boolean =
        (2 until Math.sqrt(n).toInt + 1)
            .forall(n % _ != 0) && n > 1
  }

  implicit class ListIntOps(val numbers: List[Int]) {
     lazy val evens: List[Int] = numbers.filter(_.isEven)
     lazy val oddPrimes: List[Int] = numbers
            .filter(_.isOdd) 
            .filter(_.isPrime)
  }

}

Please note that this code doesn't directly utilize the Builder pattern. While both the chaining of filters and the Builder pattern involve a step-by-step construction process, they serve different purposes.

Chaining filters:

  • Focuses on filtering existing data based on certain criteria.

  • Each filter refines the data further,resulting in a smaller, more specific subset.

  • The outcome is a modified collection, reflecting the applied filters.

Builder pattern:

  • Aims to construct new objects by assembling their parts in a controlled manner.

  • Each "build step" adds or configures specific elements of the final object.

  • The result is a newly created object with the desired configuration.

Therefore, while both involve sequential steps, chaining filters modifies existing data, whereas the Builder pattern builds new objects.

In this case, filtering odd primes would be analogous to assembling a complex object with specific attributes (oddness and primality) using separate builder methods. However, the code utilizes standard library function filter and doesn't explicitly implement the Builder pattern.

Filtering Numbers in Go: From Simple to Generic Solutions

Now let's work on the Go solution part. We will start where we left earlier and then slowly build more generic solution.

The Challenge: Combining Filter Conditions

In Scala, we can leverage the power of chained filter methods. We first filter for odd numbers and then apply another filter for primes within the resulting collection. We could try to mimic the same in Go.

However, as we introduce more complex filtering criteria (e.g., even primes, odd non-primes), creating dedicated functions for each combination becomes cumbersome and inefficient. This is where this blog presents some interesting possibilities for a more generic solution.

Solution 1: Combining Unit Functions (Rethinking the Approach)

Initially, we might be tempted to create a new function, isOddPrime, by combining the logic of isOdd and isPrime.

func TestOddPrimeNumbers(t *testing.T) {
    // given
    numRange := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    want := []int{3, 5, 7}

    // when
    output := GetIntSlice(numRange).filter(isOddPrime)

    // then
    if !reflect.DeepEqual(output, want) {
        t.Errorf("wanted: %v but got: %v", want, output)
    }
}
func isOdd(number int) bool {
    return !isEven(number)
}

func isPrime(n int) bool {
    for i := 2; i * i <= n; i++ {
        if n%i == 0 {
            return false
        }
    }
    return n > 1
}

func isOddPrime(number int) bool {
    return isOdd(number) && isPrime(number)
}

Problem: While this seems efficient for the current scenario, it becomes impractical when dealing with many criteria. The potential combinations explode exponentially, leading to code redundancy and maintenance challenges.

Solution 2: Chained Filtering (Reusing Existing Logic)

Go allows us to mimic Scala's chained filtering approach. We can convert the list of integers to a custom MyIntSlice type, apply our existing filter() method for isOdd, and then convert back to []int before applying the isPrime filter. This approach avoids creating new functions but suffers from unnecessary conversions and reduced readability.


func TestOddPrimeNumbers(t *testing.T) {
    . . .
    output := GetIntSlice(GetIntSlice(numRange).filter(isOdd))
               .filter(isPrime)
    . . .
}

This solution tackles the problem of solution 1. But the convertion of each step before adding filter is making it a) hard to read the code, b) sub-optimal due to the unnecessary conversions.

Solution 3: Builder Pattern like way

The key to optimal filtering lies in modifying the filter function itself. Instead of returning []int, we can modify it to return the custom MyIntSlice type, performing the conversion within the method. This improves readability and avoids unnecessary data type conversions.

Then we could call like below -


func TestOddPrimeNumbers(t *testing.T) {
    . . .
    // when
    output := GetIntSlice(numRange)
               .filter(isOdd)
               .filter(isPrime)
    . . .
}

Now, although it is easy to read but again it is a sub-optimal solution as now we are still doing that []int to MyIntSlice conversion each time filter is called.

FOP (Functional Oriented Programming): Filtering with Variadic Functions (Optimizing for Readability and Efficiency)

So what do we do? If you think hard our gateway of logic is this filter method so any optimization that we have to do, has to be done inside this method only. Now if you think one step further you can think what if you could take this functions or filter logic as input. Since this could be any number of input so how do we handle variable number of argument in go function?

After a little bit of research and the official Go Tour, came to know about variadic parameters in go and how to use it.

Logically thinking we could create a list of such functions and pass the parameter to the caller function. The caller function should mention the type of this variable number of functions. How do we define such type?

What is our function signature? It should take an integer as input and return boolean as output right. We can create a type with this function signature as below, and creating a slice of such functions then becomes easy as described below.

type Predicate func(n int) bool
type PredicateList []Predicate

Now that we have created this types we can create a slice of such required number of functions in our test case like below and we should then create a function which would take our numbers as input and also this variable number of parameter as input. So how do we create such list and how do we pass it?

In Go, the ... syntax is used for variadic functions or parameters. In our test case belwo we have created predicates... which is a variadic parameter.

If we call the function oddPrimeNumbers(numRange, predicates...), it means that we can pass multiple arguments of type Predicate to the predicates parameter. The ... essentially unpacks the elements of the predicates slice and passes them as separate arguments to the function.

So, for example, if we have three predicates like this:

predicates := []Predicate{isOdd, isPrime, someOtherPredicate}

Then, oddPrimeNumbers(numRange, predicates...) is equivalent to calling:

oddPrimeNumbers(numRange, isOdd, isPrime, someOtherPredicate)

Below is the code snippet if the test case -

func TestOddPrimeNumbers(t *testing.T) {
. . . 
    predicates := PredicateList {isOdd, isPrime} // create list

    // when
    output := oddPrimeNumbers(numRange, predicates...) // pass it
. . .
}

Now, all we have to do is to create this function. The function oddPrimeNumbers takes an integer slice numbers and predicates ...Predicate: This is a variadic parameter, allowing us to pass zero or more Predicate functions.

Now we are checking, if there are any predicates provided. If there are no predicates, it returns an empty integer slice ([]int{}). If there are predicates, we then have to pass it to the filter. So that means we have to update our filter method as well to make it more generic.

package main
. . . 

func isOdd(number int) bool ...

func isPrime(n int) bool ... 

func oddPrimeNumbers(numbers []int, predicates ...Predicate) []int {
    if len(predicates) == 0 {
        return []int{}
    }
    return filter(numbers, predicates...)
}

Lets update our filter method. In our filter method we could combine this predicates and check if every number is returning true for every function i.e. Predicate then we are done and here is the code.


func filter(numbers []int, predicates ...Predicate) []int {
    var result []int
    predicateList := PredicateList(predicates)

    for _, num := range numbers {
        if predicateList.forAll(num) {
            result = append(result, num)
        }
    }
    return result
}

Next how do we create a forAll function? Its simple right? What is the type of the predicateList -> its PredicateList. If we could write this receiver method forAll for the PredicateList then we could use it like above. So lets get onto it. The logic is simple. You just go through each of the predicates and if any of the predicate is returning false for the number you return false instantly and if nothing returns false means the number satisfied all the predicate criteria. Here is the logic.

func (predicate PredicateList) forAll(number int) bool {
    for _, p := range predicate {
        if !p(number) {
            return false
        }
    }
    return true
}

Putting it all Together: Testing the Generic Filter

Here's the test case showcasing the generic filtering approach in Go.

This code snippet demonstrates the flexibility and power of the generic filtering approach. We can easily plug in different predicates to filter based on diverse criteria, making the code highly adaptable and reusable.

type Predicate func(n int) bool
type PredicateList []Predicate

func (predicate PredicateList) forAll(number int) bool {
    for _, p := range predicate {
        if !p(number) {
            return false
        }
    }
    return true
}

func filter(numbers []int, predicates ...Predicate) []int {
    var result []int
    predicateList := PredicateList(predicates)

    for _, num := range numbers {
        if predicateList.forAll(num) {
            result = append(result, num)
        }
    }
    return result
}
func TestOddPrimeNumbers(t *testing.T) {
    // given
    numRange := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    want := []int{3, 5, 7}
    predicates := PredicateList {isOdd, isPrime}

    // when
    output := oddPrimeNumbers(numRange, predicates...)

    // then
    if !reflect.DeepEqual(output, want) {
        t.Errorf("wanted: %v but got: %v", want, output)
    }
}
package main

func isOdd(number int) bool {
    return !isEven(number)
}

func isPrime(n int) bool {
    for i := 2; i * i <= n; i++ {
        if n%i == 0 {
            return false
        }
    }
    return n > 1
} 

func oddPrimeNumbers(numbers []int, predicates ...Predicate) []int {
    if len(predicates) == 0 {
        return []int{}
    }
    return filter(numbers, predicates...)
}

Conclusion: Embracing Genericity for Efficient Filtering

By incorporating these concepts, you can elevate your Go code to a new level of flexibility and power, making it a robust tool for handling even the most intricate data filtering tasks.

Buttttt…..Is it enough? Have we thought of all the scenarios? Are we sure we don't need any more complex real life scenario can come? Let's find out in the next article. Till then Take Care.

Did you find this article valuable?

Support Abhijit Dutta by becoming a sponsor. Any amount is appreciated!