Vì sao kiểu mutable không phải là kiểu con của immutable (và ngược lại)?

Công nghệ18 tháng 9, 2026·7 phút đọc

Câu hỏi tưởng chừng đơn giản về quan hệ kiểu dữ liệu giữa mutable và immutable lại ẩn chứa một vấn đề lý thuyết sâu sắc. Bài viết phân tích nguyên lý thay thế Liskov để lý giải vì sao hai loại này phải là những kiểu hoàn toàn tách biệt, đồng thời chỉ ra cách các ngôn ngữ lập trình xử lý vấn đề thông qua type class, interface và duck typing.

Trên các diễn đàn lập trình, chủ đề này cứ đều đặn quay trở lại: vì sao một ngôn ngữ nào đó không cung cấp hai biến thể immutable (bất biến) và mutable (khả biến) của cùng một cấu trúc dữ liệu như những kiểu con hoặc kiểu cha của nhau? Thực ra điều này không phải là bất khả thi về mặt kỹ thuật, nhưng nếu làm vậy thì không chính xác về mặt hình thức, và bạn sẽ đánh mất ít nhất một phần những đảm bảo kiểm tra kiểu mà ngôn ngữ thường có thể mang lại.

Nguyên lý thay thế Liskov

Để hiểu vì sao cách làm này không ổn, cần nhớ lại định nghĩa của một kiểu con. Đó chính là nguyên lý thay thế Liskov: kiểu S là kiểu con của kiểu T nếu một giá trị thuộc kiểu S có thể được dùng trong mọi ngữ cảnh mà một giá trị thuộc kiểu T được mong đợi.

Như thường lệ với các vấn đề hình thức, định nghĩa này được hiểu theo nghĩa nghiêm ngặt. "Mọi" thực sự có nghĩa là mọi, chứ không chỉ phần lớn. Nhiều người từng học nguyên lý thay thế như một mẫu thiết kế được khuyến nghị cho các lớp hướng đối tượng, nhưng về mặt hình thức, một kiểu con thực sự phải thỏa mãn tiêu chí này. Một hệ thống kiểu tĩnh hỗ trợ quan hệ kiểu con sẽ phải chứng minh điều đó để chương trình của bạn vượt qua được bộ kiểm tra kiểu.

Ví dụ với cấu trúc cặp đơn giản nhất

Để minh họa, hãy lấy cấu trúc dữ liệu ghép đơn giản nhất có thể: cặp (pair). Đây là các thao tác trên phiên bản immutable:

  • (cons a d): tạo một cặp mới chứa ad rồi trả về nó
  • (car p): trả về giá trị a đã được cung cấp khi cặp p được tạo
  • (cdr p): trả về giá trị d đã được cung cấp khi cặp p được tạo

Hết. Phiên bản mutable của chúng ta thêm hai thao tác mới:

  • (set-car! p a): thay đổi giá trị a bên trong cặp p
  • (set-cdr! p d): thay đổi giá trị d bên trong cặp p

Rõ ràng là một cặp immutable không thể được đưa vào nơi mà một cặp mutable được mong đợi. Nơi cần cặp mutable sẽ có khả năng dùng hai thao tác vừa nêu, mà chúng không được định nghĩa trên cặp immutable, nên sẽ xảy ra lỗi kiểu.

Vì sao chiều ngược lại cũng không đúng?

Nhưng tại sao không thể theo chiều ngược lại? Mọi thao tác mà cặp immutable cung cấp đều cũng được cặp mutable cung cấp, nên có vẻ như ta nên có thể dùng cặp mutable ở mọi nơi cần cặp immutable. Lý do tinh tế hơn thế.

Nguyên lý thay thế không chỉ áp dụng cho tập thao tác (phương thức) mà một kiểu cung cấp, mà còn mở rộng tới hợp đồng ngầm đi kèm với những thao tác đó.

Khi ta gọi car hoặc cdr trên một cặp immutable, ta có thể tin vào một hợp đồng rằng kết quả sẽ luôn giống nhau trong mọi lần gọi trên cặp đó. Hợp đồng này cho phép ta, chẳng hạn, tính giá trị băm của cặp dựa trên nội dung của nó, cất đi trong một cấu trúc dữ liệu khác, và biết chắc rằng giá trị đó sẽ không khác đi khi ta tính lại sau này để truy xuất. Nói cách khác, tính bất biến là điều kiện tiên quyết cho kỹ thuật hash consing.

Vì lý do đó, cặp immutable và cặp mutable buộc phải là những kiểu hoàn toàn khác nhau, với hai tập thao tác riêng biệt. Việc một giá trị immutable lại là cặp mutable, hoặc ngược lại, đều là lỗi kiểu.

Phản biện: "Tôi không hề thay đổi nó"

Một ý kiến phản biện thường gặp là: "Nhưng tôi có thay đổi nó đâu, và với trường hợp dùng của tôi thì hợp đồng bất biến chẳng quan trọng". Vấn đề là vì hai kiểu cặp này không tạo thành một hệ thống phân cấp kiểu con, chúng phải là những kiểu tách biệt hoàn toàn, với các tập thao tác được định nghĩa riêng.

May thay, nhiều ngôn ngữ cung cấp cơ chế đa hình tùy biến (ad hoc polymorphism), cho phép định nghĩa cùng một thao tác trên nhiều kiểu dù chúng không nằm trong một hệ thống phân cấp. Trong bối cảnh ngôn ngữ kiểu động, việc lạm dụng cơ chế này thường bị xem là ý tồi, vì nó khiến việc suy luận về luồng dữ liệu trở nên phức tạp hơn hẳn và khó làm đúng hơn.

Cách xử lý trong ngôn ngữ kiểu tĩnh

Với trường hợp kiểu tĩnh, Wadler và Blott đã giới thiệu một cơ chế để suy luận hình thức về đa hình tùy biến và đảm bảo bộ kiểm tra kiểu thực sự chứng minh được tính đúng đắn. Theo thuật ngữ của họ, cặp mutable và immutable là hai kiểu khác nhau, nhưng cả hai đều có thể thuộc về một type class chung tên là pair, với các thao tác chính là carcdr ban đầu.

  • Trên cặp mutable, các thao tác này trỏ tới mcarmcdr
  • Trên cặp immutable, chúng trỏ tới icaricdr

Cách này vẫn đúng về mặt hình thức, vì type class pair định nghĩa một hợp đồng mới không nói gì về tính khả biến. Trong một cài đặt type class đúng đắn, hệ thống kiểu sẽ ngăn bạn dùng các thao tác thay đổi giá trị trong một phương thức mà bạn chỉ khai báo đầu vào là "một loại cặp nào đó". Nó không ngăn bạn dùng carcdr với kỳ vọng chúng bất biến trong khi thực tế có thể không phải vậy, nhưng nó cho phép bạn chọn mức độ chi tiết một cách tường minh theo cả hai hướng: khai báo đầu vào là cặp mutable, cặp immutable, hoặc cả hai, tùy theo hợp đồng mà hàm của bạn thực sự mong đợi.

Điểm mấu chốt là một quan hệ kiểu con chỉ cho phép một chiều chứ không cho phép chiều kia: bạn có thể khai báo hàm của mình chấp nhận cặp immutable nhưng vô tình bao gồm cả cặp mutable, hoặc ngược lại, mà không thể loại trừ nhất quán một trong hai kiểu (nếu không vi phạm nguyên lý thay thế).

Type class, interface và duck typing

Những thứ tương tự type class có mặt trong nhiều ngôn ngữ kiểu tĩnh, thường được gọi là interface, trait hoặc role. Tuy nhiên, các hệ thống kiểu trong thực tế rất khác nhau về mức độ nghiêm ngặt khi kiểm tra.

Trong các ngôn ngữ hướng đối tượng kiểu động, cơ chế này thường mang hình thức duck typing: ta chỉ đơn giản định nghĩa các phương thức cùng tên trên nhiều kiểu khác nhau và để việc điều phối kiểu lúc chạy làm phần việc còn lại. Ta vẫn có thể thu được lợi ích bằng cách thêm kiểm tra tường minh sự hiện diện hoặc vắng mặt của các thao tác thay đổi trước khi cho phép gọi hàm.

Trên thực tế, việc này khá hiếm gặp, đặc biệt là kiểm tra sự vắng mặt của các thao tác thay đổi. Đây chính là lý do vì sao đa hình tùy biến trong các ngôn ngữ kiểu động thường dễ dẫn đến rắc rối.

Góc nhìn cho lập trình viên Việt Nam

Với các lập trình viên đang làm việc với Java, C#, TypeScript hay Kotlin, bài học này rất đáng lưu tâm. Khi thiết kế API nội bộ, việc tách bạch rõ ràng giữa các kiểu "chỉ đọc" và "có thể thay đổi" không chỉ là chuyện thẩm mỹ của mã nguồn, mà là nền tảng để hệ thống kiểu bảo vệ bạn khỏi những lỗi khó tìm. Các interface chỉ đọc như List so với MutableList trong Kotlin, hay ReadonlyArray trong TypeScript, chính là hiện thân thực tiễn của lý luận trên.

Hiểu đúng nguyên lý thay thế Liskov sẽ giúp bạn tránh những thiết kế kế thừa sai lầm, đồng thời biết khi nào nên dùng interface hoặc trait thay vì cố ép một quan hệ kiểu con không tồn tại về mặt hình thức.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗