2.5. [Thực hành] Luyện tập OOP với PhanSoX

Trong những bài học trước, chúng ta đã cùng tìm hiểu và làm việc với các lớp. Giờ chính là lúc các bạn đưa những kiến thức đã học được vào áp dụng trong thực tiễn qua một bài tập thực hành nhỏ.

Đây là bài thực hành đầu tiên về lập trình hướng đối tượng, vậy nên chúng ta sẽ tập trung vào những kiến thức cơ bản nhất. Đã đến lúc các bạn dừng việc theo dõi hướng dẫn trong bài học và cố tự mình thực hiện bài tập nho nhỏ sau đây. Đây sẽ là 1 cơ hội tốt để kiểm nghiệm lại những kiến thức đã được học và bổ khuyết những lỗ hổng mà các bạn chưa nắm vững.

Trong bài thực hành này, các bạn sẽ phải viết 1 lớp mô tả khái niệm phân số.  Trong C++ thì chúng ta có kiểu dữ liệu int cho số nguyên và double cho số thực thế nhưng không có kiểu nào dành cho phân số cả. Nhiệm vụ của các bạn hôm nay sẽ là bổ sung thêm kiểu dữ liệu đó.

Chuẩn bị trước khi lập trình – Những lời khuyên

Lớp mà chúng ta chuẩn bị tạo ra không quá phức tạp và không đòi hỏi nhiều công sức cũng có thể nghĩ ra những phương thức và phép toán mà chúng ta cần sử dụng.

Bài tập này dùng để kiểm tra kiến thức của các bạn về các khái niệm sau :

  • Thuộc tính và quyền truy cập
  • Phương thức khởi tạo
  • Ghi đè các phép toán

Hãy xem lại chúng 1 lần cuối cùng rồi chúng ta cùng bắt đầu !

Đặc điểm của lớp PhanSoX

Bởi vì lớp của chúng ta dùng để mô tả khái niệm « phân số » nên PhanSo là 1 lựa chọn không tồi cho tên lớp. Tôi thêm vào chữ « X » mang ý nghĩa là mới, tân tiến (kiểu như RockmanX so với Rockman vậy). Vậy là chúng ta có tên của lớp : PhanSoX.

Sử dụng kiểu dữ liệu intdouble thật là vô cùng dễ dàng. Chúng ta chỉ cần khai báo, khởi tạo giá trị ban đầu và rồi đã có thể sử dụng chúng trong các phép toán. Để làm được điều tương tự với PhanSoX, các bạn sẽ cần bỏ ra thêm không ít công sức. Tiếp theo đó, chúng ta cũng cần có thể so sánh các PhanSoX với nhau thì mới coi như cơ bản hoàn thiện.

Mục tiêu cuối cùng là đoạn mã sau đây có thể được biên dịch thành công và hoạt động chính xác.

#include <iostream>
#include "PhanSoX.h"

using namespace std;

int main(){
    PhanSoX a(4,5);      //Khai bao phan so 4/5
    PhanSoX b(2);        // Khai bao phan so 2/1 (= 2)
    PhanSoX c,d;         // Khai bao 2 phan so bang 0

    c = a+b;               //Tinh 4/5 + 2/1 = 14/5
    d = a*b;               //Tinh  4/5 * 2/1 = 8/5
    cout << a << " + " << b << " = " << c << endl;
    cout << a << " * " << b << " = " << d << endl;
    
    if(a > b)
        cout << "a lon hon b." << endl;
    else if(a==b)
        cout << "a bang b." << endl;
    else
        cout << "a nho hon b." << endl;
    return 0;
}

Để làm được như thế, chúng ta cần phải :

  • Tạo ra lớp cùng các thuộc tính
  • Tạo ra 1 số phương thức khởi tạo
  • Ghi đè các phép toán, ít nhất là +, *, <<, <==

Ngoài ra thì PhanSoX còn phải đảm bảo giá trị được lưu trong đối tượng là 1 phân số đã được rút gọn, nghĩa là 4/5 thay vì 8/10 hay 12/15.

Đây là tất cả các yêu cầu cơ bản cần chú ý khi tạo ra lớp PhanSoX. Tôi nghĩ có thể 1 số kiến thức toán học sẽ hữu dụng trong bài thực hành này. Nếu các bạn đã sẵn sàng thì chúng ta hãy bắt tay vào làm thôi !

Tôi sẽ không bỏ rơi các bạn ngay đâu. Một vài chỉ dẫn sau đây sẽ rất hữu dụng để các bạn có thể bắt đầu.

Tạo 1 dự án mới

Để bắt đầu bài thực hành này, các bạn cần tạo 1 dự án mới. Hãy thoải mái sử dụng IDE nếu các bạn muốn. Chúng ta biết rằng trong dự án này sẽ có ít nhất 3 tệp.

  • main.cpp : tệp này chứa hàm main(). Trong hàm main(), chúng ta sẽ tạo ra các đối tượng của lớp PhanSoX và kiểm tra các tính năng mà chúng ta đã liệt kê bên trên.
  • PhanSoX.h : chứa nguyên mẫu của lớp PhanSoX với danh sách các thuộc tính và phương thức của nó.
  • PhanSoX.cpp : chứa mã xử lý của các phương thức được nêu bên trên.

! Cần chú ý là tên các tệp .cpp và .h phải giống với tên của lớp nếu không chương trình có thể không chạy.

Khung mã nguồn của các tệp

Để bắt đầu, chúng ta xác định khung cơ bản nhất của các đoạn mã trong các tệp.

main.cpp

Ở bên trên, tôi thậm chí đã trình bày cho các bạn mã hoàn chỉnh khi chúng ta viết xong chương trình. Thế nhưng hãy quên nó đi và bắt đầu 1 cách từ từ. Chúng ta sẽ dần dần thêm các dòng lệnh vào main() tùy theo mức độ phát triển của lớp.

#include <iostream>
#include " PhanSoX.h"

using namespace std;

int main(){
    PhanSoX a(1,5); // Tao ra phan so 1/5
    return 0;
}

Hãy bắt đầu với chương trình chỉ bao gồm 1 câu lệnh khai báo biến.

PhanSoX.h

Tệp này chứa khai báo của lớp PhanSoX. Chúng ta sẽ bao gồm gói <iostream> vì tiếp theo sẽ cần đến, ít nhất là khi chúng ta muốn sử dụng cout để in ra màn hình khi muốn tìm lỗi trong đoạn mã.

#ifndef DEF_PHANSOX
#define DEF_ PHANSOX
#include <iostream>

class PhanSoX{
  public:

  private:

};

#endif

Trước mắt thì lớp này vẫn còn trống và nhiệm vụ của các bạn chính là hoàn thiện nó. Đấy mới là mục đích chính của bài thực hành. Dù vậy thì tôi cũng đã phân ra 2 phần rõ rệt với quyền truy cập khác nhau. Tôi rất hy vọng các bạn sẽ tuân thủ tốt tính đóng gói của lập trình hướng đối tượng.

! 2 dòng đầu tiên cũng như dòng cuối cùng được IDE tự động thêm vào các tệp .h với mục đích tránh cho mỗi đoạn mã không bị khai báo nhiều lần. Vậy nên nếu bạn không sử dụng IDE, đừng quên tự tay thêm những dòng này vào tệp tiêu đề.

PhanSoX.cpp

Chính là trong tệp này mà chúng ta sẽ viết mã xử lý của các phương thức. Bởi vì lớp của chúng ta vẫn còn trống nên chưa có đoạn mã nào được viết ở đây. 1 chú ý khác là đừng quên bao gồm tệp tiêu đề PhanSoX.h.

#include " PhanSoX.h "

Vậy thế là phần khung của chương trình đã cơ bản hoàn thành.

Xác định thuộc tính của lớp

Bước đầu tiên khi muốn tạo ra 1 lớp là phải xác định các thuộc tính của lớp đấy. Tôi nghĩ là sẽ không quá khó khăn với tất cả mọi người.

Tất cả các phân số thì được tạo thành từ 2 thành phần là tử số và mẫu số. 2 thành phần này đều là 2 số nguyên nên chúng ta sẽ thêm vào 2 thuộc tính kiểu int cho lớp.

#ifndef DEF_PHANSOX
#define DEF_ PHANSOX
#include <iostream>

class PhanSoX{
  public:

  private:
    int m_tuSo ;
    int m_mauSo ;
};

#endif

Trong tên của thuộc tính, tôi luôn bắt đầu bằng « m_ ». Tôi đã từng nói điều này với các bạn từ những bài học trước. Cách đặt tên này giúp chúng ta dễ dàng phân biệt xem là trong đoạn mã chúng ta đang làm việc với 1 thuộc tính của lớp hay chỉ là 1 biến địa phương bình thường trong phương thức.

Các phương thức khởi tạo

Tôi cũng sẽ không hỗ trợ quá nhiều ở đây. Tuy nhiên nếu các bạn tinh ý thì sẽ thấy là trong đoạn mã xử lý của hàm main() mà tôi trình bày ở đầu bài thực hành, chúng ta có thể có đến 3 phương thức khởi tạo khác nhau.

  • Phương thức đầu tiên nhận vào 2 tham số nguyên, lần lượt là tử số và mẫu số. Đây là phương thức mà mọi người hay nghĩ đến đầu tiên khi muốn tạo phân số.
  • Phương thức thứ 2 chỉ nhận 1 tham số nguyên và tạo ra 1 phân số có giá trị bằng số nguyên đó. Điều này tương đương với mẫu số của phân số này sẽ bằng 1.
  • Phương thức cuối cùng không nhân tham số, là phương thức khởi tạo mặc định, tạo ra 1 phân số có giá trị bằng 0.

Tôi sẽ không giải thích thêm gì nữa. Các bạn có thể bắt đầu viết, ít nhất là phương thức đầu tiên. Một khi đã viết xong nó thì tôi chắc là các phương thức khởi tạo bên dưới sẽ trở nên hiển nhiên hơn.

Các phép toán

1 phần quan trọng của bài thực hành là viết mã xử lý cho các phép toán. Các bạn cần phải suy nghĩ kỹ trước khi viết mã cũng như áp dụng những gì chúng ta đã nói khi thao tác với lớp ThoiGian trong bài trước để có được 1 hệ thống phép toán kết hợp chặt chẽ. Ví dụ như chú ý kỹ thuật dùng phép toán == để định nghĩa != hay phép += để tạo ra phép +.

Rút gọn phân số

1 điểm rất quan trọng là các phân số cần phải được rút gọn, nghĩa là 2/5 thay vì 4/10, để dễ thao tác. Tốt nhất là lớp mà chúng ta tạo ra có thể tự rút gọn phân số mà nó đang đại diện.

Để thực hiện xử lý này, chúng ta sẽ cần sử dụng 1 phương pháp toán học để rút gọn phân số rồi dịch nó sang ngôn ngữ C++.

Nếu muốn rút gọn 1 phân số, trước hết chúng ta cần tìm ra ước số chung lớn nhất của tử số và mẫu số. Sau đó chúng ta chia cả tử và mẫu cho ước chung lơn nhất này. Ví dụ như 4/10 thì 4 và 10 có ước chung lớn nhất là 2. Vậy nên chúng ta sẽ chia cả 4 và 10 cho 2 được 2 và 5 và kết quả của phân số rút gọn sẽ là 2/5.

Để tính ước chung lớn nhất cũng không phải là đơn giản. Sau đây tôi sẽ trình bày với các bạn hàm số để tìm ước chung lớn nhất này dùng giải thuật Euclid. Các bạn có thể thêm nó vào trong tệp PhanSoX.cpp để sử dụng.

int ucln(int a, int b){
    while (b != 0){
        const int t = b;
        b = a % b;
        a = t;
    }
    return a;
}

Chúng ta cũng cần thêm nguyên mẫu của nó vào PhanSoX.h nữa.

#ifndef DEF_PHANSOX
#define DEF_ PHANSOX
#include <iostream>

class PhanSoX{
   //Khai bao lop…
};

int ucln(int a, int b);

#endif

Bây giờ các bạn có thể thoải mái sử dụng hàm này trong các phương thức của lớp.

Vậy là đủ rồi, hãy bắt đầu viết mã thôi.

Đáp án

Đừng đọc phần này nếu các bạn chưa thử tự mình thực hiện bài thực hành. Trái lại, nếu các bạn đã làm việc 1 cách nghiêm túc thì sau đây sẽ là đáp án dùng để các bạn so sánh với những gì mà các bạn đã viết.

Chắc hẳn các bạn cũng đã tốn kha khá thời gian để nghĩ xem chúng ta sẽ cần viết những phương thức và phép toán nào. Nếu các bạn không thể tự làm được hết bài thì cũng không sao cả, bài chữa dưới đây sẽ chỉ ra một số điểm chính cần lưu ý có thể giúp ích cho các bạn để hoàn thiện chương trình của mình. Sau đấy, các bạn có thể tiếp tục rèn luyện với những ý tưởng để cải tiến được đề xuất ở cuối bài học này.

Chúng ta hãy cùng xem 1 lượt các bước tạo ra chương trình.

Các phương thức khởi tạo

Tôi đã đề xuất là chúng ta sẽ bắt đầu với phương thức khởi tạo yêu cầu 2 tham số nguyên là tử số và mẫu số. Đây là phiên bản của tôi.

PhanSoX:: PhanSoX(int tuSo, int mauSo) : m_tuSo(tuSo), m_mauSo(mauSo){
}

Chúng ta sử dụng danh sách giá trị để thực hiện khởi tạo. Đến đây thì mọi thứ vẫn bình thường.

Tiếp theo là 2 phương thức khởi tạo khác lần lượt nhận 1 tham số và không nhận tham số.

PhanSoX:: PhanSoX(int giaTri) : m_tuSo(giaTri), m_mauSo(1){
}

PhanSoX:: PhanSoX() : m_tuSo(0), m_mauSo(1){
}

Cần chú ý là ở đây, số 5 sẽ được biểu diễn là 5/1 hay số 0 là 0/1.

Đến lúc này thì chúng ta vẫn đáp ứng đủ yêu cầu của bài tập. Trước khi chuyển sang viết những thứ phức tạp hơn, hãy viết phép toán << cho phép chúng ta hiển thị phân số của chúng ta ra màn hình. Ngoài ra nhờ vậy chúng ta cũng sẽ dễ dàng phát hiện các lỗi của chương trình nếu có.

Hiển thị 1 phân số

Theo như những gì chúng ta đã xem trong bài học trước, giải pháp tốt nhất lúc này là tạo ra 1 phương thức hienThi() cho lớp và hàm operator<< sẽ gọi phương thức này để hiển thị đối tượng. Trong trường hợp này, tôi đề nghị chúng ta bắt chước mã nguồn đã viết ở bài trước.

ostream& operator<<(ostream& luong, PhanSoX const& phanSo){
    phanSo.hienThi(luong);
    return luong;
}

Và đoạn mã của hienThi() của tôi như sau :

void PhanSoX::hienThi(ostream& luong) const {
    if(m_mauSo == 1){
        luong << m_tuSo;
    }else{
        luong << m_tuSo << '/' << m_mauSo;
    }
}

! Chú ý từ khóa const được sử dụng bởi vì phương thức này chỉ hiển thị các thuộc tính của lớp chứ không thay đổi chúng.

Chắc ít nhất các bạn cũng viết được 1 đoạn mã gần giống như trên. Ở đây tôi phân ra 2 trường hợp khi mẫu số bằng 1 hoặc khác 1. Trong trường hợp mẫu số bằng 1 thì việc hiển thị mẫu số là không cần thiết.

Phép cộng

Tương tự như phép toán <<, chúng ta cũng sẽ áp dụng giải pháp chúng ta đã sử dụng ở bài học trước : tạo ra phương thức operator+=() cho lớp và sử dụng nó trong mã xử lý của operator+().

PhanSoX operator+( PhanSoX const& a, PhanSoX const& b){
    PhanSoX saoChep(a);
    saoChep += b;
    return saoChep;
}

Phần khó khăn nằm ở mã xử lý của phép toán rút gọn.

Đề phòng có bạn nào đó không nhớ thì sau đây là công thức cộng 2 phân số.

Nếu chuyển sang ngôn ngữ C++ thì chúng ta sẽ có phương thức sau :

PhanSoX& PhanSoX::operator+=( PhanSoX const& phanSoKhac){
    m_tuSo= phanSoKhac.m_mauSo * m_tuSo+ m_mauSo * phanSoKhac.m_tuSo;
    m_mauSo = m_mauSo  * phanSoKhac.m_mauSo;

    return *this;   
}

! Giống như tất cả các phép toán rút gọn khác, operator+= sẽ trả về *this. Đây là quy ước.

Phép nhân

Phép nhân của 2 phân số thậm chí còn dễ hơn so với phép cộng.

Chắc các bạn sẽ không ngạc nhiên nếu tôi sử dụng đến phương thức operator*=() chứ. Tôi nghĩ hẳn tất cả mọi người đều đã nắm được nguyên lý.

PhanSoX operator*( PhanSoX const& a, PhanSoX const& b){
    PhanSoX saoChep(a);
    saoChep *= b;
    return saoChep;
}

PhanSoX& PhanSoX::operator*=( PhanSoX const& phanSoKhac){
    m_tuSo *= phanSoKhac.m_tuSo;
    m_mauSo *= phanSoKhac.m_mauSo;

    return *this;
}
Các phép toán so sánh bằng

Hai phân số đã rút gọn sẽ bằng nhau nếu cả tử số và mẫu số của chúng đều lần lượt bằng nhau. Thuật toán ở đây sẽ khá đơn giản. Lần này chúng ta cũng vẫn cần 1 phương thức bang() ở trong lớp và được sử dụng trong các hàm phép toán bên ngoài.

bool PhanSoX::bang(PhanSoX const& phanSoKhac) const {
    if(m_tuSo == phanSoKhac.m_tuSo && m_mauSo == phanSoKhac.m_mauSo)
        return true;
    else
        return false;
}

bool operator==( PhanSoX const& a, PhanSoX const& b){
    if(a.bang(b))
        return true;
    else
        return false;
}

bool operator!=( PhanSoX const& a, PhanSoX const& b){
    if(a.bang(b))
        return false;
    else
        return true;
}

Hoặc ngắn gọn hơn :

bool PhanSoX::bang(PhanSoX const& phanSoKhac) const {
    return (m_tuSo == phanSoKhac.m_tuSo && m_mauSo == phanSoKhac.m_mauSo);
}

bool operator==( PhanSoX const& a, PhanSoX const& b){
    return (a.bang(b));
}

bool operator!=( PhanSoX const& a, PhanSoX const& b){
    return !(a.bang(b));
}

Một khi phương thức bang() hoàn thành thì 2 hàm phép toán trở nên quá đơn giản. Hơn nữa phép toán != còn sử dụng phép toán ==, mọi việc trở nên càng đơn giản.

Các phép toán so sánh hơn kém

Chỉ còn sót lại phép toán so sánh hơn kém là chúng ta sẽ hoàn thành cơ bản chương trình. Có rất nhiều cách để so sánh 2 phân số nhưng dưới đây tôi sẽ dùng 1 công thức có sẵn trong sách toán trung học cơ sở.

Công thức này biểu diễn ở trong C++ sẽ như sau.

bool PhanSoX::nhoHon(PhanSoX const& phanSoKhac) const{
    if(m_tuSo * phanSoKhac.m_mauSo < m_mauSo * phanSoKhac.m_tuSo)
        return true;
    else
        return false;
}

Lần này có đến 4 phép toán có thể sử dụng phương thức chúng ta vừa tạo.

bool operator<( PhanSoX const& a, PhanSoX const& b) {
    return a.nhoHon(b);
}

bool operator>( PhanSoX const& a, PhanSoX const& b){
    return b.nhoHon(a);
}

bool operator<=( PhanSoX const& a, PhanSoX const& b){
    return !(b.nhoHon(a));
}

bool operator>=( PhanSoX const& a, PhanSoX const& b){
    return !(a.nhoHon(b));
}

Hoàn thành 4 phép toán này là chúng ta thực hiện xong hết các phép toán cần sử dụng đến trong yêu cầu của đề bài. Chỉ còn lại vấn đề phức tạp nhât, đấy là rút gọn phân số.

Rút gọn phân số

Bên trên tôi đã giải thích là để rút gọn phân số, trước tiên chúng ta cần tìm ra ước số chung lớn nhất của tử số và mẫu số, sau đó chia cả tử và mẫu cho ước chung vừa tìm được.

Bởi vì phép toán này sẽ được sử dụng ở nhiều chỗ khác nhau trong chương trình, tôi đề nghĩ chúng ta sẽ tạo ra 1 phương thức riêng cho lớp. Thậm chí chúng ta có thể chỉ cho phép sử dụng phương thức này bên trong lớp bằng cách trao quyền truy cập private cho nó bởi vì người dùng bên ngoài cũng không thật sự cần đến nó.

void PhanSoX::rutGon(){
    int uocSo = ucln(m_tuSo, m_mauSo);  //Tim uoc chung lon nhat
    m_tuSo /= uocSo;     //Roi rut gon phan so
    m_mauSo  /= uocSo;
}

? Lúc nào thì cần sử dụng phương thức này ?

Đơn giản là mỗi khi có một phân số mới được tạo ra hoặc bị thay đổi, nghĩa là trong phương thức khởi tạo bởi có thể người dùng đưa ra giá trị các tham số tạo thành 1 phân số chưa rút gọn.

PhanSoX::PhanSoX(int tuSo, int mauSo) : m_tuSo(tuSo), m_mauSo(mauSo){
    rutGon();
}

Một phân số bị thay đổi khi là kết quả của 1 phép toán như operator+= hoặc operator*=.

PhanSoX& PhanSoX::operator+=( PhanSoX const& phanSoKhac){
    m_tuSo= phanSoKhac.m_mauSo * m_tuSo+ m_mauSo * phanSoKhac.m_tuSo;
    m_mauSo = m_mauSo  * phanSoKhac.m_mauSo;

    rutGon() ;
    return *this;   
}

PhanSoX& PhanSoX::operator*=( PhanSoX const& phanSoKhac){
    m_tuSo *= phanSoKhac.m_tuSo;
    m_mauSo *= phanSoKhac.m_mauSo;

    rutGon() ;
    return *this;
}

Cuối cùng thì lớp của chúng ta đã hoàn thành và đáp ứng tất cả những yêu cầu của đầu bài.

Cải tiến chương trình

Chương trình của chúng ta dù đã đáp ứng được yêu cầu của bài thực hành nhưng vẫn có thể có thêm rất nhiều cải tiến khác bởi vì có quá nhiều thứ chúng ta có thể làm với 1 phân số.

Tôi đề nghị các bạn tải mã nguồn của bài thực hành bên trên trước khi bắt tay vào cải tiến nó.

 Tải mã nguồn

Sau đây là 1 vài ý tưởng của tôi mà chúng ta có thể làm với các phân số.

  • Thêm các phương thức lấy cho phép chúng ta trích xuất giá trị của tử số và mẫu số.
  • Thêm phương thức trả về số thực đại diện bởi phân số.
  • Đơn giản hóa các phương thức khởi tạo bằng cách sử dụng các giá trị mặc định cho tham số. Chúng ta có thể rút gọn còn 1 phương thức khởi tạo.
  • Thêm vào các phép toán khác như phép trừ và phép chia.
  • Cải tiến lớp để có thể tạo ra các phân số âm vì hiện giờ lớp của chúng ta mới đáp ứng được các phân số dương. Các bạn có thể sẽ phải đặt ra thêm các quy tắc ví dụ như nếu là phân số âm thì dấu trừ phải ở trên tử số (nghĩa là -1/4 chứ không chấp nhận 1/-4), vv…
  • Thêm vào phép toán lấy số đối của 1 số.
  • Thêm vào các hàm toán học như abs(), sqrt() hay pow().

Tôi nghĩ là từng này cải tiến cũng khiến các bạn bận bịu được trong 1 khoảng thời gian nhất định rồi. Những thực hành này rất quan trọng để giúp các bạn hình thành lối suy nghĩ theo hướng đối tượng. Và cách suy nghĩ này chỉ có thể được hình thành thông qua không ngừng rèn luyên và rèn luyện.

Nếu các bạn có những ý tưởng khác, đừng ngại chia sẻ và chúng ta sẽ cùng thảo luận !