Classes and objects in Java — theory with students and courses (and the array full of null that made me think Java hated me)

The first time I built a list of students in Java, I wrote this with total confidence:

Student[] group = new Student[30];
System.out.println(group[0].getName());

Java answered with a NullPointerException. I thought new Student[30] created thirty students. What it actually creates is thirty empty slots where you could store students, and every slot is null until you put one in. That difference between “room for an object” and “the actual object” is exactly what we’re going to take apart in this article.

If you’re coming from Python, a lot of this will feel familiar: class, __init__, self… Java has all of it, just with different names and a couple of stricter rules. We’ll use an example that explains itself: students and courses.

A class is a cookie cutter: it describes what data each thing will have and what it can do. An object is a cookie made with that cutter: one concrete thing, with its own data.

  • The Student class says: “every student has an ID, a name and a grade, and can tell you whether they’ve passed”.
  • ana and luis are two objects of that class, each with their own ID, name and grade.

Objects are created with new:

Student ana = new Student("11111111H", "Ana", 7.5);
Student luis = new Student("22222222J", "Luis");

new does three things: it reserves memory for a new object, runs the constructor to give it its initial values, and returns a reference to that object. That reference is what gets stored in the variable ana.

In Python, Student("11111111H", "Ana", 7.5) would be the equivalent call. In Java, new is mandatory: without it, there’s no object.

The complete Student class

Before explaining each piece, here’s the whole class so you have the map in front of you:

public class Student {

    // ----- Class attributes (static): one for the WHOLE class -----
    public static final double PASS_MARK = 5.0;      // shared constant
    private static int totalStudents = 0;             // how many objects have been created

    // ----- Instance attributes: each object has its own -----
    private final String id;       // never changes: final
    private String name;
    private double grade;

    // Full constructor
    public Student(String id, String name, double grade) {
        this.id = id;              // this.id = the attribute; id = the parameter
        this.name = name;
        setGrade(grade);           // reuse the setter's validation
        totalStudents++;
    }

    // Overloaded constructor: no grade, starts at 0
    public Student(String id, String name) {
        this(id, name, 0.0);       // calls the constructor above
    }

    // ----- Getters and setters -----
    public String getId()      { return id; }
    public String getName()    { return name; }
    public double getGrade()   { return grade; }

    public void setGrade(double grade) {
        if (grade < 0 || grade > 10) {
            System.out.println("Invalid grade for " + name + ": " + grade);
            return;                // change nothing
        }
        this.grade = grade;
    }

    // ----- Instance methods -----
    public boolean hasPassed() {
        return grade >= PASS_MARK;
    }

    // ----- Class method -----
    public static int getTotalStudents() {
        return totalStudents;
    }

    @Override
    public String toString() {
        return name + " (" + id + "): " + grade;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                       // same reference
        if (!(o instanceof Student)) return false;         // another type or null
        Student other = (Student) o;
        return id.equals(other.id);                        // same ID = same student
    }

    @Override
    public int hashCode() {
        return id.hashCode();
    }
}

It looks like a lot, but it’s only five ideas: attributes, constructors, encapsulation, static and the toString/equals methods. Let’s go one by one.

Grades here go from 0 to 10, with 5 as the pass mark, as in Spanish universities.

Attributes: each object’s data

Attributes are variables that live inside the object. id, name and grade are instance attributes: each student has their own, and changing Ana’s grade doesn’t touch Luis’s.

If an attribute isn’t initialized, Java gives it a default value: 0 for numbers, false for boolean, and null for objects (including Strings). That’s why a Student created with the second constructor has a grade of 0.0.

final on an attribute means “assigned once and never changed”. It’s perfect for data that identifies the object, like the ID. If you try to change it later, the code won’t even compile.

Constructors and this

The constructor is the special method that runs with new. It has the same name as the class and no return type (not even void).

What is this for?

Look at this line from the constructor:

this.id = id;

There are two variables called id: the parameter the constructor receives and the object’s attribute. Inside the constructor, plain id is the parameter (the closest one). this.id is the attribute of the object being built. this means “this object”: it’s the equivalent of Python’s self, except that in Java you don’t write it as the first parameter.

Writing name = name; without this. It compiles without complaint, but it assigns the parameter to itself: the attribute stays null. It’s one of Java’s quietest bugs. (With id, which is final, at least the compiler warns you that it’s never initialized.)

Constructor overloading

A class can have several constructors, as long as they take different parameters. That’s called overloading. Student‘s second constructor doesn’t repeat any code: it calls the first one with this(id, name, 0.0).

public Student(String id, String name) {
    this(id, name, 0.0);    // must be the FIRST line
}

That way all the logic (validating the grade, counting students) lives in one place. If it changes tomorrow, you change it once.

If you don’t write any constructor, Java gives you an empty one with no parameters for free. As soon as you write one, that freebie disappears: new Student() would stop compiling.

Encapsulation: private, getters and setters

Why are the attributes private? So that nobody outside can touch them directly. If grade were public, anyone could write luis.grade = 12; and you’d have a student with 12 out of 10.

With private, the only way to change the grade is through setGrade, and that method checks the value before accepting it:

luis.setGrade(12);     // Invalid grade for Luis: 12.0
luis.setGrade(4.5);    // accepted
System.out.println(luis.getName() + " passed? " + luis.hasPassed());

Output:

Invalid grade for Luis: 12.0
Luis passed? false

That’s encapsulation: the object protects its data and decides what can be done with it. Notice too that the constructor calls setGrade instead of this.grade = grade: that way the validation also applies when the student is created.

ModifierWho can use it?
publicAny class
privateOnly the class itself
(nothing)Classes in the same package
protectedThe package and subclasses (you’ll see it in Inheritance)

A practical rule for FP2: attributes private, methods public. Only create a setter if that data really needs to change; id has no setter because it must never change.

static: what belongs to the class, not the object

So far, every attribute belonged to an object. But some data belongs to the whole class: for example, how many students have been created. That isn’t Ana’s or Luis’s; it belongs to “students” in general. That’s what static is for.

private static int totalStudents = 0;     // exists ONLY ONCE
public static final double PASS_MARK = 5.0;
  • totalStudents is shared: every new Student(...) increments it, and everyone sees the same value.
  • PASS_MARK is a class constant: static (one for everyone) and final (doesn’t change). By convention, in UPPERCASE.

static methods are called with the class name, without needing any object:

System.out.println("Total: " + Student.getTotalStudents());   // Total: 2
System.out.println(Student.PASS_MARK);                         // 5.0

From a static method you can’t use instance attributes (name, grade) or this. It makes sense: a class method doesn’t know which student you’re talking about. If you try, the error is non-static variable cannot be referenced from a static context. Sound familiar? main is static too.

Instancestatic (class)
How many?One per objectOne for the whole class
Used with…ana.getGrade()Student.getTotalStudents()
Can it use this?YesNo
Examplegrade, hasPassed()totalStudents, PASS_MARK

References: the variable is not the object

This is the hardest idea and the one that prevents the most bugs. A variable of type Student doesn’t contain the student: it contains a reference, a sort of address pointing to where the object is.

Student sameOne = ana;     // does NOT copy Ana: points to the same object
sameOne.setGrade(9);
System.out.println(ana.getGrade());     // 9.0

When you change the grade through sameOne, Ana’s changes, because they’re the same object with two names. If you’ve read the arrays article, it’s exactly what happened with int[] b = a;.

== versus equals

Student copy = new Student("11111111H", "Ana", 9);
System.out.println(ana == copy);         // false: two different objects
System.out.println(ana.equals(copy));    // true: same ID
System.out.println(ana == sameOne);      // true: same reference
  • == asks whether two variables point to the same object.
  • equals asks whether two objects are equal in the sense you define. That’s why we overrode it: for us, two students are the same if they have the same ID.

It’s the same story as with Strings, except now you write the equals yourself.

Overriding equals and forgetting hashCode. With arrays you won’t notice anything, but when you get to collections like HashSet or HashMap, two “equal” students would be treated as different. The rule: if two objects are equals, they must have the same hashCode.

null: the reference that points nowhere

null is an empty reference. Calling a method on it throws a NullPointerException:

Student nobody = null;
nobody.getName();           // NullPointerException

Student[] group = new Student[3];
System.out.println(group[0]);    // null: the slot exists, the student doesn't

That’s my story from the beginning: an array of objects is created full of null, and each student has to be created with its own new.

toString: how an object is printed

If you print an object without toString, Java shows something like Student@1b6d3586: the class name and an internal code. When you override toString, you decide the text:

System.out.println(ana);     // Ana (11111111H): 7.5

println calls toString() automatically. The @Override annotation tells the compiler “I’m replacing a method that already exists”; if you misspell the name (tostring, say), it warns you instead of quietly creating a new method that nobody will ever call.

Objects inside objects: the Course class

A course has students. In Java you express that with an attribute that’s an array of objects:

public class Course {
    private final String name;
    private final Student[] students;
    private int numStudents = 0;

    public Course(String name, int capacity) {
        this.name = name;
        this.students = new Student[capacity];   // empty slots: all null
    }

    public boolean enroll(Student s) {
        if (numStudents == students.length) return false;   // full
        students[numStudents++] = s;
        return true;
    }

    public double averageGrade() {
        if (numStudents == 0) return 0;
        double sum = 0;
        for (int i = 0; i < numStudents; i++) sum += students[i].getGrade();
        return sum / numStudents;
    }

    public int passed() {
        int n = 0;
        for (int i = 0; i < numStudents; i++) if (students[i].hasPassed()) n++;
        return n;
    }

    public String getName() { return name; }
}

Notice that the loops go up to numStudents, not students.length: the unused slots are null, and calling getGrade() on them would be another NullPointerException. It’s the array + counter pattern you already used in the arrays exercises.

Course fp2 = new Course("FP2", 3);
fp2.enroll(ana);
fp2.enroll(luis);
fp2.enroll(new Student("33333333P", "Marta", 5));
System.out.println(fp2.enroll(new Student("44444444A", "Pablo", 6)));   // false: full
System.out.printf("%s: average %.2f, passed %d%n", fp2.getName(), fp2.averageGrade(), fp2.passed());

Output:

false
FP2: average 6.17, passed 2

Ana has a 9 (we set it through sameOne), Luis a 4.5 and Marta a 5: an average of 6.17 and two passes, because 5 >= PASS_MARK.

Watch it step by step in Python Tutor

Python Tutor runs your code line by line and draws the variables and objects. Choose Java in the drop-down, paste this program and step through it with Next:

public class Tutor {
    static class Student {
        String name;
        double grade;
        Student(String name, double grade) {
            this.name = name;
            this.grade = grade;
        }
    }

    public static void main(String[] args) {
        Student a = new Student("Ana", 7.5);
        Student b = a;                          // b points to the SAME object
        Student c = new Student("Ana", 7.5);    // new object with the same data
        b.grade = 9;
        System.out.println(a.grade + " " + c.grade);
        System.out.println((a == b) + " " + (a == c));
    }
}

Output:

9.0 7.5
true false

What to watch:

  1. When the first new runs, an object appears on the right (the heap) with an arrow from a. The variable doesn’t contain the object: it points to it.
  2. Inside the constructor you’ll see a frame with this: it’s the arrow to the object being built.
  3. After Student b = a;, no new object appears: just another arrow to the same one. After the second new, there are two objects.
  4. When b.grade = 9 runs, the single object that a and b point to changes. The one c points to keeps 7.5. That’s why a == b is true and a == c is false.

Summary and quick table

ConceptIn JavaIf you come from Python
Templateclass Student { … }class Student:
Create an objectnew Student(…)Student(…)
Constructorpublic Student(…)def __init__(self, …)
The object itselfthis (implicit)self (explicit)
Hide dataprivate_name convention
Class datastaticclass attribute
Object as texttoString()__str__
Equalityequals() + hashCode()__eq__ + __hash__

Three ideas to keep:

  1. The variable isn’t the object: it’s a reference. Assigning copies the arrow, not the data, and == compares arrows.
  2. **this.x = x** tells the attribute apart from the parameter; without this, the attribute stays empty.
  3. **private + methods protect the object; static** is for what belongs to the whole class.

In the practice article you’ll build three complete programs with classes: a bank account, a library and a lab inventory. And if you want to review the previous topic, there’s regular expressions in Java.

Next step: practiceClasses and objects in Java — practice with a bank, a library and a lab inventory (and the account that belonged to nobody)

Did this article help you? Here is how you can support SergioLearns →

Sigue aprendiendo

4 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *