Java is built around object-oriented programming. Object-oriented programming is built on the idea of representing collections of data as “objects” to be interacted with. Similar objects are described by a single class.
In this module, we’ll show some examples for how to create and use a Java class.
Note: This unit covers pre-requisite material for CS 3140, and should already be understood by students before taking CS 3140. However, because of the heavy emphasis on Object-Oriented Programming on CS 3140, this material is included in the online textbook.
A class is a description of how a particular “type” (or classification) of objects are interacted with and behave. In Java, we write a Java class as a .java file. This .java file describes the data and methods the class has, as well as defining constructors for instantiating objects. It’s important to remember that a class is a pattern, or blueprint, used to describe a classification of objects, but is not itself and object.
In Java, when we create a class, we are defining our own data type. In the same way we can create String
and ArrayList
objects in Java, we can create objects of our own custom data type.
Example: the following shows a partial example of a simple class for modeling Pez Dispensers:
public class PezDispenser {
//fields
private String characterName;
private final int capacity;
private int count;
//constructor
public PezDispenser(String characterName, int capacity) {
this.characterName = characterName;
this.capacity = capacity;
this.count = 0;
}
//methods
public void load() {
count = capacity;
}
public boolean dispense() {
if (count == 0) {
return false;
}
count--;
return true;
}
}
Here, we see a field for storing the name of the character (characterName
) of the PezDispenser. We also see fields for tracking the maximum number of candies in the PezDispenser (capacity
) and the current number of candies (count
).
However, without creating an instance of a Pez Dispenser, we cannot interact with this code.
An instance is a particular example of a class. For example, if I bought 3 Pez Dispensers, I would have 3 instances of the class PezDispenser.
In a well-designed class, instances of the same class: 1) Each have their own state, separate from other instances 2) Share the same behavior with other instances
Constructors are special methods used to create instances of a class. For example, in the above PezDispenser
class, the constructor is:
public PezDispenser(String characterName, int capacity) {
this.characterName = characterName;
this.capacity = capacity;
this.count = 0;
}
This method is invoked in a special way. For example, let’s say I wanted to make a Superman Pez Dispenser with a capacity of 12. I would, in the code that uses the PezDispenser class, write:
public class PezDemo {
public static void main(String[] args) {
PezDispenser superman = new PezDispenser("Superman", 12);
...//do something with superman
}
}
Now we have an instance of the class PezDispenser, referenced by the variable superman
. If we also wanted a second, Batman Pez Dispenser, we could augment this code:
public class PezDemo {
public static void main(String[] args) {
PezDispenser superman = new PezDispenser("Superman", 12);
PezDispenser batman = new PezDispenser("Batman", 12);
...//do something with superman and Batman
}
}
This gives us two separate instances.
The state of an object is the description of the current status of an instance (not a class!). In this way, all instances have their own state. Typically, the state of an object is described that the value of its fields.
In the above class, the fields of the class PezDispenser are:
public class PezDispenser {
private String characterName;
private final int capacity;
private int count;
...
}
Note that it is not required to list all fields at the top of the class. However, it is convention to do so. Following this convention ensures that classes are more easily readable and predictable.
The state of the two instances are as follows:
superman
- characterName = "Superman"
, capacity = 12
, count = 0
batman
- characterName = "Batman"
, capacity = 12
, count = 0
Think of each of these objects as having a set of their own 3 variables, one String
and two int
s. Technically, for both objects capacity is not a variable but a final
, or immutable value, but the key is that both values of capacity
are stored in separate memory locations.
While each instance of a class has its own state, the instances share the same behavior. For example, the way that I load
or dispense
from a Superman Pez Dispenser is going to be the same as the way I load
and dispense
a Batman PezDispenser.
Typically, behaviors of a class are described by their methods (non-static functions). In the above class, the fields of the class PezDispenser are:
public class PezDispenser {
...
public void load() {
count = capacity;
}
public boolean dispense() {
if (count == 0) {
return false;
}
count--;
return true;
}
}
These methods describe the two behaviors we can do on a Pez Dispenser. That is, we can load or dispense from a Pez Dispenser. These methods are invoked by calling the method on a specific instance using the dot operator. For example:
public class PezDemo {
public static void main(String[] args) {
PezDispenser superman = new PezDispenser("Superman", 12);
PezDispenser batman = new PezDispenser("Batman", 12);
superman.load();
...
}
}
In the last line of this method, superman.load();
, we are saying:
Call the load method on the superman
instance of PezDispenser.
Before we call this method, are values are:
superman
- characterName = "Superman"
, capacity = 12
, count = 0
batman
- characterName = "Batman"
, capacity = 12
, count = 0
However, after calling superman.load()
, the values of our two instances are:
superman
- characterName = "Superman"
, capacity = 12
, count = 12
batman
- characterName = "Batman"
, capacity = 12
, count = 0
You’ll notice that only the value of count
for superman
changed. The value of count
for batman
did not change, because we didn’t act on the batman
instance. This is because both instances have a separate state. And while we changed the state of superman
, we did not change the state of batman
.
However, if we also wanted to load the batman
Pez Dispenser, it would be as simple as calling batman.load()
. That is, invoking the same behavior on the different instance.
Why is this useful?
The key benefit of the tools we looked at so far is encapsulation. That is, separating how something is used from how it works. And we interact with the idea of encapsulation all the time!
For example, if you want to use a toaster, you plug it in, add bread and push down the lever. You don’t have to think about:
You don’t have to answer these questions, but whoever designed and built the toaster did! These questions have answer, but you don’t need them to use the toaster.
This is valuable in design because it allows us to create classes that are simple to use, and hides any implementation details from the user.
In this way, we want to ensure that the user of a class only interacts with classes in intended ways. For example, you may notice that I made all three fields of Pez Dispenser private
, but I made the methods public
. These values describe how encapsulation will be enforced. Specifically:
public
- this field/method/constructor can be accessed via any other class in a given project
private
- this field is hidden from all other classes in a given project. For example, PezDemo cannot access the fields in the class directly. This means that PezDemo can only set the value of count
through usages of the load
and dispense
methods.This limitation is a good thing, because it prevents the PezDispenser
object from being used incorrectly. For example, if we had count
as public
, wwe could modify it directly, with something like:
public class PezDemo {
public static void main(String[] args) {
PezDispenser superman = new PezDispenser("Superman", 12);
PezDispenser batman = new PezDispenser("Batman", 12);
superman.load();
batman.count = 7;
...
}
}
While this may seem convenient, the problem is that it allows a client (someone using the class) to perform meaningless and incorrect operations.
public class PezDemo {
public static void main(String[] args) {
PezDispenser batman = new PezDispenser("Batman", 12);
batman.count = 99;
batman.load();
}
}
In the above case, we set the Batman PezDispensers count
value to 99, which is much larger than the capacity. Then, when we load
the PezDispenser, it results in count
decreasing to 12. This weird behavior could cause confusion or a bug down the line.
As such, by making count
be private
, it becomes syntactically illegal to use batman.count
(or superman.count
), as the field is hidden.
The following units cover common programming tools/techniques for creating classes and using objects.
Of course, we may still want to see what the value of count
is, or we may want to allow the client to set the value of count
, but within certain parameters (i.e., between 0 and capacity
inclusive).
You can have multiple constructor methods for a single class. For example, with our PezDispenser
class, we could have a secondary constructor that defaults the value of capacity to 12
:
public class PezDispenser {
//fields
private String characterName;
private final int capacity;
private int count;
//constructor
public PezDispenser(String characterName, int capacity) {
this.characterName = characterName;
this.capacity = capacity;
this.count = 0;
}
public PezDispenser(String characterName) {
this.characterName = characterName;
this.capacity = 12;
this.count = 0;
}
...
}
Additionally, if we wanted to make PezDispensers that we can set the initial count value, we could add a three argument constructor:
public class PezDispenser {
//fields
private String characterName;
private final int capacity;
private int count;
//constructor
public PezDispenser(String characterName, int capacity, int count) {
if (capacity < count || count < 0) {
throw new IllegalArgumentException("count must be non-negative and no greater than capacity");
}
this.characterName = characterName;
this.capacity = capacity;
this.count = count;
}
...
Here, we have to handle some basic error checking to ensure that the value of count makes sense given the capacity.
But…this is all very redundant, yes? Three constructors that are nearly identical, and simply set default values in two of them. As such, we can shorten these three constructors using the this
constructor:
public class PezDispenser {
//fields
private String characterName;
private final int capacity;
private int count;
public static final int DEFAULT_CAPACITY = 12;
public static final int DEFAULT_COUNT = 0;
//constructor
public PezDispenser(String characterName, int capacity, int count) {
if (capacity < count || count < 0) {
throw new IllegalArgumentException("count must be non-negative and no greater than capacity");
}
this.characterName = characterName;
this.capacity = capacity;
this.count = count;
}
public PezDispenser(String characterName, int capacity) {
this(characterName, capacity, DEFAULT_COUNT);
}
public PezDispenser(String characterName) {
this(characterName, DEFAULT_CAPACITY, DEFAULT_COUNT);
}
...
}
Here, we have one primary constructor, and two constructors that simply pass the inputs (along with any default values) to the primary constructor. In this way, we are still only setting the initial field values in one constructor, but allowing the flexibility to call the constructor with different numbers of arguments, depending on what the user needs. Be aware that when using the this
constructor in this way, the this
call must be the first line of the constructor.
Some methods in objects may be either destructive or non-destructive. A destructive method is one that, when the method is called, could result in the state of the object changing. For example, the PezDispenser
methods load
and dispense
are destructive methods. Note that we call dispense
destructive even if a particular call (namely, when count is already equal to zero) would not result in the object changing. In general, setter methods are always considered destructive (since that is the point of a setter).
Non-destructive methods are methods that do not change the state of the object they are called on. Typically, methods like toString
and hashCode
should always be non-destructive, as should getter methods. In some classes, there will be no destructive methods at all! For example, the Java String
class has no destructive methods. This is because the contents of a String are final
, or immutable, meaning they can’t be changed. Take for example the following code:
String s = "Hello World!";
s.toUppercase();
System.out.println(s);
What would you expect to print here? Well, you might think what will print is HELLO WORLD!
, but you would be incorrect! While the method toUppercase
returns the String
"HELLO WORLD"
, it doesn’t change the value of s
. That’s because the existing String s
cannot be changed after it is created. If you want to change the String s
to be all uppercase, you would have to reassign the variable s to a new String.
String s = "Hello World!";
s = s.toUppercase(); //reassign the value of s
System.out.println(s);
The important difference here is that the String
variable s
is now actually referencing to a different String entirely. That is, the new String "HELLO WORLD"
is stored in memory in a completely different location than the old String "Hello World"
. As a note, after this code executes, the old String location will eventually be released back to memory to be reallocated as needed.
There are a few Java keywords worth mentioning as it relates to class construction.
Unless explicitly stated to be static
, all methods are non-static. For example, everything, except the constants we created (DEFAULT_CAPACITY
and DEFAULT_COUNT
) is non-static. So what does that mean?
As an example, consider the following small Java class:
public class Example {
private int x;
private static int sum = 0;
public Example(int x) {
this.x = x;
sum += x;
}
public int getX() {
return x;
}
public static int getSum() {
return sum;
}
}
Let’s say we run the following code:
public class ExampleDemo {
public static void main(String[] args) {
Example example1 = new Example(1);
Example example2 = new Example(2);
Example example3 = new Example(3);
System.out.println(Example.getSum());
}
}
The above code will print 6. That’s because whenever we create a new instance of Example, we are adding that Example object’s x
value to sum
. Additionally, note that when we call getSum
, we aren’t using an instance of Example
, but rather we are calling getSum on the class Example. This is what we mean by a static method not belonging to any one instance. It can be invoked by the class name. There are a number of static methods you have like used before:
Math.sqrt(5);
Integer.parseInt(input);
Arrays.toString(myArray);
Collections.sort(myList);
Here, we are calling the method we want on the class. We don’t make an instance of the classes Math
, Integer
, Arrays
, or Collections
.
By contrast, non-static method calls will look like:
String s = myString.toUpperCase();
String input = scanner.nextLine();
myList.sort();
superman.load();
Here, we are calling methods on an instance of a class, specifically an instance of String
, Scanner
, List
, and PezDispenser
.
When in doubt, you should generally make your methods non-static. This is especially true if you are create a datatype that you are going to use multiple times in your program.
final
By default, every variable you create in Java is mutable, which means “it can change”. However, when we create objects, there may be variables that we don’t want to change. The final
keyword gives us a syntactic way to make a variable immutable.
For instance, consider our PezDispenser fields:
public class PezDispenser {
//fields
private String characterName;
private final int capacity;
private int count;
...
}
You’ll notice that the variable capacity
is final
. What this means is that, after the constructor is completed, the value of capacity can never be changed. That is, the value of capacity
is immutable. On the other hand, count
is not stated as final
, and is able to change. In fact, it’s necessary for count to be able to change in order to mimic the behavior of a PezDispenser (since loading and dispensing from a Pez Dispenser changes the number of candies inside).
It’s worth noting that I left characterName
as mutable. Do I have a good reason for this? Not really! And, as Brian Goetz states:
“Just as it is a good practice to make all fields private unless they need greater visibility, it is a good practice to make all fields final unless they need to be mutable.” -― Brian Goetz, Java Concurrency in Practice
In this case, it would make sense for me to make characterName
final
unless I am envisioning something like a PezDispenser where you can change the character head on top. If I do want to be able to change the character head on top, then I wouldn’t make the value final
.
Note that when a value is final
, we cannot have a setter
method to modify it, since modifying a final variable outside of the constructor is explicitly prohibited.