In the previous couple of chapters, we looked at some basic
programming techniques such as loops and conditions, and used some of the
data types built into the language and platform, such as int
and string
.
Unfortunately, real programs—even fairly simple ones—are much, much more complicated than the examples we’ve built so far. They need to model the behavior of real-world objects like cars and planes, or ideas like mathematical expressions, or behaviors, like the transaction between you and your favorite coffee shop when you buy a double espresso and a brownie with your bank card.
The best way to manage this complexity is to break a system down into manageable pieces, where each piece is small enough for us to understand completely. We should aim to craft each piece so that it fits neatly into the system as a whole with a small enough number of connections to the other pieces that we can comprehend all of those too.
We’ve already seen one tool for dividing our code into manageable pieces: methods. A method is a piece of a program that encapsulates a particular behavior completely. It’s worth understanding the benefits of methods, because the same principles apply to the classes and structs that are this chapter’s main subject.
You will often see the term function used instead of method; they’re related, but not identical. A function is a method that returns something. Some methods just do some work, and do not return any value. So in C#, all functions are methods, but not all methods are functions.
Methods offer a contract: if we meet particular conditions, a method will do certain things for us. Conditions come in various forms: we might need to pass arguments of suitable types, perhaps with limits on the range (e.g., negative numbers may not be allowed). We may need to ensure certain things about the program’s environment—maybe we need to check that certain directories exist on disk, or that there’s sufficient free memory and disk space. There may be constraints on when we are allowed to call the method—perhaps we’re not allowed to call it if some related work we started earlier hasn’t completed yet.
Likewise, there are several ways in which a method can hold up its side of the bargain. Perhaps it will just return a string or a number that is the result of a calculation involving the method’s inputs. It might change the state of some entity in our system in some way, such as modifying an employee’s salary. It may change something about the system environment—the method might install a new device driver, or change the current user’s color scheme, for example. Some methods interact with the outside world by sending messages over the network.
Some aspects of the contract are formalized—a method’s parameter list defines the number and type of arguments we need to pass, for example, and its return type tells us what, if anything, to expect as a return value. But most of the contract is informally specified—we rely on documentation (or sometimes, conversations with the developer who wrote the method) to understand the full contract. But understand it we must, because the contract is at the heart of how methods make our lives easier.
Methods simplify things for us in two ways. If we are the user of a method, then, as long as its internal implementation conforms to the contract, we can treat it as a “black box.” We call it, we expect it to work as described, and we don’t need to worry about how it worked. All its internal complexity is hidden from us, freeing us to think about ideas like “increase this employee’s salary,” without getting bogged down by details such as “open a connection to the database and execute some SQL.”
If, on the other hand, we are the developer of a method, we don’t need to worry about who might call us, and why. As long as our implementation works as promised, we can choose any means of implementation we like—perhaps optimizing for speed, or size, or (more often than not) simplicity and maintainability. We can concentrate on details like whether we’re using the right connection string, and whether the SQL query modifies the database as intended, without needing to ask ourselves questions like “should we even be adjusting this particular employee’s salary at all?”
So, one objective of good design is to hide distracting details and expose a simple model to your client. This practice is called encapsulation, and it’s harder than it looks. As is so often the case in life, making something look easy takes years of practice and hard work. It can also be a thankless task: if you devise a contract that is a model of clarity, people will probably think it was easy to design. Conversely, unnecessary complexity is often mistaken for cleverness.
While methods are essential for achieving encapsulation, they do not guarantee it. It’s all too easy to write methods whose contract is unclear. This often happens when developers do something as an afterthought—it can be oh so tempting to add a bit of extra code to an existing method as a quick solution to a problem, but this risks making that’s method’s responsibilities less clear.
A method’s name is often a good indicator of the clarity of the contract—if the name is vague, or worse, if it’s an inaccurate description of what the method does, you’re probably looking at a method that does a bad job of encapsulation.
One of the great things about methods is that we can use them to
keep breaking things into smaller and smaller pieces. Suppose we have
some method called PlaceOrder
, which
has a well-defined responsibility, but which is getting a bit
complicated. We can just split its implementation into smaller
methods—say, CheckCustomerCredit
,
AllocateStock
,
and IssueRequestToWarehouse
. These
smaller methods do different bits of the work for us.
This general technique, sometimes called functional decomposition, has a long history in mathematics. It was explored academically in computing applications as early as the 1930s. Bearing in mind that the first working programmable computers didn’t appear until the 1940s, that’s quite a pedigree. In fact, it has been around for so long that it now seems “obvious” to most people who have had anything to do with computer programming.
That’s not the end of the story, though. Methods are great for
describing the dynamics of a system—how things
change in response to particular input data (the method arguments), and
the results of those changes (a function’s return value, or a method’s
side effects). What they’re not so good at is describing the current
state of the system. If we examine a set of
functions and a load of variables, how can we work out which pieces of
information are supposed to be operated on by which functions? If
methods were the only tool available for abstraction, we’d have a hard
time telling the difference between the double
that describes my blood pressure, and
can be operated on by this method:
void LowerMyBloodPressure(double pressureDelta)
and the double
that describes
my weight and can be affected by this method:
void EatSomeDonuts(int quantityOfDonuts)
As programs get ever larger, the number of system state variables floating around increases, and the number of methods can explode exponentially. But the problems aren’t just about the sheer number of functions and variables you end up with. As you try to model a more complex system, it becomes harder to work out which functions and variables you actually need—what is a good “decomposition” of the system? Which methods relate to one another, and to which variables?
In the 1960s, two guys called Dahl and Nygaard (they’re Norwegian) were working on big simulation systems and were struggling with this problem. Because they worked on simulating real things, they realized that their code would be easier to understand if they had some clear way to group together all of the data and functions related to a particular type of real thing (or a particular object, we might say).
They designed a programming language that could do this, called Simula 67 (after the year of its birth), and it is generally recognized as the grandmother of all the languages we’d call object-oriented, which (of course) includes C#.
They had hit upon two important concepts:
With these simple ideas, we can remove all doubt over which functions operate on which data—the class describes for us exactly what goes with what, and we can handle multiple entities of the same kind by creating several objects of a particular class.
As an example, let’s think about a very simple computer system that maintains the information for an air traffic control (ATC) operation. (Safety notice: if you happen to be building an ATC system, I strongly recommend that you don’t base it on this one.)
How does (this particular, slightly peculiar) ATC system work?
It turns out that we’ve got a bunch of people in a big room in
Washington, tracking a large number of planes that buzz around the
airport in Seattle. Each plane has an identifier (BA0049
, which flies in from London Heathrow,
for instance). We need to know the plane’s position, which we’ll
represent using three numbers: an altitude (in feet); the distance
from the airport control tower (in miles); and a compass heading
(measured in degrees from North), which will also be relative to the
tower. Just to be clear, that’s not the direction the aircraft itself
is facing—it’s the direction we’d have to face in order to be looking
at the plane if we’re standing in the tower. We also need to know
whether the aircraft is coming in to us, or away from us, and how
fast. This, apparently, is quite important. (A more comprehensive
model might include a second compass heading, representing the exact
direction the plane is facing. But to keep this example simple, we’ll
just track whether planes are approaching or departing.)
As the planes come in, the controllers give them permission to take off or land, and instruct them to change their heading, height, or speed. The aim is to avoid them hitting each other at any point. This, apparently, is also quite important.
At present they have a system where each controller is responsible for a particular piece of airspace. They have a rack which contains little slips of plastic with the aircraft’s ID on it, ordered by the height at which they are flying. If they are coming in to the airport, they use a piece of blue plastic. If they are going away, they use white plastic. To keep track of the heading, distance, and speed, they just write on the slip with a china graph pencil.[9] If the plane moves out of their airspace, they hand the plane over to another controller, who slips it into his own rack.
So that’s our specification.
In reality, a safety-critical system such as ATC would have a more robust spec. However, when lives are not at stake, software specifications are often pretty nebulous, so this example is, sadly, a fair representation of what to expect on your average software project.
Armed with this brilliant description we need to come up with a design for a program which can model the system. We’re going to do that using object-oriented techniques.
When we do an object-oriented analysis we’re looking for the different classes of object that we are going to describe. Very often, they will correspond to real things in the system. For a class to represent these real objects properly, we need to work out what information it is going to hold, and what functions it will define to manipulate that information. In general, any one piece of information will belong to exactly one object, of exactly one class.
Not all of your classes will represent real-world objects. Some will relate to more abstract concepts like collections, or commands. However, designs that wander too far into the realms of the wholly abstract are often “clever” but not necessarily “good”.
In our ATC example, it’s clear that we have a whole lot of
different planes buzzing round the airport. It would therefore seem
logical that we would model each one as an object, for which we would
define a class called Plane
.
Because C# is a language with object-oriented features, we have a simple and expressive way of doing that.