Hình dung một tiệm in. Bạn đặt in cùng một tờ rơi "error: not found" ở mười đơn hàng khác nhau. Tiệm khôn ngoan không in mười lần: họ in một bản mẫu, rồi mọi đơn cùng trỏ về đúng tờ đó — tiết kiệm giấy, tiết kiệm mực. Hệ quả bất ngờ: hai đơn hàng bạn tưởng là riêng biệt hóa ra lại chỉ về cùng một tờ giấy. Compiler làm y hệt với chuỗi literal trong chương trình của bạn, và cái "cùng một tờ giấy" đó vừa là món quà tiết kiệm, vừa là một cái bẫy so sánh mà nhiều người sập.
Bạn dùng chuỗi "error: not found" ở mười chỗ trong chương trình. Có mười bản của nó trong binary không? Trực giác nói mỗi lần viết literal là một vùng nhớ mới. Nhưng compiler và trình liên kết làm một việc tiết kiệm: gộp các hằng giống nhau thành một bản duy nhất trong .rodata (vùng dữ liệu chỉ đọc). Tôi đo cái gộp đó trong container gcc:13 (ARM), và thấy nó vừa tiết kiệm thật, vừa để lộ một cạm bẫy so sánh con trỏ mà nhiều người vấp.
Gộp literal: một bản cho mọi lần dùng
Khi hai chuỗi literal giống hệt nhau xuất hiện trong mã, compiler đặt một bản trong .rodata và cho mọi chỗ dùng trỏ tới cùng địa chỉ đó — gọi là string pooling. Tương tự với hằng số và mảng hằng trùng nhau. Có cả kỹ thuật tail merging (gối hậu tố): nếu một chuỗi là hậu tố của chuỗi khác — "world" nằm cuối "hello world" — chúng có thể chia sẻ phần đuôi, tùy cờ.
Tôi kiểm bằng hai hàm f1 và f2 cùng trả về literal "hello-world-constant". strings trên binary cho thấy chỉ một bản của chuỗi đó — compiler đã gộp. Và điều thú vị hiện ra khi so con trỏ:
f1() == f2() -> BẰNG NHAU (cùng địa chỉ 0x400758)
Hai con trỏ tới hai literal viết riêng ở hai hàm lại bằng nhau, vì chúng trỏ tới cùng một bản đã gộp. Đây là chỗ tôi nhầm: tôi tưởng "mỗi literal là một vùng nhớ riêng, so con trỏ literal thì mỗi lần một địa chỉ khác". Đo ra ngược — compiler gộp chúng thành một, con trỏ bằng nhau, và .rodata tiết kiệm thật khi một chuỗi lặp nhiều nơi. Đúng cái tiệm in một bản mẫu.
Tôi cũng kiểm tail merging: chuỗi "world-constant" (là hậu tố của "hello-world-constant") không được gối vào bản kia ở -O2 mặc định — nó có địa chỉ riêng. Tail merging cần điều kiện/cờ mạnh hơn, không phải mặc định. Và một bất ngờ nữa: -fno-merge-constants (tôi kỳ vọng tắt gộp) không tắt được string pooling — con trỏ vẫn bằng nhau, .rodata vẫn một bản. Cờ đó điều khiển việc gộp hằng số/mảng khác, còn gộp chuỗi literal là cơ chế riêng, gần như luôn bật.
Cạm bẫy: đừng dựa vào con trỏ literal
Cái đo hớ vui (con trỏ bằng nhau) dẫn thẳng tới một cạm bẫy nghiêm túc. Vì "abc" == "abc" cho ra true trên gcc này, một người có thể tưởng nhầm rằng so sánh con trỏ literal là cách kiểm tra chuỗi. Đừng. Chuẩn C nói rõ việc hai literal giống nhau có chung địa chỉ hay không là không xác định (unspecified) — trình biên dịch được phép gộp hoặc không. Nghĩa là:
- Trên gcc này, hôm nay,
"abc" == "abc"có thể làtrue. - Trên một compiler khác, một mức tối ưu khác, hay với hai chuỗi ở hai đơn vị dịch chưa được linker gộp, nó có thể là
false.
Dựa vào == để so chuỗi là viết một lỗi ẩn — chạy đúng ở máy bạn, sai ở nơi khác. Cách đúng luôn là so nội dung bằng strcmp (hoặc so từng ký tự), không so địa chỉ. Đây là một trong những hành vi không xác định/không đặc tả mà một tối ưu (gộp) vô tình làm cho trông như hoạt động, khiến lỗi càng khó phát hiện. Cùng một tờ giấy không phải là cách bạn kiểm hai tờ có cùng chữ hay không — vì tiệm in khác có thể in riêng.
Không chỉ chuỗi: hằng và mảng cũng gộp
String pooling là ca dễ thấy nhất, nhưng cơ chế gộp rộng hơn. Hai mảng hằng giống nhau — ví dụ hai bảng tra static const int T[256] = {...} trùng nội dung ở hai file — có thể được linker gộp thành một khi bật -fmerge-constants (mặc định) hoặc -fmerge-all-constants. Hằng số dấu phẩy động lặp lại (như 3.14159 dùng nhiều nơi) cũng vào chung một ô .rodata. Với chương trình có nhiều bảng tra hay hằng lặp, khoản tiết kiệm này cộng dồn đáng kể — cùng nguyên lý khử cái trùng lặp mà compiler áp lên mã.
Lưu ý -fmerge-all-constants mạnh tay hơn: nó gộp cả những hằng mà chuẩn C đòi phải có địa chỉ riêng biệt (hai mảng const khác tên nhưng cùng giá trị), nên nó thực ra vi phạm chuẩn và gcc không bật mặc định — một lời nhắc nữa rằng gộp là con dao hai lưỡi: tiết kiệm kích thước, nhưng có thể phá vỡ giả định về tính riêng biệt của địa chỉ nếu đẩy quá xa.
Về đến code hằng ngày
Điều đầu tiên: gộp hằng là một tối ưu kích thước có thật, hưởng miễn phí. Bạn cứ viết cùng một chuỗi thông báo ở nhiều chỗ cho rõ nghĩa; compiler gộp chúng thành một bản, không phình .rodata. Không cần tự tay tạo một hằng static const char* MSG = ... để "tránh lặp" vì lý do kích thước — trình biên dịch đã lo (dù đặt tên hằng vẫn tốt cho bảo trì, tránh gõ sai). Đây cùng họ với các tối ưu kích thước khác.
Điều thứ hai, và là bài học an toàn quan trọng nhất: không bao giờ so sánh chuỗi bằng con trỏ. if(s == "done") là một bug chờ nổ — nó so địa chỉ, không so nội dung, và chỉ tình cờ đúng khi s trỏ vào cùng literal đã gộp. Dùng strcmp(s, "done") == 0.
Và đây là chỗ cực kỳ đáng dừng lại, vì cái bẫy này lặp lại y hệt ở các ngôn ngữ khác, chỉ nguy hiểm hơn vì trông "sạch" hơn. Java là ca kinh điển bậc nhất: "abc" == "abc" trả true vì literal được intern vào chung một pool, nhưng new String("abc") == "abc" là false, và s == "abc" với s đọc từ input cũng false — vô số bug Java sinh ra từ đúng chỗ này, và câu trả lời luôn là .equals(). Python cache chuỗi nhỏ và số nguyên nhỏ y hệt: a is b có thể true cho "abc" hay 256 mà false cho 257 hay chuỗi dài — nên so giá trị phải dùng ==, is chỉ để hỏi "cùng một đối tượng". Go thì == trên string so nội dung luôn (ngôn ngữ định nghĩa thế), nên Go né hẳn bẫy này — một minh chứng rằng đây là quyết định thiết kế, không phải định luật tự nhiên. Mẫu số chung, thành một quy tắc vàng: con trỏ/định danh bằng nhau ngụ ý nội dung bằng nhau, nhưng nội dung bằng nhau không ngụ ý con trỏ bằng nhau — nên so danh tính (== con trỏ, Java ==, Python is) không bao giờ là cách đúng để hỏi "hai chuỗi này có cùng chữ không".
Muốn tự thấy thì viết hai hàm cùng trả về một literal giống hệt: const char* a(){ return "xyz"; } và const char* b(){ return "xyz"; }. Trong main, in a() == b() (so con trỏ) — trên gcc/clang bạn sẽ thấy true, vì chúng đã gộp. Chạy strings ./a.out | grep xyz để đếm số bản: chỉ một. Rồi thử điều nguy hiểm: char buf[4]; strcpy(buf,"xyz"); printf("%d\n", buf == "xyz"); — lần này false, vì buf là mảng riêng chứ không phải literal đã gộp, dù nội dung y hệt. Ba mươi giây đó cho bạn thấy vì sao so chuỗi bằng == là bẫy: nó phụ thuộc con trỏ có trùng không, không phải nội dung — và chỉ strcmp mới trả lời đúng câu hỏi bạn thật sự hỏi.