Thứ Tư, 1 tháng 2, 2012

OOP - Bài 5: Tổ chức giải bóng đá



Center of Excellence


Bài 5
Tổ chức giải bóng đá


Thượng đế hiện diện trong những tiểu tiết
-- Ludwig mies van der Rohe


I. Bài Toán

Hãy phát triển code nhận từ bàn phím số cầu thủ qui định cho từng đội bóng và tổng số cầu thủ tham dự giải đấu, trong đó số cầu thủ mỗi đội phải từ 9 đến 15 người, và tổng số cầu thủ tham dự phải là dương. Code sẽ cho biết kết quả về số đội bóng mà liên đoàn có thể tổ chức giải đấu và số cầu thủ bị dôi ra.

Bạn không phải phát triển phần UI, nhưng để hiểu rõ hơn yêu cầu bài toán, có thể tham khảo một UI mẫu dưới đây.

Hình 1


II. Giải pháp

Theo yêu cầu bài toán, ta cần phải thực hiện bốn nhiệm vụ, trong đó có hai nhiệm vụ về nhập data và hai nhiệm vụ liên quan đến tính toán.
  1. Nhập số cầu thủ mỗi đội (phải từ 9 đến 15)
    • Input: từ bàn phím (keyboard)
    • Output: số cầu thủ mỗi đội (number of players per team)
  2. Nhập số cầu thủ tham dự (phải là số nguyên dương)
    • Input: từ keyboard
    • Output: số cầu thủ tham dự (number of players)
  3. Tính số đội bóng (number of teams)
    • Input: number of players per team, number of players
    • Output: number of teams
  4. Tính số cầu thủ bị dôi ra (number of left over players)
    • Input: number of players per team, number of players
    • Output: number of left over players
Ta sẽ từng bước giải quyết bốn nhiệm vụ này.

1. Nhập number of players per team

1.1. Thiết kế sơ bộ

Ta thiết kế một class phục vụ cho việc nhập data, đặt tên là Input.

Hình 2

Tuy nhiên, theo kinh nghiệm lập trình, ta thường né tránh việc sử dụng hằng số (constant) trong tên gọi nhằm làm cho method linh hoạt hơn. Ở đây có hai constants là 9 và 15. Ta sẽ thay chúng bằng hai parameters, một parameter gọi là giá trị chặn dưới (lower bound), một gọi là chặn trên (upper bound). Sau đó ta thử chuyển parameters thành fields.

Hình 3

Rõ ràng là readPlayersPerTeams() method linh hoạt hơn readPlayersPerTeamFrom9To15() vì khi đó lowerBoundupperBound không nhất thiết phải là 9 và 15 nữa, mà có thể là những giá trị khác, chẳng hạn 11 và 15.

Ngoài ra, do Input class là bộ phận tương tác với người dùng (user), ta đặt nó vào view package. View (hay presentation) là từ thường dùng để biểu thị bộ phận tương tác với user từ chương trình.

Hình 4

1.2. Test cases


readPlayersPerTeam()
No
input từ keyboard
playersPerTeams?
1
8
không chấp nhận
2
9
9
3
10
10
4
15
15
5
16
không chấp nhận



1.3. Phát triển test class

  • Project: Soccer

package view;

import org.junit.Test;
import static org.junit.Assert.*;

public class InputTest {
    @Test
    public void readPlayersPerTeam() {
        ???
    }
}

Biết rằng số nguyên mà người dùng nhập vào phải từ 9 đến 15, nhưng ta không biết chính xác là bao nhiêu, nên ta chỉ có thể viết

public class InputTest {
    @Test
    public void readPlayersPerTeam() {
        int playersPerTeam = new Input(9, 15).readNPlayersPerTeam();
        assertTrue(9 <= playersPerTeam && playersPerTeam <= 15);
    }
}

Lưu ý rằng đoạn code trên đã lặp lại các hằng số 9 và 15 hai lần. Tương tự như code duplication, vấn đề trùng lặp dữ liệu (data duplication) là không tốt bởi vì sau này nếu phải thay đối giá trị, chẳng hạn từ 9 sang 11, ta phải tìm khắp nơi trong code xem chỗ nào có số 9 thì phải sửa lại là 11. Nếu chỉ cần sót một chỗ là chương trình sẽ bị lỗi. Để loại bỏ rủi ro của data duplication, ta sẽ đặt tên cho các constants. Sau này, nếu phải thay đổi giá trị của một constant nào đó, ta chỉ cần sửa code ở một chỗ mà thôi.

public class InputTest {
    @Test
    public void readPlayersPerTeam() {
        final int LOWER_BOUND = 9; // local constant – hằng cục bộ
        final int UPPER_BOUND = 15;
        int playersPerTeam = new Input(LOWER_BOUND, UPPER_BOUND).readPlayersPerTeam();
        assertTrue(LOWER_BOUND <= playersPerTeam && playersPerTeam <= UPPER_BOUND);
    }
}

Từ InputTest, ta tiến hành xây dựng InputreadPlayersPerTeams().

package view;

public class Input {
    private int lowerBound;
    private int upperBound;

    public Input(int lowerBound, int upperBound) {
        this.lowerBound = lowerBound;
        this.upperBound = upperBound;
    }

    public int readPlayersPerTeam() {
        ???
    }
}

Bây giờ ta tập trung phát triển readPlayersPerTeam(). Đầu tiên ta phát ra một lời nhắc (prompt) để người dùng biết phải nhập data vào, sau đó tiến hành nhập một số nguyên, rồi kiểm tra số nguyên đó, nếu đạt yêu cầu thì trả về kết quả, ngược lại thì yêu cầu người dùng nhập lại.

    public int readPlayersPerTeam() {
        // Nhắc người dùng nhập data
        // Nhập vào một số nguyên
        // Nếu số nguyên thỏa yêu cầu thì
        //    trả về kết quả
        // Ngược lại thì
        //    yêu cầu người dùng nhập lại
    }

Ta từng bước thay các dòng chú thích (comment) viết bằng ngôn ngữ tự nhiên ở trên bằng ngôn ngữ Java. Trước hết là đưa ra lời nhắc.

public int readPlayersPerTeam() {
    System.out.print("Number of players per team [" + this.lowerBound +
            ", " + this.upperBound + "]: ");
    // Nhập vào một số nguyên
    // Nếu số nguyên thỏa yêu cầu thì
    //    trả về kết quả
    // Ngược lại thì
    //    yêu cầu người dùng nhập lại
}

Để nhập vào một số nguyên, ta cần tạo Scanner object rồi gọi nextInt().

package view;

import java.util.Scanner;

public class Input {
    private int lowerBound;
    private int upperBound;

    public Input(int lowerBound, int upperBound) {
        this.lowerBound = lowerBound;
        this.upperBound = upperBound;
    }

    public int readPlayersPerTeam() {
        System.out.print("Number of players per team [" + this.lowerBound +
                ", " + this.upperBound + "]: ");
        Scanner scanner = new Scanner(System.in);
        int n = scanner.nextInt();
        // Nếu số nguyên thỏa yêu cầu thì
        //    trả về kết quả
        // Ngược lại thì
        //    yêu cầu người dùng nhập lại
    }
}

Kế đến là kiểm tra số nguyên vừa được nhập vào có thỏa yêu cầu hay không.

public int readPlayersPerTeam() {
    System.out.print("Number of players per team [" + this.lowerBound +
            ", " + this.upperBound + "]: ");
    Scanner scanner = new Scanner(System.in);
    int n = scanner.nextInt();
    if (lowerBound <= n && n <= upperBound)
        return n;
    else
    //    yêu cầu người dùng nhập lại
}

Nếu user nhập vào một số nguyên không đạt, ta sẽ yêu cầu nhập lại. Nói cách khác, ta thi hành lại từ đầu readPlayersPerTeam(). Ta viết

public int readPlayersPerTeam() {
    System.out.print("Number of players per team [" + this.lowerBound +
            ", " + this.upperBound + "]: ");
    Scanner scanner = new Scanner(System.in);
    int n = scanner.nextInt();
    if (lowerBound <= n && n <= upperBound)
        return n;
    else
        return this.readPlayersPerTeam();
}

Để ý ở đoạn code trên, bên trong readPlayersPerTeam() method, dòng cuối, ta đã gọi thi hành bản thân readPlayersPerTeam(). Kỹ thuật này gọi là đệ qui (recursion).

Bây giờ nếu thi hành InputTest rồi nhập vào số nguyên, ta sẽ được tín hiệu màu xanh. Song nếu cắc cớ, thay vì nhập vào một số nguyên, ta lại nhập vào một số thực hay một chuỗi ký tự (String), chương trình sẽ tung InputMismatchException và cho tín hiệu đỏ. Để giải quyết tình huống này, ta buộc phải xử lý exception bằng khối try-catch như sau

import java.util.InputMismatchException;
import java.util.Scanner;

public class Input {
    private int lowerBound;
    private int upperBound;

    public Input(int lowerBound, int upperBound) {
        this.lowerBound = lowerBound;
        this.upperBound = upperBound;
    }

    public int readPlayersPerTeam() {
        System.out.print("Number of players per team [" + this.lowerBound +
                ", " + this.upperBound + "]: ");
        Scanner scanner = new Scanner(System.in);
        try {
            int n = scanner.nextInt();
            if (this.lowerBound <= n && n <= this.upperBound)
                return n;
        } catch (InputMismatchException e) {}
        return this.readPlayersPerTeam();
    }
}

ReadPlayersPerTeam() method ở trên đã yêu cầu người dùng nhập số liệu từ bàn phím thông qua cái gọi là console. Console chẳng qua là một môi trường đơn giản tương tác với người dùng. Ngoài console, còn có một môi trường tương tác thân thiện hơn gọi là Giao diện Người dùng Đồ họa (Graphical User Interface), viết tắt là GUI. Dưới đây là một phương án dùng GUI.

Hình 5

package view;

import java.util.InputMismatchException;
import java.util.Scanner;
import javax.swing.JOptionPane;

public class Input {
    private int lowerBound;
    private int upperBound;

    public Input(int lowerBound, int upperBound) {
        this.lowerBound = lowerBound;
        this.upperBound = upperBound;
    }
    
    public int readPlayersPerTeamGui() {
        try {
            String s = JOptionPane.showInputDialog(
                "Enter the number of players per team in [" +
                    this.lowerBound + ", " + this.upperBound + "]: ");
            int n = Integer.parseInt(s);
            if (this.lowerBound <= n && n <= this.upperBound)
                return n;
        } catch (NumberFormatException e) {}
        return this.readPlayersPerTeamGui();
    }

    public int readPlayersPerTeam() {
        System.out.print("Number of players per team [" + this.lowerBound +
                ", " + this.upperBound + "]: ");
        Scanner scanner = new Scanner(System.in);
        try {
            int n = scanner.nextInt();
            if (this.lowerBound <= n && n <= this.upperBound)
                return n;
        } catch (InputMismatchException e) {}
        return this.readPlayersPerTeam();
    }
}

Trong readPlayersPerTeamGui(), ta đã gọi thi hành showInputDialog() của JOptionPane class. Khi đó Java sẽ hiển thị một hộp thoại (dialog) để user nhập data. Lưu ý rằng cho dù user nhập vào một số, kết quả trả về luôn là một String. Để chuyển một số thuộc kiểu String về kiểu nguyên int, ta dùng phương thức tĩnh (static method) tên là parseInt() của Integer class. Nếu quá trình chuyển đổi thất bại, tức String đó thật sự không phải là một số, ta sẽ nhận được NumberFormatException.


2. Nhập number of players

2.1. Thiết kế sơ bộ

Do đây cũng là thao tác nhập nên ta thử đặt method này vào Input class.

Hình 6

Tuy nhiên thao tác đặt để đơn thuần như vậy là không phù hợp vì readPlayersGui() hoàn toàn không dùng đến fields. Khi quyết định khai báo một field, ta cần đảm bảo rằng field đó có ý nghĩa nhất định đối với tất cả các methods bên trong class.. Để giải quyết tình huống này, ta chuyển fields về lại parameters cho readPlayersPerTeam()readPlayersPerTeamGui().

Hình 7

Đến đây ta thấy rằng Input class không còn field nào cả, vì vậy ta có thể biến tất cả methods thành static methods.Static method khác với method bình thường ở chỗ: khi thi hành một static method, ta không cần tạo object, mà dùng trực tiếp tên class. Chẳng hạn, để thi hành readPlayersGui() static method, ta chỉ cần viết Input.readPlayersGui(), thay vì phải viết new Input().readPlayersGui(). Static method còn được gọi là class method, tức method thuộc class; và method bình thường còn được gọi là object method, tức method thuộc object. Trong OOP, ta chỉ sử dụng static methods trong những tình huống thích hợp, để code thật sự có tính OO.

Hình 8


2.2. Test cases


readPlayers()
No
input từ keyboard
players?
1
100
100
2
0
không chấp nhận
3
-1
không chấp nhận



2.3. Phát triển methods

InputTest.java

package view;

import static org.junit.Assert.assertTrue;
import org.junit.Test;

public class InputTest {
    private static final int LOWER_BOUND = 9; // class constant – hằng thuộc lớp
    private static final int UPPER_BOUND = 15;
    
    @Test
    public void readPlayersPerTeam() {
        int playersPerTeam = Input.readPlayersPerTeam(LOWER_BOUND, UPPER_BOUND);
        assertTrue(LOWER_BOUND <= playersPerTeam && playersPerTeam <= UPPER_BOUND);
    }
    
    @Test
    public void readPlayersPerTeamGui() {
        int playersPerTeam = Input.readPlayersPerTeamGui(LOWER_BOUND, UPPER_BOUND);
        assertTrue(LOWER_BOUND <= playersPerTeam && playersPerTeam <= UPPER_BOUND);
    }
    
    @Test
    public void readPlayersGui() {
        assertTrue(Input.readPlayersGui() > 0);
    }
}

Input.java

package view;

import java.util.InputMismatchException;
import java.util.Scanner;
import javax.swing.JOptionPane;

public class Input {
    public static int readPlayersPerTeamGui(int lowerBound, int upperBound) {
        try {
            String s = JOptionPane.showInputDialog("Number of players per team [" +
                    lowerBound + ", " + upperBound + "]: ");
            int n = Integer.parseInt(s);
            if (lowerBound <= n && n <= upperBound)
                return n;
        } catch (NumberFormatException e) {}
        return readPlayersPerTeamGui(lowerBound, upperBound);
    }

    public static int readPlayersPerTeam(int lowerBound, int upperBound) {
        System.out.print("Number of players per team [" + lowerBound +
                ", " + upperBound + "]: ");
        Scanner scanner = new Scanner(System.in);
        try {
            int n = scanner.nextInt();
            if (lowerBound <= n && n <= upperBound)
                return n;
        } catch (InputMismatchException e) {}
        return readPlayersPerTeam(lowerBound, upperBound);
    }

    public static int readPlayersGui() {
        try {
            String s = JOptionPane.showInputDialog("Total number of players: ");
            int n = Integer.parseInt(s);
            if (n > 0)
                return n;
        } catch (NumberFormatException e) {}
        return readPlayersGui();
    }
}


3. Tính number of teams

3.1. Thiết kế sơ bộ

Do đây là thao tác tính toán, ta đặt class vào model package. Model (hay business logic) là từ thường dùng để biểu thị bộ phận tính toán của chương trình.

Hình 9

Để xác định tên class ta cần trả lời câu hỏi: Đối tượng nào có số cầu thủ mỗi đội và số cầu thủ tham dự, rồi từ đó tính được số đội bóng? Câu trả lời có thể là: ban tổ chức (organizer).

Hình 10


3.2. Test cases

Ta có hai input (fields) là playersPerTeamplayers, nên có thể xây dựng 3 test cases, trong đó playersPerTeam nhỏ hơn, bằng, hoặc lớn hơn players.


computeTeams()
No
playersPerTeam
players
teams?
1
11
10
0
2
11
11
1
3
11
100
9




Lưu ý là khi xây dựng test cases cho computeTeams(), ta đã không tính đến các trường hợp bất hợp lệ của playersPerTeamplayers. Điều này dựa trên cơ sở là phần tính toán chỉ phải lo tính toán, không nên lo đến việc kiểm tra tính hợp lệ của data.

3.3. Phát triển method

OrganizerTest.java

package model;

import static org.junit.Assert.assertEquals;
import org.junit.Test;

public class OrganizerTest {
    @Test
    public void computeTeams() {
        assertEquals(0, new Organizer(11, 10).computeTeams());
        assertEquals(1, new Organizer(11, 11).computeTeams());
        assertEquals(9, new Organizer(11, 100).computeTeams());
    }
}

Organizer.java

package model;

public class Organizer {
    private int playersPerTeam;
    private int players;

    public Organizer(int playersPerTeam, int players) {
        this.playersPerTeam = playersPerTeam;
        this.players = players;
    }
    
    public int computeTeams() {
        return this.players / this.playersPerTeam;
    }
}

Lưu ý là phép chia bên trong computeTeams() là một phép chia nguyên, không quan tâm đến phần dư.


4. Tính number of left over players

4.1. Thiết kế sơ bộ

Hình 11


4.2. Test cases


computeLeftOverPlayers()
No
playersPerTeam
players
leftOverPlayers?
1
11
10
10
2
11
11
0
3
11
100
1



4.3. Phát triển method

OrganizerTest.java

public class OrganizerTest {
    @Test
    public void computeTeams() {
        assertEquals(0, new Organizer(11, 10).computeTeams());
        assertEquals(1, new Organizer(11, 11).computeTeams());
        assertEquals(9, new Organizer(11, 100).computeTeams());
    }
    
    @Test public void computeLeftOverPlayers() {
        assertEquals(10, new Organizer(11, 10).computeLeftOverPlayers());
        assertEquals(0, new Organizer(11, 11).computeLeftOverPlayers());
        assertEquals(1, new Organizer(11, 100).computeLeftOverPlayers());
    }
}

Để ý rằng đoạn code trên có hiện tượng code duplication ở hai methods khác nhau. Để loại bỏ hiện tượng này, ta đặt tên cho phần duplication và khai báo chúng là fields trong class.

public class OrganizerTest {
    private Organizer organizer1 = new Organizer(11, 10);
    private Organizer organizer2 = new Organizer(11, 11);
    private Organizer organizer3 = new Organizer(11, 100);
    
    @Test
    public void computeTeams() {
        assertEquals(0, this.organizer1.computeTeams());
        assertEquals(1, this.organizer2.computeTeams());
        assertEquals(9, this.organizer3.computeTeams());
    }
    
    @Test public void computeLeftOverPlayers() {
        assertEquals(10, this.organizer1.computeLeftOverPlayers());
        assertEquals(0, this.organizer2.computeLeftOverPlayers());
        assertEquals(1, this.organizer3.computeLeftOverPlayers());
    }
}

Tiếp theo, ta phát triển computeLeftOverPlayers(), trong đó ta sử dụng toán tử modulo % để xác định phần dư của phép chia nguyên.

Organizer.java

public class Organizer {
    private int playersPerTeam;
    private int players;

    public Organizer(int playersPerTeam, int players) {
        this.playersPerTeam = playersPerTeam;
        this.players = players;
    }
    
    public int computeTeams() {
        return this.players / this.playersPerTeam;
    }
    
    public int computeLeftOverPlayers() {
        return this.players % this.playersPerTeam;
    }
}


5. Tích hợp

Như vậy là ta đã hoàn tất việc phát triển bốn nhiệm vụ mà bài toán đã đặt ra. Tuy nhiên, ta cần tích hợp bốn nhiệm vụ này lại để hình thành một chương trình hoàn chỉnh. Để thực hiện việc này, ta tạo thêm Main class và main() method đặc biệt của class đó.

Hình 12

Các mũi tên đứt nét đi từ Main đến InputOrgernizer có nghĩa là để Main có thể hoàn thành nhiệm vụ, nó cần dùng đến InputOrgernizer. Nói cách khác, quan hệ giữa MainInput, cũng như quan hệ giữa MainOrgernizer là quan hệ “sử dụng” (“uses” relationship) hay cộng tác (collaboration relationship).

package controller;

import javax.swing.JOptionPane;
import model.Organizer;
import view.Input;

public class Main {
    public static void main(String[] args) {
        int playersPerTeams = Input.readPlayersPerTeamGui(9, 15);
        int players = Input.readPlayersGui();

        Organizer organizer = new Organizer(playersPerTeams, players);

        JOptionPane.showMessageDialog(null,
            "There will be " + organizer.computeTeams() + " team(s) with " +
            organizer.computeLeftOverPlayers() + " player(s) left over");
        }
}

Sau đây là một ví dụ khi thi hành Main. (Trong Eclispe, vào Main class, click Run menu, rồi chọn Run)

Hình 13

Hình 14

Hình 15


6. Phân định rõ class chỉ chứa static method(s)

Như đã được thảo luận ở trên, khi gọi thi hành một static method, ta không cần tạo object, mà dùng trực tiếp tên class. Nói chung, nên tránh lạm dụng static methods để giải pháp của ta mang tính tư duy hướng đối tượng hơn. Trong trường hợp cần thiết, chẳng hạn ở bài này, ta có lời khuyên là nên ngăn trở những toan tính tạo object từ các classes đặc biệt đó. Để làm được điều này, ta tạo thêm constructor phi tham số (no-arg constructor) cho class, đồng thời khai báo constructor đó là private (riêng tư), tức bên ngoài class không thể gọi new trên class đó được nữa. Nếu bên trong class gọi new, ngoại lệ RuntimeException sẽ được tung ra.

Input.java

package view;

import java.util.InputMismatchException;
import java.util.Scanner;
import javax.swing.JOptionPane;

public class Input {
    private Input() {
        throw new RuntimeException(this.getClass() +
                " is a noninstantiable utility class");
    }
    
    public static int readPlayersPerTeamGui(int lowerBound, int upperBound) {
        try {
            String s = JOptionPane.showInputDialog("Number of players per team [" +
                    lowerBound + ", " + upperBound + "]: ");
            int n = Integer.parseInt(s);
            if (lowerBound <= n && n <= upperBound)
                return n;
        } catch (NumberFormatException e) {}
        return readPlayersPerTeamGui(lowerBound, upperBound);
    }

    public static int readPlayersPerTeam(int lowerBound, int upperBound) {
        System.out.print("Number of players per team [" + lowerBound +
                ", " + upperBound + "]: ");
        Scanner scanner = new Scanner(System.in);
        try {
            int n = scanner.nextInt();
            if (lowerBound <= n && n <= upperBound)
                return n;
        } catch (InputMismatchException e) {}
        return readPlayersPerTeam(lowerBound, upperBound);
    }

    public static int readPlayersGui() {
        try {
            String s = JOptionPane.showInputDialog("Total number of players: ");
            int n = Integer.parseInt(s);
            if (n > 0)
                return n;
        } catch (NumberFormatException e) {}
        return readPlayersGui();
    }
}

Main.java

package controller;

import javax.swing.JOptionPane;
import model.Organizer;
import view.Input;

public class Main {
    private Main() {
        throw new RuntimeException(this.getClass() +
                " is a noninstantiable utility class");
    }
    
    public static void main(String[] args) {
        int playersPerTeams = Input.readPlayersPerTeamGui(9, 15);
        int players = Input.readPlayersGui();

        Organizer organizer = new Organizer(playersPerTeams, players);

        JOptionPane.showMessageDialog(null,
            "There will be " + organizer.computeTeams() + " team(s) with " +
            organizer.computeLeftOverPlayers() + " player(s) left over");
        }
}


III. Tổng kết

Bài viết đã trình bày một thiết kế tách biệt khối nhập data (thể hiện qua bộ phận view) ra khỏi khối tính toán (thể hiện qua bộ phận model), rồi sử dụng bộ phận controller để tích hợp tất cả lại với nhau, thông qua “uses” hay collaboration relationship, để tạo ra một ứng dụng hoàn chỉnh chạy trên môi trường console hoặc GUI.

Ngoài ra, bài viết còn đề cập đến các khái niệm và kỹ thuật sau
  • Recursion
  • Xử lý exception bằng try-catch block
  • Static/class methods
  • Local và class constants

IV. Tài liệu tham khảo

  1. Gaddis T. (2010) Starting Out with Java From Control Structures Through Objects (4th edition), Pearson Education, Boston. Chương 4.
  2. Bloch, J. (2008) Effective Java, Second Edition, Addison-Wesley, Upper Saddle River, NJ. Item 4, p. 19.


V. Bài tập

Hãy dùng console để bổ sung readPlayers() method vào Input class.


Không có nhận xét nào:

Đăng nhận xét