Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 17.222 phút đọc

Union có nhãn

Sau bài này bạn sẽ làm được

  • Cài union có nhãn để lưu nhiều kiểu trong một biến
  • Viết hàm xử lý an toàn dựa trên nhãn
  • Nhận ra mẫu này trong trình phân tích cú pháp và trình thông dịch
  • So sánh với cách dùng con trỏ void

Union một mình rất nguy hiểm vì không có cách nào biết thành viên nào đang hoạt động. Ghép nó với một enum làm nhãn thì bạn được một kiểu biến thể an toàn, và đó là mẫu quan trọng nhất của cả Phần 7.

#Ghép enum với union

Union có nhãn
Một struct gồm hai phần: một enum cho biết thành viên nào đang hoạt động, và một union chứa dữ liệu. Mọi thao tác đều phải kiểm tra nhãn trước.
gia-tri.c
typedef enum {
    GT_RONG,
    GT_INT,
    GT_DOUBLE,
    GT_CHUOI,
    GT_SO_LUONG,
} LoaiGiaTri;

typedef struct {
    LoaiGiaTri loai;

    union {                  /* union vô danh của C11, xem Bài 15.4 */
        long   i;
        double d;
        char  *s;            /* SỞ HỮU: phải giải phóng trong gt_huy */
    };
} GiaTri;
NhãnThành viên hoạt độngÝ nghĩa
GT_RONGkhông cóChưa có giá trị
GT_INTiMột số nguyên
GT_DOUBLEdMột số thực
GT_CHUOIsMột chuỗi do struct sở hữu
kich-thuoc.c
#include <stdio.h>

int main(void)
{
    printf("union rieng : %zu\n", sizeof(union { long i; double d; char *s; }));
    printf("GiaTri      : %zu\n", sizeof(GiaTri));

    /* So với việc dùng struct thay cho union */
    typedef struct {
        LoaiGiaTri loai;
        long   i;
        double d;
        char  *s;
    } GiaTriPhi;

    printf("GiaTriPhi   : %zu\n", sizeof(GiaTriPhi));

    return 0;
}
terminal
./kich-thuoc
union rieng : 8
GiaTri      : 16
GiaTriPhi   : 32

Union tiết kiệm một nửa so với struct đầy đủ, và chênh lệch càng lớn khi số biến thể càng nhiều. Với một triệu giá trị thì đó là mười sáu megabyte.

#Bộ hàm thao tác an toàn

gia-tri.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Tạo: một hàm cho mỗi biến thể. */
GiaTri gt_int(long v)
{
    return (GiaTri){ .loai = GT_INT, .i = v };
}

GiaTri gt_double(double v)
{
    return (GiaTri){ .loai = GT_DOUBLE, .d = v };
}

/* Chuỗi cần cấp phát, nên hàm này có thể thất bại. */
int gt_chuoi(const char *s, GiaTri *ra)
{
    if (s == NULL || ra == NULL) return -1;

    size_t n = strlen(s) + 1;
    char  *p = malloc(n);

    if (p == NULL) return -1;

    memcpy(p, s, n);

    *ra = (GiaTri){ .loai = GT_CHUOI, .s = p };

    return 0;
}

/* Hủy: chỉ biến thể chuỗi mới cần dọn. Gọi nhiều lần vẫn an toàn. */
void gt_huy(GiaTri *g)
{
    if (g == NULL) return;

    if (g->loai == GT_CHUOI) {
        free(g->s);
        g->s = NULL;
    }

    g->loai = GT_RONG;
}

/* Đọc an toàn: trả về 0 nếu đúng kiểu, âm một nếu không. */
int gt_lay_int(const GiaTri *g, long *ra)
{
    if (g == NULL || ra == NULL || g->loai != GT_INT) return -1;

    *ra = g->i;

    return 0;
}

/* In: switch không có default, để trình biên dịch bắt lỗi thiếu nhánh */
void gt_in(const GiaTri *g)
{
    if (g == NULL) { printf("(null)\n"); return; }

    switch (g->loai) {
        case GT_RONG:      printf("(rong)\n");         return;
        case GT_INT:       printf("%ld\n", g->i);      return;
        case GT_DOUBLE:    printf("%.6g\n", g->d);     return;
        case GT_CHUOI:     printf("\"%s\"\n", g->s); return;
        case GT_SO_LUONG:  break;
    }

    printf("(loai la: %d)\n", (int)g->loai);
}

/* Sao chép sâu: biến thể chuỗi phải cấp vùng mới, xem Bài 15.7 */
int gt_ban_sao(const GiaTri *goc, GiaTri *ra)
{
    if (goc == NULL || ra == NULL) return -1;

    if (goc->loai == GT_CHUOI)
        return gt_chuoi(goc->s, ra);

    *ra = *goc;      /* các biến thể khác không có con trỏ, sao chép nông là đủ */

    return 0;
}
dung.c
int main(void)
{
    GiaTri a = gt_int(42);
    GiaTri b = gt_double(3.14);
    GiaTri c;

    if (gt_chuoi("xin chao", &c) != 0) return 1;

    gt_in(&a);
    gt_in(&b);
    gt_in(&c);

    long v;

    if (gt_lay_int(&a, &v) == 0) printf("lay duoc: %ld\n", v);
    if (gt_lay_int(&b, &v) != 0) printf("b khong phai int\n");

    GiaTri d;

    if (gt_ban_sao(&c, &d) == 0) {
        gt_huy(&c);
        gt_in(&d);      /* vẫn hợp lệ, vì d có chuỗi riêng */
        gt_huy(&d);
    }

    gt_huy(&a);
    gt_huy(&b);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra -g -fsanitize=address gia-tri.c dung.c -o t && ./t
42
3.14
"xin chao"
lay duoc: 42
b khong phai int
"xin chao"
valgrind --leak-check=full ./t
All heap blocks were freed -- no leaks are possible

#Ứng dụng: trình thông dịch nhỏ

Union có nhãn là cấu trúc trung tâm của mọi trình phân tích cú pháp và trình thông dịch. Đây là một ví dụ rút gọn nhưng chạy được.

bieu-thuc.h
typedef enum {
    BT_SO,          /* một số */
    BT_CONG,        /* a + b */
    BT_TRU,         /* a - b */
    BT_NHAN,        /* a * b */
    BT_CHIA,        /* a / b */
    BT_SO_LUONG,
} LoaiBT;

typedef struct BieuThuc BieuThuc;

struct BieuThuc {
    LoaiBT loai;

    union {
        double so;                              /* khi loai là BT_SO */

        struct { BieuThuc *trai, *phai; } nhi;   /* khi là phép toán hai ngôi */
    };
};
bieu-thuc.c
#include <stdlib.h>

BieuThuc *bt_so(double v)
{
    BieuThuc *b = calloc(1, sizeof *b);

    if (b == NULL) return NULL;

    b->loai = BT_SO;
    b->so   = v;

    return b;
}

BieuThuc *bt_nhi(LoaiBT loai, BieuThuc *trai, BieuThuc *phai)
{
    if (trai == NULL || phai == NULL) return NULL;

    BieuThuc *b = calloc(1, sizeof *b);

    if (b == NULL) return NULL;

    b->loai      = loai;
    b->nhi.trai  = trai;
    b->nhi.phai  = phai;

    return b;
}

/* Hủy đệ quy: từ trong ra ngoài, đúng như Bài 14.9 */
void bt_huy(BieuThuc *b)
{
    if (b == NULL) return;

    switch (b->loai) {
        case BT_SO:
            break;

        case BT_CONG:
        case BT_TRU:
        case BT_NHAN:
        case BT_CHIA:
            bt_huy(b->nhi.trai);
            bt_huy(b->nhi.phai);
            break;

        case BT_SO_LUONG:
            break;
    }

    free(b);
}

typedef enum { TINH_OK, TINH_CHIA_KHONG, TINH_LOI } MaTinh;

/* Tính giá trị, đệ quy theo cấu trúc cây */
MaTinh bt_tinh(const BieuThuc *b, double *ra)
{
    if (b == NULL || ra == NULL) return TINH_LOI;

    double t, p;
    MaTinh m;

    switch (b->loai) {
        case BT_SO:
            *ra = b->so;
            return TINH_OK;

        case BT_CONG:
        case BT_TRU:
        case BT_NHAN:
        case BT_CHIA:
            m = bt_tinh(b->nhi.trai, &t);
            if (m != TINH_OK) return m;

            m = bt_tinh(b->nhi.phai, &p);
            if (m != TINH_OK) return m;

            switch (b->loai) {
                case BT_CONG: *ra = t + p; return TINH_OK;
                case BT_TRU:  *ra = t - p; return TINH_OK;
                case BT_NHAN: *ra = t * p; return TINH_OK;

                case BT_CHIA:
                    if (p == 0.0) return TINH_CHIA_KHONG;

                    *ra = t / p;

                    return TINH_OK;

                case BT_SO:
                case BT_SO_LUONG:
                    break;
            }

            break;

        case BT_SO_LUONG:
            break;
    }

    return TINH_LOI;
}
main.c
#include <stdio.h>

int main(void)
{
    /* Biểu thức: (3 + 4) * 2 */
    BieuThuc *a = bt_so(3);
    BieuThuc *b = bt_so(4);
    BieuThuc *c = bt_so(2);

    if (a == NULL || b == NULL || c == NULL) goto loi;

    BieuThuc *cong = bt_nhi(BT_CONG, a, b);

    if (cong == NULL) goto loi;

    a = b = NULL;      /* cong đã sở hữu a và b */

    BieuThuc *goc = bt_nhi(BT_NHAN, cong, c);

    if (goc == NULL) { bt_huy(cong); goto loi; }

    cong = c = NULL;   /* goc đã sở hữu cong và c */

    double kq;

    if (bt_tinh(goc, &kq) == TINH_OK)
        printf("%.2f\n", kq);      /* 14.00 */

    bt_huy(goc);       /* hủy đệ quy cả cây */

    return 0;

loi:
    bt_huy(a);
    bt_huy(b);
    bt_huy(c);

    return 1;
}
terminal
gcc -std=c17 -Wall -Wextra -g -fsanitize=address bieu-thuc.c main.c -o t && ./t
14.00
valgrind --leak-check=full ./t
All heap blocks were freed -- no leaks are possible

#So với con trỏ void

Bài 13.7 đã giới thiệu void * làm công cụ lưu dữ liệu bất kỳ. Union có nhãn giải quyết cùng bài toán theo cách khác.

Tiêu chívoid sao cộng nhãnUnion có nhãn
Số kiểu lưu đượcBất kỳ, kể cả kiểu chưa biết lúc viếtCố định, phải liệt kê trước
Số lần cấp phátThêm một lần cho dữ liệuKhông thêm, dữ liệu nằm ngay trong struct
Truy cập dữ liệuThêm một lần đọc bộ nhớTrực tiếp
Kiểm tra kiểuKhông có, phải ép kiểuTrình biên dịch biết kiểu của từng thành viên
Kích thướcBằng con trỏ, tức 8 byteBằng thành viên lớn nhất
Thêm kiểu mớiKhông phải sửa gì trong cấu trúcPhải sửa union và mọi switch
Trình biên dịch bắt thiếu nhánhKhôngCó, nhờ switch không default
void sao: mất kiểm tra kiểu
typedef struct {
    int   loai;
    void *du_lieu;      /* phải cấp phát riêng, và phải ép kiểu khi dùng */
} GiaTri;

GiaTri g = { .loai = GT_INT, .du_lieu = malloc(sizeof(long)) };

*(long *)g.du_lieu = 42;

/* Ép nhầm kiểu thì không ai báo */
printf("%f\n", *(double *)g.du_lieu);
Union có nhãn: giữ kiểm tra kiểu
typedef struct {
    LoaiGiaTri loai;

    union { long i; double d; char *s; };
} GiaTri;

GiaTri g = { .loai = GT_INT, .i = 42 };

/* Đọc sai thành viên vẫn có kiểu đúng, và hàm đọc kiểm tra nhãn */
long v;

if (gt_lay_int(&g, &v) == 0) printf("%ld\n", v);

#Năm quy tắc

Quy tắcVì sao
1Nhãn nằm ngoài union, không dựa vào tiền tố chungĐơn giản, di động, ai đọc cũng hiểu
2Nhãn có giá trị 0 là trạng thái rỗng hợp lệĐể khởi tạo bằng { 0 } cho ra đối tượng dùng được
3Một hàm tạo cho mỗi biến thểNhãn và dữ liệu không bao giờ lệch nhau
4Mọi switch trên nhãn đều không có defaultThêm biến thể mới thì trình biên dịch chỉ ra mọi chỗ cần sửa
5Hàm hủy và hàm sao chép xử lý riêng biến thể có con trỏTránh rò rỉ và giải phóng hai lần
mau-day-du.c
/* Mẫu tối giản nhưng đầy đủ, dùng lại được cho mọi bài toán biến thể. */

typedef enum { X_RONG = 0, X_A, X_B, X_SO_LUONG } LoaiX;

typedef struct {
    LoaiX loai;

    union {
        int   a;
        char *b;      /* SỞ HỮU */
    };
} X;

X    x_a(int v);
int  x_b(const char *s, X *ra);
void x_huy(X *x);
int  x_ban_sao(const X *goc, X *ra);
void x_in(const X *x);

Khi nào không cần union

Dùng union khi các biến thể gần giống nhau
typedef struct {
    Loai loai;

    union {
        struct { int x, y; }         diem;
        struct { int x, y, mau; }    diem_mau;
    };
} DoiTuong;

/* Hai biến thể chỉ khác một trường. Union chỉ tiết kiệm 4 byte
   mà đổi lại toàn bộ độ phức tạp của nhãn. */
Dùng struct thường
typedef struct {
    int x, y;
    int mau;        /* 0 nghĩa là không có màu */
} DoiTuong;

/* Đơn giản hơn nhiều, và tiết kiệm 4 byte không đáng để đánh đổi. */

Tự làm thử

  1. Cài đủ kiểu GiaTri với bốn biến thể và bộ sáu hàm trong bài, chạy dưới valgrind.
  2. So sizeof của union có nhãn với struct chứa mọi trường, tính mức tiết kiệm với một triệu bản sao.
  3. Thử cả ba cách phá vỡ bất biến trong bài, quan sát hậu quả từng cái.
  4. Thêm biến thể GT_BOOL vào enum, biên dịch lại và để trình biên dịch chỉ cho bạn mọi chỗ cần sửa.
  5. Cài trình thông dịch biểu thức trong bài, thêm phép lũy thừa và phép đảo dấu một ngôi.
  6. Thêm hàm in biểu thức ra dạng chuỗi có ngoặc, dùng đệ quy theo cấu trúc cây.
  7. Viết lại kiểu GiaTri bằng void *, so sánh số dòng, số lần cấp phát và độ an toàn với bản dùng union.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Union có nhãn gồm một enum cho biết thành viên nào hoạt động và một union chứa dữ liệu.
  • Đặt nhãn ngoài union, cho nhãn rỗng giá trị 0, và viết một hàm tạo riêng cho mỗi biến thể.
  • Mọi switch trên nhãn đều không có default, để trình biên dịch chỉ ra mọi chỗ cần sửa khi thêm biến thể.
  • Biến thể chứa con trỏ cần xử lý riêng trong hàm hủy và hàm sao chép sâu.
  • Chọn union có nhãn khi tập kiểu là cố định và nhỏ, chọn void * khi tập kiểu mở cho người dùng thư viện tự thêm.