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

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 <<>> 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 inin 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ộ.

Luồng cout << a << b: vì sao phải trả về ostream& C++
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}
7
8int main() {
9 Complex a(3, 4);
10 Complex b(1, -2);
11 cout << a << b;
12}
Ngăn xếp stack
main()
#1Complex
re3
im4
#2Complex
re1
im-2
Bộ nhớ động heap
(trống)
Tạo a = 3 + 4i và b = 1 - 2i trên ngăn xếp của main().
1/7

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+hàm thành viên, thì a + b dịch thành a.operator+(b): toán hạng trái a trở thành đối tượng gọi (this), toán hạng phải b là tham số.
  • Nếu operator+hàm bạn (hàm tự do), thì a + b dịch thành operator+(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.

Vì sao hàm bạn được chuyển kiểu cả hai phía?

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.0double 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 <<>> 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 <<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.

Để ý kiểu tham số

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.rez.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.

Bốn lỗi kinh điển của nhập xuất
  • Viết operator<< làm hàm thành viên. Trình biên dịch sẽ hiểu z << 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ề void thay 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& cho operator<< 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.re mà viết is << z.re do 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()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<<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.

Vì sao nên đặt explicit ở đây?

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.

Ghi nhớ

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<<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 <<ostream (của >>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
}

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&?

>> 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

Kiểm tra nhanh: nạp chồng nhập xuất và chuyển kiểu0/4 đúngchưa trả lời
  1. 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?
  2. 2operator<< trả về ostream& và trả lại chính dòng đã nhận vào để làm gì?
  3. 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?
  4. 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<<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.

Ghi nhớ

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.