6.102
6.102 — Software Construction
Spring 2023

Reading 5: Designing Specifications

Software in 6.102

Safe from bugsEasy to understandReady for change
Correct today and correct in the unknown future. Communicating clearly with future programmers, including future you. Designed to accommodate change without rewriting.

Objectives

  • Understand underdetermined specs, and be able to identify and assess specs that are not deterministic
  • Understand declarative vs. operational specs, and be able to write declarative specs
  • Understand strength of preconditions, postconditions, and specs, and be able to compare spec strength
  • Be able to write coherent, useful specifications of appropriate strength

Introduction

In this reading we’ll look at different specs for similar behaviors, and talk about the tradeoffs between them. We’ll look at three dimensions for comparing specs:

  • How deterministic it is. Does the spec define only a single possible output for a given input, or does it permit a set of legal outputs?

  • How declarative it is. Does the spec just characterize what the output should be, or does it explicitly say how to compute the output?

  • How strong it is. Does the spec have a small set of legal implementations, or a large set?

Not all specifications we might choose for a module are equally useful, and we’ll explore what makes some specifications better than others.

Deterministic vs. underdetermined specs

Consider these two implementations of find:

function findFirst(arr: Array<number>, val: number): number {
    for (let i = 0; i < arr.length; i++) {
        if (arr[i] === val) return i;
    }
    return arr.length;
}
function findLast(arr: Array<number>, val: number): number {
    for (let i = arr.length - 1 ; i >= 0; i--) {
        if (arr[i] === val) return i;
    }
    return -1;
}

The subscripts First and Last are not actual TypeScript syntax. We’re using them here to distinguish the two implementations for the sake of discussion. In the actual code, both implementations should be TypeScript methods called find.

Since we’ll be talking about multiple specifications of find too, we’ll identify each specification with a superscript, like ExactlyOne:

function findExactlyOne(arr: Array<number>, val: number): number
requires:
val occurs exactly once in arr
effects:
returns index i such that arr[i] = val

The findExactlyOne specification is deterministic: when presented with a state satisfying the precondition, the outcome is completely determined. Only one return value and one final state is possible. There are no valid inputs for which there is more than one valid output.

Both the findFirst and findLast implementations satisfy the specification, so if this is the specification on which the clients relied, the two implementations are substitutable for one another.

Here is a slightly different specification:

function findOneOrMore,AnyIndex(arr: Array<number>, val: number): number
requires:
val occurs in arr
effects:
returns index i such that arr[i] = val

This specification is not deterministic. It doesn’t say which index is returned if val occurs more than once. It simply says that if you look up the entry at the index given by the returned value, you’ll find val. On some inputs, this specification allows multiple valid outputs.

But even though this spec is not deterministic, we wouldn’t call it nondeterministic in the usual sense of that word. Nondeterministic code sometimes behaves one way and sometimes another, even if called in the same program with the same inputs. This can happen, for example, when the code’s behavior depends on a random number, or when it depends on the timing of concurrent processes. But a specification which is not deterministic doesn’t have to have a nondeterministic implementation. It can be satisfied by a fully deterministic implementation.

To avoid the confusion, we’ll refer to specifications that are not deterministic as underdetermined.

This underdetermined findOneOrMore,AnyIndex spec is again satisfied by both findFirst and findLast, each resolving the underdeterminedness in its own (fully deterministic) way. A client of this spec can’t rely on which index will be returned if val appears more than once. The spec would be satisfied by a nondeterministic implementation, too — for example, one that tosses a coin to decide whether to start searching from the beginning or the end of the array. But in almost all cases we’ll encounter, underdeterminism in specifications offers a choice that is made by the implementor at implementation time. An underdetermined spec is typically implemented by a fully-deterministic implementation.

reading exercises

Distinguished

With the same two implementations from above:

function findFirst(arr: Array<number>, val: number): number {
    for (let i = 0; i < arr.length; i++) {
        if (arr[i] === val) return i;
    }
    return arr.length;
}
function findLast(arr: Array<number>, val: number): number {
    for (let i = arr.length - 1 ; i >= 0; i--) {
        if (arr[i] === val) return i;
    }
    return -1;
}

Consider this spec:

function find(arr: Array<number>, val: number): number
requires:
nothing
effects:
returns largest index i such that arr[i] = val, or -1 if no such i

(missing explanation)

Over/under

For each spec below, say whether it is deterministic or underdetermined.

function find(arr: Array<number>, val: number): number
requires:
val occurs exactly once in arr
effects:
returns index i such that arr[i] = val

(missing explanation)

function find(arr: Array<number>, val: number): number
requires:
nothing
effects:
returns largest index i such that arr[i] = val, or -1 if no such i

(missing explanation)

function find(arr: Array<number>, val: number): number
requires:
val occurs in arr
effects:
returns largest index i such that arr[i] = val

(missing explanation)

Declarative vs. operational specs

For this dimension of comparison, there are two kinds of specifications. Operational specifications give a series of steps that the method performs; pseudocode descriptions are operational. Declarative specifications don’t give details of intermediate steps. Instead, they just give properties of the final outcome, and how it’s related to the initial state.

Declarative specifications are almost always preferable to operational specifications. They’re usually shorter, easier to understand, and most importantly, they don’t inadvertently expose implementation details that a client may rely on (and then find no longer hold when the implementation is changed). For example, if we want to allow either implementation of find, we would not want to say in the spec that the method “goes down the array until it finds val,” since aside from being rather vague, this spec suggests that the search proceeds from lower to higher indices and that the lowest will be returned, which perhaps the specifier did not intend.

One reason programmers sometimes lapse into operational specifications is because they’re using the spec comment to explain the implementation for a maintainer. Don’t do that. When it’s necessary, use comments within the body of the method, not in the spec comment.

For a given specification, there may be many ways to express it declaratively:

function startsWith(str: string, prefix: string): boolean
effects:
returns true if and only if there exists a string suffix such that prefix + suffix = str
function startsWith(str: string, prefix: string): boolean
effects:
returns true if and only if there exists integer i such that str.substring(0, i) = prefix
function startsWith(str: string, prefix: string): boolean
effects:
returns true if and only if the first prefix.length characters of str are the characters of prefix

It’s up to us to choose the clearest specification for clients and maintainers of the code.

Note that these startsWith specs have no preconditions, so we can omit requires: nothing for the sake of brevity.

reading exercises

Joint declaration

Given this function, which is supposed to do the same thing as Array.join:

function join(delimiter: string, elements: Array<string>): string

(missing explanation)

Stronger vs. weaker specs

We discussed in the last reading (Specifications) how the presence of a spec allows you to replace one implementation with another safely, as long as both implementations satisfy the spec. But suppose you need to change not only the implementation but also the specification itself? Assume there are already clients that depend on the method’s current specification. How do you compare the behaviors of two specifications to decide whether it’s safe (for those clients) to replace the old spec with the new spec?

To answer that question, we compare the strength of the two specs. A specification S2 is stronger than a specification S1 if the set of implementations that satisfy S2 is a strict subset of those that satisfy S1.

A predicate is an expression that maps its inputs to true or false. We often implement predicates in code as boolean functions, e.g. isEven(n) returns n % 2 === 0.

In specifications, the pre­condition is a predicate on the inputs, and the post­condition is a predicate on the outputs.

And a specification as a whole is a predicate on implemen­tations: either an implemen­tation satisfies the spec or it does not.

This notion of “stronger” and “weaker” comes from predicate logic. A predicate P is stronger than a predicate Q (and Q is weaker than P) if the set of states that match P is a strict subset of those that match Q. Since a specification is a predicate over implementations, making a specification stronger means shrinking the set of implementations that satisfy it. It may help to think of “stronger” as more constrained, or tighter, while “weaker” as less constrained, looser.

Note that it is also possible for predicates P and Q to be incomparable – neither stronger nor weaker than the other – if neither is a subset of the other.

To decide whether one specification is stronger or weaker than another, we compare the strengths of their preconditions and postconditions. A precondition is a predicate over the state of the input, so making a precondition stronger means shrinking the set of legal inputs. Similarly, a postcondition is a predicate over the state of the output, so a stronger postcondition shrinks the set of allowed outputs and effects.

We can now state the rule:

A specification S2 is stronger than or equal to a specification S1 if and only if

  • S2’s precondition is weaker than or equal to S1’s,
    and
  • S2’s postcondition is stronger than or equal to S1’s, for the states that satisfy S1’s precondition.

If this is the case, then an implementation that satisfies S2 can be used to satisfy S1 as well, and it’s safe for clients if S1 is replaced with S2.

This rule embodies several ideas. It tells you that you can weaken the precondition without disturbing clients, because placing fewer demands on a client will never upset them — the weaker precondition will still satisfy their inputs, in addition to allowing more inputs that were not legal previously. And you can strengthen the postcondition, which means making more promises to the client — the new postcondition may allow fewer outputs than before, but it will be a subset of the outputs the client was already prepared to handle.

For example, this spec for find:

function findExactlyOne(a: Array<number>, val: number): number
requires:
val occurs exactly once in a
effects:
returns index i such that a[i] = val

can be replaced with:

function findOneOrMore,AnyIndex(a: Array<number>, val: number): number
requires:
val occurs at least once in a
effects:
returns index i such that a[i] = val

which is a stronger spec because it has a weaker precondition — it constrains the inputs less. This in turn can be replaced with:

function findOneOrMore,FirstIndex(a: Array<number>, val: number): number
requires:
val occurs at least once in a
effects:
returns lowest index i such that a[i] = val

which is a stronger spec because it has a stronger postcondition — it constrains the output more.

What about this specification:

function findCanBeMissing(a: Array<number>, val: number): number
requires:
nothing
effects:
returns index i such that a[i] = val, or -1 if no such i

Let’s compare to findOneOrMore,FirstIndex. Again the precondition is weaker, but for inputs that satisfy findOneOrMore,FirstIndex’s precondition, the postcondition is also weaker: the requirement for lowest index has been removed. Neither of these two specifications is stronger than the other: they are incomparable.

We’ll come back to findCanBeMissing in the exercises and compare it to our other specifications.

reading exercises

Bulking up

(missing explanation)

One client, many implementers

We have focused so far on a common case, where the spec has one implementation (a function in a program) and many clients (places in the program where the function is called). We considered how the spec can be changed without requiring changes to all the clients.

Let’s consider another case now: where the spec has many implementations (say, solutions to a problem set written by students in a class), but only one client (a test suite that is autograding those implementations). What changes can we make to the spec that would not require changing implementations that already satisfy the spec? Choose all that apply.

(missing explanation)

Diagramming specifications

Imagine (very abstractly) the space of all possible TypeScript functions.

Each point in this space represents a function implementation.

First we’ll diagram findFirst and findLast defined above. Look back at the code and see that findFirst and findLast are not specs. They are implementations, with function bodies that implement their actual behavior. So we denote them as points in the space.

A specification defines a region in the space of all possible implementations. A given implementation either behaves according to the spec, satisfying the precondition-implies-postcondition contract (it is inside the region), or it does not (outside the region).

Both findFirst and findLast satisfy findOneOrMore,AnyIndex, so they are inside the region defined by that spec.

We can imagine clients looking in on this space: the specification acts as a firewall, guaranteeing only that the implementation lies within the region of the specification, but not promising any particular implementation.

  • Implementors have the freedom to move around inside the spec region, changing their code without fear of upsetting a client. This is crucial in order for the implementor to be able to improve the performance of their algorithm, the clarity of their code, or to change their approach when they discover a bug, etc.

  • Clients don’t know which implementation they will get. They must respect the spec, but also have the freedom to change how they’re using the implementation without fear that it will suddenly break.

How will similar specifications relate to one another? Suppose we start with specification S1 and use it to create a new specification S2.

If S2 is stronger than S1, how will these specs appear in our diagram?

  • Let’s start by strengthening the postcondition. If S2’s postcondition is now stronger than S1’s postcondition, then S2 is the stronger specification.

    Think about what strengthening the postcondition means for implementors: it means they have less freedom, the requirements on their output are stronger. Perhaps they previously satisfied findOneOrMore,AnyIndex by returning any index i, but now the spec demands the lowest index i. So there are now implementations inside findOneOrMore,AnyIndex but outside findOneOrMore,FirstIndex.

    Could there be implementations inside findOneOrMore,FirstIndex but outside findOneOrMore,AnyIndex? No. All of those implementations satisfy a stronger postcondition than what findOneOrMore,AnyIndex demands.

  • Think through what happens if we weaken the precondition, which will again make S2 a stronger specification. Implementations will have to handle new inputs that were previously excluded by the spec. If they behaved badly on those inputs before, we wouldn’t have noticed, but now their bad behavior is exposed.

We see that when S2 is stronger than S1, it defines a smaller region in this diagram; a weaker specification, on the other hand, defines a larger region.

In our figure, since findLast iterates from the end of the array arr, it does not satisfy findOneOrMore,FirstIndex and is outside that region.

Another specification S3 that is neither stronger nor weaker than S1 might overlap (such that there exist implementations that satisfy only S1, only S3, and both S1 and S3) or might be disjoint. In both cases, S1 and S3 are incomparable.

reading exercises

Strength is truth

(missing explanation)

Finding findExactlyOne

Here are the find specifications again:

function findExactlyOne(a: Array<number>, val: number): number
requires:
val occurs exactly once in a
effects:
returns index i such that a[i] = val
function findOneOrMore,AnyIndex(a: Array<number>, val: number): number
requires:
val occurs at least once in a
effects:
returns index i such that a[i] = val
function findOneOrMore,FirstIndex(a: Array<number>, val: number): number
requires:
val occurs at least once in a
effects:
returns lowest index i such that a[i] = val
function findCanBeMissing(a: Array<number>, val: number): number
requires:
nothing
effects:
returns index i such that a[i] = val, or -1 if no such i

We already know that findOneOrMore,FirstIndex is stronger than findOneOrMore,AnyIndex, which is stronger than findExactlyOne.

(missing explanation)

Finding findCanBeMissing

Let’s determine where findCanBeMissing is on the diagram.

(missing explanation)

(missing explanation)

(missing explanation)

Found

(missing explanation)

Mutability

Let’s look at another design choice for a specification: whether to use mutable or immutable types for parameters and return values.

Recall from Static Checking that some objects are immutable: once created, they always represent the same value. Other objects are mutable: they have methods that change the value of the object.

sstring"a"string"ab"

String is an example of an immutable type. Once created, a string object always has the same value, representing the same string. To add something to the end of a string, you have to create a new string object:

let s = "a";
s = s.concat("b"); // s+="b" and s=s+"b" also mean the same thing
vArray<string>01string"a"string"b"

In contrast, Array is a mutable type. This class has methods that change the value of the Array object, rather than just returning new values:

let v: Array<string> = [ "a" ];
v.push("b");

So what? In both cases, you end up with s and v basically referring to the sequence of characters ab. The difference between mutability and immutability doesn’t matter much when there’s only one reference to the object. But there are big differences in how they behave when there are other references to the object.

sstring"a"string"ab"tstring"abc"vArray<string>012string"a"string"b"string"c"w

For example, when another variable t points to the same string object as s, and another variable w points to the same Array as v, then the differences between the immutable and mutable objects become more evident:

let t = s;
t = t + "c";

let w = v;
w.push("c");

The snapshot diagram shows that changing t had no effect on s, but changing w affected v too — possibly to the surprise of the programmer. That’s the essence of the problem we’re going to look at now.

reading exercises

Follow me


(missing explanation)

(missing explanation)

Choose all correct answers.

(missing explanation)

Risks of mutation

Mutable types seem much more powerful than immutable types. If you were shopping in the Data Type Supermarket, and you had to choose between an immutable ReadonlyArray and a super-powerful-do-anything mutable Array, why on earth would you choose the immutable one? Array should be able to do everything that ReadonlyArray can do, plus push() and pop() and everything else.

The answer is that immutable types are safer from bugs, easier to understand, and more ready for change. Mutability makes it harder to understand what your program is doing, and much harder to enforce contracts. Here are some examples that illustrate why.

Risky example #1: passing mutable values

Let’s start with a simple function that sums the numbers in an array:

/**
 * @returns the sum of the numbers in the array
 */
function sum(arr: Array<number>): number {
    let sum = 0;
    for (const x of arr) {
        sum += x;
    }
    return sum;
}

Suppose we also need a function that sums the absolute values. Following good DRY practice (Don’t Repeat Yourself), the implementer writes a method that uses sum():

/**
 * @returns the sum of the absolute values of the numbers in the array
 */
function sumAbsolute(arr: Array<number>): number {
    // let's reuse sum(), because DRY, so first we take absolute values
    for (let i = 0; i < arr.length; ++i) {
        arr[i] = Math.abs(arr[i]);
    }
    return sum(arr);
}

Notice that this method does its job by mutating the array directly. It seemed sensible to the implementer, because it’s more efficient to reuse the existing array. If the array is millions of items long, then you’re saving the time and memory of generating a new million-item array of absolute values. So the implementer has two very good reasons for this design: DRY, and performance.

But the resulting behavior will be very surprising to anybody who uses it! For example:

// meanwhile, somewhere else in the code...
let myData: Array<number> = [-5, -3, -2];
console.log(sumAbsolute(myData));
console.log(sum(myData));

What will this code print? Will it be 10 followed by -10? Or something else?

reading exercises

Risky #1

(missing explanation)

Let’s think about the key points here:

  • Safe from bugs? In this example, it’s easy to blame the implementer of sum­Absolute() for going beyond what its spec allowed. But really, passing mutable objects around is a latent bug. It’s just waiting for some programmer to inadvertently mutate that array, often with very good intentions like reuse or performance, but resulting in a bug that may be very hard to track down.

  • Easy to understand? When reading this code, what would you assume about the calls sum(myData) and sum­Absolute(myData)? Is it clearly visible to the reader that myData gets changed by one of them?

This example doesn’t illustrate readiness for change yet, but we’ll get to that in the next two examples.

Risky example #2: returning mutable values

We just saw an example where passing a mutable object to a method caused problems. What about returning a mutable object?

Let’s consider Date, one of the built-in JavaScript classes. Date happens to be a mutable type. Suppose we write a method that determines the first day of spring:

/**
 * @returns the first day of spring this year
 */
function startOfSpring():Date {
    return askGroundhog();
}

Here we’re using the well-known Groundhog algorithm for calculating when spring starts (Harold Ramis, Bill Murray, et al. Groundhog Day, 1993).

Clients start using this method, for example to plan their big parties:

// somewhere else in the code...
function partyPlanning():void {
    let partyDate:Date = startOfSpring();
    // ...
}

All the code works and people are happy. Now, independently, two things happen. First, the implementer of startOfSpring() realizes that the groundhog is starting to get annoyed from being constantly asked when spring will start. So the code is rewritten to ask the groundhog at most once, and then cache the groundhog’s answer for future calls:

let groundhogAnswer:Date|undefined = undefined;

/**
 * @returns the first day of spring this year
 */
function startOfSpring():Date {
    if (groundhogAnswer === undefined) {
        groundhogAnswer = askGroundhog();
    }
    return groundhogAnswer;
}

Second, one of the clients of startOfSpring() decides that the actual first day of spring is too cold for the party, so the party will be exactly a month later instead:

// somewhere else in the code...
function partyPlanning():void {
    // let's have a party one month after spring starts!
    let partyDate:Date = startOfSpring();
    partyDate.setMonth(partyDate.getMonth() + 1);
    // ...
}

What happens when these two decisions interact? Even worse, think about who will first discover this bug — will it be startOfSpring()? Will it be partyPlanning()? Or will it be some completely innocent third piece of code that also calls startOfSpring()?

reading exercises

Risky #2

We don’t know how Date stores the month, so we’ll represent that with the abstract values ...march... and ...april... in an imagined month field of Date.

(missing explanation)

Key points:

  • Safe from bugs? Again we had a latent bug that reared its ugly head.

  • Ready for change? Obviously the mutation of the date object is a change, but that’s not the kind of change we’re talking about when we say “ready for change.” Instead, the question is whether the code of the program can be easily changed without rewriting a lot of it or introducing bugs. Here we had two apparently independent changes, by different programmers, that interacted to produce a bad bug.

In both of these examples — the Array<number> and the Date — the problems would have been completely avoided if the array and the date had been immutable types. The bugs would have been impossible by design.

This example also illustrates why using mutable objects can actually be bad for performance. The simplest solution to this bug, which avoids changing any of the specifications or function signatures, is for startOfSpring() to always return a copy of the groundhog’s answer:

    return new Date(groundhogAnswer);

This pattern is defensive copying, and we’ll see much more of it when we talk about abstract data types. The defensive copy means partyPlanning() can freely stomp all over the returned date without affecting startOfSpring()’s cached date. But defensive copying forces startOfSpring() to do extra work and use extra space for every client — even if 99% of the clients never mutate the date it returns. We may end up with lots of copies of the first day of spring throughout memory. If we used an immutable type instead, then different parts of the program could safely share the same values in memory, so less copying and less memory space is required. Immutability can be more efficient than mutability, because immutable types never need to be defensively copied.

Risky example #3: changeability

In our prior discussion of specifications for mutating functions, we noted that programmers tend to assume that mutation of parameters is disallowed unless explicitly stated, because undocumented mutation is surprising and dangerous. (In risky example #1 that we just saw, the implementer of sumAbsolute violated this convention!)

But as we’ve just seen, parameters aren’t the only way that objects can be shared between client and implementer, and mutations don’t always occur during the actual function call. This can complicate our ability to change the client and implementer independently, which is one of the big reasons we have have specs in the first place.

Here’s an example to illustrate the point. Consider this function, which looks up a username in MIT’s database and returns the user’s 9-digit identifier:

/**
 * @param username username of person to look up
 * @returns an array containing the 9-digit MIT identifier for username, one digit per element.
 * @throws NoSuchUserError if nobody with username is in MIT's database
 */
function getMitId(username: string): Array<number> {         
    // ... look up username in MIT's database and return the 9-digit ID
}

A reasonable specification. Now suppose we have a client using this method to print out a user’s identifier:

let id = getMitId("bitdiddle");
console.log(id);

Now both the client and the implementer separately decide to make a change. The client is worried about the user’s privacy, and decides to hide the first 5 digits of the id by deleting them:

let id = getMitId("bitdiddle");
for (let i = 0; i < 5; ++i) {
    delete id[i];
}
console.log(id);

The implementer is worried about the speed and load on the database, so the implementer introduces a cache that remembers usernames that have been looked up:

const cache: Map<string, Array<number>> = new Map();

function getMitId(username: string): Array<number> {        
    // see if it's in the cache already
    if (cache.has(username)) {
        return cache.get(username);
    }

    // ... look up username in MIT's database ...

    // store it in the cache for future lookups
    cache.set(username, id);
    return id;
}

These two changes have created a subtle bug. When the client looks up "bitdiddle" and gets back a digit array, now both the client and the implementer’s cache are pointing to the same digit array. The array is aliased. That means that the client’s obscuring code is actually overwriting the identifier in the cache, so future calls to getMitId("bitdiddle") will not return the full 9-digit number, like [9,2,8,4,3,2,0,3,3], but instead the obscured version [,,,,,2,0,3,3].

Sharing a mutable object complicates a contract. If this contract failure went to software engineering court, it would be contentious. Who’s to blame here? Was the client obliged not to modify the object it got back? Was the implementer obliged not to hold on to the object that it returned?

Here’s one way we could have clarified the spec:

function getMitId(username: string): Array<number> 
requires:
nothing
effects:
returns an array containing the 9-digit MIT identifier of username, or throws NoSuchUser­Error if nobody with username is in MIT’s database. Caller may never modify the returned array.

This is a bad way to do it. The problem with this approach is that it means the contract has to be in force for the entire rest of the program. It’s a lifetime contract! The other contracts we wrote were much narrower in scope; you could think about the precondition just before the call was made, and the postcondition just after, and you didn’t have to reason about what would happen for the rest of time.

Here’s a spec with a similar problem:

function getMitId(username: string): Array<number> 
requires:
nothing
effects:
returns a new array containing the 9-digit MIT identifier of username, or throws NoSuchUserError if nobody with username is in MIT’s database.

This doesn’t entirely fix the problem either. This spec at least says that the array has to be fresh. But does it keep the implementer from holding an alias to that new array? Does it keep the implementer from changing that array or reusing it in the future for something else?

In general, mutable objects make the contracts between clients and implementers more complicated, and reduce the freedom of the client and implementer to change. In other words, using objects that are allowed to change makes the code harder to change.

Here’s a better spec:

function getMitId(username: string): string
requires:
nothing
effects:
returns a length-9 string containing the 9-digit MIT identifier of username, or throws NoSuchUser­Error if nobody with username is in MIT’s database.

The immutable string return value provides a guarantee that the client and the implementer will never step on each other the way they could with string arrays. It doesn’t depend on a programmer reading the spec comment carefully. String is immutable. Not only that, but this approach (unlike the previous one) gives the implementer the freedom to introduce a cache — a performance improvement.

Aliasing is what makes mutable types risky

Actually, using mutable objects is just fine if you are using them entirely locally within a method, and with only one reference to the object. What led to the problem in the two examples we just looked at was having multiple references, also called aliases, for the same mutable object.

reading exercises

Aliasing 1

What is the output from this code?

let a: Array<string> = [];
a.push("cat");
let b: Array<string> = a;
b.push("dog");
console.log(a);
console.log(b);

(missing explanation)

Walking through the risky examples above and drawing a snapshot diagram yourself will make the problem of aliasing clear – here’s the outline:

  • In the Array example, the same array is pointed to by both arr (in sum and sumAbsolute) and myData. One programmer (sumAbsolute’s) thinks it’s ok to modify the array; the other programmer wants the array to stay the same. Because of the aliases, the other programmer loses.

  • In the Date example, there are two variable names that point to the Date object, groundhogAnswer and partyDate. These aliases are in completely different parts of the code, under the control of different programmers who may have no idea what the other is doing.

Draw snapshot diagrams on paper first, but your real goal should be to develop the snapshot diagram in your head, so you can visualize what’s happening in the code.

Readonly collections in TypeScript

The collection types Array, Set, and Map have corresponding interfaces ReadonlyArray, ReadonlySet, and ReadonlyMap that omit the mutating operations. Declaring a variable with one of these types tells the TypeScript compiler that you don’t intend to mutate the collection, and you will get a static error if you call a mutating operation:

const arr:ReadonlyArray<number> = [1,2,3];
arr.push(4); // static error -- ReadonlyArray doesn't have the push() operation

const set:ReadonlySet<number> = new Set([1,2,3]);
set.add(4); // static error -- ReadonlySet doesn't have the add() operation

const map:ReadonlyMap<string, number> = new Map(Object.entries({ "apple": 5, "banana": 7 }));
map.set("pear", 9); // static error -- ReadonlyMap doesn't have the set() operation
arr: ReadonlyArrayArray012123

This is an example of where the static type of a variable differs from the dynamic type of the object that it actually points to at runtime. The snapshot diagram at right shows what this means: the variable arr has static type ReadonlyArray, but it points to an object whose type is Array. When we discuss subtyping in a future reading, we will see the specific relationship that must hold between the static type and the dynamic type, but for now it’s enough to say that arr must point to an object whose dynamic type has all the operations that its static type does. Since ReadonlyArray is a subset of the methods on Array, any operation that could be called on the ReadonlyArray will be available on the actual runtime Array as well.

Unfortunately, this design also has a risk: a ReadonlyArray is not guaranteed to be immutable. The readonly collections are just interfaces, so they have methods but no constructors. So you can’t make a ReadonlyArray directly. Instead, you make an Array (like [1,2,3] in the example above) and then assign it to a ReadonlyArray. But if another part of the program has an alias to the underlying Array, then the array can change under your feet.

So be careful about the following pitfall, which shows up not just in TypeScript but in other languages too. We may think we’ve made something immutable, for instance by declaring it const, and using ReadonlyArray instead of Array. But it may still be mutable if we’re not careful about aliases!

For example:

let yourArray: Array<number> = [ 1, 2, 3 ];
const myArray: ReadonlyArray<number> = yourArray;

I’m trying to keep myArray unchanged, so I declare it const and ReadonlyArray. What can you do with yourArray that undermines that?

reading exercises

Readonly and aliasing

Given the declarations of yourArray and myArray above, which of the following expressions would end up changing myArray?

(missing explanation)

The truly-immutable types in TypeScript — number, boolean, string, bigint — do not have the aliasing risk that we saw in the exercise just above. There is no way to mutate a value of these types. If you declare a const of type int, then there is no way to change that value.

Let’s now return fully to the crux of this reading: How do we design good specifications?

Designing good specifications

What makes a good function? Designing a function means primarily writing a specification.

About the form of the specification: it should obviously be succinct, clear, and well-structured, so that it’s easy to read.

The content of the specification, however, is harder to prescribe. There are no infallible rules, but there are some useful guidelines.

The specification should be coherent

A coherent spec makes sense to its clients as a single, complete unit that performs one task. The spec shouldn’t have lots of different cases. Long argument lists, boolean flags that enable or disable behavior, and intricate logic, are all signs of trouble. Consider this specification:

function sumFind(a: Array<number>, b: Array<number>, val: number): number
effects:
returns the sum of all indices in arrays a and b at which val appears

Is this a well-designed function? Probably not: it’s incoherent, since it does several things (finding in two arrays and summing the indexes) that are not really related. It would be better to split it into two separate functions, one that finds the indexes, and the other that sums them.

Here’s another example, the countLongWords function from Code Review:

let LONG_WORD_LENGTH: number = 5;
let longestWord: string;

/**
 * Update longestWord to be the longest element of words, and print
 * the number of elements with length > LONG_WORD_LENGTH to the console.
 * @param text     text to search for long words
 */
function countLongWords(text: string): void

In addition to terrible use of global variables, printing instead of returning, and mentioning a local variable (words), the specification is not coherent. It does two different things: counting words and finding the longest word. Separating those two responsibilities into two different functions will make them simpler (easy to understand) and more useful in other contexts (ready for change).

The specification must carefully consider mutation

Recall that a function’s signature is part of its specification. In that respect, when possible, always opt for built-in immutable types like string and number, to protect both the client and the implementer from the pitfalls of unintentional aliasing.

Keep in mind that mutation is implicitly disallowed unless stated otherwise. When explicitly allowed, it’s crucial to document the terms judiciously.

And, although not 100% safe, it’s always worth considering using immutable readonly collection types over their mutable counterparts.

The specification should be strong enough

Of course the spec should give clients a strong enough guarantee in the general case — it needs to satisfy their basic requirements. We must use extra care when specifying the special cases, to make sure they don’t undermine what would otherwise be a useful function.

For example, consider this spec:

function addAll(arr1: Array<T>, arr2: Array<T>): void
effects:
appends the elements from arr2 to arr1 in order, unless it encounters a null element in arr2, at which point it throws a TypeError

This spec is written in an inappropriately operational style, but let’s focus on a different problem. This spec is stronger than one that doesn’t mention null elements at all (because then nulls would be implicitly disallowed). But it’s not strong enough to be useful to a client, because it allows an arbitrary amount of mutation to happen before the TypeError exception is thrown. If the exception is caught by the client, the client is left wondering which elements of arr2 actually made it to arr1.

We could improve the specification by strengthening it further, e.g.:

function addAll(arr1: Array<T>, arr2: Array<T>): void
effects:
appends the elements from arr2 to arr1 in order. If arr2 contains null elements, it throws a TypeError and no elements are appended to arr1.

The specification should also be weak enough

Consider this specification for a function that opens a file:

function open(filename: string): File
effects:
opens a file named filename

This is a bad specification. It lacks important details: is the file opened for reading or writing? Does it already exist or is it created? And it’s too strong, since there’s no way it can guarantee to open a file. The process in which it runs may lack permission to open a file, or there might be some problem with the file system beyond the control of the program. Instead, the specification should say something much weaker: that it attempts to open a file, and if it succeeds, the file has certain properties.

Precondition or postcondition?

Another design issue is whether to use a precondition, and if so, whether the function code should attempt to make sure the precondition has been met before proceeding. In fact, the most common use of preconditions is to demand a property precisely because it would be hard or expensive for the function to check it.

As mentioned above, a non-trivial precondition inconveniences clients, because they have to ensure that they don’t call the function in a bad state (that violates the precondition); if they do, there is no predictable way to recover from the error. So users of functions don’t like preconditions. That’s why many JavaScript library functions, for example, tend to specify as a postcondition that they throw exceptions when arguments are inappropriate. This approach makes it easier to find the bug or incorrect assumption in the caller code that led to passing bad arguments.

In general, it’s better to fail fast, as close as possible to the site of the bug, rather than let bad values propagate through a program far from their original cause. Even if, for example, atan(y, x) requires as a precondition that its input not be (0,0), it would still be helpful for it to check for that mistake and throw an exception with a clear error message, rather than returning a garbage value or a misleading exception.

Sometimes, it’s not feasible to check a condition without making a function unacceptably slow, and a precondition is often necessary in this case. If we wanted to implement the find function using binary search, we would have to require as a precondition that the array be sorted. Forcing the function to actually check that the array is sorted would defeat the entire purpose of the binary search: to obtain a result in logarithmic and not linear time.

The decision of whether to use a precondition is an engineering judgment. The key factors are the cost of the check (in writing and executing code), and the scope of the function. If the function is only called locally, inside one module, the precondition can be discharged by carefully checking all the sites that call the method. But if the function is exported or public, so that it can be called anywhere in a program and used by other developers, it would be less wise to use a precondition. Instead, like the JavaScript library, you should throw an exception as a postcondition.

reading exercises

Show me a sign

(missing explanation)

That’s an odd way of looking at it
function secondToLastIndexOf(arr: Array<number>, val: number): number
requires:
val appears in arr an odd number of times
effects:
returns the 2nd-largest i such that arr[i] = val

(missing explanation)

Behavioral oddities
function secondToLastIndexOf(arr: Array<number>, val: number): number
requires:
val appears in arr an odd number of times
effects:
returns the 2nd-largest i such that arr[i] = val

Consider the following test cases for secondToLastIndexOf. Say if each one is already valid; or if not, what changes are necessary to make it valid:

(missing explanation)

(missing explanation)

(missing explanation)

(missing explanation)

Summary

A specification acts as a crucial firewall between implementor and client — both between people (or the same person at different times) and between code. As we saw last time, it makes separate development possible: the client is free to write code that uses a module without seeing its source code, and the implementor is free to write the implementation code without knowing how it will be used.

Declarative specifications are the most useful in practice. Preconditions (which weaken the specification) make life harder for the client, but applied judiciously they are a vital tool in the software designer’s repertoire, allowing the implementor to make necessary assumptions.

As always, our goal is to design specifications that make our software:

  • Safe from bugs. Without specifications, even the tiniest change to any part of our program could be the tipped domino that knocks the whole thing over. Well-structured, coherent specifications minimize misunderstandings and maximize our ability to write correct code with the help of static checking, careful reasoning, testing, and code review.

  • Easy to understand. A well-written declarative specification means the client doesn’t have to read or understand the code. You’ve probably never read the code for, say, Python dict.update, and doing so isn’t nearly as useful to the Python programmer as reading the declarative spec.

  • Ready for change. An appropriately weak specification gives freedom to the implementor, and an appropriately strong specification gives freedom to the client. We can even change the specs themselves, without having to revisit every place they’re used, as long as we’re only strengthening them: weakening preconditions and strengthening postconditions.