Slices


1. Tổng quan về Slices trong Go

Slice là một cấu trúc dữ liệu mạnh mẽ và linh hoạt trong Go, được sử dụng để làm việc với các chuỗi dữ liệu có độ dài động. Không giống như array (có độ dài cố định), slice là một "view" (lát cắt) của một array bên dưới, cho phép thay đổi kích thước động.

Đặc điểm chính:

  • Slice không lưu trữ dữ liệu trực tiếp mà tham chiếu đến một array cơ sở.

  • Slice có length (độ dài hiện tại) và capacity (dung lượng tối đa của array cơ sở mà slice tham chiếu).

  • Slice hỗ trợ các thao tác như cắt (slicing), thêm phần tử (append), và sao chép (copy).

Cú pháp khai báo:

var s []int           // Slice rỗng, nil
s := []int{1, 2, 3}   // Slice với giá trị ban đầu
s := make([]int, 5)   // Slice với length=5, capacity=5
s := make([]int, 5, 10) // Slice với length=5, capacity=10

2. Cấu trúc nội tại của Slice

Một slice trong Go thực chất là một struct với 3 thành phần:

  1. Pointer: Con trỏ đến phần tử đầu tiên của array cơ sở mà slice tham chiếu.

  2. Length: Số phần tử hiện tại của slice (truy cập qua len(slice)).

  3. Capacity: Dung lượng tối đa của array cơ sở từ phần tử đầu tiên của slice (truy cập qua cap(slice)).

Cấu trúc này được định nghĩa trong runtime của Go (không phải mã người dùng có thể thấy trực tiếp):

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

Ví dụ minh họa:

s := []int{1, 2, 3, 4, 5}
fmt.Println(len(s), cap(s)) // Output: 5 5

Khi bạn cắt một slice:

t := s[1:3]
fmt.Println(t, len(t), cap(t)) // Output: [2 3] 2 4
  • t là một slice mới, tham chiếu đến cùng array cơ sở.

  • len(t) = 3-1 = 2 (độ dài của slice mới).

  • cap(t) = 5-1 = 4 (dung lượng từ index 1 đến cuối array cơ sở).


3. Khai báo và khởi tạo Slice

3.1. Khai báo Slice

  • Nil slice:

    var s []int
    fmt.Println(s == nil) // true
    fmt.Println(len(s), cap(s)) // 0 0
    • Nil slice an toàn để sử dụng (có thể append, không gây panic).

  • Empty slice:

    s := []int{}
    fmt.Println(s == nil) // false
    fmt.Println(len(s), cap(s)) // 0 0
    • Empty slice không phải nil nhưng có length và capacity bằng 0.

3.2. Sử dụng make

  • make là cách khởi tạo slice với length và capacity xác định:

    s := make([]int, 5)      // length=5, capacity=5, giá trị [0,0,0,0,0]
    s := make([]int, 5, 10)  // length=5, capacity=10

3.3. Khởi tạo với Literal

  • Khởi tạo trực tiếp với giá trị:

    s := []int{1, 2, 3}
    fmt.Println(len(s), cap(s)) // 3 3

4. Thao tác với Slice

4.1. Slicing

  • Cắt slice để tạo slice mới từ một phần của slice hiện tại:

    s := []int{0, 1, 2, 3, 4}
    t := s[1:4] // [1, 2, 3]
    fmt.Println(len(t), cap(t)) // 3 4
  • Cú pháp: slice[start:end] (bao gồm start, không bao gồm end).

  • Có thể bỏ start hoặc end:

    s[:3] // [0, 1, 2]
    s[2:] // [2, 3, 4]
    s[:]  // Copy toàn bộ slice

4.2. Append

  • append thêm phần tử vào slice và trả về slice mới:

    s := []int{1, 2}
    s = append(s, 3) // [1, 2, 3]
    s = append(s, 4, 5) // [1, 2, 3, 4, 5]
  • Nếu capacity không đủ, Go sẽ cấp phát array mới với capacity lớn hơn (thường gấp đôi), sao chép dữ liệu, và trả về slice mới.

Cơ chế tăng capacity:

  • Khi capacity không đủ, Go nhân đôi capacity (hoặc tăng theo thuật toán tối ưu) để giảm số lần cấp phát.

  • Ví dụ:

    s := make([]int, 0, 1)
    fmt.Println(cap(s)) // 1
    s = append(s, 1)
    fmt.Println(cap(s)) // 1
    s = append(s, 2)
    fmt.Println(cap(s)) // 2
    s = append(s, 3)
    fmt.Println(cap(s)) // 4

4.3. Copy

  • copy sao chép dữ liệu từ slice nguồn sang slice đích:

    src := []int{1, 2, 3}
    dst := make([]int, len(src))
    copy(dst, src)
    fmt.Println(dst) // [1, 2, 3]
  • copy chỉ sao chép số phần tử bằng min(len(dst), len(src)).

  • Slice đích và nguồn độc lập sau khi copy (không chia sẻ array cơ sở).

4.4. Xóa phần tử

Go không có hàm xóa trực tiếp, nhưng bạn có thể dùng append và slicing:

s := []int{1, 2, 3, 4}
i := 2 // Xóa phần tử tại index 2
s = append(s[:i], s[i+1:]...) // [1, 2, 4]

5. Cơ chế hoạt động và Performance

5.1. Sharing Underlying Array

  • Nhiều slice có thể chia sẻ cùng array cơ sở, dẫn đến việc thay đổi một slice có thể ảnh hưởng đến slice khác:

    s := []int{1, 2, 3, 4}
    t := s[1:3] // [2, 3]
    t[0] = 99
    fmt.Println(s) // [1, 99, 3, 4]
    fmt.Println(t) // [99, 3]
  • Để tránh vấn đề này, sử dụng copy hoặc tạo slice độc lập.

5.2. Performance Considerations

  • Append: Nếu capacity đủ, append nhanh (O(1)). Nếu cần cấp phát mới, chậm hơn (O(n) do sao chép).

  • Slicing: Rẻ (O(1)), chỉ cập nhật pointer, len, cap.

  • Copy: Tốn O(n) vì sao chép từng phần tử.

  • Memory Usage: Slice tiết kiệm bộ nhớ hơn array vì chỉ là view, nhưng cần quản lý capacity để tránh cấp phát thừa.

Mẹo tối ưu:

  • Sử dụng make với capacity dự đoán để giảm số lần cấp phát:

    s := make([]int, 0, 100) // Dự đoán chứa 100 phần tử
    for i := 0; i < 100; i++ {
        s = append(s, i)
    }
  • Tránh slicing không cần thiết khi làm việc với slice lớn, vì nó giữ tham chiếu đến array cơ sở, ngăn garbage collection.


6. Concurrency và Slices

Slices không an toàn với concurrency theo mặc định. Nếu nhiều goroutines truy cập cùng slice, bạn phải đồng bộ hóa.

6.1. Sử dụng Mutex

var (
    s  []int
    mu sync.Mutex
)

func appendSafe(x int) {
    mu.Lock()
    s = append(s, x)
    mu.Unlock()
}

6.2. Sử dụng Channel

Thay vì chia sẻ slice, dùng channel để truyền dữ liệu:

func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func main() {
    ch := make(chan int)
    var s []int
    go producer(ch)
    for x := range ch {
        s = append(s, x)
    }
    fmt.Println(s) // [0, 1, 2, 3, 4]
}

Chuyên sâu:

  • Khi làm việc với concurrency, tránh gửi slice qua channel nếu slice có thể bị thay đổi bởi goroutine khác. Thay vào đó, gửi dữ liệu cụ thể hoặc dùng copy.


7. Best Practices

Một Senior Golang cần áp dụng các best practices để viết code hiệu quả và ít lỗi:

  1. Khởi tạo với Capacity hợp lý:

    • Dự đoán kích thước slice để tránh cấp phát không cần thiết:

      s := make([]int, 0, expectedSize)
  2. Kiểm tra Nil Slice:

    • Nil slice an toàn để append nhưng có thể gây lỗi khi truy cập index:

      var s []int
      s = append(s, 1) // OK
      fmt.Println(s[0]) // Panic: index out of range
  3. Sử dụng copy để tránh chia sẻ dữ liệu:

    • Khi cần slice độc lập, luôn dùng copy:

      src := []int{1, 2, 3}
      dst := make([]int, len(src))
      copy(dst, src)
  4. Hiểu Length và Capacity:

    • Luôn kiểm tra lencap khi slicing để tránh vượt quá giới hạn:

      s := make([]int, 5, 10)
      t := s[:10] // OK, vì cap(s) = 10
      u := s[:11] // Panic: slice bounds out of range
  5. Tránh Memory Leaks:

    • Khi cắt slice, slice mới vẫn giữ tham chiếu đến array cơ sở, có thể ngăn garbage collection:

      s := make([]int, 10000)
      t := s[:10] // t vẫn giữ toàn bộ array 10000 phần tử

      Giải pháp: Dùng copy để tạo slice độc lập.

  6. Không lạm dụng Append trong vòng lặp lớn:

    • Nếu biết số lượng phần tử, khởi tạo slice với capacity đủ lớn ngay từ đầu.


8. Các vấn đề nâng cao

8.1. Full Slice Expression

Go hỗ trợ cú pháp slice[start:end:max] để giới hạn capacity của slice mới:

s := []int{0, 1, 2, 3, 4}
t := s[1:3:3] // [1, 2], cap(t) = 3-1 = 2
fmt.Println(len(t), cap(t)) // 2 2
  • max giới hạn capacity của slice mới, giúp kiểm soát phạm vi tham chiếu.

8.2. Slice và Garbage Collection

  • Slice giữ tham chiếu đến array cơ sở, có thể gây memory leak nếu không được quản lý đúng:

    func keepReference() []int {
        s := make([]int, 10000)
        return s[:1] // Giữ toàn bộ array 10000 phần tử
    }

    Giải pháp:

    func noReference() []int {
        s := make([]int, 10000)
        t := make([]int, 1)
        copy(t, s[:1])
        return t
    }

8.3. Slice Tricks

Go có nhiều "slice tricks" để thực hiện các thao tác phức tạp:

  • Xóa phần tử mà không giữ thứ tự:

    s := []int{1, 2, 3, 4}
    i := 2
    s[i] = s[len(s)-1]
    s = s[:len(s)-1] // [1, 2, 4]
  • Reverse slice:

    s := []int{1, 2, 3}
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
    // s = [3, 2, 1]

9. Ví dụ thực tế

Dưới đây là một chương trình minh họa các khái niệm về slice trong một ứng dụng thực tế (xử lý danh sách user với concurrency):

package main

import (
    "fmt"
    "sync"
)

type User struct {
    ID   int
    Name string
}

func main() {
    // Khởi tạo slice với capacity dự đoán
    users := make([]User, 0, 100)
    var mu sync.Mutex
    var wg sync.WaitGroup

    // Hàm thêm user vào slice
    addUser := func(id int, name string) {
        defer wg.Done()
        user := User{ID: id, Name: name}
        mu.Lock()
        users = append(users, user)
        mu.Unlock()
    }

    // Thêm user từ nhiều goroutines
    wg.Add(3)
    go addUser(1, "Alice")
    go addUser(2, "Bob")
    go addUser(3, "Charlie")

    wg.Wait()

    // Lọc user với ID > 1
    filtered := make([]User, 0, len(users))
    for _, u := range users {
        if u.ID > 1 {
            filtered = append(filtered, u)
        }
    }

    // Copy để tạo slice độc lập
    usersCopy := make([]User, len(filtered))
    copy(usersCopy, filtered)

    fmt.Println("Filtered users:", usersCopy)
}

10. Kết luận

Hiểu sâu về slices trong Go là một kỹ năng quan trọng của một Senior Golang. Bạn cần nắm rõ cách slices hoạt động ở cấp độ thấp (pointer, length, capacity), cách tối ưu performance (capacity planning, copy), và cách sử dụng an toàn trong concurrency. Slice là công cụ mạnh mẽ, nhưng cũng dễ gây lỗi nếu không quản lý cẩn thận (memory leak, data race, sharing underlying array).

Last updated