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

Cấu trúc struct trong C

Tới giờ bạn đã biết dùng biến đơn (int, double, char) để giữ một giá trị, và mảng để giữ nhiều giá trị cùng kiểu. Nhưng dữ liệu thật ngoài đời hiếm khi gọn gàng như vậy. Một sinh viên có mã (chuỗi), họ tên (chuỗi), tuổi (số nguyên) và điểm trung bình (số thực). Bốn mẩu thông tin này khác kiểu nhau nhưng cùng thuộc về một sinh viên. Chương này giới thiệu cấu trúc (struct): công cụ gom những dữ liệu khác kiểu ấy thành một khối duy nhất, đặt cho nó một cái tên, rồi xử lý như một thực thể trọn vẹn.

Thử ngay: xem con trỏ struct chạy từng bước

Bấm ▶ Chạy (hoặc kéo thanh trượt) để theo dõi: biến struct trên ngăn xếp với các trường của nó, con trỏ p trỏ tới chính struct đó, và lời gọi tangDiem qua con trỏ sửa thẳng vào trường diem của struct gốc. Chưa quen với dấu -> cũng không sao, các mục bên dưới sẽ đi từ cách khai báo một struct cho tới đúng toán tử ấy.

Con trỏ struct và toán tử mũi tên C
1#include <stdio.h>
2
3typedef struct { char ten[30]; double diem; } SinhVien;
4
5void tangDiem(SinhVien *p, double them) {
6 p->diem += them;
7}
8
9int main(void) {
10 SinhVien sv = {"Le Thi B", 8.0};
11 SinhVien *p = &sv;
12 printf("Truoc: %.1f\n", p->diem);
13 tangDiem(&sv, 0.5);
14 printf("Sau : %.1f\n", sv.diem);
15 return 0;
16}
Ngăn xếp stack
main()
Bộ nhớ động heap
(trống)
Bắt đầu hàm main(). Chưa có biến nào trên ngăn xếp.
1/8

Vì sao cần struct

Thử quản lý ba sinh viên mà chưa có struct. Ta buộc phải khai báo nhiều mảng rời rạc, mỗi mảng cho một thuộc tính:

#include <stdio.h>

int main(void) {
char ma[3][10]; // student IDs
char ten[3][30]; // student names
int tuoi[3]; // ages
double diem[3]; // GPAs
// student i is scattered across 4 arrays: ma[i], ten[i], tuoi[i], diem[i]
return 0;
}

Cách này chạy được nhưng rất dễ hỏng. Dữ liệu của một sinh viên bị xé lẻ ra bốn mảng; muốn sắp xếp danh sách theo điểm thì phải hoán đổi đồng thời cả bốn mảng, chỉ cần quên một mảng là dữ liệu lẫn lộn ngay. Bản chất vấn đề: ngôn ngữ chưa biết rằng ma[i], ten[i], tuoi[i], diem[i] cùng mô tả một sinh viên.

Hãy nhìn kỹ hơn vào chỗ hỏng. Ràng buộc "bốn mảng phải luôn khớp chỉ số" không được ghi ở đâu trong mã nguồn, nó chỉ tồn tại trong đầu người viết, nên trình biên dịch không thể nhắc bạn khi bạn vi phạm. Muốn viết một hàm nhận "một sinh viên", bạn buộc phải truyền bốn thứ rời rạc, và không gì ngăn người gọi ghép tên của người này với điểm của người kia. Muốn thêm một thuộc tính mới, bạn phải sửa mọi khai báo mảng và mọi chữ ký hàm có liên quan.

struct sinh ra để giải đúng bài toán này. Nó cho phép ta định nghĩa một kiểu dữ liệu mới gói tất cả thuộc tính của một sinh viên lại với nhau. Ràng buộc "chúng thuộc về nhau" nhờ đó được phát biểu một lần trong khai báo kiểu, và trình biên dịch trở thành người canh giữ nó thay bạn. Đây là bước chuyển tư duy quan trọng đầu tiên trong nghề: thay vì mô tả dữ liệu bằng những biến rời rạc, ta mô tả bằng các kiểu phản ánh đúng cấu trúc của bài toán ngoài đời.

Định nghĩa

Cấu trúc (struct) là một kiểu dữ liệu do người lập trình tự định nghĩa, gom nhiều biến thành viên (member) có thể khác kiểu nhau vào trong một khối có tên. Mỗi biến kiểu struct chứa đủ tất cả thành viên đã khai báo.

Khai báo struct và truy cập thành viên

Ta khai báo một kiểu struct bằng từ khoá struct, theo sau là tên thẻ (tag) và danh sách thành viên đặt trong cặp ngoặc nhọn. Lưu ý dấu chấm phẩy bắt buộc sau ngoặc nhọn đóng:

struct SinhVien {
char ma[10]; // student ID
char ten[30]; // full name
int tuoi; // age
double diem; // GPA
}; // do not forget this semicolon

Dòng trên mới chỉ mô tả kiểu chứ chưa tạo ra biến nào, giống bản thiết kế ngôi nhà chưa phải là ngôi nhà. Để có biến thật, ta khai báo biến với kiểu struct SinhVien:

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

struct SinhVien {
char ma[10];
char ten[30];
int tuoi;
double diem;
};

int main(void) {
struct SinhVien sv; // one student variable

strcpy(sv.ma, "SV001"); // member access with dot
strcpy(sv.ten, "Tran Van A");
sv.tuoi = 20;
sv.diem = 8.5;

printf("Ma : %s\n", sv.ma);
printf("Ten : %s\n", sv.ten);
printf("Tuoi: %d\n", sv.tuoi);
printf("Diem: %.1f\n", sv.diem);
return 0;
}

Điểm mấu chốt là toán tử dấu chấm (dot operator). Cú pháp sv.tuoi đọc là thành viên tuoi của biến sv. Vì maten là mảng ký tự, ta không gán bằng dấu bằng mà phải dùng strcpy từ thư viện string.h; còn tuoidiem là số nên gán trực tiếp được.

Vì sao mảng ký tự lại đặc biệt như vậy? Trong C, tên của một mảng không phải là giá trị gán được, nó là địa chỉ phần tử đầu tiên nên không thể đứng bên trái dấu bằng; viết sv.ten = "Tran Van A" do đó sai ngay từ cú pháp. Nói cách khác, dấu chấm không làm gì kỳ bí: nó chỉ đưa ta tới đúng ô nhớ của thành viên, còn ô nhớ đó cư xử ra sao thì phụ thuộc kiểu của chính thành viên ấy.

Cũng cần phân biệt rõ khai báo kiểukhai báo biến. Khối struct SinhVien { ... }; không chiếm byte nào lúc chạy, nó chỉ dạy trình biên dịch biết một SinhVien gồm những gì. Chỉ khi viết struct SinhVien sv; thì bộ nhớ mới thật sự được cấp.

Khởi tạo struct

Giống biến thường, ta có thể khởi tạo một struct ngay lúc khai báo bằng danh sách giá trị trong ngoặc nhọn, theo đúng thứ tự thành viên:

struct SinhVien a = {"SV001", "Tran Van A", 20, 8.5}; // by order

// designated initializer: state the member name explicitly (C99)
struct SinhVien b = {.ma = "SV002", .ten = "Le Thi B",
.tuoi = 19, .diem = 9.0};

Cách thứ nhất gán giá trị theo đúng thứ tự khai báo. Cách thứ hai dùng khởi tạo có chỉ định (designated initializer), nêu rõ tên thành viên nên không phụ thuộc thứ tự và dễ đọc hơn khi struct có nhiều trường. Nếu liệt kê thiếu, các thành viên còn lại tự nhận giá trị 0.

typedef: đặt tên kiểu cho gọn

Việc phải viết struct SinhVien ở mọi nơi khá dài dòng. Từ khoá typedef cho phép đặt một bí danh (alias) ngắn cho kiểu, từ đó ta chỉ cần viết tên kiểu mà bỏ được chữ struct:

#include <stdio.h>

typedef struct {
char ma[10];
char ten[30];
int tuoi;
double diem;
} SinhVien; // SinhVien is now a type name

int main(void) {
SinhVien sv = {"SV001", "Tran Van A", 20, 8.5}; // no "struct"
printf("%s - %.1f\n", sv.ten, sv.diem);
return 0;
}

Sau khi typedef, SinhVien trở thành một tên kiểu đầy đủ, dùng y như int hay double. Từ đây trở đi ta dùng dạng typedef cho gọn.

Ghi nhớ

typedef struct { ... } Ten; khai báo kiểu struct vô danh rồi gắn cho nó tên Ten. Sau đó viết Ten bien; là đủ, không cần lặp lại từ khoá struct.

Struct nằm thế nào trong bộ nhớ

Một struct không phải là thứ trừu tượng lơ lửng: nó là một khối byte liên tiếp trong bộ nhớ, các thành viên xếp nối đuôi nhau theo đúng thứ tự khai báo. Trực giác đầu tiên của đa số người học là sizeof của struct bằng tổng sizeof các thành viên. Trực giác đó thường sai, và lý do đằng sau rất đáng hiểu.

#include <stdio.h>

typedef struct {
char c; // 1 byte
int n; // 4 bytes
double d; // 8 bytes
} Goi;

int main(void) {
printf("char=%zu int=%zu double=%zu\n",
sizeof(char), sizeof(int), sizeof(double));
printf("tong cac thanh vien = %zu\n",
sizeof(char) + sizeof(int) + sizeof(double)); // usually 13
printf("sizeof(Goi) = %zu\n", sizeof(Goi)); // usually 16
return 0;
}

Trên phần lớn máy hiện nay, tổng các thành viên là 13 byte nhưng sizeof(Goi) lại in ra 16. Ba byte kia đi đâu? Câu trả lời nằm ở căn lề (alignment). Bộ xử lý đọc một số int 4 byte nhanh nhất khi địa chỉ của số đó chia hết cho 4, và một double 8 byte muốn địa chỉ chia hết cho 8. Nếu để c chiếm byte thứ 0 rồi đặt n ngay tại byte thứ 1, địa chỉ của n sẽ lệch lề, khiến việc đọc chậm hẳn hoặc thậm chí sinh lỗi phần cứng trên một số kiến trúc.

Để tránh chuyện đó, trình biên dịch tự chèn những byte trống vô nghĩa vào giữa các thành viên, gọi là byte đệm (padding). Trong ví dụ trên, sau c (1 byte) nó chèn 3 byte đệm để n bắt đầu ở vị trí chia hết cho 4; như vậy c và phần đệm chiếm 4 byte, n chiếm 4 byte tiếp theo, và d bắt đầu đúng ở byte thứ 8. Tổng cộng 16 byte. Trình biên dịch còn có thể đệm thêm ở cuối struct sao cho kích thước tổng chia hết cho căn lề lớn nhất, để khi đặt nhiều struct liền nhau trong một mảng thì phần tử nào cũng nằm đúng lề.

Hệ quả thực hành rất cụ thể. Thứ tự khai báo thành viên ảnh hưởng tới kích thước, nên gom các thành viên lớn lên trước thường giảm được đệm, và với mảng hàng triệu struct thì vài byte tiết kiệm mỗi phần tử là con số đáng kể. Quan trọng hơn, đừng bao giờ tính kích thước struct bằng cách cộng tay rồi ghi cứng con số đó vào mã, luôn để sizeof trả lời giúp.

Đừng cộng tay sizeof

sizeof(struct) không nhất thiết bằng tổng sizeof các thành viên vì trình biên dịch chèn byte đệm để căn lề. Con số cụ thể còn phụ thuộc kiến trúc và trình biên dịch, nên mọi tính toán bộ nhớ phải đi qua sizeof, không được đoán.

Xem chạy thêm: byte đệm làm sizeof lớn hơn tổng

Bản trace ở đầu bài minh hoạ con trỏ struct và toán tử mũi tên. Trace này soi vào một cơ chế khác là cách struct nằm trong bộ nhớ: trình biên dịch chèn byte đệm để mỗi thành viên nằm đúng lề, nên sizeof(Goi) là 16 chứ không phải 1 + 4 + 8 = 13.

Byte đệm: sizeof(Goi) = 16, không phải 13 C
1#include <stdio.h>
2typedef struct {
3 char c;
4 int n;
5 double d;
6} Goi;
7int main(void) {
8 Goi g;
9 g.c = 'A';
10 g.n = 100;
11 g.d = 3.5;
12 printf("%zu\n", sizeof(g));
13 return 0;
14}
Ngăn xếp stack
main()
#1Goi g
c @0?
đệm @1-3(bỏ trống)
n @4-7?
d @8-15?
Bộ nhớ động heap
(trống)
Khai báo biến g kiểu Goi trên ngăn xếp. Trình biên dịch dành một khối 16 byte (offset 0 tới 15) và sắp các thành viên theo lề.
1/6

Mảng struct

Khi đã có kiểu SinhVien, một danh sách sinh viên chỉ đơn giản là một mảng struct. Mỗi phần tử của mảng là một sinh viên trọn vẹn, dữ liệu không còn bị xé lẻ như cách làm ban đầu.

#include <stdio.h>

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

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
for (int i = 0; i < n; i++) {
printf("%-12s %.1f\n", lop[i].ten, lop[i].diem);
if (lop[i].diem > lop[best].diem)
best = i; // update the leader
}
printf("Cao nhat: %s (%.1f)\n", lop[best].ten, lop[best].diem);
return 0;
}

Hãy để ý lop[i].tenlop[i].diem: trước hết lop[i] lấy ra sinh viên thứ i, rồi dấu chấm truy cập thành viên của sinh viên đó. Biến best chỉ giữ chỉ số của người dẫn đầu hiện tại; mỗi lần gặp ai điểm cao hơn thì cập nhật.

Con trỏ struct và toán tử mũi tên

Như đã học ở chương con trỏ, ta có thể lấy địa chỉ của một biến struct bằng & và lưu vào một con trỏ struct. Vấn đề là: làm sao truy cập thành viên thông qua con trỏ?

Cách viết đầy đủ là (*p).diem: trước hết *p lấy ra biến struct mà p trỏ tới, rồi dấu chấm truy cập thành viên. Cặp ngoặc bắt buộc vì dấu chấm có độ ưu tiên cao hơn *. Cách này đúng nhưng rườm rà, nên C cho ta một lối tắt: toán tử mũi tên (arrow operator) ->.

Ghi nhớ

p->diem hoàn toàn tương đương (*p).diem. Quy tắc nhớ đơn giản: khi đứng trước là một con trỏ thì dùng ->; khi đứng trước là một biến struct thì dùng dấu chấm.

#include <stdio.h>

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

// receives the address, so it CAN modify the original
void tangDiem(SinhVien *p, double them) {
p->diem += them; // same as (*p).diem += them
}

int main(void) {
SinhVien sv = {"Le Thi B", 8.0};
SinhVien *p = &sv; // pointer to the struct

printf("Truoc: %.1f\n", p->diem); // arrow access through pointer
tangDiem(&sv, 0.5); // pass the address
printf("Sau : %.1f\n", sv.diem); // original changed: 8.5
return 0;
}

Hàm tangDiem nhận con trỏ SinhVien *p, tức là địa chỉ của sinh viên thật bên ngoài, nên khi viết p->diem += them nó sửa thẳng vào struct gốc. Đây là khác biệt cốt lõi so với truyền theo trị: lần này thay đổi hiệu lực ra ngoài.

Khi nào dấu chấm, khi nào mũi tên
  • sv.diem khi svbiến struct (truy cập trực tiếp).
  • p->diem khi pcon trỏ struct (viết tắt của (*p).diem).
  • lop[i].diemlop[i] là một biến struct.
  • Lỗi kinh điển: viết p.diem khi p là con trỏ, hoặc sv->diem khi sv là biến struct. Hãy nhìn lại xem thứ đứng trước là con trỏ hay biến struct.

Truyền struct vào hàm: bản sao hay con trỏ

C truyền tham số theo trị, nghĩa là hàm luôn nhận một bản sao của thứ ta đưa vào. Với int thì bản sao chỉ tốn 4 byte, chẳng ai bận tâm. Nhưng struct thì khác: nếu kiểu SinhVien nặng 48 byte, mỗi lần gọi hàm nhận SinhVien theo trị là một lần chép 48 byte lên ngăn xếp. Gọi trong một vòng lặp một triệu lần thì đó là 48 triệu byte bị chép đi chép lại một cách vô ích. Chi phí này chỉ càng lớn khi struct chứa mảng con hoặc struct lồng nhau.

Ngoài chi phí, truyền theo trị còn có một hệ quả về ngữ nghĩa mà người mới hay bị bất ngờ: hàm sửa bản sao, còn bản gốc bên ngoài không hề đổi. Hãy so sánh hai hàm sau.

#include <stdio.h>

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

// by value: works on a COPY, the caller's struct is untouched
void tangSai(SinhVien sv, double them) {
sv.diem += them; // only the local copy changes
}

// by pointer: works on the ORIGINAL struct
void tangDung(SinhVien *p, double them) {
p->diem += them;
}

int main(void) {
SinhVien sv = {"Tran Van A", 8.0};

tangSai(sv, 1.0);
printf("Sau tangSai : %.1f\n", sv.diem); // still 8.0

tangDung(&sv, 1.0);
printf("Sau tangDung: %.1f\n", sv.diem); // now 9.0
return 0;
}

Hàm tangSai chạy không lỗi, biên dịch không cảnh báo, và nó thật sự có cộng thêm 1.0 vào diem, chỉ có điều nó cộng vào bản sao nằm trong ngăn xếp của chính nó. Bản sao ấy biến mất khi hàm kết thúc, nên khi quay về main ta thấy diem vẫn là 8.0. Đây là lỗi im lặng, kiểu lỗi tệ nhất, vì không có thông báo nào chỉ điểm cho bạn. Hàm tangDung nhận địa chỉ của struct gốc, nên p->diem += them chạm đúng vào ô nhớ thật.

Từ đó rút ra quy tắc thực hành gọn gàng. Nếu hàm cần sửa struct, bắt buộc nhận con trỏ. Nếu hàm chỉ đọc struct nhưng struct đủ lớn, vẫn nên nhận con trỏ để khỏi chép, và thêm const vào kiểu tham số (const SinhVien *p). Chữ const này không phải bình luận suông: nếu bạn lỡ tay viết p->diem = 0 bên trong, trình biên dịch báo lỗi ngay. Đổi lại, con trỏ có cái giá của nó, vì nó có thể là NULL hoặc trỏ tới vùng nhớ đã chết. Đó là đánh đổi cổ điển trong C: hiệu năng và khả năng sửa trực tiếp đổi lấy trách nhiệm tự kiểm soát tính hợp lệ của con trỏ.

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 1: struct điểm ba chiều

Khai báo một kiểu struct Diem3D gồm ba thành viên x, y, z kiểu double. Viết chương trình gán toạ độ một điểm rồi in lại theo dạng (x, y, z).

Lời giải chi tiết

Bước 1: dùng typedef struct { ... } Diem3D; để đặt tên kiểu cho gọn. Bước 2: khai báo một biến Diem3D và khởi tạo theo thứ tự ba toạ độ. Bước 3: in từng thành viên bằng dấu chấm (d.x, d.y, d.z) vì d là biến struct.

#include <stdio.h>

typedef struct {
double x, y, z; // 3D coordinates
} Diem3D;

int main(void) {
Diem3D d = {1.0, 2.5, -3.0};
printf("(%.1f, %.1f, %.1f)\n", d.x, d.y, d.z);
return 0;
}

Ý chính: gom ba toạ độ khác nhau về một struct giúp truyền và xử lý một điểm như một thực thể trọn vẹn.

Bài tập 2: tăng tuổi qua con trỏ struct

Định nghĩa kiểu SinhVien gồm ten, tuoidiem. Viết hàm void tangTuoi(SinhVien *p) dùng con trỏ struct và toán tử -> để tăng tuổi thêm 1. Gọi hàm rồi in lại để xác nhận tuổi gốc đã thay đổi.

Lời giải chi tiết

Bước 1: hàm nhận SinhVien *p (con trỏ), nên nó thao tác trên struct gốc chứ không phải bản sao. Bước 2: bên trong viết p->tuoi += 1, tương đương (*p).tuoi += 1. Bước 3: trong main, truyền &sv để đưa địa chỉ vào hàm, sau đó in sv.tuoi bằng dấu chấm (vì sv là biến struct) sẽ thấy giá trị đã tăng.

#include <stdio.h>

typedef struct {
char ten[30];
int tuoi;
double diem;
} SinhVien;

// pointer parameter: modifies the original struct
void tangTuoi(SinhVien *p) {
p->tuoi += 1; // arrow access through pointer
}

int main(void) {
SinhVien sv = {"Tran Van A", 20, 8.5};
tangTuoi(&sv); // pass the address
printf("%s, tuoi = %d\n", sv.ten, sv.tuoi); // 21
return 0;
}

Ý chính: truyền con trỏ struct cho phép hàm sửa thẳng dữ liệu gốc, khác hẳn truyền theo trị vốn chỉ làm việc trên bản sao.

Bài tập 3: lọc danh sách theo điểm

Tạo một mảng SinhVien gồm vài người khởi tạo sẵn. Duyệt mảng và in ra những sinh viên có điểm từ 8.0 trở lên.

Lời giải chi tiết

Bước 1: khai báo kiểu SinhVien bằng typedef, rồi tạo mảng struct khởi tạo sẵn. Bước 2: dùng một vòng for duyệt qua từng phần tử. Với mỗi lop[i], ta lấy ra một biến struct, nên truy cập thành viên bằng dấu chấm: lop[i].diem. Bước 3: nếu lop[i].diem >= 8.0 thì in tên và điểm của người đó.

#include <stdio.h>

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

int main(void) {
SinhVien lop[4] = {
{"Tran Van A", 8.5},
{"Le Thi B", 9.0},
{"Pham Van C", 7.0},
{"Hoang Thi D", 8.0}
};
int n = 4;

printf("Sinh vien tu 8.0 tro len:\n");
for (int i = 0; i < n; i++) {
if (lop[i].diem >= 8.0)
printf(" %-12s %.1f\n", lop[i].ten, lop[i].diem);
}
return 0;
}

Ý chính: mảng struct giữ trọn vẹn từng sinh viên nên việc lọc theo một thuộc tính (diem) trở nên gọn gàng và không sợ dữ liệu lẫn lộn như khi dùng các mảng rời rạc.

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.

Tự kiểm tra

1) Khi nào dùng dấu chấm, khi nào dùng ->?

Dùng dấu chấm khi đứng trước là một biến struct (sv.diem, lop[i].diem). Dùng -> khi đứng trước là một con trỏ struct (p->diem, viết tắt của (*p).diem).

2) Vì sao hàm sửa struct nên nhận con trỏ thay vì nhận theo trị?

Truyền theo trị tạo một bản sao toàn bộ struct, sửa bản sao không ảnh hưởng struct gốc, lại tốn bộ nhớ nếu struct lớn. Nhận con trỏ (SinhVien *p) cho phép hàm sửa thẳng struct gốc và không phải sao chép.

3) Vì sao sizeof của struct thường lớn hơn tổng các thành viên?

Vì trình biên dịch chèn byte đệm để mỗi thành viên bắt đầu ở địa chỉ căn lề đúng với kiểu của nó (một int 4 byte muốn địa chỉ chia hết cho 4, một double 8 byte muốn chia hết cho 8), và có thể đệm thêm ở cuối để kích thước tổng chia hết cho căn lề lớn nhất, giúp các phần tử trong mảng struct đều nằm đúng lề. Kết luận thực hành: luôn dùng sizeof chứ không cộng tay.

Câu hỏi tự kiểm

Kiểm tra nhanh: cấu trúc struct0/4 đúngchưa trả lời
  1. 1Vì sao sizeof của một struct thường lớn hơn tổng sizeof các thành viên?
  2. 2Nếu p là con trỏ tới struct, cách truy cập thành viên diem đúng cú pháp là gì?
  3. 3Truyền một struct vào hàm theo trị rồi sửa thành viên bên trong hàm thì điều gì xảy ra?
  4. 4Với thành viên ten là mảng ký tự, câu lệnh sv.ten = "An" sẽ ra 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.