Endomorphic Composite as a monoid by Mark Seemann
A variation of the Composite design pattern uses endomorphic composition. That's still a monoid.
This article is part of a series of articles about design patterns and their category theory counterparts. In a previous article, you learned that the Composite design pattern is simply a monoid.
There is, however, a variation of the Composite design pattern where the return value from one step can be used as the input for the next step.
Endomorphic API #
Imagine that you have to implement some scheduling functionality. For example, you may need to schedule something to happen a month from now, but it should happen on a bank day, during business hours, and you want to know what the resulting date and time will be, expressed in UTC. I've previously covered the various objects for performing such steps. The common behaviour is this interface:
public interface IDateTimeOffsetAdjustment { DateTimeOffset Adjust(DateTimeOffset value); }
The Adjust
method is an endomorphism; that is: the input type is the same as the return type, in this case DateTimeOffset
. A previous article already established that that's a monoid.
Composite endomorphism #
If you have various implementations of IDateTimeOffsetAdjustment
, you can make a Composite from them, like this:
public class CompositeDateTimeOffsetAdjustment : IDateTimeOffsetAdjustment { private readonly IReadOnlyCollection<IDateTimeOffsetAdjustment> adjustments; public CompositeDateTimeOffsetAdjustment( IReadOnlyCollection<IDateTimeOffsetAdjustment> adjustments) { if (adjustments == null) throw new ArgumentNullException(nameof(adjustments)); this.adjustments = adjustments; } public DateTimeOffset Adjust(DateTimeOffset value) { var acc = value; foreach (var adjustment in this.adjustments) acc = adjustment.Adjust(acc); return acc; } }
The Adjust
method simply starts with the input value
and loops over all the composed adjustments
. For each adjustment
it adjusts acc
to produce a new acc
value. This goes on until all adjustments
have had a chance to adjust the value.
Notice that if adjustments
is empty, the Adjust
method simply returns the input value. In that degenerate case, the behaviour is similar to the identity function, which is the identity for the endomorphism monoid.
You can now compose the desired behaviour, as this parametrised xUnit.net test demonstrates:
[Theory] [InlineData("2017-01-31T07:45:55+2", "2017-02-28T07:00:00Z")] [InlineData("2017-02-06T10:03:02+1", "2017-03-06T09:03:02Z")] [InlineData("2017-02-09T04:20:00Z" , "2017-03-09T09:00:00Z")] [InlineData("2017-02-12T16:02:11Z" , "2017-03-10T16:02:11Z")] [InlineData("2017-03-14T13:48:29-1", "2017-04-13T14:48:29Z")] public void AdjustReturnsCorrectResult( string dtS, string expectedS) { var dt = DateTimeOffset.Parse(dtS); var sut = new CompositeDateTimeOffsetAdjustment( new NextMonthAdjustment(), new BusinessHoursAdjustment(), new DutchBankDayAdjustment(), new UtcAdjustment()); var actual = sut.Adjust(dt); Assert.Equal(DateTimeOffset.Parse(expectedS), actual); }
You can see the implementation for all four composed classes in the previous article. NextMonthAdjustment
adjusts a date by a month into its future, BusinessHoursAdjustment
adjusts a time to business hours, DutchBankDayAdjustment
takes bank holidays and weekends into account in order to return a bank day, and UtcAdjustment
convert a date and time to UTC.
Monoidal accumulation #
As you've learned in that previous article that I've already referred to, an endomorphism is a monoid. In this particular example, the binary operation in question is called Append
. From another article, you know that monoids accumulate:
public static IDateTimeOffsetAdjustment Accumulate( IReadOnlyCollection<IDateTimeOffsetAdjustment> adjustments) { IDateTimeOffsetAdjustment acc = new IdentityDateTimeOffsetAdjustment(); foreach (var adjustment in adjustments) acc = Append(acc, adjustment); return acc; }
This implementation follows the template previously described:
- Initialize a variable
acc
with the identity element. In this case, the identity is a class calledIdentityDateTimeOffsetAdjustment
. - For each
adjustment
inadjustments
,Append
theadjustment
toacc
. - Return
acc
.
mconcat
. We'll get back to that in a moment, but first, here's another parametrised unit test that exercises the same test cases as the previous test, only against a composition created by Accumulate
:
[Theory] [InlineData("2017-01-31T07:45:55+2", "2017-02-28T07:00:00Z")] [InlineData("2017-02-06T10:03:02+1", "2017-03-06T09:03:02Z")] [InlineData("2017-02-09T04:20:00Z" , "2017-03-09T09:00:00Z")] [InlineData("2017-02-12T16:02:11Z" , "2017-03-10T16:02:11Z")] [InlineData("2017-03-14T13:48:29-1", "2017-04-13T14:48:29Z")] public void AccumulatedAdjustReturnsCorrectResult( string dtS, string expectedS) { var dt = DateTimeOffset.Parse(dtS); var sut = DateTimeOffsetAdjustment.Accumulate( new NextMonthAdjustment(), new BusinessHoursAdjustment(), new DutchBankDayAdjustment(), new UtcAdjustment()); var actual = sut.Adjust(dt); Assert.Equal(DateTimeOffset.Parse(expectedS), actual); }
While the implementation is different, this monoidal composition has the same behaviour as the above CompositeDateTimeOffsetAdjustment
class. This, again, emphasises that Composites are simply monoids.
Endo #
For comparison, this section demonstrates how to implement the above behaviour in Haskell. The code here passes the same test cases as those above. You can skip to the next section if you want to get to the conclusion.
Instead of classes that implement interfaces, in Haskell you can define functions with the type ZonedTime -> ZonedTime
. You can compose such functions using the Endo
newtype
'wrapper' that turns endomorphisms into monoids:
λ> adjustments = reverse [adjustToNextMonth, adjustToBusinessHours, adjustToDutchBankDay, adjustToUtc] λ> :type adjustments adjustments :: [ZonedTime -> ZonedTime] λ> adjust = appEndo $ mconcat $ Endo <$> adjustments λ> :type adjust adjust :: ZonedTime -> ZonedTime
In this example, I'm using GHCi (the Haskell REPL) to show the composition in two steps. The first step creates adjustments
, which is a list of functions. In case you're wondering about the use of the reverse
function, it turns out that mconcat
composes from right to left, which I found counter-intuitive in this case. adjustToNextMonth
should execute first, followed by adjustToBusinessHours
, and so on. Defining the functions in the more intuitive left-to-right direction and then reversing it makes the code easier to understand, I hope.
(For the Haskell connoisseurs, you can also achieve the same result by composing Endo
with the Dual
monoid, instead of reversing the list of adjustments.)
The second step composes adjust
from adjustments
. It first maps adjustments
to Endo
values. While ZonedTime -> ZonedTime
isn't a Monoid
instances, Endo ZonedTime
is. This means that you can reduce a list of Endo ZonedTime
with mconcat
. The result is a single Endo ZonedTime
value, which you can then unwrap to a function using appEndo
.
adjust
is a function that you can call:
λ> dt 2017-01-31 07:45:55 +0200 λ> adjust dt 2017-02-28 07:00:00 +0000
In this example, I'd already prepared a ZonedTime
value called dt
. Calling adjust
returns a new ZonedTime
adjusted by all four composed functions.
Conclusion #
In general, you implement the Composite design pattern by calling all composed functions with the original input value, collecting the return value of each call. In the final step, you then reduce the collected return values to a single value that you return. This requires the return type to form a monoid, because otherwise, you can't reduce it.
In this article, however, you learned about an alternative implementation of the Composite design pattern. If the method that you compose has the same output type as its input, you can pass the output from one object as the input to the next object. In that case, you can escape the requirement that the return value is a monoid. That's the case with DateTimeOffset
and ZonedTime
: neither are monoids, because you can't add two dates and times together.
At first glance, then, it seems like a falsification of the original claim that Composites are monoids. As you've learned in this article, however, endomorphisms are monoids, so the claim still stands.
Next: Null Object as identity.