Lesson 11 of 25

Classes & Objects

A Class Describes a Kind of Thing

Up to now your programs have been loose variables and methods. That works until the program grows. Imagine storing details for two hundred students with separate arrays for names, roll numbers, marks and attendance: the fourth entry of every array has to stay in step with every other, and one sorting operation applied to one array breaks the whole thing.

A class solves this by letting you define a new type of your own. It describes what a student is (the data — name, roll number, marks) and what a student can do (the operations — calculate percentage, mark attendance), in one place. Then a single array of Student objects keeps every student's details together automatically.

The vocabulary is worth getting exactly right because interviewers test it. A class is the description; it is written once and occupies no memory of its own. An object is one actual thing built from that description; each object has its own copies of the fields, and you can create as many as you like. A common analogy is that a class is the architectural plan and each object is a house built from it — the plan is not a house, and changing one house does not change the others.

The variables declared inside a class are called fields (or instance variables). The methods declared there are the object's behaviour. Fields, unlike local variables, get default values automatically: 0 for numbers, false for booleans, null for object types.

Example
// File: Student.java  — the description
public class Student {

    // Fields: what every Student has
    String name;
    int rollNo;
    double marks;

    // Methods: what every Student can do
    void display() {
        System.out.println(rollNo + " - " + name + " - " + marks);
    }

    String grade() {
        if (marks >= 90) return "A";
        if (marks >= 75) return "B";
        if (marks >= 40) return "C";
        return "F";
    }
}

// File: Main.java — using it
public class Main {
    public static void main(String[] args) {
        Student s1 = new Student();      // one object
        s1.name = "Ananya";
        s1.rollNo = 101;
        s1.marks = 87.5;

        Student s2 = new Student();      // a completely separate object
        s2.name = "Rahul";
        s2.rollNo = 102;
        s2.marks = 64.0;

        s1.display();                    // 101 - Ananya - 87.5
        s2.display();                    // 102 - Rahul - 64.0

        System.out.println(s1.grade());  // B

        // And now one array holds everything, in step, forever
        Student[] batch = { s1, s2 };
        for (Student s : batch) {
            s.display();
        }
    }
}
Notes
  • A file may contain only one public class, and the file must be named after it. Small helper classes without the public keyword may share a file, which is convenient while learning — but in a real project each class gets its own file.

What new Actually Does

Student s1 = new Student(); does three separate things, and separating them explains most of the confusing behaviour that follows.

First, new Student() sets aside memory for one Student's fields in an area called the heap and fills those fields with defaults. Second, the constructor runs. Third, the address of that new block of memory is handed back and stored in the variable s1.

So s1 does not contain the student. It contains a reference — an arrow pointing to where the student lives. Everything that follows from that is exactly what you saw with arrays and Strings: assigning s2 = s1 copies the arrow, not the object, so both variables now refer to the same student and a change through one is visible through the other. Creating a genuine second copy requires a second new.

A reference variable can also hold null, meaning it points at nothing. Calling a method on a null reference throws NullPointerException — by far the most common runtime error in Java. Since Java 14 the message tells you which variable was null, which turned a frustrating error into a readable one, so read it carefully rather than guessing.

Example
Student a = new Student();
a.name = "Ananya";

// Copying the VARIABLE copies the arrow, not the object
Student b = a;
b.name = "Changed";
System.out.println(a.name);       // Changed — same object!
System.out.println(a == b);       // true   — same address

// A real second object
Student c = new Student();
c.name = "Ananya";
System.out.println(a == c);       // false  — different objects

// null: a reference pointing at nothing
Student d = null;
// d.display();
// NullPointerException: Cannot invoke "Student.display()"
//                      because "d" is null

if (d != null) {
    d.display();                  // guard before use
}

// Fields you never assigned hold defaults, not garbage
Student fresh = new Student();
System.out.println(fresh.rollNo); // 0
System.out.println(fresh.marks);  // 0.0
System.out.println(fresh.name);   // null   <- not ""
Notes
  • This is why the previous lesson's rule holds: a method can change what an object contains but cannot change which object the caller's variable points at. Passing an object to a method passes a copy of the arrow.

The this Keyword

Inside an instance method, this refers to the object the method was called on. When you write s1.display(), then inside display() the word this means s1. Every field access inside the method has an invisible this. in front of it, which is how one copy of the method code works correctly for a thousand different objects.

You must write this explicitly in one situation: when a parameter has the same name as a field, the parameter shadows the field. Inside setName(String name), plain name means the parameter, so name = name; assigns the parameter to itself and the field is never touched. The compiler often warns about this but does not stop you, so the object silently keeps its old value. this.name = name; says unambiguously: the field of this object gets the value of the parameter.

Giving the parameter the same name as the field is deliberate, not lazy. It communicates exactly what the parameter is for, and every Java codebase you will read does it. Learn to write the this. rather than inventing names like nameParam.

this has two other uses. this(...) as the first statement of a constructor calls another constructor of the same class, covered in the next lesson. And passing this as an argument hands the current object to some other method, which is common when registering an object as a listener or adding it to a collection.

One hard limit: this does not exist in a static method, because there is no current object. Using it there is a compile error, and understanding why makes the static-versus-instance distinction concrete.

Example
public class Student {
    private String name;
    private int rollNo;
    private double marks;

    // Parameter shadows the field — 'this' resolves it
    public void setName(String name) {
        this.name = name;        // field  <-  parameter
    }

    // WRONG: assigns the parameter to itself, field stays null
    public void setNameBroken(String name) {
        name = name;
    }

    public void setMarks(double marks) {
        if (marks < 0 || marks > 100) {
            throw new IllegalArgumentException("Marks must be 0-100");
        }
        this.marks = marks;
    }

    // No shadowing here, so 'this' is optional — both lines mean the same
    public boolean hasPassed() {
        return marks >= 40;
        // return this.marks >= 40;
    }

    // 'this' can be passed on to another method
    public void addTo(java.util.List<Student> list) {
        list.add(this);
    }

    // static: no object, so no 'this'
    public static String schoolName() {
        // return this.name;   // compile error
        return "Modern Public School";
    }
}
Notes
  • Many editors will auto-generate getters and setters for you (in IntelliJ, Alt+Insert). Use that once you understand what they do — typing them by hand for twelve fields teaches you nothing after the first two.

toString: Making Your Objects Printable

Print a Student object and you get something like Student@1b6d3586. That is not a bug. Every class in Java silently inherits from a class called Object, which provides a default toString() returning the class name and a hexadecimal hash code. It is technically accurate and completely useless for debugging.

Override toString() in your own class and Java uses yours instead — everywhere, automatically. System.out.println(student), string concatenation with +, and printing a whole List of students all call it. Writing one method makes every one of those readable at once, which is why toString() is the first thing experienced developers add to a new class.

Put @Override above it. That annotation asks the compiler to verify that you really are overriding an inherited method. Misspell it as toSting() or give it the wrong signature and you get a clear compile error instead of a method that silently never runs.

There is one thing to be careful about: toString() is for humans reading logs, so keep it short and never put a password, token or other secret in it. Log files travel further than you expect.

Example
public class Student {
    private String name;
    private int rollNo;
    private double marks;

    public Student(String name, int rollNo, double marks) {
        this.name = name;
        this.rollNo = rollNo;
        this.marks = marks;
    }

    @Override
    public String toString() {
        return String.format("Student{roll=%d, name='%s', marks=%.1f}",
                             rollNo, name, marks);
    }
}

// Now everything prints properly
Student s = new Student("Ananya", 101, 87.5);

System.out.println(s);
// Student{roll=101, name='Ananya', marks=87.5}

System.out.println("Topper: " + s);        // concatenation uses it too

List<Student> batch = List.of(s, new Student("Rahul", 102, 64.0));
System.out.println(batch);                 // the whole list is readable

// Without the override you would see:
// Student@1b6d3586
// [Student@1b6d3586, Student@4554617c]
Notes
  • System.out.println(obj) is null-safe — it prints the word null rather than throwing. But obj.toString() called directly on a null reference throws a NullPointerException, so prefer String.valueOf(obj) when the reference might be null.

Comparing Your Own Objects, and a Modern Shortcut

Two Student objects with identical name, roll number and marks are still two different objects in memory, so == between them is false. The inherited equals() is no better — Object's version does exactly what == does. If you want "same roll number means the same student", you have to say so by overriding equals() yourself.

There is a rule attached that beginners skip and then spend an evening debugging: whenever you override equals(), you must also override hashCode(). HashMap and HashSet find an object by first computing its hash code to decide which bucket to look in, and only then comparing with equals(). If two objects are equal but produce different hash codes, they land in different buckets and the map never even compares them — so set.contains(student) returns false for an object that is equal to one already inside. The Collections lesson returns to this in detail.

The good news is that you almost never write either by hand. Every IDE generates both correctly, and Objects.equals() and Objects.hash() in java.util do the fiddly parts.

Better still, if a class exists purely to carry data, Java 16 and later offer records. One line declares the fields and the compiler generates the constructor, the accessor methods, equals(), hashCode() and toString() for you, all correct. Records are immutable, which is usually what you want for a data carrier. Use a record when the class is just data; use a normal class when it has real behaviour or needs to change after creation.

Example
import java.util.Objects;

public class Student {
    private final int rollNo;
    private final String name;

    public Student(int rollNo, String name) {
        this.rollNo = rollNo;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student other = (Student) o;
        return rollNo == other.rollNo && Objects.equals(name, other.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(rollNo, name);   // MUST match equals()
    }
}

Student a = new Student(101, "Ananya");
Student b = new Student(101, "Ananya");
System.out.println(a == b);        // false — two objects
System.out.println(a.equals(b));   // true  — same contents

// ---- The modern shortcut for pure data (Java 16+) ----
public record Point(int x, int y) { }

Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);
System.out.println(p1);            // Point[x=3, y=4]   toString generated
System.out.println(p1.equals(p2)); // true              equals generated
System.out.println(p1.x());        // 3   — accessor is x(), not getX()
Notes
  • The two rules a correct pair must satisfy: equal objects must always return the same hash code, and the fields used in hashCode() must be the same fields used in equals(). If those fields can change while the object sits in a HashMap, you have a different problem — covered under mutable keys in the HashMap lesson.
Ask AI