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.