jonnifyelixir
F5 / F5.04 / conditions

F5.04 · part 1 of 3

Preconditions, postconditions & invariants

A contract is an agreement between a caller and a function, and like any contract it assigns obligations. The precondition is the caller’s obligation — what must hold before the call. The postcondition is the function’s obligation — what it guarantees on return, given the precondition held. The invariant is a shared obligation on the data itself — what every operation must leave true. The value of naming them is precise blame: when something breaks, the term that broke says exactly whose fault it is, and that tells you whether you have a bad request or a bug.

Three terms, three owners. Select one to see who must guarantee it and what that buys the other side.

Who owns each term

The asymmetry is the point. Because the caller owns the precondition, the function may assume it and need not defend against what it forbids. Because the function owns the postcondition, the caller may trust the result blindly. The invariant binds both: no operation may leave the entity in a shape its type forbids. Select a term to see its owner.

The three terms · select one

PRECONDITION checked before the call owner: the caller POSTCONDITION guaranteed on return owner: the function INVARIANT true before and after owner: every operation

term: Precondition

owner: the caller

Name the owner and you name the fault. A broken precondition is the caller’s; a broken postcondition is the function’s; a broken invariant is a defect in the type itself.

In code

Write the contract down, even before you enforce it. As documentation, the three terms tell the next reader the rules; in the body they map onto a guard or check for the precondition, the returned shape for the postcondition, and a constraint every operation respects for the invariant.

# @precondition  user_id is a USR, course_id is a CRS, learner not already enrolled  (caller)
# @postcondition {:ok, %Enrollment{progress: 0}}, stored                              (function)
# @invariant     0 <= progress <= 100                                                  (always)
def enroll(user_id, course_id) do
  with :ok <- check_precondition(user_id, course_id) do
    enrollment = %Enrollment{id: Portal.ID.new("ENR"), user_id: user_id, course_id: course_id}
    Portal.Store.put(enrollment)
    {:ok, enrollment}            # postcondition holds: progress defaults to 0
  end
end

an agreement

Caller and function each owe the other something; the invariant binds the data they share.

precise blame

The term that breaks tells you whether it is a bad request or a bug.

Next: assertions in Elixir — the idioms that turn these three terms into running checks.

Invariants that always hold

The progress range is a one-field invariant; most invariants relate several fields. Bertrand Meyer’s Applying Design by Contract calls such a constraint a class invariant: a property the data keeps at all times, asserted at every point an instance is observable from outside. A Portal.Learning.Roster record carries two fields — the lessons enrolled in and the lessons completed — bound by one rule: $\textit{completed} \subseteq \textit{enrolled}$. A completed lesson that was never enrolled is not a bad request; it is an impossible state, so the type must never hold it.

Meyer’s lever is that the invariant need be checked in only one place. If every instance is built through a single checked constructor, and the fields are immutable thereafter, then proving the rule once at construction proves it for the whole lifetime. @enforce_keys forbids a struct literal that omits a required field, and Portal.Learning.Roster.new/1 is the one door through which a value is admitted — it raises on a violation rather than returning a tagged error, because a broken invariant is a defect in the caller’s logic, not an expected outcome.

The one rule a Roster keeps: completed ⊆ enrolled

ENROLLED DONE L1 L2 L3 L4 completed ⊆ enrolled holds — admitted done ¬⊆ enrolled impossible — new/1 raises
defmodule Portal.Learning.Roster do
  # @invariant  completed is a subset of enrolled  (held at all times)
  @enforce_keys [:learner_id, :enrolled, :completed]
  defstruct [:learner_id, :enrolled, :completed]

  # the one door: the only sanctioned way to build a Roster
  def new(%{learner_id: id, enrolled: enrolled, completed: completed}) do
    enrolled = MapSet.new(enrolled)
    completed = MapSet.new(completed)

    unless MapSet.subset?(completed, enrolled) do
      raise ArgumentError, "completed must be a subset of enrolled"
    end

    %Portal.Learning.Roster{learner_id: id, enrolled: enrolled, completed: completed}
  end
end

# the invariant holds — the value is admitted
Portal.Learning.Roster.new(%{learner_id: "USR_7f", enrolled: ["L1", "L2", "L3"], completed: ["L1"]})
# => %Portal.Learning.Roster{learner_id: "USR_7f", enrolled: #MapSet<["L1", "L2", "L3"]>, completed: #MapSet<["L1"]>}

# a completed lesson that was never enrolled — an impossible state, rejected at the door
Portal.Learning.Roster.new(%{learner_id: "USR_7f", enrolled: ["L1"], completed: ["L1", "L9"]})
# => ** (ArgumentError) completed must be a subset of enrolled

a class invariant

One rule the data keeps for its whole lifetime, not a check a single function performs.

checked once, at the door

@enforce_keys plus a raising new/1 make construction the only place state is built.

An invariant proven at the single point a value is constructed, on immutable fields, is an invariant that cannot later be false.

References

Primary sources for this lesson, and where it connects in the course.

Sources

Related in this course