An Explanation
The following explanation builds upon and modifies this article's explanation:
- Name of original author: Gabriel Gonzalez
- License: CC 4.0 International License
- Changes:
- Convert code examples to Purescript
- Renamed
attackUnit
toattack
to reduce characters per line in code sections - Omitted section on Algebraic Data Types
- Omitted section on Kleisli Composition
Why Callbacks Exist
When writing a library (e.g. GUI toolkit, game engine, etc.), one may want the end-developer to be able to run their own custom code at some point. In the example below, the custom code is represented by the type hole, ?doSomething
:
attack :: Target -> Effect Unit
attack target = do
valid <- isTargetValid target
if valid
then ?doSomething target
else ignoreAttack
Since the library developer does not know how the end-developer will use this function, they can convert ?doSomething
into a callback function that the end-developer supplies:
-- library developer's code
attack :: Target -> (Target -> Effect Unit) -> Effect Unit
attack target doSomething = do
valid <- isTargetValid target
if valid
then doSomething target
else ignoreAttack
-- end-developer's code
attack orc reduceLifeBy50
This makes life easy for the library-developer, but not for the end-developer as a large number of nested callbacks can make code very unreadable. (We only need to look at Node.js for an example)
The Continutation Solution
The problem is not the callback function; there is no other solution for the library-developer. The problem is "where" that function appears in attackUnit
. In short, the type appears in the argument part of the function (attackUnit_arg
) instead of in the return part of the function (attackUnit_return
):
-- original version (curried function)
attack :: Target -> (Target -> Effect Unit) -> Effect Unit
-- original version (uncurried function)
attack :: (Target -> (Target -> Effect Unit)) -> Effect Unit
-- desugar the last "->" into "Function"
attack :: Function (Target -> (Target -> Effect Unit)) Effect Unit
-- make function appear in return type
attack :: Function Target ((Target -> Effect Unit) -> Effect Unit)
-- resugar "->"
attack :: Target -> ((Target -> Effect Unit) -> Effect Unit)
Effect
is a monad, so we can chain multiple sequential computations like that together using bind
/>>=
. If we can do that for Effect
, why not do so for every monad? This changes our type signature of attack
:
attack_no_monad :: Target -> ((Target -> Effect Unit) -> Effect Unit)
attack_monad :: Target -> ((Target -> monad Unit) -> monad Unit)
That's a lot of code to write each time, so it can be converted into a newtype
:
newtype ContT return monad input =
ContT ((input -> monad return) -> monad return)
attack_no_monad :: Target -> ((Target -> Effect Unit) -> Effect Unit)
attack_monad :: Target -> ((Target -> monad Unit) -> monad Unit)
attack_cont :: Target -> ContT Unit Effect Target
To create a ContT
, we create a function whose only argument is the callback function:
ContT (\callbackFunction -> do
-- everything else we did beforehand...
callbackFunction arg
-- everything else we did afterwards...
)
Let's implement it for attack
and compare the two approaches:
attack_original :: Target -> (Target -> Effect Unit) -> Effect Unit
attack_original target doSomething = do
valid <- isTargetValid target
if valid
then doSomething target
else ignoreAttack
attack_contT :: Target -> ContT Unit Effect Target
attack_contT target = ContT (\doSomething -> do
valid <- isTargetValid target
if valid
then doSomething target
else ignoreAttack
)
And if we didn't want to use a monad, we could use Identity
as a placeholder monad:
type Cont return input = ContT return Identity input
Comparing ContT to Another Function
Let's play with ContT
for a bit and see what it reminds us of:
-- Original version
newtype ContT return monad input =
ContT ((input -> monad return) -> monad return)
-- Let's newtype a version of `ContT` when `Identity` is its monad:
newtype ContIdentity return input =
ContIdentity ((input -> Identity return) -> Identity return)
-- Since `Identity` is merely a placeholer monad,
-- let's remove it, and see what the resulting function's signature is:
contDesugared :: forall input return. ((input -> return) -> return)
-- and if we wanted to run `contDesugared`,
-- we'd need an initial `input` value:
runCont :: forall i r. ((i -> r) -> r) -> (i -> r) -> r
runCont contDesugared callbackFunction = contDesugared callbackFunction
-- Hmm... Doesn't that function's type and body look familiar?
-- ((i -> r) -> r) -> (i -> r) -> r
apply :: forall a b. (a -> b) -> a -> b
apply function arg = function arg
infixr 0 apply as $
Exactly. ContT
is just a monad transformer for the apply
/$
function. Let's compare them further:
print 10
-- ==
print $ 10
-- ==
apply print (10)
-- ==
runCont (Cont (\f -> f (10))) print
-- which reduces to
(\f -> f (10)) print
-- which reduces to
print (10)
When You Need Two or More Callback Functions
If attack
is modified, so that it requires two callback functions, ?doSomethingWithDamage
and doSomethingWithBoth
, it would seem that our nice solution from above would no longer work since we can only specify one of the two functions:
attackWith :: Target -> Weapon -> ContT Unit Effect Target
attackWith target weapon = ContT (\only1CallbackFunction -> do
damage <- calculateDamageFor weapon (modifiedBy 1.5)
?doSomethingWithDamage damage
valid <- isTargetValid target
if valid
then ?doSomethingWithBoth target damage
else ignoreAttack
)
The solution is to pass in a callback function that takes a sum type as its argument. When using ContT
/Cont
, the callback function is usually called k
, so we'll do that here, too:
data AllPossibleInputs
-- where each constructor wraps the arguments
-- that will be used in a function
= DoSomethingWithDamage Damage
| DoSomethingWithBoth Target Damage
-- Note: This approach requires the callback function to return the same
-- `monadType returnType` type for each output.
callbackFunction :: AllPossibleInputs -> Effect Unit
callbackFunction (DoSomethingWithBoth t d) = doSomethingWithBoth t d
callbackFunction (DoSomethingWithDamage d) = doSomethingWithDamage d
doSomethingWithBoth :: Target -> Damage -> Effect Unit
-- implementation
doSomethingWithDamage :: Target -> Effect Unit
-- implementation
attackWith :: Target -> Weapon -> ContT Unit Effect Target
attackWith target weapon = ContT (\callback -> do
damage <- calculateDamageFor weapon (modifiedBy 1.5)
callback (DoSomethingWithDamage damage)
valid <- isTargetValid target
if valid
then callback (DoSomethingWithBoth target damage)
else ignoreAttack
)
(I think one might be able to get around the runtime box overhead imposed by AllPossibleInputs
by using type-level programming and Variant
, the open Either
type.)
Consider Your Perspective
If you are... | ... and you come across a situation where... | ... then you should... |
---|---|---|
a library developer | you want to use a callback function | move it from the function's LHS to its RHS using ContT . If it should take different kinds of input, define a sum type as demonstrated above. |
a developer using a library | you need to use a function that requires or returns a ContT | understand that you need to specify what to do at specific points by passing in a callback function via runCont /runContT . You may also need to write a callback function that takes a sum type like that demonstrated above as its input |