When building larger programs, we need to divide them into manageable units, or modules. The primary way of doing this in an object-oriented language is with classes. A class is a building block of code which groups together data being stored and operations you can do on that data.
You've already used many pre-built classes (such as String
,
ArrayList
and Scanner
) in your programs. String, for
example, stores the characters that comprise the text being stored along with
methods you can call on it (like indexOf
, substring
,
charAt
, etc.)
We can of course make our own classes in a Java program. This is the main way we divide up bigger programs: by splitting them into multiple classes and then working on those.
For instance, let's say we are working on a graphics program where the users can draw shapes. We might create classes for the different shapes that can be drawn. Below is how we might create a class called Circle to store information we need about circles in this program:
class Circle {
private int centerX;
private int centerY;
private int radius;
private Color color;
}
This code creates the Circle class which contains four pieces of
information. Variables declared inside a class like this are called
instance variables. The first three are primitive types,
while the fourth, color
has another class as its type (presumably
another one we wrote for this program). Objects can contain other objects
inside of them like this.
In the code above, we made the data elements being stored private. This is a good idea (which we'll discuss below) but as things stand it means we can't really do anything with this class yet. The first thing we need to be able to do is create Circle objects with different values for the values being stored.
To do that, we create constructors which are special methods of a
class that setup the object being created. Constructors always have the same
name as the class itself and are called when an object is being created with
new
.
We actually call constructors whenever we make one of the built-in objects like an ArrayList:
ArrayList<String> list = new ArrayList<>();
We can add a constructor for the Circle class to set all the fields to a sensible default value:
class Circle {
private int centerX;
private int centerY;
private int radius;
private Color color;
public Circle() {
centerX = 0;
centerY = 0;
radius = 0;
color = new Color();
}
}
Note that this method has the same name as the class it's in, and no return type! That's true of every constructor. Also, notice that we create the color object inside the constructor (which will call a constructor in the Color class).
Here, we picked default values for the object being created, but a lot of the time we'll want to specify the starting values for our objects. To do that, we can have constructors which take parameters for the starting values.
These get passed into the constructor when the object is created with new as well. For example, when you make a Scanner, you'll normally pass in the file to scan from as a parameter to the constructor:
Scanner in = new Scanner(System.in);
We can add a constructor to our class which takes parameters for all the values so they can be initialized to whatever the user wants:
class Circle {
private int centerX;
private int centerY;
private int radius;
private Color color;
public Circle() {
centerX = 0;
centerY = 0;
radius = 0;
color = new Color();
}
public Circle(int centerX, int centerY, int radius, Color color) {
centerX = centerX;
centerY = centerY;
radius = radius;
color = color;
}
}
First off, see that there are now two constructors. This is common for classes to have multiple constructors. Only one of them will be called for each object created. When a Circle object is created with new, which constructors gets called depends on whether the programmer passed parameters after new (like for a Scanner) or not (like for the ArrayList above).
However! There is a big mistake in the constructor above. The mistake is that we've named the instance variables the same as the parameters to the method. The result is that the parameters shadow the instance variables which means they make it so they can't be referenced like this.
There are two solutions to this problem. The first is to name the parameters something different, as seen here:
public Circle(int circleX, int circleY, int circleRadius, Color circleColor) {
centerX = circleX;
centerY = circleY;
radius = circleRadius;
color = circleColor;
}
I don't especially like this solution because I find it somewhat confusing to have two names for what's essentially the same piece of information. Coming up with multiple names can be annoying too.
The other solution is to explicitly refer to our instance variables with the
keyword this
. Here's that approach:
public Circle(int centerX, int centerY, int radius, Color color) {
this.centerX = centerX;
this.centerY = centerY;
this.radius = radius;
this.color = color;
}
}
this
is a reference to the object of the class. Here we
use it to refer to the instance variables of the class directly. This
gets rid of the ambiguity of having two variables with the same name.
If we want, we could also make constructors which take just the X and Y coordinates and set the rest to default values:
public Circle(int centerX, int centerY) {
this.centerX = centerX;
this.centerY = centerY;
this.radius = 1;
this.color = new Color();
}
}
You as the author of the class can decide what constructors it makes sense to include.
Of course we can also include methods in our class which provide a way to do actions or operations on our classes. Instance variables are the nouns and methods are the verbs. They let the class actually do stuff.
For this class, we can include a method to compute the area of the circle being stored:
public double calculateArea() {
return Math.PI * radius * radius;
}
This method uses one of the instance variables, radius
to calculate
the area of the circle. This method (unlike a constructor) has a return type which
is double in this case. Notice that it is not static. We'll talk much more
about static methods next time, but most methods (apart from main) will not be static.
Notice that we don't use this
for this method. In fact we could have done
so, writing it like this:
public double calculateArea() {
return Math.PI * this.radius * this.radius;
}
For our constructor, we needed to be explicit because the parameters had the same
names. Here, there is no such confusion, so almost all programmers would choose not
to use this
, writing it the first way.
A common type of method included in classes are those that access the information stored therein. These are called accessors or, more commonly, "getters". For example, we can make methods to get the X and Y coordinates of our circle:
public int getX() {
return centerX;
}
public int getY() {
return centerY;
}
They are called getters because there names often start with the word "get" like this.
Another common type of method is "setters" (the fancier name for which is "mutators"). Those are used to change the instance variables being stored by a class. We can make setters for our X and Y coordinates like this:
public void setX(int centerX) {
this.centerX = centerX;
}
public void setY(int centerY) {
this.centerY = centerY;
}
These are void methods, because they do not return anything. They take the
new value as a parameter and set the instance variable equal to that. These
also use this
for the same reason as the constructors: we used
the same name for the method parameter.
Sometimes, people will make getters and setters for all of the instance
variables in a class. However, this isn't always necessary. Think about
what the user of the class should actually be allowed to change. For example,
the String
class has a method to get the length, but not one
to change the length. That wouldn't really make sense or be useful.
So far we have made a Circle class with some instance variables, some constructors and some methods. In doing so, we've said what a Circle would look like and how it would work, if it were ever made. But by making the class, we have so far not made any actual circles.
Making a class is like making a blueprint for an object. It describes how the thing works, but doesn't actually make one. To do that, we need to make an obejct or an instance which is an actual example of the thing the class describes.
To do that of course we use new, same as we do for making objects of the built-in classes. This code makes a couple of Circles, calling our different constructors:
Circle c1 = new Circle();
Circle c2 = new Circle(7, 12, 5, red); // assume red is a Color object
Circle c3 = new Circle(13, 8);
Now we have three actual circles. Each of them has their own copies of the instance variables centerX, centerY, and so on, which can store different values. We can also call the methods on these objects now:
System.out.println(c1.getX());
double area = c2.calculateArea();
In the code above, we made all of the instance variables private. That's something we'll do in every code example this semester, and the reason is because it's better for encapsulation. Encapsulation means keeping the different parts of our code cut off, or insulated, from each other.
If we are working on this graphics drawing program, the Square class should not be able to muck around and change things about Circle objects. Moreover, the Square class should not even be allowed to know how Circle works.
The only place we should be able to change a Circle's data is from within the Circle class. That way, if something is going wrong with it, we know exactly where to look to find the problem. By making things private like this, we make it harder to accidentally mess up our instance variables.
For example, let's say we are getting Circle objects with negative radii somewhere in
our programs. That should not happen since a negative radius doesn't make sense. If the
radius was a public variable, we would need to hunt down the entire program for places where
radius might accidentally be set to negative. If it was properly encapsulated, then radius
would be private and could only be accessed through Circle class methods. We could then
put a check into out setRadius()
method:
public void setRadius(int radius) {
if (radius < 0) {
throw new IllegalArgumentException("Radius cannot be negative");
}
this.radius = radius;
}
Now, whenever the rest of the program attempts to set the radius to be negative, we will get an exception thrown. That gives us a full stack trace of where we were in the code when this happened. That's WAY better than having to hunt around for the issue.
Another benefit of encapsulation is it makes it easier to change things later on.
For example, let's say we decide it's easier to store the diameter of the circle rather
than the radius. In that case we could make the change without having to change
the code that uses Circle objects at all. We could just change the way setRadius
and getRadius
work:
public void setRadius(int radius) {
this.diameter = 2 * radius;
}
public int getRadius() {
return radius / 2;
}
This keeps the interface of the class, how you use it, the same. But allows us to change the implementation, how it works internally.
Copyright © 2024 Ian Finlayson | Licensed under a Creative Commons BY-NC-SA 4.0 License.