Everyday Applicatives!
I recently revised the Applicatives page on this site, and it got me wondering...when do I use applicatives in my code? Functors are simpler, and monads are more ubiquitous. But applicatives fill kind of an in-between role where I often don't think of them too much.
But a couple weeks ago I encountered one of those small coding problems in my day job that's easy enough to solve, but difficult to solve elegantly. And as someone who works with Haskell, of course I like to make my code as elegant as possible.
But since my day job is in C++, I couldn't find a good solution. I was thinking to myself the whole time, "there's definitely a better solution for this in Haskell". And it turns out I was right! And the answer in this case, was to functions specific to Applicative
!
To learn more about applicative functors and other functional structures, make sure to read our Monads series! But for now, let's explore this problem!.
Setup
So at the most basic level, let's imagine we're dealing with a Messsage
type that has a timestamp
:
class Message {
Time timestamp;
...
}
We'd like to compare two messages based on their timestamps, to see which one is closer to a third timestamp. But to start, our messages are wrapped in a StatusOr
object for handling errors. (This is similar to Either
in Haskell).
void function() {
...
Time baseTime = ...;
StatusOr<Message> message1 = ...;
StatusOr<Message> message2 = ...;
}
I now needed to encode this logic:
- If only one message is valid, do some logic with that message
- If both messages are valid, pick the closer message to the
baseTime
and perform the logic. - If neither message is valid, do a separate branch of logic.
The C++ Solution
So to flesh things out more, I wrote a separate function signature:
void function() {
...
Time baseTime = ...;
StatusOr<Message> message1 = ...;
StatusOr<Message> message2 = ...;
optional<Message> closerMessage = findCloserMessage(baseTime, message1, message2);
if (closerMessage.has_value()) {
// Do logic with "closer" message
} else {
// Neither is valid
}
}
std::optional<Message> findCloserMessage(
Time baseTime,
const StatusOr<Message>& message1,
const StatusOr<Message>& message2) {
...
}
So the question now is how to fill in this helper function. And it's simple enough if you embrace some branches:
std::optional<Message> findCloserMessage(
Time baseTime,
StatusOr<Message> message1,
StatusOr<Message> message2) {
if (message1.isOk()) {
if (message2.isOk()) {
if (abs(message1.value().timestamp - baseTime) < abs(message2.value().timestamp - baseTime)) {
return {message1.value()};
} else {
return {message2.value()};
}
} else {
return {message1.value()};
}
} else {
if (message2.isOk()) {
return {message2.value()};
} else {
return std::nullopt;
}
}
}
Now technically I could combine conditions a bit in the "both valid" case and save myself a level of branching there. But aside from that nothing else really stood out to me for making this better. It feels like we're doing a lot of validity checks and unwrapping with .value()
...more than we should really need.
The Haskell Solution
Now with Haskell, we can actually improve on this conceptually, because Haskell's functional structures give us better ways to deal with validity checks and unwrapping. So let's start with some basics.
data Message = Message
{ timestamp :: UTCTime
...
}
function :: IO ()
function = do
let (baseTime :: UTCTime) = ...
(message1 :: Either IOError Message) <- ...
(message2 :: EIther IOError Message) <- ...
let closerMessage' = findCloserMessage baseTime message1 message2
case closerMessage' of
Just closerMessage -> ...
Nothing -> ...
findCloserMessage ::
UTCTime -> Either IOError Message -> Either IOError Message -> Maybe Message
findCloserMessage baseTime message1 message2 = ...
How should we go about implementing findCloserMessage
?
The answer is in the applicative nature of Either
! We can start by defining a function that operates directly on the messages and determines which one is closer to the base:
findCloserMessage baseTime message1 message2 = ...
where
f :: Message -> Message -> Message
f m1@(Message t1) m2@(Message t2) =
if abs (diffUTCTime t1 baseTime) < abs (diffUTCTime t2 basetime)
then m1 else m2
We can now use the applicative operator <*>
to apply this operation across our Either
values. The result of this will be a new Either
value.
findCloserMessage baseTime message1 message2 = ...
where
f :: Message -> Message -> Message
f m1@(Message t1) m2@(Message t2) =
if abs (diffUTCTime t1 baseTime) < abs (diffUTCTime t2 basetime)
then m1 else m2
bothValidResult :: Either IOError Message
bothValidResult = pure f <*> message1 <*> message2
So if both are valid, this will be our result. But if either of our inputs has an error, we'll get this error as the result instead. What happens in this case?
Well now we can use the Alternative
behavior of many applicative functors such as Either
. This lets us use the <|>
operator to combine Either
values so that instead of getting the first error, we'll get the first success. So we'll combine our "closer" message if both are valid with the original messages:
import Control.Applicative
findCloserMessage baseTime message1 message2 = ...
where
f :: Message -> Message -> Message
f m1@(Message t1) m2@(Message t2) =
if abs (diffUTCTime t1 baseTime) < abs (diffUTCTime t2 basetime)
then m1 else m2
bothValidResult :: Either IOError Message
bothValidResult = pure f <*> message1 <*> message2
allResult :: Either IOError Message
allResult = bothValidResult <|> message1 <|> message2
The last step is to turn this final result into a Maybe
value:
import Control.Applicative
import Data.Either
findCloserMessage ::
UTCTime -> Either IOError Message -> Either IOError Message -> Maybe Message
findCloserMessage baseTime message1 message2 =
if isRight allResult then Just (fromRight allResult) else Nothing
where
f :: Message -> Message -> Message
f m1@(Message t1) m2@(Message t2) =
if abs (diffUTCTime t1 baseTime) < abs (diffUTCTime t2 basetime)
then m1 else m2
bothValidResult :: Either IOError Message
bothValidResult = pure f <*> message1 <*> message2
allResult :: Either IOError Message
allResult = bothValidResult <|> message1 <|> message2
The vital parts of this are just the last 4 lines. We use applicative and alternative operators to simplify the logic that leads to all the validity checks and conditional branching in C++.
Conclusion
Is the Haskell approach better than the C++ approach? Up to you! It feels more elegant to me, but maybe isn't as intuitive for someone else to read. We have to remember that programming isn't a "write-only" activity! But these examples are still fairly straightforward, so I think the tradeoff would be worth it.
Now is it possible to do this sort of refactoring in C++? Possibly. I'm not deeply familiar with the library functions that are possible with StatusOr
, but it certainly wouldn't be as idiomatic.
If you enjoyed this article, make sure to subscribe to our monthly newsletter! You should also check out our series on Monads and Functional Structures so you can learn more of these tricks!