Chuyển tới nội dung chính

Đồ án nhỏ: thư viện số học

Đồ án "hệ quản lý sinh viên" trước đó nhấn vào kế thừa và đa hình: một lớp cơ sở trừu tượng hoá phần chung, các lớp con đặc tả phần riêng, và một lớp quản lý giữ tập đối tượng. Đồ án này đi sang một trục khác của lập trình hướng đối tượng: làm cho kiểu do ta tự định nghĩa hành xử mượt mà như kiểu số dựng sẵn. Để được vậy, ta cần hai kỹ thuật mới: nạp chồng toán tử để viết a + b thay vì a.cong(b), và template để viết một thuật toán một lần rồi dùng lại cho mọi kiểu số.

Bài toán là một thư viện số học nhỏ gồm hai lớp: PhanSo biểu diễn phân số tự rút gọn, và SoPhuc biểu diễn số phức. Mỗi lớp nạp chồng đầy đủ bốn phép số học + - * /, phép so sánh bằng ==, cùng hai toán tử luồng nhập xuất >><<. Sau đó ta viết vài hàm template tổng quát, như tính tổng một dãy hay tìm phần tử lớn nhất, chạy được trên cả phân số lẫn số phức mà không phải viết lại, đúng tinh thần lập trình tổng quát.

Mục tiêu sau bài này

Bạn sẽ thiết kế một kiểu số do người dùng định nghĩa với bất biến nội bộ được canh giữ (đóng gói), trang bị cho nó một bộ toán tử đầy đủ, phân biệt rạch ròi khi nào toán tử là hàm thành viên và khi nào bắt buộc là hàm bạn, rồi viết các hàm template vận hành thống nhất trên nhiều kiểu số nhờ chính những toán tử vừa nạp chồng.

Đề bài và yêu cầu chức năng

Ta phát biểu yêu cầu rõ ràng trước khi viết mã. Thư viện gồm hai kiểu số và một nhóm hàm tổng quát.

  • Lớp PhanSo. Biểu diễn một phân số với tử số và mẫu số nguyên. Lớp tự rút gọn về dạng tối giản bằng ước chung lớn nhất ngay khi tạo, kiểm tra mẫu khác 0 để không bao giờ rơi vào trạng thái hỏng, và chuẩn hoá dấu để dấu âm luôn nằm ở tử số. Nạp chồng + - * / == cùng nhập xuất >> <<.
  • Lớp SoPhuc. Biểu diễn một số phức gồm phần thực và phần ảo kiểu double. Nạp chồng đúng bộ toán tử như trên, với phép nhân và phép chia theo công thức số phức.
  • Hàm template tổng quát. Viết một hàm tong cộng dồn một mảng phần tử và một hàm lonNhat tìm phần tử lớn nhất, cả hai viết một lần dưới dạng template rồi gọi được trên PhanSo, SoPhuc hay bất kỳ kiểu số nào, miễn kiểu đó có sẵn các toán tử cần dùng.
  • Chương trình main minh hoạ. Tạo vài phân số và số phức, tính biểu thức bằng cú pháp toán học tự nhiên, in kết quả qua cout <<, và gọi các hàm template trên cả hai kiểu.

Mỗi yêu cầu ánh xạ vào một kỹ thuật. "Tự rút gọn, mẫu khác 0" là đóng gói bất biến của lớp. "Viết a + b cho phân số" là nạp chồng toán tử thành viên. "In bằng cout << a" là nạp chồng << dạng hàm bạn, vì toán hạng trái là luồng chứ không phải kiểu của ta. "Một hàm tổng chạy trên mọi kiểu số" là template.

Quy ước include

Trong cả bài, ta giả định mỗi tệp đã có #include <iostream>, #include <cstdlib> (cho abs) và using namespace std;. Phần nhập xuất dùng ostreamistream của <iostream>. Trong trace, ký tự xuống dòng trong chuỗi được viết là \n theo đúng cú pháp C++.

Thiết kế tổng thể

Bước phân tích cho ta hai lớp dữ liệu và một nhóm hàm tự do template.

  1. PhanSo đóng gói hai thuộc tính private là tử và mẫu. Nó canh một bất biến: mẫu khác 0 và phân số luôn tối giản với mẫu dương. Mọi lối vào sửa dữ liệu, gồm hàm dựng và setter, đều đi qua một chốt kiểm tra rồi gọi lại rutGon. Phép tính ước chung lớn nhất không gắn với một phân số cụ thể nên đặt làm phương thức trợ giúp static.
  2. SoPhuc đóng gói hai thuộc tính private là phần thực và phần ảo kiểu double. Nó không có bất biến phức tạp như phân số (mọi cặp số thực đều là số phức hợp lệ), nên trọng tâm dồn vào bộ toán tử số học, đặc biệt là công thức nhân và chia số phức.
  3. Nhóm hàm template không thuộc lớp nào. Chúng nhận một kiểu T bất kỳ và chỉ giả định T hỗ trợ các toán tử cần dùng. Nhờ vậy cùng một thân hàm tong chạy được cho PhanSo, SoPhuc, thậm chí cho int hay double.

Điểm tinh tế chung của cả hai lớp là cách chọn dạng cài đặt toán tử. Toán tử số học và so sánh có toán hạng trái luôn là một đối tượng của lớp, nên cài làm hàm thành viên: a + b dịch thành a.operator+(b), với a ẩn dưới this, còn b là tham số. Toán tử luồng thì khác hẳn: cout << a có toán hạng trái là cout kiểu ostream, không phải kiểu của ta. Vì không sửa được lớp ostream của thư viện chuẩn, ta phải viết << thành hàm tự do nhận ostream& và đối tượng của ta. Hàm tự do này cần đọc dữ liệu private, nên khai báo friend bên trong lớp.

PhanSo SoPhuc
- tu : int - thuc : double
- mau : int - ao : double
+ operator+ - * / (member) + operator+ - * / (member)
+ operator== (member) + operator== (member)
# ucln(int,int) : int {static} friend operator<< (free)
friend operator<< (free) friend operator>> (free)
friend operator>> (free)

template<typename T> T tong(const T* a, int n);
template<typename T> T lonNhat(const T* a, int n); // tong, lonNhat dung
// cho ca PhanSo lan SoPhuc nho cac toan tu da nap chong o tren
Ghi nhớ: thành viên hay hàm bạn?

Một quy tắc ánh xạ: nếu toán hạng bên trái là một đối tượng của lớp thì ưu tiên hàm thành viên (+ - * / == <). Nếu toán hạng bên trái không phải kiểu của lớp, điển hình là luồng cin, cout, thì bắt buộc hàm tự do; và nếu hàm đó cần đọc hoặc ghi dữ liệu private thì khai báo nó là friend.

Cài đặt lớp PhanSo

Ta bắt đầu từ phần đóng gói, đúng tinh thần đồ án trước nhưng lần này bất biến nằm ở chính dữ liệu phân số. Hàm dựng chặn mẫu số 0 ngay trong danh sách khởi tạo (biểu thức m == 0 ? 1 : m), rồi gọi rutGon để phân số tối giản từ giây phút đầu tiên. Setter datMau cũng đi qua đúng chốt kiểm tra đó. Phép ucln đặt static vì nó không cần một đối tượng nào.

#include <iostream>
#include <cstdlib> // for abs
using namespace std;

class PhanSo {
private:
int tu, mau; // numerator and denominator

// helper: greatest common divisor by Euclid, no object needed
static int ucln(int a, int b) {
a = abs(a); b = abs(b);
while (b != 0) { int t = b; b = a % b; a = t; }
return a == 0 ? 1 : a; // avoid dividing by zero later
}

// reduce to lowest terms and move the sign onto the numerator
void rutGon() {
int g = ucln(tu, mau);
tu /= g; mau /= g;
if (mau < 0) { tu = -tu; mau = -mau; }
}

public:
// parameterized constructor with defaults; guard the denominator
PhanSo(int t = 0, int m = 1) : tu(t), mau(m == 0 ? 1 : m) {
rutGon(); // keep the invariant from the first moment
}

int layTu() const { return tu; } // const getter, read-only
int layMau() const { return mau; } // const getter, read-only

void datMau(int m) { // setter with validation
if (m != 0) { mau = m; rutGon(); }
}
};

Mọi quyết định ở đây nhắm tới việc canh giữ bất biến. Vì hàm dựng vừa chặn mẫu 0 vừa gọi rutGon, một đối tượng PhanSo không bao giờ tồn tại ở dạng chưa tối giản hay mẫu 0. Nhờ đó các phương thức đọc tin tưởng tuyệt đối vào dữ liệu mà không phải kiểm tra lại. Một lỗi hay gặp là quên abs trong ucln: nếu tử hoặc mẫu âm, thuật toán Euclid có thể trả về số âm và làm rút gọn sai dấu.

Bây giờ tới phần điểm nhấn: nạp chồng toán tử. Bốn phép số học và phép ==hàm thành viên vì toán hạng trái luôn là một PhanSo. Chúng đều const vì không sửa toán hạng mà trả về một phân số mới. Mẹo đẹp ở đây là kết quả của + - * / được trả về qua hàm dựng PhanSo(...), nên nó tự rút gọn mà ta không phải viết thêm một dòng nào.

public:
// arithmetic operators as member functions; left operand is *this
PhanSo operator+(const PhanSo& k) const {
return PhanSo(tu * k.mau + k.tu * mau, mau * k.mau); // result auto-reduces
}
PhanSo operator-(const PhanSo& k) const {
return PhanSo(tu * k.mau - k.tu * mau, mau * k.mau);
}
PhanSo operator*(const PhanSo& k) const {
return PhanSo(tu * k.tu, mau * k.mau);
}
PhanSo operator/(const PhanSo& k) const {
return PhanSo(tu * k.mau, mau * k.tu); // multiply by the reciprocal
}

// equality as a member function; both fractions are already reduced
bool operator==(const PhanSo& k) const {
return tu == k.tu && mau == k.mau;
}

// stream operators must be friends: left operand is a stream, not PhanSo
friend ostream& operator<<(ostream& os, const PhanSo& p);
friend istream& operator>>(istream& is, PhanSo& p);
};

Phép == chỉ cần so sánh trực tiếp tử với tử và mẫu với mẫu. Đây không phải lười: vì mọi phân số đều rút gọn ngay khi tạo, hai phân số bằng nhau về giá trị thì cũng giống nhau từng chữ số. Bất biến mà đóng gói canh giữ chính là thứ làm cho == đơn giản và đúng.

Cuối cùng là hai toán tử luồng, viết ngoài lớp vì là hàm tự do. Chúng đọc và ghi tu, mau nhờ đã được khai báo friend. Cả hai trả về tham chiếu tới luồng để cho phép xâu chuỗi cout << a << b. Toán tử nhập >> đọc hai số rồi dựng lại một phân số hợp lệ qua hàm dựng, nên bất biến tự được tái lập kể cả khi người dùng nhập mẫu 0.

// output: not a member, reads p.tu and p.mau through friendship
ostream& operator<<(ostream& os, const PhanSo& p) {
os << p.tu << "/" << p.mau;
return os; // return the stream to allow chaining
}

// input: read two ints, rebuild a valid reduced fraction
istream& operator>>(istream& is, PhanSo& p) {
int t, m;
is >> t >> m;
p = PhanSo(t, m); // constructor guards the denominator and reduces
return is;
}
Hai cái bẫy của toán tử luồng

Thứ nhất, nhận và trả luồng theo tham chiếu ostream& chứ không theo trị: luồng không sao chép được. Thứ hai, đừng quên return os;, vì thiếu nó thì cout << a << b không biên dịch được. Một lỗi tinh vi nữa thuộc về so sánh: nếu cài operator< bằng cách đổi sang double rồi so, kết quả có thể sai do sai số dấu phẩy động; với phân số nên nhân chéo trên số nguyên.

Cài đặt lớp SoPhuc

Lớp SoPhuc dùng đúng khuôn vừa luyện, nhưng phép nhân và phép chia theo công thức số phức. Với z1 = a + biz2 = c + di, ta có tích z1*z2 = (ac - bd) + (ad + bc)i, còn thương thì nhân cả tử lẫn mẫu với liên hợp của mẫu để khử phần ảo dưới mẫu, được z1/z2 = ((ac + bd) + (bc - ad)i) / (c^2 + d^2). Vì SoPhuc chỉ chứa hai double, không có con trỏ thành viên, nên phép gán ngầm định của trình biên dịch đã đúng, ta không cần nạp chồng operator=.

#include <iostream>
using namespace std;

class SoPhuc {
private:
double thuc, ao; // real and imaginary parts

public:
SoPhuc(double r = 0.0, double i = 0.0) : thuc(r), ao(i) {}

double layThuc() const { return thuc; } // const getters
double layAo() const { return ao; }

// arithmetic operators as member functions; left operand is *this
SoPhuc operator+(const SoPhuc& k) const {
return SoPhuc(thuc + k.thuc, ao + k.ao);
}
SoPhuc operator-(const SoPhuc& k) const {
return SoPhuc(thuc - k.thuc, ao - k.ao);
}
SoPhuc operator*(const SoPhuc& k) const {
// (a+bi)(c+di) = (ac - bd) + (ad + bc)i
return SoPhuc(thuc * k.thuc - ao * k.ao,
thuc * k.ao + ao * k.thuc);
}
SoPhuc operator/(const SoPhuc& k) const {
// multiply numerator and denominator by the conjugate of k
double mau = k.thuc * k.thuc + k.ao * k.ao;
return SoPhuc((thuc * k.thuc + ao * k.ao) / mau,
(ao * k.thuc - thuc * k.ao) / mau);
}

// equality compares both parts
bool operator==(const SoPhuc& k) const {
return thuc == k.thuc && ao == k.ao;
}

friend ostream& operator<<(ostream& os, const SoPhuc& z);
friend istream& operator>>(istream& is, SoPhuc& z);
};

// output as "a + bi", handling the sign of the imaginary part
ostream& operator<<(ostream& os, const SoPhuc& z) {
os << z.thuc << (z.ao >= 0 ? " + " : " - ") << abs(z.ao) << "i";
return os; // return stream for chaining
}

// input reads the real part then the imaginary part
istream& operator>>(istream& is, SoPhuc& z) {
is >> z.thuc >> z.ao;
return is;
}

Hãy đối chiếu hai lớp để thấy một khuôn lặp lại: dữ liệu private, bốn toán tử số học thành viên const trả về đối tượng mới, một operator== thành viên, và hai toán tử luồng friend trả về tham chiếu luồng. Khác biệt chỉ nằm ở công thức bên trong mỗi toán tử. Đó chính là lý do sâu xa để ta tiếp tục bước sang template: nếu nhiều kiểu cùng phơi ra một bộ toán tử giống nhau, ta có thể viết thuật toán chung một lần cho mọi kiểu đó.

Vì sao SoPhuc không cần operator=

Phép gán ngầm định sao chép từng thành viên. Với SoPhuc chỉ gồm hai double, sao chép từng thành viên là đúng và đủ. Chỉ khi lớp có thành viên là con trỏ trỏ tới vùng nhớ động thì sao chép nông mới gây hỏng, lúc đó mới phải tự viết operator= làm sao chép sâu theo quy tắc bộ ba.

Phần template: một thuật toán cho mọi kiểu số

Đây là phần điểm nhấn thứ hai của đồ án. Ta muốn một hàm tính tổng một mảng số mà không quan tâm số đó là phân số, số phức, hay số nguyên. Theo cách cũ, ta sẽ phải viết tong cho PhanSo, rồi chép lại cho SoPhuc, rồi lại cho double, ba thân hàm giống hệt nhau về logic, chỉ khác kiểu. Template loại bỏ đúng kiểu trùng lặp đó: ta viết thân hàm một lần với một tham số kiểu T, trình biên dịch tự đúc ra phiên bản cụ thể cho mỗi kiểu khi ta gọi.

// sum of an array of T; works for any T that supports operator+
template <typename T>
T tong(const T* a, int n) {
T s = a[0]; // start from the first element
for (int i = 1; i < n; ++i)
s = s + a[i]; // uses T::operator+
return s;
}

// largest element by a "bigger" rule passed as a comparator
template <typename T, typename Cmp>
T lonNhat(const T* a, int n, Cmp lonHon) {
T best = a[0];
for (int i = 1; i < n; ++i)
if (lonHon(a[i], best)) best = a[i]; // uses the comparator
return best;
}

Mấu chốt nằm ở dòng s = s + a[i] trong tong: nó dùng operator+ của kiểu T. Khi ta gọi tong trên mảng PhanSo, trình biên dịch suy ra T = PhanSo, nên s + a[i] chính là PhanSo::operator+ ta đã nạp chồng, và kết quả tự rút gọn. Khi gọi trên mảng SoPhuc, cùng dòng đó lại gọi SoPhuc::operator+. Một thân hàm, nhiều phiên bản, mỗi phiên bản dùng đúng toán tử của kiểu tương ứng. Đây là chỗ nạp chồng toán tử và template gặp nhau: template chỉ tổng quát được vì các kiểu đã trang bị sẵn toán tử mà nó cần.

Hàm lonNhat minh hoạ một sắc thái: số phức không có thứ tự tự nhiên, nên ta không hard-code phép < mà nhận một bộ so sánh lonHon làm tham số. Với phân số, bộ so sánh có thể nhân chéo; với số phức, có thể so theo môđun. Cùng một hàm lonNhat phục vụ cả hai mà không cần biết trước quy tắc so sánh.

Template không phải hàm, nó là khuôn để sinh hàm

template <typename T> không tạo ra mã máy. Chỉ khi bạn gọi tong với một kiểu cụ thể thì trình biên dịch mới khởi tạo template, đúc ra một phiên bản thật và biên dịch nó. Gọi tong trên PhanSo sinh một bản, gọi trên SoPhuc sinh bản khác, hai bản hoàn toàn độc lập trong chương trình đã dịch.

Chương trình main minh hoạ

Bây giờ ta ráp mọi thứ lại. Hàm main tạo vài phân số và số phức, tính biểu thức bằng cú pháp toán học tự nhiên nhờ toán tử đã nạp chồng, in kết quả qua cout <<, rồi gọi các hàm template trên cả hai kiểu để thấy cùng một hàm chạy cho hai loại số khác nhau.

#include <iostream>
#include <cstdlib>
using namespace std;

// (the two classes and the two template functions above are included here)

int main() {
// --- PhanSo: natural arithmetic through overloaded operators ---
PhanSo a(1, 2), b(1, 3);
cout << "a + b = " << (a + b) << "\n"; // 5/6
cout << "a * b = " << (a * b) << "\n"; // 1/6
cout << "a == 2/4 ? " << (a == PhanSo(2, 4)) << "\n"; // 1, both reduce to 1/2

// --- SoPhuc: same operator surface ---
SoPhuc z1(3, 2), z2(1, -1);
cout << "z1 + z2 = " << (z1 + z2) << "\n"; // 4 + 1i
cout << "z1 * z2 = " << (z1 * z2) << "\n"; // 5 - 1i

// --- one template, two element types ---
PhanSo ps[3] = { PhanSo(1, 2), PhanSo(1, 3), PhanSo(1, 6) };
cout << "tong phan so = " << tong(ps, 3) << "\n"; // 1/1

SoPhuc cs[3] = { SoPhuc(1, 1), SoPhuc(2, -1), SoPhuc(0, 3) };
cout << "tong so phuc = " << tong(cs, 3) << "\n"; // 3 + 3i

// largest fraction by cross multiply (mau > 0 holds by invariant)
auto lonHonPS = [](const PhanSo& x, const PhanSo& y) {
return x.layTu() * y.layMau() > y.layTu() * x.layMau();
};
cout << "phan so lon nhat = " << lonNhat(ps, 3, lonHonPS) << "\n"; // 1/2
return 0;
}

Khi biên dịch và chạy, chương trình in a + b = 5/6, a * b = 1/6, rồi a == 2/4 ? 1 (vì cả hai rút gọn về 1/2), tiếp đó z1 + z2 = 4 + 1iz1 * z2 = 5 - 1i, rồi tong phan so = 1/1tong so phuc = 3 + 3i, cuối cùng phan so lon nhat = 1/2. Điều đáng chú ý là lời gọi tong xuất hiện hai lần với hai kiểu khác nhau mà thân hàm chỉ viết một lần.

Xem chạy từng bước: a + b của phân số

Trace đầu tiên theo dõi a + b với hai phân số. Trình biên dịch dịch a + b thành a.operator+(b): a ẩn dưới this, b là tham số. Bên trong, ta quy đồng theo công thức rồi trả kết quả qua hàm dựng PhanSo(...), nên kết quả tự rút gọn trước khi quay về. Chú ý các phân số là đối tượng giá trị nằm trên ngăn xếp (thẻ objs trong khung), không phải trên heap.

a + b chính là a.operator+(b), kết quả tự rút gọn C++
1PhanSo PhanSo::operator+(const PhanSo& k) const {
2 // tu/mau + k.tu/k.mau, quy dong roi rut gon
3 return PhanSo(tu * k.mau + k.tu * mau, mau * k.mau);
4}
5
6int main() {
7 PhanSo a(1, 2), b(1, 3);
8 PhanSo c = a + b; // 1/2 + 1/3
9 cout << c; // 5/6
10}
Ngăn xếp stack
main()
#1PhanSo a
tu1
mau2
#2PhanSo b
tu1
mau3
Bộ nhớ động heap
(trống)
Tạo a = 1/2 và b = 1/3 trên ngăn xếp. Cả hai đã ở dạng tối giản vì hàm dựng gọi rutGon ngay khi tạo.
1/5

Xem chạy từng bước: in phân số bằng operator<<

Trace thứ hai làm rõ vì sao << phải là hàm bạn tự do. Khi viết cout << c, toán hạng trái là cout kiểu ostream, không phải PhanSo. Trình biên dịch dịch thành operator<<(cout, c), một hàm tự do nhận luồng bên trái và phân số bên phải. Hàm này đọc c.tu, c.mau (dữ liệu private) nhờ được khai báo friend, rồi trả lại chính luồng để các lời gọi << nối tiếp nhau.

cout << c dịch thành operator<<(cout, c), trả về luồng để nối chuỗi C++
1ostream& operator<<(ostream& os, const PhanSo& p) {
2 os << p.tu << "/" << p.mau; // friend doc duoc tu, mau private
3 return os; // tra ve luong de noi chuoi
4}
5
6int main() {
7 PhanSo c(5, 6);
8 cout << c << "\n"; // operator<<(cout, c) roi noi "\n"
9}
Ngăn xếp stack
main()
#1PhanSo c
tu5
mau6
Bộ nhớ động heap
(trống)
Có sẵn c = 5/6 trên ngăn xếp main. Ta sắp in nó bằng cout << c.
1/4
Trả về luồng là chìa khoá của xâu chuỗi

Nếu operator<< trả về void, thì cout << c không còn giá trị nào để << "\n" bám vào, và câu cout << c << "\n" không biên dịch được. Trả về ostream& đúng bằng tham chiếu os truyền vào là điều cho phép cout << a << b << c chạy từ trái sang phải, mỗi bước trả lại cùng một cout.

Xem chạy từng bước: tong(cs, 3) trên số phức

Trace cuối minh hoạ một lời gọi hàm template trên mảng số phức. Trình biên dịch suy ra T = SoPhuc từ kiểu đối số, rồi đúc ra phiên bản tong<SoPhuc>. Bên trong, dòng s = s + a[i] gọi SoPhuc::operator+ ta đã nạp chồng. Cùng thân hàm này, nếu gọi trên mảng PhanSo, lại gọi PhanSo::operator+. Các số phức là đối tượng giá trị trên ngăn xếp, hiển thị dưới dạng thẻ.

tong(cs, 3) với T = SoPhuc, mỗi bước cộng dùng SoPhuc::operator+ C++
1template <typename T>
2T tong(const T* a, int n) {
3 T s = a[0]; // s = cs[0]
4 for (int i = 1; i < n; ++i)
5 s = s + a[i]; // dung T::operator+
6 return s;
7}
8// goi: tong(cs, 3), cs = { (1+1i), (2-1i), (0+3i) }
tong(cs, 3)tong<SoPhuc>(cs, 3)
Ngăn xếp stack
tong<SoPhuc>()
n= 3
Bộ nhớ động heap
(trống)
Gọi tong(cs, 3). Trình biên dịch nhìn đối số cs kiểu SoPhuc*, suy ra T = SoPhuc, rồi đúc phiên bản tong<SoPhuc>. Template là khuôn, lời gọi này sinh ra một hàm thật.
1/5
Vì sao template chạy được trên cả hai kiểu

tong không hề biết SoPhuc hay PhanSo là gì. Nó chỉ giả định kiểu T có một operator+. Cả hai lớp đều đã nạp chồng +, nên cả hai đều "vừa khuôn". Đây là hợp đồng ngầm của lập trình tổng quát: hàm template nêu ra các thao tác nó cần, kiểu nào đáp ứng đủ thì dùng được.

Nhận xét và hướng mở rộng

Thư viện trên cố tình giữ nhỏ để làm bật hai kỹ thuật trọng tâm. Trong thực tế, ta sẽ mở rộng theo vài hướng, mỗi hướng nối lại với một kiến thức đã học hoặc một học phần kế tiếp.

  • Bổ sung bộ toán tử đầy đủ. Thêm các so sánh còn lại !=, <, <= và các toán tử gán phức hợp +=, -=. Một mẫu đẹp là định nghĩa += trước rồi viết + dựa trên +=, và định nghĩa != bằng cách phủ định ==, để các toán tử không bao giờ mâu thuẫn nhau.
  • Tách bất biến của SoPhuc. Có thể thêm phương thức moDun tính môđun và một toán tử < so theo môđun, để lonNhat dùng được mà không cần truyền bộ so sánh từ ngoài.
  • Gói thành lớp template. Hai lớp dùng chung khuôn toán tử gợi ý một bước trừu tượng hoá: viết một lớp template SoHuuTi<T> hay một khái niệm số tổng quát, để tái dùng phần khung. Đây là bước từ "nhiều lớp giống nhau" lên "một khuôn lớp".
  • Dùng std::vector và thuật toán chuẩn. Thay mảng thô bằng vector<PhanSo> rồi gọi std::accumulate hay std::max_element, vốn cũng là các hàm template của thư viện chuẩn, vận hành đúng nhờ chính các toán tử ta đã nạp chồng.
Khuôn mẫu thu nhỏ của một thư viện số

Đồ án này là khuôn mẫu thu nhỏ của các thư viện số học thực tế. Một kiểu số tự định nghĩa trang bị đủ bộ toán tử thì dùng được tự nhiên như kiểu nguyên thuỷ trong mọi biểu thức và mọi thuật toán template. Nắm chắc khuôn này, bạn có thể xây kiểu ma trận, đa thức, số lớn, hay vector hình học, rồi thả thẳng chúng vào các thuật toán tổng quát có sẵn.

Khi nối sang học phần sau, khuôn này dùng lại trong đồ án Trí tuệ nhân tạo. Một thư viện như tensor hay mạng nơ ron về bản chất là tập hợp các lớp template thao tác trên kiểu số bất kỳ, kết hợp với toán tử đã nạp chồng để viết biểu thức ma trận một cách tự nhiên. Bộ kỹ năng đóng gói, nạp chồng toán tử và template mà ta vừa luyện ở đây chính là nền để đọc và viết những thư viện đó.

Bài tập thực hành

Bài tập 1: nạp chồng operator< cho PhanSo

Thêm cho PhanSo toán tử operator< so sánh hai phân số. Gợi ý: đừng đổi sang double rồi so, hãy nhân chéo trên số nguyên để tránh sai số dấu phẩy động. Sau đó viết main so 1/3 < 1/2.

Lời giải chi tiết

Vì hàm dựng luôn chuẩn hoá mẫu về dương, ta nhân chéo an toàn mà không lo đổi chiều bất đẳng thức. a < b đúng khi a.tu * b.mau < b.tu * a.mau. Toán tử là hàm thành viên const vì toán hạng trái là một PhanSo và phép so sánh không sửa gì.

bool operator<(const PhanSo& k) const {
// cross multiply; mau > 0 is guaranteed by the constructor
return tu * k.mau < k.tu * mau;
}

int main() {
PhanSo a(1, 3), b(1, 2);
cout << (a < b) << "\n"; // 1 (true), since 1/3 < 1/2
}

Ý chính: nhân chéo trên số nguyên cho kết quả chính xác, miễn mẫu dương, điều mà đóng gói đã bảo đảm sẵn.

Bài tập 2: hàm bạn operator>> cho SoPhuc đọc dạng a b

Lớp SoPhuc ở trên đã có operator>>. Hãy viết main dùng nó: đọc một số phức từ cin (nhập hai số, phần thực rồi phần ảo), rồi in lại bằng cout <<. Giải thích vì sao >> cũng phải là hàm bạn.

Lời giải chi tiết

cin >> z dịch thành operator>>(cin, z), với toán hạng trái là cin kiểu istream, không phải SoPhuc. Vì không sửa được lớp istream, >> phải là hàm tự do; và vì nó ghi thẳng vào z.thuc, z.ao (dữ liệu private), nó phải là friend. Tham số thứ hai là SoPhuc& không const vì hàm cần thay đổi z.

int main() {
SoPhuc z;
cout << "Nhap phan thuc va phan ao: ";
cin >> z; // operator>>(cin, z)
cout << "Ban vua nhap: " << z << "\n";
return 0;
}

Ý chính: toán tử luồng luôn là hàm bạn vì toán hạng trái là luồng; >> cần SoPhuc& không const để ghi vào đối tượng, khác với << nhận const SoPhuc& chỉ để đọc.

Bài tập 3: hàm template trungBinh cho mảng số

Viết một hàm template trungBinh tính trung bình cộng một mảng n phần tử kiểu T. Gợi ý: dùng lại tong, rồi chia cho PhanSo(n) hoặc SoPhuc(n). Gọi nó trên cả mảng PhanSo lẫn mảng SoPhuc.

Lời giải chi tiết

Trung bình là tổng chia cho số phần tử. Ta tái dùng tong, rồi chia cho một đối tượng T dựng từ n. Vì cả PhanSo lẫn SoPhuc đều có hàm dựng nhận một số và đều nạp chồng operator/, biểu thức s / T(n) chạy đúng cho cả hai. Đây lại là chỗ template tựa vào toán tử đã nạp chồng.

template <typename T>
T trungBinh(const T* a, int n) {
T s = tong(a, n); // reuse the sum
return s / T(n); // T(n): build a T from the count, then divide
}

int main() {
PhanSo ps[2] = { PhanSo(1, 2), PhanSo(1, 2) };
cout << "TB phan so = " << trungBinh(ps, 2) << "\n"; // 1/2

SoPhuc cs[2] = { SoPhuc(2, 4), SoPhuc(4, 0) };
cout << "TB so phuc = " << trungBinh(cs, 2) << "\n"; // 3 + 2i
}

Ý chính: một hàm template gọn dùng lại được cho mọi kiểu số có +/; chỗ T(n) cho thấy ta dựng một đối tượng của kiểu tham số ngay trong thân template.

Tự kiểm tra

1) Vì sao operator<< phải là hàm bạn tự do mà không thể là hàm thành viên của PhanSo?

cout << p dịch thành lời gọi với toán hạng trái là cout kiểu ostream. Nếu << là hàm thành viên thì nó phải thuộc lớp của toán hạng trái, tức ostream, mà lớp đó nằm trong thư viện chuẩn, ta không sửa được. Lối thoát là viết << thành hàm tự do nhận ostream&const PhanSo&; vì nó cần đọc tu, mau đang private nên khai báo friend.

2) Vì sao operator== của PhanSo chỉ cần so trực tiếp tử với tử và mẫu với mẫu?

mọi PhanSo đều được rút gọn ngay khi tạo, hai phân số bằng nhau về giá trị thì cũng giống hệt nhau từng chữ số tử và mẫu. Bất biến mà đóng gói canh giữ làm cho phép so sánh trở nên đơn giản. Nếu phân số không tự rút gọn, 1/22/4 sẽ bị coi là khác nhau dù bằng về giá trị.

3) Cùng một hàm tong chạy được trên cả PhanSo lẫn SoPhuc nhờ điều gì?

Nhờ template sinh ra một phiên bản hàm riêng cho mỗi kiểu, và nhờ nạp chồng toán tử trang bị cho mỗi kiểu một operator+ riêng. Trong thân tong, dòng s = s + a[i] gọi operator+ của kiểu T hiện hành: với PhanSoPhanSo::operator+, với SoPhucSoPhuc::operator+. Hai kỹ thuật bổ trợ nhau, template tổng quát hoá thuật toán còn toán tử nạp chồng làm cho từng kiểu "vừa khuôn".

Ghi nhớ

Đồ án này gói hai trục lớn của lập trình hướng đối tượng vào một chỗ: nạp chồng toán tử làm cho kiểu tự định nghĩa hành xử như kiểu số dựng sẵn (a + b, cout << a), và template cho phép viết một thuật toán một lần rồi dùng cho mọi kiểu. Chúng tựa vào nhau: template chỉ tổng quát được vì các kiểu đã trang bị sẵn những toán tử mà nó cần.