Class summary: Data Structure and Program Structure
Copyright (c) 2017 Kathi Fisler
1 Organizing Game States
We reviewed your thoughts on this problem from the lecture prep: which of these two data organizations makes more sense for a game with an alien and a robot?
data GameState1: |
| robot(pos :: Posn) |
| alien(pos :: Posn) |
end |
|
data GameState2: |
| state(robot-pos :: Posn, alien-pos :: Posn) |
end |
The first one says that the game state (which gathers the data for all game components) will be EITHER a robot or an alien. The second one says that the game state will contain BOTH. For a game state, we want the BOTH version. In other contexts, EITHER will be more appropriate. We’ll continue to see examples of this decision as we progress through the course.
2 Program structure follows data structure
Understanding the connection between the structure or shape of your data and that of your code can help you get started on code. We first saw this idea with images, when we wrote programs for multi-stripe flags:
above(rectangle(120, 30, "solid", "red"), |
above(rectangle(120, 30, "solid", "white"), |
rectangle(120, 30, "solid", "red"))) |
We have again seen this idea with lists. Recall that a list has the following datatype:
data List: |
| empty |
| link(fst, rst :: List) |
end |
We’ve talked about the list-program structure (or "template"). A function that processes a List will have the following shape:
fun processList(myLst :: List): |
cases (List) myList: |
| empty => ... |
| link(fst, rst) => ... processList(rst) ... |
end |
end |
Here, we see a direct relationship between the shape of the code and the shape of the list: both have separate cases for each of the empty and link data. Both reflect that the rst is a list (which gets processed by calling the function on the rest of the list).
Key Principle: The shape of your code will follow the shape of your data.
2.1 Lists of Lists
So what does this principle imply if we are working with a list of lists? Imagine that you had a list of lists describing a maze (like we have on the project), and we wanted to know how many walls we would have. What do the data and program template look like?
We could write the data block two ways: if we designed this by hand from scratch, we might write:
# this is for the outer list |
data MazeTable: |
| empty |
| table-link(fst :: MazeRow, rst :: MazeTable) |
end |
|
# this is for the inner list |
data MazeRow: |
| empty |
| row-link(fst :: String, rst :: MazeRow) |
end |
(Note: in lecture we wrote this with link instead of table-link and row-link. The names are changed here to avoid conflict with the built-in link in Pyret)
If instead we thought about having built-in lists, you might have expressed this as:
data List-of-List-of-String: |
| empty |
| link(fst :: List[String], rst :: List-of-List-of-String) |
end |
The second version is actually the same as the first due to the embedded list in the first position. The main difference is in how we’d write the example in each case. For example, here’s a simple grid written as list of lists:
gridList = [list: |
[list: "x", "o"], |
[list: "y", "z"]] |
If we expand the [list:] shorthand into links, this would look like:
gridList = link(link("x", link("o", empty)), |
link(link("y", link("z", empty)), |
empty)) |
Here’s the same grid written in the MazeTable and MazeRow definitions:
gridMaze = table-link(row-link("x", row-link("o", empty)), |
table-link(row-link("y", row-link("z", empty)), |
empty)) |
This second version separates the outer from the inner list through the names of the link functions.
If we look at the first (MazeTable) version, a code structure emerges. Specifically:
# process the outer list |
fun processMazeTable(mt :: MazeTable): |
cases (MazeTable) mt: |
| empty => ... |
| table-link(mrow, rst) => |
... processMazeRow(mrow) ... processMazeTable(rst) |
end |
end |
|
# process the inner list |
fun processMazeRow(maze-row :: MazeRow): |
cases (MazeRow) mt: |
| empty => ... |
| row-link(tileString, rst) => |
... processMazeRow(rst) |
end |
end |
What does this code DO? Nothing yet. This is just the template, the shape of a program to process grids. To write an actual program to process grids, you’d copy the template (both functions) and fill in the holes.
2.2 A Concrete Example
Let’s write a function that counts how many times "x" appears in the grid. We copy the template, rename the functions, and add some tests/examples:
# process the outer list |
fun countXMazeTable(mt :: MazeTable): |
cases (MazeTable) mt: |
| empty => ... |
| table-link(mrow, rst) => |
... CountXMazeRow(mrow) ... countXMazeTable(rst) |
end |
where: |
# always test each case, so test the empty case |
processMazeTable(empty) is 0 |
# test the sample maze we wrote earlier |
processMazeTable(gridMaze) is 1 |
end |
|
# process the inner list |
fun countXMazeRow(maze-row :: MazeRow): |
cases (MazeRow) mt: |
| empty => ... |
| row-link(tileString, rst) => |
... countXMazeRow(rst) |
end |
where: |
# always test each case, so test the empty case |
processMazeRow(empty) is 0 |
# these are the two individual rows from our grid example |
processMazeRow(row-link("x", row-link("o", empty))) is 1 |
processMazeRow(row-link("y", row-link("z", empty))) is 0 |
end |
Now, we fill in the holes in the functions, looking at the examples for guidance:
# process the outer list |
fun countXMazeTable(mt :: MazeTable): |
cases (MazeTable) mt: |
| empty => 0 |
| table-link(mrow, rst) => |
CountXMazeRow(mrow) + countXMazeTable(rst) |
end |
where: |
# always test each case, so test the empty case |
countXMazeTable(empty) is 0 |
# test the sample maze we wrote earlier |
countXMazeTable(gridMaze) is 1 |
end |
|
# process the inner list |
fun countXMazeRow(maze-row :: MazeRow): |
cases (MazeRow) mt: |
| empty => 0 |
| row-link(tileString, rst) => |
if tileString == "x": |
1 + countXMazeRow(rst) |
else: |
countXMazeRow(rst) |
end |
end |
where: |
# always test each case, so test the empty case |
countXMazeRow(empty) is 0 |
# these are the two individual rows from our grid example |
countXMazeRow(row-link("x", row-link("o", empty))) is 1 |
countXMazeRow(row-link("y", row-link("z", empty))) is 0 |
end |
2.3 Critical Things to Notice
Here are the criticial things to notice:
There are TWO recursive functions, one for the outer list and one for the inner list
The recursive function over the outer list (processMazeTable) calls the recursive function over the inner lists (processMazeRow) as the first elements of the list.
This is the essential code structure for processing a list of lists. You need two functions (one for outer, one for inner). You can use a built-in function like map or filter for the inner list, but that is still using two different functions that process lists. You need different functions because one function can process only one type of data as its main input.
Wait, does this mean we cannot process the table in just one recursive function? That’s exactly what it means. In some cases, if you can use a built-in function to process either the outer or the inner list, it might look like you have only one function. But all of the functions we’ve seen that process lists (map, filter, length, distinct) are also recursive functions – you just didn’t have to write the recursion yourself.
Carry this observation over to your work on the project.
3 Study Questions
Our gridMaze example has four letters("x", "y", "z", "o"). If we run countXMazeTable(gridMaze), in what order are the letters tested against "x" in countXMazeRow?
Review your data structures and initial code for project 2 in light of what we talked about here.