Could someone please explain to me, what is the purpose of the typeclass Traversable
?
The typeclass definition is:
class (Functor t, Folda
The result type of the application f a has to be an applicative, why? Would a functor not be enough?
This is a fantastic question. The original McBride & Paterson paper goes in the other direction: it notices that a lot of computation are applicative in nature (can be rewritten with pure
and <*>
). Then it notices that certain containers, like []
, allow for a function of this type:
idist :: Applicative f => [f a] -> f [a]
idist = ...
This we now call sequence
in the Traversable
class. All well and good, but it helps to probe the strength of our assumptions when we write abstractions. What if we tried to build a traversable library without Applicative
, using only Functor
? What exactly would go wrong?
For this it helps to read through the Jaskelioff & Rypacek paper that tries to pin down structures in category theory that correspond to applicative functors and traversable containers. The most interesting property of traversable containers is that they are closed under finite sums and products. This is great for Haskell programming, where a vast number of datatypes can be defined with sums and products:
data WeirdSum a = ByList [a] | ByMaybe (Maybe a)
instance Traversable WeirdSum where
traverse a2fb (ByList as) =
ByList <$> traverse a2fb as
traverse a2fb (ByMaybe maybeA) =
ByMaybe <$> traverse a2fb maybeA
Ah, more evidence that we do not need all the power of Applicative! We are only using fmap
here. Now finite products:
data WeirdProduct a = WeirdProduct [a] (Maybe a)
instance Traversable WeirdProduct where
traverse a2fb (WeirdProduct as aMaybe) =
WeirdProduct <$> traverse a2fb as <*> traverse a2fb aMaybe
Here it is impossible to write a definition with just functors: fmap
is great for sums but gives us no way to "glue" together two different functorial values. It is only with <*>
that we are able to "close" traversable containers over finite products.
This is all well and good but lacks precision. We are sort of cherry-picking evidence here that Functor
might be bad, but could we argue from first principles that Applicative
is exactly what we need, no more and no less?
This problem is tackled in the second half of the Jaskelioff & Rypacek paper. In category-theoretic terms, a functor T
is traversable iff it allows for a family of natural transformations
{ sequence | sequence : TFX -> FTX, any applicative F }
where each natural transformation is "natural in F" and respects the "monoidal structure of applicative functor composition." It is that last phrase, that last little piece of jargon, where it is important to have Applicative
rather than Functor
. With Applicative f
, we are able to glue together values of type f a
and f b
, where we either act on them (a la foo <$> fa <*> fb
where foo :: a -> b -> c
and fa, fb :: f a, f b
) or just shove them into a tuple f (a, b)
. This gives rise to the aforementioned "monoidal structure"; we need this to then prove that traversable functors are closed over finite products, just like we showed above. Without applicatives we couldn't even begin talking about how functors and products interact! If Hask is our category of Haskell types, then an applicative is just a way to name Hask-to-Hask endofunctors that "behave well" around (->)
types and product types.
Hopefully this two-pronged answer, one in practical programming and one in categorical foo-foo, gives a little intuition as to why you want applicative functors when talking about traversability. I think often traversables are introduced with an element of magic around them, but they are very much motivated by practical concerns with solid theoretical foundations. Other language ecosystems may have easier-to-use iteration patterns and libraries, but I for one love the simplicity and elegance of traverse
and sequence
.
Traversable
in Haskell unifies the concept of mapping over a container (getting a similary-shaped container in return) with the concept of "internal iterator" that performs an effect for each element.
Compared to external iterators, internal iterators are constrained in that we can't use a value obtained for one element to decide what to do with other elements. We can't say "mmmm, if the operation returns 7 for some element, launch the missiles when processing the next one".
This type of "rigid" computations, that can't change course based on values determined mid-way, is represented in Haskell by the Applicative
typeclass. That's the reason Traversable
(the containers) and Applicative
(the effects) go hand-in-hand. Functor
is not enough because it doesn't provide a method to combine effectful actions.
Allowing any kind of Applicative
effect is a boon; it means we can traverse a container performing IO, existing early from failure, collecting log messages, collecting error messages from failed iterations, iterating concurrently... or any combination of those effects.
Identity
is a bit of a poor example as it always contains exactly one value. You're right – in this case, a Functor f
constraint would be sufficient. But clearly, most traversables aren't so structurally trivial.
What traverse
does is: it “visits”, in some well-specified order, all elements in a container, performs some operation on them, and reconstructs the structure as it was. This is more powerful than either
Functor t
, which also allows you to visit/modify all elements and reconstructs the structure, but only completely independent of one another (thus allowing to choose an arbitrary order-of-computation, returning a thunk to the structure before any of the elements have been (lazily) mapped at all, etc.).Foldable t
, which brings the elements in a linear order, but does not reconstruct the structure. Basically, Foldable
is just the class of containers that can be demoted to a simple list, as witnessed by
toList :: Foldable t => t a -> [a]
...or to a concatenation of any monoidal type, via
foldMap :: (Foldable t, Monoid m) => (a -> m) -> t a -> m
Here, the results of the operation on each element are combined through the monoid operation (or, in case the are no elements, the result is mempty
).
In case of traverse
, the Applicative f
constraint basically lifts this monoid-combining to something in which you can also reconstruct the structure. The correspondence is
mempty :: m
pure mempty :: f m
and
(<>) :: m -> m -> m
liftA2 (<>) :: f m -> f m -> f m
...but in addition, because f
is also a functor, you can wrap the local results in any data constructor and thus build not only a generic list-like thing but an arbitrary container, including one with the original structure.