Đa hình

Kế thừa

Ta bắt đầu chạm đến phần thú vị nhất của lập trình hướng đối tượng: đa hình.

Đa hình

Trước khi trình bày về đa hình, ta nhắc lại một chút về cách khai báo một tham chiếu và tạo một đối tượng.

Trong ví dụ trên, tham chiếu w được khai báo bằng lệnh Wolf w, đối tượng lớp Wolf được khai báo bằng lệnh new Wolf. Điểm đáng chú ý là kiểu của biến tham chiếu và kiểu của đối tượng cùng là Wolf.

Với đa hình thì sao? Đây là ví dụ: w được khai báo thuộc kiểu Animal, trong khi đối tượng vẫn được tạo theo kiểu Wolf:

Với đa hình, tham chiếu có thể thuộc kiểu lớp cha của lớp của đối tượng được tạo. Khi ta khai báo một biến tham chiếu thuộc kiểu lớp cha, nó có thể được gắn với bất cứ đối tượng nào thuộc một trong các lớp con.

Đặc tính này cho phép ta có những thứ thú vị kiểu như mảng đa hình. Ví dụ, trong Hình 7.2, ta khai báo một mảng kiểu Animal, nghĩa là một mảng để chứa các đối tượng thuộc loại Animal. Nhưng sau đó ta lại gắn vào mảng các đối tượng thuộc các lớp con tùy ý của Animal. Và vòng lặp duyệt mảng sau đó là phần thú vị nhất liên quan đến đa hình – ý trọng tâm của ví dụ. Tại đó, ta duyệt từ đầu đến cuối mảng, với mỗi phần tử mảng, ta gọi một trong các phương thức Animal từ tham chiếu kiểu Animal. Khi i chạy từ 0 tới 4, animals[i] lần lượt chiếu tới một đối tượng Dog, Cat, Wolf, Hippo, Lion. Kết quả của animals[i].eat() hay animals[i].roam() đều là: mỗi đối tượng thực hiện đúng phiên bản thích hợp với loại của chính mình.

Tính đa hình còn có thể thể hiện ở kiểu dữ liệu của đối số và giá trị trả về.

Trong ví dụ trên, tại phương thức giveShot(), tham số Animal chấp nhận đối số thuộc kiểu Animal bất kì. Đoạn mã bên dưới đã gọi giveShot() lần lượt với đối số là các đối tượng Dog và Cat. Sau khi bác sĩ thú y (Vet) tiêm xong, makeNoise() được gọi từ trong phương thức giveShot() cho đối tượng Animal mà a đang chiếu tới. Mặc dù a là tham chiếu thuộc kiểu Animal, nhưng đối tượng nó chiếu tới thuộc lớp nào quyết định phiên bản makeNoise() nào được chạy. Kết quả là phiên bản của Dog được chạy cho đối tượng Dog, và phiên bản của Cat được chạy cho đối tượng Cat.

Như vậy, với đa hình, ta có thể viết những đoạn mã không phải sửa đối khi ta bổ sung lớp con mới vào chương trình. Lấy ví dụ lớp Vet trong ví dụ vừa rồi, do sử dụng tham số kiểu Animal, phần mã này có thể dùng cho lớp con bất kì của Animal. Bên cạnh các lớp Lion, Tiger…sẵn có, nếu ta muốn bổ sung loài động vật mới, chẳng hạn Cow, trong khi vẫn muốn tận dụng lớp Vet, ta chỉ cần cho lớp mới đó là lớp con của Animal. Khi đó, các phương thức của Vet vẫn tiếp tục hoạt động được với lớp mới, mặc dù khi viết Vet ta không có chút thông tin gì về các loại con của Animal mà nó sẽ hoạt động cùng.

Tóm lại, đa hình là gì? Theo nghĩa tổng quát, đa hình là khả năng tồn tại ở nhiều hình thức. Trong hướng đối tượng, đa hình đi kèm với quan hệ thừa kế và có hai đặc điểm sau: (1) các đối tượng thuộc các lớp dẫn xuất khác nhau có thể được đối xử như nhau, như thể chúng là các đối tượng thuộc lớp cơ sở, chẳng hạn có thể gửi cùng một thông điệp tới đối tượng; (2) khi nhận được cùng một thông điệp đó, các đối tượng thuộc các lớp dẫn xuất khác nhau hiểu nó theo những cách khác nhau.

Ta đã thấy đặc điểm thứ nhất thể hiện ở việc ta có thể dùng tham chiếu kiểu lớp cha để chiếu tới các đối tượng thuộc lớp con như thể chúng đều là các đối tượng thuộc lớp cha, trong các ví dụ gần đây là tham số Animal chấp nhận các đối số kiểu Dog và Cat, Vet đối xử với các loại con của Animal một cách thống nhất như thể chúng đều thuộc loại Animal. Đặc điểm thứ hai thể hiện ở việc khi ta gọi phương thức của đối tượng từ tham chiếu kiểu cha, phiên bản được gọi tùy theo đối tượng thuộc loại cụ thể gì. Kết quả của cùng một lệnh a.makeNoise() là makeNoise() của

Dog được gọi nếu a đang chiếu tới đối tượng Dog, makeNoise() của Cat được gọi nếu a đang chiếu tới đối tượng Cat.

Gọi phiên bản phương thức của lớp cha

Đôi khi, tại một lớp con, ta cài đè một hành vi của lớp cha, nhưng ta không muốn thay thế hoàn toàn mà chỉ muốn bổ sung một số chi tiết. Chẳng hạn, lớp Account đại diện cho tài khoản ngân hàng chung chung. Nó cung cấp phương thức withdraw(double) với chức năng rút tiền, phương thức này thực hiện quy trình rút tiền cơ bản: trừ số tiền rút khỏi số dư tài khoản (balance). FeeBasedAccount là loại tài khoản ngân hàng thu phí đối với mỗi lần rút tiền, nghĩa là bên cạnh quy trình rút tiền cơ bản, nó còn làm thêm một việc là trừ phí rút tiền khỏi số dư tài khoản. Như vậy, FeeBasedAccount có cần đến nội dung của bản withdraw() được Account cung cấp sẵn, nhưng vẫn phải cài đè vì nội dung đó không đủ dùng. Ta cũng không muốn chép nội dung bản withdraw() của Account vào bản của FeeBasedAccount. Thay vào đó, ta muốn có cách gọi phương thức withdraw() của Account từ trong phiên bản cài đè tại FeeBasedAccount.

Tóm lại, từ trong phiên bản cài đè tại lớp con, ta muốn gọi đến chính phương thức đó của lớp cha, ta phải làm như thế nào? Từ khóa super cho phép gọi đến cách thành viên được thừa kế. Phương thức withdraw() của FeeBasedAccount có thể được cài đặt đại loại như trong ví dụ dưới đây:

Một tham chiếu tới đối tượng thuộc lớp con sẽ luôn luôn gọi phiên bản mới nhất – chính là phiên bản của lớp con nếu có. Đó là cách hoạt động của đa hình. Tuy nhiên, từ khóa super cho phép gọi phiên bản cũ hơn – phiên bản mà lớp con được thừa kế.

Từ khóa super của Java thực chất là một tham chiếu tới phần được thừa kế của một đối tượng. Khi mã của lớp con dùng super, chẳng hạn như trong lời gọi phương thức, phiên bản được thừa kế sẽ chạy.

Các quy tắc cho việc cài đè

Khi ta cài đè một phương thức của lớp cha, ta đồng ý tuân thủ hợp đồng mà lớp cha đã cam kết. Chẳng hạn, hợp đồng nói rằng “tôi không lấy đối số và tôi trả về một giá trị boolean”. Nói cách khác, các kiểu đối số và kiểu trả về của phiên bản mới của phương thức phải trông giống hệt với bản của lớp cha.

Các phương thức chính là hợp đồng.

Nhớ lại rằng, với mỗi lời gọi phương thức, trình biên dịch dùng kiểu tham chiếu để xác định xem ta có thể gọi phương thức đó từ tham chiếu đó hay không. Với một tham chiếu kiểu Appliance (thiết bị điện) chiếu tới một đối tượng ElectricFan (quạt điện), trình biên dịch chỉ quan tâm xem lớp Appliance có phương thức mà ta đang gọi từ tham chiếu Appliance hay không. Còn khi chương trình chạy, máy ảo Java không để ý đến kiểu tham chiếu (Appliance) và chỉ quan tâm đến đối tượng ElectricFan thực tế đang nằm trong bộ nhớ heap. Do đó, nếu trình biên dịch đã chấp thuận lời gọi phương thức, lời gọi đó chỉ có thể hoạt động được nếu như phiên bản cài đè cũng có các tham số và kiểu trả về giống như phiên bản của Appliance. Khi ai đó dùng một tham chiếu Appliance gọi turnOn() không có đối số, phiên bản turnOn() của Appliance sẽ được chạy, ngay cả khi ElectricFan có một bản turnOn() với một tham số int. Nói cách khác, đơn giản là phương thức turnOn(int level) tại ElectricFan không đè phiên bản turnOn() không tham số tại Appliance!

Việc cài đè phải tuân thủ các quy tắc sau:

  1. Danh sách tham số phải trùng nhau, kiểu giá trị trả về phải tương thích. Hợp đồng của lớp cha quy định quy cách mà các phần mã khác sử dụng các phương thức của nó. Phương thức của lớp cha có thể được gọi với danh sách đối số như thế nào thì cũng có thể gọi phương thức của lớp con với danh sách đối số đó. Phương thức của lớp cha tuyên bố kiểu trả về là gì, thì phương thức của lớp con cũng phải khai báo chính kiểu trả về đó hoặc một kiểu lớp con của kiểu đó. Nhớ lại rằng một đối tượng thuộc lớp con phải được đảm bảo có thể làm được bất cứ thứ gì mà lớp cha đã tuyên bố, do đó, việc trả về đối tượng lớp con ở vị trí của đối tượng lớp cha là việc an toàn.
  2. Phương thức đè không được giảm quyền truy nhập so với phiên bản của lớp cha. Nói cách khác, quyền truy nhập mà phiên bản của lớp con cho phép phải bằng hoặc rộng hơn phiên bản của lớp cha. Ta không thể cài đè một phương thức public bằng một phiên bản private. Nếu không, tình huống xảy ra là một lời gọi phương thức đã được trình biên dịch chấp nhận vì tưởng là phương thức public nhưng đến khi nó chạy lại bị máy ảo từ chối vì phiên bản được gọi lại là private.

Như vậy, ta đã hiểu thêm về hai mức quyền truy nhập: private và public. Còn hai mức quyền truy nhập khác sẽ được nói đến trong những mục tiếp theo. Ngoài ra còn có một quy tắc khác về cài đè liên quan đến xử lý ngoại lệ, ta sẽ nói về quy tắc này tại chương sau.

Chồng phương thức

Các ví dụ về cài đè sai trong mục trước đã nói đến khái niệm cài chồng phương thức (method overload).

Cài chồng phương thức chỉ đơn giản là có một vài phương thức trùng tên nhưng khác danh sách đối số. Phương thức chồng không liên quan đến đa hình hay thừa kế. Một phương thức cài chồng không phải phương thức cài đè.

Cài chồng phương thức cho phép ta tạo nhiều phiên bản của một phương thức, mỗi phiên bản chấp nhận một danh sách đối số khác nhau, nhằm tạo thuận lợi cho việc gọi phương thức.

Ta sẽ còn quay lại các trường hợp áp dụng cài chồng khi nói về các hàm khởi tạo (constructor) trong chương sau.

Do cơ chế cài chồng phương thức không phải tuân thủ hợp đồng đa hình do lớp cha quy định, các phương thức chồng có tính linh hoạt cao hơn.

  • Kiểu trả về có thể khác nhau. Ta có thể tùy ý thay đổi kiểu trả về tại các phương thức chồng, miễn là danh sách đối số khác nhau.
  • Khác biệt duy nhất ở kiểu trả về là không đủ. Nếu không, đó không phải là việc cài chồng hợp lệ, trình biên dịch sẽ cho rằng ta đang định cài đè phương thức. Để overload, ta nhất định phải sửa danh sách tham số.
  • Có thể nới rộng hoặc hạn chế quyền truy nhập tùy ý. Ta có thể tùy ý thay đổi quyền truy nhập của phương thức chồng vì phương thức mới không bị buộc phải tuân theo hợp đồng đa hình, nếu có, của phương thức cũ.

Các mức truy cập

Đến đây, ngoài hai từ khóa public và private quy định mức truy nhập, ta đã có thể học thêm về loại protected (được bảo vệ). Mục này tổng kết các kiến thức về các loại quyền truy nhập mà Java quy định.

Ta có bốn mức truy nhập (access level) và ba từ khóa tương ứng private, protected và public, mức còn lại là mức mặc định không cần từ khóa. Các mức truy nhập được liệt kê theo thứ tự từ chặt tới lỏng như sau:

  • mức private: chỉ có mã bên trong cùng một lớp mới có thể truy nhập được những thứ private. private ở đây có nghĩa “của riêng lớp” chứ không phải “của riêng đối tượng”. Một đối tượng Dog có thể sửa các biến private hay gọi phương thức private của một đối tượng Dog khác, nhưng một đối tượng Cat thì thậm chí không ‘nhìn thấy’ các thứ private của Dog. Các đối tượng Dog cũng không thể ‘nhìn thấy’ các biến / phương thức private của các đối tượng Animal mà nó thừa kế. Vậy nên người ta nói rằng lớp con không thừa kế các biến / phương thức private của lớp cha.
  • mức truy nhập mặc định: các biến/phương thức với mức truy nhập mặc định của một lớp chỉ có thể được truy nhập bởi mã nằm bên trong cùng một gói với lớp đó.
  • mức protected: các biến/phương thức với mức protected của một lớp chỉ có thể được thừa kế bởi các lớp con cháu của lớp đó, kể cả nếu lớp con đó không nằm trong cùng một gói với lớp cha.
  • mức public: mã ở bất cứ đâu cũng có thể truy nhập các thứ public (lớp, biến thực thể, biến lớp, phương thức, hàm khởi tạo…)

public và private là hai mức được sử dụng nhiều nhất. Mức public thường dùng cho các lớp, hằng, các phương thức dành cho mục đích tương tác với bên ngoài (ví dụ các phương thức get và set), và hầu hết các hàm khởi tạo. private được dùng cho hầu hết các biến thực thể và cho các phương thức mà ta không muốn được gọi từ bên ngoài lớp (các phương thức dành riêng cho các phương thức public của lớp đó sử dụng).

Mức mặc định được dùng để giới hạn phạm vi trong một gói (xem thêm về gói tại Phụ lục B). Người ta dùng giới hạn này vì gói được thiết kế là một nhóm các lớp cộng tác với nhau như là một tập hợp gắn bó với nhau. Trong khi tất cả các lớp bên trong cùng một gói thường cần truy nhập lẫn nhau, chỉ có một nhóm trong số đó cần phải để lộ ra ngoài gói, nhóm này sẽ dùng các mức public hay protected một cách thích hợp. Lưu ý rằng nếu lớp có mức protected, thì các phương thức bên trong nó dù có thuộc mức public thì bên ngoài cũng không thể ‘nhìn thấy’, do không thể nhìn thấy lớp chứa các phương thức đó.

Mức protected gần như giống hệt với mức mặc định, chỉ khác ở chỗ: nó cho phép các lớp con thừa kế các thứ protected của lớp cha, kể cả khi lớp con nằm ngoài gói chứa lớp cha. Như vậy, mức này chỉ áp dụng cho quan hệ thừa kế. Nếu một lớp con nằm ngoài gói có một tham chiếu tới một đối tượng thuộc lớp cha, và giả sử lớp cha này có một phương thức protected, lớp con cũng không thể gọi phương thức đó từ tham chiếu đó. Cách duy nhất để một lớp con có khả năng truy nhập một phương thức protected là thừa kế phương thức đó. Nói cách khác, lớp con ngoài gói không thể truy nhập phương thức protected, nó chỉ sở hữu phương thức đó qua quan hệ thừa kế.

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

  • Lớp con chuyên biệt hóa lớp cha của nó.
  • Lớp con thừa kế tất cả các biến thực thể và phương thức public của lớp cha, nhưng không thừa kế các biến thực thể và phương thức private của lớp cha.
  • Có thể cài đè các phương thức được thừa kế; không thể cài đè các biến thực thể được thừa kế (tuy có thể gán trị lại tại lớp con, nhưng đây là hai việc khác nhau)
  • Dùng thử nghiệm IS-A để kiểm tra xem cấu trúc thừa kế của ta có hợp lí hay không. Nếu X là lớp con của Y thì khẳng định X IS-A Y phải hợp lý.
  • Quan hệ IS-A chỉ có một chiều. Con sói nào cũng là động vật, nhưng không phải con vật nào cũng là chó sói.
  • Khi một phương thức được cài đè tại một lớp con, và phương thức đó được kích hoạt cho một đối tượng của lớp đó, thì phiên bản tại lớp con sẽ được chạy (cái gì ở thấp nhất thì được gọi).
  • NếulớpBlàlớpconcủaA,lớpClàlớpconcủaB,thìmỗiđốitượngBthuộcloại A, mỗi đối tượng C thuộc loại B, và mỗi đối tượng C cũng thuộc loại A. (quan hệ IS-A)
  • Để gọi phiên bản phương thức của lớp cha từ trong lớp con, sử dụng từ khóa super làm tham chiếu tới lớp cha.
  1. Nếu muốn nói thật chính xác thì phải là “tất cả các phương thức thừa kế được”. Tạm thời, nó có nghĩa là “các phương thức public”, nhưng ta sẽ tinh chỉnh định nghĩa này sau. 

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 *