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 classesInterfaces. 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à:

  1. Không thể tạo thể hiện trực tiếp (không thể dùng new AbstractClassName()).
  2. 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).
  3. Dùng làm lớp nền tảng (base class) cho các lớp khác kế thừa.
  4. 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ĩa Animal là một abstract class.
  • name: string: Một thuộc tính bình thường mà mọi Animal đề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 cho makeSound.

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ớp Dog kế thừa từ Animal.
  • super(name): Trong constructor của lớp con, phải gọi super() để gọi constructor của lớp cha trừu tượng.
  • makeSound() { ... }: Cả DogCat đều cung cấp implementation cụ thể cho phương thức makeSound, như yêu cầu của lớp cha trừu tượng Animal.

Lúc này, bạn có thể tạo thể hiện của DogCat, 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 để:

  1. Định nghĩa cấu trúc của các đối tượng (object types).
  2. Định nghĩa cấu trúc của các hàm (function types).
  3. Định nghĩa cấu trúc mà một lớp phải tuân theo.
  4. 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ức getPerimeter.

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ớp Circle cam kết sẽ tuân theo "hợp đồng" của IShape.
  • Các phương thức calculateAreagetPerimeter được triển khai cụ thể trong từng lớp (Circle, Rectangle) theo yêu cầu của interface IShape.

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ức startEngine() (có implementation) và drive() (abstract). Các lớp Car, Motorcycle kế thừa Vehicle, tái sử dụng startEngine và triển khai drive 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ức getDescription(): string. Các lớp Product, User, Location đều có thể triển khai IDescribable 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àm printDescription(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 ITalkableIMovable, định nghĩa hai khả năng khác nhau.
  • Lớp RobotPerson đề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ặc IMovable[]) 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

There are no comments at the moment.