The Inner Product, December 2003
Jonathan Blow (jon@number-none.com)
Last updated 27 January 2004
Predicate Logic
Related articles:
Lerp 1 (January 2004)
Lerp 2 (February 2004)
Lerp 3 (March 2004)
I believe we are in the midst of a software engineering crisis. Games are
getting larger and more complicated, but our programming tools -- languages,
compiler systems, debuggers -- are basically stagnant. So far we've been saved,
somewhat, by the fact that computers get faster every year, so our code can be
worse and worse, and we still get by. But big, sprawling, complex code will eat
up as many man-hours as you can dish out, and this year more than ever we've
been seeing the results. Delays and feature-cuts are nothing new in our
industry, but now it feels as though we are hitting a wall: there seems to be no
cutting-edge game that isn't horribly delayed, its features slashed, released as
a mere shadow of its creators' intentions. Consequently, each year's
cutting-edge is landing ever closer to the previous year's cutting-edge. It
would seem that we are traveling along a convergent series, and we will soon be
stuck at the furthest point of complexity to which our development methods can
reach.
So we try to improve our development methods. Toward that end, the programmers
of our industry have dived head-first into a pool of Object Oriented Development
books; they bury their programs' data and flow control deep within tangled nests
of C++ features. I suggest that these recently adopted ideas are wrong, that
these techniques are failing us. After all, the proof is in the pudding -- if
the techniques were working, then games using them would become shining
development successes. But to the contrary, the games making heaviest use of C++
features seem to be among the tardiest, most spectacular messes. The programmers
of our industry are buying heavily into dogma that has not been sufficiently
field-tested.
But if you say such a thing in public, you're likely to be scoffed at. How could
that huge stack of OOP books be wrong? What kind of joker are you to deride
these things which have become standard practice? Well they can scoff at me,
because I'm just not buying any of that crap. I spend a lot of time doing
contract work with various developers, and I just see them shooting themselves
in the foot with this stuff, over and over and over.
I want to explore alternative programming techniques, in areas far away from the
current OOP morass. We're now in the habit of needlessly complicating our
programs; we need techniques that simplify programs, that enable us to
accomplish more, with less exertion. I'm not trying to say that the idea
of object-oriented design is totally wrong; clearly it's useful in many
contexts. However, we've been trying to take it too far, which is what
causes all the problems. It's time to look for some alternatives to
augment the object-oriented design philosohpy.
This month I begin a series of articles investigating one trail away from the
beaten path. I don't promise that it will revolutionize programming, but it may
help some; if every game studio were doing a little bit of open-minded
investigation like this, we'd be much better off.
Data Structures and Redundancies
As experienced game programmers, there are many small problems we just solve by
rote -- we just set up some data structures and go, same as we've done for
years. I would like to take an example of such a situation and question it.
Suppose you're making a game, and you've got objects in the game world --
Entities -- and some of these Entities are players who can carry other Entities.
We need some way to represent who is carrying what. Typically we would implement
this as follows: each Entity is represented by an instance of a C++ struct or
class, and each of those instances contains a list (or array) of all items
carried by that Entity. When a player gives a command to drop an item, we step
through his "carrying" list to find that item, and we remove it.
Now suppose we have a magical disappearing Stone that needs to remove itself
from our inventory, of its own volition. Starting with only a pointer to the
stone, we need to remove that stone from the appropriate carrying list. We could
find that list by iterating over all Entities in the world, searching for the
stone in every carrying list. But that's a big pain, and it's slow besides;
instead we tend to put a pointer on the stone (actually, on every entity) that
points to the entity who carries it (or NULL if the stone is not being carried).
Now we face an odd issue. A single fact about the world, "The Dude is carrying
the Stone", is represented by two different pieces of state: the carrying
list on Dude, and the carried_by pointer on the Stone. Our code must be
careful to keep these two pieces of data in sync, or else we're in trouble.
I want to quantify this situation. Suppose that the concept of "carrying"
represents one unit of game functionality (because it's difficult to see how you
might subdivide "carrying" into sub-concepts that make sense on their own). How
many units of work it cost us to implement this one unit of functionality,
"carrying"? As described above, we need to (1) implement the "carrying" list,
then (2) implement the "carried_by" pointer, and then (3) maintain the
constraint between them. By a naively simple method of accounting, this is 3
units of work, yieliding 1 unit of functionality. (Probably item 3 is more
expensive than the other two, as it is more subtle and is a long-term concern).
Ideally to implement 1 unit of functionality, we want to do 1 unit of work (or
less!). Now, taken in just the isolated case of "carrying", this 3-unit
situation is not a huge problem -- we just do the work and then move on. But as
our program becomes larger, filled with analogous cases, and becomes host to a
tangle of interdependent concepts, suddenly we find that we're spending great
piles of work units for mere handfulls of functionality units. We can use all
the simplicity we can get.
High-Level Languages?
Typically the stated goal of a high-level language is to reduce the amount of
work required to create software. However, I think most such languages take
insufficient approaches toward this goal (this is especially true of the
scripting languages we have been developing for games!) Often they provide
features that make programming a little easier, but they fall well short of the
dramatic changes we need. Using a language like Lisp or Objective CAML to
implement our "carrying" functionality, we may end up typing a little bit less,
so our 3 units of work will become slimmer; but they are still 3 pieces of work,
and that is bad.
Here I will make an analogy to the situation of optimizing a program's CPU
usage. Suppose you profile your program, and there are some frequently-called
functions that are taking up a lot of time. The straightforward approach --
streamlining these functions to make them faster -- will give you a speed
benefit. But experienced optimizers know that it's much more effective to
redesign the code to eliminate those functions (if possible!). A function that
you think is fast, is infinitely slower than a function that does not exist.
We face the same situation with this other optimization problem: reducing the
time we spend creating software. Many languages have set out to reduce the
amount of typing you have to perform in order to make thingsdg happen; but if
data manipulation and flow control still occur at roughly the level of C++, then
there's a limit to how fast programming can go, and it's not all that much
faster than what we already have. Rather than trying to make our small-grained
manipulations faster, we should find ways to eliminate them altogether.
The Predicate Logic Experiment
Which brings us to the title of this article. To eliminate redundancies like
carries/carried_by, I want to use an unstructured database that just holds facts
about who carries what. There are several ways to build such a database, but I
chose the style of First-Order Predicate Logic. Predicate logic has been deeply
studied throughout the history of computer science so a simple web search
provides lots of reference material.
To use predicate logic for our "carrying" example, we just want to insert a fact
into the database that says "Dude is carrying Stone." Only that single fact
represents the carrying relation, so there is no redundancy. We can perform
queries like "What is Dude carrying?" and "Who is carrying the Stone?" with
equal ease -- The database engine uses a matching system to generate the
answers.
To represent facts, my syntax is a set of arguments enclosed in parentheses,
with the predicate listed first. So the fact "Dude is carrying Stone" is written
like this: (carrying dude stone). The "carrying" is just an arbitrary label that
we are free to choose. In a real game, you would want "dude" and "stone" to be
identifiers of instantiated entities. For this simple example, though, we will
just use labels for them.
To ask what Dude is carrying, we perform the following query: (carrying dude
?x). The question mark indicates that "x" is a variable. The database engine
looks for any facts that are 3 arguments long and have "carrying" and "dude" as
the first two arguments. It returns a list of all possible values for x, in
other words, everything that Dude is carrying. If we want to know who is
carrying the stone, we can make this query: (carrying ?x stone); now we will get
back a list containing everyone who is carrying the stone (which should be only
1 or 0 items long.)
As programmers who care about the speed of things, we might worry: when there
are a lot of facts in the database, how do we organize them so that these
queries can be answered quickly? My answer for now is, we're simply not going to
optimize. All database items will be stored in one big linked list and the
search will proceed through them all. That's because I will be changing this
system rapidly over the next few articles, so it needs to be very simple and
flexible. In the long term, when we care about speed, we can let the programmer
set policies about which labels or argument slots get indexed by primary hashes,
and secondary hashes, etc -- decisions that are hopefully informed by a good
profile report. These policies would be set late in development, allowing for a
rapid development model where the programmer can create initial functionality
worrying about speed.
Interestingly, we now have a full implementation of the "carrying" functionality
without performing any of the 3 work units listed earlier. We have no inter-data
constraints to maintain, and we don't even have to declare anything either,
assuming we can just dump all the "carrying" facts into a global namespace. I
would say that there's about 1 work unit here, which involves remembering that
the label "carrying" has been used, and that it's a predicate with two
arguments, first the guy who carries, second the guy being carried. Though this
isn't directly comparable with our earlier implementation method, it seems to be
much less effort.
With this predicate logic approach applied across an entire codebase, the
resulting simplification would be significant. Just to speak of lists of
Entities: in my current game, implemented the old-school C++ way, I have many
views of the same set of data: one hash table of all Entities that exist, mapped
by an integer ID; one list for each type of Entity (containing all of that
type), lists for Entities that have been created but not fully initialized by
network traffic, lists of Entities that have been fully initialized but not yet
added to the world, or that have been removed from the world but not yet
destroyed; lists for Entities that are "awake" or "asleep" according to the
physics system. All of these are just the poor C++ programmer's method of
performing simple hardcoded database operations. (There are further redundant
views that are beyond our scope today, like spatial partitionings of Entities
for visibility culling or collision management.) Properly juggling entities
between all these lists, in response to game events, can be a challenge.
Within a fact-database framework, we can perform database queries directly, and
most of the lists above simply disappear -- they go away and are replaced by
nothing. It is infinitely easier to program nothing than to program a lot of
small things.
The Logic Part
Besides adding bare facts to the database, we can also add rules of inference:
if such-and-such things are true, that implies that this other thing is true.
The implied fact can be queried about, just like facts that have been asserted
directly into the database.
Let's look at a time-honored example, the "sister scenario", which seems to have
been expounded in every tutorial ever written about the programming language
Prolog. We have some people, Ann, Mark, and Don, and some facts about them:
(female ann) -- "Ann is Female"; (parent ann don) -- "A parent of Ann is Don",
et cetera. See Listing 1 for the full set of asserted facts. In addition to
these, we can assert an inference rule, "Is y the sister of x?":
(sister ?x ?y) <- (female ?y), (parent ?x ?p), (parent
?y ?p), (notequal ?x ?y).
The "<-" means "is implied by", and the comma is a logical AND. So y is the
sister of x if y is female, the parent of x is some p, the parent of y is that
same p, and x and y are not equal (assume notequal is a built-in primitive). Now
suppose we want to perform a query, like "Who is Mark's sister?", in other
words, (sister mark ?s). The database engine will match this query against the
rule for sister, substituting the arguments mark for ?x and ?s
for ?y, giving us the following temporary rule:
(sister mark ?s) <- (female ?s), (parent mark ?p),
(parent ?s ?p), (notequal mark ?s)
The engine will then attempt to handle all the queries on the right-hand side.
If each query can be resolved (whether by a direct assertion or another
inference rule!), then a result will be returned, a binding for the ?s in
(sister mark ?s). In this case, the query will
return Ann as the result. See Listing 1 for more examples of queries we can
perform. More complex inference rules can be asserted, including recursive ones
(as we will see next month). The general framework of predicate logic can
include other operations besides what we have described here. However, just with
assertion and implication, we can do some very interesting things.
Sample Code, and Next Month
This month's sample code provides a simple predicate logic assertion and
querying system. Next month we'll expand the power of this system, building it
into the larger framework of a full programming language.
Listing 1a // Database facts: (female ann) (male mark) (male don) (parent mark don) (parent ann don) // Inference rule: // Is y the sister of x? (sister ?x ?y) <- (female ?y), (parent ?x ?p), (parent ?y ?p), (notequal ?x ?y). |
Listing 1b // Queries you can perform, and their results... (sister mark ?x) -> { x = ann } // "Who is mark's sister?" (sister ?x ann) -> { x = mark } // "Who is ann the sister of?" (sister don ann) -> false // "Is ann don's sister?" (sister ?x ?y) -> { x = mark, y = ann} // "Who are all the sisters we know about, and who are they sister of?" (sister ? ?y) -> { y = ann } // "Who are all the sisters we know about?" (not caring who they are sister of) (?r mark ?x) // "What kinds of relationships do we know about involving mark in the second slot?" -> { r = parent, x = don ; r = sister, x = ann} |