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

Con trỏ void

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

  • Dùng con trỏ void để viết hàm chạy với mọi kiểu
  • Biết vì sao không giải tham chiếu con trỏ void được
  • Cài hàm hoán đổi tổng quát
  • Nhận ra rủi ro mất kiểm tra kiểu

Kiểu void * giữ được địa chỉ nhưng cố tình vứt bỏ thông tin kiểu. Nghe như một bước lùi, nhưng đó chính là công cụ duy nhất của C để viết hàm chạy được với mọi kiểu dữ liệu.

#Con trỏ không kiểu

Con trỏ void
Con trỏ chỉ mang địa chỉ, không mang thông tin về kiểu của đối tượng ở địa chỉ đó. Nó chuyển tự do sang và về từ mọi con trỏ đối tượng khác.
void *p;

int    n = 42;
double d = 3.14;
char   s[] = "abc";

p = &n;      /* hợp lệ */
p = &d;      /* hợp lệ */
p = s;       /* hợp lệ */
Đặc điểmvoid *
Giữ được địa chỉCó, mọi con trỏ đối tượng
Giải tham chiếu đượcKhông, vì không biết đọc mấy byte
Số học con trỏKhông theo chuẩn, GCC coi như char sao
sizeof8 trên máy sáu tư bit, như mọi con trỏ
Giữ được con trỏ hàmKhông bảo đảm theo chuẩn

#Chuyển đổi tự động

chuyen-doi.c
int   n = 42;
int  *pi = &n;

void *pv = pi;       /* int sao sang void sao: tự động, không cần ép */
int  *pi2 = pv;      /* void sao sang int sao: cũng tự động trong C */

printf("%d\n", *pi2);      /* 42 */

Vòng chuyển đổi luôn an toàn

int  n = 42;
int *p1 = &n;

void *pv = p1;
int  *p2 = pv;

/* Chuẩn bảo đảm p1 == p2 */

#Ba điều không làm được

Không giải tham chiếu

void *p = &n;

*p;              /* error: invalid use of void expression */

*(int *)p;       /* đúng: ép về kiểu cụ thể trước */

Lý do rất trực tiếp: trình biên dịch không biết đọc bao nhiêu byte và diễn giải chúng ra sao. Bạn phải nói cho nó biết bằng phép ép kiểu.

Không làm số học con trỏ

void *p = base;

p + 1;               /* không chuẩn: GCC cho phép và coi như char sao */
(char *)p + 1;       /* đúng và di động */
(char *)p + i * co;  /* mẫu chuẩn để đi tới phần tử thứ i */

Không giữ con trỏ hàm một cách bảo đảm

int (*f)(int) = ham_nao_do;

void *p = (void *)f;      /* không bảo đảm theo chuẩn C */

int (*g)(int) = (int (*)(int))p;

#Hàm hoán đổi tổng quát

Một hàm cho mỗi kiểu
void hoan_doi_int(int *a, int *b);
void hoan_doi_double(double *a, double *b);
void hoan_doi_char(char *a, char *b);
void hoan_doi_sv(SinhVien *a, SinhVien *b);

/* Thêm kiểu mới phải thêm hàm mới. */
Một hàm cho mọi kiểu
void hoan_doi(void *a, void *b, size_t co);

int    x = 1, y = 2;
double p = 1.5, q = 2.5;

hoan_doi(&x, &y, sizeof x);
hoan_doi(&p, &q, sizeof p);
hoan-doi.c
#include <string.h>

/* Cách 1: đơn giản, dùng bộ đệm tạm cố định. */
void hoan_doi(void *a, void *b, size_t co)
{
    if (a == NULL || b == NULL || a == b || co == 0) return;

    unsigned char *x = a;
    unsigned char *y = b;

    for (size_t i = 0; i < co; ++i) {
        unsigned char t = x[i];

        x[i] = y[i];
        y[i] = t;
    }
}

/* Cách 2: nhanh hơn với dữ liệu lớn, dùng bộ đệm theo khối. */
void hoan_doi_nhanh(void *a, void *b, size_t co)
{
    if (a == NULL || b == NULL || a == b) return;

    unsigned char  tam[64];
    unsigned char *x = a;
    unsigned char *y = b;

    while (co > 0) {
        size_t n = co < sizeof tam ? co : sizeof tam;

        memcpy(tam, x, n);
        memcpy(x, y, n);
        memcpy(y, tam, n);

        x += n;
        y += n;
        co -= n;
    }
}
terminal
# Hoán đổi hai struct 4 KB, một triệu lần
./do-hoan-doi
tung byte  : 2.914 s
theo khoi  : 0.183 s
ti le      : 15.9 lan

Hàm đảo mảng tổng quát

dao.c
void dao_nguoc(void *base, size_t n, size_t co)
{
    if (base == NULL || n < 2 || co == 0) return;

    unsigned char *p = base;

    for (size_t i = 0; i + 1 < n - i; ++i)
        hoan_doi(p + i * co, p + (n - 1 - i) * co, co);
}

int a[5] = { 1, 2, 3, 4, 5 };

dao_nguoc(a, 5, sizeof a[0]);      /* 5 4 3 2 1 */

char *s[3] = { "a", "b", "c" };

dao_nguoc(s, 3, sizeof s[0]);      /* c b a */

#Rủi ro mất kiểm tra kiểu

Ba cách giảm rủi ro

giam-rui-ro.c
/* Cách 1: macro bọc, tự tính sizeof từ chính mảng */
#define DAO_NGUOC(mang, n) dao_nguoc((mang), (n), sizeof (mang)[0])

int a[5];

DAO_NGUOC(a, 5);      /* không thể sai sizeof nữa */

/* Cách 2: dùng _Generic của C11 để chọn hàm theo kiểu */
#define SO_SANH(a, b) _Generic((a),          \
    int:    so_sanh_int,                     \
    double: so_sanh_double,                  \
    char *: so_sanh_chuoi                    \
)(&(a), &(b))

/* Cách 3: bọc bằng hàm inline có kiểu cụ thể */
static inline void dao_int(int *a, size_t n)
{
    dao_nguoc(a, n, sizeof *a);
}
CáchƯuNhược
Macro bọcĐơn giản, tự lấy sizeof đúngKhông kiểm tra kiểu, đối số có thể bị đánh giá nhiều lần
_Generic của C11Chọn hàm theo kiểu thật, kiểm tra đượcCú pháp rườm rà, phải liệt kê trước mọi kiểu
Hàm bọc có kiểuKiểm tra kiểu đầy đủ, dễ đọc, dễ gỡ lỗiPhải viết một hàm cho mỗi kiểu

Tự làm thử

  1. Gán địa chỉ của ba biến kiểu khác nhau vào cùng một void *, rồi ép về đúng kiểu và in giá trị.
  2. Thử giải tham chiếu void * trực tiếp, chép lại thông báo lỗi.
  3. Cài hoan_doi tổng quát theo cả hai cách, đo thời gian hoán đổi hai struct 4 kilobyte một triệu lần.
  4. Cài dao_nguoc tổng quát, dùng nó với mảng int, mảng double và mảng con trỏ chuỗi.
  5. Cố tình truyền sai kích thước phần tử, chạy dưới -fsanitize=address và đọc thông báo.
  6. Viết macro DAO_NGUOC tự tính sizeof, chứng minh nó chặn được lỗi trên.
  7. Dùng _Generic để viết một macro in giá trị đúng định dạng cho ba kiểu khác nhau.

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

  • void * giữ được mọi địa chỉ nhưng vứt bỏ thông tin kiểu, nên không giải tham chiếu và không làm số học con trỏ được.
  • Trong C, chuyển giữa void * và con trỏ khác là tự động. Ép kiểu kết quả của malloc là thừa.
  • Mẫu đi tới phần tử thứ i là (char *)base + i * co, vì sizeof(char) luôn bằng 1.
  • Hàm tổng quát luôn nhận bộ ba: con trỏ dữ liệu, số phần tử, kích thước một phần tử.
  • Cái giá là mất hoàn toàn kiểm tra kiểu. Giảm rủi ro bằng cách bọc lô gic tổng quát trong các hàm static inline có kiểu cụ thể.