Khóa OOP người mới: Bài 1 — Phân số (PS)

Bài giảng từng bước: ví dụ đời thực → đọc sơ đồ UML → code Java chú thích chi tiết → lỗi thường gặp → bài tập tự luyện. Nội dung được dựng thành web từ buổi học với AI; toàn bộ code Java đã được kiểm chứng biên dịch và chạy thử thành công.

Chào mừng bạn đến với khóa học nhập môn Lập trình Hướng đối tượng (OOP) bằng ngôn ngữ Java!

Thầy rất vui được đồng hành cùng bạn trên hành trình chinh phục tư duy OOP. Đừng lo lắng nếu bạn chưa từng tiếp xúc với OOP trước đây; thầy sẽ đi cùng bạn từng bước một, giải thích từ những khái niệm đơn giản nhất, trực quan hóa bằng ví dụ thực tế và phân tích từng dòng code rõ ràng.

Chúng ta sẽ mở đầu chuỗi bài học bằng PART 1: KHAI BÁO CLASS VÀ OBJECT CƠ BẢN với Bài 1: Lớp Phân Số (PS).

🌟 BÀI 1: LỚP PHÂN SỐ (PS) - KHÁI NIỆM CLASS & OBJECT CƠ BẢN

1. Trực quan hóa tư duy OOP: Class và Object là gì?

Hãy tưởng tượng bạn đang kinh doanh bánh pizza:

Class (Khuôn mẫu/Lớp): Là bản thiết kế một chiếc bánh phân số trên giấy (nói cho chương trình biết một Phân số sẽ gồm có những thuộc tính gì: Tử số, Mẫu số và làm được những việc gì: Cộng, Trừ, Rút gọn...).

Object (Đối tượng): Là chiếc bánh pizza thực tế được nướng ra từ bản thiết kế đó (ví dụ bánh p=1/2, bánh q=5/7).

2. Đọc hiểu sơ đồ UML của lớp Phân Số (PS)

Dựa trên đề bài Lab 02, sơ đồ UML của lớp PS như sau:

Ký hiệu trong UML Ý nghĩa trong Java Giải thích
- (ví dụ: - tu: int) private Tính đóng gói: Giấu dữ liệu bên trong, không cho truy cập trực tiếp từ ngoài vào để tránh bị sửa lung tung (ví dụ set mẫu số = 0).
+ (ví dụ: + hienThi()) public Phương thức/hàm mở cho bên ngoài gọi sử dụng.
+ PS(), + PS(int t, int m) Constructor Phương thức khởi tạo đối tượng khi dùng từ khóa new.
ganTu(), layTu() Setter / Getter Hàm đặt/lấy giá trị cho các thuộc tính private.

3. Phân tích & Cài đặt Mã nguồn Java Chi Tiết

Bước 3.1: Tạo lớp PS.java

public class PS {
    // 1. Thuộc tính (Attributes) - Giấu kín với private
    private int tu;
    private int mau;

    // 2. Các phương thức khởi tạo (Constructors)

    // Constructor 1: Mặc định không tham số (tạo phân số 0/1)
    public PS() {
        this.tu = 0;
        this.mau = 1;
    }

    // Constructor 2: Có 1 tham số (chuyển số nguyên n thành n/1)
    public PS(int n) {
        this.tu = n;
        this.mau = 1;
    }

    // Constructor 3: Có 2 tham số (tử và mẫu)
    public PS(int t, int m) {
        this.tu = t;
        if (m == 0) {
            System.out.println("Lỗi: Mẫu số không được bằng 0! Đặt mặc định mẫu = 1.");
            this.mau = 1;
        } else {
            this.mau = m;
        }
        this.rutGon(); // Tạo xong thì rút gọn ngay cho đẹp
    }

    // Constructor 4: Constructor sao chép (Copy Constructor)
    public PS(PS p) {
        this.tu = p.tu;
        this.mau = p.mau;
    }

    // 3. Các hàm Getter và Setter (Lấy và gán giá trị)
    public int layTu() {
        return this.tu;
    }

    public void ganTu(int t) {
        this.tu = t;
    }

    public int layMau() {
        return this.mau;
    }

    public void ganMau(int m) {
        if (m == 0) {
            System.out.println("Lỗi: Mẫu số phải khác 0!");
            return;
        }
        this.mau = m;
    }

    // 4. Thuật toán tìm Ước số chung lớn nhất (UCLN) để rút gọn phân số
    private int UCLN(int a, int b) {
        a = Math.abs(a);
        b = Math.abs(b);
        while (b != 0) {
            int temp = a % b;
            a = b;
            b = temp;
        }
        return a;
    }

    public void rutGon() {
        int u = UCLN(this.tu, this.mau);
        this.tu /= u;
        this.mau /= u;
        // Đảm bảo mẫu số luôn dương (ví dụ: -1/-2 -> 1/2 hoặc 1/-2 -> -1/2)
        if (this.mau < 0) {
            this.tu = -this.tu;
            this.mau = -this.mau;
        }
    }

    // 5. Các phép toán cộng, trừ, nhân, chia với phân số khác
    public PS cong(PS p) {
        int t = this.tu * p.mau + p.tu * this.mau;
        int m = this.mau * p.mau;
        return new PS(t, m);
    }

    public PS tru(PS p) {
        int t = this.tu * p.mau - p.tu * this.mau;
        int m = this.mau * p.mau;
        return new PS(t, m);
    }

    public PS nhan(PS p) {
        int t = this.tu * p.tu;
        int m = this.mau * p.mau;
        return new PS(t, m);
    }

    public PS chia(PS p) {
        if (p.tu == 0) {
            System.out.println("Lỗi: Không thể chia cho phân số có tử bằng 0!");
            return new PS(0, 1);
        }
        int t = this.tu * p.mau;
        int m = this.mau * p.tu;
        return new PS(t, m);
    }

    // Các phép toán cộng, trừ, nhân, chia với số nguyên n
    public PS cong(int n) {
        return this.cong(new PS(n));
    }

    public PS tru(int n) {
        return this.tru(new PS(n));
    }

    public PS nhan(int n) {
        return this.nhan(new PS(n));
    }

    public PS chia(int n) {
        return this.chia(new PS(n));
    }

    // 6. Hàm so sánh hai phân số
    // Trả về: > 0 nếu phân số hiện tại lớn hơn, < 0 nếu nhỏ hơn, 0 nếu bằng nhau
    public int soSanh(PS p) {
        PS hieu = this.tru(p);
        return hieu.tu; // Vì mẫu số luôn dương sau khi rút gọn nên tử số quyết định dấu
    }

    // 7. Hiển thị và chuyển đổi sang dạng String
    public String toString() {
        if (this.mau == 1) return this.tu + ""; // Nếu mẫu bằng 1 thì in ra số nguyên
        return this.tu + "/" + this.mau;
    }

    public void hienThi() {
        System.out.println(this.toString());
    }
}

Bước 3.2: Viết chương trình chính Main.java (Thực thi các yêu cầu Lab 02)

public class Main {
    public static void main(String[] args) {
        // 1. Tạo 2 phân số p = 1/2 và q = 5/7
        PS p = new PS(1, 2);
        PS q = new PS(5, 7);

        System.out.println("Phân số p: " + p);
        System.out.println("Phân số q: " + q);

        // 2. Thực hiện r = p + q và hiển thị r
        PS r = p.cong(q);
        System.out.print("r = p + q = ");
        r.hienThi();

        // 3. Thực hiện t = 2*p - p/q và hiển thị t
        // 2*p được viết thành p.nhan(2)
        // p/q là p.chia(q)
        PS t = p.nhan(2).tru(p.chia(q));
        System.out.print("t = 2*p - p/q = ");
        t.hienThi();

        // 4. So sánh 2 phân số p và q
        int kqSoSanh = p.soSanh(q);
        if (kqSoSanh > 0) {
            System.out.println("p lớn hơn q");
        } else if (kqSoSanh < 0) {
            System.out.println("p nhỏ hơn q");
        } else {
            System.out.println("p bằng q");
        }

        // 5. Tạo mảng 5 phân số: 1/2, 13/2, 5/7, 6/9, 30/23 và sắp xếp tăng dần
        PS[] dsPS = new PS[] {
            new PS(1, 2),
            new PS(13, 2),
            new PS(5, 7),
            new PS(6, 9),
            new PS(30, 23)
        };

        // Thuật toán sắp xếp nổi bọt (Bubble Sort)
        for (int i = 0; i < dsPS.length - 1; i++) {
            for (int j = i + 1; j < dsPS.length; j++) {
                if (dsPS[i].soSanh(dsPS[j]) > 0) {
                    // Tráo đổi vị trí
                    PS temp = dsPS[i];
                    dsPS[i] = dsPS[j];
                    dsPS[j] = temp;
                }
            }
        }

        System.out.println("\n--- Dãy phân số sau khi sắp xếp tăng dần ---");
        for (PS ps : dsPS) {
            System.out.print(ps + "  ");
        }
        System.out.println();
    }
}

⚠️ Lỗi sai phổ biến người mới bắt đầu cần tránh!

Lỗi NullPointerException khi khởi tạo mảng:

Mắc phải: Khai báo PS[] ds = new PS[5]; xong vội vã gọi ds[0].hienThi();.

Nguyên nhân: Dòng code trên mới tạo ra 5 "ô trống chưa có bánh" (tất cả bằng null).

Cách khắc phục: Phải gán đối tượng cụ thể cho từng ô: ds[0] = new PS(1, 2); trước khi dùng.

Chia cho 0 (Divide by Zero):

Phải luôn kiểm tra mẫu số có bị bằng 0 hay không trong Constructor và trong phép chia phân số chia(PS p).

📝 BÀI TẬP TỰ RÈN LỰC (Dành cho bạn)

Đề bài: Bổ sung phương thức public PS luyThua(int k) vào lớp PS để tính lũy thừa bậc k của một phân số (ví dụ: (1/2)³ = 1/8).

public PS luyThua(int k) {
    int t = (int) Math.pow(this.tu, k);
    int m = (int) Math.pow(this.mau, k);
    return new PS(t, m);
}

Bổ sung: hiểu sâu từng dòng (Bài 1)

Từ mới trong bài này (giải từ số 0):

Mổ xẻ hàm rút gọn (trái tim của lớp PS):

private int UCLN(int a, int b) {
    a = Math.abs(a);
    b = Math.abs(b);
    while (b != 0) {
        int temp = a % b;  // lấy dư
        a = b;
        b = temp;
    }
    return a;
}

Trace tay với UCLN(6, 9): vòng 1 — temp = 6 % 9 = 6, a = 9, b = 6. Vòng 2 — temp = 9 % 6 = 3, a = 6, b = 3. Vòng 3 — temp = 6 % 3 = 0, a = 3, b = 0 → dừng, trả về 3. Đây là thuật toán Euclid: cứ lấy dư liên tục, số dư cuối cùng khác 0 chính là UCLN.

public PS cong(PS p) {
    int t = this.tu * p.mau + p.tu * this.mau;  // tử sau quy đồng
    int m = this.mau * p.mau;                   // mẫu chung
    return new PS(t, m);  // new PS(...) tự gọi rutGon() nên kết quả luôn gọn
}

Ví dụ p = 1/2 cộng q = 5/7: tử = 1×7 + 5×2 = 17, mẫu = 2×7 = 14 → 17/14 (UCLN = 1 nên giữ nguyên). Chú ý: cong không sửa p hay q, nó đúc ra object mới — đó là thói quen tốt giúp tránh bug.

Trace tay cả chương trình: p = 1/2, q = 5/7. r = p + q = 17/14. t = 2×p − p/q = 1 − 7/10 = 3/10. So sánh: p − q = 7/10 − 10/14... (quy đồng 140: 98/140 − 100/140 = −2/140, tử âm) nên p nhỏ hơn q. Mảng 5 phân số sắp tăng dần thành: 1/2, 2/3, 5/7, 30/23, 13/2 — khớp đúng output khi chạy.

Tự kiểm tra:

  1. Vì sao constructor PS(int t, int m) gọi this.rutGon() ở cuối?
Đáp ánĐể mọi object vừa sinh ra đã ở dạng gọn nhất, các phép toán và so sánh phía sau không phải lo rút gọn lại.
  1. new PS(1, 2) và PS q = p; khác nhau chỗ nào?
Đáp ánnew đúc object mới tinh; còn PS q = p; chỉ tạo thêm một cái tên cùng trỏ vào object cũ — sửa qua q thì p cũng đổi theo.
  1. Mẫu số bằng 0 thì chuyện gì xảy ra?
Đáp ánConstructor in cảnh báo và tự đặt mẫu = 1; hàm ganMau(0) in cảnh báo và giữ nguyên mẫu cũ. Không bao giờ để mẫu 0 lọt vào phép chia.