TPP Topic 23: Design by Contract

steadbytes profile image Ben Steadman ・5 min read

This post originally appeared on steadbytes.com

See the first post in The Pragmatic Programmer 20th Anniversary Edition series for an introduction.

Challenge 1

Points to ponder: If DBC is so powerful, why isn’t it used more widely? Is it hard to come up with the contract? Does it make you think about issues you’d rather ignore for now? Does it force you to THINK!? Clearly, this is a dangerous tool!

Design by contract leads to similar arguments as the great dynamic vs static typing debate:

  • Code conciseness
    • Dynamic languages can be more concise
  • Speed of iteration
    • Dynamic languages is often quicker (though there is a limit)
    • Static languages require more up front consideration
  • Ease of creating generic code
    • Often easier in dynamic languages

I emphasise that none of the above points are absolute truths; there are certainly exceptions to both sides of each and nor are they necessarily my views.

For DBC to be effective, the constraints of a system need to be known and considered. Often, one or both of these is not exercised during development. Furthermore, I think there is a notion that (similar to static types) DBC 'cements' the implementation of code and thus prevents agile, iterative development. If DBC is implemented carefully ,however, this is not the case. The implementation should be decoupled from the constraints imposed by the contracts and changing contracts as the requirements of
software changes should be a normal practice. Achieving this however does require more up front work.

Exercise 14

Design an interface to a kitchen blender. It will eventually be a web-based, IoT-enabled blender, but for now we just need the interface to control it. It has ten speed settings (0 means off). You can’t operate it empty, and you can change the speed only one unit at a time (that is, from 0 to 1, and from 1 to 2, not from 0 to 2).

Here are the methods. Add appropriate pre- and postconditions and an invariant.

int getSpeed()
void setSpeed(int x)
boolean isFull()
void fill()
void empty()

See dbc_blender for full code and tests.

I've chosen to implement this in Clojure due to it's built in support for pre- and postconditions provided by condition maps and the excellent spec which is ideal for imposing constraints on data (also because I like Clojure).

To better fit the functional approach of Clojure, I have made a few changes to the proposed OOP style interface:

  • The blender is modelled as a map instead of a class
  => {::speed 5 ::full true}
  • getSpeed and isFull are not implemented as the keys of the blender map can be used directly
  ; getSpeed
  => (::speed {::speed 5 ::full true})
  ; isFull
  => (::full {::speed 5 ::full true})
  • setSpeed, fill and empty functions are not void; instead returning a full blender map updated to reflect the desired changes in order to avoid mutation of state

Before diving into any implementations, here's the namespace definition that any code
examples will be working within:

(ns dbc-blender.core
  (:require [clojure.spec.alpha :as s]
            [clojure.math.numeric-tower :as math]))

First off, a valid range for the blender speed needs to be determined. The question states that
speed must increment one unit at a time; suggesting that an integer data type makes sense (though
further enforcement is required). Furthermore, a speed cannot be negative and (at least on all blenders I've ever seen) there is a sensible limit to the speed value.

; turn it up to 11!
(def max-speed 11)

; speed is an integer and in correct range
(s/def ::speed (s/and int? #(and (>= % 0) (<= % max-speed))))

Next, the blender map has a few constraints:

  • Requires two keys ::speed and ::full
  • ::full must be of boolean type
  • The blender should only be on when full. This is not explicitly stated in the exercise, but it is a sensible design constraint that I've chosen to apply
(s/def ::full boolean?)
(s/def ::blender (s/and (s/keys :req [::speed ::full])
                        #(if (> (::speed %) 0) ; should only be on when full
                           (::full %)

set-speed constraints:

  • Take as input a valid ::blender and a valid ::speed
  • Return a new valid ::blender with an updated ::speed value
  • ::speed can only be increased by one unit
(defn set-speed [blender x]
  {:pre [(s/valid? ::blender blender)
         (s/valid? ::speed x)
         (::full blender)
         (= (math/abs (- (::speed blender) x)) 1)] ; increase in single increments
   :post [(= (::speed %) x) ; speed was set
          (s/valid? ::blender %)]}
  (assoc blender ::speed x))

fill constraints:

  • Take as input a valid ::blender
  • Return a new valid ::blender that is full
  • Blender cannot already be full
  • Blender cannot be switched on
    • Potentially dangerous
    • Make a mess
(defn fill [blender]
  {:pre [(s/valid? ::blender blender)
         (= (::speed blender) 0) ; can't fill a spinning blender
         (not (::full blender))] ; can't fill an already full blender
   :post [(s/valid? ::blender %)
          (::full %)]} ; blender was filled
  (assoc blender ::full true))

empty constraints:

  • Take as input a valid ::blender
  • Return a new valid ::blender that is empty
  • Blender cannot already be empty
  • Blender cannot be switched on (same reasoning as for fill)
(defn empty [blender]
  {:pre [(s/valid? ::blender blender)
         (= (::speed blender) 0) ; can't empty a spinning blender
         (::full blender)] ; can't empty an empty blender
   :post [(s/valid? ::blender %)
          (not (::full %))]} ; blender was emptied
  (assoc blender ::full false))

Take a look at the tests for comprehensive examples of correct and incorrect usage
of the interface and how they are enforced by the specified contracts.

Exercise 15

How many numbers are in the series 0, 5, 10, 15, …, 100?


I was slightly confused by this question at first; what has a simple series got
to do with DBC? However, it's purpose is to challenge the reader to consider the precise
specification of the series. From the solution provided by the authors:

If you said 20, you just experienced a fence post error (not knowing whether to
count the fenceposts or the spaces in between them)

An answer of 20 implies a series exclusive of either the start or end values:

  • 0, 5, 10, 15, ..., 95
  • 5, 10, 15, ..., 100

Here's some Python to demonstrate each of these, remember that the range function
is start inclusive and end exclusive:

>>> len(range(0, 105, 5)) # correct

>>> len(range(0, 100, 5)) # incorrect: excludes the final element 100

>>> len(range(5, 105, 5)) # incorrect: excludes the starting element 0


markdown guide