Menu

Union trong C: bộ nhớ dùng chung, union và struct, tagged union

Cách union trong C lưu nhiều kiểu dữ liệu trên cùng các byte: vì sao kích thước của nó bằng thành viên lớn nhất, vì sao bạn phải đọc đúng thành viên vừa ghi, và cách tagged union (enum kết hợp union) giúp dùng union an toàn.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Struct nói "tất cả những thứ này, cùng lúc". union nói "đúng một trong số này, tại mỗi thời điểm". Các thành viên nằm chồng lên nhau tại cùng một địa chỉ, nên union chỉ lớn bằng thành viên lớn nhất, và ghi vào một thành viên sẽ phá hủy các thành viên còn lại.

Nghe thì giống một cái bẫy, và nếu không kiểm soát thì đúng là bẫy thật. Nhưng đây cũng là cách C biểu diễn một giá trị có thể thuộc một trong nhiều loại, chẳng hạn một token là số hoặc chuỗi, một thông điệp là cú nhấp chuột hoặc phím bấm, mà không phải trả chi phí bộ nhớ cho tất cả cùng lúc.

Khai báo và dùng union

Cú pháp giống hệt struct, chỉ khác từ khóa (keyword).

Các thành viên được truy cập bằng . (hoặc -> qua con trỏ), y như với struct. Điểm khác là chỉ thành viên được ghi gần nhất mới chứa giá trị có nghĩa. Sau v.f = 3.5f, đọc v.i không cho bạn 3, mà cho ra số nguyên nào đó tình cờ được tạo thành từ mẫu bit của 3.5f.

Kích thước: thành viên lớn nhất quyết định

So sánh cách bố trí bộ nhớ của một struct và một union có cùng các thành viên:

Trên một máy thông thường, struct chiếm 24 byte (4 cho int, 8 cho double, 1 cho char, cộng thêm phần đệm padding), còn union chỉ chiếm 8 byte, tức kích thước của double, làm tròn lên cho đúng căn chỉnh. Ba địa chỉ in ra giống hệt nhau, và đó là toàn bộ câu chuyện về union trong một dòng đầu ra.

Để ý phép ép kiểu sang void * khi dùng %p. printf yêu cầu đúng kiểu đó cho %p; truyền một kiểu con trỏ khác là undefined behavior, dù thường trông như vẫn chạy được. Xem thêm format specifier.

Khởi tạo union

Bộ khởi tạo dùng dấu ngoặc nhọn mà không có designator sẽ khởi tạo thành viên đầu tiên:

Bạn nên dùng dạng có designator. {42} ngầm phụ thuộc vào thứ tự thành viên, nên nếu sau này đổi thứ tự khai báo thì thành viên được khởi tạo cũng đổi theo. Đây là một bug thật sự khó chịu, vì nhìn vào code chẳng thấy gì khác.

Vấn đề thật sự: thành viên nào đang có hiệu lực?

Union không ghi lại bạn đã ghi vào thành viên nào gần nhất. Nó chỉ là các byte; thông tin đó nằm trong đầu bạn, và đó chính là nơi thông tin hay bị thất lạc.

Con số in ra rất lớn và kỳ lạ: đó là mẫu bit của 1.0f được đọc như một int. Không có gì bị crash, không có cảnh báo nào, và chương trình âm thầm sai. Union làm đúng những gì nó hứa; lỗi là của chúng ta khi quên thành viên nào đang có hiệu lực.

Cách khắc phục: tagged union

Giải pháp chuẩn là ghép union với một enum ghi lại thành viên đang có hiệu lực, rồi gói cả hai vào một struct. Sự kết hợp này gọi là tagged union (hay discriminated union), và gần như mọi union trong code ứng dụng đều nên được viết theo cách này.

Giờ mọi lần đọc đều đi qua switch trên kind, nên không thể đọc một thành viên chưa từng được ghi, miễn là mỗi lần ghi đều cập nhật cả tag. Gói các lần ghi vào những hàm khởi tạo nhỏ (value_from_int, value_from_string) là cách thường dùng để không bao giờ quên bước đó.

Lượng bộ nhớ tiết kiệm được là có thật: mỗi Value ở đây tốn 24 byte payload cộng với tag, thay vì 4 + 4 + 24 cho một struct chứa cả ba. Với một trăm nghìn giá trị như vậy, chênh lệch là đáng kể.

Biên dịch với -Wall thêm một lớp bảo vệ thứ hai: nếu sau này bạn thêm VAL_BOOL vào enum mà quên viết case cho nó, GCC sẽ cảnh báo về giá trị enum chưa được xử lý.

Anonymous union

C11 cho phép đặt một thành viên union không có tên bên trong struct, khi đó các thành viên của nó được đưa lên không gian tên của struct bên ngoài:

Vì bản thân union không có tên, bạn viết s->circle.r thay vì s->as.circle.r. Code ngắn gọn hơn, đổi lại việc có một union ở đó bị che đi, và điều này không sao cả khi tag nằm ngay bên cạnh.

Khi nào union thật sự hữu ích

Bốn trường hợp dùng thường gặp:

  • Giá trị nhiều dạng (variant). Trình thông dịch, bộ phân tích JSON và tệp cấu hình, hàng đợi thông điệp đều mang những giá trị mà kiểu chỉ được xác định khi chương trình chạy. Tagged union là cách biểu diễn kinh điển cho chúng.
  • Bản ghi tiết kiệm bộ nhớ. Khi một struct có nhiều trường loại trừ lẫn nhau và bạn có hàng triệu bản ghi như vậy, đặt chồng các trường lên nhau giúp tiết kiệm bộ nhớ trực tiếp.
  • Bố cục giao thức và phần cứng. Một gói tin có phần nội dung phụ thuộc vào một byte header rất hợp để ánh xạ sang tagged union, các thanh ghi thiết bị cũng vậy.
  • Xem từng byte. Đặt chồng một giá trị với mảng unsigned char[] cho phép bạn xem từng byte của nó, chẳng hạn để xác định endianness:

Đây là type punning: cố ý đọc lại các byte theo một kiểu khác. Đọc qua union như thế này được C cho phép một cách tường minh, với kết quả phụ thuộc vào cách cài đặt (implementation-defined), khác với ép kiểu con trỏ giữa các kiểu không liên quan, vốn vi phạm quy tắc aliasing. Còn xem các byte dưới dạng unsigned char thì luôn an toàn. Diễn giải lại một int thành float lại là chuyện khác: kết quả hoàn toàn phụ thuộc vào cách biểu diễn trên nền tảng của bạn, nên đừng đưa nó vào code cần chạy được trên nhiều nền tảng.

Lỗi thường gặp

  • Đọc một thành viên bạn chưa ghi. Mối nguy hàng đầu. Hãy dùng tag.
  • Nghĩ rằng union sẽ chuyển đổi giá trị. Không hề. u.i = 3; float f = u.f; diễn giải lại các bit; còn int i = 3; float f = i; mới là chuyển đổi. Xem ép kiểu.
  • Đặt con trỏ vào union rồi mất dấu nó. Nếu một nhánh chứa char * mà bạn đã cấp phát, ghi đè union bằng một thành viên khác sẽ gây rò rỉ bộ nhớ, vì không còn gì trỏ tới vùng đệm đó nữa. Giải phóng trước khi chuyển nhánh.
  • Trông chờ trình biên dịch kiểm tra giúp. Nó sẽ không làm vậy. Union là một trong số ít tính năng của C mà ngôn ngữ không hỗ trợ gì ngoài kích thước và căn chỉnh; tag là hàng rào bảo vệ duy nhất của bạn.

Câu hỏi thường gặp

Union trong C là gì?

Union là một kiểu dữ liệu mà mọi thành viên của nó dùng chung một vùng nhớ. Ghi vào một thành viên sẽ ghi đè lên các thành viên còn lại, nên tại mỗi thời điểm union chỉ chứa đúng một thành viên. Union được khai báo giống struct nhưng dùng từ khóa union: union Value { int i; float f; };.

Union và struct trong C khác nhau thế nào?

Struct cấp cho mỗi thành viên một vùng nhớ riêng và chứa tất cả cùng lúc, nên kích thước của nó ít nhất bằng tổng kích thước các thành viên. Union đặt mọi thành viên chồng lên nhau tại cùng một địa chỉ và mỗi lúc chỉ chứa một thành viên, nên kích thước của nó bằng thành viên lớn nhất.

Kích thước của union trong C là bao nhiêu?

Đủ lớn để chứa thành viên lớn nhất, làm tròn lên cho đúng căn chỉnh (alignment). Một union gồm int (4 byte) và double (8 byte) có kích thước 8 byte, không phải 12. Dùng sizeof để kiểm tra trên nền tảng của bạn.

Chuyện gì xảy ra khi đọc một thành viên union mà bạn chưa ghi?

Bạn diễn giải lại cùng các byte đó theo một kiểu khác. Ghi u.i = 1 rồi đọc u.f không hề chuyển đổi giá trị: chương trình đọc mẫu bit của số nguyên như một số thực và cho ra một con số vô nghĩa. Trong C chuẩn, điều này cùng lắm là unspecified, và đó là lý do mẫu tagged union ra đời: giữ thêm một tag bên cạnh để ghi lại thành viên nào đang có hiệu lực.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu