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

Cấu trúc struct

Khai báo và truy cập thành viêntypedef cho gọnsizeof ra 16 chứ không phải 13Hai vết chạy từng bước

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.

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, 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

Struct nằm thế nào trong bộ nhớ · đổi thứ tự, xem sizeof đổi
sizeof = 16tổng thành viên = 13đệm = 3 (3 giữa + 0 cuối)lề struct = 8đã nhỏ nhất có thể
thứ tựkiểutêncỡlềoffset
c110
n444
d888
cbyte 0···nnnndbyte 8ddddddd

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ả 1548 trên 1548 bộ. Đâ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 ở 897 bộ, tức 58%. Ở 42% còn lại, mọi thứ tự cho cùng một sizeof nê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.

Đừ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

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.

Điều rút ra

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

Kiểm tra nhanh: khai báo struct và bộ nhớ0/3 đú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. 2Với thành viên ten là mảng ký tự, câu lệnh sv.ten = "An" sẽ ra sao?
  3. 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