Chương 7: Cấu trúc
Định nghĩa kiểu dữ liệu mới với độ phức tạp cao hơn. Chương này cung cấp các kiến thức cơ bản về kiểu dữ liệu cấu trúc, là nền tảng quan trọng cho các môn Cấu trúc dữ liệu và giải thuật và Lập trình hướng đối tượng sau này.
1. Cấu trúc Cơ sở và Thao tác Dữ liệu
1.1. Khai báo và Khởi tạo Biến Cấu trúc
1.1.1. Khái niệm Cấu trúc
Cấu trúc (struct) là kiểu dữ liệu do người lập trình tự định nghĩa, cho phép nhóm nhiều dữ liệu có kiểu khác nhau vào chung một đối tượng.
1.1.2. Khai báo và Khởi tạo Biến Cấu trúc
Trong lập trình, chúng ta có thể khai báo và khởi tạo biến cấu trúc theo nhiều cách khác nhau, tùy thuộc vào nhiều yếu tố như phong cách lập trình hay yêu cầu của chương trình.
Tuy nhiên, để bắt đầu một cách trực quan và dễ hiểu nhất, ta sẽ sử dụng cách khởi tạo trực tiếp khi khai báo. Đây là phương pháp đơn giản, rõ ràng và phù hợp cho người mới làm quen với kiểu dữ liệu cấu trúc.
(1) Định nghĩa kiểu dữ liệu cấu trúc
Khác với các kiểu dữ liệu cơ bản chỉ biểu diễn một giá trị đơn lẻ, cấu trúc cho phép chúng ta nhóm nhiều dữ liệu có kiểu khác nhau lại thành một đơn vị thống nhất.
Vì vậy, trước khi sử dụng cấu trúc trong chương trình, ta cần biết cách định nghĩa một kiểu dữ liệu cấu trúc.
Cú pháp:
struct TenCauTruc{
kieu_du_lieu_1 ten_thuoc_tinh_1;
kieu_du_lieu_2 ten_thuoc_tinh_2;
// ...
};
Ví dụ:
struct SinhVien{
string HoTen;
int NamSinh;
double DTB;
};
// Một đối tượng SinhVien có 3 thuộc tính: họ tên, năm sinh, điểm trung bình.
(2) Khai báo biến cấu trúc
Sau khi đã định nghĩa một kiểu cấu trúc, bước tiếp theo là khai báo các biến thuộc kiểu cấu trúc đó để có thể sử dụng trong chương trình.
Việc khai báo biến cấu trúc cho phép chúng ta tạo ra các đối tượng cụ thể mang đầy đủ những thuộc tính đã được mô tả trong phần định nghĩa kiểu.
Cú pháp:
TenCauTruc ten_bien;
(3) Khởi tạo biến cấu trúc
Sau khi đã khai báo biến cấu trúc, chúng ta cần gán giá trị ban đầu cho các trường dữ liệu của nó để có thể sử dụng trong chương trình. Có hai cách phổ biến để khởi tạo một biến cấu trúc: khởi tạo trực tiếp và khởi tạo gián tiếp.
Khởi tạo trực tiếp:
Khởi tạo trực tiếp là cách gán giá trị cho các trường của biến cấu trúc ngay tại thời điểm khai báo. Đây là phương pháp nhanh chóng và thường được sử dụng khi ta đã có sẵn đầy đủ dữ liệu ban đầu cho đối tượng.
Cú pháp:
TenCauTruc ten_bien = {gia_tri_1, gia_tri_2, ...};Ví dụ:
SinhVien y = {"Nguyen Van A", 2005, 9.0};
Khởi tạo gián tiếp:
Bên cạnh cách khởi tạo trực tiếp khi khai báo, chúng ta cũng có thể gán giá trị cho từng trường của biến cấu trúc sau khi đã tạo ra nó.
Cách làm này được gọi là khởi tạo gián tiếp, và thường được dùng khi các giá trị ban đầu chưa có sẵn hoặc phụ thuộc vào quá trình nhập liệu hoặc tính toán trong chương trình.
Cú pháp:
TenCauTruc ten_bien;
ten_bien.ten_thuoc_tinh_1 = gia_tri_1;
// ...Ví dụ:
SinhVien x;
x.HoTen = "Tran Van B";
x.NamSinh = 2004;
x.DTB = 8.2;
1.2. Kích thước của Cấu trúc
Kích thước một cấu trúc bằng tổng kích thước các thuộc tính, nhưng có thể lớn hơn do cơ chế "padding" của bộ nhớ.
Trình biên dịch có thể thêm bộ đệm (padding) để đảm bảo các thành viên được căn chỉnh đúng vị trí trong bộ nhớ, tối ưu hóa việc truy xuất.
Ví dụ minh hoạ:
struct A {
char c; // 1 byte
int x; // 4 bytes
};
Kích thước thực tế: 8 bytes
Giải thích:
Nếu chỉ nhìn vào mã nguồn, ta rất dễ nhầm rằng cấu trúc có kích thước đúng bằng tổng kích thước các trường, tức là
1 byte (char) + 4 bytes (int) = 5 bytes.
Tuy nhiên, điều này không đúng vì trình biên dịch phải đảm bảo quy tắc căn chỉnh bộ nhớ (memory alignment).
Trường (int) cần nằm ở địa chỉ chia hết cho 4, nên sau trường (char), trình biên dịch tự động chèn thêm 3 bytes đệm (padding) để đảm bảo căn chỉnh đúng.
Do đó, kích thước thực tế của cấu trúc không phải 5 bytes mà là 8 bytes.
1.3. Truy xuất Thuộc tính của Kiểu Cấu trúc
Sau khi khai báo và khởi tạo một biến cấu trúc, người lập trình có thể truy cập từng thuộc tính bên trong nó. Cú pháp truy xuất sẽ phụ thuộc vào kiểu biến cấu trúc.
1.3.1. Truy xuất đối với Biến không phải Con trỏ
Đối với biến cấu trúc được khai báo trực tiếp (không phải con trỏ), ta dùng toán tử chấm (.) để truy xuất và thao tác với các thuộc tính bên trong.
Cú pháp:
ten_bien_struct.ten_thuoc_tinh
Ví dụ:
SinhVien y = {"Nguyen Van A", 2005, 9.0};
// Truy xuất năm sinh
cout << "Nam sinh cua y: " << y.NamSinh << endl;
// Thay đổi điểm trung bình
y.DTB = 9.5;
1.3.2. Truy xuất đối với Biến Con trỏ
Đối với biến cấu trúc được khai báo là con trỏ, ta dùng toán tử mũi tên (->) để truy xuất thuộc tính.
Cú pháp:
ten_con_tro_struct -> ten_thuoc_tinh
Lưu ý: Toán tử -> là cách viết tắt của (*ten_con_tro_struct).ten_thuoc_tinh.
Ví dụ:
SinhVien y = {"Nguyen Van A", 2005, 9.0};
SinhVien *p = &y;
// Truy xuất HoTen qua con trỏ
cout << "Ho ten qua con tro: " << p->HoTen << endl;
// Thay đổi năm sinh qua con trỏ
p->NamSinh = 2006;
1.4. Phép Gán Dữ liệu Kiểu Cấu trúc
Trong , ta có thể gán trực tiếp giá trị của một biến cấu trúc cho một biến cấu trúc khác cùng kiểu. Phép gán này sẽ sao chép toàn bộ dữ liệu của tất cả các thuộc tính.
Cú pháp:
bien_cau_truc_1 = bien_cau_truc_2;
Ví dụ:
SinhVien y = {"Nguyen Van A", 2005, 9.0};
SinhVien z;
z = y; // Gán toàn bộ thuộc tính của y cho z
2. Cấu trúc Nâng cao và Ứng dụng
2.1. Mảng Cấu trúc, Cấu trúc Phức hợp, Cấu trúc Tự trỏ
Khi làm việc với kiểu cấu trúc, lập trình viên không chỉ sử dụng từng biến riêng lẻ mà còn có thể phát triển các mô hình dữ liệu linh hoạt hơn.
Tùy theo mục đích lưu trữ và tổ chức thông tin, cấu trúc có thể được mở rộng thành mảng cấu trúc, được lồng nhau tạo thành cấu trúc phức hợp, hoặc thậm chí tự tham chiếu chính nó để biểu diễn các cấu trúc dữ liệu lớn hơn như danh sách liên kết.
Phần này trình bày ba hình thức sử dụng quan trọng và thường gặp nhất của kiểu cấu trúc trong lập trình .
2.1.1. Mảng Cấu trúc
Trong nhiều bài toán, ta không chỉ quản lý một đối tượng mà cần xử lý nhiều đối tượng cùng loại. Điển hình như việc lưu trữ một danh sách sinh viên, danh sách sản phẩm, hay tập các điểm trong không gian. Khi đó, thay vì tạo từng biến cấu trúc rời rạc, ta có thể lưu trữ toàn bộ bằng mảng cấu trúc.
Mảng cấu trúc cho phép quản lý nhóm dữ liệu có cùng kiểu một cách hệ thống, dễ truy xuất, dễ duyệt và xử lý bằng vòng lặp. Đây là cách tổ chức dữ liệu rất phổ biến trong lập trình khi làm việc với tập dữ liệu lớn.
Cú pháp:
TenCauTruc ten_mang[kich_thuoc];
Ví dụ:
SinhVien DSSV[100]; // Khai báo một mảng chứa tối đa 100 sinh viên
DSSV[0] = {"Le Thi C", 2003, 7.8}; // Khởi tạo phần tử thứ 0
cout << "DTB SV dau tien: " << DSSV[0].DTB << endl; // Truy xuất thuộc tính
2.1.2. Cấu trúc Phức hợp (Nested Structs)
Trong thực tế, nhiều đối tượng dữ liệu có cấu trúc “phân cấp” hoặc “gồm nhiều thành phần nhỏ hơn”. Khi đó, ta không thể mô tả toàn bộ thông tin chỉ bằng một cấu trúc đơn lẻ. Cấu trúc phức hợp (nested struct) cho phép ta lồng một cấu trúc bên trong cấu trúc khác, tạo thành mô hình dữ liệu rõ ràng, chặt chẽ và dễ mở rộng.
Nhờ khả năng lồng ghép này, lập trình viên có thể xây dựng các đối tượng phức tạp như hồ sơ sinh viên gồm thông tin cá nhân và thông tin điểm số, hoặc một sản phẩm có thông tin xuất xứ và thông tin kỹ thuật. Đây là một kỹ thuật quan trọng khi thiết kế các mô hình dữ liệu có tổ chức trong lập trình.
Ví dụ (Quản lý ngày tháng năm sinh):
struct NgaySinh {
int Ngay;
int Thang;
int Nam;
};
struct SinhVienMoi {
string HoTen;
NgaySinh NS; // Thuộc tính NS là một struct NgaySinh
};
SinhVienMoi sv;
sv.NS.Nam = 2005;
cout << sv.NS.Nam; // Truy xuất qua nhiều dấu chấm
2.1.3. Cấu trúc Tự trỏ (Self-Referential Structs)
Trong một số mô hình dữ liệu, mỗi phần tử không chỉ chứa thông tin của chính nó mà còn cần liên kết tới phần tử khác. Để biểu diễn các mối quan hệ dạng chuỗi, danh sách, cây hoặc đồ thị, ta dùng cấu trúc tự trỏ, nghĩa là một cấu trúc trong đó có con trỏ trỏ đến chính kiểu cấu trúc đó.
Kiểu khai báo này giúp xây dựng các cấu trúc dữ liệu động như danh sách liên kết (linked list), ngăn xếp (stack), hàng đợi (queue), và cây nhị phân (binary tree). Nhờ có trường con trỏ tự trỏ, các phần tử có thể được kết nối linh hoạt, tạo thành cấu trúc có thể mở rộng hoặc co lại trong quá trình chạy chương trình.
Ví dụ (Node trong Danh sách liên kết):
struct Node {
int data;
Node* next; // Con trỏ trỏ đến Node tiếp theo
};
2.2. Kiểu Hợp nhất Union
Trong một số tình huống lập trình, ta cần một biến có thể lưu nhiều kiểu dữ liệu khác nhau, nhưng tại mỗi thời điểm chỉ sử dụng một kiểu duy nhất. Để đáp ứng yêu cầu này mà vẫn tiết kiệm bộ nhớ, ngôn ngữ cung cấp kiểu dữ liệu đặc biệt gọi là Union (hợp nhất).
Union cho phép nhiều biến thành phần chia sẻ cùng một vùng bộ nhớ. Điều này có nghĩa là tất cả các thành viên của union được lưu tại cùng một địa chỉ trong bộ nhớ, và kích thước của union sẽ bằng kích thước của thành viên lớn nhất trong nó. Khi một thành viên được gán giá trị, giá trị của các thành viên khác sẽ bị ghi đè.
Union có các đặc điểm:
- Tiết kiệm bộ nhớ
- Biểu diễn dữ liệu đa dạng về kiểu
- Kích thước của Union bằng kích thước của thành phần lớn nhất.
- Tại một thời điểm, chỉ một thành phần của Union có giá trị hợp lệ.
Tuy nhiên, bên cạnh sự tiện dụng Union đem lại, việc sử dụng union cũng đòi hỏi người lập trình phải quản lý chặt chẽ kiểu dữ liệu đang được sử dụng tại từng thời điểm, nhằm tránh đọc sai dữ liệu hoặc gây lỗi logic cho chương trình.
Ví dụ:
union Thong Tin {
int i;
float f;
char c[4];
};
// tt.i và tt.f đều dùng chung vùng 4 byte bộ nhớ.
2.3. Truyền Cấu trúc cho Hàm
Trong lập trình, cấu trúc thường được truyền vào hàm để xử lý hoặc hiển thị thông tin. Tuy nhiên, vì cấu trúc có thể chứa nhiều dữ liệu, việc lựa chọn cách truyền phù hợp sẽ ảnh hưởng đến tốc độ thực thi và khả năng thay đổi dữ liệu gốc.
cho phép truyền cấu trúc theo nhiều cách khác nhau, như truyền theo giá trị, truyền theo địa chỉ và truyền theo tham chiếu, giúp người lập trình linh hoạt trong việc sử dụng và tối ưu chương trình.
2.3.1. Truyền theo Giá trị (Pass by Value)
Khi truyền cấu trúc theo giá trị, hàm sẽ nhận một bản sao của biến cấu trúc ban đầu. Mọi thay đổi bên trong hàm không ảnh hưởng đến dữ liệu gốc, nhưng cách truyền này có thể tốn bộ nhớ và thời gian nếu cấu trúc có kích thước lớn.
Cách hoạt động: Hàm nhận một bản sao (copy) của cấu trúc.
Đặc điểm: Thay đổi trong hàm không ảnh hưởng đến biến gốc. Tốn kém bộ nhớ và thời gian do phải sao chép toàn bộ cấu trúc.
Ví dụ:
void InThongTin (SinhVien sv) {
// sv là bản sao
// ...
}
2.3.2. Truyền theo Địa chỉ (Pass by Address / Dùng Con trỏ)
Khi truyền cấu trúc theo địa chỉ, hàm nhận địa chỉ (con trỏ) của biến cấu trúc. Cách này không sao chép dữ liệu, giúp tiết kiệm bộ nhớ và cho phép hàm thay đổi trực tiếp dữ liệu gốc thông qua toán tử (->).
Cách hoạt động: Hàm nhận địa chỉ (con trỏ) của cấu trúc.
Đặc điểm: Hiệu quả về bộ nhớ. Hàm có thể thay đổi cấu trúc gốc. Phải truy xuất thuộc tính bằng toán tử ->.
Ví dụ:
void ThayDoiDTB(SinhVien *sv_ptr) {
// Thay đổi trực tiếp biến gốc
sv_ptr->DTB = 10.0;
}
2.3.3. Truyền theo Tham chiếu (Pass by Reference)
Khi truyền cấu trúc theo tham chiếu, hàm làm việc trực tiếp với biến gốc mà không tạo bản sao, nhưng vẫn sử dụng cú pháp truy xuất với dấu chấm (.) như biến thông thường. Cách truyền này vừa tiết kiệm bộ nhớ, vừa giữ cho cú pháp gọn gàng, nên được sử dụng rất phổ biến trong .
Cách hoạt động: Hàm nhận tham chiếu (&) đến cấu trúc gốc.
Đặc điểm: Hiệu quả tương đương con trỏ (không sao chép dữ liệu), nhưng cú pháp truy xuất vẫn dùng toán tử chấm (.). Đây là phương pháp phổ biến nhất trong để truyền dữ liệu lớn một cách hiệu quả.
Ví dụ:
void InDTB(const SinhVien &sv) {
// Dùng const để tránh thay đổi biến gốc
cout << sv.DTB; // Truy xuất bằng (.)
}