Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our course on programming theory.
In our last lesson, we explored referential transparency—the property that allows an expression to be replaced by its value without changing a program's behavior. We saw that this is the theoretical ideal underpinning functional programming, rooted in lambda calculus, but that early languages like Lisp made practical compromises, allowing features that broke this property.
Today, we move from the why to the how. We will focus on the practical discipline of writing functions that uphold referential transparency. This lesson is designed to meet the following learning outcome:
Write pure functions that avoid side effects, tracing this concept from Lisp through modern functional languages.
We will formally define pure functions and side effects, practice writing them, and trace the evolution of how programming languages have handled the essential tension between pure computation and interacting with the outside world, from Lisp's pragmatism to the managed effects of modern functional languages.
1. What is a Pure Function?
In the previous lesson, we established that referential transparency is achieved by avoiding side effects. A function that is referentially transparent is called a pure function.
A function is pure if it satisfies two conditions:
- Deterministic: Its return value is the same for the same arguments.
- No Side Effects: Its evaluation has no observable interaction with the outside world beyond returning a value.
"Side effects" include, but are not limited to:
- Modifying a global variable or a mutable argument.
- Performing any I/O (e.g., writing to the console, reading from a file).
- Making a network request.
- Calling another impure function.
Let's watch a very brief, clear definition of a pure function.
Distributed Pure Functions by Richard Feldman
Richard Feldman provides a concise and accurate definition of a pure function, which will serve as our foundation for this lesson.
About a minute and a half into the video, watch pure function constraints. Focus on the two constraints that define a pure function.
Examples in Python
Let's make this concrete. Given your proficiency in Python, we can illustrate the distinction easily.
Pure Functions:
These functions are like mathematical mappings. Their behavior is entirely predictable.
# Depends only on its inputs, has no side effects.
def add(x, y):
return x + y
# Also pure. It creates a new list, it doesn't modify the input list.
def to_uppercase(words):
return [word.upper() for word in words]
Impure Functions:
These functions are not referentially transparent. You cannot simply replace a call to them with their return value.
import random
from datetime import datetime
# Impure: Not deterministic. Violates condition 1.
def get_lucky_number():
return random.randint(1, 100)
# Impure: Has a side effect (modifies a global variable). Violates condition 2.
user_cache = {}
def cache_user(user_id, user_data):
user_cache[user_id] = user_data
# The return value (None) is irrelevant; the side effect is the point.
# Impure: Has a side effect (I/O). Violates condition 2.
def save_report(report_data):
with open("report.txt", "w") as f:
f.write(report_data)
Writing pure functions is a discipline of constraining yourself to only operate on inputs and produce outputs, leaving all interaction with the "world" to other parts of your system.
2. The Historical Arc: From Lisp's Pragmatism to Haskell's Purity
The tension between the elegance of pure functions and the necessity of side effects has shaped the history of functional languages.
Lisp's Compromise
As we touched on last time, Lisp was a pioneer but also a pragmatist. It was built around a core of pure, expression-based computation but included impure functions from the very beginning.
Concepts in Programming Languages
Let's revisit the 'Concepts in Programming Languages' notes by John C. Mitchell to see the specific mention of Lisp's dual nature.
On slide 40 (titled "Some contributions of LISP"), read LISP contributions. Focus on the final sentence of that slide: 'The core of LISP is pure functional, but impure (side-effecting) constructs (such as SETQ, RPLACA, RPLACD) were there from the start.'
SETQwas used for assignment (mutating a variable's value).RPLACAandRPLACDwere used to modify list structures in-place.
This design choice made Lisp a powerful and practical tool, but it meant that programmers had to manage the complexity of side effects themselves, and the language couldn't provide guarantees about purity.
The ML Family and Haskell's Strictness
Later languages made different design choices.
- The ML family (including SML and OCaml) promoted a more functional style but still allowed side effects, treating them as a necessary evil.
- Haskell, developed in the late 1980s, took a radical stance: it enforced purity. In Haskell, all functions are pure by default. There is no way to write a function that performs a side effect directly.
This raises a critical question: If all functions are pure, how can a Haskell program do anything useful, like read a file or print to the screen?
The answer lies in using the type system to distinguish between pure computations and actions that interact with the world.
Concepts in Programming Languages
This section from the 'Concepts' notes explains how I/O is handled in pure vs. impure functional languages, introducing the concept of monads as a way to manage effects.
Please read slides 207-209, from 'I/O in functional languages' to 'So, how does I/O work?'. Starting just below the 'I/O in functional languages' section heading on slide 207 and continuing through to the bottom of slide 209, read monadic I/O. Focus on the contrast between the ML approach and the Haskell approach, and the idea of giving different types to pure and impure functions (A → B vs. A → B M).
Haskell's solution is to make I/O operations return a description of an action, wrapped in a special type called IO. For example, getLine doesn't return a String; it returns an IO String—a value that represents the action of "when executed, this will produce a string." The main function of a Haskell program is itself just a value of type IO (), a description of the entire program's interactions with the world, which the Haskell runtime then executes.
This brings us to the modern functional approach for handling effects.
3. Managing Effects: The Modern Functional Approach
The core idea that enables languages like Haskell to remain pure while still being useful is the separation of describing an effect from executing it. Pure functions can create and combine descriptions of effects; only the language's runtime actually performs them.
This concept of "managed effects" versus "side effects" is explained brilliantly in the following video.
Distributed Pure Functions by Richard Feldman
Richard Feldman explains how purely functional languages handle effects by returning data that describes the work to be done, rather than performing the work directly. This is a crucial concept.
Please watch the following clips: Handling Effects in Purely Functional Languages — starting around 38 minutes in, watch describing effects: Focus on the core idea of a function returning a description of an effect (data) rather than performing the effect itself. The '!' Operator and Effect Descriptions in Roc — just past the 41-minute mark, watch Roc's ! operator: This shows how a modern language, Roc, provides syntactic sugar to make these effect descriptions feel imperative and sequential, while remaining pure under the hood. This is a fascinating point on language design.
Modeling Managed Effects in Python
We can't enforce purity in Python, but we can use this pattern to structure our code. This makes the core logic testable and predictable, isolating the messy, unpredictable parts.
Consider a simple program that asks for a user's name and greets them.
The Impure Way:
The logic and the side effects (I/O) are intertwined.
def impure_greeting():
# Side effect: reading from stdin
name = input("What is your name? ")
# Side effect: writing to stdout
print(f"Hello, {name}!")
# To test this, you'd need to mock `input` and `print`.
The Pure, "Managed Effect" Way:
Here, we separate the pure logic from the effectful execution.
# --- Pure Business Logic ---
# These functions are pure. They just create data (dictionaries).
def create_prompt_action():
"""Returns a description of the action to get input."""
return {'type': 'PROMPT', 'prompt': 'What is your name?'}
def create_greeting_action(name):
"""Returns a description of the action to print a greeting."""
return {'type': 'PRINT', 'text': f"Hello, {name}!"}
# --- Impure "Runtime" / Interpreter ---
# This part of the system is responsible for executing the actions.
def run_program():
# The pure function describes the first action
action1 = create_prompt_action()
# The runtime executes the action
if action1['type'] == 'PROMPT':
name = input(action1['prompt'])
# The pure function describes the second action
action2 = create_greeting_action(name)
# The runtime executes the action
if action2['type'] == 'PRINT':
print(action2['text'])
# Run the program
run_program()
In this second version, create_prompt_action and create_greeting_action are pure. You can test them easily: create_greeting_action("Alice") will always return {'type': 'PRINT', 'text': 'Hello, Alice!'}. The unpredictable I/O is isolated in the run_program interpreter. This pattern is at the heart of how modern functional applications are built.
4. The Payoff: Why Purity Matters
Embracing the constraint of purity yields significant benefits, especially in complex systems like the ones you've built.
- Testability: As seen above, pure functions are trivial to unit test. You provide inputs and assert the output. No need for complex mocking of databases, network connections, or filesystems.
- Predictability and Reasoning: Pure functions are predictable. Their behavior doesn't depend on hidden state or the time of day. This allows for the equational reasoning we discussed in the last lesson, making code easier to understand, debug, and refactor.
- Concurrency and Parallelism: This is a major advantage. If two functions are pure, they cannot interfere with each other. There are no shared mutable states to cause race conditions. You can run them in parallel with the guarantee that the result will be the same as running them sequentially. As the
Conceptsnotes mention, this can make programs "embarrassingly parallel".
The distinction between managed effects and side effects is critical for building robust, distributed systems.
Distributed Pure Functions by Richard Feldman
To conclude, let's hear Feldman summarize the benefits you lose when you move from managed effects back to side effects.
Near the end of the talk, about 47 minutes in, watch side effects tradeoffs. Note the specific capabilities—safe retries, timeouts, memoization—that are lost when functions have direct side effects.
For systems requiring high reliability, like financial settlement or trading platforms, these guarantees are not just academic—they are essential for correctness.
Conclusion
In this lesson, we've moved from the theory of referential transparency to the practice of writing pure functions.
Key Takeaways:
- A pure function is deterministic and has no side effects. Its behavior depends only on its inputs.
- The history of functional languages shows an evolution in handling effects:
- Lisp mixed a pure core with impure, side-effecting functions for practicality.
- Haskell enforced purity, pioneering a new model for interacting with the world.
- Modern functional programming relies on managed effects: pure functions return data structures that describe actions. A separate runtime or interpreter is responsible for executing these actions.
- This separation of concerns makes code more testable, predictable, and suitable for concurrent/parallel execution.
Preview of the Next Lesson:
One of the most common side effects is the mutation of data. The functional paradigm's solution is to use immutable data structures. In our next lesson, we will analyze the design trade-offs of immutability, comparing implementations in different languages and exploring its surprising performance implications.