Lúc ngồi nghe giảng ở trên trường, hẳn bạn sẽ từng nghe kế thừa là 1 trong 4 đặc điểm của lập trình hướng đối tượng, giúp lập trình viên tiết kiệm thời gian khi tạo các class có đặc tính giống nhau.
Tuy nhiên, nếu dạo một vòng trên internet, bạn sẽ bắt gặp những ý kiến không tốt về kế thừa:
Ưu tiên kết hợp thay vì kế thừa (Favor composition over inheritance).
Cha đẻ Java James Gosling cho hay nếu được làm lại Java từ đầu, ông sẽ... loại bỏ kế thừa.
Tại sao extends lại là một điều xấu (Why extends is Evil).
Vậy kế thừa tốt hay xấu, nếu xấu thì tại sao nó được sinh ra? Chúng ta cùng tìm hiểu qua bài viết này nhé.
I. Lược sử về kế thừa
Mầm mống của kế thừa bắt đầu từ thập niên 60 của thế kỷ trước.
Năm 1967 Ole-Johan và Kristen Nygaard trình bày một thiết kế cho phép một đối tượng thuộc về một class khác nhưng có chung thuộc tính với class đang xét.
Các thuộc tính chung nằm trong class cha/class cơ sở (superclass). Mỗi class cha có thể là con của một class khác. Các class con/class dẫn xuất (subclass) sẽ có các thuộc tính chung này, cộng thêm các thuộc tính chỉ có riêng ở class con.
Ý tưởng này sau đó được đưa vào ngôn ngữ Simula 67, và từ đó được thu nạp vào các ngôn ngữ khác như C++, Java, Smalltalk hay Python…
II. Chức năng
Kế thừa cho phép chúng ta:
- Tái sử dụng (reuse) code đã có ở lớp cha
- Ghi đè (overwrite) hàm của lớp cha để phù hợp với nhu cầu của lớp con
- Đảm bảo lớp cha và con có cùng loại/interface
Hãy cùng xem qua vấn đề sau (Ví dụ lấy theo video The Flaws of Inheritance):
Tạo ứng dụng xử lý ảnh (lật ngang/dọc, thay đổi kích thước, thay đổi tỉ lệ…) trên các định dạng ảnh.
Chúng ta sẽ giải quyết bài toán theo hướng kế thừa. Mỗi một ảnh cần phải tải (load), xử lý và lưu (save) lại. Việc tải và lưu có thể khác nhau tùy vào định dạng ảnh nên sẽ để chúng ở dạng abstract ở lớp cha, và việc định nghĩa chi tiết ở lớp con.
Các thao tác lật, thay đổi kích thước, thay đổi tỉ lệ chỉ thao tác trên pixel, không bị ảnh hưởng bởi định dạng ảnh nên có thể cho vào lớp cha.
Ở class ứng dụng (ImageApp), chúng ta khai báo 1 biến image để trỏ tới hình ảnh cần thao tác. Hàm khởi tạo sẽ tạo ra đối tượng ảnh tương ứng với định dạng cần.
Ngoài ra ứng dụng sẽ có một số hàm gọi tới hàm của file ảnh (ví dụ như clickSave).
abstract class Image {
public width: number;
public height: number;
private pixels: Pixel;
public setSize(w: number, h: number) {
this.width = w;
this.height = h;
}
public resize(scale: number) {
this.width *= scale;
this.height *= scale;
}
public flipHorizontal() {
// flip logic
}
public flipVertical() {
// flip logic
}
public abstract save()
public abstract load()
}
class JPGImage extends Image {
private path: string;
constructor(path: string) {
super();
this.path = path;
}
public save() {
this.resize();
// save logic
}
public load() {
this.setSize();
// load logic
}
public uniqueJPGMethod() {}
}
class PNGImage extends Image {
private path: string;
private options: PNGOptions;
constructor(path: string, options: PNGOptions) {
super();
this.path = path;
this.options = options;
}
public save() {
this.resize();
// save logic
}
public load() {
this.setSize();
// load logic
}
public uniquePNGMethod() {}
}
class ImageApp {
private image: Image;
constructor(type: string, path: string) {
if (type === 'jpg') {
this.image = new JPGImage(path);
}
if (type === 'jpg') {
this.image = new PNGImage(path, new PNGOptions());
}
}
saveClick() {
this.image.save();
}
}
Với cách thiết kế trên, chúng ta đã tận dụng được những gì kế thừa mang lại
- Resuse code: PNGImage, JPGImage tái sử dụng lại các hàm đã định nghĩa sẵn trong Image, tiết kiệm thời gian so với việc định nghĩa lại trong từng class
- Ghi đè: Mỗi class con định nghĩa hàm load/save phù hợp
- Đảm bảo 1 interface chung: khi gọi hàm clickSave ở ImageApp, ImageApp ở thời điểm đấy sẽ không quan tâm image là PNG hay JPG. Vì cả 2 đều thừa kế từ Image nên hàm save sẽ được gọi mà không có lỗi
Mọi chuyện suôn sẻ, không vấn đề gì.
Cho đến khi…
III. Vấn đề xảy ra
Chúng ta nhận được yêu cầu tạo một class Image cho phép thực hiện thao tác vẽ lên chính nó, và ảnh này không tới từ file hệ thống (có thể tới từ mail, sms…). Class này cần đảm bảo có các hàm thao tác với ảnh như các định dạng ảnh khác.
Chúng ta sẽ tạo 1 class DrawableImage, kế thừa Image và thêm các hàm vẽ.
Đến đây chúng ta sẽ thấy được vấn đề đầu tiên ở kế thừa. Tính năng kế thừa tạo ra liên kết chặt giữa lớp cha và con, mọi chức năng của lớp cha đều truyền hết cho lớp con, dù lớp con có thể không cần tới.
Ở đây DrawableImage phải định nghĩa 2 hàm save và load để có thể tái sử dụng các hàm khác của Image, dù DrawableImage không dùng tới 2 hàm này (DrawableImage tới từ các nguồn khác, không phải từ file hệ thống).
Bỏ không sẽ bị lỗi, định nghĩa cụ thể có thể bị sử dụng ngoài ý muốn. Vì thế phương án tốt nhất là ném ngoại lệ cho 2 hàm này.
class DrawableImage extends Image {
private brush: Pencil;
constructor() {
super();
this.brush = new Pencil();
}
public drawLine(data: any) {
// draw line logic
this.brush.(data, this);
}
public save() {
throw new Error('not impelement')
}
public load() {
throw new Error('not impelement');
}
}
Chúng ta thử tìm cách tháo gỡ nhé!
Để giải quyết vấn đề này cho DrawableImage và các định dạng ảnh khác sau này, chúng ta có thể đưa 2 hàm save và load vào 1 class riêng là FileImage.
abstract class FileImage extends Image {
public abstract save();
public abstract load();
}
Nhưng việc này sẽ khiến cho những dòng gọi image.save()
sẽ bị lỗi vì class Image không còn định nghĩa 2 hàm này.
Khi những thay đổi như thế này xảy ra ở class cha, chúng sẽ ảnh hưởng tới tất cả các class con, buộc đi sửa lại tất cả chỗ đã sử dụng tới (bằng cách cho kế thừa FileImage thay vì Image) – một vấn đề với tên gọi lớp cơ sở yếu (fragile base class).
Một vấn đề khác là khi chúng ta muốn tạo ra một class Image mới có các hàm độc nhất ở PNG và JPG thì phải làm sao?
Đa số ngôn ngữ chỉ cho phép đơn thừa kế. Nếu kế thừa một trong 2 thì không thể gọi hàm của class còn lại.
Nếu kế thừa JPG xong rồi kế thừa tiếp PNG, chúng ta sẽ đạt được yêu cầu nhưng đổi lại class NewImage sẽ không dùng hết các hàm từ 2 class cha.
Tái định nghĩa lại các hàm độc nhất ở 2 class kia vào trong NewImage sẽ thành lập code.
Vậy giải pháp là gì?
IV. Composition (kết hợp)
Composition là một trong các phương án cho kế thừa trong vấn đề đã nêu.
Lấy một ví dụ như thế này để chúng ta dễ dàng mường tượng ra:
Kế thừa giống như bữa ăn theo thực đơn (sit-down dinner, course dinner). Chúng ta sẽ được phục vụ 3-4 món: khai vị, món chính, salad/phô mai và tráng miệng. Thực đơn sẽ dính cứng như vậy, nếu chúng ta không thích tráng miệng thì cũng phải trả tiền, phục vụ vẫn dọn ra và ăn hay không là quyết định ở bạn.
Composition, như cái tên gợi ý, là sự kếp hợp của nhiều thứ. Composition giống như bữa ăn buffet, chúng ta thoải mái chọn các món ăn theo ý thích của bản thân, không bị gò bó theo một trật tự nào
Composition là phương thức dùng để tái sử dụng code mà không dính tới kế thừa.
Phần lớn chúng ta đã sử dụng tới composition nhưng có thể không nhận ra. Nếu 2 class muốn tái sử dụng hàm A của class X nhưng không kế thừa X, thì chúng chỉ cần giữ thể hiện của class X và gọi A thông qua X.
1. Tái sử dụng code
Theo chỉ dẫn trên, trước hết chúng ta sẽ loại bỏ abstract khỏi class Image, vì chức năng kế thừa sẽ không dùng tới. Đồng thời 2 hàm abstract load và save sẽ không còn nằm trong Image nữa.
class Image {
public width: number;
public height: number;
private pixels: { x: number; y: number };
...
public flipVertical() {
// flip logic
}
}
Kéo theo đó PNG và JPG sẽ không còn kế thừa Image, nhưng 2 class vẫn giữ lại hàm save và load vì chúng cần thiết.
class PNGImage {
private path: string;
private options: PNGOptions;
constructor(path: string, options: PNGOptions) {
this.path = path;
this.options = options;
}
public save() {
// save logic
}
public load() {
// load logic
}
public uniquePNGMethod() {}
}
class JPGImage {
private path: string;
constructor(path: string) {
this.path = path;
}
public save() {
// save logic
}
public load() {
// load logic
}
public uniqueJPGMethod() {}
}
class DrawableImage {
private brush: Pencil;
private image: Image;
constructor(image: Image) {
this.brush = new Pencil();
this.image = image;
}
public drawLine(data: any) {
// draw line logic
this.brush.(data, this.image);
}
}
Tiếp đến là các hàm lật ảnh, thay đổi kích thước… ở phương án kế thừa, các lớp con sẽ truy xuất thông qua toán tử this. Với composition, chúng ta sẽ truyền image vào và truy xuất các hàm nói trên thông qua image.
public save(image: Image) {
image.resize();
// save logic
}
public load(image: Image) {
image.setSize();
// load logic
}
public uniqueZZZMethod(image: Image) {}
Từ đây chúng ta có thể kết hợp sử dụng các loại định dạng ảnh theo ý bản thân, không còn phụ thuộc cứng ngắc vào một loại định dạng duy nhất.
Chúng ta có thể tạo 1 ảnh PNG, vẽ lên đấy và lưu lại dưới dạng JPG.
{
// create PNG image
const image: Image = new Image();
const pngImage: PNGImage = new PNGImage("image.png", new PNGOptions());
pngImage.load(image);
// draw on it
const drawImage: DrawableImage = new DrawableImage(image);
drawImage.drawLine({ 10, 10, 20, 20});
drawImage.drawLine({ 30, 0, 10, 25});
// save as jpg
const jpgImage: JPGImage = new PNGImage("output.png");
jpgImage.save(image);
}
Thế còn NewImage thì sao?
class NewImage {
private path: string;
constructor(path: string) {
this.path = path;
}
public save(image: Image) {
image.resize();
// save logic
}
public load(image: Image) {
image.setSize();
// load logic
}
public uniquePNGMethod(image: Image) {
const pngImage: PNGImage = new PNGImage("image.png", new PNGOptions());
pngImage.uniquePNGMethod(image);
}
public uniqueJPGMethod(image: Image) {
const jpgImage: JPGImage = new JPGImage("image.jpg");
jpgImage.uniqueJPGMethod(image);
}
}
{
// create New image
const image: Image = new Image();
const newImage: NewImage = new NewImage("image.png");
newImage.load(image);
// call unique method of PNG and JPG
newImage.uniquePNGMethod(image);
newImage.uniqueJPGMethod(image);
// flip the iamge
image.flipVertical();
// save it
newImage.save(image);
}
Hoàn toàn có thể.
2. Trừu tượng (abstraction)
Ở phương án kế thừa, class ImageApp có thể gọi hàm save trên các loại image vì kế thừa tạo ra tính trừu tượng (abstraction). Kế thừa vẽ ra một bản hợp đồng giữa class cha và con, bắt buộc class con ít nhất phải có các hàm được định nghĩa trong class cha.
ImageApp nghĩ rằng nó sẽ nhận image thuộc class Image, nhưng không biết chính xác image đấy thuộc về class nào (JPG hay PNG).
Trở lại phương án composition, chúng ta cũng muốn ImageApp có thể gọi hàm save nhưng không cần biết rõ image thuộc class nào. Việc này đạt được bằng cách nào nếu không dùng tới kế thừa?
Chúng ta sẽ sử dụng interface.
Thực hiện (implement) một interface cũng giống như kế thừa một class ở phương diện tạo ra 1 bản hợp đồng giữa đôi bên. Tuy nhiên khác với kế thừa, thực hiện interface sẽ không yêu cầu class phải nhận hết biến/hàm đã định nghĩa sẵn từ class cha.
Cụ thể chúngt ta sẽ định nghĩa ra interface ImageFile với 2 hàm save và load.
interface ImageFile {
public save(image: Image): void;
public load(image: Image): void;
}
Các class ảnh sẽ thực hiện interface ImageFile.
class JPGImage implements ImageFile {
...
}
class PNGImage implements ImageFile {
...
}
class NewImage implements ImageFile {
...
}
Trong ImageApp, để hàm save có thể gọi trên mọi định dạng ảnh, đối tượng được gọi cũng phải thực hiện ImageFile. Chúng ta định nghĩa ra một biến file cho mục tiêu này.
Với việc trừu tượng hóa thông qua interface ImageFile, chúng ta có thể loại bỏ logic khởi tạo ảnh tùy theo định dạng. Người dùng chỉ cần truyền một class bất kì có thực hiện ImageFile là xong.
class ImageApp {
image: Image;
file: ImageFile;
constructor(file: ImageFile) {
this.image = new Image();
this.file = file;
this.file.load(this.image);
}
public saveClick() {
this.file.save(this.image);
}
}
Vậy là chúng ta đã chuyển hóa từ phương án kế thừa sang composition.
V. Khi nào sử dụng kế thừa?
Ưu tiên kết hợp thay vì kế thừa. Ở đầu bài chúng ta đã thấy câu này, hàm ý composition luôn ưu việt hơn. Tuy nhiên thực tế không phải như vậy. Composition có những điểm yếu sau:
- Lượng code nhiều lên: phân tách hàm sang class khác, tạo interface chung, khởi tạo giá trị cho các biến nội tại.
- Hàm wrapper nhiều: nếu chúng ta muốn đưa ra thông tin của các biến nội tại, chúng ta phải tạo 1 hàm bọc lấy hàm gọi thông tin từ biến nội tại. Ví dụ như lấy thông tin path của image.
public getPath(image: Image): string {
return image.getPath();
}
Kế thừa sẽ tốt hơn composition trong tình huống chúng ta muốn thay đổi một phần nhỏ của một thư viện/class lớn.
Giả sử chúng ta có 1 thư viện Faker dùng để tạo giữ liệu giả. Faker có rất nhiều hàm, nhưng chúng ta chỉ muốn thay đổi hàm số 1. Lưu ý chúng ta sử dụng thư viện Faker nên khi dùng composition, chúng ta phải tạo wrapper cho tất cả các hàm mà ứng dụng dùng tới.
class CustomFaker {
private faker: Faker;
constructor() {...}
// method need to be modified
public methodNum1() {
let result = this.faker.methodNum1();
// modified the result
return result;
}
// expose the rest of methods used by our app
public methodNum2() {
return this.faker.methodNum2();
}
public methodNum3() {
return this.faker.methodNum3();
}
...
public methodNumN() {
return this.faker.methodNumN();
}
}
Với kế thừa, chúng ta chỉ cần ghi đè hàm cần thay đổi là xong.
class CustomFaker extends Faker {
// overwrite superclass's method
public methodNum1() {
// overwritten code
}
}
Nếu vì một lí do gì đó bạn phải sử dụng kế thừa, hãy nhớ tới các nguyên tắc sau trong giai đoạn thiết kế:
- Thiết kế class cha tổng quát nhất có thể, chỉ chứa các phương thức/hàm/biến được sử dụng hầu hết ở các class con.
- Tránh cho phép truy cập trực tiếp tới biến protected.
- Tránh sự cố xảy ra khi class con ghi đè class cha bằng cách ghi rõ các hàm có thể ghi đè với từ khóa protected. Các hàm còn lại để private/final, ngăn không cho ghi đè.
Nguồn tham khảo
Kế thừa: Inheritance_(object-oriented_programming)
Composition: Composition_over_inheritance
Lớp cơ sở yếu (base class fragile): Fragile_base_class
Vì sao kế thừa là điều xấu: why-extends-is-evil.html
Điểm yếu của kế thừa: https://www.youtube.com/watch?v=hxGOiiR9ZKg