Vấn đề

OOP là thứ hầu như ai học Java cũng đã đọc qua. Nhưng nhiều người học thuộc định nghĩa mà khi code vẫn viết sai — hoặc đúng nhưng không hiểu tại sao đúng.

Bài viết này sẽ đi vào từng tính chất bằng ví dụ thực tế, không nhắc lại định nghĩa khô khan. Phần cuối sẽ đi sâu vào Abstract Class vs Interface — thứ thay đổi nhiều từ Java 8 và là câu hỏi phỏng vấn gần như chắc chắn xuất hiện.

Encapsulation — Đóng Gói

Encapsulation là việc ẩn dữ liệu nội bộ và kiểm soát hoàn toàn ai được đọc, ai được thay đổi — thông qua method thay vì cho phép truy cập field trực tiếp.

public class BankAccount {
    private double balance;

    public double getBalance() {
        return balance;
    }

    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("Số tiền phải dương");
        balance += amount;
    }

    public void withdraw(double amount) {
        if (amount > balance) throw new IllegalStateException("Số dư không đủ");
        balance -= amount;
    }
}

balanceprivate — không ai có thể gán thẳng account.balance = -1000 từ bên ngoài. Mọi thay đổi đều đi qua deposit()withdraw(), nơi validation được đảm bảo bên trong class.

Một điểm hay bị bỏ qua là nguyên tắc “Tell, Don’t Ask”: thay vì lấy data ra rồi xử lý bên ngoài, hãy đưa logic đó vào trong chính class.

// Sai — lấy data ra rồi xử lý bên ngoài
if (account.getBalance() >= 100) {
    account.setBalance(account.getBalance() - 100);
}

// Đúng — đưa logic vào trong class
account.withdraw(100);

Cách viết thứ hai không chỉ gọn hơn — nó còn đảm bảo mọi business rule liên quan đến balance đều nằm trong BankAccount, không bị phân tán ra ngoài.

Nếu không tạo setter thì cũng không ai bypass được logic đó. Tạo setter chỉ để “đúng chuẩn JavaBean” mà không cần thiết thực ra đang phá vỡ encapsulation.

Java 16 giới thiệu record như một dạng encapsulation chặt hơn cho data class. Toàn bộ state là private final, không có setter, compiler tự sinh constructor, getter, equals(), hashCode(), toString():

public record Point(int x, int y) {}

Point p = new Point(3, 4);
System.out.println(p.x());  // 3
System.out.println(p);      // Point[x=3, y=4]

Inheritance — Kế Thừa

Inheritance cho phép class con tái sử dụng code của class cha và mở rộng thêm. Quan hệ này mô hình hóa ý nghĩa “is-a”Dog là một Animal, Car là một Vehicle. Nếu quan hệ đó không tồn tại trong thực tế, kế thừa thường là lựa chọn sai.

public class Animal {
    protected String name;

    public Animal(String name) {
        this.name = name;
    }

    public void eat() {
        System.out.println(name + " đang ăn.");
    }
}

public class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    public void bark() {
        System.out.println(name + " đang sủa.");
    }
}

super(name) gọi constructor của Animal để khởi tạo name. Nếu class cha không có no-arg constructor, class con bắt buộc phải gọi super(...) tường minh — nếu không, compiler báo lỗi.

Java chỉ cho phép extends một class duy nhất. Vì vậy một lỗi phổ biến là dùng inheritance chỉ để tái dùng code, dù không có quan hệ “is-a” thực sự. Trong trường hợp đó, composition thường là lựa chọn tốt hơn:

// Sai về ngữ nghĩa — OrderService không phải "là" một Logger
public class OrderService extends Logger { ... }

// Đúng — OrderService "có" một Logger
public class OrderService {
    private final Logger logger;

    public OrderService(Logger logger) {
        this.logger = logger;
    }
}

Với composition, bạn có thể inject bất kỳ implementation nào — console logger, file logger, mock logger khi test — mà không cần thay đổi OrderService.

Java 17 giới thiệu sealed class cho phép kiểm soát chính xác tập hợp class nào được phép kế thừa — thay vì chỉ có hai lựa chọn là final (chặn hoàn toàn) hoặc để mở hoàn toàn:

public sealed class Shape permits Circle, Rectangle, Triangle {}

public final class Circle    extends Shape { public double radius; }
public final class Rectangle extends Shape { public double width, height; }
public final class Triangle  extends Shape { public double base, height; }

Trong Domain-Driven Design, sealed class rất hữu ích để mô hình hóa các domain concept có tập hợp trạng thái cố định — ví dụ PaymentStatus, OrderState. Bạn đảm bảo không ai tự ý tạo thêm subclass ngoài ý muốn, và kết hợp với pattern matching trong Java 21, compiler còn kiểm tra xem bạn đã xử lý hết các case chưa:

double area = switch (shape) {
    case Circle c    -> Math.PI * c.radius * c.radius;
    case Rectangle r -> r.width * r.height;
    case Triangle t  -> 0.5 * t.base * t.height;
    // Không cần default — compiler biết đã cover hết
};

Polymorphism — Đa Hình

Polymorphism cho phép cùng một lời gọi method hoạt động khác nhau tùy vào object thực tế đằng sau nó. Trong Java có hai dạng.

Overloading (Static Binding) — nhiều method cùng tên nhưng khác tham số. Compiler quyết định gọi method nào ngay lúc compile dựa trên kiểu được khai báo, không phải kiểu thực tế lúc runtime — đó là lý do người ta gọi nó là static binding:

public class Calculator {
    public int add(int a, int b)          { return a + b; }
    public double add(double a, double b) { return a + b; }
    public int add(int a, int b, int c)   { return a + b + c; }
}

Vì resolve lúc compile time, overloading nhìn vào kiểu khai báo — điều này hay gây nhầm:

public class Printer {
    void print(Object o) { System.out.println("Object: " + o); }
    void print(String s) { System.out.println("String: " + s); }
}

Object obj = "Hello"; // Kiểu khai báo là Object, dù thực tế là String
new Printer().print(obj); // In ra: "Object: Hello"

obj thực ra đang giữ một String, compiler chỉ thấy kiểu khai báo là Object nên gọi print(Object). Static binding — quyết định tại compile time, không thay đổi lúc runtime.

Overriding (Dynamic Binding) — subclass override method của class cha. JVM quyết định gọi method nào lúc runtime dựa trên kiểu thực tế của object — đó là dynamic binding:

public abstract class Shape {
    public abstract double area();
}

public class Circle extends Shape {
    private double radius;
    public Circle(double radius) { this.radius = radius; }

    @Override
    public double area() { return Math.PI * radius * radius; }
}

public class Rectangle extends Shape {
    private double w, h;
    public Rectangle(double w, double h) { this.w = w; this.h = h; }

    @Override
    public double area() { return w * h; }
}

List<Shape> shapes = List.of(new Circle(5), new Rectangle(4, 6));
shapes.forEach(s -> System.out.println(s.area()));
// 78.539...
// 24.0

Cả hai đều là Shape, nhưng JVM tự tìm đúng implementation khi gọi area() tùy theo kiểu thực tế của từng object lúc chạy. @Override không bắt buộc nhưng nên luôn viết — nếu gõ nhầm tên method, compiler báo lỗi ngay thay vì âm thầm tạo một method mới không liên quan.

Abstraction — Trừu Tượng Hóa

Abstraction là việc ẩn đi chi tiết implementation, chỉ expose ra những gì caller cần biết. Trong Java, abstraction được thực hiện qua abstract classinterface — và đây cũng là phần đáng nói nhất.

Abstract Class và Interface

Sự khác biệt cốt lõi

Trước Java 8, ranh giới giữa hai thứ này rất rõ: interface là contract thuần túy, không có implementation, mọi field đều là public static final. Abstract class có thể có implementation, có instance field, có constructor.

// Interface trước Java 8
public interface Drawable {
    void draw(); // implicitly public abstract
}

// Abstract class
public abstract class Animal {
    protected String name;

    public Animal(String name) {
        this.name = name;
    }

    public void eat() {
        System.out.println(name + " đang ăn.");
    }

    public abstract void makeSound();
}

Interface thay đổi từ Java 8

Java 8 thêm default methodstatic method vào interface. Đây là lý do Oracle có thể bổ sung hàng loạt method mới vào List, Map, Collection… mà không phá vỡ backward compatibility — các class implement không cần sửa gì vẫn compile được:

public interface Notification {
    void send(String message);

    // Class implement không bắt buộc phải override
    default void sendWithLog(String message) {
        System.out.println("[LOG] Gửi: " + message);
        send(message);
    }

    static Notification noOp() {
        return message -> {};
    }
}

Java 9 tiếp tục thêm private method, giúp tái sử dụng logic giữa các default method mà không lộ ra ngoài:

public interface Notification {
    void send(String message);

    default void sendWithRetry(String message, int times) {
        for (int i = 0; i < times; i++) {
            log("Lần thử " + (i + 1));
            send(message);
        }
    }

    default void sendUrgent(String message) {
        log("URGENT");
        send("[URGENT] " + message);
    }

    private void log(String info) {
        System.out.println("[" + info + "]");
    }
}

So sánh (Java 21)

Abstract Class Interface
Constructor
Instance field ❌ (chỉ public static final)
Abstract method
Default method ✅ (từ Java 8)
Static method ✅ (từ Java 8)
Private method ✅ (từ Java 9)
Đa kế thừa
Access modifier cho method Mọi loại Chỉ public hoặc private

Diamond Problem

Java không cho extends nhiều class vì khi hai class cha có cùng method, compiler không biết dùng method nào. Với interface, nếu hai interface có cùng default method, Java bắt buộc class implement phải override để giải quyết conflict tường minh:

interface A { default void hello() { System.out.println("A"); } }
interface B { default void hello() { System.out.println("B"); } }

public class C implements A, B {
    @Override
    public void hello() {
        A.super.hello(); // Chọn tường minh
    }
}

Nếu không override, compiler báo lỗi — không để conflict âm thầm xảy ra lúc runtime.

Khi nào dùng cái nào?

Dùng abstract class khi các class con chia sẻ state chung hoặc cần constructor để khởi tạo:

public abstract class Vehicle {
    protected String brand;
    protected int year;

    public Vehicle(String brand, int year) {
        this.brand = brand;
        this.year = year;
    }

    public abstract double fuelEfficiency();

    public String info() {
        return brand + " (" + year + ")";
    }
}

Dùng interface khi muốn định nghĩa capability mà nhiều class không liên quan có thể implement, đặc biệt khi cần đa kế thừa:

public interface Exportable {
    byte[] exportToPdf();
    byte[] exportToExcel();
}

public interface Auditable {
    LocalDateTime getCreatedAt();
    String getCreatedBy();
}

// SalesReport cần cả hai capability
// Không thể dùng abstract class vì Java không cho extends 2 class
public class SalesReport implements Exportable, Auditable { ... }

Nếu phân vân, hãy bắt đầu với interface. Luôn có thể thêm abstract class sau, nhưng migrate theo chiều ngược lại thì phải sửa nhiều hơn.

Tổng kết

OOP không phải bộ quy tắc để thuộc lòng — mà là công cụ để thiết kế code dễ đọc, dễ mở rộng và ít lỗi hơn. Trong thực tế, bốn tính chất này không tách rời nhau: encapsulation giữ state an toàn, inheritance và polymorphism giúp tái sử dụng và mở rộng linh hoạt, abstraction tách biệt “cái gì” khỏi “làm thế nào”.

Áp dụng đúng abstraction — biết khi nào dùng interface, khi nào dùng abstract class — cũng là nền tảng để hiểu nguyên tắc Dependency Inversion trong SOLID. Nếu chưa đọc, bạn có thể xem thêm ở bài SOLID Không Khó — Chỉ Là Bạn Chưa Thấy Đúng Ví Dụ.