Lesson 3 - It's All Patterns

  1. Introduction
  2. What is Pattern Matching?
  3. Guards: Fine-Tuning Your Patterns
  4. If-Else
  5. Real-World Example
  6. Recap & Exercises

Introduction

In Haskell, pattern matching is one of the most powerful tools at your disposal. It allows you to deconstruct and inspect data in a concise, readable way, handling different cases with ease. Pattern matching goes beyond simple conditional logic—Haskell’s strong type system allows us to match patterns across a wide range of data types, ensuring we handle all possible cases safely.

In this lesson, we’ll dive deep into pattern matching, guards, and if-else expressions, and we’ll see how these tools allow us to express complex logic elegantly and concisely. We will also explore why pattern matching is far superior to similar approaches in imperative or object-oriented languages, and how it allows for mathematical guarantees in our code.

What is Pattern Matching?

Pattern matching in Haskell is an integral part of the language’s type system that provides guarantees no other paradigm offers. In languages like Java or Python, handling multiple cases usually involves complex if-else trees or switch statements, which can easily become unwieldy and miss edge cases. Haskell’s pattern matching, combined with its strong typing and exhaustive checking, ensures that all possible cases are handled and nothing is missed.

Why Pattern Matching is So Powerful

Exhaustiveness Checking

One of the most powerful aspects of pattern matching in Haskell is that the compiler will warn you (by not compiling) if you haven’t covered all possible cases of a data type. This feature eliminates a common source of bugs in imperative and object-oriented languages, where missing a single branch in a chain of conditionals could lead to unpredictable behavior.

Example:

data TrafficLight = Red | Yellow | Green

-- If we omit a case, the compiler will warn us.
trafficAction :: TrafficLight -> String
trafficAction Red = "Stop"
trafficAction Yellow = "Slow down"
-- We forgot Green!

Safe Division Example with Maybe

In Haskell, Maybe is an algebraic data type that represents a computation that might fail or return nothing. It has two constructors:

When you pattern match on a Maybe type, the Haskell compiler ensures that you handle both the Just and Nothing cases. If you miss one of these cases, the compiler will warn you, ensuring you don’t accidentally leave out an edge case.

Here’s an example of safely performing division, where we return Nothing if there’s an attempt to divide by zero:

safeDiv :: Int -> Int -> Maybe Int
safeDiv _ 0 = Nothing      -- Handle division by zero
safeDiv x y = Just (x `div` y)

In this case:

By forcing us to handle both Just and Nothing, the Haskell compiler ensures we’ve considered all possibilities and haven’t missed an edge case. If you accidentally forget to account for Nothing, the compiler will throw a warning or error.

Using Pattern Matching on Maybe

When you use a Maybe result in another function, pattern matching ensures that both possible cases (Just and Nothing) are addressed:

describeResult :: Maybe Int -> String
describeResult Nothing = "No result"  -- Handle Nothing
describeResult (Just n) = "The result is " ++ show n

In this function:

Guarantee Against Missed Edge Cases

If we forget to handle one of these cases, Haskell’s compiler will warn us:

describeResult (Just n) = "The result is " ++ show n

The above code would produce a warning like:

Warning: Pattern match(es) are non-exhaustive

This warning indicates that we’ve missed a case (Nothing), ensuring we handle all possible outcomes of Maybe.

Mathematical Guarantees

Haskell’s pattern matching is rooted in mathematical proofs and logic. Thanks to algebraic data types, the set of possible patterns for any given input is finite and well-defined, meaning the compiler can mathematically prove that your function is total (i.e., defined for all possible inputs). This gives you the confidence that your function will never fail due to a missing case, which is a massive benefit in industries like finance, healthcare, and aerospace, where correctness is paramount.

Integration with Property-Based Testing

Pattern matching is of vital importance when it comes to property testing, especially in Haskell. Since we know we’ve covered all possible cases of our data types, we can use tools like QuickCheck to automatically generate test cases that ensure our code behaves correctly for all possible inputs. This is a massive advantage over manually writing test cases, where it’s easy to miss certain edge cases.

Example:

prop_trafficLight :: TrafficLight -> Bool
prop_trafficLight light = case trafficAction light of
    "Stop"       -> light == Red
    "Slow down"  -> light == Yellow
    "Go"         -> light == Green

Cleaner, More Declarative Code

Traditional if-else chains in object-oriented or imperative languages tend to become unwieldy as complexity grows. They are often verbose and harder to reason about. Pattern matching, by contrast, provides a declarative way to handle different cases. Each case is explicitly defined in a way that mirrors how we naturally think about different possibilities for a given problem.

Example (Java-style logic with switch statements):

switch (trafficLight) {
    case RED:
        return "Stop";
    case YELLOW:
        return "Slow down";
    case GREEN:
        return "Go";
    default:
        throw new IllegalStateException("Unexpected value");
}

Example (Haskell-style pattern matching):

trafficAction :: TrafficLight -> String
trafficAction Red    = "Stop"
trafficAction Yellow = "Slow down"
trafficAction Green  = "Go"

Haskell’s approach not only makes the code more concise but also removes the need for an arbitrary default case. There’s no risk of missing an unexpected value, as the compiler will force you to handle every possibility.

Guards: Fine-Tuning Your Patterns

Guards allow you to add additional conditions to a pattern, providing more control over how patterns are matched. They act like filters that further refine the match by specifying conditions that must be true.

Example of Guards:

Let’s improve a function to calculate a grade based on a numeric score using guards.

grade :: Int -> String
grade score
  | score >= 90 = "A"
  | score >= 80 = "B"
  | score >= 70 = "C"
  | score >= 60 = "D"
  | otherwise   = "F"

Here, we use guards (|) to specify the conditions under which each result is returned:

Real-World Example with Guards:

Consider a program that calculates shipping rates based on package weight:

shippingCost :: Double -> Double
shippingCost weight
  | weight <= 1.0  = 5.00
  | weight <= 5.0  = 10.00
  | weight <= 10.0 = 20.00
  | otherwise      = 50.00

In this example, guards allow you to calculate the shipping cost based on weight ranges, providing clean and readable logic for each case.

If-Else

While pattern matching and guards are the more functional way to handle conditional logic in Haskell, traditional if-else expressions still exist and are used for simpler cases. Haskell’s if-else is an expression rather than a statement (like in imperative languages), meaning it must always return a value.

Example of If-Else:

isEven :: Int -> Bool
isEven x = if x `mod` 2 == 0 then True else False

Here, the if-else expression evaluates whether x is divisible by 2, returning True if it is, and False otherwise.

Why Prefer Pattern Matching?

Although if-else is available, Haskell encourages the use of pattern matching and guards because they allow for clearer and more composable code. You’ll often find that pattern matching leads to cleaner, more maintainable code.

Real-World Example

Let’s look at how pattern matching, guards, and if-else can come together in a more complex scenario: modeling a traffic light system.

data TrafficLight = Red | Yellow | Green

nextLight :: TrafficLight -> TrafficLight
nextLight Red    = Green
nextLight Yellow = Red
nextLight Green  = Yellow

trafficAction :: TrafficLight -> String
trafficAction light
  | light == Red    = "Stop"
  | light == Yellow = "Slow down"
  | light == Green  = "Go"

A More Complex Example: ATM Withdrawal

Let’s design a system that processes withdrawals from an ATM, handling the available balance and the amount requested.

data Account = Account { balance :: Double } deriving Show

withdraw :: Account -> Double -> Either String Account
withdraw acc amount
  | amount <= 0 = Left "Withdrawal amount must be positive"
  | amount > balance acc = Left "Insufficient funds"
  | otherwise = Right acc { balance = balance acc - amount }

main :: IO ()
main = do
  let myAccount = Account 100.0
  print $ withdraw myAccount 150.0 -- Insufficient funds
  print $ withdraw myAccount 50.0  -- Right Account {balance = 50.0}

This example demonstrates:

Recap & Exercises

Recap

Exercises

Exercise 1: Categorize Numbers Using Guards

Write a function categorize that takes an integer and categorizes it as “Negative”, “Zero”, or “Positive” using guards.

categorize :: Int -> String
-- Your implementation here

Exercise 2: Safe Head

Write a function safeHead that takes a list and returns the first element wrapped in a Maybe. If the list is empty, it should return Nothing.

safeHead :: [a] -> Maybe a
-- Your implementation here

Exercise 3: Simple ATM Withdrawal

Modify the ATM withdrawal example to handle overdraft protection, where an account can go up to $100 in the negative.

withdrawWithOverdraft :: Account -> Double -> Either String Account
-- Your implementation here

Exercise 4: Fibonacci with Pattern Matching

Write a function fib that calculates the Fibonacci sequence using pattern matching.

fib :: Int -> Int
-- Your implementation here

Previous Chapter | Next Chapter