1 Mutation in Search
2 So what is Mutation?
3 When To Use Mutation
4 Mutation and Testing
5 A Practical Perspective
5.1 Reflection
6 Back to Graph Representations
7 Summary

To Mutate or Not to Mutate

Kathi Fisler

Earlier this week, we left off with two ways to maintain the visited nodes during graph operations: maintaining a parameter with the list of visited nodes, and changing a value inside each node to mark whether or not we had visited it. The real question here is whether (or when) is it a good idea to mutate data (the checked version), rather than to maintain information about the data separately (the visited version). Today, we look at a series of small examples about the challenges of programming when changing/editing data.

1 Mutation in Search

Imagine that you are writing a program to choose moves in a game (such as chess or some other strategy-based game). Your program needs to try out a move, see how it will perform, then backup and try additional moves until you have enough data to choose a good move.

Let’s look at this problem in a bit more detail in the context of a simpler game: tic-tac-toe. Imagine that the game board so far looks like

-------------

|   |   |   |

-------------

| X | O |   |

-------------

| O | X |   |

-------------

If X goes next and chooses the upper-middle cell, X is guaranteed not to win the game. If X chooses the top-right cell, X could still win depending on what O does. We can imagine writing a move-search program that simply tries all the moves (including these two) to determine which makes the most sense.

The search defines a tree of partially-completed boards; at each level, we fill another cell in turn. Each branch terminates when one player has won the game or the board is filled with a tie/draw.

Of course, when we implement the search, we might not want to create a tree of so many boards (especially if we are considering a larger game such as chess). Instead, we might choose to create a single board structure that we modify on each selection:

  class Game

    Board b;

  

    searchMoves(Player forPlayer) {

      LinkedList<Cell> openCells = b.getOpenCells();

      for (Cell c : openCells) {

        this.tryMove(c, forPlayer);

      }

    }

  

    void tryMove(Cell cell, Player byPlayer) {

       b.set(cell, byPlayer);

       // continue search to fill in remaining boards

    }

This uses much less storage, but introduces another problem: once we reach the end of a branch and try to backtrack to try another cell (in the for-loop), the board is already filled with the moves we tried on the previous branch. If we don’t handle this carefully, our search from subsequent moves won’t yield the right answer.

"Carefully" here means that as we back out of each branch of the tree, we have to undo any moves that we may have made on the way down. This task is not insurmountable, but it does complicate the code and open up lots of room for error (if someone forgets to undo and edit or does so incorrectly). Isn’t there a better way to handle this?

Unless space really is so critical an issue, you really are better off generating new game boards as you search through the tree. Note that this is different from generating the entire tree of moves and searching over that. You can simply generate the boards on the current branch, leaving the garbage collector to remove those nodes as you finish processing each branch. If you have to backtrack or undo computation, creating new objects is usually preferable to mutating existing objects.

2 So what is Mutation?

Clearly, as programmers we need to think about whether or not to use mutation. We have examples and warnings here, but not principles. We should try to articulate the impact of mutation on computation and some guidelines on how to use it effectively.

Philosophically, we can state the following principle:

Mutation introduces time into computation

To help explain this, consider the following sequence of operations:

  LinkedList<Integer> MyList = new LinkedList<Integer>();

  MyList.contains(3) --> returns false

  MyList.add(3);

  MyList.contains(3) --> returns true

Clearly, the two evaluations of MyList.contains(3) yield different reults. This means that when we call a computation matters. We’re not measuring "when" in clock-time, but in time as an ordered sequence of events. Depending on what events happened in the past (a notion of time), we get different answers.

Mathematically, this statement is somewhat bizarre: a function, by definition, returns the same output every time it is called on the same input. How do we make sense of this?

We understand this by realizing that there is always an implicit argument to every function: the contents of memory. The contains method is still a function (in the mathematical sense), but if we are being honest about it, its type is

  contains : LinkedList<E> E (VarNames->Addresses) -> boolean

Between the two calls to contains, memory changes. Hence, contains really is a function; it just doesn’t always appear that way since the memory argument is implicit. This is our second main observation about mutation:

Mutation forces you to consider an implicit parameter.

But wait–isn’t memory also an implicit parameter in programs without mutation? Yes, but the whole point of mutation is that it can change the contents of that implicit parameter (beyond adding new names). When you program without mutation, the address tied to variable name remains the same, so the implicit argument is irrelevant. When you program with mutation, you have to think about the contents of that implicit parameter.

But so what? Why is this an issue? Because people tend to forget to think about implicit data. Remember our backtracking tic-tac-toe example? People often forget to "undo" the board edit. With memory not being explicit in the program, that’s easy to do.

3 When To Use Mutation

Mutation is not uniformly evil. We’ve seen that we cannot create cyclic data (graphs) without it, for example. Many programs also require that you use mutation to maintain information. To understand these conditions, consider our observation that mutation introduces time into computation (via past events). There are many computations when you do want the result to be dependent on past events. In a banking system, for example, the history of past transactions determines a customer’s current balance. This suggests that a banking system should use mutation to reflect transactions over time.

But hang on: when we discussed tic-tac-toe earlier, we said that we should not have used mutation there. We should have just passed the current board around as an argument. Didn’t that "current board" capture past choices of moves, which would justify state?

The current board does indeed capture past move choices. However, only the search function can modify or consult those choices. In a banking application, different functions add to or consult the history of transactions. Mutation helps coordinate the actions between those different functions. Put differently, mutation helps manage the combination of remembering (the effect of) past events and sharing that knowledge across multiple functions.

4 Mutation and Testing

On the voting machine assignment, some of you ran into challenges with test cases. Imagine that you set up an election with three candidates: "Ernie", "Bert", and "Oscar". To test your findWinner method, you also set up some initial votes in your Examples class constructor, such as:

  V.processVote("Ernie", "Bert", "Oscar");

Now you write test cases, such as:

  boolean test1 (Tester t) {

    t.checkExpect(V.findWinner(new MaxFirstPoints(), "Ernie")

  }

  

  boolean test2 (Tester t) {

    V.processVote("Bert", "Ernie", "Oscar");

    V.processVote("Bert", "Oscar", "Ernie");

    t.checkExpect(V.findWinner(new MaxFirstPoints(), "Bert")

  }

Note that the order of your test cases now matters: if test2 runs before test1, then test1 will fail. In other words, when you use mutation, tests are no longer independent of one another. That’s a big problem, as it makes maintaining your test cases a LOT harder (especially if multiple people are writing tests for the same code).

In practice, programmers deal with this by writing separate methods to setup each test. Here, you might write:

  void setUpElection1 {

    V.votedata = new VoteData(); // clear out the old election

    V.processVote("Ernie", "Bert", "Oscar");

  }

  

  void setUpElection2 {

    V.votedata = new VoteData(); // clear out the old election

    V.processVote("Ernie", "Bert", "Oscar");

    V.processVote("Bert", "Ernie", "Oscar");

    V.processVote("Bert", "Oscar", "Ernie");

  }

  

  boolean test1 (Tester t) {

    setUpElection1();

    t.checkExpect(V.findWinner(new MaxFirstPoints(), "Ernie")

  }

  

  boolean test2 (Tester t) {

    setUpElection2();

    t.checkExpect(V.findWinner(new MaxFirstPoints(), "Bert")

  }

Now, no matter which order the tests run in, each test sets up the data as it expects. Realistically, testing methods also sometimes undo the effects of the test (in a "teardown" method) – you’ll learn more about this if you go on in software engineering.

The moral of this is that testing somehow got a bit more tedious once we started working with mutation-based classes. Testing is one of the first cases when we see the adverse effects of mutation, but there are others.

5 A Practical Perspective

A friend of mine is on the Google Chrome team. These are his comments on the role of functional programming (versus mutation-based programming) at Google.

What should be type of a search method be? Assume we want (roughly) the same results each time we search on the same terms.

  String -> results

What’s the type of a typical command in a system for searching for flights (for example)?

  flight-constraints -> void

Both of these operations search massive amounts of data. We therefore want to distribute the computation into smaller chucks that different machines can run in parallel. Which of these two type contracts is easier to parallelize?

Relying on state complicates handling many real-world problems:
  • Functional code is easy to parallelize; mutation-based code is not.

  • The average PC stays up roughly 2 months without crashing. If you build your infrastructure on thousands of cheap machines, you’ll have one failing every few minutes. You must be able to move computations to new machines when a machine goes down.

  • Testing is much harder with shared state or lots of local state.

  • How do you schedule updates to highly-shared data structures when machines might die mid-computation? Functions have to return effects (ie, operations to perform on data). Now, highly-parallel algorithms such as map-reduce can work with the results.

5.1 Reflection

Think about this proposal that a function return effects rather than modify data: what it really does is make an implicit parameter (memory) explicit. When we pass a tic-tac-toe board as an argument rather than modify it, we are making the board data explicit. Making data updates explicit turns methods back into functions. This is a powerful and useful technique that simplifies many real-world concerns such as those Google faces.

6 Back to Graph Representations

So now, back to visited versus checked for graphs: what are the implications and tradeoffs? If you use checked flags inside each Node:

Note that none of this discussion was about whether we used a mutation-based data structure (ie, a LinkedList) to store the visited nodes. That list is a local data structure; you can handle that however you want. The question is whether we modify the overall data structure or use a parameter to store our relevant history. There are cases in which each approach is arguably preferable.

Overall, though the visited version is far less error-prone. To program safely with mutation on systems of any complexity, you have to think hard about what you’re doing, know your invariants, and make sure that your code respects those invariants (and those of other programmers with whom you are working). Mutation is actually a fairly advanced topic. It’s unfortunately that programming classes often teach you to use mutation before you are ready to actually handle it well, which leads to a lot of broken code once you start writing larger programs.

7 Summary

The message from all of this is that you should think carefully about not just your data structures, but the properties of their implementations, when writing larger-scale software. We’re not saying "never use a mutation-based data structure" – that would be silly, as it would imply that you never use the built-in Java libraries, for example.

We are, however, saying that you need to think about mutation across the interfaces of your code. Do you want clients of your code to have a stateful view of your code (regardless of whether you use state internally)? What should your code return (void or effects)? You have control over your APIs and the access modifiers (including immutable, which prevents clients from modifying a piece of data) that you put on your objects.