Khám phá công nghệ

Khám phá các công cụ và kỹ thuật hữu ich cho Lập trình viên

Tính Đa hình (Polymorphism) trong Java - Java OOP

Thắng Nguyễn

Sun, 04 May 2025

 Tính Đa hình (Polymorphism) trong Java - Java OOP

Từ "Polymorphism" có nguồn gốc từ tiếng Hy Lạp, với "poly" nghĩa là "nhiều" và "morph" nghĩa là "hình dạng". Do đó, Polymorphism có nghĩa là "nhiều hình dạng". Trong ngữ cảnh OOP, nó thể hiện khả năng của một đối tượng, một biến tham chiếu, hoặc một phương thức có thể tồn tại dưới nhiều hình thức khác nhau hoặc thực hiện một hành động theo nhiều cách khác nhau tùy thuộc vào ngữ cảnh.

Hãy tưởng tượng hành động "phát ra âm thanh". Con chó sẽ "gâu gâu", con mèo sẽ "meo meo", con vịt sẽ "quạc quạc". Cùng là hành động "phát ra âm thanh" nhưng mỗi loài vật lại thực hiện theo cách riêng của nó. Đó chính là bản chất của tính đa hình – một hành động chung (phương thức) có thể có nhiều cách triển khai (hình dạng) khác nhau.

Trong Java, tính đa hình cho phép chúng ta:

  1. Tăng tính linh hoạt và khả năng mở rộng: Dễ dàng thêm các lớp mới (hình dạng mới) mà không cần sửa đổi nhiều ở code hiện có đang sử dụng các hành động chung.
  2. Giảm sự phức tạp của code: Tránh được việc phải viết nhiều câu lệnh if-else hoặc switch-case để kiểm tra kiểu đối tượng trước khi gọi hành động tương ứng.
  3. Tăng khả năng tái sử dụng code: Các phương thức có thể làm việc với nhiều kiểu đối tượng khác nhau miễn là chúng tuân theo một "hợp đồng" chung (thông qua kế thừa hoặc interface).

Các loại Đa hình trong Java

Java hỗ trợ hai loại đa hình chính:

  1. Đa hình lúc biên dịch (Compile-time Polymorphism / Static Polymorphism): Được xác định tại thời điểm biên dịch.
  2. Đa hình lúc chạy (Runtime Polymorphism / Dynamic Polymorphism): Được xác định tại thời điểm thực thi chương trình.

1. Đa hình lúc biên dịch (Compile-time Polymorphism) - Nạp chồng phương thức (Method overloading)

Đây là loại đa hình được giải quyết bởi trình biên dịch (compiler) dựa trên "chữ ký" của phương thức. Nó đạt được thông qua Nạp chồng phương thức (Method Overloading).

Nạp chồng phương thức (Method Overloading):

  • Là việc định nghĩa nhiều phương thức có cùng tên trong cùng một lớp, nhưng có danh sách tham số khác nhau.
  • Sự khác biệt về tham số có thể là:
    • Khác nhau về số lượng tham số.
    • Khác nhau về kiểu dữ liệu của tham số.
    • Khác nhau về thứ tự của các tham số (nếu kiểu dữ liệu khác nhau).
  • Kiểu trả về của phương thức không được dùng để phân biệt các phương thức nạp chồng. Trình biên dịch chỉ dựa vào tên phương thức và danh sách tham số.
  • Trình biên dịch sẽ xác định phương thức nào cần gọi dựa trên đối số (arguments) được truyền vào khi lời gọi phương thức diễn ra. Quá trình này gọi là ràng buộc tĩnh (static binding).

Mục đích: Cho phép thực hiện các tác vụ tương tự nhưng với các loại hoặc số lượng dữ liệu đầu vào khác nhau, giúp code dễ đọc và dễ sử dụng hơn.

Ví dụ về Nạp chồng phương thức:

class Calculator {

    // Nạp chồng phương thức add
    public int add(int a, int b) {
        System.out.println("Gọi add(int, int)");
        return a + b;
    }

    public double add(double a, double b) {
        System.out.println("Gọi add(double, double)");
        return a + b;
    }

    public int add(int a, int b, int c) {
        System.out.println("Gọi add(int, int, int)");
        return a + b + c;
    }

    public String add(String s1, String s2) {
        System.out.println("Gọi add(String, String)");
        return s1 + s2;
    }

    // Ví dụ: Thay đổi thứ tự tham số với kiểu khác nhau
    public void process(int id, String name) {
        System.out.println("Processing ID: " + id + ", Name: " + name);
    }

    public void process(String name, int id) {
        System.out.println("Processing Name: " + name + ", ID: " + id);
    }

     // Lỗi biên dịch! Chỉ khác kiểu trả về là không đủ để nạp chồng
    /*
    public String add(int x, int y) {
         return "Sum: " + (x + y);
    }
    */
}

public class OverloadingDemo {
    public static void main(String[] args) {
        Calculator calc = new Calculator();

        System.out.println("Tổng 1: " + calc.add(5, 10));          // Gọi add(int, int)
        System.out.println("Tổng 2: " + calc.add(3.5, 2.5));        // Gọi add(double, double)
        System.out.println("Tổng 3: " + calc.add(1, 2, 3));        // Gọi add(int, int, int)
        System.out.println("Chuỗi nối: " + calc.add("Hello", " World")); // Gọi add(String, String)

        calc.process(101, "Alice"); // Gọi process(int, String)
        calc.process("Bob", 202);   // Gọi process(String, int)
    }
}

Trong ví dụ này, trình biên dịch biết chính xác phiên bản nào của phương thức add hoặc process cần gọi dựa vào các đối số được cung cấp tại thời điểm viết code.

2. Đa hình lúc chạy (Runtime Polymorphism) - Ghi đè phương thức (Method overriding)

Đây là dạng đa hình mạnh mẽ và thường được nhắc đến nhiều nhất khi nói về Polymorphism trong OOP. Nó liên quan đến kế thừa (inheritance)giao diện (interface), và được thực hiện thông qua Ghi đè phương thức (Method Overriding).

Ghi đè phương thức (Method Overriding):

  • Là khi một lớp con (subclass) cung cấp một triển khai cụ thể cho một phương thức đã được định nghĩa ở lớp cha (superclass) hoặc interface mà nó kế thừa/triển khai.
  • Phương thức ở lớp con phải có cùng tên, cùng danh sách tham số (số lượng, kiểu, thứ tự)cùng kiểu trả về (hoặc kiểu trả về con - covariant return type) với phương thức ở lớp cha/interface.
  • Quyền truy cập (access modifier) của phương thức ghi đè không được phép hạn chế hơn quyền truy cập của phương thức bị ghi đè (ví dụ: nếu ở lớp cha là protected, thì ở lớp con có thể là protected hoặc public, nhưng không thể là private hoặc default).
  • Phương thức finalstatic không thể bị ghi đè.
  • Annotation @Override được khuyến khích sử dụng để trình biên dịch kiểm tra xem phương thức có thực sự ghi đè một phương thức từ lớp cha/interface hay không.

Cách hoạt động (Điều phối phương thức động - Dynamic Method Dispatch):

  1. Upcasting: Bạn tạo một biến tham chiếu kiểu lớp cha (hoặc interface) nhưng lại trỏ đến một đối tượng của lớp con.
    SuperClass ref = new SubClass();
    // Hoặc
    Interface ref = new ImplementingClass();
    
  2. Gọi phương thức: Khi bạn gọi một phương thức bị ghi đè thông qua biến tham chiếu kiểu lớp cha/interface (ref.overriddenMethod();), Máy ảo Java (JVM) sẽ xác định phiên bản nào của phương thức (của lớp cha hay lớp con) sẽ được thực thi tại thời điểm chạy (runtime), dựa trên kiểu đối tượng thực tế mà biến tham chiếu đang trỏ tới. Quá trình này gọi là ràng buộc động (dynamic binding) hay điều phối phương thức động (dynamic method dispatch).

Mục đích: Cho phép các lớp con cung cấp hành vi chuyên biệt cho các phương thức được kế thừa, trong khi vẫn giữ được "hợp đồng" chung được định nghĩa bởi lớp cha hoặc interface. Điều này tạo ra sự linh hoạt lớn, cho phép xử lý các đối tượng khác nhau một cách thống nhất.

Ví dụ về Ghi đè phương thức và Đa hình lúc chạy:

// Sử dụng lại ví dụ Shape từ bài Abstraction (hoặc định nghĩa mới)
abstract class Animal {
    String name;

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

    // Phương thức sẽ được ghi đè bởi các lớp con
    public abstract void makeSound(); // Hoặc là phương thức cụ thể nếu có âm thanh mặc định

    public void eat() {
        System.out.println(name + " is eating."); // Hành vi chung
    }
}

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

    // Ghi đè phương thức makeSound
    @Override
    public void makeSound() {
        System.out.println(name + " says: Woof woof!");
    }
}

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

    // Ghi đè phương thức makeSound
    @Override
    public void makeSound() {
        System.out.println(name + " says: Meow!");
    }
}

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

     // Ghi đè phương thức makeSound
    @Override
    public void makeSound() {
        System.out.println(name + " says: Quack quack!");
    }
}

public class RuntimePolymorphismDemo {
    public static void main(String[] args) {
        // Upcasting: Tham chiếu kiểu Animal trỏ đến đối tượng cụ thể
        Animal myPet1 = new Dog("Buddy");
        Animal myPet2 = new Cat("Whiskers");
        Animal myPet3 = new Duck("Donald");

        // Mảng chứa các đối tượng Animal khác nhau
        Animal[] pets = { myPet1, myPet2, myPet3, new Dog("Lucy") };

        System.out.println("--- Making sounds ---");
        // Gọi cùng một phương thức makeSound() trên các đối tượng khác nhau
        // JVM sẽ quyết định phiên bản nào được thực thi tại runtime
        myPet1.makeSound(); // Gọi makeSound() của Dog
        myPet2.makeSound(); // Gọi makeSound() của Cat
        myPet3.makeSound(); // Gọi makeSound() của Duck

        System.out.println("\n--- Pets in array ---");
        for (Animal pet : pets) {
            pet.makeSound(); // Đa hình lúc chạy!
            pet.eat();       // Gọi phương thức kế thừa từ Animal
            System.out.println("-----");
        }
    }
}

Trong ví dụ này, mặc dù myPet1, myPet2, myPet3 đều là biến tham chiếu kiểu Animal, nhưng khi gọi makeSound(), JVM đã gọi đúng phương thức được ghi đè trong các lớp Dog, Cat, Duck tương ứng. Vòng lặp for xử lý mảng pets thể hiện rõ sức mạnh của đa hình: cùng một lời gọi pet.makeSound() nhưng kết quả lại khác nhau tùy thuộc vào đối tượng thực tế mà pet đang tham chiếu tới tại thời điểm đó.

3. Ép kiểu lên (Upcasting) và Ép kiểu cuống (Downcasting)

Hai khái niệm này liên quan chặt chẽ đến đa hình lúc chạy:

  • Ép kiểu lên (Upcasting): Là việc ép kiểu một đối tượng từ lớp con lên lớp cha hoặc interface mà nó triển khai. Đây là hành động ngầm địnhan toàn, vì đối tượng lớp con luôn là một (is-a) phiên bản của lớp cha/interface. Upcasting chính là nền tảng cho đa hình lúc chạy như đã thấy ở ví dụ trên.

    Dog myDog = new Dog("Rex");
    Animal myAnimal = myDog; // Upcasting ngầm định (từ Dog lên Animal)
    
  • Ép kiểu xuống (Downcasting): Là việc ép kiểu một tham chiếu từ lớp cha/interface xuống lớp con cụ thể. Đây là hành động tường minhcó thể không an toàn, vì không phải lúc nào một đối tượng được tham chiếu bởi lớp cha cũng thực sự là đối tượng của lớp con cụ thể đó. Cần sử dụng toán tử instanceof để kiểm tra kiểu trước khi ép kiểu xuống để tránh lỗi ClassCastException.

    Animal anotherPet = new Cat("Tom"); // Upcasting
    
    // Kiểm tra trước khi ép kiểu xuống
    if (anotherPet instanceof Cat) {
        Cat myCat = (Cat) anotherPet; // Downcasting tường minh và an toàn
        myCat.makeSound();
        // myCat.climbTree(); // Giả sử Cat có phương thức riêng
    } else if (anotherPet instanceof Dog) {
         Dog myDog = (Dog) anotherPet; // Downcasting
         myDog.makeSound();
         // myDog.fetchStick(); // Giả sử Dog có phương thức riêng
    }
    
    Downcasting thường cần thiết khi bạn muốn gọi các phương thức hoặc truy cập các thuộc tính chỉ có ở lớp con mà không có ở lớp cha/interface.

Lợi ích của Tính đa hình

  • Linh hoạt (Flexibility): Cho phép đối tượng thể hiện nhiều hành vi khác nhau thông qua cùng một interface (phương thức).
  • Khả năng mở rộng (Extensibility): Dễ dàng thêm các lớp con mới với hành vi riêng mà không cần sửa đổi code đã sử dụng interface/lớp cha. Chỉ cần lớp mới đó ghi đè các phương thức cần thiết.
  • Khả năng tái sử dụng (Reusability): Code viết cho lớp cha/interface có thể hoạt động với bất kỳ đối tượng nào của lớp con mà không cần biết kiểu cụ thể của chúng.
  • Code rõ ràng, dễ đọc hơn: Giảm thiểu việc sử dụng các cấu trúc điều kiện if-else hoặc switch dựa trên kiểu đối tượng, làm code ngắn gọn và logic mạch lạc hơn.

Kết luận

Tính đa hình là một khái niệm cực kỳ mạnh mẽ trong Java và OOP nói chung. Nó cho phép chúng ta viết code linh hoạt hơn, dễ mở rộng và dễ bảo trì hơn bằng cách cho phép các đối tượng khác nhau phản hồi theo cách riêng của chúng đối với cùng một thông điệp (lời gọi phương thức). Hiểu và vận dụng thành thạo cả đa hình lúc biên dịch (nạp chồng) và đặc biệt là đa hình lúc chạy (ghi đè) là chìa khóa để thiết kế và xây dựng các ứng dụng Java hiệu quả và chuyên nghiệp.

0 Comments

Để lại một bình luận