Đa thừa kế và vấn đề hình thoi

Cây thừa kế động vật vốn được thiết kế để dùng cho bài toán giả lập môi trường sống của động vật. Nếu cần xây dựng phần mềm dạy học cho môn động vật học, ta sẽ tái sử dụng được các lớp trong cây thừa kế đó. Giả sử bây giờ ta mới nhận được yêu cầu xây dựng phần mềm PetShop cho cửa hàng thú cảnh, và ta muốn dùng lớp Dog cho phần mềm mới. Hiện tại các lớp động vật chưa có các hành vi của thú cảnh (Pet) như play() và beFriendly(). Với vai trò lập trình viên cho lớp Dog, ta sẽ làm gì? Chỉ việc thêm những phương thức cần thiết? Làm vậy ta sẽ không phá vỡ mã của bất kì ai khác vì ta không động đến các phương thức đã có sẵn mà mã của người khác có thể gọi cho các đối tượng Dog.

Đúng nhưng chưa đủ. Lưu ý rằng đây là phần mềm cho cửa hàng thú cảnh, ở đó không chỉ có chó, ta sẽ không chỉ cần đến lớp Dog. Việc bổ sung các phương thức mới vào Dog, do đó, có những nhược điểm gì?

Ta lần lượt xét từng phương án:

Phương án 1: đặt các hành vi thú cảnh tại lớp Animal.

Ưu điểm: Tất cả các lớp động vật lập tức có các hành vi thú cảnh. Ta không phải sửa các lớp khác, và các lớp con sẽ được tạo trong tương lai cũng được thừa kế. Lớp Animal có thể dùng làm kiểu đa hình trong chương trình muốn đối xử đồng loạt các đối tượng Animal như là thú cảnh.

Nhược điểm: Hà mã, sư tử, chó sói hầu như chắc chắn không phải thú cảnh nên Hippo, Lion, và Wolf không nên có các hành vi thú cảnh. Kể cả nếu cài đè các hành vi thú cảnh tại các lớp này để chúng ‘không làm gì’ thì vẫn không ổn, vì khi đó hợp đồng của các lớp Hippo, Lion,… cho những đối tượng không bao giờ là thú cảnh vẫn có những hành vi của thú cảnh.

Đây là cách tiếp cận tồi. Ta không nên đưa vào lớp Animal những thứ không áp dụng cho tất cả các lớp con của nó.

Phương án 2: chỉ đặt các hành vi thú cảnh tại các lớp cần đến nó.

Ưu điểm: Không còn rắc rối về chuyện hà mã làm thú cảnh. Dog và Cat có thể cài các phương thức đó và các lớp khác không bị liên lụy.

Nhược điểm: Hai vấn đề nghiêm trọng: Thứ nhất, phải có giao thức chung mà từ nay trở đi tất cả các lập trình viên cho các lớp Animal phải biết. Giao thức đó bao gồm các phương thức mà ta quyết định rằng tất cả các lớp thú cảnh phải có, tên là gì, trả về kiểu gì, đối số kiểu gì. Nói cách khác là hợp đồng của thú cảnh. Và ta hiện không có cách gì để đảm bảo sẽ không có ai nhầm.

Thứ hai, ta không có đa hình cho các phương thức thú cảnh đó. Không thể dùng tham chiếu Animal cho các phương thức thú cảnh.

Tóm lại, ta cần gì?

  • Đặt hành vi thú cảnh tại các lớp thú cảnh và chỉ tại đó mà thôi.
  • Đảm bảo rằng tất cả các lớp thú cảnh hiện có cũng như sẽ được viết sẽ phải có tất cả các phương thức đã được quy định (tên, đối số, kiểu trả về…) mà không phải ngồi hy vọng rằng ai đó sẽ làm đúng.
  • Tận dụng được lợi thế của đa hình, sao cho có thể gọi được phương thức của tất cả các loại thú cảnh mà không phải dùng riêng các kiểu đối số, kiểu trả về, dùng từng mảng riêng cho từng loại một.

Có vẻ như ta cần đến HAI lớp cha trong cây thừa kế.

Khi lớp con thừa kế từ nhiều hơn một lớp cha, ta có tình trạng được gọi là “đa thừa kế”. Hình thức đa thừa kế này có tiềm năng gây ra một rắc rối nghiêm trọng được gọi là Vấn đề Hình thoi (the Diamond problem) như ví dụ trong hình dưới đây. Trong ví dụ đó, hai lớp DVDBurner (thiết bị ghi đĩa DVD) và CDBurner (thiết bị ghi đĩa CD) cùng là lớp con của DigitalRecorder (đầu thu kĩ thuật số), cả hai cài đè phương thức burn() và cùng thừa kế biến thành viên i. Giả sử biến i được dùng tại DVDBurner cũng như CDBurner, nhưng với các giá trị khác nhau. Chuyện gì xảy ra nếu ComboDrive – lớp con thừa kế cả hai lớp trên – cần dùng đến cả hai giá trị i đó? Còn nữa, khi gọi phương thức burn() cho một đối tượng ComboDrive, phiên bản burn() nào sẽ được chạy?

Ngôn ngữ lập trình nào cho phép đa thừa kế sẽ phải giải quyết những tình trạng rối rắm trên, sẽ phải có những quy tắc đặc biệt để xử lý những tình huống nhập nhằng ngữ nghĩa có thể xảy ra. C++ là một trong những ngôn ngữ như vậy. Java được thiết kế theo tiêu chí đơn giản, nên nó không cho phép một lớp được thừa kế từ nhiều hơn một lớp cha.

Vậy ta phải giải quyết bài toán thú cảnh như thế nào với Java?

Interface

Giải pháp mà Java cung cấp là interface. Thuật ngữ interface của tiếng Anh thường được dùng với nghĩa ‘giao diện’, chẳng hạn như “giao diện người dùng”, hay như trong câu “Các phương thức public của một lớp là giao diện của nó đối với bên ngoài”. Tuy nhiên, trong mục này, ta nói đến khái niệm interface với ý nghĩa là một cấu trúc lập trình của Java được định nghĩa với từ khóa interface (tương tự như cấu trúc lớp được định nghĩa với từ khóa class).

Cấu trúc interface này cho phép ta giải quyết bài toán đa thừa kế, cho ta hưởng phần lớn các ích lợi mang tính đa hình mà đa thừa kế mang lại, nhưng tránh cho ta các rắc rối nhập nhằng ngữ nghĩa như đã giới thiệu trong mục trước.

Nguy cơ nhập nhằng ngữ nghĩa được tránh bằng cách rất đơn giản: phương thức nào cũng phải trừu tượng! Theo đó, lớp con buộc phải cài đặt các phương thức. Nhờ vậy, khi chương trình chạy, máy ảo Java không phải bối rối lựa chọn giữa hai phiên bản mà một đối tượng được thừa kế.

Một interface, do đó, giống như một lớp thuần túy trừu tượng bao gồm toàn các phương thức trừu tượng và không có biến thực thể. Nhưng về cú pháp thì interface có khác lớp trừu tượng một chút. Để định nghĩa một interface, ta dùng từ khóa interface thay vì class như đối với lớp:

public interface Pet {…}

Đối với một lớp trừu tượng, ta cần tạo lớp con cụ thể. Còn đối với một interface, ta tạo lớp cài đặt các phương thức trừu tượng mà interface đó đã quy định. Lớp đó được gọi là lớp cài đặt interface mà ta đang nói đến.

Để khai báo rằng một lớp cài đặt một interface, ta dùng từ khóa implements thay vì extends, theo sau là tên của interface.

Một lớp có thể cài đặt một vài interface và đồng thời là lớp con của một lớp khác. Chẳng hạn lớp Dog vừa là lớp con của Canine, vừa là lớp cài đặt interface Pet:

class Dog extends Canine implements Pet {…}

Dưới đây là ví dụ cụ thể về interface Pet và lớp Dog cài đặt Pet. Các phương thức của interface đều ngầm định là public và abstract, do đó ta không bắt buộc phải dùng hai từ khóa public abstract khi khai báo các phương thức. Do là các phương thức trừu tượng nên chúng không có thân mà chỉ có một dấu chấm phảy ở cuối dòng khai báo. Trong lớp Dog có hai loại phương thức: các phương thức cài đặt interface Pet, và các phương thức cài đè lớp cha Canine như thông thường.

Interface Pet:

public interface Pet {
    public abstract void beFriendly();
    public abstract void play();
}

Lớp Dog:

public class Dog extends Canine implements Pet {
    public void beFriendly() {...};
    public void play() {...};
    
    public void roam() {...};
    public void eat() {...};
}

Như vậy ta có thể dùng cấu trúc interface để thực hiện một thứ gần giống đa thừa kế. Nó không hẳn là đa thừa kế ở chỗ: khác với lớp trừu tượng, ta không thể đặt mã cài đặt tại các interface.

Khi các phương thức tại interface đều trừu tượng, và do đó không thể tái sử dụng, ta được ích lợi gì ở đây? Câu trả lời là đa hình và đa hình. Khi ta dùng một interface thay cho các lớp riêng biệt làm tham số và giá trị trả về của phương thức, ta có thể truyền lớp bất kì nào cài đặt interface đó vào vị trí của tham số hay giá trị trả về đó. Không chỉ có vậy, các lớp nằm trên các cây thừa kế khác nhau có thể cùng cài đặt một interface.

Trong thực tế, đối với đa số thiết kế tốt, việc interface không thể chứa mã cài đặt không phải là vấn đề. Lí do là hầu hết các phương thức của interface có đặc điểm là không thể được cài đặt một cách tổng quát, đằng nào cũng phải cài đè các phương thức này ngay cả nếu chúng không bị buộc phải là phương thức trừu tượng.

Quay trở lại với ý rằng các lớp nằm trên các cây thừa kế khác nhau có thể cùng cài đặt một interface. Ta có ví dụ sau: Chó máy RoboDog là một loại robot và cũng là một loại thú cảnh. Lớp RoboDog thuộc cây thừa kế Robot chứ không thuộc cây Animal. Tuy nhiên, nó cũng có thể cài interface Pet như Cat và Dog.

Không chỉ có vậy, mỗi lớp còn có thể cài đặt nhiều hơn một interface. Sự linh hoạt của interface là đặc điểm vô cùng quan trọng đối với Java API. Ví dụ, để một lớp đối tượng ở bất cứ đâu trên một cây thừa kế có thể được lưu ra file, ta có thể cho lớp đó cài interface Serializable.

Khi nào nên cho một lớp là lớp độc lập, lớp con, lớp trừu tượng, hay nên biến nó thành interface?

  • Một lớp nên là lớp độc lập, nghĩa là nó không thừa kế lớp nào (ngoại trừ Object) nếu nó không thỏa mãn kiểm tra IS-A đối với bất cứ loại nào khác.
  • Một lớp nên là lớp con nếu ta cần cho nó làm một phiên bản chuyên biệt hơn của một lớp khác và cần cài đè hành vi có sẵn hoặc bổ sung hành vi mới.
  • Một lớp nên là lớp cha nếu ta muốn định nghĩa một khuôn mẫu cho một nhóm các lớp con, và ta có một chút mã cài đặt mà tất cả các lớp con kia có thể sử dụng. Cho lớp đó làm lớp trừu tượng nếu ta muốn đảm bảo rằng không ai được tạo đối tượng thuộc lớp đó.
  • Dùng một interface nếu ta muốn định nghĩa một vai trò mà các lớp khác có thể nhận, bất kể các lớp đó thuộc cây thừa kế nào.

Những điểm quan trọng:

  • Khi muốn cấm tạo đối tượng từ một lớp, ta dùng từ khóa abstract tại định nghĩa lớp để tuyên bố lớp đó là lớp trừu tượng.
  • Một lớp trừu tượng có thể có các phương thức trừu tượng cũng như không trừu tượng.
  • Nếu một lớp có dù chỉ một phương thức trừu tượng, lớp đó buộc phải là lớp trừu tượng. Một phương thức trừu tượng không có thân, khai báo phương thức đó kết thúc bằng dấu chấm phảy.
  • Một lớp cụ thể phải cài đặt hoặc được thừa kế cài đặt của tất cả các phương thức trừu tượng.
  • Mỗi lớp trong Java đều là lớp con trực tiếp hoặc gián tiếp của lớp Object.
  • Nếu ta dùng một tham chiếu để gọi phương thức, tham chiếu đó được khai báo thuộc lớp gì hay interface gì thì ta chỉ được gọi các phương thức có trong lớp đó hoặc interface đó, bất kể đối tượng mà tham chiếu đó đang chiếu tới là đối tượng thuộc lớp nào.
  • Một biến tham chiếu lớp cha có thể được gán giá trị là tham chiếu kiểu lớp con bất kì mà không cần đổi kiểu. Có thể dùng phép đổi kiểu để gán giá trị là tham chiếu kiểu lớp cha cho một biến tham chiếu kiểu lớp con, tuy nhiên khi chạy chương trình, phép đổi kiểu đó sẽ thất bại nếu đối tượng đang được chiếu tới không thuộc kiểu tương thích với phép đổi kiểu.
  • Java không hỗ trợ đa thừa kế do vấn đề Hình thoi. Java chỉ cho phép mỗi lớp chỉ có duy nhất một lớp cha.
  • Một interface tương tự với một lớp thuần túy trừu tượng. Nó chỉ định nghĩa các phương thức trừu tượng.
  • Một lớp có thể cài đặt nhiều interface.
  • Lớp nào cài đặt một interface thì phải cài tất cả các phương thức của interface đó, do tất cả các phương thức interface đều là các phương thức trừu tượng public.

Bài viết liên quan

Leave a Reply

Your email address will not be published.