Bài 12.5: Bài tập thực hành OOP với TypeScript

Chào mừng bạn quay trở lại với hành trình chinh phục Lập trình Web Front-end cùng TypeScript! Sau khi đã khám phá những khái niệm nền tảng của Lập trình Hướng đối tượng (OOP), đã đến lúc chúng ta biến lý thuyết thành hành động. Bài viết này tập trung hoàn toàn vào thực hành với các bài tập cụ thể, giúp bạn áp dụng OOP trong TypeScript một cách tự tin và hiệu quả hơn. Hãy cùng đi sâu vào code!

Chúng ta sẽ đi qua các bài tập từ cơ bản đến nâng cao một chút, mỗi bài tập sẽ giúp bạn củng cố một hoặc nhiều nguyên tắc OOP quan trọng như Class, Object, Inheritance, Encapsulation, và Polymorphism.

Bài tập 1: Xây dựng Lớp (Class) và Đối tượng (Object) cơ bản

Mục tiêu: Hiểu cách định nghĩa một Class và tạo ra các Object (đối tượng) từ lớp đó, cùng với việc định nghĩa các thuộc tính (properties) và phương thức (methods).

Ý tưởng: Tạo một lớp đơn giản đại diện cho một Cuốn sách.

Yêu cầu:

  1. Định nghĩa một Class có tên Book.
  2. Lớp Book có các thuộc tính: title (chuỗi), author (chuỗi), và yearPublished (số).
  3. Sử dụng constructor để khởi tạo các thuộc tính này khi tạo một đối tượng Book mới.
  4. Thêm một phương thức getDetails() trả về chuỗi mô tả chi tiết về cuốn sách.
  5. Tạo một vài đối tượng Book và gọi phương thức getDetails() của chúng.

Code thực hành:

// 1. Định nghĩa Class Book
class Book {
    // 2. Các thuộc tính của lớp
    title: string;
    author: string;
    yearPublished: number;

    // 3. Constructor để khởi tạo đối tượng
    constructor(title: string, author: string, yearPublished: number) {
        this.title = title;
        this.author = author;
        this.yearPublished = yearPublished;
    }

    // 4. Phương thức để lấy chi tiết sách
    getDetails(): string {
        return `"${this.title}" bởi ${this.author}, xuất bản năm ${this.yearPublished}.`;
    }
}

// 5. Tạo các đối tượng Book và sử dụng phương thức
const book1 = new Book("The Hobbit", "J.R.R. Tolkien", 1937);
const book2 = new Book("1984", "George Orwell", 1949);

console.log(book1.getDetails()); // Output: "The Hobbit" bởi J.R.R. Tolkien, xuất bản năm 1937.
console.log(book2.getDetails()); // Output: "1984" bởi George Orwell, xuất bản năm 1949.

Giải thích:

  • Chúng ta định nghĩa lớp Book với ba thuộc tính title, author, yearPublished.
  • constructor là một phương thức đặc biệt được gọi khi bạn tạo một đối tượng mới bằng từ khóa new. Nó nhận các giá trị và gán chúng cho các thuộc tính của đối tượng hiện tại (sử dụng this).
  • Phương thức getDetails() truy cập các thuộc tính của đối tượng (this.title, this.author, v.v.) để tạo ra chuỗi mô tả.
  • const book1 = new Book(...)const book2 = new Book(...) là cách chúng ta tạo ra các đối tượng cụ thể (các instance) từ lớp Book. Mỗi đối tượng có bản sao riêng của các thuộc tính và có thể gọi các phương thức được định nghĩa trong lớp.

Bài tập 2: Áp dụng Kế thừa (Inheritance)

Mục tiêu: Nắm vững cách sử dụng kế thừa để tạo ra các lớp con (subclasses) dựa trên các lớp cha (superclasses), tái sử dụng code và mở rộng chức năng.

Ý tưởng: Mở rộng lớp Book thành lớp EBook (sách điện tử), thêm các thuộc tính và phương thức đặc trưng cho sách điện tử.

Yêu cầu:

  1. Tạo một lớp mới có tên EBook kế thừa từ lớp Book đã định nghĩa ở Bài tập 1.
  2. Lớp EBook có thêm thuộc tính fileSize (số, đơn vị MB) và format (chuỗi, ví dụ: "PDF", "EPUB").
  3. Sử dụng constructor trong EBook để khởi tạo cả thuộc tính của lớp cha và các thuộc tính mới.
  4. Override phương thức getDetails() để nó bao gồm thông tin về kích thước file và định dạng.
  5. Tạo một đối tượng EBook và gọi phương thức getDetails().

Code thực hành:

// Sử dụng lại Class Book từ bài tập 1 (đảm bảo nó được định nghĩa)
// class Book { ... }

// 1. Định nghĩa Class EBook kế thừa từ Book
class EBook extends Book {
    // 2. Các thuộc tính mới của EBook
    fileSize: number;
    format: string;

    // 3. Constructor của EBook
    constructor(title: string, author: string, yearPublished: number, fileSize: number, format: string) {
        // Gọi constructor của lớp cha Book
        super(title, author, yearPublished);

        // Khởi tạo các thuộc tính mới
        this.fileSize = fileSize;
        this.format = format;
    }

    // 4. Override phương thức getDetails()
    getDetails(): string {
        // Có thể gọi phương thức getDetails() của lớp cha nếu cần
        // const bookDetails = super.getDetails();
        // return `${bookDetails} - Kích thước: ${this.fileSize} MB, Định dạng: ${this.format}.`;

        // Hoặc viết lại hoàn toàn
        return `[EBook] "${this.title}" bởi ${this.author}, xuất bản năm ${this.yearPublished}. Kích thước: ${this.fileSize} MB, Định dạng: ${this.format}.`;
    }
}

// 5. Tạo đối tượng EBook và sử dụng phương thức
const ebook1 = new EBook("Learning TypeScript", "Jane Doe", 2023, 10.5, "PDF");

console.log(ebook1.getDetails());
// Output: [EBook] "Learning TypeScript" bởi Jane Doe, xuất bản năm 2023. Kích thước: 10.5 MB, Định dạng: PDF.

// book1 (đối tượng Book gốc) vẫn hoạt động như cũ
console.log(book1.getDetails()); // Output: "The Hobbit" bởi J.R.R. Tolkien, xuất bản năm 1937.

Giải thích:

  • Từ khóa extends Book chỉ ra rằng lớp EBook kế thừa tất cả các thuộc tính và phương thức public hoặc protected từ lớp Book.
  • Trong constructor của lớp con (EBook), chúng ta phải gọi super() trước khi truy cập hoặc sử dụng this. super() gọi constructor của lớp cha (Book), đảm bảo các thuộc tính của lớp cha được khởi tạo đúng cách.
  • Việc định nghĩa lại phương thức getDetails() trong EBook với cùng tên và chữ ký (số lượng và kiểu tham số) là override. Khi bạn gọi getDetails() trên một đối tượng EBook, phiên bản trong lớp EBook sẽ được thực thi thay vì phiên bản trong lớp Book.

Bài tập 3: Thực hành Tính đóng gói (Encapsulation) và Bổ trợ truy cập (Access Modifiers)

Mục tiêu: Hiểu và áp dụng tính đóng gói bằng cách sử dụng các bổ trợ truy cập (public, private, protected) để kiểm soát khả năng truy cập vào các thành viên của lớp.

Ý tưởng: Tạo một lớp đơn giản đại diện cho một Tài khoản ngân hàng và bảo vệ thông tin số dư.

Yêu cầu:

  1. Định nghĩa một Class có tên BankAccount.
  2. Lớp BankAccount có thuộc tính _balance (số) để lưu trữ số dư. Thuộc tính này phải là riêng tư (private).
  3. Sử dụng constructor để khởi tạo số dư ban đầu (chỉ cho phép số dương hoặc bằng 0).
  4. Thêm phương thức deposit(amount: number) (gửi tiền). Phương thức này phải là công khai (public). Chỉ cho phép gửi số tiền dương.
  5. Thêm phương thức withdraw(amount: number) (rút tiền). Phương thức này phải là công khai (public). Chỉ cho phép rút số tiền dương và không lớn hơn số dư hiện tại. Phương thức này nên trả về boolean cho biết việc rút tiền thành công hay không.
  6. Thêm phương thức getBalance() (lấy số dư). Phương thức này phải là công khai (public).
  7. (Tùy chọn nâng cao) Sử dụng cú pháp getter cho thuộc tính balance.
  8. Tạo một đối tượng BankAccount và thử gửi, rút, và kiểm tra số dư. Quan sát cách không thể truy cập trực tiếp thuộc tính _balance.

Code thực hành:

// 1. Định nghĩa Class BankAccount
class BankAccount {
    // 2. Thuộc tính _balance là riêng tư
    private _balance: number;

    // 3. Constructor
    constructor(initialBalance: number) {
        if (initialBalance < 0) {
            console.error("Lỗi: Số dư ban đầu không thể âm.");
            this._balance = 0; // Hoặc throw new Error(...)
        } else {
            this._balance = initialBalance;
        }
        console.log(`Tài khoản đã được tạo với số dư ban đầu: ${this._balance}`);
    }

    // 4. Phương thức gửi tiền (public theo mặc định)
    deposit(amount: number): void {
        if (amount > 0) {
            this._balance += amount;
            console.log(`Đã gửi ${amount}. Số dư hiện tại: ${this._balance}`);
        } else {
            console.log("Số tiền gửi phải lớn hơn 0.");
        }
    }

    // 5. Phương thức rút tiền (public theo mặc định)
    withdraw(amount: number): boolean {
        if (amount > 0 && amount <= this._balance) {
            this._balance -= amount;
            console.log(`Đã rút ${amount}. Số dư hiện tại: ${this._balance}`);
            return true;
        } else if (amount > this._balance) {
             console.log(`Rút tiền thất bại: Số dư không đủ (${this._balance}).`);
             return false;
        }
        else {
             console.log("Rút tiền thất bại: Số tiền rút phải lớn hơn 0.");
             return false;
        }
    }

    // 6. Phương thức lấy số dư (public theo mặc định)
    getBalance(): number {
        return this._balance;
    }

    // 7. Getter cho thuộc tính balance
    get balance(): number {
        // Logic kiểm tra hoặc định dạng có thể thêm ở đây
        return this._balance;
    }

    // Có thể thêm setter nếu muốn kiểm soát cách thiết lập số dư (ví dụ: chỉ cho phép reset về 0)
    // set balance(newBalance: number) {
    //    if (newBalance === 0) {
    //        this._balance = 0;
    //        console.log("Số dư đã được reset về 0.");
    //    } else {
    //        console.log("Không được phép thiết lập số dư trực tiếp bằng giá trị khác 0.");
    //    }
    // }
}

// 8. Tạo đối tượng và thử nghiệm
const myAccount = new BankAccount(1000);

// Thử truy cập trực tiếp (sẽ báo lỗi trong TypeScript)
// console.log(myAccount._balance); // Lỗi: Property '_balance' is private

myAccount.deposit(500); // Gửi tiền thành công
myAccount.withdraw(200); // Rút tiền thành công
myAccount.withdraw(2000); // Rút tiền thất bại (số dư không đủ)
myAccount.withdraw(-100); // Rút tiền thất bại (số âm)
myAccount.deposit(-50); // Gửi tiền thất bại (số âm)

console.log(`Số dư cuối cùng qua getBalance(): ${myAccount.getBalance()}`);
console.log(`Số dư cuối cùng qua getter: ${myAccount.balance}`); // Sử dụng getter

Giải thích:

  • Thuộc tính _balance được khai báo với từ khóa private. Điều này có nghĩa là nó chỉ có thể được truy cập từ bên trong chính lớp BankAccount. Mọi nỗ lực truy cập myAccount._balance từ bên ngoài lớp sẽ gây lỗi TypeScript.
  • Các phương thức deposit, withdraw, getBalance (và getter balance) mặc định là public. Chúng cung cấp giao diện công khai để tương tác với đối tượng BankAccount. Bằng cách này, logic kiểm tra (số tiền dương, đủ số dư) được đóng gói bên trong lớp, đảm bảo tính toàn vẹn của dữ liệu _balance.
  • Encapsulation giúp làm cho code dễ bảo trì hơn và ngăn chặn việc thay đổi dữ liệu nội bộ của đối tượng một cách không kiểm soát.

Bài tập 4: Khám phá Tính đa hình (Polymorphism)

Mục tiêu: Hiểu tính đa hình - khả năng các đối tượng thuộc các lớp khác nhau phản ứng theo cách riêng của chúng khi được gọi cùng một phương thức thông qua một tham chiếu chung (lớp cha hoặc interface).

Ý tưởng: Tạo một hệ thống đơn giản về các loại Động vật với khả năng phát ra âm thanh đặc trưng.

Yêu cầu:

  1. Định nghĩa một lớp cha (có thể là trừu tượng - abstract class hoặc sử dụng interface) có tên Animal. Lớp này có một phương thức makeSound().
  2. Tạo hai lớp con: DogCat kế thừa từ Animal.
  3. Mỗi lớp con override phương thức makeSound() để in ra âm thanh đặc trưng của chúng ("Gâu gâu!" và "Meo meo!").
  4. Tạo một mảng chứa các đối tượng của cả DogCat nhưng được khai báo là kiểu Animal[].
  5. Duyệt qua mảng và gọi phương thức makeSound() cho từng phần tử. Quan sát kết quả.

Code thực hành:

// 1. Định nghĩa lớp cha trừu tượng Animal
// Sử dụng abstract class buộc các lớp con phải implement các phương thức abstract
abstract class Animal {
    protected name: string; // Thêm thuộc tính protected để dùng trong lớp con

    constructor(name: string) {
        this.name = name;
    }

    // Phương thức trừu tượng - phải được implement bởi lớp con
    abstract makeSound(): void;

    // Phương thức concrete (có implement) có thể được kế thừa
    sayHello(): void {
        console.log(`Xin chào, tôi là ${this.name}!`);
    }
}

// 2. Tạo lớp con Dog kế thừa từ Animal
class Dog extends Animal {
    constructor(name: string) {
        super(name);
    }

    // 3. Implement phương thức makeSound() đặc trưng cho Dog
    makeSound(): void {
        console.log(`${this.name} nói: Gâu gâu!`);
    }
}

// 2. Tạo lớp con Cat kế thừa từ Animal
class Cat extends Animal {
    constructor(name: string) {
        super(name);
    }

    // 3. Implement phương thức makeSound() đặc trưng cho Cat
    makeSound(): void {
        console.log(`${this.name} nói: Meo meo!`);
    }
}

// 4. Tạo mảng chứa các đối tượng Dog và Cat, nhưng kiểu là Animal[]
const animals: Animal[] = [
    new Dog("Buddy"),
    new Cat("Whiskers"),
    new Dog("Lucy"),
    new Cat("Tiger")
];

console.log("Gọi phương thức makeSound() trên từng đối tượng trong mảng Animal:");
// 5. Duyệt mảng và gọi makeSound()
for (const animal of animals) {
    animal.makeSound(); // Đây là lúc đa hình xảy ra!
    // animal.sayHello(); // Cũng có thể gọi phương thức được kế thừa từ lớp cha
}

/*
Output mong đợi:
Gọi phương thức makeSound() trên từng đối tượng trong mảng Animal:
Buddy nói: Gâu gâu!
Whiskers nói: Meo meo!
Lucy nói: Gâu gâu!
Tiger nói: Meo meo!
*/

Giải thích:

  • abstract class Animal định nghĩa một khuôn mẫu chung và khai báo phương thức makeSound()abstract, buộc các lớp con phải cung cấp cài đặt riêng cho phương thức này. (Nếu dùng interface, nó sẽ chỉ là định nghĩa chữ ký phương thức mà không có cài đặt).
  • Lớp DogCat implement (cung cấp cài đặt cụ thể) cho phương thức makeSound() theo cách riêng của chúng.
  • Mảng animals chứa các đối tượng thuộc các lớp con (Dog, Cat), nhưng nó được khai báo với kiểu dữ liệu của lớp cha (Animal[]). Đây là điểm mấu chốt của đa hình.
  • Khi chúng ta duyệt qua mảng và gọi animal.makeSound(), TypeScript (và JavaScript khi chạy) sẽ biết được kiểu thực tế của đối tượng hiện tại (Dog hay Cat) và thực thi đúng phiên bản makeSound() của lớp đó. Cùng một lời gọi phương thức (makeSound()) mang lại nhiều hình thái (đa hình) kết quả khác nhau tùy thuộc vào kiểu đối tượng cụ thể.

Bài tập 5: Kết hợp các nguyên tắc OOP

Mục tiêu: Áp dụng đồng thời nhiều nguyên tắc OOP đã học để giải quyết một vấn đề phức tạp hơn.

Ý tưởng: Xây dựng hệ thống quản lý đơn giản cho một cửa hàng trực tuyến, bao gồm các loại sản phẩm khác nhau và giỏ hàng.

Yêu cầu:

  1. Tạo một lớp cha Product (có thể trừu tượng) với các thuộc tính chung: id (số), name (chuỗi), price (số).
  2. Thêm phương thức getDisplayPrice() trả về giá sản phẩm (có thể có định dạng hoặc tính toán gì đó sau này).
  3. Tạo các lớp con kế thừa từ Product, ví dụ: ElectronicsProduct (thêm thuộc tính warrantyMonths) và BookProduct (thêm thuộc tính author).
  4. Mỗi lớp con có thể override getDisplayPrice() nếu cần logic hiển thị giá đặc biệt (ví dụ: thêm ký hiệu tiền tệ).
  5. Tạo một lớp ShoppingCart có thuộc tính là một mảng các Product.
  6. Lớp ShoppingCart có phương thức addProduct(product: Product) để thêm sản phẩm vào giỏ.
  7. Lớp ShoppingCart có phương thức getTotalPrice() tính tổng giá của tất cả sản phẩm trong giỏ hàng. Sử dụng đa hình bằng cách gọi getDisplayPrice() trên từng sản phẩm.

Code thực hành:

// 1. Lớp cha Product (trừu tượng)
abstract class Product {
    id: number;
    name: string;
    price: number; // Giá gốc

    constructor(id: number, name: string, price: number) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    // 2. Phương thức lấy giá hiển thị (có thể override)
    getDisplayPrice(): number {
        return this.price; // Mặc định trả về giá gốc
    }

    // Phương thức chung để lấy thông tin cơ bản
    abstract getInfo(): string; // Bắt buộc lớp con implement

}

// 3. Lớp con ElectronicsProduct
class ElectronicsProduct extends Product {
    warrantyMonths: number;

    constructor(id: number, name: string, price: number, warrantyMonths: number) {
        super(id, name, price);
        this.warrantyMonths = warrantyMonths;
    }

    getInfo(): string {
        return `${this.name} (ID: ${this.id}) - Bảo hành: ${this.warrantyMonths} tháng`;
    }

    // Có thể override getDisplayPrice nếu muốn thêm logic (ví dụ: thuế)
    // getDisplayPrice(): number {
    //     return this.price * 1.05; // Thêm 5% thuế
    // }
}

// 3. Lớp con BookProduct
class BookProduct extends Product {
    author: string;

    constructor(id: number, name: string, price: number, author: string) {
        super(id, name, price);
        this.author = author;
    }

    getInfo(): string {
        return `${this.name} (ID: ${this.id}) bởi ${this.author}`;
    }
}

// 5. Lớp ShoppingCart
class ShoppingCart {
    // Thuộc tính là một mảng các đối tượng Product (đa hình!)
    private items: Product[] = [];

    // 6. Phương thức thêm sản phẩm (áp dụng encapsulation)
    addProduct(product: Product): void {
        this.items.push(product);
        console.log(`Đã thêm "${product.name}" vào giỏ hàng.`);
    }

    // Lấy danh sách sản phẩm (có thể dùng get Info của từng loại)
    listItems(): void {
        console.log("\nSản phẩm trong giỏ hàng:");
        this.items.forEach(item => {
            console.log(`- ${item.getInfo()} - Giá: ${item.getDisplayPrice()} VNĐ`);
        });
    }

    // 7. Phương thức tính tổng giá (áp dụng đa hình)
    getTotalPrice(): number {
        let total = 0;
        // Duyệt qua mảng và gọi getDisplayPrice() cho từng sản phẩm
        // Dù là ElectronicsProduct hay BookProduct, phương thức đúng sẽ được gọi
        for (const item of this.items) {
            total += item.getDisplayPrice(); // Đa hình ở đây!
        }
        return total;
    }
}

// Sử dụng hệ thống
const laptop = new ElectronicsProduct(101, "Laptop ABC", 25000000, 24);
const novel = new BookProduct(205, "Cuốn sách hay", 150000, "Tác giả nổi tiếng");
const mouse = new ElectronicsProduct(102, "Chuột không dây", 500000, 6);

const cart = new ShoppingCart();

cart.addProduct(laptop);
cart.addProduct(novel);
cart.addProduct(mouse);

cart.listItems();

const totalPrice = cart.getTotalPrice();
console.log(`\nTổng giá trị giỏ hàng: ${totalPrice} VNĐ`);

Giải thích:

  • Lớp Product định nghĩa cấu trúc chung và một phương thức getDisplayPrice() mặc định. Nó là abstract và có thêm phương thức getInfo abstract để buộc các lớp con cung cấp thông tin chi tiết riêng.
  • ElectronicsProductBookProduct kế thừa từ Product, thêm các thuộc tính đặc trưng và implement phương thức getInfo(). Chúng có thể override getDisplayPrice() nếu logic giá khác biệt.
  • Lớp ShoppingCart sử dụng tính đóng gói bằng cách quản lý danh sách sản phẩm (items) nội bộ và cung cấp phương thức công khai (addProduct) để tương tác.
  • Trong phương thức getTotalPrice(), chúng ta duyệt qua mảng items (là mảng kiểu Product). Khi gọi item.getDisplayPrice(), TypeScript/JavaScript thực hiện đa hình: nó nhận diện đối tượng thực tế là ElectronicsProduct hay BookProduct và gọi đúng phiên bản getDisplayPrice() (hoặc phiên bản mặc định trong Product nếu không bị override) cho đối tượng đó. Điều này giúp code linh hoạt và dễ mở rộng (bạn có thể thêm các loại sản phẩm mới mà không cần thay đổi logic tính tổng giá trong ShoppingCart).

Comments

There are no comments at the moment.