Principles of Object-oriented Programming
-
Last Updated: August 4, 2023
-
By: javahandson
-
Series
Learn Java in a easy way
The principles of object-oriented programming shape almost every line of Java you will ever write. They give you a simple way to model real things in code. A class, an object, and a handful of related ideas do the heavy lifting. Learn them once, and the rest of Java starts to click.
Java is an object-oriented language. That single sentence hides a lot of meaning. It means Java asks you to think in terms of things, not steps.
Picture a payroll app. An older style of code would hold one big list of names, one big list of salaries, and a pile of loops that walk both. Nothing ties a name to a salary except your careful bookkeeping. Miss an index, and the whole thing quietly breaks.
Object-oriented code flips that around. You build an Employee, and each employee carries their own name, their own salary, and their own behavior. The data and the logic travel together. That small shift removes a surprising amount of pain.
So what makes a language object-oriented? A short checklist of ideas. Java ticks every box, and those boxes are exactly what we cover here.
We walk through all eight principles, one at a time, with small runnable examples. Then we go past the textbook list. Here is the plan:
You need no prior OOP knowledge. If you can write a main method and print a line, you have enough to follow along.
Here is the full list. Read it once, and do not worry if half the words mean nothing yet.
Notice how they stack. Class and Object come first, because everything else stands on them. Let us start there.
A class is a blueprint. It describes what a thing knows and what a thing can do.
Think of a house plan. The plan shows rooms, doors, and windows. Yet nobody sleeps in a plan. You must build a house first.
A Java class works the same way. It lists the fields, which hold data. It lists the methods, which hold behavior. Together they form one neat unit.
The class keyword starts every class. The body then holds fields and methods.
class ClassName {
// fields -> what it knows
// methods -> what it can do
}Fields carry the state. Methods carry the behavior. Every method must sit inside a class, because Java has no free-floating functions.
Let us model an employee. The employee has a name, a role, and a salary.
package com.javahandson.oops;
public class Employee {
String name;
String designation;
float salary;
float getBaseSalary() {
return 45000f;
}
float getGrossSalary() {
return 50000f;
}
}Three fields, two methods. That is the whole blueprint. Nothing runs yet, and no memory holds a name.
Here is the point beginners miss most. Writing a class reserves no memory for its fields.
Why not? Because a blueprint is a description, not a thing. The JVM only hands out memory when you build an actual object.
So we say a class is a logical entity. An object, on the other hand, is a physical entity that lives in memory. Keep that pair straight, and half the confusion around OOP disappears.
An object is one instance of a class. It is the house you built from the plan.
Your company has many employees. One class describes them all. Each employee, though, needs their own object.
The new operator builds an object. It asks the JVM for heap memory, fills in default values, and hands you a reference.
Employee employee = new Employee(); // Output: an Employee object now lives on the heap
The variable employee holds a reference, not the object itself. Think of it as a remote control pointing at a TV. Lose the remote, and the garbage collector eventually clears the TV away.
You can declare and build in one line, or split it across two.
// Style 1: one line Employee a = new Employee(); // Style 2: two lines Employee b; // b points to null, no memory yet b = new Employee(); // now the heap holds a real object
Line one of style two only declares a reference. At that moment b holds null. Call a method on it, and you meet a NullPointerException.
The second line does the real work. It builds the object and points b at it. Both styles end up identical, so pick whichever reads better.
Two objects of the same class share behavior. They rarely share state.
package com.javahandson.oops;
public class Demo {
public static void main(String[] args) {
Employee rohit = new Employee();
rohit.name = "Rohit";
rohit.designation = "Developer";
Employee virat = new Employee();
virat.name = "Virat";
virat.designation = "Tester";
System.out.println(rohit.name + " - " + rohit.designation);
System.out.println(virat.name + " - " + virat.designation);
System.out.println(rohit.getGrossSalary());
}
}
// Output:
// Rohit - Developer
// Virat - Tester
// 50000.0Both objects answer getGrossSalary() the same way. Yet each one carries its own name and role. State differs, behavior stays common. That is the whole idea of an object in one line.
Encapsulation bundles data and its methods into one unit. Then it locks the data away from the outside world.
Think of a medicine capsule. The powder sits inside a shell. You swallow the capsule whole, and you never touch the powder directly.
The recipe has two halves. Mark every field private. Then add public getters and setters as the only doors in.
package com.javahandson.oops;
class Employee {
private String name;
private float salary;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public float getSalary() {
return salary;
}
public void setSalary(float salary) {
if (salary < 0) {
throw new IllegalArgumentException("Salary cannot be negative");
}
this.salary = salary;
}
}
public class Demo {
public static void main(String[] args) {
Employee e = new Employee();
e.setName("Suchit");
e.setSalary(60000f);
System.out.println(e.getName() + " earns " + e.getSalary());
}
}
// Output: Suchit earns 60000.0Look closely at setSalary. It rejects a negative number before the field ever changes.
Now imagine salary as a public field instead. Any class anywhere could write e.salary = -5000f; and nothing would stop it. Your object would sit there holding nonsense.
That guard clause is the real payoff. Private fields mean every write must pass through your code. So you get one place to validate, one place to log, and one place to fix a bug.
Abstraction hides the messy details and shows only what matters. It answers “what does this do?” and skips “how does it do it?”
You drive a car with three pedals and a wheel. Press the brake, and the car slows down.
Do you know how the brake fluid moves through the lines? Probably not. Do you care? Not while you are driving.
The car exposes a simple surface and hides a complicated machine. Good Java classes work exactly the same way.
An abstract class can declare a method without writing its body. Subclasses must fill in that body.
public abstract class Employee {
private String name;
// no body: every company pays differently
abstract float calculateSalary();
// a normal method: shared by all subclasses
void printBadge() {
System.out.println("Employee badge printed");
}
}Notice the mix. One method has no body, another has a full one. That blend makes abstract classes handy when subclasses share some logic but differ elsewhere.
An interface pushes abstraction further. Classically, it holds only method signatures and zero state.
interface Payable {
float calculateSalary();
}
class Contractor implements Payable {
public float calculateSalary() {
return 1200f * 20; // hourly rate times days
}
}
// Output: nothing yet, but Contractor now honors the Payable contractAn interface acts like a contract. It says “any class that signs this must offer these methods”. Callers then rely on the contract and ignore the class behind it.
These two get muddled constantly, so let us separate them cleanly.
private fields plus getters and setters.They cooperate, yet they solve different problems. One shapes your API. The other protects your state.
Inheritance lets one class take on the fields and methods of another. The child gets everything the parent offers, then adds its own twist.
One keyword does the work: extends. Write class Dog extends Animal, and Dog inherits from Animal.
The vocabulary comes in pairs. People say parent, super, or base class for the giver. They say child, sub, or derived class for the taker. All six words show up in interviews, so learn them all.
Java allows only one direct parent per class. That rule dodges the “diamond problem”, where two parents offer clashing versions of the same method.
package com.javahandson.oops;
abstract class Employee {
String name = "Unknown";
abstract float calculateSalary();
}
class MicrosoftEmployee extends Employee {
@Override
float calculateSalary() {
return 50000f;
}
}
public class Demo {
public static void main(String[] args) {
MicrosoftEmployee m = new MicrosoftEmployee();
m.name = "Suchit"; // field inherited from Employee
System.out.println(m.name + " : " + m.calculateSalary());
}
}
// Output: Suchit : 50000.0MicrosoftEmployee never declares name. It still uses name, because the parent already declared it. That reuse costs you nothing extra at runtime.
Inheritance has a dark side too, though. Change a parent method, and every subclass feels the ripple. We tackle that trade-off in section 10.
Polymorphism means “many forms”. One name, several behaviors.
Think about the word “open”. You open a door, a bank account, and a file. Same verb, three very different actions. Context decides the meaning.
Java offers two flavors. The compiler resolves one, and the JVM resolves the other.
Method overloading gives you compile-time polymorphism. Several methods share a name but take different parameters.
package com.javahandson.oops;
public class AreaCalculator {
int area(int side) {
return side * side;
}
int area(int length, int breadth) {
return length * breadth;
}
double area(double radius) {
return 3.14 * radius * radius;
}
public static void main(String[] args) {
AreaCalculator calc = new AreaCalculator();
System.out.println(calc.area(4)); // Output: 16
System.out.println(calc.area(4, 2)); // Output: 8
System.out.println(calc.area(2.0)); // Output: 12.56
}
}The compiler reads each call and matches the argument types. Then it wires the call to exactly one method, long before the program runs. Nothing gets decided later, so we call this static or early binding.
Method overriding gives you runtime polymorphism. Here the child rewrites a method it inherited, keeping the exact same signature.
package com.javahandson.oops;
interface Shape {
int area(int side);
}
class Square implements Shape {
public int area(int side) {
return side * side;
}
}
class Cube implements Shape {
public int area(int side) {
return 6 * side * side; // total surface area
}
}
public class Demo {
public static void main(String[] args) {
Shape s = new Square();
Shape c = new Cube();
System.out.println(s.area(4)); // Output: 16
System.out.println(c.area(4)); // Output: 96
}
}Both variables have the type Shape. Yet the two calls behave differently. The object on the heap, not the reference type, decides the winner.
Runtime polymorphism unlocks a beautiful trick. You write code against the parent type and stay blind to the children.
List<Shape> shapes = List.of(new Square(), new Cube());
for (Shape shape : shapes) {
System.out.println(shape.area(3));
}
// Output:
// 9
// 54The loop knows nothing about squares or cubes. Add a Triangle tomorrow, and this loop still compiles and runs untouched. That is how OOP keeps big codebases flexible.
Binding links a method call to a method body. The question is simply when that link forms.
Static binding happens at compile time. The compiler already knows which body to run, so it hard-wires the call.
Which members use static binding? Three groups:
private methods, since no subclass can even see them.static methods, since they belong to the class, not an object.final methods, since nobody may override them.Fields join that list too. We come back to that quirk in a moment.
Dynamic binding takes the opposite route. The JVM waits, looks at the real object on the heap, and only then chooses the body.
Here is the mechanism, minus the jargon. Every class carries a small lookup table of its methods, often nicknamed a vtable. A call like shape.area(4) follows the reference to the object, reads that object’s table, and jumps to the matching entry.
Shape shape = new Cube(); // reference type Shape, object type Cube shape.area(4); // Compiler: "area(int) exists on Shape. Good, this compiles." // JVM: "the object is really a Cube, so run Cube.area()" // Output: 96
Two different actors, two different jobs. The compiler checks that the method exists on the reference type. The JVM then picks the version that belongs to the actual object.
So dynamic binding and runtime polymorphism describe one event from two angles. Polymorphism names the behavior. Dynamic binding names the mechanism underneath.
Now for a genuine gotcha, and a favorite interview trap. Java never applies dynamic binding to fields.
class Parent {
String label = "PARENT";
String describe() { return "I am the parent"; }
}
class Child extends Parent {
String label = "CHILD"; // hides, not overrides
@Override
String describe() { return "I am the child"; }
}
public class Demo {
public static void main(String[] args) {
Parent p = new Child();
System.out.println(p.label); // Output: PARENT
System.out.println(p.describe()); // Output: I am the child
}
}Read those two outputs again. The method call follows the object, so it prints the child version. The field access follows the reference type, so it prints PARENT.
Methods override. Fields merely hide. Because of that trap, most experienced developers keep fields private and never redeclare a parent’s field name.
Objects rarely work alone. They ask each other for help, and that request is a message.
Strip away the theory, and message passing means one plain thing. Object A calls a method on object B.
class Printer {
void print(String text) {
System.out.println("Printing: " + text);
}
}
class Report {
private final Printer printer = new Printer();
void send() {
printer.print("Q3 sales report"); // the message
}
}
// Output: Printing: Q3 sales reportBreak that single line down. Report is the sender. Printer is the receiver. The method name says what to do, and the argument carries the data.
Why bother with the fancy name? Because it changes how you design. Instead of reaching into another object and pulling its data out, you send it a request and let it decide. “Tell, don’t ask” is the slogan, and it produces far cleaner classes.
Messages also cross thread boundaries. A producer thread creates work, and a consumer thread handles it.
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// Producer thread
new Thread(() -> {
queue.put("order-101"); // send the message
}).start();
// Consumer thread
new Thread(() -> {
String order = queue.take(); // receive the message
System.out.println("Processing " + order);
}).start();
// Output: Processing order-101The queue sits between them as a mailbox. Neither thread calls the other directly, and neither one needs a reference to the other. Loose coupling like that keeps concurrent code sane.
Zoom out further, and the pattern repeats at every scale:
Same shape every time. Someone sends, someone receives, and someone acts. Message passing is the principle that scales from a single method call all the way to a distributed system.
Now for the design question that separates juniors from seniors. Should this class extend another class, or simply hold one?
Say the relationship out loud. If “is-a” sounds right, reach for inheritance. If “has-a” sounds right, reach for composition.
// Inheritance: a Car IS-A Vehicle
class Vehicle {
void move() { System.out.println("Moving"); }
}
class Car extends Vehicle { }
// Composition: a Car HAS-A Engine
class Engine {
void start() { System.out.println("Engine started"); }
}
class ComposedCar {
private final Engine engine = new Engine();
void start() { engine.start(); }
}A car is a vehicle. That reads fine. A car is an engine? That sounds absurd, so composition wins there.
Experienced developers usually reach for composition first. Here is their reasoning:
DieselEngine or an ElectricEngine into the constructor.Engine cannot ripple through a subclass tree.Inheritance still earns its keep. Use it for a true is-a relationship, or when you genuinely want runtime polymorphism. Just never reach for extends merely to grab a handy method.
Runtime polymorphism only works when you override correctly. Get a detail wrong, and Java quietly does something else.
public method cannot turn protected.static, final, and private methods. Java simply will not let you override them.That third rule about return types has a friendly name: covariant return types. It lets a subclass promise something more specific than its parent did.
class Animal {
protected Animal reproduce() { return new Animal(); }
}
class Dog extends Animal {
@Override
public Dog reproduce() { // Dog is a subtype of Animal: legal
return new Dog();
}
}
// Output: callers of Dog.reproduce() get a Dog, no cast neededTwo rules bend at once in that snippet. The return type narrows from Animal to Dog. Visibility widens from protected to public. Both moves are legal, and both help the caller.
The @Override annotation looks optional. Please use it anyway.
Suppose you mistype a parameter type. Without the annotation, Java shrugs and treats your method as a brand new overload. It compiles, it runs, and the parent version keeps executing. You then lose an afternoon hunting a bug that never should have compiled.
Add @Override, and the compiler double-checks your signature against the parent. A typo becomes a compile error instead of a silent Friday-night surprise. One annotation, hours saved.
Interviewers love to ask about the “four pillars of OOP”. So why four, when we just listed eight principles?
The four pillars are Encapsulation, Abstraction, Inheritance, and Polymorphism. Those four describe how you organise behavior, and every OOP language offers them.
What about the other four? They are the foundations underneath, not extra pillars:
If someone asks for the pillars, name the four. Then mention the other four as the supporting concepts. That answer shows you understand the list rather than reciting it.
Here is everything on one screen. Skim it the night before an interview.
| Principle | Java Tool | One-line Meaning |
|---|---|---|
| Class | class keyword |
A blueprint of fields and methods; costs no memory |
| Object | new operator |
A live instance on the heap, with its own state |
| Encapsulation | private fields, getters, setters |
Bundle data with logic, and hide the data |
| Abstraction | abstract classes, interfaces |
Show what an object does, hide how it works |
| Inheritance | extends, implements |
Reuse a parent’s fields and methods |
| Polymorphism | Overloading, overriding | One name, many behaviors |
| Dynamic Binding | Overriding plus the JVM | The real object picks the method at runtime |
| Message Passing | Method calls, queues, sockets | Objects collaborate by sending requests |
These five trip up almost everyone. Spot them now, and skip the pain later.
Abstraction hides complexity through interfaces and abstract classes. Encapsulation hides data through private fields.
One quick way to remember it: abstraction is about the design, and encapsulation is about the fields. Different problem, different tool.
Overloading happens at compile time. The compiler matches your arguments and locks the call in.
Only overriding waits for runtime. So overloading gives static polymorphism, and overriding gives dynamic polymorphism. Interviewers ask this constantly, and plenty of candidates fumble it.
Declaring a class allocates nothing for its instance fields. Memory arrives only when new runs.
Write a hundred classes and never call new. Your heap stays empty. Blueprints are cheap; houses are not.
Many beginners mark every field private, then bolt a public getter and setter onto each one. That combination re-opens the door you just locked.
Real encapsulation means thinking about each field. Does anyone outside truly need to change it? If not, drop the setter. Better yet, expose behavior instead of data: account.deposit(500) beats account.setBalance(account.getBalance() + 500) every single time.
You spot a useful method in another class, so you extend it. Tempting, and usually wrong.
Inheritance welds two classes together forever. Your class now inherits every parent method, including ones that make no sense for it. Ask the is-a question first. When the answer feels shaky, hold a reference instead and delegate.
Theory sticks better with a real problem. Let us build a tiny payment system.
Your app must accept money by card and by UPI. Tomorrow it might add a wallet.
The checkout code should never care which one runs. It just says “charge this amount” and moves on. Every principle we covered pitches in here.
package com.javahandson.oops;
import java.util.List;
// ABSTRACTION: a contract, no implementation
interface PaymentMethod {
boolean pay(double amount);
}
// CLASS + ENCAPSULATION: private state, guarded access
class Card implements PaymentMethod {
private final String number;
private double balance;
Card(String number, double balance) {
this.number = number;
this.balance = balance;
}
private String masked() { // hidden helper
return "****" + number.substring(number.length() - 4);
}
@Override // POLYMORPHISM + DYNAMIC BINDING
public boolean pay(double amount) {
if (amount > balance) {
System.out.println("Card " + masked() + " declined");
return false;
}
balance -= amount;
System.out.println("Card " + masked() + " charged " + amount);
return true;
}
}
class Upi implements PaymentMethod {
private final String vpa;
Upi(String vpa) {
this.vpa = vpa;
}
@Override
public boolean pay(double amount) {
System.out.println("UPI " + vpa + " paid " + amount);
return true;
}
}
// COMPOSITION: Checkout HAS-A PaymentMethod
class Checkout {
private final PaymentMethod method;
Checkout(PaymentMethod method) {
this.method = method;
}
void buy(double amount) { // MESSAGE PASSING
if (method.pay(amount)) {
System.out.println("Order confirmed");
} else {
System.out.println("Order failed");
}
}
}
public class Store {
public static void main(String[] args) {
// OBJECT: three live instances
List<PaymentMethod> methods = List.of(
new Card("4111111111111234", 5000),
new Upi("suchit@upi"),
new Card("4111111111119876", 100)
);
for (PaymentMethod m : methods) {
new Checkout(m).buy(900);
}
}
}
// Output:
// Card ****1234 charged 900.0
// Order confirmed
// UPI suchit@upi paid 900.0
// Order confirmed
// Card ****9876 declined
// Order failedTrace the principles through that file. PaymentMethod is pure abstraction. Card encapsulates its balance and hides masked().
Look at the loop next. It calls pay on a PaymentMethod reference, and the JVM routes each call to the right class. That is polymorphism riding on dynamic binding.
Checkout holds a payment method rather than extending one, so composition shows up too. And buy simply sends a message and trusts the answer.
Now add a Wallet class tomorrow. Implement the interface, drop it in the list, and ship. Not one existing line changes. That flexibility is the entire reward for learning these principles.
A: Java rests on eight principles: Class, Object, Encapsulation, Abstraction, Inheritance, Polymorphism, Dynamic Binding, and Message Passing. Interviewers often shorten the list to four pillars, namely Encapsulation, Abstraction, Inheritance, and Polymorphism. The remaining four act as the foundation those pillars stand on.
A: A class is a blueprint that lists fields and methods, and it reserves no memory. An object is a live instance of that class, built with the new operator, and it owns memory on the heap. One class can produce thousands of objects, each carrying its own state.
A: Abstraction hides complexity and shows only the useful surface, using abstract classes and interfaces. Encapsulation hides data, using private fields plus getters and setters. Put simply, abstraction shapes your design while encapsulation protects your state.
A: Overloading works at compile time. The compiler reads your argument types and wires the call to one exact method before the program starts. Only overriding waits until runtime, because the JVM must look at the real object first.
A: Dynamic binding means the JVM links a method call to a method body at runtime, using the actual object rather than the reference type. It powers method overriding. Private, static, and final methods skip it entirely and use static binding instead.
A: No, and this trips up many candidates. Java resolves fields using the reference type at compile time. So if a Parent reference points to a Child object, p.label prints the parent’s field while p.describe() runs the child’s method. Fields hide, methods override.
A: Composition keeps classes loosely coupled. You can swap the inner object, hold several collaborators at once, and pass a fake one in tests. Inheritance welds a child to its parent, so a change in the parent can ripple through every subclass. Use inheritance only for a genuine is-a relationship.
A: The name and parameter list must match the parent exactly. The return type must match or narrow to a subtype, which we call a covariant return. Visibility may widen but never shrink, and checked exceptions may shrink but never widen. Java also blocks you from overriding static, final, and private methods.
A: Message passing describes objects communicating with each other. At the smallest scale, one object calls a method on another and passes arguments. The same idea scales up to threads sharing a blocking queue, programs talking over sockets, and services exchanging JSON through REST calls.
A: No. A class is a logical template, so its instance fields claim no memory at declaration. The JVM allocates heap memory only when you build an object with the new operator. That difference is exactly why we call a class a logical entity and an object a physical one.
Let us pull the threads together. A class is a blueprint, and an object is the real thing you build from it.
Encapsulation guards an object’s data behind private fields. Abstraction shows a simple surface and hides the machinery. Inheritance lets a child reuse a parent, while polymorphism lets one method name behave many ways.
Underneath, dynamic binding sends each call to the right body at runtime. Message passing then ties your objects into a working system.
These ideas are not eight separate tricks. They lean on one another. Master the class-object pair first, and the other six will feel like natural extensions rather than new material.
One last piece of advice. Write code, break it, and read the errors. Reading about OOP helps, but a NullPointerException at 11pm teaches faster than any tutorial ever will.