8. Classes and Encapsulation
Types play a central role in Java, as we have seen. They represent the state of our program, and they determine how different components can behave and interact. While Java’s libraries provide us with many built-in (reference) types, we often will want to create our own bespoke types to fit the requirements of our program. In this lecture, we’ll discuss how to write classes for user-defined types and how we can extend our memory diagrams to represent objects from the implementer’s perspective. We’ll also extend our discussion of invariants and specifications to classes and their members. Finally, we’ll introduce other properties of variables (scope, lifetime, and visibility) that are enforced by the compiler and see how to leverage these to adhere to the object-oriented programming design principle of encapsulation.
Defining New Types
As we have discussed, a type associates a set of possible states and behaviors to a name. For example, variables with the primitive int
type have state that represents integers within a certain range, and we can perform calculations with int
s using built-in Java operations such as addition and multiplication. Objects with the String
reference type store as their state sequences of characters, and we can interact with these objects through their instance methods like length()
, charAt()
, and substring()
.
If we want to define a new reference type, we can do this by declaring a class with the same name as the type. As an initial example, suppose that we’d like to create a Counter
type that will model a handheld counter similar to the one pictured below.
We start by creating a new file “Counter.java” in which we will declare the Counter
class. We mark the class as public
so that it can be accessed from other files (e.g., the client’s code file that will use Counter
objects).
Counter.java
|
|
|
|
Next, we should think about the state and behaviors of a Counter
object.
Representing State
An object’s state models its current configuration and is represented through variables called fields.
A field (also called an instance variable) is a variable that is associated to an object that stores part of its state.
Fields can be either primitive types or reference types. To determine appropriate fields for a class, we should think about what information is necessary to model the state of one of its objects. In the case of our Counter
object, we should store the numbers that it currently displays. There are different ways we can choose to represent this information. We could store it as a String
of digits, or as a char[]
of length 4. We will opt to represent the state using an int
field that we will call count
.
We pause to note a subtle distinction from the preceding paragraph between state and state representation. A type is an abstract notion which is characterized as having a state. In our example, the abstract notion of a counter is as a type that has an intrinsic "count". We model a type by defining a class, which requires us to settle on a particular state representation for its fields. There are likely many different choices of state representation that all model the same state.
We declare fields directly within the class, outside of any of its methods. Fields are declared using the same syntax as all the other variable declarations we’ve seen. However, we typically do not initialize their values as part of their declarations; we leave initialization to the class’ constructor, as we’ll soon see. When we declare fields, we always include a documentation comment that describes their specifications. This includes their interpretation in the class (i.e., the part of the state that they model) as well as any constraints on their values. This documentation helps to give a clear picture of how the class was designed, which will be crucial for future maintainers. In the case of the count
field, it models the number displayed on the counter, which is limited to 4 digits. This bounds the range of possible values for the field.
Counter.java
|
|
|
|
Sometimes, the specification will also describe relationships that must be satisfied between multiple fields. Together, all the field specifications make up the class invariant.
The class invariant describes a collection of properties of an object's fields that must be true at the start and end of any non-private
method invoked on that object.
We will think more about the second half of this definition shortly, once we have introduced the syntax for defining instance methods. First, let’s consider how to model fields within our memory diagrams (now often called object diagrams since they will visualize the state representation of the objects in our program). We do this by adding variable boxes within the rounded rectangle representing the object. You can interpret this as depicting that each instantiation of a class (i.e., each object) has a variable for each of that class’ fields whose values represent that particular object’s state. A diagram for a Counter
object (which would live on the memory heap) is shown below.
Defining Behaviors
Now that we have introduced fields as a way to model a type’s state, we need a way to model its behaviors. Behaviors describe ways that a client can interact with a type. There are multiple different types of behaviors. Some behaviors simply access (or look at) part of the object’s state. Other behaviors mutate (or modify) an object’s state in some way. Still other behaviors may be called for some side-effect, an action that does not modify an object’s state or return a value to the client (such as printing). A behavior may even do multiple of these in combination.
Let’s think about what behaviors our Counter
object should have. How does a person interact with one of these handheld counters? First, they might want to look at number that it currently displays, so our Counter
object should have a way to access the current value of count
. Next, they can press the silver button, which increments the count by 1, we’ll need a way to increment the count
field. Lastly, they can spin the black dial to reset the display to “0000”. We’ll need a way to reset count
to 0.
Behaviors are modeled with instance methods. These are methods defined within a class that do not have a static
modifier and are able to access an object’s fields. Let’s look at the instance methods for the Counter
class that implement the three behaviors described above.
Counter.java
|
|
|
|
Let’s notice a few things about our instance methods. First, as we already noted, we do not include the static
keyword at the start of the method declaration. The static
keyword tells Java “this method is not invoked on a particular object”, which is not true of instance methods. Instance methods are called on an object, and we refer to this object as the method’s target or implicit parameter.
The target or implicit parameter of an instance method is the object on which the method is being invoked.
Next, notice that each of the methods includes JavaDoc specification following the same conventions that we introduced previously. Finally, notice that we are using a new Java keyword, this
. this
is used to reference the object on which the method was invoked, so it provides us a way to access that object’s fields. A helpful mnemonic to remember what this
is doing is to ask a question like, “Whose count
is being incremented?”: this
object’s count is being incremented since we called the increment()
method on this object. We’ll see how to visualize this
in our memory diagrams shortly.
In most cases, this
can be omitted, since Java can infer that we are referencing an object's fields or methods. One exception to this is variable shadowing, which we'll discuss soon. It doesn't hurt to include this
, though, and we will continue to do so throughout this and upcoming lectures for pedagogical reasons.
The Constructor
As we saw a few lectures ago, every class has a special method called its constructor that is responsible for setting up the object. With our new terminology, the constructor is responsible for initializing an object’s fields in a way that establishes its class invariant. We can add a constructor to a class we are writing by declaring a method with the same name as the class and with no return type. In our Counter
class we add the constructor,
Counter.java
|
|
|
|
Technically, the Counter()
constructor that we just wrote is not necessary. When no other constructors are defined, Java will provide a "default" constructor that takes in no arguments and initializes all the fields to their default values (0 for primitive numerical types, false
for boolean
, and null
for reference types).
Invoking Instance Method
Whenever you call a method, Java checks whether it is a static
method or an instance
method (including a constructor). Since instance methods are called using the syntax “<object name>.<method name>()
”, where the expression before the “.” is an object reference, Java stores this reference, which can be accessed through the keyword this
within the method. Although not technically precise, we’ll imagine that this
is another parameter that is included in the call frame of an instance method.
Let’s walk through a simple example of calling an instance method. To do this, we’ll need to write some client code that constructs a Counter
object. We’ll do this in a main()
method in a separate Runner
class.
previous
next
While it is technically more accurate, writing phrases like "the Counter
object referenced by c
" is rather cumbersome, so we will often abbreviate this to just "c
" where doing so is unambiguous. Remember, it is objects that have fields, methods, invariants, etc., not variables, so saying "invoke increment()
on c
is shorthand for "invoke increment()
on the Counter
object referenced by c
."
Variable Scope and Lifetime
These enhanced memory diagrams we just looked at illustrate that variables can exist in multiple different places; for example, some variables are stored in the call frames on the runtime stack whereas others are fields in objects stored on the heap. These distinctions are closely related to different scopes of variables.
The scope of a variable describes from where in the code it is allowed to be accessed.
Scope is delimited using curly braces in Java, so every code construct that includes curly braces defines a different scope. Some possible scopes are:
- Method Scope: Variables that are declared within the parameters or body of a method (and not within another set of curly braces delimiting an inner scope of the method) have method scope. They are accessible from their point of declaration through to the end of the method.
- Block Scope: Within a method, any code block delimited by curly braces (e.g., a loop body, the
if
andelse
branch bodies of a conditional statement) defines its own scope. Variables declared within these blocks will not be accessible to other code in the method outside of the block. - Class Scope: (Non-
static
) variables that are declared within a class but not within a method of the class (i.e., fields) have class scope. They are accessible through any variable that references an object of that class. - Global Scope: Many programming languages define a global scope whose variables are accessible anywhere in the code. This idea is a little muddled in Java (which places all code within classes) but is related to
static
variables that can be accessed anywhere using the class name. Generally, using mutable global variables is a bad programming practice, so we will not dwell on this further in CS 2110.
Local variables are those with method scope (including parameters) or block scope within a method. They are allocated in call frames on the runtime stack. Fields, as we have seen, are allocated within objects on the heap.
Scopes are nested, just as we can arbitrarily nest control structures such as loops and if
-statements. A line of code can access any variables that are in its same scope (i.e., within the same set of curly braces) or any outer scope that encloses it. In particular, within an instance method of a class, we can access the fields in the outer class scope. A related notion to scope is lifetime.
The lifetime of a variable describes the times during which it is usable during a program.
Local variables have a lifetime that begins when they are declared and ends when returning from the method where they were declared (which deallocates their memory on the runtime stack). Fields have a lifetime that begins when their associated object is constructed and ends when the object is no longer accessible from the runtime stack (perhaps through a chain of references through multiple objects on the memory heap). Scope and lifetime validity (i.e., making sure that no line of code tries to access a variable that is not in its scope) are properties that are checked statically by the compiler.
Shadowing
In most cases, there can be at most one variable with a given name declared in a given scope. Otherwise, Java would have no way to distinguish to which “version” of that variable the name refers. One convenient exception to this rule relates to class and local variables, which may share a name. This can be useful when defining instance methods that take parameters.
For example, suppose that we wanted to add a setCount()
method to our Counter
class that takes in a value from the client, checks that it is within range, and then sets the count
field to this value. The most natural name for this parameter is count
, but this would introduce a count
variable (the parameter) into the method scope inside a class scope with a field count
. When we type count
within this method, which of these variables does it refer to?
The answer is that it refers to the parameter count
. Java resolves scopes from inside-out, working its way to larger and larger scopes until it finds a variable with the given name. This resolution starts at the method scope and never reaches the class scope. For this reason, we say that the local variable shadows the field.
A local variable shadows a field with the same name since references to that name within the method will resolve to the local variable rather than the field.
How do we access the shadowed field? We use the keyword this
. Unambiguously, this
references the Counter
object, so this.count
is the count
field variable associated with this object. This lets us define our setCount()
method as follows:
|
|
|
|
The RHS of this assignment statement evaluates to the value of the count
parameter. This gets stored in the variable on the LHS, which is the count
field.
Maintaining Class Invariants
The implementer of a class is responsible for maintaining its class invariant, since this will ensure that the code conforms to its specifications. In particular, the class invariant must be true at the beginning and end of every (non-private
) instance method call, including at the end of the constructor. Because of this, we can view the class invariant as an implicit pre-condition and post-condition of all (non-private
) instance methods.
As an alternate perspective, we can think about the execution of code involving an object as passing control between the client and the implementer. Typically, the client is in control when the code that they have written is being executed. However, when they invoke an instance method on an object, control is passed to the implementer of that object’s class. While the instance method is being executed, the client’s code “pauses” (it sits in a lower call frame on the runtime stack), so the client has no way to check the state of the object. The implementer (who is writing the object’s class, so is granted more control) can temporarily violate the class invariant to execute the method. They must, however, restore the class invariant before handing control back to the client at the end of the method. In this way, the object is always in a “clean state” for the client. This is similar to loop invariants, which may be temporarily violated in the middle of a loop iteration, but must be restored by the time we re-evaluate the loop guard.
Since the class invariant expresses properties about an object’s state, the implementer must ensure that it is maintained (or restored) within any method that modifies the state of the object (including the constructor). Let’s check this for our Counter
class, which has the class invariant that 0 <= count <= 9999
.
- The constructor initializes
count = 0
, which is within the allowable range. - The
count()
method is an accessor; it does not modify the state of itsCounter
object, so a class invariant violation is not possible. - The
reset()
method reassignscount = 0
, which is within the allowable range. - The
increment()
method increments the value ofcount
. Since the class invariant must hold whenincrement()
is called, we knowcount
’s value was between 0 and 9999. Most of the time, adding 1 will keep it in this range; however, there is one potential problem. As written, ifcount
is equal to 9999 at the start of the method (satisfying the class invariant), it will be incremented to 10000 during the method (violating the class invariant).
Thinking about the class invariant identified a “corner case” that can cause our Counter
class to violate its specification. We can correct this by observing the behavior of the physical handheld counters. When the display reads “9999” and the silver button is pressed, the display “rolls over” to “0000”. Let’s adjust the increment()
definition and spec to model this behavior.
|
|
|
|
In this definition, we have invoked another instance method (reset()
) from within an instance method (increment()
), using the keyword this
as its target.
Encapsulation
With this modification to the increment()
method, Counter
now appears to satisfy its class invariant. However, there is still another issue. Consider the following nefarious client code.
|
|
|
|
When this code is executed, it prints “-49”. This is not good. -49 is not included in the range of allowable values of count
, so c
’s class invariant has been violated. We might be quick to blame this nefarious client for reassigning c.count
to -50, but remember, maintaining the class invariant is the responsibility of the implementer. We need a way, as the implementer, to prevent a client of our class from being able to take this sort of nefarious action, and we can do this by modifying the visibility of our class’ fields and methods.
The visibility of a variable, method, or class describes whether it is accessible within a particular unit of code, such a method or another class.
We can control the visibility of a variable, method, or class by supplying a visibility modifier in its declaration. Since it is included in the declaration, visibility is a static property that can be checked and enforced by the compiler. Today, we’ll consider two new visibility modifiers, public
and private
.
When a declaration is marked as public
, that variable/method/class is visible and accessible whenever it is in scope. On the other hand, when a declaration is marked as private
, that variable/method/class is only visible within the class in which it was declared.
You might ask what the "default" visibility is (when no visibility modifier is included in the declaration). This is a third visibility level called "package-private", which despite its name is much closer to public
for our purposes. We used the default visibility where possible until now to avoid getting bogged down with too many new keywords. However, now that we've introduced visibility modifiers, we'll start to incorporate them in most of our declarations.
We don't use visibility modifiers for local variables since their accessibility is already limited by their scope; they are only reachable from within the current call frame.
The following are some guidelines for selecting an appropriate visibility modifier:
- Any method that we want the client to be able to invoke, either statically or on objects of our class, should be marked as
public
. - Helper methods should be marked as
private
since they are often invoked in the middle of apublic
method’s execution when the class invariant may not hold (note that the definition of the class invariant does not impose a requirement onprivate
methods). - Fields of a class are almost always marked as
private
. We do not want the client messing with our fields, as this poses a risk to our class invariant.
Let’s put these rules into practice in our Counter
code.
Counter.java
|
|
|
|
After these modifications, the “nefarious” code will no longer compile. Instead, we receive the error message
'count' has private access in 'Counter'.
The compiler detected a violation of count
’s private
visibility, preventing the code from executing and protecting the class invariant. Controlling visibility is one example of encapsulation.
Encapsulation is a programming practice where we separate the concerns the implementer and client of a piece of code.
The client should only be able to access fields and methods of the code that correspond to its public interface. Private, implementation specific details that could compromise the class invariant are hidden from the client. A common term for this idea of separation is an abstraction barrier. We abstract the view of a class to the client such that they only have the information that they need to use it. As we proceed in the course, we will discuss other programming practices that promote good encapsulation.
Another Example
Let’s develop another class with a more complicated invariant that will give us more practice with the concepts from today’s lecture. The game Tic-tac-toe is played on a 3 x 3 grid, traditionally drawn without the external border. The grid is initially empty, and two players (“X” and “O”) take turns selecting an empty square on the board and writing their symbol in that square, with the “X” player going first. A player wins if they can write three of their symbols in three cells that form a line, either horizontally, vertically, or diagonally. For example, the following game was a win for the “X” player along a diagonal line.
Modeling State
Let’s write a class that models the state of the game. What do we need to keep track of? Most prominently, we need to keep track of the state of the grid, which symbol is written in each of the cells. Again, there are multiple possible representations of this state, but we’ll use a char[]
array, grid
, with length 9, whose entries are constrained to ‘X’, ‘O’, and ’ ’ and whose indices each represent particular grid locations.
We call this a row-major ordering of the grid because we number all entries in the first row before proceeding to the next row. We’ll also need variables to keep track of whose turn it is, which we can also do with a char
variable, currentPlayer
, that stores either ‘X’ or ‘O’. We can add a boolean
variable, gameOver
that stores whether the game has ended or another turn will take place. Finally, when the game ends, we’ll add a variable that keeps track of who has won. This will be a char
variable, winner
, that also stores either ‘X’, ‘O’, or ‘T’ (if the game ended in a tie).
Some of our choices for how to represent the game state appear a bit arbitrary. Why have we chosen these characters to "encode" the particular states as opposed to other ones? A Java feature called an enumerated type (or enum, for short) offers a more principled way to model sets with a small number of options. However, the representation that we chose is fine, provided it can conform to the specifications.
Let’s set up our TicTacToe
class with these fields.
TicTacToe.java
|
|
|
|
assertInv()
Methods
The class invariant consists of all the constraints outlined in these field specifications. Since the invariant of this class is more involved, it will be helpful to include a private
method that we can use to check that the invariant holds at the end of our (non-private
) mutator methods. We’ll call this method assertInv()
and it will consist of a series of Java assert
statements.
|
|
|
|
This assertInv()
method will help us avoid bugs as we are writing the TicTacToe
class by enforcing the class invariant. We usually include all the “easy” checks in an assertInv()
but leave out the more complicated checks (such as a check verifying the correctness of the value of gameOver
) that would essentially redo the calculations in the other methods. These properties are better enforced by unit testing.
When using an assertInv()
method, remember to run IntelliJ with the -ea
flag so that the assert
statements are actually evaluated. Forgetting this is a common mistake made by many students that leads to uncaught bugs in their assignments.
In the constructor, we must initialize the fields to make the class invariant true. grid
must be initialized to a char[]
containing 9 ' '
s, since the grid is initially empty. gameOver
should be initialized to false
, since more moves are needed before a winner can be determined. currentPlayer
should be initialized to X
, who moves first. We do not need to initialize the value of winner
, as this is only specified once gameOver
is true
. Finally, since our constructor is a mutator method, we should call assertInv()
at the end to confirm the validity of our initializations.
TicTacToe.java
|
|
|
|
Implementing Behaviors
Next, let’s think about the behaviors provided by this class. We can start with the accessor methods, since these are usually easier to implement. What information might the client want to know about the state of the game? First, they might want to know about the symbols in a particular grid cell so they can draw the current board state for the players. Let’s provide an accessor method contentsOf()
that takes in (row, column) grid coordinates and returns the character at that position.
|
|
|
|
This approach is preferable to simply returning grid
for a few reasons. First, it better separates the client’s concern (the game state) from the implementer’s concern (the state representation of the board). If we decided to change the TicTacToe
class later to store grid
as a 2D array (for example), we would not need to adjust the specifications of this method, only its implementation, meaning the client’s code would be unaffected. As a separate concern, returning an array can cause a vulnerability in our code called a rep exposure if we are not careful. We’ll discuss these more soon.
Next, the client may want to query whether the game is over, or who the current player or winning player is. The latter two only make sense at certain points of the game, which we can enforce with method pre-conditions.
|
|
|
|
Lastly, let’s write the mutator method of the TicTacToe
class which the client will use to add moves to the board. We’ll call this method processMove()
. It should take in the position for the move as its parameters and then update the game state accordingly. We must consider the possibility that the parameters do not represent a valid move (either because they are out of range or refer to a square that is already occupied). How should we handle this? We’ll choose to add pre-conditions that delegate the responsibility of checking for legal moves to the client. In a few lectures, we’ll introduce Exceptions, which offer an alternate approach.
Within the method, how do we adjust the board state to reflect the new move? I find it best to go through each of the fields to see which might need to change.
- Clearly, we’ll need to update
grid
to write thecurrentPlayer
’s symbol into their selected cell. - Once the move is made, we need to flip
currentPlayer
to model passing the turn. IfcurrentPlayer
was ‘X’, it should become ‘O’ and if it was ‘O’, then it should become ‘X’. - We must check whether this move ended the game, either by completing a line and awarding that player a win or by filling the entire grid and resulting in a tie. After performing these checks, we may need to update
gameOver
andwinner
accordingly.
We can define the processMove()
method as follows, delegating the work of checking if the game is over to a (private
) helper method. Note that we call assertInv()
before returning from this mutator method.
|
|
|
|
As an exercise, try to complete the implementation of checkForGameOver()
. A complete implementation is provided with the lecture code. We’ve also provided a suite of comprehensive unit tests for the TicTacToe
class. Read through these tests to see how they are structured and what they assert. Then, you can use these tests to verify the correctness of your implementation.
The Client Side
We can write a “client side” application for the TicTacToe
class that processes a user’s inputs to play the game. We’ll write a command line application, TicTacToeConsole
.
TicTacToeConsole.java
|
|
|
|
The complete implementation is provided with the lecture code. Notice the nice separation of roles that our encapsulated class design provides. Most of the code that we have written in the TicTacToeConsole
class is focused on input and output, which is its responsibility. It delegates all the work managing the game state to the TicTacToe
class. Later in the course, we’ll see how to turn this into a graphical application. While the client code will look a lot different, it will use the TicTacToe
class in the same way.
Main Takeaways:
- We can create new types in Java by defining a class. The fields of the class represent the state of its instances (i.e., objects). The instance methods model the behaviors of these objects and can access and modify the fields.
- The class invariant includes all properties that we enforce on the fields. It is the responsibility of the implementer to ensure that the class invariant holds at the start and end of any non-
private
instance method call. We often write aprivate assertInv()
method to help check this. - The accessibility of a variable at a particular point in the code is determined by its scope (where it was declared), lifetime (when it exists in memory), and visibility (which code is permitted to access it). All these properties are checked statically by the compiler.
- The keyword
this
is used to access the target of an instance method within its body. Most of the time,this
can be omitted and inferred by the compiler, except in the case of shadowing. - Encapsulation is a principle of object-oriented programming in which implementation details of a class are hidden from its client. This helps to separate the concerns of the client and the implementer and maintain class invariants.
Exercises
static
method foo()
and a non-static
method bar()
. Which of the following will fail to compile?Consider the following class definition:
|
|
|
|
// HERE
”?Consider the partial definition of a class Incrementer
|
|
|
|
c
is an object of class Incrementer
, and c
’s count
field is initialized to some value \(W\). After calling c.bar()
, what will the value of c
’s count
field be?main()
method when ran. Do not include args
in your diagram.
Refer to the Counter
class in the above lecture notes.
|
|
|
|
|
|
|
|
Refer to the TicTacToe
class in the above lecture notes.
|
|
|
|
Consider the following Student
class.
|
|
|
|
|
|
|
|
private
mutable reference type, clients are able to edit the contents of the state without restrictions, possibly violating class invariants.
BankAccount
class that represents a user’s bank account. A user should be able to withdraw and deposit money.
Point
class that represents a point in 2-dimensional space. A user should be able to query its distance from the origin and translate the point around the 2D plane.
Stopwatch
class that represents a stopwatch. A user should be able to add time, lap, and view all previous lap times.
|
|
|
|
|
|
|
|
|
|
|
|
Consider the partial class definition of IntervalMidpointRadius
.
|
|
|
|
Add documentation for these two fields to describe the class invariant of IntervalMidpointRadius
.
isEmpty()
and intersects()
in this implementation of an interval.
convert()
to the IntervalMidpointRadius
class that returns an IntervalLeftRight
containing the same set of points. Add a method convert()
to the IntervalLeftRight
class that returns an IntervalMidpointRadius
containing the same set of points.
intersectWith()
method to both interval classes that has another interval other
(of its same class) as a parameter and returns the set of points common to both this
and other
(which will be another open interval).
ConnectFour
class as follows:
|
|
|
|
assertInv()
method to enforce the class invariant.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|