Cấu trúc struct
Cấu trúc struct trong C
Gom những dữ liệu khác kiểu của cùng một thực thể vào một khối có tên, rồi nhìn khối đó nằm thật trong bộ nhớ với những byte đệm mà bạn không hề khai báo.
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.
Chương này chia làm ba bài. Bài bạn đang đọc lo phần nền: vì sao cần struct, cách khai
báo và truy cập thành viên, cách khởi tạo, typedef, và cách một struct nằm thật trong
bộ nhớ. Bài mảng struct, con trỏ struct và truyền vào hàm
đi tiếp sang việc giữ nhiều struct cùng lúc và đưa struct vào hàm. Bài
struct lồng nhau và ví dụ tổng hợp khép lại chương.
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,
bài này đi từ cách khai báo một struct, còn chính toán tử ấy được mổ xẻ ở bài
mảng struct, con trỏ struct và truyền vào hàm.
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, 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ể.
Thử ngay: đổi thứ tự khai báo, xem sizeof đổi theo
| thứ tự | kiểu | tên | cỡ | lề | offset |
|---|---|---|---|---|---|
| c | 1 | 1 | 0 | ||
| n | 4 | 4 | 4 | ||
| d | 8 | 8 | 8 |
Câu "gom thành viên lớn lên trước thì bớt đệm" nghe như một mẹo, nên đáng tách làm hai
mệnh đề rồi đo từng cái. Vét cạn 1548 bộ thành viên gồm mọi tổ hợp cỡ 2, 3 và 4 trên
sáu kiểu cơ bản:
- Xếp giảm dần theo căn lề luôn đạt kích thước nhỏ nhất, đúng ở cả
1548trên1548bộ. Đây là mệnh đề mạnh, không phải "thường". Lý do gọn: khi các thành viên đi từ lề lớn xuống lề nhỏ thì mỗi offset đã tự chia hết cho lề của nó, nên không cần chèn gì ở giữa. - Nhưng thứ tự chỉ đổi được kích thước ở
897bộ, tức58%. Ở42%còn lại, mọi thứ tự cho cùng mộtsizeofnên không có gì để tiết kiệm.
Nói cách khác, chữ "thường" đúng nhưng nó mô tả nhầm chỗ: cái không chắc chắn nằm ở chỗ có gì để giảm hay không, chứ không nằm ở chỗ cách sắp xếp có tối ưu hay không.
Bấm preset Hai char kẹp double để thấy ca rõ nhất: char, double, char tốn 24 byte
cho 10 byte dữ liệu, và chỉ cần gom hai char cạnh nhau là xuống 16, tức từ
24 byte xuống 16. 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}
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.
Ba bài tập còn lại của chương nằm ở hai bài sau, vì mỗi bài cần đúng phần kiến thức được dạy ở đó.
Tự kiểm tra
1) 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.
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, nhờ đó 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. 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ố ấy, hãy hỏi sizeof.
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?
- 2Với thành viên ten là mảng ký tự, câu lệnh sv.ten = "An" sẽ ra sao?
- 3Kiểu SinhVien có bốn thành viên ma, ten, tuoi, diem. Viết SinhVien sv = {"SV001", "Tran Van A"}; thì tuoi và diem mang giá trị nào?
Học tiếp
- Mảng struct, con trỏ struct và truyền vào hàm: một danh
sách sinh viên chỉ là một mảng struct, và toán tử
->là cách chạm tới thành viên khi trong tay bạn là một con trỏ. - Struct lồng nhau và ví dụ tổng hợp: struct chứa struct, một chương trình quản lý danh sách hoàn chỉnh, và tóm tắt cả chương.