Mainstream programming languages provide various constructs for control flow: conditionals, loops, exceptions, etc. Many of these can be modeled using the general functor concept. In this post I’m going to show you how.
In most programming languages defining a function (or a method, subroutine, procedure, etc.) is simple. Using this function in a real program is more complex: the function’s arguments may be missing (None, NULL, nil, etc.), the computation in the function may fail because of an unexpected condition, the function’s input argument may be the result of an asynchronous call.
In a real program a function call appears in a specific context with a particular control flow. In a given context specific edge cases appear which we must handle as well.
In the next sections I show you how a simple function is typically used in different contexts. I’m using examples written in Python and Haskell.
Modeling a camera
As a working example, let’s model a camera with a projection from a three-dimensional world point coordinates to two-dimensional image point coordinates.
In Haskell the type signature of such a function is:
project :: WorldPoint -> ImagePoint
where I assume that some reasonable definitions of
ImagePoint exist. That is, given a world point
project returns a point on
the camera’s image plane.
In Python the outline of this function would read:
def project(world_point): ... return image_point
The concrete implementation of
project is not important. For the sake of
this article we can assume that all relevant camera parameters are available in
the function’s body.
Let’s see how we could use the
project function in various contexts.
We designed our function to be pure, free of side-effects. Projecting a single
world point is just a matter of calling
project. It’s easy both in Python
In a real world program, however, the situation is rarely that simple.
Imagine that a preceding computation provides an empty world point to our
program. We would like to use
project in this context where the world point
may not be present.
In Python, the missing value could be represented as a
None value. Let’s use
a conditional to check for it:
if world_point: image_point = project(world_point) # use image_point else: # handle the missing value
This is a common pattern, you find such conditionals in every code base.
In Haskell we could accept the world point wrapped in a
Maybe type and use
fmap to compute a projection in case the world point value is present:
let maybeImagePoint = fmap project maybeWorldPoint
where the type of the input and output values are
Maybe WorldPoint and
Maybe ImagePoint, respectively.
This works, because
Maybe is a functor. This means we can use the function
fmap to lift our pure
project function to operate on potentially missing values.
Handling a scene
We rarely work with a single world point, but with a collection of world points which I call here a scene.
If we wanted to project an entire scene on a camera, in Python we could use a list comprehension and write:
image_points = [project(world_point) for world_point in scene]
or more succinctly, using the built-in
image_points = list(map(project, scene))
In Python 3,
map returns an iterator which we
explicitly convert to a list.
In Haskell, choosing a simple list representation for Scene, we write:
-- type Scene = [WorldPoint] let imagePoints = fmap project scene
where the type of
imagePoints is a list of image points.
This is almost identical to the Python code using
map. List is a functor,
fmap on list applies the provided function on each element. This is exactly
map as we know it from Python.
Input from a data file
Let’s imagine that the world point is stored on disk in a data file. Before we
project we need to read and decode the data file.
In Python, we wrap the file operation in a
try: world_point = read_data_file(...) image_point = project(world_point) except (DecodeError e): # handle the error ...
This is also fairly common pattern: the exception handler, if we don’t forget to write it, allows us to handle the potential errors.
In Haskell, it’s again just
-- worldPointOrError :: Either WorldPoint DecodeError -- imagePointOrError :: Either ImagePoint DecodeError let imagePointOrError = fmap project worldPointOrError
Either WorldPoint is the functor and
fmap acts as follows: if the
decoding is successful
project is applied on the returned value. In case of
project function is not used at all and
contain the returned error value.
Because the potential failure is encoded in the data type we cannot forget about error handling: that code would just not compile.
Finally let’s assume that we read the world point from the network. Network communication requires a lot of input/output so let’s do it concurrently with other tasks of our application.
In Python, using the asyncio library we can write concurrent code using the async/await syntax:
async def read_from_network(): # ① await asyncio.sleep(1) return (0, 1, 2) # dummy value async def async_image_point(): # ② return project(await read_from_network()) image_point = asyncio.run(async_image_point()) # ③
read_from_networkis a coroutine simulating an asynchronous action obtaining the world point. In a real application this operation would run concurrently with other coroutines.
async_image_pointapplies the projection on the value returned by
read_from_network. This is also a coroutine and it completes after the network communication has finished.
asyncio.runblocks until the provided coroutine delivers its return value.
image_pointis now a regular image point value.
Now let’s see how something similar works in Haskell:
readFromNetwork :: IO WorldPoint -- ① readFromNetwork = do threadDelay 1000000 -- µs return (0, 1, 2) -- dummy value imagePoint :: IO ImagePoint imagePoint = do worldPoint <- async readFromNetwork -- ② wait (fmap project worldPoint) -- ③
readFromNetworksimulates the network IO: the body of this function can be replaced with calls to a real networking library which do not know anything about asynchronous operations.
Spawn the network operation asynchronously in a separate (lightweight) thread. Note that
asyncis just a library function, not a special keyword.
fmapto lift the transformation into the asynchronous computation, then
waitblocks until the asynchronous action completes.
By looking at the type signature of
fmap specialized to this example:
fmap :: (WorldPoint -> ImagePoint) -> Async WorldPoint -> Async ImagePoint
We can see that
fmap constructs a new asynchronous action where the provided
function is applied to the result of the provided asynchronous action. It
reaches under the
Async constructor and transforms the underlying values.
We looked at how a pure function is used in four different computational contexts: missing values, collections, potentially failing and asynchronous actions.
In Python we used different, specialized language constructs to apply our
- Conditional: to handle the case when the input point may be missing
- List comprehension (or loop): when the function is applied on a collection of points
- Try-except block: when the data file decoding may fail
- Special keywords async/await: when the function operates on results of asynchronous computations
In Haskell we always used
fmap, because these seemingly different
computations can all be modeled as a functor. Instead of using special
language keywords we used one organizing principle borrowed from
Mathematics where the control flow and the edge cases are
You can read more about functors on the Haskell wiki or elsewhere on the Internet.
For the topic I took inspiration from the talk The Human Side of Haskell by Josh Godsiff.