Tutorial for Module 2

Functional Programming with OCaml

Tutorial: small expressions, end to end

Module 2 · Lecture 6

KC Sivaramakrishnan
IIT Madras

This is the tutorial video for Module 2. We will work through five small programs that exercise everything in the module: literals, let bindings, type inference, operators, and if-expressions. After the worked problems, we will dwell on the three type errors you will see most often in your first programs, and close with an activity for you to try.

The point of the tutorial is to type code and meet the type errors when they show up. Every cell is editable. Make deliberate mistakes; see what the compiler says; fix them. The five-minute frustration of "why won't this compile" is the fastest path to fluency.

Problem 1: classify a response time

A function that returns a label for an HTTP response time in milliseconds. The classification: under 50ms is "instant", under 200ms is "fast", under 1000ms is "noticeable", anything else is "slow".

let response_class ms = if ms < 50.0 then "instant" else if ms < 200.0 then "fast" else if ms < 1000.0 then "noticeable" else "slow" let _ = response_class 180.0 (* = "fast" *)

Problem 1: classify a response time

let response_class ms = if ms < 50.0 then "instant" else if ms < 200.0 then "fast" else if ms < 1000.0 then "noticeable" else "slow" let _ = response_class 180.0

Result for 180.0: string = "fast". Try the boundaries: response_class 50.0 returns "fast" (because < is strict; 50 is not less than 50); response_class 200.0 returns "noticeable". The choice of < vs <= at thresholds is a judgement call. Both are right; this version treats 50 ms as "fast" and 200 ms as "noticeable". If you would rather it be the other way (treat 50 ms as "instant"), swap < for <=. The point is to be deliberate.

This is also a good example of a function that has type float -> string: the operator drives inference. The comparisons are against float literals (50.0, 200.0, etc.), so ms is float; the branches return string literals, so the body has type string; the function is float -> string.

Problem 2: leap year

A year is a leap year if it is divisible by 4, unless divisible by 100, unless again divisible by 400. So 2000 is a leap year (divisible by 400), 1900 is not (divisible by 100 but not by 400), 2024 is (divisible by 4, not by 100), 2025 is not (not divisible by 4).

let is_leap y = (y mod 4 = 0 && y mod 100 <> 0) || y mod 400 = 0 let _ = is_leap 2024 (* = true *) let _ = is_leap 2025 (* = false *) let _ = is_leap 1900 (* = false *) let _ = is_leap 2000 (* = true *)

Problem 2: a leap year predicate

let is_leap y = (y mod 4 = 0 && y mod 100 <> 0) || y mod 400 = 0 let _ = is_leap 2024 let _ = is_leap 2025 let _ = is_leap 1900 let _ = is_leap 2000

Expected: true, false, false, true. The parentheses around the first && are not strictly needed (&& binds tighter than ||, so the parse is the same either way), but they make the rule readable. The expression "either (divisible by 4 and not by 100) or (divisible by 400)" reads off the code with the parens; without them you have to mentally insert them. Explicit parens cost nothing at runtime; spend them.

This is a useful place to notice that mod produces an int, which we then compare with =. The comparisons are all int = int, so they all type-check; the && and || glue them into one bool-typed expression.

Problem 3: shipping cost label

A two-function problem: a shipping table that computes cost from a package's weight (kg), and a labeller that categorises the cost as "cheap", "standard", or "premium". Here is the solution:

let shipping_cost weight = if weight < 1.0 then 5.0 else if weight < 5.0 then 10.0 else if weight < 20.0 then 25.0 else 100.0 let shipping_label weight = let cost = shipping_cost weight in if cost < 10.0 then "cheap" else if cost < 25.0 then "standard" else "premium" let _ = shipping_label 2.5 (* = "standard" *)

Problem 3: shipping cost label

Problem 3: shipping cost label, solution

let shipping_cost weight = if weight < 1.0 then 5.0 else if weight < 5.0 then 10.0 else if weight < 20.0 then 25.0 else 100.0 let shipping_label weight = let cost = shipping_cost weight in if cost < 10.0 then "cheap" else if cost < 25.0 then "standard" else "premium" let _ = shipping_label 2.5

Result for 2.5: string = "standard" (weight 2.5 falls in the < 5.0 band, so cost = 10.0, which is < 25.0, so the label is "standard"). The pattern let cost = shipping_cost weight in if cost < ... else ... is idiomatic: when you need to inspect the same value at several thresholds, name it once and compare repeatedly. Without the let, you would compute shipping_cost weight three times in the if-chain (once for each threshold), which is wasteful and clutters the code.

The function shipping_label is built by composing two smaller functions, shipping_cost and an if-chain. This is the rhythm of functional programming: small, focused functions, combined into larger behaviours. Module 6 will give us tools to make this composition explicit; here it is just let + function call.

Problem 4: clamp

Constrain a value to a given range. If the value is below the lower bound, return the lower bound; if above the upper bound, return the upper bound; otherwise return the value as-is.

let clamp lo hi x = if x < lo then lo else if x > hi then hi else x let _ = clamp 0 10 7 (* = 7 *) let _ = clamp 0 10 (-3) (* = 0 *) let _ = clamp 0 10 25 (* = 10 *)

Problem 4: clamp

Constrain a number to a range:

let clamp lo hi x = if x < lo then lo else if x > hi then hi else x let _ = clamp 0 10 7 let _ = clamp 0 10 (-3) let _ = clamp 0 10 25

Results: 7, 0, 10. The function's type is int -> int -> int -> int. Note the argument order: lo, hi, x. There is no one right argument order; this one mirrors the conceptual reading ("clamp into the range lo..hi, the value x"). Another defensible order is x lo hi; both are fine, just be consistent.

The parenthesisation (-3) is the unary-minus pitfall from the operators lecture (without parens it would parse as subtraction). Worth remembering.

Problem 5: tying it together

A small utility function for "divide safely":

let safe_divide a b = if b = 0.0 then 0.0 else a /. b let scaled value scale offset = safe_divide (value +. offset) scale let _ = scaled 100.0 4.0 5.0 (* = 26.25 *) let _ = scaled 100.0 0.0 5.0 (* = 0. *)

Problem 5: tying it together

let safe_divide a b = if b = 0.0 then 0.0 else a /. b let scaled value scale offset = safe_divide (value +. offset) scale let _ = scaled 100.0 4.0 5.0 let _ = scaled 100.0 0.0 5.0

Results: 26.25 (which is (100 + 5) / 4) and 0.0. The second call would have been a divide-by-zero in a /. b, but safe_divide intercepts it and returns 0.0 instead.

A short aside: replacing a bad case with a "sentinel" value (returning 0.0 for divide-by-zero) is a design decision, and not always the right one. The sentinel can hide real bugs: if your caller didn't notice that you returned 0.0, they might incorporate it into a subsequent computation and silently produce nonsense. The alternatives are:

For a tutorial example, the sentinel is fine. In production code, either of the two alternatives is usually better. Mention this to set up Modules 4 and 7.

Reading type errors

Type errors are noisy at first. The cure is repetition: write some code, read the message, fix, repeat. One error worth a fresh slide here; two more were covered earlier in the module.

Reading a type error: int / float confusion

let bad r = 3.14 * r * r
Error: The constant 3.14 has type float
       but an expression was expected of type int

The int/float operator mix-up: you wrote * when you meant *.. The compiler points at the float literal as the offender, says it expected an int (because * is integer multiplication), and tells you the actual type is float. The fix: change the operator to *..

The trick to reading the error: the operator drives the expected type. If you see "expected int", look for an int operator nearby; that's where the constraint came from.

Two more error shapes you have already seen elsewhere in the module are worth re-skimming when you hit them:

Together these three shapes (operator mismatch, missing conversion, mismatched branches) account for the bulk of first-week type errors. After enough repetition the muscle memory takes over.

Activity

Activity

Re-implement sign from the if lecture, then write the float twin:

Compare what changed between the two.

Try this one yourself before reading on.

Write sign : int -> int that returns -1 for negative inputs, 0 for zero, and 1 for positive inputs.

let sign x = failwith "not implemented"

Now write the float version: sign_f : float -> float returning -1.0, 0.0, 1.0.

let sign_f x = failwith "not implemented"
Show reference solution

Activity solution

let sign x = if x < 0 then -1 else if x > 0 then 1 else 0 let sign_f x = if x < 0.0 then -1.0 else if x > 0.0 then 1.0 else 0.0

What changed:

  • Literals: 0 to 0.0; -1, 0, 1 to -1.0, 0.0, 1.0.
  • Type: int -> int to float -> float.

Structure is identical. OCaml made you spell out the type choice.

Compare the two versions. The logic (negative? zero? positive?) is identical. What changed is the literals: 0 becomes 0.0, -1 becomes -1.0, etc. OCaml made you write out the type choice; the algorithm itself didn't change. This is the cost of the no-implicit-conversion rule. The benefit is that anyone reading either function knows unambiguously what types are involved.

A small philosophical aside, since the think about this prompt invites it. Could you replace the three-way if in sign with arithmetic? Almost; one if survives, to guard the zero case:

let sign_arith x = if x = 0 then 0 else x / abs x let _ = sign_arith 5 (* = 1 *) let _ = sign_arith (-3) (* = -1 *) let _ = sign_arith 0 (* = 0 *)

This works: x / abs x is 1 for positive and -1 for negative, and we handle the 0 case separately to avoid division by zero. It is more compact than the three-branch if, but arguably less clear: a reader has to think to convince themselves that the formula gives the right answer. The three-branch version reads like the specification.

This is a general theme: cleverness and clarity are different virtues, and clarity usually wins. We will see this again with recursion versus higher-order functions (Module 6).

What you should be able to do now

By the end of Module 2 you should be comfortable doing the following without checking references:

What you should be able to do now

After Module 2 you can:

Next, Module 3: functions as values, currying, recursion.

If any of these still feel shaky, the right move is to go back to the relevant lecture and re-attempt the quizzes. Module 3 will assume Module 2 is solid: we will start treating functions as values you can pass around, store, and return from other functions. That's where OCaml starts to feel like a genuinely different language from C or Python, and you'll want the expression-level mechanics from Module 2 to be automatic.

Reading

Sources

This lecture's prose, worked examples, and quizzes are original to this course. Materials referenced during preparation are listed in the Reading section above; Cornell CS3110 and Real World OCaml are CC BY-NC-ND-licensed and have not been derivatively reused. See LICENSES.md at the repository root for the full source posture.