0:00

We have now put the lot of the material developed in this course to work in a

concrete case study. The task is to find a pure functional

solution to run on problem the so-called water pouring problem.

Another variant of the problem is found in the course by Peter Norvig, called Designs

of Computer Programs and Udacity where Peter develops a very nice solution in

Python. The solution developed in our course

differs from his in some respects, first, it's purely functional, and second, it's

also more general. You might find it instructive to compare

the two solutions, if only to see that there's different ways to skin a cat.

The final program we are going to develop in this course is the solution to a

well-known problem called the water-pouring problem.

The idea is as follows, let's say you are given a faucet and a sink,

1:01

And a number of glasses. Right now, I will just draw two of them,

of different sizes. So, just for the sake of the example,

let's assume this glass has size four deciliters, and this glass has size nine

deciliters. What you need to do is put use of a given

quantity, let's say, for the sake of the argument, six deciliters of liquid in one

of the glasses, It doesn't matter which one.

However, you're not giving any marking on the glasses.

So, the only knowledge you have is the total capacity of the glasses.

And the moves that are available to you is, you can either fill a glass completely

using the faucet or you can empty it in the sink or you can pour from one glass to

the other, until either the glass from which you pour is empty, or the glass into

which you pour is full. Now, the classical water pouring problem

uses two glasses of the given sizes and the given target size of six deciliters.

But we are going to generalize that a little bit.

We want to have an arbitrary number of glasses of arbitrary given capacities, and

an arbitrary target capacity in one of the glasses.

So, let's see how we would model this problem.

First, how do we represent the state of the glasses?

Well, the idea would be to represent a glass as an int ranging from zero to the

number of glasses minus one and then our state would be a vector of int that would

give us, for each glass, the number of decilitres that are in that glass.

And then the question is what kind of moves do we have?

Well, we have three, we can empty a glass, you can fill a glass, or you can pour from

a glass to another glass. So, let's see if we have, let's say, the

two glasses of size four and nine, and we start in a state where both glasses are

empty so that would be state zero and zero how that would evolve? So, one thing we

can could do is we could fill glass number zero.

And that will bring us into the state four and zero.

Or we could fill glass number one. And that would bring us into the state,

zero and nine. From there on, we could pour, starting

from this state here, from glass zero into glass one, that could, would give us the

state zero, four, the action with be pour zero to one.

Or, we could for instance, pour one to zero here.

Then, we would end in a state where the first glass has capacity four its full.

And the second glass, has five ounces in it because that's what remains.

3:59

Sometimes, moves lead back to state that we've visited before.

For instance, if we empty the glass one in this state here,

Then we would be left with a state four, Zero, which we can also reach shorter by

just a single move here. Now, you've seen how moves span out.

Question is, how do we find now the right solution?

How do we generate moves so that we find the right sequence of moves to lead us to

our target capacity, let's say, that's six here?

So now, that we know what moves are, let's see how we can use them to find the

solution to our problem. The idea would be that we generate all

possible move sequences, call them paths, Until we hit on one that contains one of

the glasses with the right target amount of liquid in it.

So, we would then start from an initial state of all glasses empty.

And then, generate all possible moves to new glasses.

5:08

Once we have generated all possible moves of length one, we will generate then all

possible moves of length two and so on, Like an onion,

Until possibly we hit on a path where we would have one of the glasses with the

target amount of liquid in it, in this case, six.

The idea then, is that we generate these path sets from inside out, starting with

the shortest ones and progressively lengthening the paths until we hit one

which is the right one, or, that's another possibility, until we have exhausted our

search space and there is no solution. So now that we have an idea for a

solution, let's see how we can put it in code using the Scala Eclipse IDE.

What I going to do is I will, I am going to create a glass.

6:16

So, pouring should take as a parameter, a vector of all the glasses and their

capacity. So, that would be a vector a vector of

int. One entry per glass, and for each entry,

its capacity. The first thing I'm going to do is, I'm

going to work on states. So what is a state?

Well, a state is a vector of integers. One thing we could do to make that clear

is make a type alias, saying, state equals vector of int.

The next thing to do is, well, let's define what the initial state is.

So, the initial state would consist of all empty glasses.

So, what we're after is a vector of the length of capacity consisting of all

zeroes. There are several ways to do that.

Probably, the most elegant way would be to use a map.

7:16

So, what should I apply in the map while it's a function that takes a glass, an

integer, and returns zero for any integer it gets.

Good. So now, we have the initial stat, how can we move from that state?

Let's define what are our moves are. The idea there is that I would have a

glass for each move. Let's make it a case glass for

convenience. And they all would inherit from a common

base trait move. So, I would have trait move here.

And I would then define three case glasses, empty.

Fill and pour that all extend move. So now, that we know what types of moves

are available to us, we still need to generate all possible moves, so all

possible moves would be empty, an arbitrary glass.

Fill an arbitrary glass or pour from an arbitrary glass into another.

So, let's generate those. As a, as an eight here, I have defined one

auxiliary state of structure, all glasses that will be arranged, that goes from one

until capacity length.. So, what would the possible moves are?

Well, one way to do that would be to define it as for-expressions.

Let me do that. So, let's say for g taken from glasses,

Yield empty g. So, those are the first moves available to

me, empty and arbitrary glass. Other moves are for g taken from glasses,

fill g. And finally, for from, taken from glasses

to, Taken from glasses if.

From is different from to. We can't pour from one glass into the

same. Then yield, pour from and to Okay.

So, let's set up a test to see what we have so far.

I'm going to create a new worksheet. Call it test.

10:08

And what we see here is I get a vector to say empty zero, empty one, fill zero, fill

one, pour zero, one pour one, zero. So, those are all the moves that I have.

But, of course, I could also generate another glass.

Let's put another glass nine in here. And then I would have more moves that you

can see over here. So, three empties, three fills, and six

pours. Back to our glass.

The next thing to consider is how moves change states.

There are a number of ways we could record that.

What I'm going to do here is I'm going to write a method change in the move trait

that will have to be implemented by each case glass.

So, change is defined on a move and it takes a state.

11:15

state changed? Well, we thought the new state would be

the old state. With the updated methods, so that, there's

a change at one point in the state. At what point is it?

Well, at my glass, which now will be empty.

So, it's the old state updated at the point of the glass where it's now empty.

But remember, that updated doesn't destroy the state, the old state will still be

available, it's just that, a new state object that gets generated by this updated

method. Updated is a purely functional method

here. Good.

So, let's see for fill. Here, our change would be that the glass

is updated not to be zero, but to be full to its capacity.

So, glass gets updated to capacity of glass.

And finally, for pour, There, the problem is a bit more

complicated, because it depends whether one glass can fully fit into another or

not. So, what I want to do is, I want to first

define the amount that gets poured from one glass to the other.

So, what would that be? Well,

It could be that we take everything that is in the from glass in the given state,

provided that there's enough room in the to glass.

Or if there's not, then it could also be that we fill the to glass to its capacity.

So, it would be state of capacity of to minus state of to.

So, the amount is the smaller of the current filling grade of the glass we pour

from and the free space in the glass in which, into which we pour.

Now, that we know the amount to pour, the state can be updated straightforwardly.

So, it's the old state updated at the from glass, where the new value of the from

glass is the previous value minus amount. And the new value of the to glass is the

previous value of the to glass plus the amount.

So now, that we've seen moves and the changes they do to states, it's time to

look at paths. So, paths would be sequences of moves.

I'm also going to define a class for paths, so I define a path by its history,

which is a list of moves. And the idea would be that the history is

taken in reverse that makes it easier to extend the path with the new moves.

So, that means that the last move in the path comes first in that history list.

14:11

So, what I use for operations on our path? Well, one operation I would be interested

in is, what state does it lead to? So, let's define that end state.

And will, that would be a state. How do I define that?

Well, one way to do it would be by a pattern match over the history list, and

that would be a recursive function. So, let's try that.

So, let's call this, track state of history.

And we would have a auxiliary function, Which would be defined by a pattern match

over an argument list. If the argument list is Nil, then we

return the initial state. If it consists of a move and some

remainder excess one, Then what we would do is we would track

the state of the rest, remaining list. Remember, the last move comes first in the

list. And then, we would apply the first move to

change the result of that. So, that's simply move, change the result

of track state. Because track state returns the state, and

move is a change method that changes the state to give the new state.

15:41

So, that computation of end state works, but let's look at it closer for a moment.

Let's look at it graphically. So, what we have here is we have

essentially done a set of changes with moves, where each cursive call is a

previous version of the history, until initially when the list is Nil, we return

initial state. So, what does that remind you of?

Well, it's a foldRight. So, it's a foldRight where we go through

the list, and we combine it each time with the change operator.

But we can reformulate it as follows. We take the history, we do a foldRight.

We state the initial state at the lower right as initial state.

And our operation is, The one way we take each move from the

history and we change each state on the right.

And that will do exactly the same thing. Now, the new formulation is, without a

doubt, much shorter and some would argue more elegant, than the recursive pattern

matching solution. But for most people and particular

beginners, it's also much harder to come up with and maybe to read.

So, if you or your team would decide at some point that you prefer the recursive

explicit pattern matching solution, that's perfectly okay.

So, what we can do now is we can delete our previous auxiliary method track state.

We have now condensed it into the foldRight and obtain a solution that's

much shorter for pass. So, what else do we need to do on a path?

Well, one useful operation is no doubt to extend it with another move.

17:37

And that would just be a new path where the move precedes the history that we have

so far. And finally, it's always good to be able

to print objects in an intelligible manner so let's define a two-string function.

So, to print the path, we want to print its history.

But you probably want to reverse it first, so first moves first and later moves,

later. And we want to print it, let's say, with a

space between different moves. And finally, it's also good to know where

the path leads to, so we are, interested in its end state, so let me write it this

way. .

18:41

Now, we have all the elements that we need for our solution.

Let's take a quick look at the diagram. So, you see here that we would have a

method that extends a set of paths with new moves, successively of longer and

longer lengths. So, the way we will put that in code is

that we have a function very similar to the from, from integers.

Only now, we don't start from an integer and say, generate all successive integers,

We from a set of paths. And we generate a stream of set of paths,

so that means you generate longer and longer set of sets of paths in a string.

So, one thing to take care of is the boundary condition what if path is empty.

In that case, there is nothing to evolve so we should return the empty string.

If paths is not empty, what do we do? Well,

The idea is we need to generate all the possible new paths that, I have the paths

in the set as a prefix. And from then on further more, evolve the

stream. So, let's do the first thing first.

So, let's call the new paths, more. So more gets generated by a

for-expression. We let path iterate over all the paths in

our set. And then for each of them, we generate a

next path by extending the current path. And extending the current path, we do with

all possible moves. So, the way we do that is that we have the

call moves map path.extend. For each of the possible moves, we apply

the operation extent with that move to the path and that gives us a new path.

20:41

And all those mixed paths would yield my more set.

Now, that I have my next generation set more, I can use that to define the full

stream that starts with the given path set.

And that is followed by the evolution of my next generation more.

So, I, it would start with paths, then more.

Because that would be the paths for the next iteration from more.

Then more of more. So, twice paths of, of lengths two added

to paths, and so on. So, that's my path-generating function

21:28

So, let me call that pathSets. And that would be from applied to the set

that consists only of the initial path. So, what that set gives us is, in the

first set, it gives me set of initial path.

In the second element, it gives me the set of all paths of length one that start with

initial path. Then, all paths of length two and so on,

until infinity. So now, that we've that much, we can have

a quick look in the worksheet to see what we get.

So, let's take a problem, and take all the path sets.

Maybe reduce the size of the problem like this. So, we would get a stream,

22:18

Which is the vector of 0,0. And then, because it's a stream, it

doesn't evolve any further. So, let's do the usual trick, and say,

let's take three elements and convert to a list, and see what we get.

And we get an impressively long list of possible moves that are all the moves that

we generate here in the int states. So now, that we have all possible paths,

let's see how we would find the solutions. So, let's define a function solution.

It takes a target, which is the volume that we want to see in one of the glasses,

an int, and it should give us back a stream of paths that have the target

volume in the end states. So, how would we go about defining that

function? Well, the idea is to go through all the

pathSets and from each of the pathSets, pick the paths that are solutions, that

have the target volume in their end state, and finally, to concatinate them all in

another stream. So, the result stream would then consist

of all solution paths ordered by their length.

So, let's do that. So, it would be a for-expression where we

first go through the pathSets which are taken from the pathSets value here.

And then, for each pathSets, we go through all of the paths in that set.

And then, we want to demand that the path is a solution.

So, what would that be? Well, it would be that the end state of

the path contains the target volume. Then, the path is a solution and, in that

case, we return it in the resulting string.

Now, that we have the solution sets, let's put it to a test in our worksheet.

24:44

Then, we see it, unfortunately, it takes already a long time.

That's not so good so it seems we have found a solution but not a very efficient

one. So, let's go back and see what the problem

could be. So, one problem is if we look at the

diagram, that we move blindly. That means we generate from each possible

state, Let's say, we have a state here,

We generate new states, but we will also generate a lot of old states.

So, we do a sort of random walk on the states, constantly revisiting also old

states. And these old btates don't really bring

anything to the solution Because when we go back to an old state, that's not a path

we want to consider because we, by definition, there is a shorter path that

leads to the same state. So, the problem we have is that all this

exploration is rather aimless. And a better way to do it would be to

exclude an state that we have visited before.

So, let's try to do that. So, the idea would be that, in my stream

generating function, I would have a second set, now a set of states that represent

the explored states. And I will now restrict my moves to those

that don't lead to a state that's already explored.

So, what I would write here is, let's say, explored is not allowed to contain the end

state of next. Now, it, I still have to fix up some bits

that now yield type errors. So, the first thing is in the recursive

call to from, I have to pass a new explored set, so that would be the old

one. And also for each of our moves, our path,

and also, for each of the paths in more, its end states.

So, That would be more map end state.

27:02

And now, we have a solution to this problem, which is slightly longer than the

one we've seen before, also, in very little time.

We can also try something else. We can add more glasses, such as, let's

say, nineteen. And we want to find seventeen.

We can try that. And again, we would get solutions fairly

quickly. Let's look at the problem again.

We have a workable solution. It's reasonably efficient.

Can we make it more efficient? Is there, is there room for optimization?

Well, one area where we could optimize is the endState method.

So, endState gets called a lot, you see, in the, in the exploration of possible

solutions. And each endState is a foldRight over a

complete history. So, a recursive function over the history

as paths get longer, and state becomes more and more complicated to compute.

There again, you could say, well, why recompute the end state of a path?

Can't we just restore the end state once and for all in the path and that would

avoid the recomputation? So, let's try that.

So, What I'm going to do is I'm going to put

the endState in the path. That means, I can remove it here.

And because it should be available from the outside, I make it a L parameter, so

now I can see it. And now, I have to compute a new endState

when I compute a new path. So, what would be the endState be of the

path that consists of the given move last, and the history before that.

Well, it would be the change effect on the move, on the previous endState.

So, that's the new one, where if you add a move to a path, then we add the new

endState that consists of the change effect of the move on that endState.

And the initial path then would be, The endState is the initial state.

29:09

And if we do that and tested again, we find the same solutions.

And maybe, it's a tad quicker. It definitely will become more apparent as

paths would get longer. So, that completes the solution.

So, what we did is, we modeled the problem in a glass pouring.

We first modeled states, and we modeled moves and the changes they have on states.

And we generated all the moves in this variable here.

Then, we modeled paths as their own class. And finally, we modeled the state

exploration function from as a for-expression.

And the function that picked out all solutions from the generated paths as

another for-expression. So, this was quite a complex program. In

particular, there is a lot of choice of representations.

So, we picked specific glasses for moves and paths but we could also have taken

some encoding. We picked object-oriented methods.

We could also have done some naked data structures with functions.

Present elaboration is just one solution not necessarily the shortest ones but I

believe it's actually quite clear from a domain modeling standpoint.

Now, you might ask what are good guiding principles for a program like that?

In the end, it comes down to experience. As the saying goes, you can learn

programming in ten days, and then you improve for ten years.

But there are a couple of guidelines anyway, that are useful.

So, I believe the first guideline is, name everything you can.

Function programming is great because it allows you to break up things in really

small pieces, Expressions that consist of maybe just

three words. Make use of that.

Some of the assignments I've seen have crammed dozen or more operators on a

single line. Taking advantage of the conciseness of

functional programming in Scala. But I don't actually think that's good

style. So, break things up in little pieces,

Give a name for each piece, that makes programs much more intelligible and

readable. So, for instance, we put the change method

inside the move classes, because a move changes things.

So, it makes sense to define change right where you have the move.

And finally, when you do a design, keep in mind that you always want to keep degrees

of freedom for future refinements. So, think of what might change in the

future and how you could encapsulate this, the implementations from clients so that

you could change it in the future without changing any of the client code.