Ở bài viết trước, chúng ta đã tìm hiểu về từ khóa type dùng để định nghĩa kiểu dữ liệu. Tuy nhiên type chỉ dừng lại ở việc tạo ra một định danh, nó chưa thể tổ chức dữ liệu. struct chính là công cụ để tổ chức dữ liệu trong Go.
Khác với các ngôn ngữ OOP truyền thống, Go loại bỏ hoàn toàn khái niệm kế thừa (Inheritance). Thay vào đó, struct trong Go thúc đẩy mạnh mẽ mô hình Composition over Inheritance (Lắp ghép thay vì Kế thừa).
Hãy cùng tìm hiểu về struct từ những khái niệm cơ bản nhất cho đến cách nó được sắp xếp dưới tầng bộ nhớ vật lý.
1. Struct là gì? Khởi tạo như thế nào?
Struct là một cấu trúc dữ liệu cho phép bạn gom nhóm nhiều trường (fields) có kiểu dữ liệu khác nhau lại thành một thực thể duy nhất.
Khai báo và Khởi tạo
| |
Trong Go, có 3 cách phổ biến để khởi tạo một struct:
- Dùng Named Fields (Khuyên dùng): Gán giá trị tường minh theo tên. Cách này an toàn, rõ ràng và không bị phụ thuộc vào thứ tự khai báo.
1emp1 := Employee{ID: 1, Name: "An", Salary: 5000.0} - Dùng Anonymous Fields: Bắt buộc truyền đủ tham số theo đúng thứ tự. Cách này ngắn gọn nhưng rất dễ gây lỗi ngầm nếu sau này struct thay đổi cấu trúc.
1emp2 := Employee{2, "Bình", 6000.0} - Dùng con trỏ (Pointer):
1 2 3 4// Hàm new trả về một con trỏ (*Employee), các trường nhận giá trị Zero Value emp3 := new(Employee) // Hoặc cú pháp khởi tạo con trỏ ngắn gọn và tương đương: emp4 := &Employee{ID: 4}
Implicit Dereferencing: Một điểm thú vị và tiện lợi ở Go là khi bạn có con trỏ trỏ tới struct (ví dụ
emp4), bạn không cần phải viết(*emp4).Namenhư trong C/C++. Go cho phép bạn gọi thẳngemp4.Name, trình biên dịch sẽ tự động giải bọc (dereference) giúp bạn.
2. Struct Embedding (Lắp ghép thay vì Kế thừa)
Khi không có từ khóa extends, làm sao để tái sử dụng mã nguồn? Go giải quyết bài toán này bằng Embedded Fields (Trường lồng nhau).
Bạn có thể nhúng một struct vào bên trong một struct khác mà không cần đặt tên trường. Khi đó, các trường và phương thức của struct được nhúng sẽ tự động được thăng cấp (Promoted) lên struct ngoài cùng.
| |
Đây là bản chất của triết lý Composition: Thay vì định nghĩa “Manager là một Person” (Kế thừa), hệ thống type của Go định hình “Manager có chứa các đặc tính của Person” (Lắp ghép).
3. Struct Tags: Giao tiếp với thế giới bên ngoài
Trong các hệ thống thực tế, struct không chỉ tồn tại trong RAM mà còn cần được gửi qua mạng dưới dạng JSON, XML hoặc lưu trữ vào Database. Struct Tags là các chuỗi metadata tùy chọn đính kèm ngay sau khai báo trường, dùng để hỗ trợ các thư viện mã hóa (encode/decode) thông qua cơ chế Reflection.
| |
Lưu ý: Để các thư viện như encoding/json bên ngoài package của bạn có thể đọc được dữ liệu, tên trường bắt buộc phải là Exported (Viết hoa chữ cái đầu).
4. Bản chất Bộ nhớ: Padding, Alignment và CPU Cache
Đây là khía cạnh đánh dấu sự khác biệt giữa việc biết code Go cơ bản và việc làm chủ Go ở cấp độ hệ thống.
Theo đặc tả của Go, dữ liệu của struct được sắp xếp liên tục trên bộ nhớ RAM (self-contained). Tuy nhiên, để CPU có thể truy xuất dữ liệu nhanh nhất, trình biên dịch Go phải tuân theo quy tắc Memory Alignment (Căn chỉnh bộ nhớ). Nếu dữ liệu không khớp với ranh giới khối đọc của CPU (thường là 8 bytes trên kiến trúc 64-bit), Go sẽ tự động chèn thêm các byte rỗng gọi là Padding.
Hãy xem ví dụ kinh điển sau về tác động của Padding:
| |
Dù chứa lượng thông tin hoàn toàn như nhau, cấu trúc Unoptimized tốn bộ nhớ gấp rưỡi. Khi hệ thống của bạn phải cấp phát hàng triệu bản ghi hoặc xử lý stream gói tin mạng liên tục, phần bộ nhớ dư thừa này không chỉ gây lãng phí RAM mà còn làm tràn CPU Cache (Cache locality kém), khiến ứng dụng bị suy giảm hiệu năng rõ rệt.
Quy tắc vàng (Rule of Thumb): Để tối ưu hóa dung lượng struct, hãy khai báo các trường theo thứ tự kích thước kiểu dữ liệu giảm dần (từ lớn nhất xuống nhỏ nhất).
5. So sánh: Struct Giá trị vs Con trỏ Struct
Khi truyền struct vào một hàm, bạn sẽ luôn phải đứng trước quyết định: Truyền tham trị (T) hay truyền tham chiếu (*T)?
| Đặc tính | Struct Giá trị (T) | Con trỏ Struct (*T) |
|---|---|---|
| Truyền vào hàm | Tạo ra một bản sao hoàn toàn mới trên bộ nhớ | Chỉ sao chép địa chỉ bộ nhớ (con trỏ), rất nhanh và gọn |
| Bảo toàn dữ liệu | An toàn: Hàm không thể làm thay đổi biến gốc bên ngoài | Rủi ro: Hàm có thể thay đổi trực tiếp biến gốc |
| Garbage Collector | Thường nằm trên Stack, được dọn dẹp tức thời khi thoát hàm, chi phí GC bằng 0 | Rất dễ bị phân tích là Escape và đẩy lên Heap, tạo gánh nặng dọn dẹp cho GC |
Lời khuyên: Nếu struct của bạn nhỏ (vài chục bytes) và vòng đời ngắn, hãy truyền theo giá trị (T). Điều này không chỉ giúp tránh các lỗi race-condition trong môi trường lập trình đa luồng (concurrency) mà còn giữ biến trên Stack, qua đó né được hoàn toàn gánh nặng cho Garbage Collector. Đừng lạm dụng con trỏ ở mọi nơi chỉ vì nghĩ rằng “nó nhanh hơn”!