Module 4: Principles of Programming Languages All Modules

Module 4: Overview

This module introduces the foundational concepts studied in Principles of Programming Languages (PoPL) — a field concerned with the design, analysis, and implementation of programming languages themselves. Rather than focusing on how to write programs in a specific language, PoPL asks deeper questions: Why are languages designed the way they are? What trade-offs do different design choices introduce? How do we reason formally about what a program does?

Understanding these concepts makes you a more adaptable programmer — one who can pick up new languages quickly, reason about subtle bugs (especially around scoping, typing, and memory), and make informed design decisions when building systems or DSLs. Code examples throughout this module are written in multiple languages — C, Python, Haskell, and JavaScript — to illustrate how the same concept appears differently across paradigms.

Section 1: Programming Paradigms

A programming paradigm is a fundamental style or model of programming that shapes how a programmer structures and thinks about solutions. Most modern languages support multiple paradigms, but they tend to favor one strongly. Understanding the major paradigms helps you recognise which tool fits a given problem — and why the designers of a language made the choices they did.

1.1 Imperative Programming

Imperative programming is the oldest and most directly machine-aligned paradigm. Programs are written as a sequence of statements that explicitly change program state. The programmer specifies how to compute a result, step by step. C is the canonical imperative language.

C — Imperative
// Compute the sum of an array imperatively
int sum_array(int* arr, int n) {
    int total = 0;          // mutable state
    for (int i = 0; i < n; i++) {
        total += arr[i];    // explicit step-by-step mutation
    }
    return total;
}

1.2 Object-Oriented Programming (OOP)

OOP organises programs around objects — bundles of data (attributes) and the operations that act on that data (methods). The four core principles are encapsulation, abstraction, inheritance, and polymorphism. Java, Python, and C++ are primary examples.

Python — Object-Oriented
class Animal:
    def __init__(self, name):
        self.name = name        # encapsulated state

    def speak(self):            # overridden by subclasses (polymorphism)
        raise NotImplementedError

class Dog(Animal):              # inheritance
    def speak(self):
        return f"{self.name} says: Woof!"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says: Meow!"

animals = [Dog("Rex"), Cat("Whiskers")]
for a in animals:
    print(a.speak())            # same interface, different behaviour

1.3 Functional Programming

Functional programming treats computation as the evaluation of mathematical functions. The key ideas are immutability (data is never changed in place), pure functions (no side effects — same input always gives same output), and first-class functions (functions are values that can be passed and returned). Haskell is the purest functional language; Python and JavaScript support a functional style.

Haskell — Functional
-- Pure function: no mutation, no side effects
sumList :: [Int] -> Int
sumList []     = 0
sumList (x:xs) = x + sumList xs

-- The same computation using a built-in fold
sumList' :: [Int] -> Int
sumList' = foldr (+) 0

-- Functions are first-class values
applyTwice :: (a -> a) -> a -> a
applyTwice f x = f (f x)

1.4 Logic / Declarative Programming

In declarative programming the programmer specifies what the answer should satisfy, not how to compute it. Logic programming (Prolog) takes this furthest — programs are sets of logical facts and rules, and the runtime searches for solutions by inference. SQL is the most widely-used declarative language.

Prolog — Logic
% Facts
parent(tom, bob).
parent(bob, ann).

% Rule: X is an ancestor of Y if X is a parent of Y,
%       or X is a parent of someone who is an ancestor of Y.
ancestor(X, Y) :- parent(X, Y).
ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).

% Query: ?- ancestor(tom, ann).  => true

Paradigm Comparison

Paradigm Core idea State Key languages
Imperative Step-by-step instructions that change state Mutable C, Assembly, Pascal
Object-Oriented Objects bundle state and behaviour Mutable (encapsulated) Java, Python, C++, Ruby
Functional Pure functions, immutability, function composition Immutable Haskell, Erlang, Clojure, F#
Logic / Declarative Specify constraints; runtime finds solutions Stateless Prolog, SQL, Datalog

Section 2: Syntax, Grammars, and Semantics

Every programming language needs a precise, unambiguous definition of what constitutes a valid program. This definition is split into two layers: syntax (what sequences of characters are legal) and semantics (what those legal programs mean or do).

2.1 Formal Grammars and BNF

Language syntax is most commonly described using a context-free grammar (CFG). The standard notation for writing these is Backus–Naur Form (BNF), introduced by John Backus and Peter Naur to define ALGOL 60. In BNF, <name> denotes a non-terminal (something to be expanded), ::= means "is defined as", and | separates alternatives.

BNF — Simple Arithmetic Expression Grammar
<expr>   ::= <term> | <expr> "+" <term> | <expr> "-" <term>
<term>   ::= <factor> | <term> "*" <factor> | <term> "/" <factor>
<factor> ::= <number> | "(" <expr> ")"
<number> ::= <digit> | <number> <digit>
<digit>  ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"

This grammar encodes operator precedence: multiplication binds tighter than addition because <term> is nested more deeply than <expr>. Extended BNF (EBNF) adds shorthand for optional elements ([ ]), repetition ({ }), and grouping, making grammars more concise.

2.2 Parse Trees and ASTs

A parse tree (or concrete syntax tree) is a tree that shows every step of the grammar derivation. An Abstract Syntax Tree (AST) is a simplified version that strips away redundant structure (parentheses, keywords) and retains only the semantic content. Compilers and interpreters work almost exclusively with ASTs.

For example, parsing 3 + 4 * 2 with the grammar above produces an AST where the * node is a child of the + node, correctly reflecting that multiplication has higher precedence.

2.3 Semantics

Syntax defines form; semantics defines meaning. There are three main approaches:

  • 1Operational semantics — defines meaning by specifying how a program executes on an abstract machine, step by step.
  • 2Denotational semantics — maps programs to mathematical objects (usually functions). Meaning is compositional: the meaning of a compound phrase is built from the meanings of its parts.
  • 3Axiomatic semantics — defines meaning using logical assertions (pre/post-conditions). The foundation of formal program verification (Hoare logic).
Why does this matter? When you write x = x + 1, you rely on the semantics of your language to know whether this mutates x in place, returns a new binding, or is even legal. Formal semantics is what lets language designers and compiler writers agree on the answer unambiguously.

Section 3: Type Systems

A type system is a set of rules that assigns a property called a type to every term in a program — variables, expressions, functions, etc. Type systems exist to prevent a class of program errors at compile time or runtime, and to communicate intent. Understanding type systems is critical for understanding why languages behave the way they do.

3.1 Static vs. Dynamic Typing

In a statically typed language, types are checked at compile time. Type errors are caught before the program runs. C, Java, Haskell, and Rust are statically typed. In a dynamically typed language, types are checked at runtime. Python, JavaScript, and Ruby are dynamically typed.

Python — Dynamic typing: error at runtime
x = "hello"
y = 42
print(x + y)   # TypeError raised only when this line executes
C — Static typing: error at compile time
char* x = "hello";
int   y = 42;
// x + y would be a pointer-arithmetic expression, not a type error,
// but assigning it to a char* would warn or error at compile time.

3.2 Strong vs. Weak Typing

Strong typing means the language rarely performs implicit type coercions — you must convert types explicitly. Weak (loose) typing means the runtime will silently coerce values between types. Note that strong/weak is separate from static/dynamic: Python is dynamically but strongly typed; C is statically but relatively weakly typed; JavaScript is both dynamic and weakly typed.

JavaScript — Weakly typed: implicit coercion
console.log(1 + "2");    // "12" — number silently coerced to string
console.log(true + 1);   // 2   — boolean silently coerced to number
console.log([] + {});    // "[object Object]"

3.3 Type Inference

Type inference is the ability of a compiler to deduce the type of an expression automatically, without requiring the programmer to write explicit annotations. Haskell's Hindley–Milner type inference is the gold standard: in most cases you never need to write a type annotation and yet the program is fully statically typed.

Haskell — Type inference
-- No type annotations needed; GHC infers these automatically:
double x = x * 2
-- GHC infers: double :: Num a => a -> a

addPair (x, y) = x + y
-- GHC infers: addPair :: Num a => (a, a) -> a

3.4 Polymorphism

Polymorphism allows a single piece of code to work with values of different types. The three most important forms are:

  • Parametric polymorphism — a function works for any type (generics in Java/C#, type variables in Haskell). Example: identity :: a -> a.
  • Subtype polymorphism — a function that accepts a type also accepts any subtype (the basis of OOP inheritance and interface dispatch).
  • Ad-hoc polymorphism — the same function name behaves differently for different types; achieved via function overloading or type classes.
Key insight: Every time you write a generic container (List<T> in Java, [a] in Haskell, templates in C++) you are using parametric polymorphism. The type parameter T or a ranges over all possible types.

Section 4: Scoping and Binding

Name binding is the association between a name (identifier) and the entity it refers to. Scope is the region of a program where a binding is visible. Scoping rules are one of the most consequential design decisions in any programming language, because they determine what a name means at any given point in the code.

4.1 Lexical (Static) Scope

In lexical scoping, the scope of a name is determined by the textual structure of the source code — specifically, by where the name is declared. A function can access names from its own body and from the enclosing blocks where it was defined, regardless of where it is called from. The vast majority of modern languages use lexical scoping.

Python — Lexical scope
x = "global"

def outer():
    x = "outer"          # new binding in outer's scope

    def inner():
        print(x)         # resolves to "outer" — where inner was *defined*

    inner()

outer()   # prints "outer", not "global"

4.2 Dynamic Scope

In dynamic scoping, the scope of a name is determined by the call stack at runtime — specifically, by where the function is called from, not where it was defined. Dynamic scope was common in early Lisps. Most modern languages avoid it because it makes programs hard to reason about statically.

Pseudocode — Dynamic scope contrast
x = "global"

define show():
    print(x)         # under dynamic scope, x resolves to the
                     # *caller's* x, not the definition site's x

define caller():
    x = "caller's x"
    show()           # dynamic scope: prints "caller's x"
                     # lexical scope: prints "global"

4.3 Closures

A closure is a function that captures bindings from its enclosing lexical scope and carries them with it, even after the enclosing scope has returned. Closures are a direct consequence of lexical scoping combined with first-class functions. They are fundamental to functional programming and are heavily used in JavaScript, Python, and Haskell.

JavaScript — Closure
function makeCounter(start) {
    let count = start;          // captured by the closure

    return function increment() {
        count += 1;             // reads and writes the captured `count`
        return count;
    };
}

const counter = makeCounter(0);
console.log(counter());   // 1
console.log(counter());   // 2  — `count` persists between calls
console.log(counter());   // 3

After makeCounter returns, its local variable count would normally be gone — but because increment closed over it, the binding lives on as long as counter exists.

Closures vs objects: A common observation is that closures and objects are two sides of the same coin. An object bundles data with methods; a closure bundles an environment with a function. Many OOP patterns can be expressed elegantly with closures and vice versa.

Section 5: Functions and Higher-Order Programming

In most programming languages, functions are the primary unit of abstraction. PoPL is particularly interested in how functions are defined, passed around, and called. Two concepts are central: first-class functions (functions as values) and parameter passing strategies (how arguments are evaluated and passed to functions).

5.1 Parameter Passing Strategies

When a function is called, how are the arguments evaluated and passed? The three most important strategies are:

  • 1Call by value — the argument expression is evaluated before the call; the function receives a copy of the value. Changes to the parameter do not affect the caller's variable. Used by C, Python (for immutable types), Java.
  • 2Call by reference — the function receives a reference (alias) to the caller's variable. Mutations inside the function affect the caller. C achieves this explicitly with pointers.
  • 3Call by name / call by need — the argument expression is not evaluated until it is actually used inside the function. Haskell uses call by need (lazy evaluation), which memoises the result after first evaluation.
C — Call by value vs. call by reference
// Call by value: caller's x is unchanged
void double_val(int x) {
    x = x * 2;
}

// Call by reference (via pointer): caller's x is mutated
void double_ref(int* x) {
    *x = *x * 2;
}

int main() {
    int a = 5;
    double_val(a);     // a is still 5
    double_ref(&a);    // a is now 10
}

5.2 First-Class and Higher-Order Functions

Functions are first-class when they can be stored in variables, passed as arguments, and returned from other functions — just like any other value. A higher-order function (HOF) is a function that takes a function as an argument or returns one. The three canonical HOFs are map, filter, and fold (reduce).

Python — map, filter, reduce
from functools import reduce

numbers = [1, 2, 3, 4, 5, 6]

# map: apply a function to every element
squares   = list(map(lambda x: x ** 2, numbers))     # [1, 4, 9, 16, 25, 36]

# filter: keep elements satisfying a predicate
evens     = list(filter(lambda x: x % 2 == 0, numbers))  # [2, 4, 6]

# reduce: fold the list into a single value
total     = reduce(lambda acc, x: acc + x, numbers, 0)   # 21
Haskell — The same operations
numbers = [1..6]

squares = map (^2) numbers           -- [1, 4, 9, 16, 25, 36]
evens   = filter even numbers        -- [2, 4, 6]
total   = foldr (+) 0 numbers        -- 21

-- Composing functions with (.)
squareThenSum = foldr (+) 0 . map (^2)
-- squareThenSum [1..6] => 91

5.3 Currying and Partial Application

Currying is the transformation of a function that takes multiple arguments into a chain of functions each taking a single argument. Named after mathematician Haskell Curry, it is the default in Haskell — every function technically takes exactly one argument. Partial application means supplying fewer arguments than a function expects, producing a new function awaiting the rest.

Haskell — Currying and partial application
add :: Int -> Int -> Int    -- a function returning a function
add x y = x + y

addFive :: Int -> Int
addFive = add 5             -- partial application: fix the first argument

-- addFive 3 => 8, addFive 10 => 15

Section 6: Memory Management Strategies

We covered the basics of stack and heap memory in Module 1, including manual allocation with malloc and free in C. This section focuses on the higher-level strategies that different languages use to manage memory automatically — and the trade-offs each approach involves.

See also: Module 1, Section 4 (Dynamic Memory Allocation) covers malloc, free, pointers, and memory leaks in depth. This section builds on those foundations.

6.1 Manual Memory Management

C and C++ give the programmer direct control over allocation (malloc/new) and deallocation (free/delete). This offers maximum performance and predictability, but puts the burden of correctness entirely on the programmer. Common errors include memory leaks (forgetting to free), use-after-free (accessing freed memory), and double-free (freeing twice).

6.2 Garbage Collection (Tracing GC)

Most managed languages — Python, Java, Go, JavaScript — use a garbage collector that automatically reclaims memory that is no longer reachable. The most common approach is tracing garbage collection: the GC periodically identifies all reachable objects by tracing references from a set of roots (global variables, stack variables), then reclaims everything not reached.

Modern GCs use generational collection — splitting the heap into generations based on object age, since most objects die young. Short-lived objects are collected cheaply from the young generation; long-lived objects are promoted to older generations collected less frequently.

Python — GC is invisible to the programmer
def create_garbage():
    data = [i ** 2 for i in range(1_000_000)]  # large allocation
    return None                                  # data goes out of scope; GC reclaims it

create_garbage()   # no manual free needed
# Python's GC handles deallocation automatically

6.3 Reference Counting

Reference counting is an alternative to tracing GC where each object tracks how many references point to it. When the count reaches zero, the object is immediately freed. Python uses reference counting as its primary mechanism (supplemented by a cyclic GC to handle reference cycles). Swift and Rust's Rc<T>/Arc<T> also use reference counting.

The main drawback of pure reference counting is that cycles can prevent objects from ever being freed — if object A holds a reference to B and B holds a reference to A, their counts never reach zero even if nothing else refers to them.

Python — Observing reference counting with sys.getrefcount
import sys

x = []
print(sys.getrefcount(x))   # 2 (x itself + the argument to getrefcount)

y = x                        # another reference to the same list
print(sys.getrefcount(x))   # 3

del y                        # remove one reference
print(sys.getrefcount(x))   # 2 again — object not freed yet

del x                        # count reaches 0 (minus the getrefcount arg):
                             # list is immediately freed

6.4 Ownership and Borrowing (Rust)

Rust takes a different approach entirely: no garbage collector, no reference counting by default, and no manual free. Instead, the compiler enforces an ownership system at compile time. Every value has exactly one owner; when the owner goes out of scope, the value is freed. References (borrows) are tracked by the borrow checker, which prevents use-after-free and data races statically. This gives C-level performance with memory safety guaranteed at compile time.

StrategyWho frees memoryWhenTrade-offs
Manual (C/C++)ProgrammerExplicitly with free/deleteMax performance; error-prone
Tracing GCRuntime collectorPeriodically (pause)Automatic; unpredictable pauses
Reference countingRuntime (on last deref)Immediately when count = 0Predictable; struggles with cycles
Ownership (Rust)Compiler-enforcedAt end of owner scopeSafe + fast; steep learning curve