Nạp chồng nhập xuất và chuyển kiểu
Bài trước đã giới thiệu nạp chồng toán tử như một hàm có tên đặc biệt operator@. Bài
này đi sâu vào hai phần mà bài trước mới chạm qua: vì sao toán tử nhập xuất << và >>
bắt buộc phải là hàm bạn, và cách cho một đối tượng tự chuyển sang kiểu khác bằng
toán tử chuyển kiểu. Ví dụ xuyên suốt là lớp số phức Complex.
Hãy bắt đầu từ một tình huống rất đời thường. Bạn viết xong lớp Complex và muốn in nó
ra màn hình để kiểm tra. Cách thô sơ là thêm một phương thức in() rồi gọi z.in(). Cách
đó chạy được, nhưng nó lạc lõng giữa phần còn lại của chương trình: mọi kiểu khác trong
C++ đều in bằng cout << x, riêng kiểu của bạn phải in bằng một cú pháp khác. Khi trộn
lẫn hai lối viết trong cùng một câu lệnh, mã trở nên chắp vá. Tệ hơn, phương thức in()
luôn in ra cout, nên muốn ghi số phức xuống tệp hay vào một chuỗi trong bộ nhớ thì phải
viết thêm hàm mới. Vấn đề gốc là ta đã trộn hai thứ đáng lẽ tách rời: cái gì cần in và
in ra đâu.
Nạp chồng operator<< giải quyết trọn vẹn cả hai điểm yếu đó. Kiểu của bạn hòa vào cùng
hệ thống dòng của thư viện chuẩn, dùng chung cú pháp với mọi kiểu dựng sẵn, và vì đích ghi
được truyền vào như một tham số nên cùng một hàm phục vụ được cout, một ofstream ghi
tệp, hay một ostringstream dựng chuỗi trong bộ nhớ. Bạn viết đúng một lần, dùng được ở
mọi nơi. Đó là lợi ích thực dụng, không phải chuyện làm màu cú pháp.
Nhưng để làm được như vậy, ta vấp ngay một rào cản kỹ thuật thú vị: operator<< không thể
là phương thức của lớp Complex, vì toán hạng trái của nó không phải Complex. Trước khi
mổ xẻ quy tắc đứng sau chuyện đó, hãy xem một câu lệnh in số phức chạy thật đã.
Thử ngay: xem nạp chồng nhập xuất chạy từng bước
Bấm ▶ Chạy để theo dõi câu lệnh cout << a << b. Chưa cần hiểu vì sao hàm lại được
viết như vậy, chỉ cần để ý một chỗ: sau mỗi lời gọi, thứ được trả lại là gì. Các mục bên
dưới sẽ giải thích toàn bộ.
1friend ostream& operator<<(ostream& os, const Complex& z) {2 os << z.re;3 if (z.im >= 0) os << " + " << z.im << "i";4 else os << " - " << (-z.im) << "i";5 return os;6}78int main() {9 Complex a(3, 4);10 Complex b(1, -2);11 cout << a << b;12}
Mấu chốt: mỗi lời gọi operator<< trả lại chính cout chứ không phải một bản sao,
nên cả chuỗi << cùng thao tác trên một dòng duy nhất. Đó là toàn bộ lý do kiểu trả về
phải là ostream&.
Hàm thành viên hay hàm bạn
Giờ tới quy tắc đã hẹn ở trên, và ta bắt đầu từ một toán tử quen thuộc hơn <<. Khi gặp
a + b, trình biên dịch có hai cách dịch tuỳ theo operator+ được khai báo ở đâu:
- Nếu
operator+là hàm thành viên, thìa + bdịch thànha.operator+(b): toán hạng tráiatrở thành đối tượng gọi (this), toán hạng phảiblà tham số. - Nếu
operator+là hàm bạn (hàm tự do), thìa + bdịch thànhoperator+(a, b): cả hai toán hạng đều là tham số, không cóthis.
Cả hai cho cùng kết quả khi gọi a + b. Khác biệt chỉ lộ ra khi một toán hạng không
phải kiểu lớp.
Xét 2.0 + a. Nếu operator+ là hàm thành viên, biểu thức phải dịch thành
(2.0).operator+(a), nhưng 2.0 là double và không có phương thức operator+ nhận
Complex, nên không biên dịch được. Toán hạng trái của hàm thành viên buộc phải sẵn là
đối tượng của lớp. Còn với hàm bạn, 2.0 + a dịch thành operator+(2.0, a); nhờ hàm
dựng một tham số, 2.0 được tự chuyển thành Complex(2.0) rồi cộng bình thường.
Đây là lý do thực dụng để ưu tiên hàm bạn cho các toán tử số học đối xứng (+, -,
==, <): chúng cho số dựng sẵn đứng bên trái mà vẫn được chuyển kiểu tự động. Ngược
lại, toán tử làm thay đổi đối tượng trái (=, +=, ++) thì để là hàm thành viên.
Vì sao << và >> phải là hàm bạn
Ta muốn in một số phức bằng cú pháp quen thuộc cout << z. Toán hạng trái của << là
cout, một đối tượng kiểu ostream; toán hạng phải là z kiểu Complex.
Nếu cài operator<< làm hàm thành viên của Complex, thì cout << z phải dịch thành
cout.operator<<(z), nghĩa là operator<< phải là phương thức của lớp ostream. Nhưng
ostream thuộc thư viện chuẩn, ta không sửa được nó để thêm phương thức. Vì thế
operator<< cho kiểu của ta bắt buộc phải là hàm tự do, với ostream& làm tham số
trái. Và để đọc được dữ liệu riêng tư re, im của Complex, hàm tự do đó cần khai
báo là bạn của lớp.
#include <iostream>
#include <cmath>
using namespace std;
class Complex {
private:
double re; // real part
double im; // imaginary part
public:
Complex(double r = 0.0, double i = 0.0) : re(r), im(i) {}
double modulus() const { return sqrt(re * re + im * im); }
// Output: cout << z -> operator<<(cout, z)
friend ostream& operator<<(ostream& os, const Complex& z) {
os << z.re;
if (z.im >= 0) os << " + " << z.im << "i";
else os << " - " << (-z.im) << "i";
return os; // return the stream to allow chaining
}
// Input: cin >> z -> operator>>(cin, z)
friend istream& operator>>(istream& is, Complex& z) {
is >> z.re >> z.im; // read real then imaginary part
return is;
}
};
Vì sao trả về ostream&
Chi tiết quyết định là kiểu trả về và việc trả lại chính dòng. Hàm trả về ostream&,
tức tham chiếu tới đúng dòng đã nhận vào, rồi return os. Nhờ đó biểu thức
cout << a << b hoạt động: << kết hợp trái sang phải, nên cout << a chạy trước, trả
về cout, để << b tiếp tục tác động lên chính cout đó. Nếu hàm trả về void, ta sẽ
không nối chuỗi << được nữa.
operator<< nhận const Complex& vì in không sửa đối tượng. Ngược lại operator>>
nhận Complex& (không const) vì nó phải ghi giá trị đọc vào z. Cùng lẽ đó,
operator<< trả ostream& còn operator>> trả istream&.
Đọc kỹ lại thân hàm
Thân operator<< ở trên tuy ngắn nhưng mỗi dòng đều có lý do. Dòng os << z.re in phần
thực bằng cách gọi lại chính cơ chế << sẵn có cho double. Nghĩa là ta không tự tay
biến số thành ký tự, ta ủy thác việc đó cho thư viện chuẩn và chỉ lo phần riêng của mình
là bố cục hiển thị. Đây là một thói quen tốt: khi nạp chồng << cho một lớp phức tạp
chứa các lớp con, hãy để mỗi lớp tự lo phần in của nó rồi ghép lại, thay vì viết một hàm
in khổng lồ biết mọi chi tiết của mọi lớp.
Nhánh if (z.im >= 0) tồn tại vì bài toán trình bày, không phải vì bài toán tính toán.
Nếu in thẳng os << z.re << " + " << z.im << "i" thì số phức 1 - 2i sẽ hiện ra thành
1 + -2i, đúng về giá trị nhưng xấu và khó đọc. Ta tách dấu ra khỏi độ lớn: khi phần ảo
âm thì in dấu trừ rồi in -z.im, tức là giá trị tuyệt đối của nó. Chi tiết nhỏ này minh
họa một điều quan trọng: operator<< là nơi ta định nghĩa cách một kiểu tự giới thiệu
mình cho con người, nên nó có toàn quyền lo phần thẩm mỹ.
Tham số os được truyền theo tham chiếu chứ không theo giá trị, và điều này là bắt buộc
chứ không phải tùy chọn. Các lớp dòng của C++ cố tình cấm sao chép, vì một dòng gắn với
một tài nguyên thật, chẳng hạn một bộ đệm hay một tệp đang mở. Nếu ta viết
ostream os làm tham số, chương trình đơn giản là không biên dịch. Cùng lẽ đó, kiểu trả
về cũng phải là tham chiếu ostream&, và giá trị trả về phải là chính os đã nhận vào.
Giữ định dạng và giữ đúng vai
Có hai cám dỗ mà người mới hay mắc khi viết operator<<, và cả hai đều đến từ việc quên
mất mình đang đóng vai gì.
Cám dỗ thứ nhất là kết thúc bằng os << endl hoặc os << "\n". Nghe rất tiện, vì in ra
là xuống dòng luôn. Nhưng như vậy ta đã thay người dùng quyết định bố cục. Người dùng muốn
in ba số phức trên một dòng cách nhau bởi dấu phẩy thì sao? Họ hết đường. Quy ước đúng là:
operator<< chỉ in nội dung của đối tượng, không in ký tự xuống dòng, không in dấu
cách thừa ở đầu hay cuối. Việc ngắt dòng, canh cột, chèn dấu phân cách thuộc về nơi gọi.
Cám dỗ thứ hai là tự đặt định dạng bên trong hàm, ví dụ gọi os << fixed hay
os << setprecision(2) cho số đẹp. Vấn đề là các cờ định dạng của dòng có tính dính:
đặt xong thì chúng còn nguyên hiệu lực cho mọi lần in sau đó, kể cả những lần in không
liên quan gì tới lớp của bạn. Một hàm in số phức lặng lẽ làm hỏng cách in số của cả phần
còn lại chương trình là một lỗi rất khó lần ra. Nguyên tắc lành mạnh: nếu buộc phải đổi
định dạng, hãy lưu trạng thái cũ lại và khôi phục trước khi thoát hàm.
#include <iostream>
#include <iomanip>
// Save and restore the stream flags so we do not disturb the caller.
std::ostream& operator<<(std::ostream& os, const Complex& z) {
std::ios_base::fmtflags oldFlags = os.flags(); // save flags
std::streamsize oldPrec = os.precision(); // save precision
os << std::fixed << std::setprecision(2) << z.re;
if (z.im >= 0) os << " + " << z.im << "i";
else os << " - " << (-z.im) << "i";
os.flags(oldFlags); // restore what we changed
os.precision(oldPrec);
return os;
}
Hàm trên vẫn in đẹp với hai chữ số thập phân, nhưng khi nó trả về thì dòng os đã trở lại
đúng trạng thái ban đầu. Người gọi không hề bị bất ngờ. Nếu bạn thấy cách này rườm rà, đó
là dấu hiệu cho thấy nên để nguyên định dạng mặc định và giao quyền chọn định dạng cho nơi
gọi, vì họ mới biết bối cảnh hiển thị.
Xử lý lỗi khi đọc
Xuất dữ liệu hầu như luôn thành công, còn nhập dữ liệu thì thường xuyên thất bại: người
dùng gõ chữ vào chỗ cần số, tệp bị cắt cụt, dòng dữ liệu sai định dạng. Vì vậy
operator>> phải được viết với tinh thần phòng thủ mà operator<< không cần.
Mỗi dòng nhập mang trong nó một tập cờ trạng thái. Đáng chú ý nhất là failbit, bật lên
khi thao tác đọc không diễn giải được dữ liệu theo kiểu mong đợi, và eofbit, bật lên khi
chạm hết nguồn. Khi một dòng đã hỏng, mọi thao tác đọc tiếp theo trên nó lập tức không làm
gì cả, lặng lẽ trả về ngay. Đây là điều gây bối rối cho người mới: chương trình không sập,
không báo lỗi, chỉ đơn giản là biến không được gán và vòng lặp quay tít.
Điều tinh tế là một dòng có thể tự động chuyển thành bool để dùng trong điều kiện. Nhờ
vậy if (is >> z.re >> z.im) vừa đọc vừa kiểm tra kết quả trong một câu. Tận dụng đúng
cơ chế đó, operator>> viết chắc chắn sẽ trông thế này:
#include <iostream>
// Read into a temporary; only commit if the whole read succeeded.
std::istream& operator>>(std::istream& is, Complex& z) {
double r = 0.0, i = 0.0;
if (is >> r >> i) { // both reads worked
z = Complex(r, i); // commit
} else {
is.setstate(std::ios::failbit); // mark the stream as failed
}
return is; // caller can test: if (cin >> z) { ... }
}
Ba ý đáng học ở đoạn mã ngắn này. Thứ nhất, ta đọc vào biến tạm rồi mới gán vào z. Nếu
đọc thẳng vào z.re và z.im mà giữa chừng thất bại, đối tượng sẽ rơi vào trạng thái nửa
vời: phần thực đã đổi còn phần ảo thì chưa, tức là một số phức không giống cái cũ mà cũng
chẳng phải cái mới. Đọc vào biến tạm rồi cam kết một lần là cách giữ cho đối tượng luôn
hợp lệ. Thứ hai, khi thất bại ta chủ động gọi setstate(std::ios::failbit) để báo hỏng.
Việc này quan trọng khi định dạng của ta có phần riêng, ví dụ bắt buộc phải có ký tự i
ở cuối: nếu ký tự đó thiếu thì dữ liệu sai theo luật của ta dù từng số đọc vẫn ổn, và chỉ
ta mới biết điều đó nên chỉ ta mới bật được cờ hỏng. Thứ ba, ta trả về is để người gọi
viết được while (cin >> z), một thành ngữ quen thuộc của C++.
Người gọi, về phần mình, cần biết cách hồi phục sau lỗi. Chỉ gọi cin.clear() là chưa đủ,
vì clear chỉ xóa cờ hỏng chứ không xóa ký tự xấu vẫn nằm trong bộ đệm. Ký tự đó lập tức
làm hỏng lần đọc kế tiếp, và ta được một vòng lặp vô tận. Phải xóa cờ rồi bỏ qua phần đầu
vào còn kẹt lại thì mới thoát ra được.
- Viết
operator<<làm hàm thành viên. Trình biên dịch sẽ hiểuz << cout, tức toán hạng trái là đối tượng của ta, ngược hoàn toàn với cú pháp ta muốn. Toán hạng trái là dòng, nên hàm phải tự do. - Trả về
voidthay vì tham chiếu dòng. Chỉ in được một thứ rồi tắc, không nối chuỗi. - Nhận
Complex&chooperator<<thay vìconst Complex&. Hậu quả là không in được đối tượng hằng, cũng không in được giá trị tạm nhưcout << Complex(1, 2). - Quên
is >> z.remà viếtis << z.redo quán tính gõ phím. Mã có thể vẫn biên dịch với một số kiểu và chạy sai âm thầm, nên hãy đọc lại chiều mũi tên trước khi chạy.
In cho người đọc khác với tuần tự hóa
Một câu hỏi hay bị bỏ qua: định dạng in ra nên trông thế nào? Câu trả lời phụ thuộc vào việc ai sẽ đọc kết quả đó.
Nếu người đọc là con người, mục tiêu là dễ hiểu. Ta in 3 + 4i, thêm khoảng trắng cho dễ
nhìn, làm tròn số cho đỡ rối mắt, thậm chí kèm đơn vị hay nhãn. Thông tin có thể mất bớt
mà vẫn chấp nhận được, vì mắt người không cần độ chính xác tuyệt đối.
Nếu người đọc là chính chương trình, ở một lần chạy sau, thì mục tiêu đổi hẳn. Lúc này ta
đang tuần tự hóa, tức là ghi trạng thái ra ngoài với cam kết đọc lại được đầy đủ và
chính xác. Định dạng đẹp mắt trở thành gánh nặng: mọi khoảng trắng, mọi ký hiệu trang trí
đều phải được bên đọc phân tích lại. Còn làm tròn thì trực tiếp làm mất dữ liệu, đọc lại
sẽ không ra đúng số cũ. Định dạng tuần tự hóa tốt thường tối giản, chẳng hạn chỉ hai số
cách nhau bởi dấu cách, để operator>> đọc lại bằng đúng một câu lệnh.
Vì hai mục tiêu xung khắc, một dự án nghiêm túc thường tách bạch chúng: operator<< lo
phần trình bày cho người, còn một cặp hàm riêng như luu() và nap() lo phần tuần tự
hóa cho máy. Cố nhồi cả hai vai vào một hàm sẽ dẫn tới một định dạng không đủ đẹp cho
người mà cũng không đủ chặt cho máy. Trong bài học nhỏ như bài này, ta cho operator<< và
operator>> đối xứng với nhau để dễ hiểu, nhưng hãy nhớ rằng ngoài đời hai nhu cầu ấy
thường được phục vụ bởi hai bộ hàm khác nhau.
Toán tử chuyển kiểu
Một lớp có hai chiều chuyển kiểu, và cả hai đều đã thấp thoáng ở trên.
Chuyển vào: từ kiểu khác thành Complex
Biến một double thành Complex không cần thêm gì: hàm dựng một tham số
Complex(double r = 0, double i = 0) đóng vai một hàm dựng chuyển kiểu
(converting constructor). Nhờ nó, a + 2.0 chạy được vì 2.0 được tự chuyển thành
Complex(2.0). Khi không muốn chuyển ngầm như vậy, đặt explicit trước hàm dựng để
buộc viết Complex(2.0) tường minh.
Chuyển ra: từ Complex thành kiểu khác
Chiều ngược lại dùng toán tử chuyển kiểu, một hàm thành viên có cú pháp đặc biệt: tên hàm chính là kiểu đích, không khai báo kiểu trả về.
class Complex {
// ... fields, constructor ...
public:
// Conversion operator: turns a Complex into a double (its modulus).
// Note: no return type before the name; the name IS the target type.
explicit operator double() const {
return modulus();
}
};
int main() {
Complex z(3, 4);
double m = static_cast<double>(z); // explicit cast -> 5
// double bad = z; // error: explicit forbids implicit
cout << "|z| = " << m << "\n";
}
Toán tử operator double() const không ghi kiểu trả về vì tên hàm đã là kiểu đích
double. Với từ khoá explicit, ta viết được ép kiểu tường minh
static_cast<double>(z), nhưng gán thẳng double m = z; thì bị cấm.
Nếu cho chuyển ngầm, một biểu thức như z + 1 có thể nhập nhằng: trình biên dịch không
biết nên cộng số phức (z + Complex(1)) hay chuyển z thành môđun rồi cộng số thực
(double(z) + 1). Đặt explicit loại bỏ con đường ngầm gây mơ hồ, giữ ý đồ rõ ràng.
Hai chiều chuyển kiểu của một lớp: hàm dựng một tham số chuyển từ kiểu khác vào
lớp; toán tử operator T() chuyển từ lớp ra kiểu khác. Cả hai nên cân nhắc
explicit để tránh trình biên dịch tự chuyển ở nơi không mong, sinh mã biên dịch được
nhưng sai logic.
Bài tập thực hành
Bài tập 1: nạp chồng operator<< và operator>> cho phân số
Cho lớp Phanso với tử số tu và mẫu số mau kiểu int. Hãy nạp chồng operator<<
để in dạng tu/mau, và operator>> để đọc lần lượt tử rồi mẫu. Cả hai là hàm bạn.
Lời giải chi tiết
Toán hạng trái của << là ostream (của >> là istream), không thuộc lớp ta, nên
hai toán tử bắt buộc là hàm tự do và khai báo friend để đọc/ghi tu, mau.
operator<< nhận const Phanso& vì chỉ in; operator>> nhận Phanso& vì phải ghi giá
trị đọc vào. Cả hai trả về tham chiếu dòng để nối chuỗi.
#include <iostream>
using namespace std;
class Phanso {
private:
int tu, mau;
public:
Phanso(int t = 0, int m = 1) : tu(t), mau(m) {}
friend ostream& operator<<(ostream& os, const Phanso& p) {
os << p.tu << "/" << p.mau;
return os; // return stream to allow chaining
}
friend istream& operator>>(istream& is, Phanso& p) {
is >> p.tu >> p.mau; // read numerator then denominator
return is;
}
};
int main() {
Phanso a(1, 2), b(3, 4);
cout << a << " va " << b << "\n"; // 1/2 va 3/4
}
Vì operator<< trả về ostream&, câu lệnh cout << a << " va " << b nối được nhiều
<< trên cùng dòng cout.
Bài tập 2: toán tử chuyển kiểu sang double
Thêm cho Phanso một toán tử chuyển kiểu explicit operator double() trả về giá trị
thập phân tu / mau. Trong main, dùng static_cast<double> để in giá trị của 3/4.
Lời giải chi tiết
Toán tử chuyển kiểu là hàm thành viên operator double() const, không khai báo kiểu trả
về vì tên hàm đã là kiểu đích. Phải ép static_cast<double>(mau) (hoặc nhân 1.0)
trước khi chia, nếu không phép chia số nguyên sẽ cắt cụt phần lẻ. Đặt explicit để
tránh Phanso tự lặng lẽ biến thành double trong các biểu thức số học, gây nhập nhằng.
#include <iostream>
using namespace std;
class Phanso {
private:
int tu, mau;
public:
Phanso(int t = 0, int m = 1) : tu(t), mau(m) {}
// Conversion to double; name IS the target type, no return type written
explicit operator double() const {
return static_cast<double>(tu) / mau; // avoid integer division
}
};
int main() {
Phanso p(3, 4);
double d = static_cast<double>(p); // 0.75
cout << d << "\n";
}
explicit cấm double d = p; ngầm nhưng vẫn cho phép ép tường minh
static_cast<double>(p), đúng tinh thần chỉ chuyển khi lập trình viên ghi rõ.
Bài tập 3: vì sao trả về void thì hỏng
Sửa operator<< của Phanso ở bài tập 1 để trả về void thay vì ostream&. Thử biên
dịch cout << a << b và giải thích lỗi nhận được.
Lời giải chi tiết
Khi operator<< trả về void, lời gọi đầu cout << a cho ra một giá trị kiểu void.
Biểu thức kế tiếp trở thành (void) << b, mà void không có operator<<, nên trình
biên dịch báo lỗi đại loại no match for operator<<. Câu lệnh chỉ in được một đối
tượng rồi tắc, không nối chuỗi được. Trả về ostream& (chính os) mới cho phép giá trị
của cout << a lại là cout để << b tiếp tục.
// Broken on purpose: returns void, cannot chain
friend void operator<<(ostream& os, const Phanso& p) {
os << p.tu << "/" << p.mau;
}
// cout << a << b; // compile error: void has no operator<<
// cout << a; // this single call still works
Kết luận: kiểu trả về ostream& chính là điều kiện để chuỗi << hoạt động, không phải
chi tiết trang trí.
Bài tập 4: đọc phân số an toàn theo dạng tu/mau
Viết operator>> cho Phanso đọc đúng dạng 3/4, nghĩa là một số nguyên, một dấu gạch
chéo, rồi một số nguyên nữa. Nếu định dạng sai, hoặc mẫu số bằng 0, thì đánh dấu dòng
là hỏng và không làm thay đổi đối tượng. Trong main, dùng if (cin >> p) để phân
biệt đọc được và đọc hỏng.
Lời giải chi tiết
Nguyên tắc chủ đạo là đọc vào biến tạm rồi mới cam kết. Ta đọc tử số, đọc ký tự phân cách,
đọc mẫu số, và chỉ khi cả ba bước đều đạt cùng với mẫu số khác 0 thì mới gán vào p.
Nếu bất kỳ bước nào hỏng, ta gọi setstate(ios::failbit) để dòng mang trạng thái hỏng,
nhờ đó điều kiện if (cin >> p) ở nơi gọi trở thành sai và người gọi biết đường xử lý.
Đối tượng p giữ nguyên giá trị cũ, không rơi vào trạng thái nửa vời.
Lưu ý là ta kiểm tra ký tự phân cách một cách tường minh chứ không tin rằng nó đúng. Chính việc ràng buộc định dạng là phần luật riêng của lớp, và cũng chính vì thế mà chỉ lớp mới có trách nhiệm bật cờ hỏng khi luật riêng bị vi phạm.
#include <iostream>
#include <string>
using namespace std;
class Phanso {
private:
int tu, mau;
public:
Phanso(int t = 0, int m = 1) : tu(t), mau(m) {}
friend ostream& operator<<(ostream& os, const Phanso& p) {
os << p.tu << "/" << p.mau;
return os;
}
friend istream& operator>>(istream& is, Phanso& p) {
int t = 0, m = 1;
char gach = 0;
// Read into temporaries; commit only when everything is valid.
if (is >> t >> gach >> m && gach == '/' && m != 0) {
p.tu = t;
p.mau = m;
} else {
is.setstate(ios::failbit); // our own format rule was broken
}
return is;
}
};
int main() {
Phanso p;
if (cin >> p) {
cout << "Doc duoc: " << p << "\n";
} else {
cout << "Du lieu sai dinh dang\n";
}
}
Ba điểm cần thấy rõ. Thứ nhất, biểu thức is >> t >> gach >> m tự chuyển thành bool,
nên nó vừa đọc vừa cho biết kết quả. Thứ hai, dấu && chỉ chạy vế phải khi vế trái đúng,
nên gach == '/' chỉ được kiểm tra khi ba lần đọc đã thành công, tránh dùng giá trị rác.
Thứ ba, hàm trả về is nên nơi gọi viết được if (cin >> p), đúng thành ngữ C++.
Tự kiểm tra
Vì sao operator>> nhận Phanso& chứ không phải const Phanso&?
Vì >> phải ghi dữ liệu đọc được vào đối tượng (sửa tu, mau). Nếu tham số là
const Phanso& thì không sửa được, hàm trở nên vô nghĩa. Ngược lại << chỉ đọc để in
nên dùng const Phanso&.
Hàm dựng một tham số khác toán tử operator double() ở chỗ nào?
Hàm dựng Complex(double) chuyển vào lớp: biến một double thành Complex. Toán
tử operator double() chuyển ra: biến một Complex thành double. Một cái là cổng
vào, một cái là cổng ra; cả hai nên cân nhắc explicit để tránh chuyển ngầm ngoài ý.
Câu hỏi tự kiểm
- 1Vì sao operator<< cho lớp Complex bắt buộc phải là hàm tự do (thường khai friend), không thể là hàm thành viên?
- 2operator<< trả về ostream& và trả lại chính dòng đã nhận vào để làm gì?
- 3Trong operator>> viết phòng thủ, vì sao nên đọc vào biến tạm rồi mới gán vào đối tượng?
- 4Hàm dựng một tham số Complex(double) khác toán tử operator double() ở điểm nào?
Tóm tắt
Bài này xoay quanh một câu hỏi tưởng nhỏ mà quyết định toàn bộ thiết kế: toán hạng trái là
ai? Với cout << z, toán hạng trái là dòng chứ không phải đối tượng của ta, mà ostream
lại là lớp của thư viện chuẩn nên ta không sửa được nó. Từ ràng buộc đó suy ra tất cả:
operator<< và operator>> phải là hàm tự do, và vì chúng cần chạm vào dữ liệu riêng tư
nên thường được khai friend ngay trong lớp. Cách khác là để chúng thật sự tự do và chỉ
dùng các phương thức đọc công khai, tuy phải viết thêm chút nhưng giữ được đóng gói chặt
hơn. Cả hai lối đều hợp lệ, chọn lối nào tùy mức độ bạn muốn phơi bày nội bộ lớp.
Kiểu trả về là chi tiết thứ hai không được xem nhẹ. Trả về ostream&, và trả về đúng dòng
đã nhận vào chứ không phải bản sao, chính là điều kiện để chuỗi cout << a << b << c chạy
được, vì << kết hợp từ trái sang phải và mỗi lời gọi phải nộp lại dòng cho lời gọi kế
tiếp. Trả về void là chặt đứt sợi dây đó ngay lập tức.
Về tham số, nhớ sự bất đối xứng: operator<< nhận const Complex& vì nó chỉ đọc, còn
operator>> nhận Complex& vì nó phải ghi. Và hãy viết operator>> với tinh thần phòng
thủ: đọc vào biến tạm, chỉ cam kết khi mọi thứ hợp lệ, bật failbit khi luật định dạng
riêng của lớp bị vi phạm. Cuối cùng, phân biệt rõ in cho người đọc và tuần tự hóa cho máy
đọc lại; hai mục tiêu này kéo định dạng về hai hướng ngược nhau, cố gộp làm một sẽ hỏng cả
hai.
Phần chuyển kiểu bổ sung một cặp đối xứng khác: hàm dựng một tham số mở cổng vào lớp, còn
operator T() mở cổng ra khỏi lớp. Mỗi cổng mở ra là một con đường để trình biên dịch tự
chuyển kiểu sau lưng bạn, nên explicit là công cụ giữ cho mọi phép chuyển đều là lựa
chọn có ý thức của lập trình viên chứ không phải một tai nạn biên dịch được.
Toán hạng trái quyết định nơi đặt toán tử: nếu nó là kiểu của ta thì dùng được hàm thành
viên; nếu nó là ostream/istream (như <<, >>) thì bắt buộc hàm bạn. Trả về tham
chiếu dòng để nối chuỗi. Chuyển kiểu có hai chiều (hàm dựng vào, operator T() ra), nên
dùng explicit để giữ rõ ràng.