Một cuộc tranh luận thiết kế cũ nhưng chưa bao giờ hết nóng: đối tượng nên bất biến (immutable — không sửa được sau khi tạo) hay khả biến (mutable — sửa trực tiếp tại chỗ)? Phái immutable nói nó an toàn hơn, dễ suy luận, hợp đa luồng; phái phản đối nói nó chậm vì "cứ phải copy mỗi lần đổi". Cả hai đều đúng — nhưng chỉ ở một nửa câu chuyện.
Nghĩ về một tài liệu Google Docs so với một tờ giấy chép tay. Sửa tờ giấy: cầm bút gạch một chữ, xong ngay — nhưng nếu muốn năm người cùng giữ một bản mà không ai vô tình sửa của người khác, bạn phải photo ra năm bản. Google Docs thì ngược: gửi link cho năm người là xong, ai cũng thấy cùng một bản an toàn — nhưng mỗi lần "lưu phiên bản" nó phải tạo một bản chụp mới. Một bên rẻ khi sửa, đắt khi chia sẻ an toàn; bên kia rẻ khi chia sẻ, đắt khi tạo bản đổi. Đó chính xác là mutable so với immutable, và cái giá thật phụ thuộc bạn đổi nhiều hay chia sẻ nhiều. Tôi đo cả hai hướng trong container gcc:13, và con số cho thấy một sự đối xứng gọn gàng: mỗi bên thắng lớn ở đúng sân của mình.
Copy cả đối tượng khi đổi, hay copy phòng thủ khi chia sẻ
Điểm mấu chốt: hai cách biểu diễn đẩy chi phí sang hai thao tác khác nhau.
-
Khả biến (mutable): sửa trực tiếp một trường — O(1), không cấp phát, không copy. Nhưng nếu bạn chia sẻ đối tượng này cho nhiều nơi và không muốn ai bị bất ngờ khi nó đổi, bạn phải khóa (đa luồng) hoặc copy phòng thủ (defensive copy) mỗi lần trao đi — biến việc "chia sẻ" thành O(n). (Photo năm bản tờ giấy.)
-
Bất biến (immutable): không sửa được, nên mỗi "thay đổi" phải tạo một bản mới với giá trị đã đổi (bản cũ giữ nguyên) — O(n) cấp phát + copy mỗi lần đổi. Nhưng đổi lại, bạn chia sẻ tự do: nhiều nơi, nhiều luồng cùng giữ một tham chiếu mà không cần khóa, không sợ ai sửa lén — chia sẻ là O(1). (Gửi link Google Docs.)
Nói cách khác: mutable rẻ khi đổi, đắt khi chia sẻ an toàn; immutable rẻ khi chia sẻ, đắt khi đổi. Đây không phải "cái nào nhanh hơn" mà là "chi phí nằm ở thao tác nào".
Đo: mutable thắng 101 lần khi đổi, immutable thắng 97 lần khi chia sẻ
Tôi dùng một đối tượng chứa 4096 int (16 KB), đo cập nhật một trường nhiều lần và chia sẻ để đọc nhiều lần:
Đối tượng 4096 int (16 KB), M=1 triệu thao tác, g++ -O2:
A. CẬP NHẬT một trường nhiều lần:
mutable (sửa tại chỗ) : 1,4 ns/lần
immutable naive (copy cả 4096) : 142,7 ns/lần -> mutable nhanh ~101 lần
B. CHIA SẺ ĐỌC (trao đối tượng đi M lần):
immutable (chia sẻ shared_ptr) : 1,5 ns/lần
mutable (copy phòng thủ cả 4096) : 141,6 ns/lần -> immutable nhanh ~97 lần
C. IMMUTABLE + STRUCTURAL SHARING (chia thành chunk 64):
update copy 1 chunk + 64 con trỏ : 86,7 ns/lần -> nhanh ~1,6 lần naive
Nhìn A — sân của mutable: cập nhật một trường tại chỗ mất 1,4 ns; còn immutable naive phải copy cả 4096 int để tạo bản mới mỗi lần đổi, mất 142,7 ns — chậm hơn 101 lần. Nếu chương trình của bạn đổi trạng thái liên tục (vòng lặp game cập nhật vị trí, bộ đếm, tích lũy), immutable naive là một O(n) đắt đỏ núp trên mỗi bước.
Nhìn B — sân của immutable, đối xứng hoàn hảo: chia sẻ một đối tượng immutable để đọc chỉ tốn 1,5 ns (copy một shared_ptr, tăng bộ đếm — không đụng dữ liệu). Còn mutable, nếu muốn an toàn (đảm bảo người nhận không thấy các thay đổi sau này, hoặc tránh đua tranh đa luồng), phải copy phòng thủ cả 4096 int mỗi lần trao đi — mất 141,6 ns, chậm hơn immutable 97 lần. Đây chính là chi phí ẩn mà "mutable nhanh" giấu đi: nó chỉ nhanh khi bạn không cần chia sẻ an toàn.
Nhìn C — lối thoát cho immutable khi đổi: nếu chia đối tượng thành các khối (chunk) và mỗi "update" chỉ tạo lại khối chứa phần đổi cộng một mảng con trỏ khối (chia sẻ các khối không đổi), chi phí update giảm còn 86,7 ns — nhanh ~1,6 lần so với copy cả đối tượng. Ở kích thước 4096 với chunk 64, lợi ích còn khiêm tốn (chi phí cấp shared_ptr và copy mảng con trỏ ăn bớt), nhưng nguyên lý structural sharing này mở rộng rất tốt: với đối tượng lớn và cây phân tầng (log n), update immutable trở thành O(log n) thay vì O(n) — đó là cách các cấu trúc persistent (HAMT, RRB-tree của Clojure/Scala) cho immutable vừa an toàn vừa đổi nhanh.
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với một định kiến quen thuộc: "immutable nghe hay nhưng chậm — cứ mỗi lần đổi lại phải copy cả đối tượng, ai chịu nổi". Đo cho thấy điều đó đúng một nửa: với cập nhật nhiều lần, immutable naive quả thật chậm hơn mutable 101 lần (142,7 vs 1,4 ns). Nhưng nửa còn lại đảo ngược hoàn toàn: khi chia sẻ để đọc, immutable khỏi copy phòng thủ và thắng mutable 97 lần. "Immutable chậm" chỉ đúng cho workload đổi-nhiều; cho workload chia-sẻ-nhiều, chính mutable mới là cái chậm (vì phải copy phòng thủ hoặc khóa). Tôi đã đánh giá immutable dựa trên đúng một thao tác và bỏ qua thao tác kia.
Nhưng nếu vì thấy con số B mà quay sang tuyệt đối hóa kiểu "functional" — "immutable an toàn, dễ suy luận, nên luôn dùng nó" — thì cũng sai khi bạn đổi liên tục: copy cả đối tượng mỗi lần đổi tốn hơn sửa tại chỗ 101 lần. Một vòng lặp nóng cập nhật trạng thái hàng triệu lần bằng immutable naive sẽ là thảm họa hiệu năng. Immutable không miễn phí — nó dời chi phí sang thao tác đổi, và nếu đổi là thao tác chính thì bạn trả giá đắt (trừ khi dùng structural sharing).
Về đến code hằng ngày
Việc đầu tiên: chọn bất biến/khả biến theo workload — đổi nhiều thì mutable, chia sẻ/đọc nhiều/đa luồng thì immutable. Một bộ tích lũy trong vòng lặp nóng, một buffer cập nhật liên tục → mutable (sửa tại chỗ). Một cấu hình đọc bởi nhiều luồng, một sự kiện truyền qua nhiều tầng, một giá trị cache chia sẻ → immutable (chia sẻ không sợ ai sửa, không cần khóa). Đừng mặc định một bên; hỏi "cái này bị đổi nhiều hay bị đọc/chia sẻ nhiều?".
Việc thứ hai: khi cần cả hai — đổi nhiều VÀ chia sẻ an toàn — dùng cấu trúc persistent với structural sharing. Đây là chỗ các cấu trúc immutable "thông minh" tỏa sáng: thay vì copy cả đối tượng khi đổi, chúng chia sẻ phần không đổi và chỉ tạo lại đường dẫn tới phần đổi — update O(log n), vẫn cho chia sẻ an toàn O(1). Nếu bạn thấy mình vừa muốn immutable (an toàn) vừa lo copy đắt, đó là tín hiệu cần một persistent data structure, không phải từ bỏ immutable.
Cái đối xứng "đổi rẻ / chia sẻ rẻ" này định hình những quyết định rất lớn ở tầng cao. Java có String bất biến (chia sẻ khắp nơi an toàn) nhưng StringBuilder khả biến cho vòng nối chuỗi nóng — đúng "đổi nhiều dùng mutable"; ai từng nối chuỗi bằng += trong vòng lặp và thấy nó chậm khủng khiếp đã vấp đúng cái giá "copy cả đối tượng mỗi lần đổi" ở trên. Cả hệ React/Redux chọn immutable chính vì chia sẻ và so-sánh-tham-chiếu rẻ, và trả giá đổi bằng structural sharing (Immutable.js). Go thì mặc định truyền struct theo giá trị (copy) cho an toàn, và người ta chuyển sang con trỏ khi struct lớn và đổi nhiều — cùng một cân nhắc. Ngay cả kiến trúc dữ liệu bất biến kiểu event-sourcing / log append-only là "immutable ở quy mô hệ thống": ghi thêm không sửa, đọc/replay tự do, đánh đổi bằng chi phí dựng lại trạng thái. Nguyên tắc chung: không bên nào thắng tuyệt đối — chi phí dời sang thao tác đối nghịch, chọn theo tỉ lệ đổi/chia sẻ của bài toán.
Muốn tự thấy thì tạo một đối tượng có một mảng vài nghìn phần tử. Viết hai phiên bản "cập nhật một phần tử": bản mutable sửa thẳng arr[i] = v, bản immutable tạo một bản copy của cả mảng rồi đổi một phần tử trong bản mới. Bấm giờ cả hai chạy một triệu lần — mutable nhanh hơn cả trăm lần, vì immutable copy cả mảng mỗi lần. Giờ đảo ngược thí nghiệm: viết hai phiên bản "trao đối tượng cho một hàm đọc nó", với yêu cầu người nhận không được thấy thay đổi về sau. Bản immutable chỉ cần truyền một shared_ptr (hoặc con trỏ hằng) — gần như miễn phí; bản mutable phải copy phòng thủ cả mảng mỗi lần — chậm cả trăm lần. Hai thí nghiệm cho hai kết quả ngược nhau trên cùng dữ liệu. Ba mươi giây đó cho thấy điều mà cả "immutable chậm" lẫn "immutable luôn tốt" đều giấu đi: bất biến và khả biến không có bên nào thắng tuyệt đối — chúng dời chi phí sang hai thao tác đối nghịch, và câu trả lời đúng là đo xem bài toán của bạn đổi nhiều hơn hay chia sẻ nhiều hơn.