How to perform side-effects in pure functional programming?

Haskell, a pure functional language, handles "impure" functions using "monads." A monad is basically a pattern that makes it easy to chain function calls with continuation passing. Conceptually, the print function in Haskell basically takes three parameters: the string to be printed, the state of the program, and the rest of the program. It calls the rest of the program while passing in a new state of the program where the string is on the screen. This way no state has been modified.

There are many in-depth explanations of how monads work because for some reason people think it's a concept that's difficult to grasp: it's not. You can find many by searching on the Internet, I think this is one that I like the most: http://blog.sigfpe.com/2006/08/you-could-have-invented-monads-and.html


I am dealing with the concept of functional programming for a while now [...] But there is one thing I do not get: How to deal with side-effects when restricting yourself to pure functions.

Claus Reinke posed a similar question when writing his thesis - from page 10 of 210:

How must interactions between a program and an external environment (consisting of, e.g., input/output-devices, file systems, ...) be described in a programming language that abstracts from the existence of an outside world?

For functional languages like Haskell which strive for the mathematical notion of a function, this brings up another question:

  • As mathematics also abstracts from the existence of an external environment (consisting of, e.g., input/output-devices, file systems, etc.):

    In a preliminary sense, mathematics is abstract because it is studied using highly general and formal resources.

    The Applicability of Mathematics (The Internet Encyclopedia of Philosophy).

    then why would anyone try using mathematics to describe interactions with an external environment, namely those which are supported by a programming language?

It just seems counterintuitive...


At a time when "traditional programming languages" were almost always imperative:

During the 1960s, several researchers began work on proving things about programs. Efforts were made to prove that:

  • A program was correct.

  • Two programs with different code computed the same answers when given the same inputs.

  • One program was faster than another.

  • A given program would always terminate.

While these are abstract goals, they are all, really, the same as the practical goal of "getting the program debugged".

Several difficult problems emerged from this work. One was the problem of specification: before one can prove that a program is correct, one must specify the meaning of "correct", formally and unambiguously. Formal systems for specifying the meaning of a program were developed, and they looked suspiciously like programming languages.

The Anatomy of Programming Languages (page 353 of 600), Alice E. Fischer and Frances S. Grodzinsky.

(emphasis added.)

Claus Reinke makes a similar observation - from page 65 of 210:

The notation for interactive programs written in the monadic style is irritatingly close to the notation used in imperative languages.

But there is still a possibility of success:

Researchers began to analyze why it is often harder to prove things about programs written in traditional languages than it is to prove theorems about mathematics. Two aspects of traditional languages emerged as sources of trouble because they are very difficult to model in a mathematical system: mutability and sequencing.

The Anatomy of Programming Languages (same page.)

("Very difficult", but not "impossible" - and apparently less than practical.)

Perhaps the remaining issues will be resolved at some time during the 2260s 2060s, using an extended set of elementary mathematical concepts. Until then, we'll just have to make do with awkward I/O-centric types e.g:

IO is not denotative.

Conal Elliott.


Since IO has (sort of) been explained elsewhere, let's try something different - inspired by Haskell's FFI:

data FF a b  -- abstract "tag" type

foreign import ccall "primArrFF"  arrFF  :: (a -> b) -> FF a b
foreign import ccall "primPipeFF" pipeFF :: FF a b -> FF b c -> FF a c
foreign import ccall "primBothFF" bothFF :: FF a b -> FF c d -> FF (a, c) (b, d)
foreign import ccall "primAltFF"  altFF  :: FF a b -> FF c d -> FF (Either a c) (Either b d)
foreign import ccall "primAppFF"  appFF  :: FF (FF a b, a) b
foreign import ccall "primTieFF"  tieFF  :: FF (a, c) (b, c) -> FF a b
                      ⋮

foreign import ccall "primGetCharFF" getChar :: FF () Char
foreign import ccall "primPutCharFF" putChar :: FF Char ()
                      ⋮

As for Main.main:

module Main(main) where

main :: FF () ()
       ⋮

...which can be expanded to:

module Main() where

foreign export ccall "FF_main" main :: FF () ()
       ⋮

(FF's instances for Arrow, et al are left as an exercise ;-)

So for now (2022 Jan) this how you can deal with side-effects when restricting yourself to ordinary Haskell functions:

  • Introduce an appropriate abstract type (FF a b) for entities with observable effects;
  • Introduce two sets of primitives - one set of combinators (arrFF, pipeFF, bothFF, altFF, appFF, tieFF, etc.) and one set of non-standard morphisms (getChar, putChar, etc);
  • You then define Main.main :: FF () () using ordinary Haskell functions and those FF primitives.

In this way, ordinary Haskell functions can remain free of side-effects - they don't actually "run" FF entities, but build them from other (usually smaller) ones. The only FF entity to be "run" is Main.main via its foreign export, which is called by the runtime system (usually implemented in an imperative language which allows side-effects).


There are a few approaches to this. One thing you will just have to accept is that at some point, there exists a magical impure machine that takes pure expressions and makes them impure by interacting with the environment. You are not supposed to ask questions about this magical machine.

There are two approaches I can think of off the top of my head. There exists at least a third one I have forgotten about.


I/O Streams

The approach that is easiest to understand could be streaming I/O. Your main function takes one argument: a stream of things that have happened on the system – this includes keypresses, files on the file system, and so on. Your main function also returns one thing: a stream of things that you want to happen on the system.

Streams are like lists, mind you, only you can build them one element at a time and the recipient will receive the element as soon as you have built it. Your pure program reads from such a stream, and appends to its own stream when it wants the system to do something.

The glue that makes all of this work is a magical machine that sits outside of your program, reads from the "request" stream and puts stuff into the "answers" stream. While your program is pure, this magical machine is not.

The output stream could look like this:

[print('Hello, world! What is your name?'), input(), create_file('G:\testfile'), create_file('C:\testfile'), write_file(filehandle, 'John')]

and the corresponding input stream would be

['John', IOException('There is no drive G:, could not create file!'), filehandle]

See how the input in the out-stream resulted in 'John' appearing in the in-stream? That's the principle.

Monadic I/O

Monadic I/O is what Haskell does, and does really well. You can imagine this as building a giant tree of I/O commands with operators to glue them together, and then your main function returns this massive expression to a magical machine that sits outside of your program and executes the commands and performs the operations indicated. This magical machine is impure, while your expression-building program is pure.

You might want to imagine this command tree looking something like

main
  |
  +---- Cmd_Print('Hello, world! What is your name?')
  +---- Cmd_WriteFile
           |
           +---- Cmd_Input
           |
           +---+ return validHandle(IOResult_attempt, IOResult_safe)
               + Cmd_StoreResult Cmd_CreateFile('G:\testfile') IOResult_attempt
               + Cmd_StoreResult Cmd_CreateFile('C:\testfile') IOResult_safe

The first thing it does is print a greeting. The next thing it does is that it wants to write a file. To be able to write to the file, it first needs to read from the input whatever it's supposed to write to the file. Then it is supposed to have a file handle to write to. It gets this from a function called validHandle that returns the valid handle of two alternatives. This way, you can mix what looks like impure code with what looks like pure code.


This "explanation" is bordering on asking questions about the magical machine you're not supposed to ask questions about, so I'm going to wrap this up with a few pieces of wisdom.

  • Real monadic I/O looks nowhere near my example here. My example is one of the possible explanations for how monadic I/O can look like "under the hood" without breaking purity.

  • Do not try to use my examples to understand how to work with pure I/O. How something works under the hood is something completely different to how you do things with it. If you had never seen a car before in your life, you wouldn't become a good driver by reading the blueprints for one either.

    The reason I keep saying you're not supposed to ask questions about the magical machine that actually does stuff is that when programmers learn things, they tend to want to go poke at the machinery to try to figure it out. I don't recommend doing so for pure I/O. The machinery might not teach you anything about how to use different variants of I/O.

    This is similar to how you don't learn Java by looking at the disassembled JVM bytecode.

  • Do learn to use monadic I/O and stream-based I/O. It's a cool experience and it's always good to have more tools under your toolbelt.