0

Go Zero to Hero - Bài 17: Slice - Linh hồn của danh sách dữ liệu trong Go

Nhiều người lầm tưởng Slice là một kiểu dữ liệu hoàn toàn tách biệt. Thực chất, Slice chỉ là một "lớp vỏ bọc" (wrapper) mỏng nhẹ nằm phía trên một Array ẩn (underlying array).


1. Slice thực chất là gì? Giải phẫu "Mặt nạ" của Array

Khi bạn nhìn vào mã nguồn của Go, cấu trúc thực sự của một Slice (Slice Header) chỉ gồm 3 thành phần siêu nhỏ, tổng cộng tốn đúng 24 bytes (trên máy 64-bit):

  • Pointer (Con trỏ): Trỏ đến phần tử đầu tiên của Array ẩn mà Slice này đang quản lý.
  • Length (Chiều dài - len): Số lượng phần tử thực tế mà Slice đang chứa.
  • Capacity (Sức chứa - cap): Kích thước tối đa của Array ẩn (tính từ vị trí con trỏ).

Chính nhờ cấu trúc 3 thành phần này, Slice vô cùng nhẹ nhàng, linh hoạt và giải quyết hoàn toàn bài toán ngốn RAM của Array.


2. 3 Cách "triệu hồi" Slice chuẩn chỉ

Bạn có thể tạo ra Slice bằng nhiều cách khác nhau, tùy thuộc vào việc bạn đã có sẵn dữ liệu hay muốn cấp phát vùng nhớ từ đầu.

Cách 1: Khai báo trực tiếp (Giống Array nhưng bỏ trống kích thước)

Cú pháp y hệt khởi tạo Array, nhưng bạn tuyệt đối không điền con số nào vào trong dấu ngoặc vuông []. Go sẽ tự động tạo ra một Array ẩn bên dưới và cho con trỏ của Slice trỏ vào đó.

package main

import "fmt"

func main() {
    // Đây là Slice, KHÔNG PHẢI Array
    // Ví dụ: Lưu trữ danh sách mã trạm vé
    stationCodes := []string{"ST01", "ST02", "ST03"} 
    
    fmt.Printf("Danh sách: %v | Chiều dài: %d | Sức chứa: %d\n", 
        stationCodes, len(stationCodes), cap(stationCodes))
}

Cách 2: "Cắt" (Slicing) từ một Array hoặc Slice khác

Đây là nguồn gốc của cái tên "Slice" (Lát cắt). Bằng cú pháp [low:high], bạn có thể tạo ra một Slice trỏ tới một đoạn dữ liệu của Array có sẵn.

func main() {
    // 1. Tạo một Array tĩnh gồm 5 phần tử
    arrLogs := [5]string{"INFO", "WARN", "ERROR", "FATAL", "DEBUG"}
    
    // 2. Cắt từ vị trí index 1 đến sát index 3 (không lấy index 3)
    // Cú pháp: arr[start_index : end_index]
    criticalLogs := arrLogs[1:3] 
    
    fmt.Println(criticalLogs) // Kết quả: [WARN ERROR]
}

Quy tắc cắt nhanh:

  • arr[:3]: Cắt từ đầu đến sát vị trí 3.
  • arr[2:]: Cắt từ vị trí 2 đến hết mảng.
  • arr[:]: Copy toàn bộ mảng thành một Slice.

Cách 3: Khởi tạo chuyên nghiệp bằng hàm make()

Khi bạn query 100,000 bản ghi lịch sử quẹt thẻ từ PostgreSQL, bạn không thể code cứng (hard-code) dữ liệu vào code được. Bạn cần yêu cầu hệ điều hành cấp phát trước một vùng nhớ rỗng. Đây là lúc dùng make().

// Cú pháp: make([]Kiểu_Dữ_Liệu, Length, Capacity)
// Khởi tạo một Slice chứa int, chiều dài 0, sức chứa dự trù 100
ticketBatch := make([]int, 0, 100) 

Bí quyết tối ưu hiệu năng: Việc khai báo sẵn Capacity bằng make là một Best Practice cực kỳ quan trọng đối với dân Backend. Nó giúp Go không phải liên tục xin cấp phát lại bộ nhớ khi dữ liệu phình to ra, giúp giảm tải cực lớn cho Garbage Collector (Bộ gom rác).


3. Truyền Tham chiếu (Reference behavior) - Giải cứu bộ nhớ RAM

Ở các bài trước, chúng ta ám ảnh vì truyền Array vào hàm sẽ tạo ra một bản copy khổng lồ. Với Slice, câu chuyện hoàn toàn khác!

Khi bạn truyền một Slice vào hàm, Go vẫn copy (pass-by-value), nhưng nó chỉ copy cái "Slice Header" 24 bytes (gồm con trỏ, len, cap). Nó KHÔNG copy dữ liệu thực tế (Underlying Array). Do đó, con trỏ ở hàm mới vẫn trỏ về đúng vùng nhớ cũ.

func maskCardNumber(cards []string) {
    if len(cards) > 0 {
        // Thay đổi phần tử đầu tiên
        cards[0] = "****-****-****-0000" 
    }
}

func main() {
    activeCards := []string{"4123-5678-9012-3456", "1111-2222-3333-4444"}
    
    // Truyền Slice vào hàm siêu nhẹ nhàng
    maskCardNumber(activeCards)
    
    // BÙM! Slice gốc bị thay đổi vì cả 2 cùng dùng chung một Array ẩn.
    fmt.Println(activeCards[0]) // Kết quả: "****-****-****-0000"
}

Nhờ cơ chế này, bạn có thể truyền một Slice chứa hàng triệu phần tử qua lại giữa các lớp kiến trúc (từ Repository layer lên Service layer) mà tốc độ vẫn nhanh như chớp và không tốn thêm byte RAM nào.


4. Hàm append: Vũ khí "co giãn" thần thánh

Điểm yếu "chết người" của Array là không thể thay đổi kích thước. Slice giải quyết điều này bằng hàm tích hợp sẵn append.

func main() {
    // Khởi tạo slice rỗng
    users := []string{"Hoàng"}
    
    // Nhồi thêm 1 phần tử
    users = append(users, "Nam")
    
    // Nhồi thêm nhiều phần tử cùng lúc
    users = append(users, "Hải", "Tuấn")
    
    fmt.Println(users) // [Hoàng Nam Hải Tuấn]
}

Nhưng hãy khoan! Nếu Slice thực chất được xây trên một Array có kích thước cố định, thì khi chúng ta append quá sức chứa (Capacity), dữ liệu sẽ tràn đi đâu? Go sẽ xử lý hiện tượng "chật ních" này như thế nào?


Tổng kết

Bạn đã nắm trong tay cấu trúc dữ liệu quan trọng nhất của Golang:

  • Bản chất: Slice chỉ là vỏ bọc 24 bytes (Con trỏ, Chiều dài, Sức chứa) trỏ tới một Array ẩn.
  • Khởi tạo: Ưu tiên dùng make() và tính toán trước Capacity để hệ thống đạt hiệu năng cao nhất.
  • Tương tác: Slice siêu nhẹ, truyền vào hàm thoải mái không lo tràn RAM, và hỗ trợ "co giãn" linh hoạt với append.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí