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)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ột Dog (chó) là một loại Animal (độ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 AnimalDog

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:

  1. Lớp Dog sử dụng extends Animal để kế thừa từ Animal.
  2. Trong constructor của Dog, chúng ta bắt buộc phải gọi super(name). Từ khóa super 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.
  3. Lớp Dog có thêm phương thức bark() là đặc trưng riêng.
  4. Lớp Dog ghi đè (override) phương thức move(). Khi một đối tượng Dog gọi move(), phiên bản trong lớp Dog sẽ được thực thi thay vì phiên bản trong lớp Animal. Bên trong phương thức move đã được ghi đè, chúng ta vẫn có thể gọi phiên bản gốc của lớp cha bằng super.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 CircleRectangle 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:

  1. Chúng ta định nghĩa giao diện Shape yêu cầu phương thức area() và tùy chọn perimeter().
  2. Lớp Circle sử dụng implements Shape để cam kết tuân thủ cấu trúc của Shape. Trình biên dịch TypeScript sẽ báo lỗi nếu Circle không có phương thức area() với đúng chữ ký.
  3. Tương tự, lớp Rectangle cũng implements Shape và phải có area(). Rectangle cũng chọn triển khai perimeter().
  4. 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ủa area() trong CircleRectangle 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úctriể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 ReadableWritable. Đâ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ện Serializable yêu cầu một phương thức serialize() mà các lớp User, Product, Order... đều có thể triển khai theo cách riêng của chúng.

Comments

There are no comments at the moment.