Chuyển tới nội dung chính

Struct lồng nhau và ví dụ tổng hợp

Struct trong structp->ngaySinh.namBa lỗi hay gặpChương trình tổng hợp

Struct lồng nhau và ví dụ tổng hợp

Thành viên của một struct có thể chính là một struct khác. Bài này dùng nó để mô hình hoá quan hệ có một, rồi gộp cả chương vào một chương trình quản lý danh sách sinh viên.

Hai bài trước đã đủ để bạn khai báo một kiểu struct, giữ nhiều struct trong một mảng và đưa chúng vào hàm qua con trỏ. Bài này thêm mảnh cuối cùng: thành viên của một struct có thể chính là một struct khác. Sau đó cả chương được gộp lại trong một chương trình quản lý danh sách sinh viên, đúng mẫu hình bạn sẽ gặp lại ở chương danh sách liên kết.

Struct lồng nhau

Thành viên của một struct có thể chính là một struct khác. Đây không phải mẹo cú pháp mà là cách ta mô hình hoá quan hệ "cái này có một cái kia" trong thế giới thật: một sinh viên có một ngày sinh, và ngày sinh tự nó lại gồm ngày, tháng, năm.

#include <stdio.h>

typedef struct {
int ngay, thang, nam;
} Ngay;

typedef struct {
char ten[30];
Ngay ngaySinh; // a struct inside a struct
double diem;
} SinhVien;

void inSV(const SinhVien *p) {
printf("%s | %02d/%02d/%d | %.1f\n",
p->ten,
p->ngaySinh.ngay, // arrow to reach the struct, dot to reach its field
p->ngaySinh.thang,
p->ngaySinh.nam,
p->diem);
}

int main(void) {
SinhVien sv = {"Tran Van A", {12, 5, 2006}, 8.5};
sv.ngaySinh.nam = 2005; // chain of dots to reach a nested field
inSV(&sv);
return 0;
}

Hãy đọc kỹ biểu thức sv.ngaySinh.nam. Dấu chấm thứ nhất lấy ra thành viên ngaySinh, bản thân nó là một biến struct kiểu Ngay; dấu chấm thứ hai lấy tiếp thành viên nam của struct đó. Ta cứ nối chuỗi như vậy đến bao nhiêu tầng cũng được. Trong hàm inSV, biểu thức p->ngaySinh.ngay trộn cả hai toán tử, và điều đó hoàn toàn hợp lý nếu bạn đọc từ trái sang: p là con trỏ nên dùng -> để chạm tới struct, còn ngaySinh là một biến struct thật nằm trong đó nên dùng dấu chấm. Quy tắc vẫn chỉ có một: nhìn thứ đứng ngay trước toán tử, nó là con trỏ thì dùng mũi tên, là biến struct thì dùng chấm.

Cũng chú ý cách khởi tạo lồng: {"Tran Van A", {12, 5, 2006}, 8.5} dùng một cặp ngoặc nhọn con cho phần Ngay. Cách viết này phản ánh đúng cấu trúc bộ nhớ, vì struct Ngay nằm gọn bên trong khối byte của SinhVien chứ không phải một khối tách rời được trỏ tới.

Ba lỗi hay gặp khi dùng struct
  • Quên dấu chấm phẩy sau ngoặc nhọn đóng của khai báo struct. Thông báo lỗi khi đó thường chỉ vào dòng kế tiếp nên rất khó lần ra, hãy nhớ }; là bắt buộc.
  • Gán chuỗi bằng dấu bằng: sv.ten = "An" không biên dịch được vì ten là mảng ký tự. Phải dùng strcpy(sv.ten, "An") và nhớ #include <string.h>.
  • Dùng nhầm chấm và mũi tên: p.diem khi p là con trỏ, hoặc sv->diem khi sv là biến struct. Nguyên nhân gốc không phải là quên cú pháp mà là chưa phân biệt được mình đang cầm một đối tượng hay cầm địa chỉ của nó.

Ví dụ tổng hợp: quản lý danh sách sinh viên

Gộp tất cả lại: một mảng struct giữ danh sách, một hàm nhận con trỏ để in một sinh viên, và phần tìm sinh viên điểm cao nhất.

#include <stdio.h>

typedef struct {
char ten[30];
double diem; // GPA
} SinhVien;

// receives a pointer (no copy of the whole struct), prints one student
void inSV(const SinhVien *p) {
printf("%-12s | %.1f\n", p->ten, p->diem);
}

int main(void) {
SinhVien lop[3] = {
{"Tran Van A", 8.5},
{"Le Thi B", 9.0},
{"Pham Van C", 7.0}
};
int n = 3, best = 0; // index of the top student

printf("=== Danh sach lop ===\n");
for (int i = 0; i < n; i++) {
inSV(&lop[i]); // pass address of element i
if (lop[i].diem > lop[best].diem)
best = i; // track the top student
}
printf("Cao nhat: %s (%.1f)\n", lop[best].ten, lop[best].diem);
return 0;
}

Chương trình này gói trọn các ý chính. Kiểu SinhVien khai báo bằng typedef cho gọn. Danh sách là mảng struct lop khởi tạo sẵn ba sinh viên. Hàm inSV nhận const SinhVien *p: nhận con trỏ nên không sao chép cả struct, từ khoá const hứa rằng hàm chỉ đọc chứ không sửa, và bên trong dùng -> để truy cập. Vòng lặp vừa in từng người (truyền &lop[i]) vừa lần ra người điểm cao nhất.

Mẫu hình quản lý danh sách

Một mảng struct giữ dữ liệu, các hàm xử lý nhận con trỏ struct để khỏi sao chép và để sửa được dữ liệu gốc khi cần. Bạn sẽ gặp lại đúng mẫu này ở chương danh sách liên kết.

Bài tập thực hành

Bài tập 4: struct lồng nhau và hàm chỉ đọc

Định nghĩa kiểu Ngay gồm ngay, thang, nam, rồi kiểu SachMuon gồm tieuDe, một Ngay ngayMuon và một int soNgay. Viết hàm void inSach(const SachMuon *p) in đầy đủ thông tin. Hàm phải nhận con trỏ (không sao chép struct) và không được sửa dữ liệu.

Lời giải chi tiết

Bước 1: khai báo Ngay trước, vì SachMuon cần biết Ngay là gì rồi mới dùng nó làm kiểu thành viên. Bước 2: hàm nhận const SachMuon *p, con trỏ để tránh chép cả struct, const để trình biên dịch chặn mọi phép gán vào p. Bước 3: bên trong hàm, để chạm tới trường lồng ta viết p->ngayMuon.ngay: mũi tên vì p là con trỏ, rồi dấu chấm vì ngayMuon là một biến struct nằm bên trong.

#include <stdio.h>
#include <string.h>

typedef struct {
int ngay, thang, nam;
} Ngay;

typedef struct {
char tieuDe[50];
Ngay ngayMuon; // nested struct
int soNgay; // loan length in days
} SachMuon;

// const pointer: no copy, and the function cannot modify the original
void inSach(const SachMuon *p) {
printf("%-20s | muon %02d/%02d/%d | %d ngay\n",
p->tieuDe,
p->ngayMuon.ngay, p->ngayMuon.thang, p->ngayMuon.nam,
p->soNgay);
}

int main(void) {
SachMuon s = {"Lap trinh C", {12, 5, 2026}, 14};
strcpy(s.tieuDe, "Ky thuat lap trinh"); // string member: use strcpy
inSach(&s); // pass the address
return 0;
}

Ý chính: struct lồng nhau mô hình hoá quan hệ "có một" rất tự nhiên, và hàm chỉ đọc nên nhận const T * để vừa tránh chi phí sao chép vừa tuyên bố rõ ràng rằng nó không đụng vào dữ liệu gốc.

Điều rút ra

Struct lồng nhau mô hình hoá quan hệ "có một" rất tự nhiên, và nó không đẻ thêm quy tắc nào: dù biểu thức có dài như p->ngaySinh.nam, bạn vẫn chỉ nhìn thứ đứng ngay trước toán tử để chọn dấu chấm hay mũi tên. Chương trình tổng hợp ở trên cho thấy mẫu hình sẽ theo bạn đi tiếp: một mảng struct giữ dữ liệu, các hàm xử lý nhận con trỏ struct để khỏi sao chép và để sửa được dữ liệu gốc khi cần.

Câu hỏi tự kiểm

Kiểm tra nhanh: struct lồng nhau và quản lý danh sách0/3 đúngchưa trả lời
  1. 1Trong hàm void inSV(const SinhVien *p), thành viên ngaySinh có kiểu Ngay. Muốn lấy nam của ngày sinh thì viết thế nào?
  2. 2Với SinhVien sv = {"Tran Van A", {12, 5, 2006}, 8.5}; thì struct Ngay bên trong nằm ở đâu?
  3. 3Chương trình quản lý danh sách gọi inSV(&lop[i]) chứ không gọi inSV(lop[i]). Vì sao?

Tóm tắt

Chương này đi từ một khó chịu rất cụ thể (dữ liệu của một thực thể bị xé lẻ ra nhiều mảng rời rạc) tới công cụ giải quyết nó. struct cho phép ta khai báo một kiểu dữ liệu mới gom các thành viên khác nhau về một khối có tên, để rồi ràng buộc "chúng thuộc về nhau" được trình biên dịch canh giữ thay vì được ghi nhớ bằng kỷ luật của lập trình viên.

Có hai toán tử truy cập nhưng chỉ một quy tắc để chọn: nhìn thứ đứng ngay trước toán tử, là biến struct thì dùng dấu chấm, là con trỏ thì dùng mũi tên. Quy tắc này đúng cả trong biểu thức dài như p->ngaySinh.nam. Về bộ nhớ, một struct là một khối byte liên tiếp có chèn byte đệm để căn lề, nên sizeof của nó thường lớn hơn tổng kích thước các thành viên; đừng đoán con số này, hãy hỏi sizeof.

Về truyền tham số, C truyền theo trị, nên nhận struct theo trị vừa tốn chi phí sao chép vừa khiến mọi thay đổi bên trong hàm không có hiệu lực ra ngoài. Mẫu hình chuẩn là nhận con trỏ: T *p khi cần sửa, const T *p khi chỉ đọc. Toàn bộ những ý này sẽ được dùng lại gần như nguyên vẹn ở chương danh sách liên kết, nơi mỗi nút chính là một struct chứa con trỏ tới struct kế tiếp.

Ghi nhớ

struct gom các dữ liệu khác kiểu về một mối và đặt tên cho kiểu mới. Truy cập thành viên bằng dấu chấm với biến struct, bằng -> với con trỏ struct. Mẫu hình quản lý danh sách: một mảng struct giữ dữ liệu, các hàm nhận con trỏ struct để khỏi sao chép và sửa được dữ liệu gốc.

Học tiếp