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

Con trỏ struct

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

  • Dùng toán tử mũi tên thành thạo
  • Chọn giữa truyền theo giá trị và truyền con trỏ
  • Cài mẫu kiểu mờ để giấu chi tiết bên trong
  • Tránh các lỗi truy cập trường qua con trỏ rỗng

Struct và con trỏ gặp nhau ở gần như mọi dòng mã C thật. Bài này gom lại cú pháp, cách truyền hiệu quả, và một mẫu thiết kế quan trọng: giấu hoàn toàn nội dung struct khỏi người dùng thư viện.

#Toán tử mũi tên

mui-ten.c
typedef struct {
    char   ten[64];
    int    tuoi;
    double diem;
} SinhVien;

SinhVien  a = { .ten = "An", .tuoi = 20, .diem = 8.5 };
SinhVien *p = &a;

/* Ba cách viết, ba mức dễ đọc */
printf("%d\n", a.tuoi);        /* trực tiếp */
printf("%d\n", (*p).tuoi);     /* qua con trỏ, đúng nhưng rườm rà */
printf("%d\n", p->tuoi);       /* dạng viết tắt, luôn dùng cách này */
Toán tử mũi tên
Viết p->truong là viết tắt hoàn toàn tương đương của (*p).truong. Nó giải tham chiếu con trỏ rồi lấy trường, trong một bước.

Nhiều tầng

typedef struct { int x, y; } Diem;
typedef struct { Diem  goc; Diem *dich; } DoanThang;

DoanThang  d;
DoanThang *p = &d;

d.goc.x;            /* struct trong struct: dùng dấu chấm cả hai lần */
p->goc.x;           /* con trỏ rồi struct: mũi tên rồi chấm */
p->dich->x;         /* con trỏ rồi con trỏ: mũi tên cả hai lần */
(*(*p).dich).x;     /* cùng nghĩa với dòng trên, và đây là lý do có mũi tên */

Gán và tăng qua con trỏ

p->tuoi = 21;
p->tuoi++;
++p->tuoi;          /* mũi tên ưu tiên cao hơn ++ tiền tố, nên đây là ++(p->tuoi) */

p->diem += 0.5;

strcpy(p->ten, "Binh");
snprintf(p->ten, sizeof p->ten, "Binh");   /* an toàn hơn */

/* Cẩn thận: hai cái này khác nhau */
p++->tuoi;          /* lấy trường tuoi của p HIỆN TẠI rồi tăng p */
(++p)->tuoi;        /* tăng p trước rồi mới lấy trường */

#Truyền struct vào hàm

Nguyên mẫuSao chépHàm sửa đượcDùng khi
void f(SinhVien sv)Cả structKhông, chỉ bản saoStruct nhỏ, và bạn muốn bản sao độc lập
void f(const SinhVien *sv)8 byteKhông, trình biên dịch chặnMặc định cho hàm chỉ đọc
void f(SinhVien *sv)8 byteHàm cần sửa dữ liệu của người gọi
void f(SinhVien **sv)8 byteĐổi được cả con trỏHàm cấp phát hoặc giải phóng
truyen.c
/* Chỉ đọc: dùng const con trỏ */
void in_sv(const SinhVien *sv)
{
    if (sv == NULL) return;

    printf("%-12s %3d %.1f\n", sv->ten, sv->tuoi, sv->diem);
}

/* Có sửa: bỏ const */
void tang_tuoi(SinhVien *sv)
{
    if (sv == NULL) return;

    sv->tuoi++;
}

/* Cần bản sao độc lập: truyền theo giá trị */
SinhVien voi_diem_moi(SinhVien sv, double diem)
{
    sv.diem = diem;      /* chỉ sửa bản sao */

    return sv;           /* trả về bản đã sửa, bản gốc không đổi */
}
terminal
# Gọi mỗi hàm mười triệu lần với struct 80 byte
./do-truyen
theo gia tri : 0.412 s
con tro      : 0.089 s
ti le        : 4.6 lan
Ba cách viết kém
void in_sv(SinhVien sv);           /* chép 80 byte chỉ để đọc */

void in_sv(SinhVien *sv);          /* thiếu const, người gọi không biết có bị sửa không */

void in_sv(SinhVien *sv)
{
    printf("%s\n", sv->ten);      /* thiếu kiểm tra NULL */
}
Cách đúng
void in_sv(const SinhVien *sv)
{
    if (sv == NULL) return;

    printf("%s\n", sv->ten);
}

#Kiểu mờ để giấu chi tiết

Kiểu mờ
Kiểu mà người dùng chỉ biết tên chứ không thấy nội dung. Họ chỉ thao tác được qua con trỏ và qua các hàm bạn cung cấp.
danhsach.h
#ifndef DANHSACH_H
#define DANHSACH_H

#include <stddef.h>

/* Khai báo trước: kiểu tồn tại, nhưng nội dung nằm ở nơi khác. */
typedef struct DanhSach DanhSach;

DanhSach *ds_tao(void);
void      ds_huy(DanhSach *ds);

int       ds_them(DanhSach *ds, int gia_tri);
int       ds_lay(const DanhSach *ds, size_t i, int *ra);
size_t    ds_so_luong(const DanhSach *ds);

#endif
danhsach.c
#include <stdlib.h>

#include "danhsach.h"

/* Nội dung thật chỉ có ở file này. */
struct DanhSach {
    int   *du_lieu;
    size_t n;
    size_t suc_chua;
};

DanhSach *ds_tao(void)
{
    return calloc(1, sizeof(DanhSach));
}

void ds_huy(DanhSach *ds)
{
    if (ds == NULL) return;

    free(ds->du_lieu);
    free(ds);
}

int ds_them(DanhSach *ds, int gia_tri)
{
    if (ds == NULL) return -1;

    if (ds->n == ds->suc_chua) {
        size_t moi = ds->suc_chua ? ds->suc_chua * 2 : 4;
        int   *tam = realloc(ds->du_lieu, moi * sizeof *tam);

        if (tam == NULL) return -1;

        ds->du_lieu  = tam;
        ds->suc_chua = moi;
    }

    ds->du_lieu[ds->n++] = gia_tri;

    return 0;
}

int ds_lay(const DanhSach *ds, size_t i, int *ra)
{
    if (ds == NULL || ra == NULL || i >= ds->n) return -1;

    *ra = ds->du_lieu[i];

    return 0;
}

size_t ds_so_luong(const DanhSach *ds)
{
    return ds != NULL ? ds->n : 0;
}
main.c
#include <stdio.h>

#include "danhsach.h"

int main(void)
{
    DanhSach *ds = ds_tao();

    if (ds == NULL) return 1;

    for (int i = 0; i < 10; ++i)
        if (ds_them(ds, i * i) != 0) { ds_huy(ds); return 1; }

    for (size_t i = 0; i < ds_so_luong(ds); ++i) {
        int v;

        if (ds_lay(ds, i, &v) == 0) printf("%d ", v);
    }

    printf("\n");

    ds_huy(ds);

    return 0;
}
terminal
gcc -std=c17 -Wall -Wextra danhsach.c main.c -o app && ./app
0 1 4 9 16 25 36 49 64 81

Biến thể: giấu một phần

hinh.h
/* Phần công khai người dùng đọc được, phần riêng thì không. */
typedef struct {
    int   rong, cao;      /* công khai, đọc thoải mái */
    void *rieng;          /* con trỏ tới phần chỉ thư viện biết */
} Hinh;

Cách này cho phép truy cập nhanh những trường hay dùng mà vẫn giấu được phần phức tạp. Nhưng nó mất một phần lợi ích: đổi hai trường công khai vẫn buộc mọi người biên dịch lại.

#Bốn lỗi thường gặp

Lỗi 1: truy cập trường qua con trỏ rỗng

SinhVien *p = NULL;

p->tuoi = 20;      /* ghi vào địa chỉ bằng đúng độ lệch của trường tuoi */

Bài 14.5 đã nói: địa chỉ gây lỗi bằng đúng offsetof của trường. Nhìn con số trong thông báo lỗi là biết ngay trường nào.

Lỗi 2: trả về con trỏ tới struct cục bộ

Trả về địa chỉ biến cục bộ
SinhVien *tao_sv(void)
{
    SinhVien sv = { .ten = "An", .tuoi = 20 };

    return &sv;      /* sv chết khi hàm kết thúc */
}
Ba cách đúng
/* Cách 1: trả về theo giá trị */
SinhVien tao_sv(void)
{
    SinhVien sv = { .ten = "An", .tuoi = 20 };

    return sv;
}

/* Cách 2: cấp phát động, người gọi giải phóng */
SinhVien *tao_sv(void)
{
    SinhVien *sv = calloc(1, sizeof *sv);

    if (sv == NULL) return NULL;

    snprintf(sv->ten, sizeof sv->ten, "An");
    sv->tuoi = 20;

    return sv;
}

/* Cách 3: người gọi cấp chỗ */
int tao_sv(SinhVien *ra)
{
    if (ra == NULL) return -1;

    *ra = (SinhVien){ .tuoi = 20 };
    snprintf(ra->ten, sizeof ra->ten, "An");

    return 0;
}

Lỗi 3: quên rằng gán struct là sao chép nông

typedef struct {
    char  *ten;        /* con trỏ, không phải mảng */
    int    tuoi;
} Nguoi;

Nguoi a = { malloc(64), 20 };

strcpy(a.ten, "An");

Nguoi b = a;           /* b.ten và a.ten là CÙNG một địa chỉ */

b.ten[0] = 'B';
printf("%s\n", a.ten); /* "Bn": sửa qua b thì a cũng đổi */

free(a.ten);
printf("%s\n", b.ten); /* b.ten treo */

Bài 15.7 sẽ nói kỹ về sao chép nông và sao chép sâu. Điểm cần nhớ bây giờ: dấu bằng giữa hai struct chép từng byte, và byte của một con trỏ chính là địa chỉ chứ không phải nội dung.

Lỗi 4: dùng struct chưa hoàn chỉnh

typedef struct DanhSach DanhSach;      /* chỉ khai báo, chưa định nghĩa */

DanhSach  a;           /* LỖI: storage size of 'a' isn't known */
DanhSach *p;           /* HỢP LỆ: con trỏ tới kiểu chưa hoàn chỉnh vẫn được */

p->n;                  /* LỖI: dereferencing pointer to incomplete type */
sizeof(DanhSach);      /* LỖI: invalid application of sizeof */

#Con trỏ tới một trường

con-tro-truong.c
typedef struct {
    char   ten[64];
    int    tuoi;
    double diem;
} SinhVien;

SinhVien sv = { .ten = "An", .tuoi = 20, .diem = 8.5 };

int    *pt = &sv.tuoi;      /* con trỏ tới một trường */
char   *pn = sv.ten;        /* trường mảng phân rã như thường */
double *pd = &sv.diem;

*pt = 21;
printf("%d\n", sv.tuoi);   /* 21 */

Đi ngược từ trường về struct

container-of.c
#include <stddef.h>

/* Từ con trỏ tới một trường, tính ra con trỏ tới cả struct.
   Đây là macro container_of nổi tiếng của nhân Linux. */
#define CHUA_TRONG(ptr, kieu, truong) \
    ((kieu *)((char *)(ptr) - offsetof(kieu, truong)))

SinhVien sv = { .tuoi = 20 };

int      *pt = &sv.tuoi;
SinhVien *ps = CHUA_TRONG(pt, SinhVien, tuoi);

printf("%d\n", ps->tuoi);      /* 20, và ps == &sv */

Tự làm thử

  1. Truy cập cùng một trường bằng cả ba cách trong bài, thử viết *p.truong và đọc thông báo lỗi.
  2. In sizeof của p, *p, p->ten p->tuoi, giải thích bốn con số.
  3. Đo thời gian truyền struct 80 byte theo giá trị và theo con trỏ, mỗi cách mười triệu lần.
  4. Cài đủ thư viện danh sách kiểu mờ trong bài, thử truy cập trường từ main.c và đọc thông báo lỗi.
  5. Đổi cài đặt bên trong từ mảng động sang một cấu trúc khác, xác nhận main.c không phải sửa gì.
  6. Gán một struct chứa con trỏ cho struct khác, sửa qua bản sao và quan sát bản gốc cũng đổi.
  7. Cài macro CHUA_TRONG, dùng nó để đi từ con trỏ trường về con trỏ struct và kiểm chứng hai địa chỉ khớp 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

  • p->x là viết tắt của (*p).x. Đừng viết *p.x vì dấu chấm ưu tiên cao hơn dấu sao.
  • Dùng const T * làm mặc định cho tham số struct, bỏ const khi hàm cần sửa, và truyền theo giá trị chỉ với struct tới khoảng mười sáu byte.
  • sizeof p->mang cho ra kích thước thật của trường mảng, khác với tham số mảng của hàm.
  • Kiểu mờ giấu hoàn toàn nội dung struct, cho phép đổi cài đặt mà không ai phải sửa mã.
  • Gán struct là sao chép nông: con trỏ bên trong được chép nguyên giá trị, nên hai bản cùng trỏ một chỗ.