Thừa kế

Kế thừa

Hai nguyên lý thừa kế và đa hình của lập trình hướng đối tượng giúp ta có thể xây dựng chương trình một cách nhanh chóng và hiệu quả hơn, thu được kết quả là những mô-đun chương trình mà các lập trình viên khác dễ mở rộng hơn, có khả năng đáp ứng tốt hơn đối với sự thay đổi liên tục của các yêu cầu của khách hàng.

QUAN HỆ THỪA KẾ

Nhớ lại ví dụ đầu tiên về lập trình hướng đối tượng. Trong đó, Dậu xây dựng 4 lớp: Square (hình vuông), Circle (đường tròn), Triangle (hình tam giác), và Amoeba (hình trùng biến hình). Cả bốn đều là các hình với hai phương thức rotate() và playSound(). Do đó, anh ta dùng tư duy trừu tượng hóa để tách ra các đặc điểm chung và đưa chúng vào một lớp mới có tên Shape (hình nói chung). Sau đó, kết nối các lớp hình vẽ kia với lớp Shape bởi một quan hệ gọi là thừa kế.

Ta nói rằng “Square thừa kế từ Shape”, “Circle thừa kế từ Shape”, v.v.. Ta tháo gỡ rotate() và playSound ra khỏi 4 loại hình, và giờ thì chỉ còn phải quản lý một bản đặt tại lớp Shape. Shape được gọi là lớp cha (superclass) hay lớp cơ sở (base class) của bốn lớp kia. Còn bốn lớp đó là các lớp con (subclass) hay lớp dẫn xuất (derived class) của lớp Shape. Các lớp con thừa kế các phương thức của lớp cha. Nói cách khác, nếu lớp Shape có chức năng gì thì các lớp con của nó tự động có các chức năng đó.

Vậy thế nào là quan hệ thừa kế? Nếu ta cần xây dựng các lớp đại diện cho hai loài mèo nhà và hổ, mèo nhà nên thừa kế từ hổ, hay hổ nên thừa kế từ mèo, hay cả hai cùng thừa kế từ một lớp thứ ba?

Khi ta dùng quan hệ thừa kế trong thiết kế, ta đặt các phần mã dùng chung tại một lớp và coi đó là lớp cha – lớp dùng chung trừu tượng hơn, các lớp cụ thể hơn là các lớp con. Các lớp con được thừa kế từ lớp cha đó. Quan hệ thừa kế có nghĩa rằng lớp con được thừa hưởng các thành viên (member) của lớp cha. Thành viên của một lớp là các biến thực thể và phương thức của lớp đó. Ví dụ, Shape trong ví dụ trên có hai thành viên rotate() và playSound(), Cow trong Hình 5.6 có các thành viên name, age, getName(), getAge(), setName(), setAge().

Ta còn nói rằng lớp con chuyên biệt hóa (specialize) lớp cha. Nghĩa của “chuyên biệt hóa” ở đây gồm có hai phần: (1) lớp con là một loại con của lớp cha – thể hiện ở chỗ lớp con tự động thừa hưởng các thành viên của lớp cha, (2) lớp con có những đặc điểm của riêng nó – thể hiện ở chỗ lớp con có thể bổ sung các phương thức và biến thực thể mới của riêng mình, và nó có thể cài đè (override) các phương thức thừa kế từ lớp cha. Ví dụ, hình trùng biến hình (Amoeba) cũng là một hình (Shape), do đó lớp con Amoeba có tất cả những gì mà Shape có. Ngoài ra, Amoeba có thêm những đặc điểm riêng của thể loại hình trùng biến hình: các biến thực thể đại diện cho tâm xoay để phục vụ cách xoay của riêng nó, và nó định nghĩa lại các phương thức rotate để xoay theo cách riêng, định nghĩa lại playSound để chơi loại âm thanh riêng. Theo thuật ngữ, và cũng là từ khóa, của Java, lớp con “nối dài” (extends) lớp cha.

Các biến thực thể không bị cài đè vì việc đó là không cần thiết. Biến thực thể không quy định một hành vi đặc biệt nào và lớp con chỉ việc gán giá trị tùy chọn cho biến được thừa kế.

THIẾT KẾ CÂY THỪA KẾ

Giả sử ta cần thiết kế một chương trình giả lập cho phép người dùng thả một đám các con động vật thuộc các loài khác nhau vào một môi trường để xem chuyện gì xảy ra. Ta hiện chưa phải viết mã mà mới chỉ ở giai đoạn thiết kế.

Ta biết rằng mỗi con vật sẽ được đại diện bởi một đối tượng, và các đối tượng sẽ di chuyển loanh quanh trong môi trường, thực hiện các hành vi được lập trình cho loài vật đó. Ta được giao một danh sách các loài vật sẽ được đưa vào chương trình: sư tử, hà mã, hổ, chó, mèo, sói.

Và ta muốn rằng, khi cần, các lập trình viên khác cũng có thể bổ sung các loài vật mới vào chương trình.

Bước 1, ta xác định các đặc điểm chung và trừu tượng mà tất cả các loài động vật đều có.

Các đặc điểm chung đó bao gồm:

  • năm biến thực thể:
    • picture – tên file ảnh đại diện cho con vật này
    • food – loại thức ăn mà con vật thích. Hiện giờ, biến này chỉ có hai giá trị: cỏ (grass) hoặc thịt (meat).
    • hunger – một biến int biểu diễn mức độ đói của con vật. Biến này thay đổi tùy theo khi nào con vật ăn và nó ăn bao nhiêu.
    • boundaries – các giá trị biểu diễn chiều dọc và chiều ngang (ví dụ 640 x 480) của khu vực mà các con vật sẽ đi lại hoạt động trong đó.
    • location – các tọa độ X và Y của con vật trong khu vực của nó.
  • và bốn phương thức:
    • makeNoise() – hành vi khi con vật phát ra tiếng kêu
    • eat() – hành vi khi con vật gặp nguồn thức ăn ưa thích, thịt hoặc cỏ.
    • sleep() – hành vi khi con vật được coi là đang ngủ.
    • roam() – hành vi khi con vật không phải đang ăn hay đang ngủ, có thể chỉ đi lang thang đợi gặp món gì ăn được hoặc gặp biên giới lãnh địa.

Bước 2, thiết kế một lớp với tất cả các thuộc tính và hành vi chung kể trên. Đây sẽ là lớp mà tất cả các lớp động vật đều có thể chuyên biệt hóa. Các đối tượng trong ứng dụng đều là các con vật (animal), do đó, ta sẽ gọi tên lớp cha chung của chúng là Animal. Ta đưa vào đó các phương thức và biến thực thể mà tất cả các con vật đều có thể cần. Kết quả là ta được lớp cha là lớp tổng quát hơn, hay nói cách khác là trừu tượng hơn, còn các lớp con mang tính đặc thù hơn, chuyên biệt hơn lớp cha.

Các con vật hoạt động có giống nhau không?

Ta đã biết rằng mỗi loại Animal đều có tất cả các biến thực thể đã khai báo cho Animal. Một con sư tử sẽ có các giá trị riêng cho picture, food, hunger, boundaries, và location. Một con hà mã sẽ có những giá trị khác cho bộ biến thực thể tương tự. Cũng như vậy đối với chó, hổ… Thế còn các hành vi của chúng thì sao?

Bước 3: Xác định xem các lớp con có cần các hành vi (cài đặt của các phương thức) đặc thù của thể loại con cụ thể đó hay không?

Để ý lớp Animal. Chắc chắn sư tử không ăn giống hà mã. Còn về tiếng kêu, ta có thể viết duy nhất một phương thức makeNoise tại Animal trong đó chơi một file âm thanh có tên là giá trị của một biến thực thể mà có giá trị khác nhau tùy loài, để con vật này kêu khác con vật khác. Nhưng làm vậy có vẻ chưa đủ vì tùy từng tình huống mà các loài khác nhau phát ra các tiếng kêu khác nhau, chẳng hạn tiếng kêu khi đang ăn và tiếng kêu khi gặp kẻ thù, v.v..

Do đó, ta quyết định rằng eat() và makeNoise() nên được cài đè tại từng lớp con. Tạm coi các con vật sleep và roam như nhau và không cần cài đè hai phương thức này. Ngoài ra, một số loài có những hành vi riêng đặc trưng của loài đó, chẳng hạn chó có thêm hành vi đuổi mèo (chaseCats()) bên cạnh các hành vi mà các loài động vật khác cũng có.

Bước 4: Tiếp tục dùng trừu tượng hóa tìm các lớp con có thể còn có hành vi giống nhau, với mục đích phân nhóm mịn hơn nếu cần.

Ví dụ, sói và chó có họ hàng gần, cùng thuộc họ Chó (canine) trong phân loại động vật học, chúng cùng có xu hướng di chuyển theo bầy đàn nên có thể dùng chung một phương thức roam(). Mèo, hổ và sư tử cùng thuộc họ Mèo (feline). Ba loài này có thể chung phương thức roam() vì khi di chuyển chúng cùng có xu hướng tránh đồng loại. Ta sẽ để cho hà mã tiếp tục dùng phương thức roam() tổng quát được thừa kế từ Animal.

Ta tạm hoàn thành thiết kế như trong hình sau và sẽ quay lại bài toán này trong chương sau.

CÀI ĐÈ – PHƯƠNG THỨC NÀO ĐƯỢC GỌI?

Lớp Wolf có bốn phương thức: sleep() được thừa kế từ Animal, roam() được thừa kế từ Canine (thực ra là phiên bản đè bản của Animal), và hai phương thức mà Wolf cài đè bản của Animal

  • makeNoise() và eat(). Khi ta tạo một đối tượng Wolf và gán một biến tham chiếu tới nó, ta có thể dùng biến đó để gọi cả bốn phương thức trên. Nhưng phiên bản nào của chúng đó sẽ được gọi?

Khi gọi phương thức từ một tham chiếu đối tượng, ta đang gọi phiên bản đặc thù nhất của phương thức đó đối với lớp của đối tượng cụ thể đó. Nếu hình dung cây thừa kế theo kiểu các lớp cha ở phía trên còn các lớp con ở phía dưới, thì quy tắc ở đây là: phiên bản thấp nhất sẽ được gọi. Trong ví dụ dùng biến w để gọi phương thức cho một đối tượng Wolf ở trên, thứ tự từ thấp lên cao lần lượt là Wolf, Canine, Animal. Khi gọi một phương thức cho một đối tượng Wolf, máy ảo Java bắt đầu tìm từ lớp Wolf lên, nếu nó không tìm được một phiên bản của phương thức đó tại Wolf thì nó chuyển lên tìm tại lớp tiếp theo bên trên Wolf ở cây thừa kế, cứ như vậy cho đến khi tìm thấy một phiên bản khớp với lời gọi phương thức. Với ví dụ đang xét, như được minh họa trong hình vẽ, w.makeNoise() sẽ dẫn đến việc kích hoạt phiên bản của Wolf, w.roam() gọi phiên bản của Canine, v.v..

7.4. CÁC QUAN HỆ IS-A VÀ HAS-A

Như đã trình bày trong các chương trước, khi một lớp kế thừa từ một lớp khác, ta nói rằng lớp con chuyên biệt hóa lớp cha. Nhưng liệu khi nào thì nên chuyên biệt hóa một lớp khác?

Nhớ lại rằng lớp cha là loại tổng quát, còn lớp con là loại cụ thể và chuyên biệt, là loại con của lớp cha. Nhìn từ khía cạnh khác, tập hợp các đối tượng mà lớp con đại diện là một tập con của các đối tượng mà lớp cha đại diện. Do đó, để đưa ra lựa chọn đúng đắn cho vấn đề nên hay không nên để lớp X là lớp chuyên biệt hóa lớp Y, ta có một phương pháp hiệu quả: kiểm tra quan hệ IS-A, nghĩa là xem thứ này có là thứ kia hay không.

Để xem X có nên là lớp con của Y hay không, ta đặt câu hỏi theo dạng “Nếu phát biểu một cách tổng quát rằng loại X là một dạng/thứ/kiểu của loại Y thì có lý hay không?”. Nếu câu trả lời là “Có”, thì X có thể là lớp con của Y.

Ví dụ: Tam giác  một hình (Triangle IS-A Shape)? Đúng. Mèo  một động vật họ Mèo (Cat IS-A Feline)? Đúng. Xe tải  một phương tiện giao thông (Truck IS-A Vehicle)? Đúng. Nghĩa là, Triangle có thể là lớp con của Shape, Cat có thể là lớp con của Feline, Truck có thể là lớp con của Vehicle.

Ta xét tiếp: Phòng bếp  một cái nhà (Kitchen IS-A House)? Chắc chắn sai. Ngược lại thì sao? Nhà là một phòng bếp (House IS-A Kitchen)? Đúng là có một số người vì phong tục hay điều kiện sống mà ngôi nhà của họ chỉ có một phòng duy nhất nên đó vừa là nơi nấu bếp vừa là phòng cho nhiều chức năng khác. Tuy nhiên, các trường hợp đó chỉ là “một số”, nên câu trả lời tổng quát vẫn là “Sai”. Cho nên, Kitchen không thể là lớp con của House hay ngược lại.

Phòng bếp và nhà rõ ràng có liên quan đến nhau, nhưng không phải qua quan hệ thừa kế mà là một quan hệ chứa – HAS-A. Câu hỏi ở đây là: Nhà có chứa một phòng bếp hay không (House HAS-A Kitchen)? Nếu câu trả lời là “Có”, điều đó có nghĩa House có một biến thực thể kiểu Kitchen. Nói cách khác, House có một tham chiếu tới một đối tượng Kitchen, chứ House không chuyên biệt hóa Kitchen hay ngược lại.

Quan hệ HAS-A trong Java được cài đặt bằng tham chiếu đặt tại đối tượng chứa chiếu tới đối tượng thành phần. Quan hệ HAS-A giữa hai lớp thể hiện một trong ba quan hệ: kết hợp (association), tụ hợp (aggregation) và hợp thành (composition) mà các tài liệu về thiết kế hướng đối tượng thường nói đến. Giữa hai lớp có quan hệ kết hợp nếu như các đối tượng thuộc lớp này cần biết đến đối tượng thuộc lớp kia để có thể thực hiện được công việc của mình. Chẳng hạn, một người nhân viên chịu sự quản lý của một người quản lý, ta có quan hệ kết hợp nối từ Employee tới Manager, thể hiện ở việc mỗi đối tượng Employee có một tham chiếu boss kiểu Manager. Hợp thành và tụ hợp là các quan hệ giữa một đối tượng và thành phần của nó (cũng là đối tượng). Khác nhau ở chỗ, với quan hệ hợp thành, đối tượng thành phần là phần không thể thiếu được của đối tượng chứa nó, còn với quan hệ tụ hợp thì ngược lại. Ví dụ, một cuốn sách bao gồm nhiều trang sách và một cuốn sách không thể tồn tại nếu không có trang nào. Do đó giữa Book (sách) và Page (trang) có quan hệ hợp thành. Thư viện có nhiều sách, nhưng thư viện không có cuốn sách nào vẫn là một thư viện, nên quan hệ giữa Library (thư viện) và Book là quan hệ tụ hợp. Java không có cấu trúc nào dành riêng để cài đặt các quan hệ tụ hợp hay hợp thành. Ta chỉ cài đặt đơn giản bằng cách đặt vào đối tượng chủ các tham chiếu tới đối tượng thành phần, hay nói cách khác là phân rã thành các quan hệ HAS-A, chẳng hạn quan hệ hợp thành giữa Book và Page có thể được phân rã thành ‘Book HAS-A ArrayList’ và nhiều quan hệ ‘ArrayList HAS-A Page’. Các ràng buộc khác được đảm bảo bởi các phương thức có nhiệm vụ khởi tạo hay sửa các tham chiếu đó.

Quay lại quan hệ IS-A, có một điểm cần lưu ý: quan hệ thừa kế IS-A chỉ có một chiều. Ví dụ: “Tam giác là một hình” là phát biểu có lý, nhưng khẳng định theo chiều ngược lại, “Hình là một tam giác”, thì không đúng. Có nhiều hình là hình tam giác, nhưng cũng có vô số hình không phải hình tam giác.

Thực ra, lưu ý trên là hiển nhiên, nếu ta nhớ đến mô tả về lớp con tại mục trước: Lớp con chuyên biệt hóa lớp cha.

Đến đây, chúng ta chưa kết thúc câu chuyện về quan hệ thừa kế. Chương sau sẽ tiếp tục trình bày về các vấn đề hướng đối tượng. Một số giải pháp thiết kế trong chương này sẽ được xem lại và cải tiến.

KHI NÀO NÊN DÙNG QUAN HỆ THỪA KẾ?

Mục này liệt kê một số quy tắc hướng dẫn việc sử dụng quan hệ thừa kế trong thiết kế. Tại thời điểm này, ta tạm bằng lòng với việc biết quy tắc. Việc hiểu quy tắc nếu chưa trọn vẹn thì sẽ được bồi đắp dần trong những phần sau của cuốn sách.

NÊN dùng quan hệ thừa kế khi một lớp là một loại cụ thể hơn của một lớp cha. Ví dụ, tài khoản tiết kiệm (saving account) là một loại tài khoản ngân hàng (bank account), nên SavingAccount là lớp con của BankAccount là hợp lí.

NÊN cân nhắc việc thừa kế khi ta có một hành vi (mã đã được viết) nên được dùng chung giữa nhiều lớp thuộc cùng một kiểu tổng quát nào đó. Ví dụ, Square, Circle và Triangle trong bài toán của Dậu và Tuất cùng cần xoay và chơi nhạc, nên việc đặt các chức năng đó tại một lớp cha Shape là hợp lí. Tuy vậy, cần lưu ý rằng mặc dù thừa kế là một trong những đặc điểm quan trọng của lập trình hướng đối tượng nhưng nó không nhất thiết là cách tốt nhất cho việc tái sử dụng hành vi. Quan hệ thừa kế giúp ta khởi động việc tái sử dụng, và nó thường là lựa chọn đúng khi thiết kế, nhưng các mẫu thiết kế sẽ giúp ta nhận ra những lựa chọn khác tinh tế và linh hoạt hơn.

KHÔNG NÊN dùng thừa kế chỉ nhằm mục đích tái sử dụng mã của một lớp khác, trong khi quan hệ giữa lớp cha và lớp con vi phạm một trong hai quy tắc ở trên. Ví dụ, giả sử ta đã viết cho lớp DoorBell (chuông cửa) một đoạn mã dành riêng cho việc in, và giờ ta cần viết mã cho chức năng in của lớp Piano. Không nên vì nhu cầu đó mà cho Piano làm lớp con của DoorBell. Đàn piano không phải là một loại chuông gọi cửa. (Giải pháp nên chọn cho tình huống này là: phần mã cho chức năng in nên được đặt trong một lớp Printer, và các lớp cần có chức năng in sẽ hưởng lợi từ lớp Printer đó qua một quan hệ HAS-A.)

KHÔNG NÊN dùng quan hệ thừa kế nếu lớp con và lớp cha không qua được thử nghiệm IS-A. Hãy tự kiểm tra xem lớp con có phải là một kiểu chuyên biệt của lớp cha hay không. Ví dụ: Bike IS-A Vehicle (xe đạp là một phương tiện giao thông) hợp lí. Nhưng Vehicle IS-A Bike (phương tiện giao thông là một loại xe đạp) thì không được.

LỢI ÍCH CỦA QUAN HỆ THỪA KẾ

Quan hệ thừa kế trong thiết kế mang lại cho ta rất nhiều điều.

Lợi ích thứ nhất: tránh lặp các đoạn mã bị trùng lặp. Ta có thể loại bỏ được những đoạn mã trùng lặp bằng cách tách ra các hành vi chung của một nhóm các lớp đối tượng và đưa phần mã đó vào một lớp cha. Nhờ đó, khi ta cần sửa nó, ta chỉ cần cập nhật mã ở duy nhất một nơi, và sửa đổi đó có hiệu lực tại tất cả các lớp kế thừa hành vi đó. Công việc gói gọn trong việc sửa và dịch lớp cha. Tóm lại: ta không phải động đến các lớp con!

Với ngôn ngữ Java, chương trình là một tập các lớp. Do đó, ta không cần phải dịch lại các lớp con để có thể dùng được phiên bản mới của lớp cha. Đòi hỏi duy nhất là phiên bản mới của lớp cha không phá vỡ cái gì của lớp con. Nghĩa cụ thể của từ “phá vỡ” trong ngữ cảnh trên sẽ được trình bày chi tiết sau. Tạm thời, ta chỉ cần hiểu rằng hành động đó có nghĩa là sửa cái gì đó tại lớp cha mà lớp con bị phụ thuộc vào, chẳng hạn như sửa kiểu tham số, hay kiểu trả về, hoặc tên của một phương thức nào đó.

Lợi ích thứ hai: ta định nghĩa được một giao thức chung cho tập các lớp gắn kết với nhau bởi quan hệ thừa kế. Quan hệ thừa kế cho phép ta đảm bảo rằng tất cả các lớp con của một lớp đều có tất cả các phương thức1 mà lớp đó có. Đó là một dạng giao thức mà lớp đó tuyên bố với tất cả các phần mã khác rằng: “Tất cả các thể loại con của tôi (nghĩa là các lớp con) đều có thể làm những việc này, với các phương thức trông như thế này…”. Nói cách khác, ta thiết lập một hợp đồng (contract).

Lưu ý rằng, khi nói về Animal bất kì, ý ta đang nói về đối tượng Animal hay đối tượng thuộc bất cứ lớp nào có Animal là tổ tiên trong cây phả hệ. Khi ta định nghĩa một kiểu tổng quát (lớp cha) cho một nhóm các lớp, bất cứ lớp con nào trong nhóm đó đều có thể dùng thay cho vị trí của lớp cha. Ta đã có Wolf là một loại con của Animal; một đối tượng Wolf có tất cả các thành viên mà một đối tượng Animal có. Vậy thì lô-gic hiển nhiên: một đối tượng Wolf có thể được coi là thuộc loại Animal; nơi nào dùng được Animal thì cũng dùng được Wolf.


Hãy tham gia nhóm Học lập trình để thảo luận thêm về các vấn đề cùng quan tâm.

Leave a Reply

Your email address will not be published. Required fields are marked *