Chuỗi ký tự: std::string
Trong C, một chuỗi là mảng char kết thúc bằng ký tự '\0', và bạn phải tự lo độ dài, tự lo bộ nhớ,
tự lo chuyện tràn bộ đệm. Bài Chuỗi ký tự trong C kể đủ những cái giá phải trả.
C++ giữ nguyên cách làm đó cho ai cần, nhưng thêm std::string: một kiểu tự nhớ độ dài, tự xin thêm
bộ nhớ khi chuỗi dài ra, và tự trả lại khi hết phạm vi.
Đổi lại, bạn phải thuộc một bộ luật chỉ số mới. Phần lớn lỗi chuỗi trong C++ không đến từ chỗ khó
hiểu, mà đến từ ba chỗ nhớ nhầm: find không thấy thì trả về gì, substr xin quá tay thì ném lỗi hay
cắt bớt, và pos bằng đúng size() là hợp lệ hay ngoài biên. Bài này gỡ đúng ba chỗ đó.
Thử ngay: gọi một thao tác, xem chỉ số chạy tới đâu
Chọn thao tác, sửa pos, sửa len, sửa chuỗi phụ. Dải ký tự sáng lên đúng đoạn bị đụng tới, và ô đọc
bên phải cho biết lời gọi trả về gì hoặc ném ngoại lệ nào. Năm nút có sẵn dẫn thẳng tới năm tình huống
đáng nhớ nhất.
s.find(phu, pos)
Tìm thấy tại chỉ số 4. `find` trả về vị trí BẮT ĐẦU của lần khớp đầu tiên kể từ 0, không phải vị trí kết thúc.
Ô ⌐ ở cuối dải không phải một ký tự. Nó là vị trí size(), chỗ mà bốn thao tác substr,
erase, insert, replace vẫn nhận làm pos hợp lệ, còn at thì ném ngoại lệ ngay tại đó. Đây là
một biên hai mặt, và nhớ nhầm nó là cách nhanh nhất để viết ra một vòng lặp chạy lố đúng một bước.
Khai báo và ba câu hỏi cơ bản
#include <iostream>
#include <string>
using namespace std;
int main() {
string ten = "Lan";
string rong; // chuỗi rỗng, hợp lệ, không phải rác
string ba_dau = string(3, '-'); // "---"
cout << ten.size() << endl; // 3
cout << ten.length() << endl; // 3, y hệt size()
cout << rong.empty() << endl; // 1, tức true
cout << ten[0] << endl; // L
return 0;
}
size() và length() trả về cùng một con số. Chúng tồn tại song song vì lý do lịch sử: length()
nghe thuận tai với chuỗi, còn size() là tên chung của mọi vùng chứa trong thư viện chuẩn, nên thuật
toán viết một lần dùng được cho cả string lẫn vector. Chọn cái nào cũng đúng, chỉ nên nhất quán
trong một dự án.
Khác với strlen của C, size() không phải duyệt để đếm. Chuỗi giữ sẵn độ dài trong chính đối
tượng, nên size() chỉ đọc một con số. Trong một vòng lặp, strlen(s) đặt ở điều kiện dừng biến vòng
lặp tuyến tính thành bậc hai, còn s.size() thì không.
:::caution Chuỗi rỗng không phải chuỗi chưa khởi tạo
string rong; cho ra một chuỗi rỗng và dùng được ngay: rong.size() bằng 0, rong.empty() bằng
true. Đây là khác biệt lớn với char buf[100]; trong C, nơi mảng chứa toàn byte rác cho tới khi bạn
tự ghi vào. Kiểu có hàm dựng thì không có trạng thái rác.
:::
Nối chuỗi, và vì sao += thường rẻ hơn +
string ho = "Nguyen";
string ten = "Lan";
string day_du = ho + " " + ten; // "Nguyen Lan"
day_du += "!"; // "Nguyen Lan!"
day_du.push_back('?'); // thêm đúng một ký tự
a + b phải dựng một chuỗi mới rồi chép cả a lẫn b vào đó. a += b thì ghi thẳng vào đuôi
a, và nếu chỗ đã xin sẵn còn dư thì nó không xin thêm bộ nhớ lần nào. Khi bạn gom chuỗi trong vòng
lặp, khác biệt ấy cộng dồn lại:
string s;
for (int i = 0; i < 1000; i++) s += "x"; // nối vào đuôi, rẻ
string t;
for (int i = 0; i < 1000; i++) t = t + "x"; // dựng chuỗi mới mỗi vòng, đắt
Cơ chế xin thêm bộ nhớ ở đây giống hệt vector: chuỗi giữ một vùng có sẵn rộng hơn số ký tự đang
dùng, và chỉ khi đầy mới xin vùng mới lớn hơn rồi chuyển sang. Bài Mảng và vector
đã mổ kỹ phép nhân đôi ấy, ở đây chỉ cần nhớ rằng string cũng chơi đúng luật đó.
Một điểm dễ vấp: "Nguyen" + " " + ten không biên dịch được nếu hai toán hạng đầu đều là chuỗi
nghĩa đen kiểu C. Cộng hai const char* với nhau là cộng hai con trỏ, và C++ không cho phép. Chỉ cần
một std::string xuất hiện đủ sớm trong biểu thức là ổn, ví dụ string("Nguyen") + " " + ten, hoặc
đơn giản là đặt biến string ở đầu như ví dụ trên.
Nhập chuỗi: cin >> và getline không làm cùng một việc
Đây là cái bẫy làm hỏng nhiều bài tập nhất trong học phần này.
string ten;
cin >> ten; // đọc tới khoảng trắng ĐẦU TIÊN rồi dừng
getline(cin, ten); // đọc HẾT dòng, kể cả khoảng trắng
Nhập Nguyen Van Lan thì cin >> ten chỉ lấy Nguyen, phần còn lại nằm nguyên trong bộ đệm. Nhưng
chuyện phiền hơn xảy ra khi bạn trộn hai cách:
1#include <iostream>2#include <string>3using namespace std;45int main() {6 int tuoi;7 string ten;89 cin >> tuoi; // nguoi dung go: 20 <Enter> Nguyen Lan <Enter>10 getline(cin, ten); // bay o day11 cout << "[" << ten << "]" << endl;12 return 0;13}
Quy tắc dễ nhớ: cin >> để lại ký tự xuống dòng, getline thì ăn luôn ký tự đó. Trộn hai thứ mà
không dọn bộ đệm ở giữa thì getline ngay sau cin >> gần như luôn trả về chuỗi rỗng.
Truy cập ký tự: [] nhanh, at() an toàn
string s = "lap trinh";
cout << s[0] << endl; // 'l', không kiểm biên
cout << s.at(0) << endl; // 'l', có kiểm biên
cout << s.at(99) << endl; // ném std::out_of_range
cout << s[99] << endl; // hành vi không xác định
Hai cách viết cho cùng kết quả khi chỉ số hợp lệ, và khác nhau hoàn toàn khi chỉ số sai. at kiểm tra
rồi ném std::out_of_range, tức chương trình dừng ở đúng chỗ có lỗi. operator[] không kiểm gì cả:
nó tính địa chỉ rồi đọc, bất kể ô nhớ đó thuộc về ai.
s[99] là hành vi không xác định, và cụm từ đó không có nghĩa là "chắc ra số rác". Nó nghĩa là
chuẩn ngôn ngữ không hứa gì hết: chương trình có thể in ra rác, có thể sập, có thể chạy đúng suốt kỳ
học rồi hỏng vào hôm chấm bài. Sim ở đầu bài cố tình không mô phỏng s[99], vì một sim vờ như biết
trước kết quả của hành vi không xác định là một sim dạy sai.
Chỉ số hợp lệ của s chạy từ 0 tới s.size() - 1. Với chuỗi rỗng thì size() - 1 là một chuyện
đáng ngại: size() có kiểu size_t không dấu, nên 0 - 1 không ra -1 mà cuộn vòng thành số
dương khổng lồ. Vòng lặp for (size_t i = 0; i <= s.size() - 1; i++) chạy hàng tỉ vòng trên chuỗi
rỗng, trong khi for (size_t i = 0; i < s.size(); i++) thì chạy không vòng nào.
find và npos: cái bẫy đắt nhất
string s = "lap trinh C++";
size_t vt = s.find("trinh");
if (vt != string::npos) cout << "thay tai " << vt << endl; // thay tai 4
find trả về vị trí bắt đầu của lần khớp đầu tiên. Không tìm thấy thì nó trả về string::npos,
một hằng số có kiểu size_t và mang giá trị lớn nhất mà kiểu ấy chứa được. Trên nền 64 bit, con số ấy
là 18446744073709551615.
Nó không phải -1, và chỗ hiểm nằm đúng ở chỗ nó gần giống -1:
int vt = s.find("Java"); // SAI: nhét size_t vào int
if (vt >= 0) cout << s[vt]; // điều kiện này ĐÚNG, vì vt bây giờ là -1
Gán npos vào int 32 bit cho ra -1, và -1 >= 0 là sai nên dòng trên có vẻ vẫn ổn. Nhưng viết
if (vt != -1) thì lại lọt, viết if (vt < s.size()) với vt là int thì trình biên dịch chuyển
vt sang không dấu và -1 biến lại thành số khổng lồ, khiến điều kiện sai theo hướng ngược. Cách viết
duy nhất luôn đúng là khai báo đúng kiểu và so với npos:
size_t vt = s.find("Java");
if (vt == string::npos) cout << "khong thay" << endl;
find còn nhận tham số thứ hai là chỗ bắt đầu tìm, và đó là cách đếm mọi lần xuất hiện:
1#include <iostream>2#include <string>3using namespace std;45int main() {6 string s = "abcabcabc";7 string tim = "abc";8 int dem = 0;9 size_t vt = s.find(tim, 0);1011 while (vt != string::npos) {12 dem++;13 vt = s.find(tim, vt + tim.size());14 }15 cout << dem << endl;16 return 0;17}
Bên cạnh find còn có rfind tìm từ phải sang, find_first_of tìm ký tự đầu tiên thuộc một tập, và
find_last_of. Cả họ đều trả về npos khi không thấy, nên luật so sánh ở trên áp dụng cho tất cả.
Cắt và sửa: substr, erase, insert, replace
string s = "lap trinh C++"; // size() = 13
s.substr(4, 5); // "trinh"
s.substr(10); // "C++", thiếu len thì lấy tới hết chuỗi
s.substr(10, 99); // "C++", xin quá tay thì cắt ở cuối, KHÔNG ném lỗi
s.substr(13); // "", pos bằng đúng size() là hợp lệ
s.substr(14); // ném std::out_of_range
Hai luật cần tách bạch, vì trộn chúng làm một là nguồn gốc của rất nhiều try/catch thừa:
posphải nằm trong0tớisize(), kể cả bằngsize(). Vượt quá thì némstd::out_of_range.lenthì lấy nhiều nhấtlenký tự. Xin nhiều hơn số ký tự còn lại thì hàm dừng ở cuối chuỗi và trả về ít hơn. Đây không phải lỗi, và không có ngoại lệ nào được ném.
Nói cách khác, s.substr(13) chạy trơn tru và cho chuỗi rỗng, còn s.at(13) thì ném ngoại lệ, dù cả
hai đều nhận cùng một con số 13. Lý do rất thẳng: at hỏi "ký tự thứ 13 là gì" mà chuỗi không có ký
tự nào ở đó, còn substr hỏi "cắt từ chỗ 13 trở đi" và câu trả lời hợp lệ là "không còn gì".
Ba hàm sửa chuỗi dùng chung đúng bộ luật ấy:
string s = "lap trinh C++";
s.erase(3, 7); // "lapC++", xoá 7 ký tự kể từ vị trí 3
s.insert(0, "hoc "); // chèn TRƯỚC vị trí 0
s.replace(0, 3, "nghien cuu"); // đoạn thay vào không cần dài bằng đoạn bị thay
replace là chỗ dễ quên nhất: nó nhận len của đoạn bị bỏ đi và một chuỗi thay vào, hai thứ đó
độc lập nhau, nên size() sau lời gọi có thể lớn hơn hoặc nhỏ hơn trước. Kéo thử trên sim ở đầu bài
với pos = 0, len = 3 rồi đổi chuỗi phụ để thấy độ dài nhảy theo.
So sánh chuỗi: chỗ C++ dễ hơn C hẳn một bậc
string a = "abc", b = "abd";
a == b; // false
a < b; // true, so theo thứ tự từ điển
a != b; // true
Trong C, viết if (s1 == s2) với hai mảng char là so địa chỉ, gần như luôn ra sai, và bạn buộc
phải gọi strcmp. Với std::string, các toán tử so sánh làm đúng điều bạn mong: chúng so nội dung,
từng ký tự một, theo thứ tự mã ký tự.
Một hệ quả thực dụng: vector<string> sắp xếp được bằng sort mà không cần viết thêm gì, vì < đã có
sẵn nghĩa đúng. Nhưng thứ tự ấy là thứ tự mã ký tự, nên "Zebra" < "apple" cho true do chữ hoa
có mã nhỏ hơn chữ thường, và tiếng Việt có dấu thì không sắp đúng theo cảm nhận người đọc. Sắp xếp
tiếng Việt cho đúng là bài toán riêng, cần bảng đối chiếu chứ không dựa vào < được.
Chuyển đổi qua lại với số
#include <string>
int n = stoi("42"); // 42
double d = stod("3.14"); // 3.14
string s = to_string(255); // "255"
stoi dừng ở ký tự đầu tiên không đọc được: stoi("42abc") cho 42, không báo gì. Nhưng chuỗi
không bắt đầu bằng số thì nó ném std::invalid_argument, và số quá lớn cho int thì ném
std::out_of_range. Với dữ liệu người dùng gõ vào, hãy bọc trong try/catch thay vì tin.
Cây cầu sang thư viện C: c_str()
Rất nhiều hàm cũ, kể cả trong thư viện chuẩn C, chỉ nhận const char*. c_str() trả về đúng thứ đó:
một con trỏ tới vùng ký tự bên trong chuỗi, có ký tự '\0' kết thúc đúng như C đòi hỏi.
string ten = "dulieu.txt";
FILE* f = fopen(ten.c_str(), "r");
Con trỏ ấy thuộc về chuỗi, không phải của bạn. Nó chết ngay khi chuỗi bị sửa hoặc bị huỷ, nên đừng cất nó lại để dùng sau. Đoạn dưới đây là hành vi không xác định, và nó là phiên bản chuỗi của cái bẫy mà bài Con trỏ và tham chiếu đã cảnh báo:
const char* p;
{
string tam = "tam thoi";
p = tam.c_str();
} // tam chết ở đây, p trỏ vào vùng đã trả lại
cout << p; // hành vi không xác định
Bài tập thực hành
Bài tập 1: đếm nguyên âm trong một câu
Đọc cả dòng bằng getline, đếm số ký tự thuộc aeiouAEIOU.
#include <iostream>
#include <string>
using namespace std;
int main() {
string s;
getline(cin, s);
string nguyen_am = "aeiouAEIOU";
int dem = 0;
for (size_t i = 0; i < s.size(); i++) {
if (nguyen_am.find(s[i]) != string::npos) dem++;
}
cout << dem << endl;
return 0;
}
Chỗ đáng học ở đây là dùng chính find làm phép kiểm tra thành viên: nguyen_am.find(s[i]) trả về
npos khi ký tự không có trong tập. Viết if (nguyen_am.find(s[i]) >= 0) là sai, vì size_t không
dấu nên vế đó luôn đúng, kể cả với npos.
Bài tập 2: tách một câu thành các từ
#include <iostream>
#include <string>
#include <vector>
using namespace std;
vector<string> tach(const string& s, char dau) {
vector<string> kq;
size_t bd = 0;
while (true) {
size_t vt = s.find(dau, bd);
if (vt == string::npos) {
kq.push_back(s.substr(bd)); // khúc cuối, không có dấu phân cách
break;
}
kq.push_back(s.substr(bd, vt - bd));
bd = vt + 1;
}
return kq;
}
int main() {
for (const string& t : tach("lap,trinh,C++", ',')) cout << "[" << t << "]";
cout << endl; // [lap][trinh][C++]
return 0;
}
Độ dài khúc giữa là vt - bd, tức hiệu hai vị trí, chứ không phải vt - bd + 1. Lý do là substr
nhận số ký tự cần lấy, và đoạn từ bd tới ngay trước vt có đúng vt - bd ký tự. Nếu chuỗi kết thúc
bằng dấu phân cách, ví dụ "a,b,", hàm trả về ba phần tử với phần tử cuối là chuỗi rỗng: đó là hành vi
đúng, vì giữa dấu phẩy cuối và hết chuỗi thật sự không có ký tự nào.
Bài tập 3: thay mọi lần xuất hiện của một từ
string thay_het(string s, const string& cu, const string& moi) {
if (cu.empty()) return s; // không có chốt này thì vòng lặp không dừng
size_t vt = 0;
while ((vt = s.find(cu, vt)) != string::npos) {
s.replace(vt, cu.size(), moi);
vt += moi.size(); // nhảy qua đoạn VỪA CHÈN
}
return s;
}
Hai chi tiết quyết định đúng sai. Thứ nhất, sau replace phải nhảy moi.size() chứ không phải
cu.size(), vì đoạn nằm ở vị trí vt bây giờ là đoạn mới. Thứ hai, nếu nhảy sai và moi chứa cu
bên trong, ví dụ thay "a" bằng "aa", vòng lặp sẽ tìm lại chính đoạn nó vừa viết ra và chạy mãi
không dừng. Chốt cu.empty() ở đầu chặn một ca vô hạn khác: tìm chuỗi rỗng thì lần nào cũng thấy.
Bài tập 4: kiểm tra chuỗi đối xứng, bỏ qua hoa thường và khoảng trắng
#include <cctype>
bool doi_xung(const string& s) {
string sach;
for (char c : s) {
if (isalnum(static_cast<unsigned char>(c)))
sach += tolower(static_cast<unsigned char>(c));
}
size_t i = 0, j = sach.size();
while (i + 1 < j) { // j là vị trí SAU ký tự cuối
if (sach[i] != sach[j - 1]) return false;
i++; j--;
}
return true;
}
isalnum và tolower nhận int và đòi giá trị nằm trong dải của unsigned char. Truyền thẳng một
char có dấu với ký tự mã âm là hành vi không xác định, nên phép ép sang unsigned char ở đây không
phải trang trí. Còn j được giữ ở dạng "vị trí sau ký tự cuối" thay vì "chỉ số ký tự cuối" chính là để
tránh size() - 1 trên chuỗi rỗng, đúng cái bẫy cuộn vòng đã nói ở mục truy cập ký tự.
Câu hỏi tự kiểm
- 1string s = "lap trinh"; thì s.find("Java") trả về giá trị nào?
- 2string s = "lap trinh C++"; có size() bằng 13. Lời gọi nào ném std::out_of_range?
- 3Sau cin >> tuoi; rồi getline(cin, ten); người dùng gõ 20, Enter, Nguyen Lan, Enter. Biến ten nhận giá trị gì?
- 4Vì sao for (size_t i = 0; i <= s.size() - 1; i++) là cách viết nguy hiểm?
- 5string s = "abc"; s.replace(0, 3, "nghien cuu"); thì s.size() bằng bao nhiêu?
Tóm tắt
std::string giữ độ dài trong chính đối tượng và tự lo bộ nhớ, nên size() chỉ đọc một con số thay vì
duyệt như strlen, và không có chuyện tràn bộ đệm khi chuỗi dài ra.
Các toán tử so sánh làm việc trên nội dung, khác hẳn mảng char của C vốn so địa chỉ và buộc phải
gọi strcmp.
Bộ luật chỉ số có hai tầng, và tách bạch chúng là gỡ được phần lớn lỗi chuỗi: pos phải nằm trong 0
tới size() kể cả bằng size(), vượt quá thì ném std::out_of_range; còn len chỉ có nghĩa là
lấy nhiều nhất bấy nhiêu ký tự, xin quá tay thì hàm dừng ở cuối chuỗi mà không báo gì.
at là ngoại lệ của tầng thứ nhất: nó đòi chỉ số tối đa size() - 1, nên s.at(size()) ném lỗi ngay
tại chỗ mà s.substr(size()) vẫn chạy trơn tru.
find trả về npos chứ không phải -1. Luôn hứng kết quả bằng size_t và so với string::npos; hứng
bằng int là mở cửa cho một lớp lỗi rất khó lần ra.
c_str() cho mượn con trỏ tới vùng ký tự bên trong chuỗi để gọi các hàm cũ của C, nhưng con trỏ ấy chết
ngay khi chuỗi bị sửa hoặc hết phạm vi.