Free Handbook · Every example compiled & verified

Classes & Objects

Java classes and objects: fields, constructors, this, encapsulation, static members, toString/equals/hashCode, records, immutability and null.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 05 · what you'll be able to do

  • Write a class with fields, constructors and methods, and create objects from it with new
  • Use this, chain constructors, and choose the right access modifier for each member
  • Encapsulate state behind methods that protect the object's rules
  • Override toString, equals and hashCode, or get all three for free with a record
  • Design immutable classes and read a NullPointerException message to find the null
01

A class is a blueprint, an object is a thing

A class describes a kind of thing: what data it holds (fields) and what it can do (methods). An object is one concrete instance created from that class with new. Book is the class; the copy of a particular novel on your desk is an object. Each object has its own copy of the instance fields.

javaMain.java
class Book {
    String title;          // fields: every Book object has its own
    int pages;
    boolean read;          // fields get defaults: false here

    void finish() {        // a method that changes this object's state
        read = true;
    }

    String describe() {
        return title + " (" + pages + " pages)" + (read ? " - read" : "");
    }
}

public class Main {
    public static void main(String[] args) {
        Book a = new Book();
        a.title = "Clean Code";
        a.pages = 464;

        Book b = new Book();
        b.title = "Refactoring";
        b.pages = 448;

        a.finish();
        System.out.println(a.describe());
        System.out.println(b.describe());
    }
}
Outputcompiled & run with real Java
Clean Code (464 pages) - read
Refactoring (448 pages)

Two classes in one file: only one top-level class may be public, and the file is named after it. In real projects each class gets its own file.

Your turn

Add a field String author and include it in describe(). What does it print before you set it?

Variables hold references
Book a = new Book(); creates an object on the heap and stores a reference to it in a. Book c = a; copies the reference, not the book: c.pages = 1 changes the one object both names point at. This is the same rule you met with arrays in module 03.
02

Constructors, this and constructor chaining

Setting fields one by one after new is error-prone: it is easy to forget one and leave the object half-built. A constructor is a special method with the class's name and no return type that runs when the object is created, so the object is valid from its first moment. Inside it, this means "the object being built" — this.title = title copies the parameter into the field with the same name.

javaMain.java
class Book {
    String title;
    int pages;
    String format;

    Book(String title, int pages, String format) {
        this.title = title;          // this.title is the field, title the parameter
        this.pages = pages;
        this.format = format;
    }

    Book(String title, int pages) {
        this(title, pages, "paperback");   // chaining: reuse the full constructor
    }

    Book(String title) {
        this(title, 0);
    }

    public String toString() {
        return title + " / " + pages + " / " + format;
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(new Book("Dune", 612, "hardcover"));
        System.out.println(new Book("Emma", 474));
        System.out.println(new Book("Draft"));
    }
}
Outputcompiled & run with real Java
Dune / 612 / hardcover
Emma / 474 / paperback
Draft / 0 / paperback
Your turn

Add a System.out.println("building " + title); to the three-argument constructor only. It prints for all three objects, because every constructor ends up there.

Visualizenew Book("Draft") through the chainStep 1 / 7
Book(String title, int pages, String format) {
this.title = title;
this.pages = pages;
this.format = format;
}
Book(String title, int pages) {
this(title, pages, "paperback");
}
Book(String title) {
this(title, 0);
}
Book d = new Book("Draft");
Line 12

new allocates an object with default fields, then calls the one-argument constructor.

Variables now
titlenull
pages0
formatnull
All 7 steps as a table
StepLineWhat happenedVariables now
112new allocates an object with default fields, then calls the one-argument constructor.title = null pages = 0 format = null
210this(title, 0) hands off to the two-argument constructor. It must be the first statement.
37Which hands off again, adding the default format.
42The full constructor does the real work: title is set.title = "Draft"
53pages is set.pages = 0
64format is set. Control returns back up the chain.format = "paperback"
712The finished object's reference is stored in d.d = → Book
The default constructor disappears
If you write no constructor, Java gives you an invisible no-argument one. The moment you write any constructor, that free one is gone — which is usually what you want, because it stops people building invalid objects.
Error you will hit

constructor cannot be applied to given types

java
class Point {
    int x, y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

public class Main {
    public static void main(String[] args) {
        Point origin = new Point();
        System.out.println(origin.x);
    }
}
Main.java:12: error: constructor Point in class Point cannot be applied to given types;
        Point origin = new Point();
                       ^
  required: int,int
  found:    no arguments
  reason: actual and formal argument lists differ in length
1 error
error: compilation failed
Why the compiler said that

Point declares a two-argument constructor, so the automatic no-argument constructor no longer exists. new Point() matches nothing.

The fix

Pass the arguments, or add a no-argument constructor that chains to the real one.

java
Point() {
    this(0, 0);
}
03

Access modifiers and encapsulation

Encapsulation means an object guards its own data. Fields are private, and the only way to change them is through methods that enforce the rules — a bank balance can only change through deposit and withdraw, which refuse negative amounts and overdrafts. Then no code anywhere can put the object into an impossible state.

ModifierVisible fromTypical use
privatethe same class onlyfields, internal helpers
(none) package-privateclasses in the same packagehelpers shared inside one package
protectedsame package + subclasseshooks for subclasses (module 06)
publiceverywherethe class's API: constructors and methods others call
javaMain.java
class Account {
    private final String owner;
    private long balancePaise;               // money in minor units, never double

    Account(String owner) {
        this.owner = owner;
    }

    public void deposit(long paise) {
        if (paise <= 0) {
            System.out.println("rejected deposit " + paise);
            return;
        }
        balancePaise += paise;
    }

    public boolean withdraw(long paise) {
        if (paise <= 0 || paise > balancePaise) return false;
        balancePaise -= paise;
        return true;
    }

    public long getBalancePaise() { return balancePaise; }   // getter: read-only access
    public String getOwner() { return owner; }
}

public class Main {
    public static void main(String[] args) {
        Account acc = new Account("Asha");
        acc.deposit(50_000);
        acc.deposit(-100);
        System.out.println(acc.withdraw(20_000));
        System.out.println(acc.withdraw(90_000));
        System.out.println(acc.getOwner() + ": " + acc.getBalancePaise());
    }
}
Outputcompiled & run with real Java
rejected deposit -100
true
false
Asha: 30000
Your turn

Add a transferTo(Account other, long paise) method that only deposits into other if the withdrawal succeeded.

Error you will hit

has private access

java
class Account {
    private String owner;
    private double balance;

    Account(String owner) {
        this.owner = owner;
    }
}

public class Main {
    public static void main(String[] args) {
        Account a = new Account("Asha");
        a.balance = 1_000_000;
        System.out.println(a.balance);
    }
}
Main.java:13: error: balance has private access in Account
        a.balance = 1_000_000;
         ^
Main.java:14: error: balance has private access in Account
        System.out.println(a.balance);
                            ^
2 errors
error: compilation failed
Why the compiler said that

This error is encapsulation working. balance is private to Account, so code in Main cannot read or write it directly.

The fix

Do not make the field public. Add a method that expresses what the caller actually wants to do, and let the class check the rules.

java
public double getBalance() { return balance; }
public void deposit(double amount) {
    if (amount > 0) balance += amount;
}
Getters and setters are not automatically encapsulation
A private field with a public getter and a public setter that accepts anything is a public field with extra typing. Only add a setter when changing that value on its own makes sense, and validate in it. Many classes need no setters at all — see immutability below.
04

static fields and methods

An instance field exists once per object. A static field exists once per class, shared by every object. Use it for constants (static final, named in UPPER_SNAKE_CASE) and for genuinely class-wide data like a counter of objects created. Static methods are for work that needs no object, such as factory methods and utilities.

javaMain.java
class Ticket {
    static final String PREFIX = "TKT";     // a constant
    private static int nextNumber = 1;       // shared by all tickets

    private final String id;

    private Ticket(String id) { this.id = id; }

    static Ticket issue() {                  // static factory method
        String id = PREFIX + "-" + String.format("%03d", nextNumber);
        nextNumber++;
        return new Ticket(id);
    }

    static int issuedSoFar() { return nextNumber - 1; }

    String id() { return id; }
}

public class Main {
    public static void main(String[] args) {
        Ticket a = Ticket.issue();
        Ticket b = Ticket.issue();
        Ticket c = Ticket.issue();
        System.out.println(a.id() + " " + b.id() + " " + c.id());
        System.out.println(Ticket.issuedSoFar() + " issued");
    }
}
Outputcompiled & run with real Java
TKT-001 TKT-002 TKT-003
3 issued

The constructor is private, so the only way to get a Ticket is Ticket.issue() — which guarantees every ticket gets a unique number.

Mutable static state is a global variable
A static counter like nextNumber is shared by the whole program — and by every thread. It is fine in a small example, but in a web server two requests can issue the same number (module 10 shows how to fix that with AtomicInteger). Prefer static for constants and pure helper methods.
05

toString, equals and hashCode

Every class inherits three methods from Object whose defaults are rarely what you want. toString() returns something like Point@1b6d3586. equals() behaves like ==: two objects with identical fields are "not equal". hashCode() is used by HashMap and HashSet. Override toString for readable logs; override equals and hashCode together when two objects with the same data should count as the same value.

javaMain.java
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

class Point {
    private final int x, y;

    Point(int x, int y) { this.x = x; this.y = y; }

    @Override
    public String toString() {
        return "Point(" + x + ", " + y + ")";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                   // same object
        if (!(o instanceof Point other)) return false; // null or another type
        return x == other.x && y == other.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);                    // equal objects -> equal hashes
    }
}

public class Main {
    public static void main(String[] args) {
        Point p = new Point(2, 3);
        Point q = new Point(2, 3);
        System.out.println(p);
        System.out.println(p == q);
        System.out.println(p.equals(q));

        Set<Point> visited = new HashSet<>();
        visited.add(p);
        System.out.println(visited.contains(q));
    }
}
Outputcompiled & run with real Java
Point(2, 3)
false
true
true
Your turn

Delete the hashCode method and run again. equals still says true, but visited.contains(q) becomes false — the set looks in the wrong bucket.

Always write @Override
A classic bug is public boolean equals(Point o) — that overloads equals instead of overriding equals(Object), so collections never call it. With @Override on it, the compiler rejects the mistake. The full equals/hashCode contract is in module 08.
06

Records and immutable objects

An immutable object cannot change after it is built: all fields private final, no setters, and "changes" return a new object (exactly like String). Immutable objects are safe to share, safe as map keys and safe across threads. A record (Java 16+) is the short way to write one: record Money(String currency, long cents) {} gives you private final fields, a constructor, accessor methods currency() and cents(), and correct equals, hashCode and toString.

javaMain.java
record Money(String currency, long cents) {
    Money {                                        // compact constructor: validate
        if (cents < 0) throw new IllegalArgumentException("negative: " + cents);
        currency = currency.toUpperCase();
    }

    Money plus(Money other) {                      // "change" = return a new object
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currency mismatch");
        }
        return new Money(currency, cents + other.cents);
    }
}

public class Main {
    public static void main(String[] args) {
        Money price = new Money("inr", 1999);
        Money total = price.plus(new Money("INR", 501));

        System.out.println(price);
        System.out.println(total);
        System.out.println(total.cents());
        System.out.println(total.equals(new Money("INR", 2500)));

        try {
            new Money("INR", -5);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }
}
Outputcompiled & run with real Java
Money[currency=INR, cents=1999]
Money[currency=INR, cents=2500]
2500
true
rejected: negative: -5
Your turn

Add a method String display() that returns "INR 25.00" using String.format("%s %d.%02d", ...).

Error you will hit

Assigning to a record field — has private access

java
record Money(String currency, long cents) {}

public class Main {
    public static void main(String[] args) {
        Money m = new Money("INR", 1999);
        m.cents = 2999;
        System.out.println(m);
    }
}
Main.java:6: error: cents has private access in Money
        m.cents = 2999;
         ^
1 error
error: compilation failed
Why the compiler said that

A record's components become private final fields. Outside the record you can only read them through the accessor m.cents(), and nobody can reassign them at all.

The fix

Make a new record with the new value. A small "wither" method keeps call sites readable.

java
record Money(String currency, long cents) {
    Money withCents(long newCents) { return new Money(currency, newCents); }
}
// m = m.withCents(2999);

Use a record when…

  • The object is data: a DTO, an API response, a map key, a result pair
  • All fields are set once in the constructor
  • Equality should mean "same field values"

Use a regular class when…

  • The object has changing state (an account balance, a connection)
  • You need to extend another class (records cannot)
  • Some fields must stay hidden or be computed lazily
final fields can still point at mutable things
A record with a List<String> tags component is only shallowly immutable: nobody can swap the list, but anyone can add to it. Copy it in the compact constructor with tags = List.copyOf(tags); to make the record truly immutable.
07

null and NullPointerException

A reference variable can hold null: "points at no object". Reading a field or calling a method through null throws a NullPointerException (NPE) at runtime — the most common exception in Java. Since Java 14, the message says exactly which expression was null, so read it before reaching for the debugger.

Error you will hit

NullPointerException — calling a method on null

java
class Customer {
    String name;
    Customer(String name) { this.name = name; }
}

public class Main {
    public static void main(String[] args) {
        Customer c = new Customer(null);
        System.out.println(c.name.toUpperCase());
    }
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "<local1>.name" is null
	at Main.main(Main.java:9)
Why the compiler said that

The helpful NPE message says the call that failed (String.toUpperCase()) and the expression that was null (.name of local variable 1, which is c). The object c exists, but its name field was never given a real value. You see <local1> instead of c because the class was compiled without local variable names (javac -g keeps them, and IDEs do this by default).

The fix

Stop the null at the door: validate in the constructor so a Customer can never exist without a name.

java
Customer(String name) {
    this.name = java.util.Objects.requireNonNull(name, "name");
}
javaMain.java
import java.util.Objects;
import java.util.Optional;

public class Main {
    static Optional<String> findEmail(String user) {
        return user.equals("asha") ? Optional.of("[email protected]") : Optional.empty();
    }

    public static void main(String[] args) {
        String nickname = null;

        System.out.println(nickname == null ? "no nickname" : nickname);
        System.out.println(Objects.requireNonNullElse(nickname, "guest"));
        System.out.println("admin".equals(nickname));       // literal first: no NPE

        System.out.println(findEmail("asha").orElse("none"));
        System.out.println(findEmail("ravi").orElse("none"));
        System.out.println(findEmail("asha").map(String::length).orElse(0));
    }
}
Outputcompiled & run with real Java
no nickname
guest
false
[email protected]
none
16

Optional is for return values that may be absent: the type itself tells the caller to handle "not found". Do not use it for fields or parameters.

In real jobs
Teams fight NPEs with conventions: validate constructor arguments with Objects.requireNonNull, return empty collections instead of null, return Optional from lookups, and use immutable objects that are fully built in one step. Most NPEs in production come from an object that was allowed to exist half-initialised.
Class
A blueprint that defines fields and methods. new creates objects from it.
Object (instance)
One concrete thing built from a class, with its own copy of the instance fields.
Constructor
Code that runs when an object is created; same name as the class, no return type.
this
A reference to the current object. this(...) as the first line calls another constructor.
Encapsulation
Keeping fields private and exposing behaviour through methods that enforce the object's rules.
static member
A field or method that belongs to the class, shared by all instances.
Record
A concise immutable data class (Java 16+) with generated constructor, accessors, equals, hashCode and toString.
NullPointerException
Thrown when code dereferences null — reads a field or calls a method on a reference that points at no object.
Quick check

You override equals in Point but not hashCode. Which of these breaks?

Frequently asked questions

What is the difference between a class and an object in Java?
A class is the definition — the fields and methods a kind of thing has. An object is one instance created from that class with new, with its own field values. One Book class can produce any number of book objects.
When should I use a Java record instead of a class?
When the type is a carrier of data whose fields never change after construction — DTOs, API responses, map keys, multiple return values. Use a regular class for objects with changing state or that must extend another class.
Why do I need to override hashCode when I override equals?
The contract says equal objects must have equal hash codes. HashMap and HashSet use the hash code to find the right bucket before calling equals, so equal objects with different hash codes are treated as different keys.

Finish the Java handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.