Đi tới nội dung chính
Go

Bài 1.4: Cấu trúc Dữ liệu (Struct)

Đi sâu vào Struct trong Go: từ cơ chế Composition thay thế kế thừa, khai báo Struct Tags, cho đến kỹ thuật tối ưu Layout bộ nhớ để tận dụng tối đa CPU Cache.

6 phút1265 từMạnh Cường

Ở 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

1
2
3
4
5
type Employee struct {
    ID     int
    Name   string
    Salary float64
}

Trong Go, có 3 cách phổ biến để khởi tạo một struct:

  1. 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.
    1
    
    emp1 := Employee{ID: 1, Name: "An", Salary: 5000.0}
    
  2. 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.
    1
    
    emp2 := Employee{2, "Bình", 6000.0}
    
  3. 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).Name như trong C/C++. Go cho phép bạn gọi thẳng emp4.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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Person struct {
    Name string
    Age  int
}

type Manager struct {
    Person // Embedded field (Chỉ khai báo kiểu, không đặt tên)
    Level  int
}

func main() {
    m := Manager{
        Person: Person{Name: "Dũng", Age: 40},
        Level:  2,
    }
    
    // Thuộc tính 'Name' được thăng cấp từ Person lên Manager
    // Bạn có thể gọi trực tiếp m.Name thay vì m.Person.Name
    fmt.Println(m.Name) // In ra: Dũng
    
    // Truy cập tường minh (dùng để giải quyết khi có trùng lặp tên trường)
    fmt.Println(m.Person.Name) 
}

Đâ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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type User struct {
    // Đổi tên key thành 'user_name' khi chuyển thành định dạng JSON
    Username string `json:"user_name"`
    
    // Dấu "-" chỉ định bỏ qua hoàn toàn trường này, không bao giờ bị lộ ra JSON
    Password string `json:"-"`
    
    // 'omitempty' sẽ bỏ qua trường này nếu dữ liệu đang rỗng (nil hoặc chuỗi rỗng)
    Tags     []string `json:"tags,omitempty"`
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import "unsafe"

// Struct CHƯA tối ưu (Tốn 24 bytes)
type Unoptimized struct {
    Flag     bool   // 1 byte (+ 7 bytes padding)
    StreamID int64  // 8 bytes
    Type     uint16 // 2 bytes (+ 6 bytes padding ở cuối)
}

// Struct ĐÃ tối ưu (Chỉ tốn 16 bytes)
type Optimized struct {
    StreamID int64  // 8 bytes
    Type     uint16 // 2 bytes
    Flag     bool   // 1 byte (+ 5 bytes padding ở cuối)
}

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ínhStruct Giá trị (T)Con trỏ Struct (*T)
Truyền vào hàmTạ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ệuAn toàn: Hàm không thể làm thay đổi biến gốc bên ngoàiRủi ro: Hàm có thể thay đổi trực tiếp biến gốc
Garbage CollectorThườ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 0Rấ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”!

Chủ đề của bài viết

Chia sẻ bài viết

Gửi bài này cho người đang cần đúng chủ đề, hoặc lưu lại để quay về sau.

Đọc tiếp

Tất cả lộ trình học

Tiếp theo

Gợi ý bài tiếp theo

Bài 1.1e: Controllers (Bộ điều khiển mạng)

Controllers là gì? Trong mạng truyền thống, kỹ sư mạng thường cấu hình và quản lý từng thiết bị riêng lẻ như router, switch, firewall hoặc access point. Cách …

Mở bài tiếp theo