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.
1#include <stdio.h>23typedef struct { char ten[30]; double diem; } SinhVien;45void tangDiem(SinhVien *p, double them) {6 p->diem += them;7}89int 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}
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.
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ì ma và ten 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 tuoi và diem 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ểu và khai 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.
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.
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.
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}
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].ten và lop[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) ->.
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 có hiệu lực ra ngoài.
sv.diemkhisvlà biến struct (truy cập trực tiếp).p->diemkhiplà con trỏ struct (viết tắt của(*p).diem).lop[i].diemvìlop[i]là một biến struct.- Lỗi kinh điển: viết
p.diemkhiplà con trỏ, hoặcsv->diemkhisvlà 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.
- 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ìtenlà mảng ký tự. Phải dùngstrcpy(sv.ten, "An")và nhớ#include <string.h>. - Dùng nhầm chấm và mũi tên:
p.diemkhiplà con trỏ, hoặcsv->diemkhisvlà 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ộ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, tuoi và diem. 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
- 1Vì sao sizeof của một struct thường lớn hơn tổng sizeof các thành viên?
- 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ì?
- 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?
- 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.
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.