jonnifyfsharp

Dive C2.04.1

From a class hierarchy to a DU

Every case in one declaration, so the whole set is there for the compiler to check.

In C#, "one of several shapes" is an abstract base class with a subclass per shape — CircleC : Shape, RectC : Shape — and any file is free to add another. The set of shapes is open: it is never written down in one place, so no tool can be sure it has the full list. F# writes the same domain as one discriminated union: type Shape = Circle of float | Rectangle of float * float | Triangle of float * float. Every case sits in that single declaration — the set is closed. A value is built by naming a case and its payload (Circle 1.0), and because the type lists every case, the compiler can later check that each operation handles them all. This dive is the data half of the shift; the dispatch half is the next one.

C2.04.1 · part 1

One type, a case per shape

A discriminated union is a single type whose value is exactly one of a fixed list of cases. Each case has a name and, optionally, a payload. The Shape union below has three cases: a Circle carrying one float (its radius), and a Rectangle and a Triangle each carrying two. The whole set of shapes is the three lines of the declaration — there is no fourth shape hiding in another file.

type Shape =
    | Circle of float
    | Rectangle of float * float
    | Triangle of float * float          // a closed set of cases

A value is constructed by naming the case and supplying its payload — no new, no constructor body. Circle 1.0 is a Shape; so is Rectangle (2.0, 3.0). Each is the whole value, tagged with which case it is, ready to be read back by a match.

let c = Circle 1.0                       // val c : Shape = Circle 1.0
let r = Rectangle (2.0, 3.0)              // val r : Shape = Rectangle (2.0, 3.0)
let t = Triangle (4.0, 3.0)               // val t : Shape = Triangle (4.0, 3.0)
case
one named alternative of the union — Circle, Rectangle, Triangle. A value is exactly one of them.
of
introduces a case's payload: Circle of float carries one number, Rectangle of float * float carries a pair.
closed set
the cases are the whole type; adding one is an edit to this declaration, which the compiler then propagates.
vs. subclass
a subclass is open — declared anywhere, never enumerated; a case is closed — listed here, and counted.
C2.04.1 · part 2

The C# the reader knows, beside the F#

In C#, the same three shapes are an abstract base class and three subclasses. The shapes are correct, but the set of them is spread across however many files declare a : Shape, and nothing collects them. The F# union puts the identical information on three lines, in one place, where the compiler has all of it at once.

C# — an abstract base class and a subclass per shape

abstract class Shape { public abstract double Area(); }
class CircleC : Shape { public double R; public override double Area() => Math.PI*R*R; }
class RectC   : Shape { public double W, H; public override double Area() => W*H; }

// build one — and any other file may add a fourth subclass:
Shape sh = new RectC { W = 2, H = 3 };                // sh.Area() == 6

F# — one union, every case in the declaration

type Shape =
    | Circle of float
    | Rectangle of float * float
    | Triangle of float * float

// build one — the set of shapes is exactly the three cases above:
let sh = Rectangle (2.0, 3.0)             // val sh : Shape = Rectangle (2.0, 3.0)

A case can name its fields, which reads better than a bare tuple when the payload is more than one anonymous number. The Payment union below tags each case the way a domain would: a Card carries a last4 string, a Cheque carries a number. The describe function reads them back, and the FSI echoes are exact.

type Payment =
    | Cash
    | Card of last4: string
    | Cheque of number: int
let describe p =                         // val describe : Payment -> string
    match p with
    | Cash -> "cash"
    | Card l4 -> "card ****" + l4
    | Cheque n -> sprintf "cheque #%d" n
describe (Card "4242")                    // val it : string = "card ****4242"

Build a Shape value

Pick a case and its payload. The diagram constructs the value the F# way — case name, then payload — and highlights the one match arm in area that this case selects. The readout shows the constructed Shape and the arm's exact result, the value dotnet fsi prints. Every case has exactly one arm, and one arm fires.

a Shape value · and the match arm it selects

a Shape value match s with Circle 1.0 | Circle r -> System.Math.PI * r * r | Rectangle (w, h) -> w * h | Triangle (b, h) -> 0.5 * b * h
value = Circle 1.0 · the arm | Circle r fires · area (Circle 1.0) = 3.141592654
Without JavaScript: each case, its value, and the arm it selects
caseconstructed valuematch armarea
CircleCircle 1.0| Circle r3.141592654
RectangleRectangle (2.0, 3.0)| Rectangle (w, h)6.0
TriangleTriangle (4.0, 3.0)| Triangle (b, h)6.0

Constructing a value names a case; reading it picks an arm. Because the cases live in one declaration, there is always exactly one arm for the value, and the compiler can count them.

C2.04.1 · part 3

Why the location matters

The gain is not a shorter way to spell a class. It is that the set of cases now has a single home. With a base class, the shapes are wherever their subclasses are declared, and a question like "have all the shapes been handled here?" has no answer the compiler can give — the list is open. With a union, the list is the declaration, so that question becomes a check the compiler runs on every match. The next dive is exactly that check.

A union case carries its data and nothing else — no methods. Behaviour lives in free functions that match the union, which is the move dive C2.04.2 takes up: one function per operation, rather than one override per subclass.

C# habit

An abstract class with a subclass per shape — the set open, declared across files, never enumerated.

F# form

One type Shape = Circle | Rectangle | Triangle — the set closed, in one declaration, ready to be checked.

Where this leads

The union holds the shapes; the next dive adds the operations. In C# each operation is an override inside each subclass; in F# each operation is one function that matches every case — and the compiler checks that the match is total. The dive after takes the recursive case, where the Visitor pattern lived.

References

Sources

Related in this course