Các kiểu dữ liệu cơ bản như int, float, char chỉ giúp chúng ta
mô tả các giá trị đơn lẻ. Tuy nhiên, trong thế giới thực tế, một thực thể thường bao gồm nhiều thuộc
tính kết hợp lại. Ví dụ: Một Sinh Viên gồm có: Tên (chuỗi), Tuổi (số nguyên), Điểm số (số thực). Để
gom nhóm các thông tin liên quan này lại với nhau thành một kiểu dữ liệu phức hợp tự định nghĩa, C
cung cấp cho chúng ta từ khóa Struct (Structure).
1. Cấu trúc Struct là gì?
Struct cho phép gom nhiều biến có kiểu dữ liệu khác nhau lại thành một kiểu dữ liệu mới duy nhất. Các biến thành phần bên trong được gọi là thành viên (members) của Struct.
Ví dụ: Định nghĩa cấu trúc đại diện cho một Sinh Viên:
#include <stdio.h>
#include <string.h>
// Dinh nghia struct Student
struct Student {
char name[50];
int age;
double score;
};
int main() {
// Khai bao va khoi tao bien kieu struct Student
struct Student sv1;
// Gan gia tri cho cac thanh vien bang toan tu cham (.)
strcpy(sv1.name, "Nguyen Van A");
sv1.age = 20;
sv1.score = 8.5;
// Khoi tao nhanh luc khai bao
struct Student sv2 = {"Tran Thi B", 19, 9.2};
// Truy xuat va in du lieu
printf("Sinh vien 1: %s, %d tuoi, %.1f diem\n", sv1.name, sv1.age, sv1.score);
printf("Sinh vien 2: %s, %d tuoi, %.1f diem\n", sv2.name, sv2.age, sv2.score);
return 0;
}
Chú ý dấu chấm phẩy sau dấu ngoặc nhọn đóng của struct — đây là một trong số ít chỗ trong
C bắt buộc phải có, và quên nó cho ra thông báo lỗi rất khó hiểu ở dòng kế tiếp chứ không
phải ở dòng sai. Truy cập từng trường bằng dấu chấm: sv1.name, sv1.age.
2. Bản chất lưu trữ RAM: Structure Padding & Alignment
Mặc dù một Struct gộp nhiều biến, nhưng cách trình biên dịch sắp xếp các biến này trên RAM không diễn ra liên tục từng byte một. Để CPU có thể truy xuất dữ liệu từ RAM với hiệu năng cao nhất (thường là truy xuất theo từng dòng bus địa chỉ chia hết cho 4 hoặc 8 bytes gọi là Word Lines), trình biên dịch sẽ áp dụng quy tắc Căn biên dữ liệu (Data Alignment).
Quy tắc này sẽ tự động chèn các byte trống gọi là Padding Bytes vào giữa các thành viên của Struct.
A. Lỗi lãng phí bộ nhớ do Padding
Xét cấu trúc Struct sau đây:
struct BadStruct {
char a; // 1 byte
int b; // 4 bytes
char c; // 1 byte
};
Mặc dù tổng kích thước thật của các biến là \(1 + 4 + 1 = 6\) bytes, nhưng hàm
sizeof(struct BadStruct) sẽ trả về 12 bytes!
Dưới đây là sơ đồ ô
nhớ RAM mà trình biên dịch tạo ra cho struct này:
[a (1B)] [padding (3B)] [ b (4B) ] [c (1B)] [padding (3B)] -> Tổng: 12 Bytes
B. Cách tối ưu sắp xếp (Field Reordering)
Để tối ưu hóa bộ nhớ, ta chỉ cần khai báo các biến có kích thước lớn ở phía trước và các biến nhỏ ở phía sau:
struct GoodStruct {
int b; // 4 bytes
char a; // 1 byte
char c; // 1 byte
};
Lúc này, sơ đồ ô nhớ sẽ gom các biến nhỏ lại chung một Word Line:
[ b (4B) ] [a (1B)] [c (1B)] [padding (2B)] -> Tổng: 8 Bytes (Tiết kiệm 33% RAM!)
C. Sử dụng #pragma pack(1) để xóa bỏ hoàn toàn Padding
Trong các hệ thống nhúng (Embedded) hoặc khi lập trình truyền gói tin qua mạng (Network Protocols), ta cần kích thước struct phải chuẩn từng byte để gửi nhận dữ liệu trực tiếp. C cung cấp chỉ thị tiền xử lý để ép biên dịch không chèn padding:
#pragma pack(push, 1) // Force 1-byte alignment (removes padding)
struct PackedStruct {
char a; // 1 byte
int b; // 4 bytes
char c; // 1 byte
};
#pragma pack(pop) // Restore the default alignment
Lúc này, sizeof(struct PackedStruct) sẽ trả về đúng 6 bytes thực tế. Lợi
thế là tiết kiệm RAM tối đa, nhưng nhược điểm là tốc độ đọc ghi của CPU lên struct này sẽ chậm đi một
chút do địa chỉ nhớ bị lệch biên.
D. Kỹ thuật khai báo trường Bit (Bit-fields)
Khi bạn cần khai báo các biến Boolean hoặc số nguyên có phạm vi cực kỳ nhỏ (ví dụ chỉ từ 0 đến 7,
tương đương 3 bit), việc dùng một biến int 32 bit là quá lãng phí. C cho phép chỉ định số
bit tối đa cho mỗi biến thành viên:
struct StatusFlags {
unsigned int isOnline : 1; // Takes exactly 1 bit (0 or 1)
unsigned int role : 3; // Takes exactly 3 bits (values 0 to 7)
unsigned int status : 4; // Takes exactly 4 bits
}; // The whole struct is packed into exactly 1 byte (8 bits)!
3. Sử dụng từ khóa typedef để viết code sạch hơn
Khi khai báo biến struct ở ví dụ trên, ta luôn phải viết từ khóa struct Student sv1;.
Điều này khiến mã nguồn trở nên dài dòng.
Từ khóa typedef (Type Definition) dùng để định nghĩa một
bí danh (alias) cho kiểu dữ liệu hiện tại, giúp viết code ngắn gọn hơn.
#include <stdio.h>
// typedef ket hop struct giup tao ra ten kieu du lieu moi gon sach
typedef struct {
int x;
int y;
} Point;
int main() {
// Khong can ghi chu "struct Point p1;"
Point p1 = {10, 20};
Point p2 = {30, 40};
printf("Toa do diem 1: (%d, %d)\n", p1.x, p1.y);
printf("Toa do diem 2: (%d, %d)\n", p2.x, p2.y);
return 0;
}
Khác biệt nằm ở chỗ khai báo biến: không có typedef thì mỗi lần khai báo phải viết đủ
struct Point p;, còn có rồi thì chỉ cần Point p;. Với những kiểu bạn dùng
hàng trăm lần trong dự án, chỗ tiết kiệm đó cộng lại thành đáng kể — và quan trọng hơn, code đọc gần
với ngôn ngữ hiện đại hơn.
4. Struct với con trỏ và hàm
Ba mục trên đều thao tác với struct ngay tại chỗ khai báo. Nhưng trong code thật, struct hầu như lúc nào cũng được truyền vào hàm — và đây là lúc bài học về con trỏ ở Bài 5 kết hợp với struct để tạo ra thứ bạn sẽ gặp trong mọi dự án C.
Nhắc lại từ Bài 5: C chỉ có truyền tham trị, nên truyền một struct vào hàm là sao chép toàn bộ struct đó. Với struct chứa mảng 50 ký tự, mỗi lời gọi hàm là một lần chép hơn 50 byte — và mọi thay đổi bên trong hàm đều rơi vào bản sao rồi biến mất.
#include <stdio.h>
typedef struct {
char name[50];
int age;
float gpa;
} Student;
// Takes a COPY: changes here never reach the caller
void birthday_by_value(Student s) {
s.age++;
}
// Takes the ADDRESS: -> reaches through the pointer to the real struct
void birthday_by_pointer(Student *s) {
s->age++; // identical to (*s).age++, just far easier to read
}
// const says: I will read this struct, never modify it
void print_student(const Student *s) {
printf("%-6s age=%d gpa=%.2f\n", s->name, s->age, s->gpa);
}
int main() {
Student a = {"An", 20, 3.6f};
birthday_by_value(a);
printf("after by-value: age = %d\n", a.age);
birthday_by_pointer(&a);
printf("after by-pointer: age = %d\n", a.age);
Student students[3] = {{"An", 20, 3.6f}, {"Binh", 21, 3.1f}, {"Chi", 19, 3.9f}};
for (int i = 0; i < 3; i++) {
print_student(&students[i]);
}
return 0;
}
Chạy thử, hai dòng đầu nói hết câu chuyện:
after by-value: age = 20
after by-pointer: age = 21
An age=20 gpa=3.60
Binh age=21 gpa=3.10
Chi age=19 gpa=3.90
Cùng một phép age++, nhưng chỉ bản nhận con trỏ mới đổi được tuổi thật. Ba điều đáng nhớ
từ đoạn code trên:
-
Toán tử
->là cách truy cập trường qua con trỏ. Nó chỉ là cách viết gọn của(*s).age— cặp ngoặc bắt buộc phải có vì dấu chấm bám chặt hơn dấu*. Vì viết(*s).agequá rườm rà nên C cho hẳn một toán tử riêng, và trong code thật bạn sẽ thấy->nhiều hơn dấu chấm. -
const Student *snói với người đọc và với trình biên dịch rằng hàm này chỉ đọc. Lỡ tay viếts->age = 0;trong đó là lỗi biên dịch ngay, chứ không phải một bug âm thầm. -
Mảng struct khai báo và duyệt y hệt mảng thường;
&students[i]là địa chỉ của phần tử thứi. Đây là cách gần như mọi chương trình C quản lý một danh sách bản ghi.
const nếu chỉ đọc. Truyền bản sao khi struct nhỏ (vài byte, như
một điểm toạ độ) và bạn muốn chắc chắn hàm không thể đụng vào bản gốc. Quy tắc thực dụng: struct lớn
hơn một hai con trỏ thì truyền địa chỉ.
5. Phân biệt Struct và Union
Union có cú pháp định nghĩa hoàn toàn tương tự Struct, nhưng cấu trúc lưu trữ trên RAM thì hoàn toàn khác biệt:
- Struct: Mỗi thành viên được cấp phát một phân vùng nhớ riêng độc lập. Dung lượng của struct bằng tổng dung lượng các thành viên cộng lại (có thể cộng thêm padding để căn lề bộ nhớ).
- Union: Tất cả các thành viên dùng chung một phân vùng nhớ duy nhất. Dung lượng của Union bằng dung lượng của thành viên lớn nhất. Tại một thời điểm, bạn chỉ có thể lưu trữ và truy xuất giá trị của một thành viên duy nhất. Nếu ghi đè thành viên này, giá trị của các thành viên khác sẽ bị thay đổi hoặc sai lệch.
Hãy cùng chạy chương trình so sánh kích thước dung lượng (kích thước byte) để thấy rõ sự khác biệt
(lưu ý rằng kích thước của từng kiểu dữ liệu cụ thể như int, double và cấu
trúc padding/căn lề bộ nhớ có thể thay đổi tùy theo kiến trúc hệ thống, tham khảo thêm đặc tả về
Object Alignment (Căn lề đối tượng)
và
Struct declarations trên cppreference.com):
#include <stdio.h>
typedef struct {
char a; // 1 byte
int b; // 4 bytes
double c; // 8 bytes
} MyStruct;
typedef union {
char a; // 1 byte
int b; // 4 bytes
double c; // 8 bytes
} MyUnion;
int main() {
MyStruct s;
MyUnion u;
// In kich thuoc bo nho (Sizeof)
printf("Kich thuoc Struct: %lu bytes\n", sizeof(s)); // Ket qua: thuong la 16 bytes (do padding)
printf("Kich thuoc Union: %lu bytes\n", sizeof(u)); // Ket qua: dung 8 bytes (kich thuoc cua double c)
// Thu thay doi gia tri trong Union
u.c = 9.87;
printf("u.c = %.2f\n", u.c);
u.b = 100; // Ghi de len phan bo nho chung
printf("u.b = %d\n", u.b);
printf("u.c sau khi ghi de u.b: %.2f\n", u.c); // Gia tri u.c bi thay doi, khong con chinh xac!
return 0;
}
📥 Tải về mã nguồn mẫu: struct_vs_union.c
Ứng dụng thực tế của Union:
Union thường được dùng khi lập trình nhúng (vi điều khiển) hoặc lập trình hệ thống ở các phân vùng tài nguyên RAM cực kỳ hạn chế, nơi ta có một biến có thể đại diện cho nhiều kiểu dữ liệu khác nhau nhưng không bao giờ xuất hiện đồng thời.
union Data { char a; int b; double c; } u; và thực hiện gán
u.c = 9.87; tiếp theo gán u.b = 100;. Phát biểu nào sau đây là chính xác
nhất?
Bình luận