Học bằng cách đọc theo thứ tự

Polymorphism — cùng một lời gọi, cách chạy khác nhau

Viết cho mỗi class một phương thức cùng tên để một lời gọi dùng được cho tất cả. Gồm mảng lẫn nhiều loại, kiểm tra instanceof và duck typing.

Giả sử form liên hệ có danh sách file đính kèm: ảnh thì hiện ảnh thu nhỏ, video thì hiện nút phát. Nếu bạn lưu loại file dưới dạng chuỗi và dùng if trong hàm hiển thị để rẽ nhánh, thì mỗi lần thêm một loại, bạn lại phải mở hàm đó ra để viết thêm.

Bài này nói về polymorphism (đa hình), khi một phương thức cùng tên chạy code khác nhau ở mỗi class, và duck typing, cách làm không quan tâm đến quan hệ kế thừa.

Đặt phần hiển thị khác nhau vào class — phương thức cùng tên

Trong cách làm cũ, mỗi file đính kèm là một object như { kind: "video", fileName: "demo.mp4" }, và hàm previewOf rẽ nhánh theo kind. Để thêm video, bạn phải mở previewOf, nơi cũng đang chứa nhánh xử lý ảnh, và viết thêm vào đó.

Với polymorphism (các class khác nhau có một phương thức cùng tên; phía gọi chỉ viết một lời gọi, còn code nào chạy là do class đã tạo ra instance quyết định), bạn viết phần hiển thị của từng loại vào preview của lớp con.

Khi mỗi lớp con đã override preview của lớp cha, phía gọi chỉ cần viết preview() mà không phải kiểm tra đó là lớp con nào.

// Lưu loại file dưới dạng chuỗi và rẽ nhánh bằng if trong hàm hiển thị
function previewOf(file) {
  if (file.kind === "image") return `${file.fileName}: Hiện ảnh thu nhỏ`;
  return `${file.fileName}: Không có bản xem trước`;
}
console.log(previewOf({ kind: "video", fileName: "demo.mp4" }));   // demo.mp4: Không có bản xem trước

// Viết phương thức preview cùng tên trong class của từng loại
class Attachment {
  constructor(fileName) { this.fileName = fileName; }
  preview() { return `${this.fileName}: Không có bản xem trước`; }
}
class ImageFile extends Attachment {
  preview() { return `${this.fileName}: Hiện ảnh thu nhỏ`; }
}
class VideoFile extends Attachment {
  preview() { return `${this.fileName}: Hiện nút phát`; }
}
console.log(new ImageFile("logo.png").preview());   // logo.png: Hiện ảnh thu nhỏ
console.log(new VideoFile("demo.mp4").preview());   // demo.mp4: Hiện nút phát
Code cần mở khi thêm phần hiển thị video
Thêm phầnhiển thị videoMởpreviewOfSửa hàm đangchứa nhánh ảnhPhần hiển thị ảnhcó thể bị đổi theoThêm phầnhiển thị videoViếtclass VideoFileKhông mởImageFilePhần hiển thị ảnhgiữ nguyên
Hàng trên sửa previewOf, chính hàm đang chứa nhánh của ảnh. Hàng dưới chỉ viết thêm VideoFile và không đụng đến ImageFile.

console.log đầu tiên in ra "Không có bản xem trước" vì previewOf chưa có nhánh cho "video". Với cách dùng class, người thêm video chỉ cần dùng đúng tên preview, và không cần nhớ những chuỗi nào được đặt vào kind.

Trên màn hình thanh toán, bạn hiển thị số tiền phải trả của từng phương thức thanh toán, đã tính cả phí. PaymentMethod đã được khai báo sẵn.

① Hãy định nghĩa class thanh toán tại cửa hàng tiện lợi, kế thừa PaymentMethod.

② Tương tự, hãy định nghĩa class thanh toán khi nhận hàng.

③ Hãy tạo thẻ tín dụng, thanh toán tại cửa hàng tiện lợi và thanh toán khi nhận hàng, rồi hiển thị số tiền phải trả cho đơn 4980 yên.

④ Với phương thức thanh toán khi nhận hàng, hãy hiển thị số tiền phải trả cho đơn 12800 yên.

(Nếu chạy đúng, phần giải thích sẽ xuất hiện.)

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Gọi một lượt cho nhiều loại lẫn nhau — mảng và for...of

Danh sách được gửi tới dưới dạng một mảng duy nhất, lẫn cả ảnh, PDF và những file không xác định được loại. Nếu gán từng file vào một biến riêng rồi viết lời gọi preview() cho từng biến, thì mỗi khi số file đính kèm thay đổi, bạn lại phải thêm hoặc bớt dòng gọi.

Instance của các class khác nhau vẫn có thể nằm chung một mảng. Khi duyệt bằng for...of, lời gọi file.preview() trong thân vòng lặp chạy trên instance của một class khác ở mỗi lượt, và dòng gọi vẫn chỉ là một dòng.

Khi thêm PdfFile cho file PDF, bạn cũng không phải sửa vòng lặp.

// Attachment và ImageFile được định nghĩa như ở ví dụ đầu tiên
// Loại được thêm sau. Vòng lặp không cần sửa
class PdfFile extends Attachment {
  preview() { return `${this.fileName}: Hiện trang 1`; }
}

const attachments = [
  new ImageFile("logo.png"),
  new PdfFile("manual.pdf"),
  new Attachment("memo.txt"),
];

// Thân vòng lặp chỉ một dòng. Ở mỗi lượt, file chứa instance của một class khác
for (const file of attachments) {
  console.log(file.preview());
}
// logo.png: Hiện ảnh thu nhỏ
// manual.pdf: Hiện trang 1
// memo.txt: Không có bản xem trước
preview nào chạy ở mỗi lượt
Thân for...offile.preview()Lượt 1logo.pngLượt 2manual.pdfLượt 3memo.txtpreview củaImageFilepreview củaPdfFilepreview củaAttachmentlogo.png: Hiệnảnh thu nhỏmanual.pdf:Hiện trang 1memo.txt: Khôngcó bản xem trước
Lời gọi trong vòng lặp chỉ có một dòng, nhưng mỗi lượt lại chạy một phần thân phương thức khác. preview nào chạy là do class đã tạo ra file của lượt đó quyết định.

memo.txt ở lượt thứ ba được tạo từ Attachment, nên preview viết trong chính Attachment được chạy. Nếu một lớp con mới quên viết preview, nó cũng sẽ ra đúng kết quả này thay vì báo lỗi, vì vậy mỗi khi thêm một loại, hãy kiểm tra dòng của loại đó trong danh sách.

Trên màn hình xác nhận đơn hàng, bạn gợi ý phương thức thanh toán có số tiền phải trả thấp nhất cho từng mức tổng đơn. PaymentMethod, BankTransfer, PayLaterorderTotals đã được khai báo sẵn.

① Hãy định nghĩa class trả góp, cộng thêm phí bằng 3% giá.

② Hãy gom ba phương thức thanh toán vào một mảng.

③ Với từng mức tổng đơn, hãy chọn phương thức có số tiền phải trả thấp nhất.

④ Hãy hiển thị phương thức đã chọn và số tiền phải trả.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Gọi phương thức mà chỉ một loại có — rẽ nhánh bằng instanceof

Tiếp theo, các dòng ảnh trong danh sách cần thêm nút cắt ảnh. cropButton, phương thức trả về nút đó, chỉ được viết trong ImageFile, nên nếu gọi file.cropButton() cho mọi phần tử trong vòng lặp, chương trình sẽ dừng với TypeError ở lượt của memo.txt.

instanceof trả về true nếu giá trị bên trái được tạo từ class bên phải hoặc từ một lớp con của nó. Hãy kiểm tra file instanceof ImageFile và chỉ gọi cropButton ở những lượt cho ra true.

Class nào cũng có preview, nên bạn gọi thẳng mà không cần kiểm tra.

class Attachment {
  constructor(fileName) { this.fileName = fileName; }
  preview() { return `${this.fileName}: Không có bản xem trước`; }
}
class ImageFile extends Attachment {
  preview() { return `${this.fileName}: Hiện ảnh thu nhỏ`; }
  cropButton() { return `${this.fileName}: Hiện nút cắt ảnh`; }   // Chỉ ImageFile có
}

const attachments = [new ImageFile("logo.png"), new Attachment("memo.txt")];

for (const file of attachments) {
  console.log(file.preview());          // Class nào cũng có, nên gọi mà không cần kiểm tra
  if (file instanceof ImageFile) {
    console.log(file.cropButton());     // Chỉ gọi ở lượt của ImageFile
  }
}
// logo.png: Hiện ảnh thu nhỏ
// logo.png: Hiện nút cắt ảnh
// memo.txt: Không có bản xem trước
Chọn những lượt gọi cropButton
file.cropButton()cho mọi phần tửfile ở lượt 2là memo.txtAttachment khôngcó cropButtonDừng vớiTypeErrorRẽ nhánh bằnginstanceofImageFileLượt memo.txtra falseQua lượt sau,không gọicropButtonChỉ logo.pngcó thêm nút
Hàng trên dừng ở lượt của memo.txt, file không có cropButton. Hàng dưới chỉ gọi ở các lượt của ImageFile.

Hàng trên báo lỗi TypeError: file.cropButton is not a function. Nếu dùng instanceof để rẽ nhánh cả với preview, bạn sẽ phải thêm một lần kiểm tra cho mỗi loại, giống hệt previewOf. Chỉ dùng nhánh instanceof cho những phương thức mà chỉ một số class có.

Bọc instanceof trong ngoặc khi dùng !

Trong !file instanceof ImageFile, !file được tính trước và thành false, nên thực chất bạn đang kiểm tra false instanceof ImageFile, và kết quả luôn là false. Muốn viết "không phải ImageFile", hãy bọc trong ngoặc: !(file instanceof ImageFile).

Với đơn hàng có thể thanh toán bằng điểm, bạn chỉ thêm số điểm còn lại vào dòng thanh toán bằng điểm. PaymentMethod, PointPayment, methodscartTotal đã được khai báo sẵn.

① Hãy lần lượt xử lý các phương thức thanh toán và hiển thị số tiền phải trả của từng phương thức.

② Chỉ ở dòng thanh toán bằng điểm, hãy thêm vào cuối số điểm còn lại sau khi trả.

③ Hãy hiển thị số phương thức thanh toán không dùng điểm.

JavaScript / TypeScript Editor

Chạy code để xem đầu ra

Đưa vào danh sách cả giá trị không kế thừa — duck typing

Danh sách còn cần hiển thị cả liên kết tới thư mục. Liên kết là một object literal có phương thức preview(), và không kế thừa Attachment. Nếu bạn kiểm tra instanceof Attachment để loại bỏ dữ liệu cũ không có preview, như { fileName: "old.doc" }, thì liên kết cũng ra false và bị xếp chung với dữ liệu cũ vào nhóm "Không hiển thị được".

Với duck typing (quyết định cách xử lý một giá trị chỉ dựa vào việc nó có những phương thức cần thiết hay không, không kiểm tra class hay quan hệ kế thừa), bạn kiểm tra typeof item.preview === "function".

Một lời gọi phương thức sẽ chạy dù class là gì, miễn là giá trị bên trái dấu chấm có một hàm mang tên đó.

class Attachment {
  constructor(fileName) { this.fileName = fileName; }
  preview() { return `${this.fileName}: Không có bản xem trước`; }
}

// Liên kết tạo mà không dùng class. Không kế thừa Attachment
const folderLink = {
  fileName: "Thư mục báo giá",
  preview() { return `${this.fileName}: Mở liên kết`; },
};
console.log(folderLink instanceof Attachment);   // false

// Quyết định dựa vào việc có preview là hàm hay không, không dựa vào class
for (const item of [new Attachment("memo.txt"), folderLink, { fileName: "old.doc" }]) {
  if (typeof item.preview === "function") {
    console.log(item.preview());                   // memo.txt: Không có bản xem trước / Thư mục báo giá: Mở liên kết
  } else {
    console.log(`${item.fileName}: Không hiển thị được`);   // old.doc: Không hiển thị được
  }
}
Danh sách xử lý folderLink ra sao
instanceofAttachmentKhông đượctạo từ classinstanceofra falseThư mục báo giá:Không hiển thịđượctypeofitem.previewCó hàmpreviewtypeof ra"function"Thư mục báo giá:Mở liên kết
Cùng một folderLink nhưng được hiển thị khác nhau tùy cách kiểm tra. typeof không nhìn class, chỉ xem preview có phải là hàm hay không.

Vì không có bước kiểm tra instanceof, một giá trị có hàm preview không trả về chuỗi cũng lọt qua. Hãy đảm bảo mọi giá trị đưa vào danh sách đều có preview() trả về chuỗi để hiển thị. Bảng dưới đây so sánh ba cách gọi dùng trong bài.

Cách gọiKiểm tra gìKhi nào dùng
Gọi thẳng preview()Không kiểm tra gìMọi phần tử đều có preview
Rẽ nhánh bằng instanceofTạo từ ImageFile hay lớp con?Gọi phương thức chỉ class đó có
Rẽ nhánh bằng typeofpreview có phải là hàm?Nhận mọi giá trị có preview

Toán tử in trả về true dù preview không phải là hàm

"preview" in item vẫn là true khi giá trị là chuỗi, như trong { preview: "Sắp ra mắt" }. Gọi item.preview() trên giá trị đó sẽ báo lỗi TypeError: item.preview is not a function, nên trước khi gọi, hãy dùng typeof để chắc chắn đó thật sự là một hàm.

QUIZ

Kiểm tra kiến thức

Hãy trả lời từng câu hỏi một.

Câu 1Bạn thêm PdfFile vào một danh sách có vòng lặp gọi file.preview(). Bạn cần viết thêm ở đâu?

Câu 2Với memo được tạo từ Attachment, kết quả của !memo instanceof ImageFile là gì?

Câu 3Với object literal link có phương thức preview(), link instanceof Attachmenttypeof link.preview cho ra gì?