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

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:
- Định nghĩa một
Class
có tênBook
. - Lớp
Book
có các thuộc tính:title
(chuỗi),author
(chuỗi), vàyearPublished
(số). - Sử dụng
constructor
để khởi tạo các thuộc tính này khi tạo một đối tượngBook
mới. - Thêm một phương thức
getDetails()
trả về chuỗi mô tả chi tiết về cuốn sách. - Tạo một vài đối tượng
Book
và gọi phương thứcgetDetails()
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ínhtitle
,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óanew
. 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ụngthis
).- 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(...)
và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ớpBook
. 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:
- Tạo một lớp mới có tên
EBook
kế thừa từ lớpBook
đã định nghĩa ở Bài tập 1. - Lớp
EBook
có thêm thuộc tínhfileSize
(số, đơn vị MB) vàformat
(chuỗi, ví dụ: "PDF", "EPUB"). - Sử dụng
constructor
trongEBook
để khởi tạo cả thuộc tính của lớp cha và các thuộc tính mới. - Override phương thức
getDetails()
để nó bao gồm thông tin về kích thước file và định dạng. - Tạo một đối tượng
EBook
và gọi phương thứcgetDetails()
.
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ớpEBook
kế thừa tất cả các thuộc tính và phương thức public hoặc protected từ lớpBook
. - Trong
constructor
của lớp con (EBook
), chúng ta phải gọisuper()
trước khi truy cập hoặc sử dụngthis
.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()
trongEBook
với cùng tên và chữ ký (số lượng và kiểu tham số) là override. Khi bạn gọigetDetails()
trên một đối tượngEBook
, phiên bản trong lớpEBook
sẽ được thực thi thay vì phiên bản trong lớpBook
.
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:
- Định nghĩa một
Class
có tênBankAccount
. - 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
). - Sử dụng
constructor
để khởi tạo số dư ban đầu (chỉ cho phép số dương hoặc bằng 0). - 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. - 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. - Thêm phương thức
getBalance()
(lấy số dư). Phương thức này phải là công khai (public
). - (Tùy chọn nâng cao) Sử dụng cú pháp
getter
cho thuộc tínhbalance
. - 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óaprivate
. Điều này có nghĩa là nó chỉ có thể được truy cập từ bên trong chính lớpBankAccount
. Mọi nỗ lực truy cậpmyAccount._balance
từ bên ngoài lớp sẽ gây lỗi TypeScript. - Các phương thức
deposit
,withdraw
,getBalance
(và getterbalance
) mặc định làpublic
. Chúng cung cấp giao diện công khai để tương tác với đối tượngBankAccount
. 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:
- Định nghĩa một lớp cha (có thể là trừu tượng -
abstract class
hoặc sử dụnginterface
) có tênAnimal
. Lớp này có một phương thứcmakeSound()
. - Tạo hai lớp con:
Dog
vàCat
kế thừa từAnimal
. - 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!"). - Tạo một mảng chứa các đối tượng của cả
Dog
vàCat
nhưng được khai báo là kiểuAnimal[]
. - 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ứcmakeSound()
là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ùnginterface
, nó sẽ chỉ là định nghĩa chữ ký phương thức mà không có cài đặt).- Lớp
Dog
vàCat
implement (cung cấp cài đặt cụ thể) cho phương thứcmakeSound()
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
hayCat
) và thực thi đúng phiên bảnmakeSound()
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:
- 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ố). - 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). - Tạo các lớp con kế thừa từ
Product
, ví dụ:ElectronicsProduct
(thêm thuộc tínhwarrantyMonths
) vàBookProduct
(thêm thuộc tínhauthor
). - 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ệ). - Tạo một lớp
ShoppingCart
có thuộc tính là một mảng cácProduct
. - Lớp
ShoppingCart
có phương thứcaddProduct(product: Product)
để thêm sản phẩm vào giỏ. - Lớp
ShoppingCart
có phương thứcgetTotalPrice()
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ọigetDisplayPrice()
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ứcgetDisplayPrice()
mặc định. Nó làabstract
và có thêm phương thứcgetInfo
abstract
để buộc các lớp con cung cấp thông tin chi tiết riêng. ElectronicsProduct
vàBookProduct
kế thừa từProduct
, thêm các thuộc tính đặc trưng và implement phương thứcgetInfo()
. Chúng có thể overridegetDisplayPrice()
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ảngitems
(là mảng kiểuProduct
). Khi gọiitem.getDisplayPrice()
, TypeScript/JavaScript thực hiện đa hình: nó nhận diện đối tượng thực tế làElectronicsProduct
hayBookProduct
và gọi đúng phiên bảngetDisplayPrice()
(hoặc phiên bản mặc định trongProduct
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á trongShoppingCart
).
Comments