Reader as a profunctor by Mark Seemann
Any function gives rise to a profunctor. An article for object-oriented programmers.
This article is an instalment in a short article series about profunctors. It assumes that you've read the introduction.
Previous articles in the overall article series on functors and similar abstractions discussed Reader as both a covariant functor, as well as a contravariant functor. As the profunctor introduction intimated, if you combine the properties of both co- and contravariance, you'll have a profunctor.
There's a wide selection of well-known functors and a smaller selection of contravariant functors. Of all those that I've covered so far, only one appears in both lists: Reader.
Reader #
Consider, again, this IReader
interface:
public interface IReader<R, A> { A Run(R environment); }
When discussing IReader
as a covariant functor, we'd fix R
and let A
vary. When discussing the same interface as a contravariant functor, we'd fix A
and let R
vary. If you allow both to vary freely, you have a profunctor.
As the profunctor overview article asserted, you can implement ContraMap
and Select
with DiMap
, or you can implement DiMap
with ContraMap
and Select
. Since previous articles have supplied both Select
and ContraMap
for IReader
, it's a natural opportunity to see how to implement DiMap
:
public static IReader<R1, A1> DiMap<R, R1, A, A1>( this IReader<R, A> reader, Func<R1, R> contraSelector, Func<A, A1> coSelector) { return reader.ContraMap(contraSelector).Select(coSelector); }
You simply pass contraSelector
to ContraMap
and coSelector
to Select
, while chaining the two method calls. You can also flip the order in which you call these two functions - as long as they are both pure functions, it'll make no difference. You'll shortly see an example of flipping the order.
First, though, an example of using DiMap
. Imagine that you have this IReader
implementation:
public sealed class TotalSecondsReader : IReader<TimeSpan, double> { public double Run(TimeSpan environment) { return environment.TotalSeconds; } }
This class converts a TimeSpan
value into the total number of seconds represented by that duration. You can project this Reader in both directions using DiMap
:
[Fact] public void ReaderDiMapExample() { IReader<TimeSpan, double> reader = new TotalSecondsReader(); IReader<DateTime, bool> projection = reader.DiMap((DateTime dt) => dt.TimeOfDay, d => d % 2 == 0); Assert.True(projection.Run(new DateTime(3271, 12, 11, 2, 3, 4))); }
This example maps the Reader from TimeSpan
to DateTime
by mapping in the opposite direction: The lambda expression (DateTime dt) => dt.TimeOfDay
returns the time of day of a given DateTime
. This value is a TimeSpan
value representing the time passed since midnight on that date.
The example also checks whether or not a double
value is an even number.
When the resulting projection
is executed, the expected result is true
because the input date and time is first converted to a time of day (02:03:04) by the contraSelector
. Then TotalSecondsReader
converts that duration to the total number of seconds: 2 * 60 * 60 + 3 * 60 + 4 = 7,384. Finally, 7,384 is an even number, so the output is true
.
Raw functions #
As already explained in the previous Reader articles, the IReader
interface is mostly a teaching device. In a language where you can treat functions as values, you don't need the interface. In C#, for example, the standard function delegates suffice. You can implement DiMap
directly on Func<A, B>
:
public static Func<R1, A1> DiMap<R, R1, A, A1>( this Func<R, A> func, Func<R1, R> contraSelector, Func<A, A1> coSelector) { return func.Select(coSelector).ContraMap(contraSelector); }
As promised, I've here flipped the order of methods in the chain, so that the implementation first calls Select
and then ContraMap
. This is entirely arbitrary, and I only did it to demonstrate that the order doesn't matter.
Here's another usage example:
[Fact] public void AreaOfDate() { Func<Version, int> func = v => v.Major + v.Minor * v.Build; Func<DateTime, double> projection = func.DiMap( (DateTime dt) => new Version(dt.Year, dt.Month, dt.Day), i => i * i * Math.PI); Assert.Equal( expected: 16662407.390443427686297314140028, actual: projection(new DateTime(1991, 12, 26))); }
This example starts with a nonsensical function that calculates a number from a Version
value. Using DiMap
the example then transforms func
into a function that produces a Version
from a DateTime
and also calculates the area of a circle with the radius i
.
Clearly, this isn't a useful piece of code - it only demonstrates how DiMap
works.
Identity law #
As stated in the profunctor introduction, I don't intend to make much of the profunctor laws, since they are only reiterations of the (covariant) functor and contravariant functor laws. Still, an example (not a proof) of the profunctor identity law may be in order:
[Fact] public void ProfunctorIdentityLaw() { Func<Guid, int> byteRange = g => g.ToByteArray().Max() - g.ToByteArray().Min(); T id<T>(T x) => x; Func<Guid, int> projected = byteRange.DiMap<Guid, Guid, int, int>(id, id); var guid = Guid.NewGuid(); Assert.Equal(byteRange(guid), projected(guid)); }
This example uses another silly function. Given any Guid
, the byteRange
function calculates the difference between the largest and smallest byte in the value. Projecting this function with the identity function id
along both axes should yield a function with the same behaviour. The assertion phase generates an arbitrary Guid
and verifies that both byteRange
and projected
produce the same resulting value.
Haskell #
As usual, I've adopted many of the concepts and ideas from Haskell. The notion of a profunctor is so exotic that, unlike the Contravariant type class, it's not (to my knowledge) part of the base library. Not that I've ever felt the need to import it, but if I did, I would probably use Data.Profunctor. This module defines a Profunctor
type class, of which a normal function (->)
is an instance. The type class defines the dimap
function.
We can replicate the above AreaOfDate
example using the Profunctor
type class, and the types and functions in the time library.
First, I'll implement func
like this:
func :: Num a => (a, a, a) -> a func (maj, min, bld) = maj + min * bld
Instead of using a Version
type (which I'm not sure exists in the 'standard' Haskell libraries) this function just uses a triple (three-tuple).
The projection is a bit more involved:
projection :: Day -> Double projection = dimap ((\(maj, min, bld) -> (fromInteger maj, min, bld)) . toGregorian) (\i -> toEnum (i * i) * pi) func
It basically does the same as the AreaOfDate
, but the lambda expressions look more scary because of all the brackets and conversions. Haskell isn't always more succinct than C#.
> projection $ fromGregorian 1991 12 26 1.6662407390443427e7
Notice that GHCi returns the result in scientific notation, so while the decimal separator seems to be oddly placed, the result is the same as in the C# example.
Conclusion #
The Reader functor is not only a (covariant) functor and a contravariant functor. Since it's both, it's also a profunctor. And so what?
This knowledge doesn't seem immediately applicable, but shines an interesting light on the fabric of code. If you squint hard enough, most programming constructs look like functions, and functions are profunctors. I don't intent to go so far as to claim that 'everything is a profunctor', but the Reader profunctor is ubiquitous.
I'll return to this insight in a future article.
Next: Invariant functors.