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ỗ)? Người theo phái immutable nói nó an toàn hơn, dễ suy luận, hợp đa luồng; ngườ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. 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).
-
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).
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.
Một lần tôi đo hớ: "immutable chậm vì cứ phải copy" và "immutable luôn an toàn nên luôn nên dùng"
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 đo cũng phá luôn niềm tin ngược — cái mà cộng đồng "functional" hay tuyệt đối hóa: "immutable an toàn, dễ suy luận, nên luôn dùng nó". 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).
Bài học đo lường: bất biến vs khả biến dời chi phí sang hai thao tác ngược nhau: CẬP NHẬT — mutable sửa tại chỗ O(1) (1,4 ns) vs immutable naive copy cả đối tượng O(n) (142,7 ns = 101x chậm); CHIA SẺ ĐỌC — immutable chia sẻ tham chiếu O(1) an toàn (1,5 ns) vs mutable copy phòng thủ O(n) mỗi lần trao (141,6 ns = 97x chậm). 'Immutable chậm' đúng cho đổi-nhiều; 'immutable luôn nên dùng' sai cho đổi-nhiều. Structural sharing (chia khối) cho immutable đổi O(log n) — vừa an toàn vừa nhanh. Chọn theo tỉ lệ đổi so với chia sẻ của bài toán, không theo tín điều.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầ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?".
Hệ quả 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.
Hệ quả thứ ba là tinh thần đo lường: "nhanh" hay "chậm" của một lựa chọn thiết kế phụ thuộc thao tác nào chiếm đa số — đo cả hai chiều trước khi kết luận. Con số mang theo: mutable: đổi O(1) (1,4 ns) nhưng chia sẻ an toàn tốn copy phòng thủ O(n) (141,6 ns); immutable: chia sẻ O(1) an toàn (1,5 ns) nhưng đổi naive tốn copy O(n) (142,7 ns) — hai bên đối xứng ~100x. Structural sharing hạ chi phí đổi của immutable (O(log n)). Chọn theo tỉ lệ đổi/chia sẻ. Một benchmark chỉ đo một thao tác sẽ "chứng minh" bên nào cũng được — sự thật nằm ở việc đo đúng thao tác mà bài toán của bạn làm nhiều nhất.
Thử ba mươi giây
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 bạn 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.