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

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.

std::string · gọi một thao tác, xem chỉ số chạy tới đâu
size() = 13trả về 4lời gọi hợp lệ
s.find(phu, pos)
chuỗi gốc
l
0
a
1
p
2
3
t
4
r
5
i
6
n
7
h
8
9
C
10
+
11
+
12
13

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()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 >>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:

Vì sao getline sau cin >> lại đọc được một dòng rỗng C++
1#include <iostream>
2#include <string>
3using namespace std;
4
5int main() {
6 int tuoi;
7 string ten;
8
9 cin >> tuoi; // nguoi dung go: 20 <Enter> Nguyen Lan <Enter>
10 getline(cin, ten); // bay o day
11 cout << "[" << ten << "]" << endl;
12 return 0;
13}
Ngăn xếp stack
main()
tuoi= 20ten= ""bộ đệm= "\n Nguyen Lan\n"
Bộ nhớ động heap
(trống)
Người dùng gõ 20 rồi Enter. cin >> tuoi đọc hai chữ số 20 và dừng lại ngay trước ký tự xuống dòng. Ký tự xuống dòng ấy VẪN NẰM trong bộ đệm, chưa ai lấy đi.
1/4

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

findnpos: 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.

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

Đếm số lần một từ xuất hiện, dùng find với vị trí bắt đầu C++
1#include <iostream>
2#include <string>
3using namespace std;
4
5int main() {
6 string s = "abcabcabc";
7 string tim = "abc";
8 int dem = 0;
9 size_t vt = s.find(tim, 0);
10
11 while (vt != string::npos) {
12 dem++;
13 vt = s.find(tim, vt + tim.size());
14 }
15 cout << dem << endl;
16 return 0;
17}
Ngăn xếp stack
main()
s= "abcabcabc"dem= 0vt= 0
Bộ nhớ động heap
(trống)
Tìm "abc" kể từ vị trí 0. Lần khớp đầu tiên bắt đầu ngay tại 0, nên vt bằng 0. Lưu ý find trả về vị trí BẮT ĐẦU của lần khớp chứ không phải vị trí kết thúc.
1/6

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:

  • 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.
  • len thì lấy nhiều nhất len ký 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;
}

isalnumtolower 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

Kiểm tra nhanh: std::string0/5 đúngchưa trả lời
  1. 1string s = "lap trinh"; thì s.find("Java") trả về giá trị nào?
  2. 2string s = "lap trinh C++"; có size() bằng 13. Lời gọi nào ném std::out_of_range?
  3. 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ì?
  4. 4Vì sao for (size_t i = 0; i <= s.size() - 1; i++) là cách viết nguy hiểm?
  5. 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.