Classes #
Classes are the foundation of almost all the Java code you write. Without understanding how classes work, you’ll struggle to understand why Java is designed the way it is and how pieces of code interact with each other. A class isn’t just a container for functions — it’s a blueprint that defines data structures, behaviors, and the relationships between parts of a program. This article covers classes from the most basic concepts to the four main implementation techniques: encapsulation, inheritance, polymorphism, and abstraction.
Basic Concepts #
Before looking at code, there are four concepts you need to understand. They’re the building blocks that form Java’s class system.
| Concept | Role | Analogy |
|---|---|---|
| Attributes | The data an object owns | Car specifications: brand, color, speed |
| Methods | Actions an object can perform | The gas pedal and brake on a car |
| Constructor | The initialization process when an object is created | The factory assembling a car from initial components |
| Object | The concrete realization of a class | One finished car unit |
The relationship between these four concepts can be illustrated as follows:
flowchart TD
A[Class — Blueprint] --> B[Constructor]
B --> C[Object — Instance]
A --> D[Attributes]
A --> E[Methods]
D --> C
E --> C
C --> F[Object 1]
C --> G[Object 2]
C --> H[Object N...]One class can produce many objects. Each object has its own copy of the attributes, but they all share the same method definitions from the class.
A Simple Class Declaration #
Let’s start with something concrete — a car. This class has three attributes, one constructor, and three methods.
Attributes and Constructors #
Attributes are declared at the class level, outside any method. A constructor is a special method whose name exactly matches the class name and has no return type.
public class Car {
// Attributes: data that describes the car
String brand;
String color;
int speed;
// Constructor: runs when a new object is created
public Car(String brand, String color, int speed) {
this.brand = brand;
this.color = color;
this.speed = speed;
}
}
Notice the this keyword inside the constructor. this.brand refers to the attribute belonging to the object, while brand (without this) refers to the constructor parameter. Without this, Java can’t tell them apart when the parameter name matches the attribute name.
Methods #
Methods are declared inside the class body and define what the object can do. Methods can read and modify the attributes belonging to that object.
public class Car {
String brand;
String color;
int speed;
public Car(String brand, String color, int speed) {
this.brand = brand;
this.color = color;
this.speed = speed;
}
// Methods: actions the car can perform
public void accelerate(int increase) {
speed += increase;
System.out.println("Current speed: " + speed + " km/h");
}
public void brake(int decrease) {
speed -= decrease;
System.out.println("Current speed: " + speed + " km/h");
}
public void carInfo() {
System.out.println("Brand: " + brand);
System.out.println("Color: " + color);
System.out.println("Speed: " + speed + " km/h");
}
}
Instantiating Objects #
A class is just a definition — it does nothing until you create an object from it. The process of creating an object is called instantiation, done with the new keyword.
Creating Objects and Calling Methods #
public class Main {
public static void main(String[] args) {
// Instantiation: creating a new object from the Car class
Car myCar = new Car("Toyota", "Red", 0);
// Calling methods on the object
myCar.accelerate(50); // Output: Current speed: 50 km/h
myCar.brake(10); // Output: Current speed: 40 km/h
myCar.carInfo();
// Output:
// Brand: Toyota
// Color: Red
// Speed: 40 km/h
}
}
Many Objects from One Class #
You can create as many objects as you want from the same class. Each object has its own copy of the attributes and they don’t affect each other — changing car1 won’t change car2.
Car car1 = new Car("Toyota", "Red", 0);
Car car2 = new Car("Honda", "White", 0);
car1.accelerate(80); // only affects car1
car2.accelerate(60); // only affects car2
car1.carInfo(); // Speed: 80 km/h
car2.carInfo(); // Speed: 60 km/h
Encapsulation #
Encapsulation hides a class’s internal data from uncontrolled external access. Without encapsulation, code outside the class can change attributes arbitrarily — including filling them with invalid values.
The Problem Without Encapsulation #
// ANTI-PATTERN: public attributes, anyone can change them directly
public class BankAccount {
public double balance; // dangerous: no validation
}
// In other code:
BankAccount account = new BankAccount();
account.balance = -9999999; // nothing prevents this
The Solution with Private and Getters/Setters #
Make the attributes private, then provide methods with validation as the only way to read or change their values.
// CORRECT: private attributes, access only through validating methods
public class BankAccount {
private String accountNumber;
private double balance;
public BankAccount(String accountNumber, double initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
// Getter: reads the attribute's value
public double getBalance() {
return balance;
}
// Setter with validation
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
System.out.println("Deposit successful. Current balance: " + balance);
} else {
System.out.println("Invalid deposit amount.");
}
}
public void withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
System.out.println("Withdrawal successful. Current balance: " + balance);
} else {
System.out.println("Withdrawal failed. Insufficient balance.");
}
}
}
With private, the balance attribute can’t be accessed directly from outside. All changes must go through the deposit() or withdraw() methods, which have validation logic inside.
flowchart LR
A[External Code] -->|"getBalance()"| B[Getter]
A -->|"deposit(amount)"| C[Setter with Validation]
B --> D[("private balance")]
C --> D
A -. blocked .-> DInheritance #
Inheritance allows one class to inherit attributes and methods from another class. The inheriting class is called the subclass, and the inherited-from class is called the superclass. Use the extends keyword to define this relationship.
Defining a Superclass and Subclasses #
// Superclass: a general definition for all animals
public class Animal {
String name;
int age;
public Animal(String name, int age) {
this.name = name;
this.age = age;
}
public void makeSound() {
System.out.println("The animal makes a sound.");
}
public void info() {
System.out.println("Name: " + name + ", Age: " + age + " years");
}
}
// Subclass: inherits everything from Animal, then overrides makeSound()
public class Dog extends Animal {
public Dog(String name, int age) {
super(name, age); // calls the superclass constructor
}
@Override
public void makeSound() {
System.out.println(name + " barks: Woof woof!");
}
}
public class Cat extends Animal {
public Cat(String name, int age) {
super(name, age);
}
@Override
public void makeSound() {
System.out.println(name + " meows: Meow!");
}
}
The super Keyword and @Override #
super calls a constructor or method from the superclass. @Override tells the compiler that you deliberately intend to override a method — not that you mistyped a name. Without @Override, a typo in the method name won’t produce an error; it just silently creates a new method that never gets called.
public class Main {
public static void main(String[] args) {
Dog dog = new Dog("Rex", 4);
dog.makeSound(); // Output: Rex barks: Woof woof!
dog.info(); // Output: Name: Rex, Age: 4 years ← inherited from Animal
}
}
flowchart TD
A["Animal\n─────────\nname, age\nmakeSound(), info()"] --> B["Dog\n─────────\n@Override makeSound()"]
A --> C["Cat\n─────────\n@Override makeSound()"]
A --> D["Other animals..."]Polymorphism #
Polymorphism is the ability of a superclass-typed variable to hold an object of any subclass, and when a method is called, Java runs the version matching the object’s actual type — not the variable’s type.
Superclass-Typed Variables #
// Both variables are typed Animal, but hold different objects
Animal animal1 = new Dog("Buddy", 3);
Animal animal2 = new Cat("Kitty", 2);
// Java runs the method of the actual object type
animal1.makeSound(); // Output: Buddy barks: Woof woof!
animal2.makeSound(); // Output: Kitty meows: Meow!
Processing a Mixed List #
Without polymorphism, you’d have to write separate if (instanceof Dog) and if (instanceof Cat) branches — code that’s hard to extend. With polymorphism, adding a new animal type doesn’t change the loop at all.
Animal[] allAnimals = {
new Dog("Rex", 5),
new Cat("Luna", 2),
new Dog("Max", 1)
};
for (Animal a : allAnimals) {
a.makeSound(); // each object responds in its own way
}
sequenceDiagram
participant Main
participant animal1 as animal1 (Dog)
participant animal2 as animal2 (Cat)
Main->>animal1: makeSound()
animal1-->>Main: "Buddy barks: Woof woof!"
Main->>animal2: makeSound()
animal2-->>Main: "Kitty meows: Meow!"Abstraction #
Abstraction hides implementation details and only exposes the “contract” — what can be done, not how. In Java, abstraction can be achieved with abstract classes or interfaces.
Defining an Abstract Class #
Abstract classes fit when you want to define a framework that subclasses must implement, but some methods already have default implementations. Use the abstract keyword at the class level and on each method that has no implementation yet.
// Abstract class: can't be instantiated directly
abstract class Vehicle {
String name;
public Vehicle(String name) {
this.name = name;
}
// Abstract method: must be implemented by subclasses
abstract void move();
// Concrete method: already has an implementation, usable directly
public void info() {
System.out.println("Vehicle: " + name);
}
}
class Car extends Vehicle {
public Car(String name) {
super(name);
}
@Override
public void move() {
System.out.println(name + " moves on wheels on land.");
}
}
class Ship extends Vehicle {
public Ship(String name) {
super(name);
}
@Override
public void move() {
System.out.println(name + " moves with a propeller at sea.");
}
}
Using an Abstract Class #
public class Main {
public static void main(String[] args) {
// ANTI-PATTERN: can't create an object from an abstract class
// Vehicle v = new Vehicle("Anything"); // error: Vehicle is abstract
// CORRECT: create objects from concrete subclasses
Vehicle car = new Car("Avanza");
Vehicle ship = new Ship("KM Nusantara");
car.move(); // Output: Avanza moves on wheels on land.
ship.move(); // Output: KM Nusantara moves with a propeller at sea.
car.info(); // inherited method from Vehicle
ship.info();
}
}
flowchart TD
A["«abstract»\nVehicle\n─────────\nname\nmove() — abstract\ninfo() — concrete"] --> B["Car\n─────────\n@Override move()"]
A --> C["Ship\n─────────\n@Override move()"]
A --> D["Plane\n─────────\n@Override move()"]The difference between abstract classes and interfaces: abstract classes can have attributes and concrete methods, while interfaces (before Java 8) only defined contracts without implementations. Use abstract classes when subclasses share some common behavior; use interfaces when you only need to define capabilities that unrelated classes can have.
When to Use Each Technique #
Use ENCAPSULATION when:
✓ Attributes have value constraints that must be maintained (balance must not be negative)
✓ You want to control who can read and change the data
✓ Almost always — it's a good default for every class
Use INHERITANCE when:
✓ There's a clear "is-a" relationship (Dog is an Animal)
✓ Subclasses share many attributes and behaviors with the superclass
✓ You want to override only some methods, not all of them
✗ Avoid it if the relationship is only "uses" (use composition instead)
✗ Avoid inheritance beyond 2-3 levels — it quickly gets complicated
Use ABSTRACTION when:
✓ You're defining a framework that subclasses must follow
✓ There are several methods that need different implementations in each subclass
✓ You want to prevent direct instantiation of the parent class
Summary #
- Classes are blueprints — they define attributes and methods, but do nothing until an object is created with
new.- The constructor runs once when an object is created — use it for initial attribute setup. The
thiskeyword distinguishes class attributes from parameters.- Encapsulation protects data — make attributes
privateand provide getters/setters with validation. This prevents invalid values from entering the object.- Inheritance shares code — subclasses inherit the superclass’s attributes and methods. Use
extendsfor “is-a” relationships (Dog is an Animal). Use@Overrideto mark overridden methods.- Polymorphism simplifies code — a superclass-typed variable can hold any subclass object, and Java automatically calls the right method at runtime.
- Abstraction defines contracts — abstract classes force subclasses to implement certain methods while still providing default implementations for shared methods.
- All four work together — a good Java application combines encapsulation as the default, inheritance for code sharing, polymorphism for flexibility, and abstraction for defining frameworks.