Classes and Objects
Defining your own types in Java: fields, constructors, methods, this, encapsulation with access modifiers, getters, static members, and equals, hashCode and toString.
Defining a class
A
class
is a blueprint for objects with the same shape and behavior. The
general ideas (objects, encapsulation, inheritance, polymorphism)
are explained in
Object-Oriented Concepts; this page shows how Java does it. Java is object-oriented to the
core: every method lives in a class, and even a compact source file
with a bare void main() is compiled into a hidden
class.
A class lists its members. A
field is a variable declared in the
class body: each object gets its own copy (an
instance field, also called an instance variable), and it
lives as long as the object does. A
method
is a function declared in the class that works on the object's
fields. You create an object with new and reach its
members with a dot.
public class Main {
public static void main(String[] args) {
Meter delft = new Meter();
delft.id = "E-001";
delft.city = "Delft";
delft.addReading(18412);
delft.addReading(18745);
System.out.println(delft.id + " in " + delft.city + ": " + delft.usage() + " kWh");
}
}
class Meter {
String id;
String city;
int firstReading;
int lastReading;
int count;
void addReading(int kwh) {
if (count == 0) {
firstReading = kwh;
}
lastReading = kwh;
count++;
}
int usage() {
return lastReading - firstReading;
}
}
E-001 in Delft: 333 kWh
The file holds two classes: Main with the
main method that starts the program, and
Meter below it. Inside a method, the fields of the
current object are available by name (count); you may
write this.count to make that explicit. By convention,
class names use PascalCase (Meter), and fields, methods
and local variables use camelCase (firstReading,
addReading).
Default field values
Fields that you do not initialize get a default value:
0 for numbers, false for
boolean, '\u0000' for char,
and null for every reference type, such as
String. Local variables inside a method get no default:
reading one before assigning it is a compile error (see
Variables, Types and Operators).
public class Main {
public static void main(String[] args) {
Meter m = new Meter();
System.out.println(m.id);
System.out.println(m.count);
System.out.println(m.active);
}
}
class Meter {
String id;
int count;
boolean active;
}
null 0 false
A field that is still null is the classic source of a
NullPointerException later on; the constructor is the
place to give every field a real value (see
Null, Optional and NullPointerException).
Variables hold references
A class type is a reference type: the object lives on the
heap,
and a variable holds a
reference
to it. Assigning one variable to another copies the reference, not
the object, so both names point to the same meter. And
== on two objects asks "is this the same object?", not
"do they contain the same data?"
public class Main {
public static void main(String[] args) {
Meter a = new Meter();
a.id = "E-001";
Meter b = a;
b.id = "E-099";
System.out.println(a.id);
Meter c = new Meter();
c.id = "E-099";
System.out.println(a == b);
System.out.println(a == c);
}
}
class Meter {
String id;
}
E-099 true false
Meter b = a; there is still one object.
Changing b.id changes what a sees,
because both references lead to the same place.
To compare objects by their content, a class overrides
equals; that is covered
further down this page. Passing
an object to a method copies the reference in the same way, which is
why a method can change the object you passed but cannot make your
variable point somewhere else (see
Methods and Parameters).
Constructors
A
constructor
is a special method with the same name as the class and no return
type, not even void. It runs once, when
new creates the object, and its job is to leave the
object in a valid state: check the arguments and set every field
that needs a value. When a parameter has the same name as a field,
this.name means the field and plain name
the parameter.
A class can have several constructors with different parameter lists
(overloading, see
Methods and Parameters).
this(...) as the first statement calls another
constructor of the same class, so the checks live in one place.
public class Main {
public static void main(String[] args) {
Customer home = new Customer("C-10042", "Mei Chen", "Utrecht");
Customer unknown = new Customer("C-10043", "Ravi Kumar");
System.out.println(home.describe());
System.out.println(unknown.describe());
new Customer("C-10044", " ");
}
}
class Customer {
String id;
String name;
String city;
Customer(String id, String name, String city) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("customer " + id + " needs a name");
}
this.id = id;
this.name = name;
this.city = city;
}
Customer(String id, String name) {
this(id, name, "unknown");
}
String describe() {
return id + " " + name + " (" + city + ")";
}
}
C-10042 Mei Chen (Utrecht) C-10043 Ravi Kumar (unknown) Exception in thread "main" java.lang.IllegalArgumentException: customer C-10044 needs a name
Throwing IllegalArgumentException from a constructor
means no half-built customer ever exists: new either
returns a valid object or fails. Exceptions are explained on
Exceptions and Error Handling.
The default constructor
A class without any constructor gets a
default constructor: an
invisible constructor without parameters, added by the compiler,
which only calls the parent class's no-argument constructor. That is
why new Meter() worked in the first examples. As soon
as you write any constructor yourself, the default one is gone, and
new Customer() becomes a compile error: the class now
insists on a name.
public class Main {
public static void main(String[] args) {
Customer c = new Customer();
System.out.println(c.name);
}
}
class Customer {
String name;
Customer(String name) {
this.name = name;
}
}
Main.java:3: error: constructor Customer in class Customer cannot be applied to given types;
If you still want a no-argument constructor next to the others, write it explicitly. Frameworks that create objects for you (for example JSON libraries and persistence frameworks) often need one, which is why you meet empty constructors in real code.
Access modifiers and encapsulation
An access modifier is the keyword in front of a class or member that decides which code may use it. Java has three keywords and a fourth level without a keyword. Access modifiers are how Java enforces encapsulation: the compiler refuses access from outside, so a class can protect its data and change its internals later without breaking callers.
| Modifier | Who can use the member |
|---|---|
public |
Any code. |
protected |
Code in the same package, and subclasses in any package (see Inheritance, Interfaces and Polymorphism). |
| (none) | Code in the same package only: package-private access. The default. |
private |
Only code inside the same class. |
Package-private access
is what a member or class gets when you write no modifier at all:
every class in the same
package may use
it, and nothing outside. That is a deliberate middle ground for
helper classes that cooperate inside one package, but it is
not private. A top-level class can only be
public or package-private, and a source file may
contain at most one public top-level class, named like
the file (Main in Main.java).
public class Main {
public static void main(String[] args) {
Meter meter = new Meter("E-001", "KE-7781-0042");
System.out.println(meter.id);
System.out.println(meter.serial);
}
}
class Meter {
final String id;
private final String serial;
Meter(String id, String serial) {
this.id = id;
this.serial = serial;
}
}
Main.java:5: error: serial has private access in Meter
meter.id compiles because Main and
Meter are in the same (unnamed) package;
meter.serial does not. The final on both
fields means they must be assigned exactly once, in the constructor,
and can never change afterward.
Getters and setters
Java has no properties. Data that other classes may read is exposed
through ordinary methods: a
getter
named getName() (or isActive() for a
boolean) and, only when the value may change, a
setter
named setName(...). This naming convention comes from
the JavaBeans specification, and many frameworks and libraries rely
on it to find your data.
import java.math.BigDecimal;
public class Main {
public static void main(String[] args) {
Tariff t = new Tariff("Green Fixed", new BigDecimal("0.31"));
System.out.println(t.getName() + ": " + t.getPricePerKwh() + " per kWh");
t.setPricePerKwh(new BigDecimal("0.29"));
System.out.println(t.getName() + ": " + t.getPricePerKwh() + " per kWh");
t.setPricePerKwh(new BigDecimal("-0.05"));
}
}
class Tariff {
private final String name;
private BigDecimal pricePerKwh;
Tariff(String name, BigDecimal pricePerKwh) {
this.name = name;
this.pricePerKwh = checkPrice(pricePerKwh);
}
public String getName() {
return name;
}
public BigDecimal getPricePerKwh() {
return pricePerKwh;
}
public void setPricePerKwh(BigDecimal pricePerKwh) {
this.pricePerKwh = checkPrice(pricePerKwh);
}
private static BigDecimal checkPrice(BigDecimal price) {
if (price.signum() < 0) {
throw new IllegalArgumentException("price cannot be negative: " + price);
}
return price;
}
}
Green Fixed: 0.31 per kWh Green Fixed: 0.29 per kWh Exception in thread "main" java.lang.IllegalArgumentException: price cannot be negative: -0.05
The name has a getter but no setter, so it can never change. The
price can change, but only through setPricePerKwh,
which runs the same check as the constructor. That is the point of a
setter: one place where every change passes a rule. Money is a
BigDecimal, not a double, for the reasons
on
Variables, Types and Operators.
Static members
Most members belong to an object: each meter has its own
id. A static field or method belongs to
the class itself and is shared by all objects; you reach it through
the class name (Ticket.created()), not through an
instance. The keyword itself is explained with static methods on
Methods and Parameters.
public class Main {
public static void main(String[] args) {
Ticket first = new Ticket("No power in Leiden");
Ticket second = new Ticket("Smart meter offline");
System.out.println(first.number + ": " + first.summary);
System.out.println(second.number + ": " + second.summary);
System.out.println(Ticket.created() + " tickets");
System.out.println(Ticket.PREFIX);
}
}
class Ticket {
static final String PREFIX = "INC-";
private static int nextNumber = 40112;
private static int created = 0;
final String number;
final String summary;
Ticket(String summary) {
this.number = PREFIX + nextNumber++;
this.summary = summary;
created++;
}
static int created() {
return created;
}
}
INC-40112: No power in Leiden INC-40113: Smart meter offline 2 tickets INC-
There is one nextNumber for the whole class, so every
new ticket takes the next number. A static method has no
this: it cannot touch instance fields unless it is
given an object. That is also why the classic
public static void main cannot call an instance method
of Main directly.
A static final field is the Java way to write a
constant; by convention its name is in UPPER_SNAKE_CASE. A class that only
holds static helpers, like the JDK's Math, usually gets
a private constructor so nobody can create an instance
of it.
toString: printing objects
Every class inherits from java.lang.Object, and with it
three methods you will often override: toString(),
equals(Object) and hashCode(). The
toString() method returns the text
form of an object; System.out.println and string
concatenation with + call it automatically. The version
inherited from Object returns the class name, an
@ and a hexadecimal hash code, which differs from run
to run and tells a reader nothing:
Meter@5ca881b5
Override it to give objects a useful text form. The
@Override line is an annotation that asks the compiler
to check you really replace an inherited method (see
Overriding).
public class Main {
public static void main(String[] args) {
Meter meter = new Meter("E-001", 412.5);
System.out.println(meter);
System.out.println("Last reading: " + meter);
}
}
class Meter {
private final String id;
private final double kwh;
Meter(String id, double kwh) {
this.id = id;
this.kwh = kwh;
}
@Override
public String toString() {
return id + " (" + kwh + " kWh)";
}
}
E-001 (412.5 kWh) Last reading: E-001 (412.5 kWh)
toString() is the Java counterpart of Python's
__str__. Keep it short and free of side effects, because log statements
and debuggers call it too, and leave secrets such as passwords out
of it: toString() output ends up in log files.
equals and hashCode
== compares references. For "do these two objects mean
the same thing?", a class overrides equals(Object). Two
customer objects with the same customer number should count as the
same customer, even when they were loaded separately.
Hash-based collections such as HashSet and
HashMap (see
Arrays and Collections)
use a second method first. The
hashCode() method returns an
int that a
hash table
uses to pick a bucket; only objects in the same bucket are compared
with equals. The contract is: objects that are equal
according to equals must return the same hash code.
Override only equals, and the inherited
hashCode() still gives every object its own value:
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class Main {
public static void main(String[] args) {
Customer a = new Customer("C-10042", "Mei Chen");
Customer b = new Customer("C-10042", "Mei Chen");
System.out.println(a == b);
System.out.println(a.equals(b));
Set<Customer> vip = new HashSet<>(List.of(a));
System.out.println(vip.contains(b));
}
}
class Customer {
private final String id;
private final String name;
Customer(String id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
return o instanceof Customer other && id.equals(other.id);
}
}
false true false
equals says yes, yet the set says it does not contain
the customer: it looked in the wrong bucket and never called
equals. The fix is a hashCode() built from
the same fields that equals compares;
Objects.hash(...) combines them for you.
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
public class Main {
public static void main(String[] args) {
Customer a = new Customer("C-10042", "Mei Chen");
Customer b = new Customer("C-10042", "Mei Chen");
System.out.println(a.equals(b));
System.out.println(a.hashCode() == b.hashCode());
Set<Customer> vip = new HashSet<>(List.of(a));
System.out.println(vip.contains(b));
}
}
class Customer {
private final String id;
private final String name;
Customer(String id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Customer other)) return false;
return id.equals(other.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
true true true
The o instanceof Customer other test checks the type
and gives you a typed variable in one step (pattern matching, see
Control Flow and Switch Expressions); it is also false for null, so
equals(null) correctly returns false. Base
both methods on fields that do not change: if a field used in
hashCode() changes while the object sits in a
HashSet, the set can no longer find it.
import java.util.HashSet;
import java.util.List;
public class Main {
public static void main(String[] args) {
Customer a = new Customer("C-10042");
Customer b = new Customer("C-10042");
System.out.println(a.equals(b));
Object asObject = b;
System.out.println(a.equals(asObject));
System.out.println(new HashSet<>(List.of(a)).contains(b));
}
}
class Customer {
final String id;
Customer(String id) {
this.id = id;
}
public boolean equals(Customer other) {
return other != null && id.equals(other.id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}
true false false
Writing these three methods by hand is repetitive and easy to get
wrong. For classes that are just data, a
record
generates equals, hashCode and
toString from its components, and IDEs can generate
them for ordinary classes.
Designing a class
A few habits keep classes easy to use and hard to misuse:
- Make invalid states impossible. Check arguments in the constructor and in setters, and give every field a valid value, so an object is correct from the moment it exists.
-
Expose as little as possible. Start with
privatefields and no setters; widen access only when another class really needs it. -
Prefer immutable objects. Make fields
finalwhen they should not change after creation. Immutable objects are simpler to reason about and safe to share between threads. -
Change state through methods with a meaning.
ticket.assignTo("Amara Okafor")says what happens and keeps related fields consistent; two separate setter calls do not. -
One class, one job. A
Meterthat also sends invoices and writes log files is hard to test and to change; split responsibilities over small classes.