Bài 12.3: Inheritance và implements trong TypeScript

Bài 12.3: Inheritance và implements trong TypeScript
Chào mừng quay trở lại với hành trình chinh phục Lập trình Web Front-end cùng TypeScript! Trong bài học hôm nay, chúng ta sẽ đào sâu vào hai khái niệm cực kỳ quan trọng trong Lập trình hướng đối tượng (OOP) và được TypeScript hỗ trợ mạnh mẽ: Kế thừa (Inheritance) và Triển khai giao diện (Implements). Hiểu rõ và áp dụng thành thạo hai kỹ thuật này sẽ giúp bạn xây dựng những ứng dụng phức tạp một cách có tổ chức, dễ quản lý và tái sử dụng mã nguồn hiệu quả.
Tại sao cần Kế thừa và Triển khai Giao diện?
Khi xây dựng các ứng dụng lớn, việc viết lại cùng một logic hoặc định nghĩa lại cấu trúc cho nhiều lớp khác nhau là điều không thể tránh khỏi nếu không có các kỹ thuật này.
- Kế thừa (
extends
) giúp chúng ta tạo ra các lớp mới dựa trên các lớp đã có, kế thừa các thuộc tính và phương thức của lớp cha. Điều này thúc đẩy khả năng tái sử dụng mã nguồn và mô tả mối quan hệ "là một loại" (is-a). Ví dụ: MộtDog
(chó) là một loạiAnimal
(động vật). - Triển khai giao diện (
implements
) cho phép một lớp cam kết tuân theo một "hợp đồng" về cấu trúc được định nghĩa bởi một giao diện (interface). Nó đảm bảo rằng lớp đó sẽ có đầy đủ các thuộc tính và phương thức theo yêu cầu của giao diện. Điều này hữu ích cho việc định nghĩa cấu trúc chung và đảm bảo tính nhất quán giữa các lớp khác nhau, ngay cả khi chúng không có mối quan hệ kế thừa trực tiếp.
Hãy cùng đi sâu vào từng khái niệm nhé!
Kế thừa (Inheritance) với extends
Kế thừa là trụ cột của OOP, cho phép một lớp (lớp con - derived class) thừa hưởng các đặc điểm (thuộc tính và phương thức) từ một lớp khác (lớp cha - base class hoặc superclass). Trong TypeScript, chúng ta sử dụng từ khóa extends
để thực hiện điều này.
Mối quan hệ ở đây là "là một loại". Nếu lớp Dog
kế thừa từ lớp Animal
, điều đó có nghĩa là Dog
là một loại Animal
, và nó có tất cả những gì một Animal
có (tên, khả năng di chuyển), cộng thêm những đặc điểm riêng của Dog
(như sủa).
Cú pháp cơ bản:
class BaseClass {
// Thuộc tính và phương thức của lớp cha
}
class DerivedClass extends BaseClass {
// Thuộc tính và phương thức riêng của lớp con
// Có thể ghi đè (override) các phương thức từ lớp cha
}
Ví dụ minh họa: Lớp Animal
và Dog
Hãy bắt đầu với một lớp cơ bản Animal
:
class Animal {
name: string; // Thuộc tính tên
constructor(name: string) {
this.name = name; // Khởi tạo tên khi tạo đối tượng Animal
}
// Phương thức di chuyển chung cho tất cả động vật
move(distanceInMeters: number = 0) {
console.log(`${this.name} di chuyển ${distanceInMeters}m.`);
}
}
Bây giờ, chúng ta tạo lớp Dog
kế thừa từ Animal
:
class Dog extends Animal {
// Lớp Dog kế thừa 'name' và 'move()' từ Animal
constructor(name: string) {
// Gọi constructor của lớp cha (Animal)
// Cần phải gọi super() trước khi sử dụng 'this' trong constructor của lớp con
super(name);
}
// Phương thức riêng của lớp Dog
bark() {
console.log("Woof! Woof!");
}
// Ghi đè (Override) phương thức move() từ lớp cha
move(distanceInMeters = 5) {
console.log("Chó đang chạy...");
// Có thể gọi phương thức gốc của lớp cha nếu cần
super.move(distanceInMeters);
}
}
Giải thích:
- Lớp
Dog
sử dụngextends Animal
để kế thừa từAnimal
. - Trong
constructor
củaDog
, chúng ta bắt buộc phải gọisuper(name)
. Từ khóasuper
dùng để tham chiếu đến lớp cha. Khi dùng trong constructor,super()
gọi đến constructor của lớp cha và truyền các đối số cần thiết (ở đây làname
) để khởi tạo phần của lớp cha trong đối tượng con. - Lớp
Dog
có thêm phương thứcbark()
là đặc trưng riêng. - Lớp
Dog
ghi đè (override) phương thứcmove()
. Khi một đối tượngDog
gọimove()
, phiên bản trong lớpDog
sẽ được thực thi thay vì phiên bản trong lớpAnimal
. Bên trong phương thứcmove
đã được ghi đè, chúng ta vẫn có thể gọi phiên bản gốc của lớp cha bằngsuper.move(distanceInMeters)
.
Cách sử dụng:
const myDog = new Dog("Buddy");
myDog.bark(); // Output: Woof! Woof! (Phương thức của Dog)
myDog.move(20);
// Output:
// Chó đang chạy...
// Buddy di chuyển 20m. (Phiên bản move() đã bị ghi đè gọi super.move())
const someAnimal: Animal = new Dog("Lucy"); // Một đối tượng Dog có thể được gán cho biến kiểu Animal
someAnimal.move(15);
// Output:
// Chó đang chạy...
// Lucy di chuyển 15m.
// someAnimal.bark(); // Lỗi! Biến kiểu Animal không biết phương thức bark() của Dog
Lưu ý: Khi bạn khai báo một biến có kiểu là lớp cha (Animal
), biến đó chỉ có thể truy cập các thuộc tính và phương thức được định nghĩa trong lớp cha (hoặc các thành viên protected
/public
mà lớp con kế thừa). Mặc dù đối tượng thực tế là Dog
, trình biên dịch TypeScript vẫn chỉ cho phép bạn truy cập các thành viên của kiểu Animal
.
Triển khai Giao diện (Implements) với implements
Trong khi extends
tập trung vào việc tái sử dụng triển khai (code thực thi), implements
tập trung vào việc định nghĩa cấu trúc hoặc hợp đồng. Một interface (giao diện) trong TypeScript định nghĩa ra một tập hợp các thuộc tính và phương thức mà một đối tượng phải có. Một lớp sử dụng từ khóa implements
để cam kết rằng nó sẽ cung cấp tất cả các thành viên được định nghĩa trong giao diện đó.
Mối quan hệ ở đây là "hành xử như một loại" (behaves-like a type) hoặc "tuân theo hợp đồng". Nếu một lớp Circle
triển khai giao diện Shape
, điều đó có nghĩa là Circle
cam kết sẽ có mọi thứ mà một Shape
cần có (ví dụ: một phương thức để tính diện tích), bất kể Circle
được kế thừa từ lớp nào.
Cú pháp cơ bản:
interface InterfaceName {
// Định nghĩa cấu trúc: thuộc tính và chữ ký phương thức
someProperty: type;
someMethod(param: type): returnType;
}
class MyClass implements InterfaceName {
// Bắt buộc phải có tất cả các thuộc tính và phương thức
// được định nghĩa trong InterfaceName với đúng kiểu dữ liệu/chữ ký
someProperty: type;
someMethod(param: type): returnType {
// Triển khai logic cụ thể cho phương thức này
// ...
}
}
Ví dụ minh họa: Giao diện Shape
và các lớp Circle
, Rectangle
Đầu tiên, định nghĩa giao diện Shape
:
interface Shape {
// Mọi lớp triển khai Shape phải có phương thức area() trả về số
area(): number;
// Phương thức perimeter() là tùy chọn (?)
perimeter?(): number;
}
Bây giờ, tạo các lớp Circle
và Rectangle
triển khai giao diện Shape
:
class Circle implements Shape {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
// Bắt buộc phải triển khai phương thức area() từ interface Shape
area(): number {
return Math.PI * this.radius * this.radius;
}
// Chúng ta không cần triển khai perimeter() vì nó là tùy chọn trong interface
}
class Rectangle implements Shape {
width: number;
height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
// Bắt buộc phải triển khai phương thức area()
area(): number {
return this.width * this.height;
}
// Chúng ta chọn triển khai cả phương thức perimeter() tùy chọn
perimeter(): number {
return 2 * (this.width + this.height);
}
}
Giải thích:
- Chúng ta định nghĩa giao diện
Shape
yêu cầu phương thứcarea()
và tùy chọnperimeter()
. - Lớp
Circle
sử dụngimplements Shape
để cam kết tuân thủ cấu trúc củaShape
. Trình biên dịch TypeScript sẽ báo lỗi nếuCircle
không có phương thứcarea()
với đúng chữ ký. - Tương tự, lớp
Rectangle
cũngimplements Shape
và phải cóarea()
.Rectangle
cũng chọn triển khaiperimeter()
. implements
chỉ yêu cầu về chữ ký (tên phương thức, tham số, kiểu trả về), không bắt buộc về lô-gic bên trong. Lô-gic củaarea()
trongCircle
vàRectangle
là hoàn toàn khác nhau, nhưng cả hai đều thỏa mãn "hợp đồng"Shape
.
Cách sử dụng:
const myCircle: Shape = new Circle(10); // Biến kiểu Shape có thể chứa đối tượng Circle
const myRectangle: Shape = new Rectangle(5, 8); // Biến kiểu Shape có thể chứa đối tượng Rectangle
console.log(`Diện tích hình tròn: ${myCircle.area()}`); // Output: Diện tích hình tròn: 314.159...
console.log(`Diện tích hình chữ nhật: ${myRectangle.area()}`); // Output: Diện tích hình chữ nhật: 40
// console.log(`Chu vi hình tròn: ${myCircle.perimeter()}`); // Lỗi biên dịch! Biến kiểu Shape không đảm bảo có perimeter() trừ khi bạn kiểm tra
console.log(`Chu vi hình chữ nhật: ${myRectangle.perimeter()}`); // OK, vì Rectangle có perimeter() và biến được gán là Rectangle
Lưu ý: Tương tự như với kế thừa, khi một biến được khai báo với kiểu giao diện (Shape
), bạn chỉ có thể truy cập các thành viên được định nghĩa trong giao diện đó. TypeScript đảm bảo rằng bất kỳ đối tượng nào được gán cho biến kiểu Shape
đều chắc chắn có phương thức area()
. Tuy nhiên, nó không đảm bảo rằng chúng có perimeter()
, trừ khi bạn thực hiện kiểm tra kiểu lúc chạy (runtime).
Mối quan hệ và Sự khác biệt then chốt
Đây là điểm quan trọng cần nắm vững:
extends
(Kế thừa Lớp):- Mô tả mối quan hệ "là một loại" (is-a).
- Lớp con thừa hưởng cả cấu trúc và triển khai (logic) từ lớp cha.
- Chỉ có thể kế thừa từ một lớp cha duy nhất trong TypeScript (và hầu hết các ngôn ngữ OOP khác).
- Dùng khi bạn muốn xây dựng một lớp chuyên biệt hơn dựa trên một lớp tổng quát đã có, tận dụng code đã viết sẵn.
implements
(Triển khai Giao diện):- Mô tả mối quan hệ "hành xử như một loại" (behaves-like) hoặc "tuân theo hợp đồng".
- Lớp chỉ cam kết cung cấp cấu trúc (chữ ký phương thức, tên thuộc tính), không thừa hưởng triển khai. Lớp phải tự viết (triển khai) logic cho các thành viên đó.
- Một lớp có thể triển khai nhiều giao diện cùng lúc.
- Dùng khi bạn muốn định nghĩa một tập hợp các yêu cầu mà nhiều lớp phải đáp ứng, bất kể chúng được kế thừa từ đâu, để đảm bảo tính đồng nhất về khả năng (capabilities).
Bạn hoàn toàn có thể sử dụng cả hai cùng lúc: Một lớp có thể kế thừa từ một lớp cha và triển khai một hoặc nhiều giao diện.
interface Readable {
read(): string;
}
interface Writable {
write(text: string): void;
}
class Document {
content: string = "";
display() {
console.log("Nội dung tài liệu:", this.content);
}
}
// Lớp TextFile kế thừa từ Document VÀ triển khai cả Readable và Writable
class TextFile extends Document implements Readable, Writable {
fileName: string;
constructor(fileName: string, initialContent: string = "") {
super(); // Gọi constructor của lớp cha Document
this.fileName = fileName;
this.content = initialContent; // Thừa hưởng thuộc tính content từ Document
}
// Triển khai phương thức read() từ interface Readable
read(): string {
console.log(`Đọc từ file ${this.fileName}...`);
return this.content;
}
// Triển khai phương thức write() từ interface Writable
write(text: string): void {
console.log(`Ghi vào file ${this.fileName}...`);
this.content = text;
}
// Phương thức riêng của TextFile
save() {
console.log(`Lưu file ${this.fileName}.`);
// Logic lưu file thực tế...
}
}
const myFile = new TextFile("my_notes.txt", "Đây là nội dung ban đầu.");
myFile.display(); // Sử dụng phương thức kế thừa từ Document
myFile.write("Nội dung mới đã được ghi."); // Sử dụng phương thức triển khai từ Writable
console.log(myFile.read()); // Sử dụng phương thức triển khai từ Readable
myFile.save(); // Sử dụng phương thức riêng của TextFile
Trong ví dụ trên, lớp TextFile
vừa kế thừa khả năng hiển thị (display
) và thuộc tính content
từ lớp Document
, vừa cam kết (và triển khai) khả năng đọc (read
) và ghi (write
) theo các giao diện Readable
và Writable
. Đây là một mô hình rất phổ biến trong thiết kế hướng đối tượng, giúp kết hợp cả tái sử dụng code và đảm bảo tuân thủ các hợp đồng chức năng.
Khi nào sử dụng cái nào?
- Sử dụng
extends
khi bạn có một tập hợp logic và dữ liệu chung muốn chia sẻ và mở rộng. Bạn có một lớp cơ sở cung cấp chức năng mặc định và các lớp con chỉ cần thêm hoặc điều chỉnh một chút. - Sử dụng
implements
khi bạn muốn định nghĩa một "khả năng" hoặc một "hợp đồng" mà nhiều lớp có thể thực hiện theo những cách khác nhau. Các lớp này có thể không liên quan gì về mặt kế thừa, nhưng chúng cùng chia sẻ một bộ hành vi nào đó. Ví dụ: Giao diệnSerializable
yêu cầu một phương thứcserialize()
mà các lớpUser
,Product
,Order
... đều có thể triển khai theo cách riêng của chúng.
Comments