The composite pattern in GO

133 views
Skip to first unread message

Travis Keep

unread,
Feb 20, 2021, 1:38:23 PM2/20/21
to golang-nuts
I am writing this to see what everyone thinks about a solution I have for the composite pattern.

The composite pattern is when 0 or more instances of some interface X can act together as a single instance of interface X. For instance you may have a Filter interface that filters instances of class Foo like so.

type Filter interface {
    IsIncluded(ptr *Foo) bool
}

Filter can follow the composite pattern like so:

type sliceFilter []Filter

func (s sliceFilter) IsIncluded(ptr *Foo) bool {
    for _, f := range s {
        if !f.IsIncluded(ptr) {
            return false
        }
    }
    return true
}

The sliceFilter returns true for a Foo instance if and only if all the Filters in the slice filter return true for that Foo instance.

Notice that sliceFilter lets a collection of Filters act as a single Filter instance.

You can also have a Filter instance the represents 0 filters like this.

type nilFilter struct {
}

func (n nilFilter) IsIncluded(ptr *Foo) bool {
    return true
}

When designing an API around Filters you may include a method called Compose that creates a Filter instance out of a bunch of existing Filter instances. A naive implementation might look like this

func Compose(filters ...Filter) Filter {
    return sliceFilter(filters)
}

While this works, it is not great because if the caller passes a Filter slice to Compose and later changes that slice, they unwittingly change the returned composite Filter as a side effect.  A better implementation may look like this.

func Compose(filter ...Filter) Filter {
    result := make(sliceFilter, len(filter))
    copy(result, filter)
    return result
}

This is better because it makes a defensive copy of the slice. If the caller passes a []Filter to Compose and changes it, the returned composite Filter works as expected. But this solution isn't optimal either because it always allocates and returns a slice no matter what the caller passes to it. If the caller passes a single Filter to Compose, Compose should return that Filter as is, not a slice. If the caller passes no arguments to Compose, Compose should return the nilFilter instance whose IsIncluded method always returns true.  If the caller passes a bunch of nil Filters to Compose, Compose should return the nil Filter instance.  If the caller passes a bunch of nilFilters and one non nil Filter to Compose, Compose should return the one non nil Filter.  The only time Compose should allocate and return a slice is if it is passed 2 or more non nil Filters.  If caller passes 2 or more Filters that are slices, Compose should flatten those out into a single slice rather than returning a slice of slices.  

common.Join in github.com/keep94/common handles all these edge cases automatically.  In addition to a slice type, common.Join requires a nil instance which represents 0 of some interface X.

Compose can be written like this

func Compose(filter ...Filter) Filter {
   return common.Join(filter, sliceFilter(nil), nilFilter{}).(Filter)
}

When written like this, Compose will always do the right thing. It will only return a newly allocated slice if 2 or more arguments passed to it are non nil Filters.  If it gets just one non-nil Filter, it simply returns that filter unchanged. If it gets 0 filters, it just returns the nilFilter{} instance.

In conclusion, there are some edge cases to consider when implementing the composite pattern. common.Join can address all these edge cases.
Reply all
Reply to author
Forward
0 new messages