Bài 12.4: Abstract classes và interfaces trong TypeScript

Bài 12.4: Abstract classes và interfaces trong TypeScript
Chào mừng các bạn đã quay trở lại với hành trình khám phá TypeScript! Trong bài viết này, chúng ta sẽ đi 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 biệt hữu ích trong TypeScript: Abstract classes và Interfaces. Chúng giúp chúng ta định nghĩa các "bản thiết kế" hoặc "hợp đồng" cho các đối tượng và lớp, làm cho code của chúng ta có cấu trúc hơn, dễ bảo trì hơn và hỗ trợ mạnh mẽ cho tính đa hình (polymorphism).
Hãy cùng nhau làm rõ hai khái niệm này và xem khi nào thì nên sử dụng chúng nhé!
Abstract Classes trong TypeScript
Hãy tưởng tượng bạn muốn tạo một loạt các lớp đại diện cho các loại động vật khác nhau (Chó, Mèo, Chim, v.v.). Tất cả động vật đều có những hành động chung như di chuyển, nhưng mỗi loại lại có cách phát ra âm thanh riêng biệt. Bạn muốn có một lớp nền tảng chung để đại diện cho khái niệm "Động vật", nhưng bản thân lớp "Động vật" chung chung thì lại không thể tạo ra một đối tượng cụ thể nào (bạn không thể có một "con động vật" mà không biết nó là chó hay mèo). Đây chính là lúc abstract class phát huy tác dụng.
Một abstract class là một lớp mà:
- Không thể tạo thể hiện trực tiếp (không thể dùng
new AbstractClassName()
). - Có thể chứa các phương thức bình thường (có implementation) và các phương thức trừu tượng (chỉ khai báo, không có implementation).
- Dùng làm lớp nền tảng (base class) cho các lớp khác kế thừa.
- Các lớp con kế thừa từ một abstract class phải bắt buộc triển khai tất cả các phương thức trừu tượng được khai báo trong lớp cha trừu tượng (trừ khi lớp con đó cũng là abstract).
Chúng ta khai báo một abstract class bằng từ khóa abstract
:
abstract class Animal {
// Thuộc tính bình thường (có implementation)
name: string;
constructor(name: string) {
this.name = name;
}
// Phương thức bình thường (có implementation)
move(distanceInMeters: number = 0) {
console.log(`${this.name} moved ${distanceInMeters}m.`);
}
// Phương thức trừu tượng (chỉ khai báo, không có implementation)
// Bắt buộc các lớp con phải triển khai
abstract makeSound(): void;
}
Giải thích:
abstract class Animal
: Định nghĩaAnimal
là một abstract class.name: string
: Một thuộc tính bình thường mà mọiAnimal
đều có.move(distanceInMeters: number = 0)
: Một phương thức bình thường, cung cấp implementation mặc định cho việc di chuyển.abstract makeSound(): void;
: Một phương thức trừu tượng. Nó chỉ định nghĩa chữ ký (signature) của phương thức (makeSound
, không nhận đối số, trả vềvoid
) nhưng không có phần thân ({}
). Bất kỳ lớp nào kế thừa từAnimal
phải cung cấp implementation chomakeSound
.
Bây giờ, chúng ta tạo các lớp con kế thừa từ Animal
:
class Dog extends Animal {
constructor(name: string) {
super(name); // Gọi constructor của lớp cha
}
// Bắt buộc phải triển khai phương thức makeSound từ lớp cha trừu tượng
makeSound() {
console.log("Woof! Woof!");
}
}
class Cat extends Animal {
constructor(name: string) {
super(name);
}
// Bắt buộc phải triển khai phương thức makeSound
makeSound() {
console.log("Meow!");
}
}
Giải thích:
class Dog extends Animal
: LớpDog
kế thừa từAnimal
.super(name)
: Trong constructor của lớp con, phải gọisuper()
để gọi constructor của lớp cha trừu tượng.makeSound() { ... }
: CảDog
vàCat
đều cung cấp implementation cụ thể cho phương thứcmakeSound
, như yêu cầu của lớp cha trừu tượngAnimal
.
Lúc này, bạn có thể tạo thể hiện của Dog
và Cat
, nhưng không thể tạo thể hiện của Animal
:
const myDog = new Dog("Buddy");
myDog.move(10); // Buddy moved 10m.
myDog.makeSound(); // Woof! Woof!
const myCat = new Cat("Whiskers");
myCat.move(5); // Whiskers moved 5m.
myCat.makeSound(); // Meow!
// const genericAnimal = new Animal("Generic"); // Lỗi: Cannot create an instance of an abstract class.
Ý nghĩa: Abstract class giúp định nghĩa một bản thiết kế chung với một số hành vi mặc định và yêu cầu các lớp con phải hoàn thiện các hành vi còn lại. Nó thiết lập một mối quan hệ kế thừa chặt chẽ ("is-a" relationship).
Interfaces trong TypeScript
Trong khi abstract class tập trung vào việc định nghĩa một lớp nền tảng chung có thể chứa cả implementation lẫn định nghĩa trừu tượng, thì interface lại tập trung hoàn toàn vào việc định nghĩa hình dạng (shape) hoặc hợp đồng của một đối tượng hoặc lớp. Interface chỉ mô tả các thành viên mà một kiểu dữ liệu nên có, chứ không cung cấp bất kỳ implementation nào.
Interface trong TypeScript có thể được sử dụng để:
- Định nghĩa cấu trúc của các đối tượng (object types).
- Định nghĩa cấu trúc của các hàm (function types).
- Định nghĩa cấu trúc mà một lớp phải tuân theo.
- Hỗ trợ đa kế thừa cho lớp (một lớp có thể triển khai nhiều interface).
Chúng ta khai báo một interface bằng từ khóa interface
:
interface IShape {
// Chỉ khai báo chữ ký của phương thức, không có implementation
calculateArea(): number;
getPerimeter(): number;
}
Giải thích:
interface IShape
: Định nghĩa một interface tên làIShape
.calculateArea(): number;
: Yêu cầu bất kỳ đối tượng hoặc lớp nào triển khai interface này phải có một phương thức tên làcalculateArea
không nhận đối số và trả về một số.getPerimeter(): number;
: Tương tự, yêu cầu phương thứcgetPerimeter
.
Bây giờ, chúng ta có thể tạo các lớp khác nhau triển khai interface IShape
bằng từ khóa implements
:
class Circle implements IShape {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
// Bắt buộc phải triển khai calculateArea từ interface IShape
calculateArea(): number {
return Math.PI * this.radius * this.radius;
}
// Bắt buộc phải triển khai getPerimeter từ interface IShape
getPerimeter(): number {
return 2 * Math.PI * this.radius;
}
}
class Rectangle implements IShape {
width: number;
height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
// Triển khai calculateArea cho hình chữ nhật
calculateArea(): number {
return this.width * this.height;
}
// Triển khai getPerimeter cho hình chữ nhật
getPerimeter(): number {
return 2 * (this.width + this.height);
}
}
Giải thích:
class Circle implements IShape
: LớpCircle
cam kết sẽ tuân theo "hợp đồng" củaIShape
.- Các phương thức
calculateArea
vàgetPerimeter
được triển khai cụ thể trong từng lớp (Circle
,Rectangle
) theo yêu cầu của interfaceIShape
.
Tương tự abstract class, bạn không thể tạo thể hiện của interface:
// const shape = new IShape(); // Lỗi: 'IShape' only refers to a type, but is being used as a value here.
Tuy nhiên, bạn có thể sử dụng interface như một kiểu dữ liệu để tham chiếu đến các đối tượng triển khai nó. Đây là một phần sức mạnh của tính đa hình:
const circle: IShape = new Circle(5);
const rectangle: IShape = new Rectangle(4, 6);
function printShapeInfo(shape: IShape) {
console.log(`Area: ${shape.calculateArea()}`);
console.log(`Perimeter: ${shape.getPerimeter()}`);
}
printShapeInfo(circle);
// Output:
// Area: 78.53981633974483
// Perimeter: 31.41592653589793
printShapeInfo(rectangle);
// Output:
// Area: 24
// Perimeter: 20
Ý nghĩa: Interface giúp định nghĩa một hợp đồng về khả năng ("can-do" relationship). Bất kỳ lớp nào (hoặc thậm chí đối tượng nào) đáp ứng được hợp đồng đó đều có thể được coi là thuộc kiểu interface đó, bất kể chúng có mối quan hệ kế thừa trực tiếp hay không.
So sánh Abstract Classes và Interfaces
Bây giờ chúng ta đã hiểu cơ bản về cả hai, hãy làm rõ những điểm khác biệt cốt lõi và khi nào nên chọn cái nào.
Đặc điểm | Abstract Class | Interface |
---|---|---|
Implementation | Có thể chứa cả phương thức/thuộc tính bình thường (có implementation) và trừu tượng (không có implementation). | Chỉ chứa các khai báo phương thức/thuộc tính (không có implementation). |
Khởi tạo (Instantiation) | Không thể tạo thể hiện trực tiếp bằng new . |
Không thể tạo thể hiện trực tiếp bằng new . (Nó chỉ là một kiểu). |
Kế thừa/Triển khai | Một lớp kế thừa (extends ) duy nhất một abstract class. |
Một lớp triển khai (implements ) nhiều interface. |
Thuộc tính (State) | Có thể định nghĩa và chứa các thuộc tính với giá trị (state). | Chỉ định nghĩa kiểu dữ liệu của thuộc tính, không chứa giá trị (state). |
Mục đích chính | Cung cấp một lớp nền tảng chung với một phần implementation và yêu cầu lớp con hoàn thiện. Thường dùng cho mối quan hệ "is-a" có chung code nền. | Định nghĩa một hợp đồng về cấu trúc hoặc khả năng mà các đối tượng/lớp phải tuân theo. Thường dùng cho mối quan hệ "can-do" hoặc định nghĩa hình dạng dữ liệu. |
Constructor | Có thể có constructor. | Không thể có constructor. |
Access Modifiers | Có thể sử dụng public , protected , private . |
Mọi thành viên mặc định là public . (TypeScript không cho phép protected hay private trong interface). |
Khi nào sử dụng Abstract Class, khi nào sử dụng Interface?
Việc lựa chọn giữa abstract class và interface phụ thuộc vào mục đích và ngữ cảnh của bạn:
Sử dụng Abstract Class khi:
- Bạn muốn định nghĩa một lớp nền tảng cho các lớp con, trong đó có một số implementation chung mà các lớp con có thể tái sử dụng.
- Bạn muốn định nghĩa các phương thức hoặc thuộc tính mà các lớp con bắt buộc phải có, nhưng đồng thời cung cấp một số hành vi mặc định.
- Các lớp có mối quan hệ "is-a" chặt chẽ và chia sẻ nhiều logic hoặc state.
- Bạn chỉ muốn cho phép một lớp kế thừa từ lớp nền tảng này (do giới hạn đơn kế thừa của class).
Ví dụ: Lớp
Vehicle
(abstract) với phương thứcstartEngine()
(có implementation) vàdrive()
(abstract). Các lớpCar
,Motorcycle
kế thừaVehicle
, tái sử dụngstartEngine
và triển khaidrive
theo cách riêng.Sử dụng Interface khi:
- Bạn muốn định nghĩa một hợp đồng về các khả năng (phương thức) hoặc cấu trúc (thuộc tính) mà nhiều lớp hoặc đối tượng không liên quan về mặt kế thừa có thể cùng tuân theo.
- Bạn muốn đạt được tính đa hình dựa trên khả năng (what an object can do), không phải dựa trên bản chất kế thừa (what an object is).
- Bạn muốn cho phép một lớp triển khai nhiều hợp đồng khác nhau (đa triển khai - multiple implements).
- Bạn chỉ quan tâm đến việc định nghĩa những gì một đối tượng nên có/làm, chứ không quan tâm đến cách thức nó làm điều đó (không có implementation).
Ví dụ: Interface
IDescribable
với phương thứcgetDescription(): string
. Các lớpProduct
,User
,Location
đều có thể triển khaiIDescribable
dù chúng không liên quan gì đến nhau về mặt kế thừa, cho phép bạn viết một hàmprintDescription(item: IDescribable)
chung.
Sức mạnh của Interface trong Polymorphism
Một trong những ứng dụng quan trọng nhất của cả abstract class và interface là hỗ trợ tính đa hình. Tuy nhiên, interface thường được xem là linh hoạt hơn trong việc này vì một lớp có thể triển khai nhiều interface. Điều này cho phép một đối tượng thuộc nhiều "kiểu" (interface) cùng lúc, dựa trên các khả năng mà nó cung cấp.
Hãy xem một ví dụ đơn giản về đa hình với interface:
interface ITalkable {
talk(): void;
}
interface IMovable {
move(distance: number): void;
}
class Robot implements ITalkable, IMovable {
name: string;
constructor(name: string) {
this.name = name;
}
talk(): void {
console.log(`${this.name} says: Beep boop.`);
}
move(distance: number): void {
console.log(`${this.name} rolls ${distance} units.`);
}
}
class Person implements ITalkable, IMovable {
name: string;
constructor(name: string) {
this.name = name;
}
talk(): void {
console.log(`${this.name} says: Hello there!`);
}
move(distance: number): void {
console.log(`${this.name} walks ${distance} steps.`);
}
}
// Một mảng các đối tượng chỉ cần triển khai ITalkable
const talkers: ITalkable[] = [new Robot("R2D2"), new Person("Alice")];
talkers.forEach(item => {
item.talk(); // Gọi phương thức talk(), mỗi đối tượng thực hiện theo cách riêng
});
// Output:
// R2D2 says: Beep boop.
// Alice says: Hello there!
// Một mảng các đối tượng chỉ cần triển khai IMovable
const movers: IMovable[] = [new Robot("C3PO"), new Person("Bob")];
movers.forEach(item => {
item.move(100); // Gọi phương thức move(), mỗi đối tượng thực hiện theo cách riêng
});
// Output:
// C3PO rolls 100 units.
// Bob walks 100 steps.
Giải thích:
- Chúng ta có hai interface
ITalkable
vàIMovable
, định nghĩa hai khả năng khác nhau. - Lớp
Robot
vàPerson
đều triển khai cả hai interface này. - Chúng ta có thể tạo các mảng chứa các đối tượng thuộc các lớp khác nhau (
Robot
,Person
), nhưng TypeScript cho phép coi chúng như cùng một kiểu (ITalkable[]
hoặcIMovable[]
) miễn là chúng đáp ứng hợp đồng của interface đó. - Khi lặp qua mảng và gọi phương thức của interface, mỗi đối tượng sẽ thực thi phương thức đó theo implementation riêng của lớp nó. Đây chính là đa hình mạnh mẽ nhờ interface.
Comments