Buổi 6: Lập trình hướng đối tượng (2): kế thừa, trừu tượng, đa hình
Giả sử ngân hàng của bạn có ba loại tài khoản: tiết kiệm, thanh toán và tín dụng. Cả ba đều có số tài khoản, chủ tài khoản, số dư, đều nạp và rút tiền được. Nhưng tài khoản tiết kiệm có lãi suất, tài khoản thanh toán có hạn mức thấu chi, còn tài khoản tín dụng có phí thường niên. Nếu viết ba lớp riêng, bạn sẽ chép đi chép lại phần chung ba lần; sửa một lỗi ở deposit là phải sửa ba nơi. Nếu gộp tất cả vào một lớp duy nhất với một biến type và hàng loạt if/else, mỗi lần thêm loại tài khoản mới bạn lại phải mở lại những đoạn mã đã chạy ổn định.
Buổi 5 bạn đã biết đóng gói dữ liệu vào một lớp. Buổi này bạn học ba công cụ để tổ chức nhiều lớp có quan hệ với nhau: kế thừa (inheritance) để dùng lại phần chung, trừu tượng (abstraction) để chỉ nêu "làm gì" mà chưa nói "làm thế nào", và đa hình (polymorphism) để một đoạn mã duy nhất xử lý được mọi loại đối tượng. Đây là phần làm nên sức mạnh thật sự của OOP, và cũng là phần quyết định bạn đọc hiểu được thư viện chuẩn của Java (các lớp ngoại lệ, List, Map, luồng vào ra… đều dựng trên chính những khái niệm này).
Mục tiêu buổi học
- Sau buổi này bạn có thể dùng
extendsvàsuperđể xây dựng cây kế thừa, và nói đúng thứ tự các hàm tạo được gọi. - Sau buổi này bạn có thể phân biệt ghi đè (override) với chồng hàm (overload), dùng
@Overridevàprotectedđúng chỗ. - Sau buổi này bạn có thể khai báo lớp trừu tượng và phương thức trừu tượng, biết khi nào chọn lớp trừu tượng.
- Sau buổi này bạn có thể định nghĩa và cài đặt interface (kể cả nhiều interface, phương thức
defaultvàstaticcủa Java 8) và so sánh với lớp trừu tượng. - Sau buổi này bạn có thể giải thích và vận dụng đa hình: ép kiểu lên/xuống, gọi phương thức động (dynamic dispatch), kiểm tra bằng
instanceof. - Sau buổi này bạn có thể dùng
finalđể chặn ghi đè/kế thừa và tổ chức chương trình thành nhiều tệp, nhiều package.
Bản đồ buổi học
| Phần | Nội dung | Thời lượng gợi ý |
|---|---|---|
| 1 | Kế thừa: extends, super, thứ tự gọi hàm tạo |
30 phút (gồm thực hành) |
| 2 | Ghi đè và chồng hàm, protected |
25 phút |
| 3 | Lớp trừu tượng | 25 phút (gồm thực hành) |
| 4 | Interface: hợp đồng, default, static |
30 phút (gồm thực hành) |
| 5 | Đa hình: upcast, downcast, dynamic dispatch, instanceof |
30 phút (gồm thực hành) |
| 6 | final, tổ chức nhiều tệp và package |
15 phút (gồm thực hành) |
| 7 | Thiết kế tổng hợp: kế thừa hay thành phần | 25 phút (thực hành tổng hợp) |
Khoảng 55–60% thời gian là thực hành: gõ lại các ví dụ có nút Chạy thử, đoán kết quả trước khi bấm, rồi làm các nhóm bài tập chấm tự động.
1. Kế thừa
1.1. Vì sao cần kế thừa
Quan sát hai lớp sau: cả hai đều có name và speak() nhưng mỗi lớp lại tự khai báo lại.
class Dog { String name; void eat() { System.out.println(name + " an"); } void bark() { System.out.println("Gau"); } }
class Cat { String name; void eat() { System.out.println(name + " an"); } void meow() { System.out.println("Meo"); } }Phần giống nhau (name, eat) bị lặp. Kế thừa cho phép bạn viết phần chung một lần trong lớp cha (superclass, còn gọi là lớp cơ sở), rồi để các lớp con (subclass, lớp dẫn xuất) nhận lại toàn bộ và chỉ viết thêm phần riêng. Quan hệ này đọc là "là một" (is-a): chó là một động vật, tài khoản tiết kiệm là một tài khoản.
Lớp con extends lớp cha (is-a) và ghi đè @Override area(); Square không ghi đè nên dùng area() của Rectangle. Interface Drawable được hiện thực bằng implements.
Hình trên là cây kế thừa của các hình học: lớp cha ở trên, các lớp con ở dưới và mũi tên chỉ từ con lên cha. Hãy để ý hai điều: mỗi lớp con chỉ có một lớp cha trực tiếp, và lớp con có thể ghi đè (viết lại) hành vi mà nó thừa hưởng.
| Thuật ngữ | Ý nghĩa |
|---|---|
| Lớp cha / superclass / base class | Lớp được kế thừa, chứa phần chung |
| Lớp con / subclass / derived class | Lớp kế thừa, thêm hoặc sửa hành vi |
extends |
Từ khóa khai báo quan hệ kế thừa |
| Kế thừa đơn | Mỗi lớp chỉ extends được một lớp cha (Java không cho đa kế thừa lớp) |
Object |
Gốc của cây: lớp không ghi extends sẽ ngầm kế thừa Object |
1.2. Cú pháp extends và những gì được thừa hưởng
class Vehicle {
String brand;
int wheels;
void info() {
System.out.println(brand + " co " + wheels + " banh");
}
}
class Car extends Vehicle {
int doors; // phần riêng của Car
void openDoors() {
System.out.println(brand + " mo " + doors + " cua");
}
}
public class Main {
public static void main(String[] args) {
Car c = new Car();
c.brand = "Toyota"; // thuộc tính thừa hưởng từ Vehicle
c.wheels = 4;
c.doors = 5;
c.info(); // phương thức thừa hưởng
c.openDoors();
}
}Car không hề khai báo brand, wheels hay info() nhưng vẫn dùng được vì thừa hưởng từ Vehicle. Một đối tượng Car thực chất gồm phần của Vehicle cộng phần của Car.
Quy tắc thừa hưởng cần nhớ:
- Lớp con thừa hưởng mọi thành viên không
privatecủa lớp cha. Thành viênprivatevẫn nằm trong đối tượng nhưng lớp con không truy cập trực tiếp được — phải đi qua getter/setter hoặc phương thứcprotected/publiccủa lớp cha (đóng gói của buổi 5 vẫn giữ nguyên). - Hàm tạo không được thừa hưởng; mỗi lớp tự có hàm tạo của mình.
- Thành viên
staticcũng truy cập được qua lớp con, nhưng bản chất vẫn thuộc lớp cha.
Lưu ý: Chỉ kế thừa khi thật sự có quan hệ "là một". Nếu chỉ để "mượn tạm vài hàm" (ví dụ cho
Studentkế thừaArrayListvì muốn cóadd), bạn đang lạm dụng kế thừa. Quan hệ "có một" (một lớp chứa một đối tượng của lớp khác, đã học ở buổi 5) mới là lựa chọn đúng. Phần 7 sẽ quay lại câu hỏi này.
1.3. super: gọi thành viên và hàm tạo của lớp cha
Từ khóa super là "tham chiếu tới phần lớp cha" của đối tượng hiện tại. Nó có hai cách dùng:
super(...): gọi hàm tạo của lớp cha — để khởi tạo phần dữ liệu mà lớp cha quản lý.super.tenPhuongThuc(...): gọi phiên bản của lớp cha khi lớp con đã ghi đè phương thức đó.
Khi lớp cha đã đóng gói (private), chính hàm tạo của lớp cha là nơi duy nhất gán giá trị cho các trường ấy, nên lớp con phải nhờ super(...).
class Account {
private final String id;
protected double balance;
Account(String id, double balance) {
this.id = id;
this.balance = balance;
}
String getId() { return id; }
}
class SavingsAccount extends Account {
private final double rate;
SavingsAccount(String id, double balance, double rate) {
super(id, balance); // phải là câu lệnh đầu tiên
this.rate = rate;
}
double interest() { return balance * rate; }
}
public class Main {
public static void main(String[] args) {
SavingsAccount s = new SavingsAccount("TK01", 2_000_000, 0.05);
System.out.println(s.getId() + " lai: " + s.interest());
}
}Hai điểm đáng chú ý: super(id, balance) phải là câu lệnh đầu tiên trong hàm tạo của lớp con; và id là private nên lớp con chỉ đọc được qua getId().
1.4. Thứ tự gọi hàm tạo
Khi bạn viết new C() với C kế thừa B kế thừa A, Java dựng đối tượng từ gốc xuống ngọn: hàm tạo của A chạy xong trước, rồi tới B, cuối cùng mới tới C. Lý do dễ hiểu: phần của lớp con có thể dựa vào phần của lớp cha, nên phần cha phải sẵn sàng trước.
class A {
A() { System.out.println("A()"); }
}
class B extends A {
B() { System.out.println("B()"); }
}
class C extends B {
C() {
System.out.println("C() bat dau");
System.out.println("C() xong");
}
}
public class Main {
public static void main(String[] args) {
new C();
}
}Kết quả in A(), B(), rồi hai dòng của C(). Dù bạn không viết super() ở B hay C, trình biên dịch tự chèn super(); (gọi hàm tạo không tham số của lớp cha) vào đầu mỗi hàm tạo không có this(...)/super(...) tường minh.
Cảnh báo: Nếu lớp cha chỉ có hàm tạo có tham số (không có hàm tạo mặc định), lớp con bắt buộc gọi
super(đối_số)tường minh. Quên điều này gây lỗi biên dịchconstructor Account in class Account cannot be applied to given types.
class Account {
Account(String id) { } // không có Account()
}
class Savings extends Account {
Savings() { } // LỖI: super() ngầm định không tìm thấy Account()
}Mẹo: Khi gặp lỗi trên, thay vì thêm một hàm tạo rỗng vào lớp cha cho "hết lỗi", hãy hỏi: lớp con nên truyền giá trị gì lên cha? Thường câu trả lời là thêm tham số vào hàm tạo của lớp con rồi gọi
super(...).
Vừa học xong extends, super và thứ tự hàm tạo, hãy làm các bài kế thừa sau (từ hai tầng đến ba tầng, có gọi super(...) và super.method()) để chắc tay trước khi sang phần ghi đè.
2. Ghi đè và chồng hàm
2.1. Ghi đè phương thức (override)
Lớp con thừa hưởng phương thức của lớp cha, nhưng đôi khi cách làm của cha không còn đúng với con. Ghi đè (override) là viết lại phương thức đó trong lớp con với cùng tên, cùng danh sách tham số. Khi gọi phương thức trên đối tượng của lớp con, phiên bản mới được dùng.
class Animal {
String name;
Animal(String name) { this.name = name; }
String sound() { return "..."; }
void intro() {
System.out.println(name + " keu: " + sound());
}
}
class Dog extends Animal {
Dog(String name) { super(name); }
@Override
String sound() { return "Gau gau"; }
}
public class Main {
public static void main(String[] args) {
new Animal("Sinh vat").intro();
new Dog("Lu").intro();
}
}Để ý intro() chỉ viết một lần ở Animal nhưng khi gọi trên Dog, dòng sound() bên trong lại chạy phiên bản của Dog. Đây là hạt giống của đa hình mà phần 5 sẽ khai thác.
Quy tắc ghi đè:
- Cùng tên và cùng danh sách tham số (cùng chữ ký).
- Kiểu trả về giống nhau, hoặc là kiểu con của kiểu trả về gốc (covariant return).
- Quyền truy cập không được hẹp hơn (cha
protectedthì con đượcprotectedhoặcpublic, không đượcprivate). - Không ghi đè được phương thức
final,statichoặcprivate. - Luôn thêm chú thích
@Override: nếu bạn gõ sai chữ ký (ví dụsond()), trình biên dịch báo lỗi ngay thay vì âm thầm tạo một phương thức mới.
Khi chỉ muốn bổ sung chứ không thay thế hoàn toàn, gọi lại bản của cha bằng super.tenPhuongThuc():
class Vehicle {
String info() { return "Phuong tien"; }
}
class Car extends Vehicle {
@Override
String info() {
return super.info() + " > Oto 4 cho";
}
}
class SportsCar extends Car {
@Override
String info() {
return super.info() + " > Xe the thao";
}
}
public class Main {
public static void main(String[] args) {
System.out.println(new SportsCar().info());
}
}Mỗi tầng thêm một mẩu thông tin, và chuỗi in ra cho thấy lời gọi super.info() đi ngược lên từng lớp cha.
Mẹo: Hãy tập thói quen ghi
@Overridecho mọi phương thức ghi đè, kể cảtoString,equals,hashCodebạn đã làm ở buổi 5. IDE cũng tự đề xuất chú thích này.
2.2. Ghi đè khác chồng hàm ở điểm nào
Hai khái niệm này hay bị nhầm vì tên gọi nghe giống nhau. Cách phân biệt nhanh: chồng hàm (overload) là nhiều phương thức cùng tên khác tham số trong cùng một lớp (hoặc cả cây kế thừa), do trình biên dịch chọn lúc biên dịch; ghi đè (override) là lớp con thay thế phương thức cùng chữ ký của lớp cha, do JVM chọn lúc chạy theo đối tượng thật.
| Tiêu chí | Chồng hàm (overload) | Ghi đè (override) |
|---|---|---|
| Quan hệ | Cùng lớp (hoặc thừa hưởng) | Giữa lớp con và lớp cha |
| Tham số | Phải khác | Phải giống |
| Kiểu trả về | Tùy ý | Giống hoặc kiểu con |
| Quyết định lúc | Biên dịch (theo kiểu khai báo của đối số) | Chạy (theo kiểu thật của đối tượng) |
| Gọi là | Đa hình lúc biên dịch | Đa hình lúc chạy |
Ví dụ sau cố tình đặt hai kiểu quyết định cạnh nhau. Hãy đoán kết quả trước khi bấm chạy.
class Animal { }
class Dog extends Animal { }
class Printer {
static void show(Animal a) { System.out.println("show(Animal)"); }
static void show(Dog d) { System.out.println("show(Dog)"); }
}
public class Main {
public static void main(String[] args) {
Animal a = new Dog();
Dog d = new Dog();
Printer.show(a); // overload chon theo kieu KHAI BAO cua a
Printer.show(d);
}
}Dòng đầu in show(Animal) dù đối tượng thật là Dog, vì với chồng hàm trình biên dịch chỉ nhìn kiểu khai báo của biến (Animal). Ngược lại, với ghi đè, JVM nhìn đối tượng thật. Đây là phân biệt hay xuất hiện trong phỏng vấn.
2.3. Phạm vi truy cập và protected
Bạn đã biết private và public từ buổi 5. Khi có kế thừa, mức protected trở nên hữu ích: thành viên protected được truy cập từ chính lớp đó, các lớp con (kể cả khác package) và các lớp cùng package.
| Modifier | Cùng lớp | Cùng package | Lớp con (khác package) | Nơi khác |
|---|---|---|---|---|
private |
có | không | không | không |
| (mặc định, không ghi) | có | có | không | không |
protected |
có | có | có | không |
public |
có | có | có | có |
class Employee {
private final String name;
protected double baseSalary; // lop con duoc dung truc tiep
Employee(String name, double baseSalary) {
this.name = name;
this.baseSalary = baseSalary;
}
String getName() { return name; }
}
class Manager extends Employee {
Manager(String name, double baseSalary) { super(name, baseSalary); }
double salary() { return baseSalary * 1.5; } // dung baseSalary truc tiep
}
public class Main {
public static void main(String[] args) {
Manager m = new Manager("Huong", 20_000_000);
System.out.println(m.getName() + ": " + m.salary());
}
}Lưu ý:
protectedlà "cửa hé" cho lớp con, nhưng vẫn làm yếu đóng gói vì lớp con (do người khác viết) có thể gán sai. Quy tắc thực tế: ưu tiênprivate+ getter/setter; chỉ dùngprotectedcho phương thức hỗ trợ lớp con, hạn chế dùng cho thuộc tính.
3. Lớp trừu tượng
3.1. Khi lớp cha quá "chung chung"
Ở cây hình học, hỏi "diện tích của một Shape bằng bao nhiêu?" là câu hỏi vô nghĩa: chỉ khi biết đó là hình tròn hay hình chữ nhật mới tính được. Lớp Shape tồn tại để gom điểm chung và định ra hợp đồng ("mọi hình đều phải tính được diện tích") chứ không nên tạo đối tượng Shape đơn thuần.
Lớp trừu tượng (abstract class) khai báo bằng từ khóa abstract. Nó:
- không thể tạo đối tượng trực tiếp (
new Shape()báo lỗi biên dịch); - có thể chứa phương thức trừu tượng: chỉ có chữ ký, không có thân, kết thúc bằng dấu
;. Mọi lớp con không trừu tượng buộc phải cài đặt (ghi đè) chúng; - vẫn có thể chứa thuộc tính, hàm tạo và phương thức thường (đã cài đặt sẵn).
abstract class Shape {
private final String name;
Shape(String name) { this.name = name; }
abstract double area(); // chi co chu ky
void describe() { // phuong thuc thuong, dung lai duoc
System.out.printf("%s: dien tich %.2f%n", name, area());
}
}
class Circle extends Shape {
private final double r;
Circle(double r) { super("Tron"); this.r = r; }
@Override double area() { return Math.PI * r * r; }
}
class Rect extends Shape {
private final double w, h;
Rect(double w, double h) { super("Chu nhat"); this.w = w; this.h = h; }
@Override double area() { return w * h; }
}
public class Main {
public static void main(String[] args) {
new Circle(1).describe();
new Rect(3, 4).describe();
}
}describe() được viết một lần ở Shape nhưng gọi area() mà mỗi lớp con cài đặt riêng. Cách tổ chức "khung thuật toán ở lớp cha, chi tiết ở lớp con" này gọi là mẫu Template Method và rất phổ biến.
Shape s = new Shape("x"); // LỖI: Shape is abstract; cannot be instantiated
class Triangle extends Shape { // LỖI: Triangle is not abstract and does not
Triangle() { super("Tam giac"); } // override abstract method area() in Shape
}Hai lỗi biên dịch trên là điều bạn sẽ gặp thường xuyên: không tạo được đối tượng của lớp trừu tượng, và lớp con quên cài đặt một phương thức trừu tượng.
Lưu ý: Một lớp có ít nhất một phương thức trừu tượng thì bắt buộc phải khai báo
abstract. Ngược lại một lớpabstractcó thể không có phương thức trừu tượng nào (chỉ để cấm tạo đối tượng trực tiếp).
3.2. Lớp con cũng có thể trừu tượng
Nếu lớp con chưa cài đặt hết các phương thức trừu tượng thì nó cũng phải là abstract; các lớp cháu mới cài đặt nốt. Nhờ đó ta xây được nhiều tầng khái niệm: Employee (trừu tượng) → Salaried (trừu tượng, cài đặt cách trả lương cố định) → Manager, Engineer.
3.3. Ví dụ: bảng lương nhiều loại nhân viên
abstract class Employee {
protected final String name;
Employee(String name) { this.name = name; }
abstract double salary();
String payslip() {
return String.format("%-6s %.0f", name, salary());
}
}
class FullTime extends Employee {
private final double monthly;
FullTime(String name, double monthly) { super(name); this.monthly = monthly; }
@Override double salary() { return monthly; }
}
class PartTime extends Employee {
private final int hours;
private final double rate;
PartTime(String n, int hours, double rate) { super(n); this.hours = hours; this.rate = rate; }
@Override double salary() { return hours * rate; }
}
public class Main {
public static void main(String[] args) {
Employee[] list = { new FullTime("An", 15_000_000), new PartTime("Binh", 80, 50_000) };
for (Employee e : list) System.out.println(e.payslip());
}
}Hãy chú ý mảng kiểu Employee[] chứa được cả hai loại nhân viên, và vòng lặp không cần biết loại nào — đó chính là đa hình, sẽ được giải thích kỹ ở phần 5.
Ví dụ thực tế: Hệ thống tính phí vận chuyển có lớp trừu tượng
Parcelvới phương thức trừu tượngfee(). Bưu kiện thường tính theo cân nặng, bưu kiện nhanh cộng phụ phí, hàng cồng kềnh tính theo thể tích. Mỗi loại là một lớp con; thêm loại mới không phải sửa mã của các loại cũ.
Vừa học xong lớp trừu tượng và phương thức trừu tượng, hãy làm các bài sau: khai báo khung ở lớp cha, để mỗi lớp con cài đặt công thức riêng, rồi xử lý chúng qua kiểu của lớp cha.
4. Interface
4.1. Interface là một hợp đồng
Lớp trừu tượng cho phép chia sẻ cả mã lẫn khung, nhưng mỗi lớp chỉ kế thừa được một lớp cha. Interface (giao diện) giải quyết nhu cầu khác: mô tả một tập khả năng mà các lớp hoàn toàn không liên quan nhau đều có thể cam kết. Một con chim, một chiếc máy bay và một siêu nhân đều "bay được" dù chẳng có chung tổ tiên.
Interface khai báo bằng interface; lớp cam kết thực hiện hợp đồng bằng implements và phải cài đặt mọi phương thức của nó (trừ khi lớp đó là abstract). Các phương thức trong interface mặc định là public abstract, nên bạn không cần viết hai từ khóa đó.
interface Notifier {
void send(String message); // ngam dinh: public abstract
}
class EmailNotifier implements Notifier {
@Override
public void send(String message) { // phai la public
System.out.println("[Email] " + message);
}
}
class SmsNotifier implements Notifier {
@Override
public void send(String message) {
System.out.println("[SMS] " + message.toUpperCase());
}
}
public class Main {
public static void main(String[] args) {
Notifier[] channels = { new EmailNotifier(), new SmsNotifier() };
for (Notifier n : channels) n.send("Ban da dat hang");
}
}Chú ý public bắt buộc ở phương thức cài đặt, vì phương thức trong interface là public và ghi đè không được thu hẹp quyền truy cập. Biến kiểu Notifier giữ được đối tượng của bất kỳ lớp nào cài đặt nó; code gửi thông báo không cần biết cụ thể là email hay SMS.
Những quy tắc cơ bản của interface:
- Không có hàm tạo, không tạo được đối tượng trực tiếp (
new Notifier()là lỗi), nhưng dùng làm kiểu cho biến, tham số, kiểu trả về. - Thuộc tính khai báo trong interface ngầm định là
public static final— tức hằng số. - Một interface có thể
extendsmột hoặc nhiều interface khác. - Quy ước đặt tên: danh từ hoặc tính từ chỉ khả năng (
Comparable,Runnable,Drawable,Payable).
4.2. Cài đặt nhiều interface
Vì không có thân phương thức cần kế thừa, một lớp được phép implements nhiều interface (cách Java hỗ trợ "đa kế thừa kiểu"), đồng thời vẫn extends được một lớp cha. Cú pháp: class A extends Cha implements I1, I2.
interface Flyable { String fly(); }
interface Swimmable { String swim(); }
class Duck implements Flyable, Swimmable {
@Override public String fly() { return "Vit bay thap"; }
@Override public String swim() { return "Vit boi"; }
}
class Fish implements Swimmable {
@Override public String swim() { return "Ca boi nhanh"; }
}
public class Main {
public static void main(String[] args) {
Duck d = new Duck();
Swimmable s = new Fish();
System.out.println(d.fly());
System.out.println(d.swim());
System.out.println(s.swim());
}
}Duck đáp ứng hai hợp đồng nên đưa vào chỗ cần Flyable hay chỗ cần Swimmable đều được. Đây là điều lớp trừu tượng không làm nổi.
4.3. Phương thức default và static (Java 8)
Trước Java 8, thêm một phương thức vào interface sẽ làm vỡ mọi lớp đã cài đặt nó. Java 8 cho phép interface chứa:
defaultmethod: có thân, lớp cài đặt được dùng luôn hoặc ghi đè nếu muốn;staticmethod: hàm tiện ích gắn với interface, gọi qua tên interface (TenInterface.ham()), không được thừa hưởng.
interface Greeter {
String name(); // bat buoc cai dat
default String greet() { // dung duoc ngay
return "Xin chao, " + name() + "!";
}
static Greeter of(String n) { // ham tien ich
return () -> n; // lambda, se hoc o buoi 10
}
}
class Teacher implements Greeter {
public String name() { return "Thay Lam"; }
@Override public String greet() { return "Chao lop, toi la " + name(); }
}
public class Main {
public static void main(String[] args) {
System.out.println(new Teacher().greet());
System.out.println(Greeter.of("Lan").greet());
}
}Teacher ghi đè greet() còn Greeter.of("Lan") dùng bản default. Đừng lo về cú pháp lambda ở of; bạn sẽ học ở buổi 10, ở đây chỉ cần hiểu: interface chỉ có một phương thức trừu tượng có thể viết gọn như vậy.
Mở rộng (Java 14+): Từ Java 9 interface còn có phương thức
privateđể chia sẻ mã giữa cácdefault method. Từ Java 17 cósealed interfaceđể giới hạn ai được phép cài đặt. Máy chấm của khóa dùng Java 13 nên bạn chỉ cần biết sự tồn tại của chúng.
4.4. Xung đột default: bạn phải tự quyết
Nếu một lớp cài đặt hai interface cùng có default method trùng chữ ký, trình biên dịch không tự chọn — bạn bắt buộc phải ghi đè phương thức đó, và có thể chọn bản nào dùng bằng cú pháp TenInterface.super.method().
interface Printer { default String hello() { return "Printer"; } }
interface Scanner2 { default String hello() { return "Scanner"; } }
class Copier implements Printer, Scanner2 {
@Override
public String hello() {
return Printer.super.hello() + " + " + Scanner2.super.hello();
}
}
public class Main {
public static void main(String[] args) {
System.out.println(new Copier().hello());
}
}Nếu không ghi đè hello(), Copier không biên dịch được (lỗi báo hai interface có default method không tương thích). Một luật khác: khi lớp cha đã có một phương thức cùng chữ ký với default của interface, phương thức của lớp cha luôn thắng.
4.5. Interface hay lớp trừu tượng?
| Tiêu chí | Lớp trừu tượng | Interface |
|---|---|---|
| Số lượng được kế thừa/cài đặt | Chỉ một lớp cha | Nhiều interface |
| Thuộc tính thể hiện (có trạng thái) | Có | Không (chỉ hằng) |
| Hàm tạo | Có | Không |
| Phương thức có thân | Có (mọi mức truy cập) | Chỉ default/static |
| Diễn tả | "là một" kiểu cha (quan hệ gốc rễ) | "có khả năng" (vai trò) |
| Ví dụ | Shape, Employee |
Comparable, Drawable, Flyable |
Mẹo: Một cách chọn nhanh: cần chia sẻ dữ liệu và mã chung cho các lớp cùng họ → lớp trừu tượng. Cần quy định hành vi cho các lớp không cùng họ hoặc cần cài đặt nhiều vai trò → interface. Thực tế, rất hay kết hợp: lớp trừu tượng cài đặt một phần interface rồi để lớp con hoàn thiện nốt.
Vừa học xong interface, default method và cài đặt nhiều interface, hãy làm các bài sau để thực hành khai báo hợp đồng, cài đặt cho nhiều lớp và giải quyết xung đột default.
5. Đa hình
5.1. Upcasting và biến kiểu cha
Đa hình (polymorphism, "nhiều hình thái") là khả năng dùng một kiểu tham chiếu của lớp cha (hoặc interface) để làm việc với nhiều loại đối tượng con, và mỗi đối tượng phản ứng theo cách của riêng nó. Nền tảng gồm hai thao tác:
- Upcasting: gán đối tượng con cho biến kiểu cha —
Animal a = new Dog("Lu");. Việc này an toàn và ngầm định vì chó "là một" động vật. - Dynamic dispatch (gọi phương thức động): khi gọi
a.sound(), JVM xem đối tượng thật màađang trỏ tới (lúc chạy) để chọn phiên bảnsound()tương ứng, chứ không xem kiểu khai báo củaa.
Do đó có hai khái niệm "kiểu" cần phân biệt: kiểu khai báo / kiểu tĩnh (Animal) quyết định những phương thức nào được phép gọi (kiểm tra lúc biên dịch), và kiểu thật / kiểu động (Dog) quyết định phiên bản nào chạy (lúc chạy).
Đa hình: gọi phương thức qua biến kiểu cha
Kiểu tĩnh quyết định được gọi gì; kiểu động quyết định bản nào chạy- speak()
- eat()
- speak()@Override
- fetch()
- eat() ← kế thừa
- speak()@Override
- eat() ← kế thừa
Animal a = new Dog(); a.speak();
- 1.Biên dịch: kiểu tĩnh Animal có speak() ✓
Hãy dùng mô phỏng trên: chọn đối tượng thật là Dog, Cat hay Animal rồi quan sát phương thức nào được gọi, cho thấy kiểu của biến không đổi kết quả mà đối tượng thật mới quyết định.
class Animal {
String sound() { return "..."; }
}
class Dog extends Animal {
@Override String sound() { return "Gau"; }
}
class Cat extends Animal {
@Override String sound() { return "Meo"; }
}
public class Main {
public static void main(String[] args) {
Animal[] zoo = { new Dog(), new Cat(), new Animal(), new Dog() };
for (Animal a : zoo) {
System.out.println(a.sound()); // moi doi tuong tu chon phien ban
}
}
}Vòng lặp chỉ biết Animal, nhưng lần lượt in Gau, Meo, ..., Gau. Muốn thêm Cow, bạn chỉ cần viết lớp mới; vòng lặp không phải sửa. Đó là nguyên tắc mở để mở rộng, đóng để sửa đổi (open/closed) — lợi ích lớn nhất của đa hình.
5.2. Đa hình thay cho chuỗi if/else theo loại
Không dùng đa hình, bạn sẽ gặp mã dạng "kiểm tra loại rồi tính", mỗi lần thêm loại phải sửa chỗ này. Với đa hình, hàm tính tổng phí vận chuyển dưới đây không cần biết có bao nhiêu loại bưu kiện.
abstract class Parcel {
protected final double kg;
Parcel(double kg) { this.kg = kg; }
abstract double fee();
}
class Normal extends Parcel {
Normal(double kg) { super(kg); }
@Override double fee() { return kg * 10_000; }
}
class Express extends Parcel {
Express(double kg) { super(kg); }
@Override double fee() { return kg * 10_000 + 20_000; } // phu phi giao nhanh
}
public class Main {
static double total(Parcel[] items) {
double sum = 0;
for (Parcel p : items) sum += p.fee(); // khong can if/else
return sum;
}
public static void main(String[] args) {
Parcel[] cart = { new Normal(2), new Express(1.5), new Normal(0.5) };
System.out.println(total(cart));
}
}Hàm total không hề nhắc tới Normal hay Express, nên thêm Bulky (hàng cồng kềnh) không đụng tới nó. So với if (p instanceof Express) … else …, mã gọn hơn và an toàn hơn.
5.3. Chỉ gọi được những gì kiểu khai báo cho phép
Với biến kiểu cha, bạn chỉ gọi được các phương thức được khai báo trong kiểu cha (hoặc tổ tiên của nó), dù đối tượng thật có thêm phương thức riêng.
Animal a = new Dog();
a.sound(); // OK: Animal có sound()
a.fetch(); // LỖI biên dịch: cannot find symbol — fetch() chỉ có ở DogMuốn gọi fetch(), bạn phải ép kiểu xuống (downcast) về Dog.
5.4. Downcasting và instanceof
Downcasting là ép biến kiểu cha về kiểu con: Dog d = (Dog) a;. Việc này không an toàn: nếu đối tượng thật không phải Dog, JVM ném ClassCastException lúc chạy. Toán tử instanceof kiểm tra kiểu thật trước khi ép:
class Animal { }
class Dog extends Animal { String fetch() { return "Lu nhat bong"; } }
class Cat extends Animal { }
public class Main {
public static void main(String[] args) {
Animal[] zoo = { new Dog(), new Cat() };
for (Animal a : zoo) {
if (a instanceof Dog) {
Dog d = (Dog) a; // an toan vi da kiem tra
System.out.println(d.fetch());
} else {
System.out.println("Khong phai Dog");
}
}
try {
Dog bad = (Dog) zoo[1]; // zoo[1] that su la Cat
} catch (ClassCastException e) {
System.out.println("Loi ep kieu: " + e.getClass().getSimpleName());
}
}
}instanceof trả true nếu đối tượng là thể hiện của kiểu đó hoặc kiểu con của nó, và luôn trả false với null. Ngược lại getClass() == Dog.class chỉ đúng khi kiểu thật chính xác là Dog; đó là lý do equals ở buổi 5 hay dùng getClass() khi muốn hai đối tượng cùng đúng một lớp.
Mở rộng (Java 14+): Từ Java 16 có
instanceofkiểu mẫu (if (a instanceof Dog d) { … }) tự khai báo biếnd. Máy chấm dùng Java 13 nên khóa này chỉ viết kiểu cổ điển: kiểm tra rồi ép kiểu thủ công.
Cảnh báo: Nếu bạn thấy mình viết nhiều
instanceofrồi ép kiểu liên tiếp, hãy dừng lại và hỏi: có thể đưa hành vi vào phương thức ghi đè trong cây kế thừa không? Phần lớn trường hợp, câu trả lời là có, và mã sẽ gọn hơn nhiều.
5.5. Điều gì KHÔNG đa hình
Dynamic dispatch chỉ áp dụng cho phương thức thể hiện (instance method) bị ghi đè. Ba thứ sau không đa hình, chúng luôn được xác định theo kiểu khai báo của biến:
- Thuộc tính (field): lớp con khai báo trùng tên chỉ che khuất (hide) chứ không ghi đè.
- Phương thức
static: chỉ bị che khuất, gọi theo kiểu khai báo. - Phương thức
private: không được thừa hưởng nên không thể ghi đè.
class Base {
String tag = "Base.tag";
static String who() { return "Base.who"; }
String me() { return "Base.me"; }
}
class Derived extends Base {
String tag = "Derived.tag";
static String who() { return "Derived.who"; }
@Override String me() { return "Derived.me"; }
}
public class Main {
public static void main(String[] args) {
Base b = new Derived();
System.out.println(b.tag); // theo kieu khai bao
System.out.println(b.who()); // static: theo kieu khai bao
System.out.println(b.me()); // theo doi tuong that
}
}Chỉ b.me() in ra Derived.me; hai dòng còn lại in giá trị của Base. Vì vậy quy tắc thực hành: không khai báo lại thuộc tính trùng tên với lớp cha, và đừng tạo static trùng chữ ký với phương thức của lớp cha.
5.6. Cạm bẫy: gọi phương thức bị ghi đè trong hàm tạo
Hàm tạo của lớp cha chạy trước phần khởi tạo của lớp con. Nếu nó gọi một phương thức mà lớp con đã ghi đè, phiên bản của lớp con sẽ chạy khi các thuộc tính của lớp con chưa được gán.
class Parent {
Parent() {
System.out.println("Parent goi show(): " + show());
}
String show() { return "parent"; }
}
class Child extends Parent {
private String text = "da gan";
Child() { System.out.println("Child xong, text = " + text); }
@Override
String show() { return "text = " + text; } // text van la null luc nay
}
public class Main {
public static void main(String[] args) {
new Child();
}
}Dòng đầu in text = null, vì lúc hàm tạo Parent chạy thì text của Child chưa được gán. Quy tắc: không gọi phương thức có thể bị ghi đè từ hàm tạo.
Vừa học xong upcast, dynamic dispatch và đa hình qua interface/lớp trừu tượng, hãy làm các bài sau: tạo danh sách đối tượng hỗn hợp, xử lý chúng qua kiểu cha và để mỗi đối tượng tự quyết cách tính.
6. final và tổ chức mã nguồn
6.1. Từ khóa final
final nghĩa là "chốt, không thay đổi nữa". Tùy vị trí, nó có ba tác dụng khác nhau:
| Đặt trước | Ý nghĩa | Ví dụ |
|---|---|---|
| Biến / thuộc tính | Chỉ gán một lần; sau đó không gán lại được | final double RATE = 0.05; |
| Phương thức | Cấm lớp con ghi đè | final String id() { … } |
| Lớp | Cấm kế thừa | final class Money { … } |
Ví dụ nổi tiếng: String, Integer và các lớp bọc đều là final class, nên không ai có thể "phá" tính bất biến của chuỗi bằng cách kế thừa. Với thuộc tính final bạn bắt buộc gán trong hàm tạo hoặc ngay lúc khai báo, và đó là công cụ chính để tạo đối tượng bất biến (immutable).
class Person {
private final String name; // gan mot lan trong ham tao
private final int age;
Person(String name, int age) { this.name = name; this.age = age; }
final String describe() { // lop con khong duoc ghi de
return name + "/" + age;
}
}
public class Main {
public static void main(String[] args) {
Person p = new Person("An", 20);
System.out.println(p.describe());
final int[] data = {1, 2, 3};
data[0] = 99; // duoc: final chi khoa THAM CHIEU, khong khoa noi dung
System.out.println(data[0]);
// data = new int[2]; // LOI: cannot assign a value to final variable
}
}Điểm hay nhầm: final trên biến tham chiếu chỉ khóa việc trỏ sang đối tượng khác, không cấm sửa nội dung bên trong đối tượng. Muốn bất biến thật sự, mọi thuộc tính của lớp phải final và không có setter.
class Base { final void log() { } }
class Sub extends Base {
void log() { } // LỖI: log() in Sub cannot override log() in Base; overridden method is final
}
final class Money { }
class Dollar extends Money { } // LỖI: cannot inherit from final MoneyMẹo: Hãy khai báo
finalcho mọi thuộc tính không cần thay đổi. Bạn được lợi ba điều: lỗi gán lại bị bắt lúc biên dịch, đối tượng dễ lập luận hơn, và chương trình đa luồng (buổi 9) an toàn hơn.
6.2. Tổ chức nhiều tệp và package
Chương trình thật không nằm gọn trong một tệp. Quy ước của Java:
- Mỗi lớp (hoặc interface) công khai một tệp, tên tệp trùng tên lớp (
Account.java,Main.java). - Các lớp liên quan được gom vào một package (gói), tên thường là tên miền đảo ngược viết thường:
vn.edu.neu.bank. Package tạo không gian tên (tránh trùng tên lớp) và quyết định phạm vi truy cập mức mặc định/protected. - Cấu trúc thư mục phản ánh tên package.
src/
└── vn/edu/neu/bank/
├── Account.java // package vn.edu.neu.bank;
├── SavingsAccount.java // package vn.edu.neu.bank;
└── App.java // package vn.edu.neu.bank; có mainDòng khai báo đầu mỗi tệp và cách dùng lớp từ package khác:
// Tệp Account.java
package vn.edu.neu.bank;
public class Account { /* ... */ }
// Tệp Report.java thuộc package khác
package vn.edu.neu.report;
import vn.edu.neu.bank.Account; // import một lớp
import java.util.*; // import cả package (không gồm package con)
public class Report { Account a = new Account(); }Biên dịch và chạy từ thư mục gốc src bằng dòng lệnh (IntelliJ và VS Code làm việc này giúp bạn):
javac -d out vn/edu/neu/bank/*.java
java -cp out vn.edu.neu.bank.AppMột số điều cần nhớ:
- Lớp không ghi modifier có mức package-private: chỉ lớp cùng package thấy được. Đây là cách ẩn các lớp "nội bộ" của một module.
- Lớp
publicphải nằm trong tệp trùng tên; một tệp tối đa một lớppubliccấp cao nhất. java.lang(gồmString,Math,Object) được import tự động; mọi package khác phảiimporthoặc viết tên đầy đủ.- Hai lớp trùng tên ở hai package (
java.util.Datevàjava.sql.Date) thì dùng tên đầy đủ cho một trong hai.
Lưu ý: Trên Codelab và các máy chấm tự động, bài nhiều lớp thường được chia thành các tệp như
Animal.java,Dog.java,Main.javanằm cùng thư mục (không package, tức cùng "package mặc định"). Nhờ vậy các lớp thấy nhau ngay mà không cầnimport. Trong khung ví dụ chạy thử của bài này, ta đặt tất cả lớp trong một khối, lớpMaincómainở cuối.
Vừa học xong final và phạm vi truy cập theo package, hãy làm hai bài ngắn sau: tạo đối tượng bất biến và quan sát quyền truy cập mặc định giữa hai lớp cùng package.
7. Thiết kế tổng hợp: kế thừa hay thành phần
7.1. "Là một" hay "có một"
Một sai lầm kinh điển của người mới là coi kế thừa là cách chính để dùng lại mã. Hãy tự hỏi hai câu:
- "A có phải là một B không?" — Tài khoản tiết kiệm là một tài khoản: kế thừa hợp lý.
- "A có chứa một B không?" — Ô tô có một động cơ: dùng thành phần (composition): thuộc tính
Engine enginetrongCar.
Ví dụ kinh điển cho thấy kế thừa sai chỗ: cho Square kế thừa Rectangle. Hình vuông "là một" hình chữ nhật trong toán học, nhưng nếu Rectangle có setWidth và setHeight độc lập thì hình vuông không thể tuân thủ (đổi chiều rộng thì chiều cao cũng phải đổi). Mã dùng Rectangle sẽ chạy sai khi nhận một Square. Nguyên lý thay thế Liskov nói gọn: đối tượng lớp con phải dùng được ở bất cứ đâu dùng lớp cha mà không làm hỏng chương trình.
| Tình huống | Chọn |
|---|---|
| Lớp con là biến thể thật sự của lớp cha, giữ mọi cam kết của cha | Kế thừa |
| Chỉ muốn dùng lại vài chức năng | Thành phần (chứa đối tượng và ủy quyền) |
| Cần nhiều vai trò, không cùng họ | Interface |
| Hành vi thay đổi được lúc chạy (chọn thuật toán) | Thành phần + interface |
Mẹo: Nguyên tắc nổi tiếng: ưu tiên thành phần hơn kế thừa. Kế thừa gắn chặt con vào cha (cha đổi, con có thể hỏng); thành phần linh hoạt hơn. Các khuôn mẫu thiết kế như Strategy, Observer đều dựa trên interface + thành phần chứ không phải kế thừa sâu.
7.2. Ví dụ tổng hợp: cửa hàng với ba tầng
Ví dụ cuối kết hợp các công cụ hôm nay: lớp trừu tượng Product, hai lớp con, interface Taxable cho khả năng "chịu thuế", và một hàm tổng hợp hoạt động qua kiểu cha.
interface Taxable {
double TAX_RATE = 0.1; // hang so cua interface
default double tax(double price) { return price * TAX_RATE; }
}
abstract class Product {
protected final String name;
protected final double price;
Product(String name, double price) { this.name = name; this.price = price; }
abstract double finalPrice();
@Override public String toString() {
return String.format("%s: %.1f", name, finalPrice());
}
}
class Book extends Product { // sach khong chiu thue
Book(String n, double p) { super(n, p); }
@Override double finalPrice() { return price; }
}
class Laptop extends Product implements Taxable { // chiu thue 10%
Laptop(String n, double p) { super(n, p); }
@Override double finalPrice() { return price + tax(price); }
}
public class Main {
public static void main(String[] args) {
Product[] cart = { new Book("Java", 100), new Laptop("Mini", 1000) };
double sum = 0;
for (Product p : cart) {
System.out.println(p);
sum += p.finalPrice();
if (p instanceof Taxable) System.out.println(" (da gom thue)");
}
System.out.println("Tong: " + sum);
}
}Hãy đọc ví dụ theo ba lớp "kiểu": Product cho phép xử lý đồng loạt (đa hình qua lớp trừu tượng), Taxable đánh dấu khả năng bổ sung (interface), và instanceof Taxable hỏi một đối tượng có khả năng đó không mà không cần biết nó là lớp nào.
7.3. Lỗi biên dịch thường gặp khi làm với kế thừa
| Thông báo lỗi (rút gọn) | Nguyên nhân | Cách sửa |
|---|---|---|
constructor X in class X cannot be applied to given types |
Lớp cha không có hàm tạo mặc định, lớp con không gọi super(...) |
Thêm super(đối_số) ở đầu hàm tạo lớp con |
X is abstract; cannot be instantiated |
new lớp trừu tượng hoặc interface |
Tạo đối tượng lớp con cụ thể |
X is not abstract and does not override abstract method |
Quên cài đặt phương thức trừu tượng/interface | Cài đặt đủ, hoặc khai báo lớp abstract |
attempting to assign weaker access privileges |
Ghi đè/cài đặt interface mà bỏ public |
Thêm public |
method does not override or implement a method from a supertype |
@Override nhưng gõ sai chữ ký |
Sửa tên/tham số cho khớp |
incompatible types: Animal cannot be converted to Dog |
Gán kiểu cha cho biến kiểu con | Ép kiểu (Dog) sau khi kiểm tra instanceof |
cannot find symbol khi gọi phương thức riêng của lớp con |
Biến kiểu cha chỉ thấy phương thức của kiểu cha | Downcast, hoặc đưa phương thức lên lớp cha |
Mở rộng (Java 14+): Java hiện đại có thêm
record(lớp dữ liệu ngắn gọn) vàsealed(giới hạn các lớp con được phép) để mô hình hóa cây kế thừa chặt chẽ hơn. Với Java 13 của máy chấm, bạn vẫn viết bằng lớp thường như trong bài này.
Cuối cùng là các bài thử thách tổng hợp: mỗi bài kết hợp lớp trừu tượng, đa hình và các mẫu thiết kế nhỏ (factory, singleton, observer). Hãy vẽ sơ đồ lớp trên giấy trước, cài đặt từng lớp rồi mới ghép lại.
Sai lầm thường gặp
| Sai lầm | Hậu quả | Cách đúng |
|---|---|---|
Lớp cha chỉ có hàm tạo có tham số, lớp con quên super(...) |
Lỗi biên dịch không tìm thấy hàm tạo | Gọi super(đối_số) ở câu lệnh đầu tiên |
Không ghi @Override và gõ sai chữ ký |
Tạo phương thức mới, ghi đè không xảy ra, kết quả sai âm thầm | Luôn ghi @Override |
| Nhầm chồng hàm với ghi đè | Tưởng gọi bản của lớp con nhưng bản của cha chạy | Ghi đè: cùng chữ ký; kiểm tra bằng @Override |
Cài đặt phương thức interface mà quên public |
Lỗi "weaker access privileges" | Thêm public |
| Khai báo lại thuộc tính trùng tên với lớp cha | Hai biến khác nhau, đọc nhầm giá trị | Dùng một thuộc tính ở lớp cha, truy cập qua protected/getter |
| Downcast không kiểm tra | ClassCastException lúc chạy |
instanceof trước, hoặc đưa hành vi lên lớp cha |
| Gọi phương thức bị ghi đè trong hàm tạo | Lớp con chạy khi thuộc tính chưa gán (null/0) |
Chỉ gọi phương thức final/private hoặc dời việc ra ngoài hàm tạo |
| Dùng kế thừa chỉ để "mượn" mã | Quan hệ sai, vi phạm thay thế Liskov | Dùng thành phần ("có một") |
new một lớp trừu tượng hoặc interface |
Lỗi biên dịch | Tạo lớp con cụ thể (hoặc lớp ẩn danh/lambda) |
Tưởng final biến tham chiếu làm đối tượng bất biến |
Nội dung vẫn bị sửa được | Mọi thuộc tính final, không có setter, sao chép khi nhận/trả mảng |
Hai interface cùng default trùng chữ ký mà không ghi đè |
Lỗi biên dịch | Ghi đè và chọn Interface.super.method() |
Tóm tắt
- Kế thừa (
extends) biểu diễn quan hệ "là một", dùng lại mã; mỗi lớp một cha; hàm tạo chạy từ cha xuống con;super(...)gọi hàm tạo cha,super.method()gọi bản của cha. - Ghi đè cùng chữ ký, không thu hẹp quyền truy cập, luôn có
@Override; khác chồng hàm (cùng tên, khác tham số, chọn lúc biên dịch).protectedmở cho lớp con và cùng package. - Lớp trừu tượng (
abstract): không tạo đối tượng, có thể có phương thức trừu tượng và phương thức thường; lớp con cụ thể phải cài đặt hết. - Interface: hợp đồng, nhiều interface được cài đặt cùng lúc, có
default/staticmethod (Java 8); xung độtdefaultgiải bằngInterface.super.method(). - Đa hình: biến kiểu cha/interface giữ đối tượng con (upcast); phương thức bị ghi đè được chọn theo đối tượng thật (dynamic dispatch); field,
static,privatekhông đa hình; downcast kiểm tra bằnginstanceof. finalkhóa biến, cấm ghi đè phương thức, cấm kế thừa lớp; mỗi lớp một tệp, gom bằng package,importđể dùng lớp ở package khác.- Ưu tiên thành phần hơn kế thừa; dùng kế thừa chỉ khi thật sự "là một" và giữ đúng nguyên lý thay thế Liskov.