4.5. Biến đếm và luồng dữ liệu

Quay trở về những ngày đầu chúng ta tiếp xúc với C++, các bạn sẽ phát hiện ra là đối tượng đầu tiên mà chúng ta thao tác cùng chính là cout. Cùng với người anh em kết nghĩa của nó là cin, chúng cho phép chúng ta giao tiếp với cửa sổ dòng lệnh. Nhưng chúng ta thật sự biết được gì về những đối tượng này ? Liệu chúng ta có thể làm gì khác ngoài việc sử dụng các dấu <<, >> và hàm getline() ? Giờ chính là lúc chúng ta tiến xa hơn và khám phá bản chất thật sự của các luồng.

Trong bài học này, chúng ta sẽ thảo luận cách sử dụng biến đếm trên các luồng. Thêm 1 lần nữa, chúng ta lại tìm hiểu thêm về các thuật toán mới và điều này sẽ cho phép chúng ta thực hiện tất cả những thao tác cũ với các luồng, như kiểu hiển thị 1 mảng đối tượng phức tạp ra màn hình hay tệp, nhưng với 1 cách đơn giản hơn nhiều.

Cuối cùng, chúng ta sẽ phát hiện ra là các luồng cũng tồn tại thao tác dành cho các chuỗi ký tự string. Lại thêm 1 phát hiện nữa về kiểu dữ liệu đặc biệt này.

Các biến đếm trên luồng

Trong bài học về biến đếm, tôi đã giới thiệu 2 loại :

  • Biến đếm truy cập ngẫu nhiên, cho phép chúng ta truy cập tới đối tượng ở bất cứ đâu trong mảng.
  • Biến đếm 2 chiều, chỉ cho phép di chuyển tới trước hay về sau 1 vị trí trong danh sách đối tượng

Ngoài 2 loại trên đây, chúng ta vẫn còn 2 loại biến đếm khác. Và nếu tại sao tự dưng tôi lai nhắc đến chúng thì chắc chắn là bởi chúng ta sẽ cần đến trong bài học này.

1 trong các đặc tính quan trọng của các luồng, đó là chúng chỉ có thể được đọc và thay đổi theo 1 chiều. Thật vậy, chúng ta không thể ghi tệp từ dưới lên trên hay hiển thị ra màn hình từ phải sang trái. Vậy nên các biến đếm trên luồng có 1 đặc điểm là chỉ có thể tiến về phía trước. Hệ quả là chúng chỉ có phép toán ++ chứ không có phép toán -- như chúng ta từng biết ở các biến đếm khác.

Bên cạnh hạn chế quan trọng này, các biến đếm trên các luồng nhập (cin, ifstream, vv…) không thể thay đổi đối tượng mà bản thân nó đang trỏ lên. Điều này rất dễ hiểu bởi chúng ta chỉ có thể đọc thông tin từ luồng nhập chứ không thể ghi dữ liệu vào đó. Vậy nên các biến đếm tuân thủ theo nguyên lý này. Với cách nghĩ tương tự, các luồng xuất (cout, ofstream, vv…) không được phép đọc giá trị mà chỉ có thể ghi vào đối tượng đang trỏ đến.

Khai báo biến đếm trên luồng xuất

Như mọi khi, câu hỏi đầu tiên là cần sử dụng tệp tiêu đề nào để có thể sử dụng các tính năng mong muốn. Biến đếm trên các luồng nói riêng và tất cả các biến đếm nói chung nằm trong tệp iterator của SL. Dễ nhớ nhỉ !

 Hãy bắt đầu bằng việc khai báo biến đếm trên luồng xuất cout.

#include <iostream>
#include <iterator>
using namespace std;
int main() {
    ostream_iterator<double> it(cout);
    return 0;
}

Đoạn mã trên khai báo một con trỏ trên luồng xuất, cho phép chúng ta ghi các double vào đó. Có 2 chỗ khác biệt so với những gì chúng ta từng biết về con trỏ :

  • Chúng ta không sử dụng cú pháp với với ::iterator ở cuối.
  • Ở giữa dấu <>, chúng ta chỉ ra kiểu dữ liệu mà chúng ta muốn ghi vào luồng

Nhưng ngoài ra, mọi thao tác khác đều giống như mọi khi. Chúng ta có thể truy cập vào giá trị được trỏ tới nhờ toán tử *.

#include <iostream>
#include <iterator>
using namespace std;
int main() {
    ostream_iterator<double> it(cout);
    *it = 3.14;
    *it = 2.71;
    return 0;
}

Và đây là kết quả mà chúng ta sẽ nhận được.

2 số đều được hiển thị ra màn hình. Vấn đề là không có khoảng trống giữa chúng. Và đây là lúc chúng ta sử dụng đến tham số thứ 2 của phương thức khởi tạo biến đếm. Tham số này dùng để chỉ ra ký tự dùng để ngăn cách các giá trị được trỏ tới bởi *. Hãy thử đoạn mã dưới đây.

#include <iostream>
#include <iterator>
using namespace std;
int main() {
    ostream_iterator<double> it(cout, ", ");
    *it = 3.14;
    *it = 2.71;
    return 0;
}

Thật không thể tin được ! Màn hình hiển thị đúng như chúng ta mong đợi.

! Để xuống dòng sau mỗi lần hiển thị, chúng ta sẽ ngăn cách bằng "\n".

Tôi nghĩ bài tập sau có thể giúp các bạn làm nóng người 1 chút. Hãy thử lấy những đoạn mã C++ mà chúng ta đã viết hồi đầu và thay đổi chúng để sử dụng biến đếm trên luồng lên cout thử xem. Sẽ rất thú vị đấy ! sealed

Khai bao biến đếm trên luồng nhập

Các biến đếm trên luồng nhập được sử dụng một cách hoàn toàn tương tự. Chúng ta khai báo các biến đếm và chỉ ra loại đối tượng mà chúng ta muốn ghi vào luồng. Để đọc dữ liệu từ 1 tệp, chúng ta có thể khai báo như sau :

ifstream tep("C:/data.txt");
istream_iterator<double> it(tep);    //Bien dem doc cac gia tri so thuc tu tep

1 chút khác biệt với người anh em ostream_iterator đó là việc khai báo cần được thực hiện tách biệt trước khi sử dụng. Việc sử dụng sau đó lại không có gì đặc biệt nữa, chúng ta sử dụng ++ để tiến dần về phía trước.

#include <fstream>
#include <iterator>
using namespace std;
int main() {
    ifstream tep("C:/data.txt");
    istream_iterator<double> it(tep);
    double a,b;
    a = *it;    //Doc gia tri dau tien trong tep
    it++;       //Chuyen qua gia tri tiep theo
    b = *it;    //Doc gia tri tiep theo
    return 0;
}

Tóm lại là cũng không có gì quá khó khăn cả. Duy nhất là cần phải biết lúc nào là kết thúc của tệp để biết mà dừng lại. Lại 1 lần nữa, những người thiết kế thư viện chuẩn đã nghĩ đến vấn đề này. Các bạn hẳn là còn nhớ biến đếm end() chúng ta vẫn thường sử dụng với các lớp chứa chứ ? Với các luồng, chúng ta cũng có 1 cơ chế hoạt động tương tự. Chúng ta cần biết rằng khi khai báo istream_iterator mà không cung cấp tham số, hệ thông sẽ trả về cho chúng ta 1 biến đếm trỏ tới « nơi tận cùng của các luồng ». Chúng ta sẽ lợi dụng biến đếm này để làm dấu hiệu để biết lúc nào cần kết thúc đọc luồng. Hãy quan sát đoạn mã dưới đây cho phép chúng ta đọc nội dung tệp từ đầu tới cuối.

#include <fstream>
#include <iterator>
#include <iostream>
using namespace std;
int main() {
    ifstream tep("data.txt");
    istream_iterator<double> it(tep); //Bien dem tren tep
    istream_iterator<double> ketThuc; //Tin hieu ket thuc
    while(it != ketThuc) { //Khi chua den ket thuc
        cout << *it << endl;  //Doc du lieu dang duoc tro
        it++;
    }
    return 0;
}

Sau khi viết mấy dòng này, tôi tự dưng nghĩ đến 1 ý tưởng dành cho chúng ta luyện tập. Tại sao chúng ta không thử thực hiện đồng thời việc đọc từ 1 luông và ghi vào luồng khác nhỉ ?

Sử trở lại của các thuật toán

Tính tới lúc này thì thật ra những biến đếm mới chưa mang lại cho chúng ta lợi ích gì quá thú vị. Lý do là chúng ta chưa biết cách sử dụng chúng kết hợp với các thuật toán sẵn có của thư viện. Đây là 1 cơ hội tốt để bắt đầu thảo luận về điều này.

! Không phải tất cả các thuật toán đều khả dụng trong trường hợp này, ít nhất thì những thuật toán yêu cầu có khả năng truy cập ngẫu nhiên của biến đếm là hoàn toàn vô dụng.

Thuật toán sao chép

Hãy bắt đầu với thuật toán được sử dụng nhiều nhất trong hoàn cảnh hiện tại làm việc với các luồng : copy(). Thuật toán sao chép này thường xuyên xuất hiện khi chúng ta cần đọc dữ liệu từ các tệp và lưu chúng vào trong các mảng chẳng hạn.

Hàm copy() nhận vào 3 tham số, 2 trong số đó là giới hạn đầu và cuối của khoảng dữ liệu cần đọc và tham số thứ 3 là 1 biến đếm trỏ lên đầu khoảng.

Và đây là cách chúng ta chuyển dữ liệu từ tệp vào 1 vector :

#include <algorithm>
#include <vector>
#include <iterator>
#include <fstream>
using namespace std;
int main() {
  vector<int> mang(100,0);
  ifstream tep("C:/data.txt");
  istream_iterator<int> it(tep);
  istream_iterator<int> ketThuc;
  copy(it, ketThuc, mang.begin());     //Ghi du lieu lay tu tep vao mang
  return 0;
}

! Chúng ta cần chú ý đảm bảo là kích thước mảng đủ lớn để có thể chứa hết các giá trị đọc từ tệp.

Chúng ta cũng hoàn toàn có thể sử dụng copy() 1 cách tương tự với đầu ra là 1 tệp khác hoặc màn hình. Qua đó, chúng ta có thể sửa đổi chút ít những đoạn mã nguồn ở bài trước và thay vòng lặp hiển thị bằng 1 lệnh copy() đơn giản.

int main() {
    srand(time(0));
    vector<int> mang(100,-1); //Mang gom 100 o

    //Phat sinh gia tri ngau nhien cho tung o
    generate(mang.begin(), mang.end(), CungCapGiaTri());   

    //Sap xep mang
    sort(mang.begin(), mang.end());    
               
    //Va hien thi
    copy(mang.begin(),mang.end(), ostream_iterator<int>(cout, "\n");
    return 0;
}

Đơn giản và hiệu quả khi chúng ta không còn phải đau đầu với các vòng lặp. Tất cả xử lý phức tạp được che giấu tốt dưới những hàm với những cái tên dễ hiểu. Đoạn mã trở nên sáng sủa và dễ hiểu hơn, thậm chí hiệu suất còn tăng lên.

Vấn đề về kích thước

Khi đọc dữ liệu từ tệp và ghi vào mảng, vấn đề nhức nhối chúng ta hay gặp phải chính là kích thước của mảng. Trước khi đọc tệp, thật khó để biết được chúng chứa bao nhiêu giá trị để khai báo kích thước cho mảng và chẳng ai lại muốn đọc dữ liệu đến 2 lần chỉ để lấy thêm thông tin này. Vậy nên việc có 1 loại biến đếm linh động cho phép tăng kích thước vector, list hay deque trong mỗi lần đọc thì sẽ rất tuyệt. Chúng được gọi là back_insert_iterator. Không khó để khai báo 1 biến đếm loại này.

vector<string> mang; //Mang chua chuoi ky tu
//Bien dem cho phep tang kich thuoc mang
back_insert_iterator<vector<string>>  it2(mang);

Biến đếm loại này cũng được sử dụng như các loại đã biết. Khác biệt duy nhất là khi chúng ta sử dụng toán tử *. Thay vì thay đổi 1 ô nhớ sẵn có, biến đếm này thêm vào 1 ô mới ở cuối bảng. Hãy thử sử dụng tính năng này để viết lại đoạn mã lúc trước.

#include <algorithm>
#include <vector>
#include <iterator>
#include <fstream>
using namespace std;
int main() {
  vector<int> mang;   //Khai bao mang rong
  ifstream tep("C:/data.txt");
  istream_iterator<int> it(tep);
  istream_iterator<int> ketThuc;
  back_insert_iterator<vector<int>> it2(mang);

  //Thuat toan phu trach viec them o vao cau truc
  copy(it, ketThuc, it2); 
  return 0;
}

Để rèn luyện, sao các bạn không lấy ra bài thực hành chọn ra 1 số ngẫu nhiên cho người chơi đoán mà chúng ta từng thảo luận hồi đầu nhỉ. Mã nguồn chắc chắn sẽ ngắn hơn nhiều đấy.

Các thuật toán khác

Hãy điểm nốt 1 vài thuật toán khả dụng với các tệp.

Trong bài học trước, chúng ta đã cùng nói chuyện về count() cho phép đếm số lượng của 1 giá trị bị chứa trong tập hợp. Chúng ta cũng có thể dùng nó với các tệp cũng như là sử dụng min_element()max_element() để tìm ra giá trị bé nhất và lớn nhất. Chúng đều rất dễ sử dụng. 1 đoạn mã nhỏ đủ để minh họa tất cả.

ifstream tep("C:/data.txt");
cout << *min_element(istream_iterator<int>(tep), istream_iterator<int>())<< endl;
Và lại trở về với string

Các luồng là khái niệm khá mạnh mẽ đến nỗi những người thiết kế thư viện chuẩn đã quyết định sẽ áp dụng nó lên các chuỗi ký tự. Cho tới giờ, chúng ta đã học cách để thay đổi chuỗi ký tự nhờ phép toán [] nhưng lại chưa bao giờ biết cách làm sao để chèn các số vào trong chuỗi. Thế rồi xuất hiện các luồng và chúng cho phép thực hiện ghi các số và thậm chí các kiểu dữ liệu khác vào chuỗi dưới dạng các chuỗi ký tự.

Các luồng áp dụng lên chuỗi ký tự là ostringstreamistringstream tùy theo xử lý chúng ta muốn thực hiện là đọc hay ghi dữ liệu.

Để tạo ra các đối tượng loại này không có gì quá khó khăn cả, chỉ cần cung cấp chuỗi ký tự mà chúng ta muốn thao tác như tham số cho phương thức khởi tạo luồng. Sau khi thao tác, chúng ta có thể lấy ra kết quả nhờ phương thức str(). À, trước đấy đừng quên thêm tệp tiêu đề sstream vào đầu đoạn mã định viết nhé.

#include <string>
#include <sstream>
#include <iostream>
using namespace std;
int main() {
  ostringstream luong;   //Luong cho phep ghi chuoi ky tu
  luong << "Xin chào";   //Su dung << de ghi vao luong
  luong << " các Tân";
  luong << " binh!";
  string const chuoiKyTu = luong.str(); //Lay ra ket qua
  cout << chuoiKyTu << endl;
  return 0;
}

Sau khi khai báo luồng, chúng ta đơn giản là sử dụng các dấu <<>> để ghi và đọc từ luồng. Nếu chúng ta muốn thêm các số vào luồng thì cũng không có gì khác biệt cả.

string chuoiKyTu("Giá trị của pi = ");
double const pi(3.1415);
ostringstream luong;
luong << chuoiKyTu;
luong << pi;
cout << luong.str() << endl;

Vừa đơn giản vừa hiệu quả ! Chúng ta đã kết hợp hoàn mỹ sự đơn giản của việc sử dụng string và sự tự do của việc ghi giá trị vào luồng. Đây chính là cách mà đôi khi chúng ta cũng sử dụng khi muốn ghi 1 số ra dưới dạng chuỗi ký tự.

Chúng ta cũng có thể hoàn toàn làm điều tương tự theo chiều ngược lại, nghĩa là lấy ra 1 số từ 1 chuỗi ký tự. Lúc này cần sử dụng luồng đọc istringstream. Lần này thì hẳn là các bạn hoàn toàn tự làm được rồi.

Cuối cùng, luôn ghi nhớ trong đầu là chúng ta hoàn toàn có thể sử dụng các biến đếm lên ostringstreamistringstream như với các luồng khác. Và đừng quên chúng ta cũng có thể áp dụng sức mạnh kết hợp của biến đếm và thuật toán dựng sẵn lên các string.

Tóm tắt bài học :
  • Có tồn tại các biến đếm trên các luồng
  • Các biến đếm chỉ có thể tiến về phía trước, nghĩa là chúng ta chỉ có thể sử dụng phép toán ++* lên chúng.
  • Chúng ta có thể kết hợp sử dụng các biến đếm và các thuật toán dựng sẵn để đơn giản hóa đoạn mã.
  • Chúng ta có thể ghi và đọc dữ liệu từ chuỗi ký tự nhờ ostringstreamistringstream. Việc này cho phép chúng ta kết hợp sự mạnh mẽ của các luồng với sự đơn giản của kiểu string.
  • Chúng ta có thể dùng các luồng trên kiểu string để chuyển đổi dữ liệu giữa kiểu số và kiểu chuỗi ký tự và ngược lại.