+1

Series Go Backend: Pointers (Con trỏ) - Hiểu về truyền tham trị vs truyền tham chiếu

Khái niệm con trỏ trong Go cực kỳ gần gũi với môi trường C/C++ nhưng được thiết kế tinh gọn và an toàn hơn rất nhiều. Go giữ lại sức mạnh của việc quản lý trực tiếp địa chỉ bộ nhớ nhưng đã thẳng tay loại bỏ tính năng tính toán con trỏ (pointer arithmetic) – nguyên nhân số một gây ra các lỗi rò rỉ và tràn bộ nhớ.

1. Con trỏ là gì? Hai toán tử cốt lõi & và *

Mọi biến bạn khai báo đều được lưu trữ tại một vị trí cụ thể trong bộ nhớ RAM (địa chỉ bộ nhớ). Con trỏ (Pointer) đơn giản là một biến đặc biệt, thay vì lưu trữ giá trị (như số 5, chuỗi "Hello"), nó lưu trữ địa chỉ bộ nhớ của một biến khác.

  • Toán tử & (Address-of): Lấy địa chỉ của một biến.

  • Toán tử * (Dereference): Truy cập vào giá trị đang nằm tại địa chỉ mà con trỏ đang trỏ tới.

Go

func main() {
    age := 22
    
    // Khai báo một con trỏ ptrAge, trỏ tới địa chỉ của biến age
    // Kiểu dữ liệu của ptrAge là *int
    var ptrAge *int = &age 
    
    fmt.Println("Giá trị của age:", age)            // 22
    fmt.Println("Địa chỉ bộ nhớ của age:", ptrAge)  // Ví dụ: 0xc00001a0a8
    
    // Dùng * để lấy giá trị từ địa chỉ đó
    fmt.Println("Giá trị tại vùng nhớ ptrAge trỏ tới:", *ptrAge) // 22
    
    // Thay đổi giá trị thông qua con trỏ
    *ptrAge = 25
    fmt.Println("Giá trị mới của age:", age) // 25 (Biến gốc đã bị thay đổi)
}

2. Truyền tham trị (Pass-by-value): Mặc định của Go

Khi bạn truyền một biến vào hàm, Go luôn luôn áp dụng cơ chế Pass-by-value. Hàm sẽ tạo ra một bản sao (copy) độc lập của biến đó. Mọi thay đổi bên trong hàm chỉ tác động lên bản sao, biến gốc bên ngoài hoàn toàn vô sự.

Go

func updateSalary(salary int) {
    salary = salary + 500
}

func main() {
    mySalary := 1000
    updateSalary(mySalary)
    fmt.Println("Lương sau khi update:", mySalary) // Vẫn là 1000!
}

Vấn đề với Pass-by-value: Nếu mySalary không phải là một số nguyên nhỏ gọn, mà là một Struct User chứa hàng tá trường thông tin (tên, email, lịch sử giao dịch, quyền hạn) nặng vài Megabyte, việc copy toàn bộ Struct này mỗi khi gọi hàm sẽ cực kỳ ngốn RAM và làm chậm ứng dụng Backend của bạn.

3. Truyền tham chiếu (Pass-by-reference bằng Con trỏ)

Để giải quyết bài toán trên, thay vì truyền toàn bộ cục dữ liệu, chúng ta chỉ truyền địa chỉ của cục dữ liệu đó. Địa chỉ bộ nhớ trong hệ điều hành 64-bit chỉ chiếm vỏn vẹn 8 byte, cực kỳ nhẹ bén.

Go

// Đổi tham số nhận vào thành con trỏ *int
func updateSalaryPtr(salary *int) {
    // Dereference để lấy giá trị gốc và cập nhật
    *salary = *salary + 500
}

func main() {
    mySalary := 1000
    // Truyền địa chỉ của biến gốc bằng toán tử &
    updateSalaryPtr(&mySalary) 
    fmt.Println("Lương sau khi update:", mySalary) // Đã lên 1500!
}

4. Bẫy "Nil Pointer Dereference" (Lỗi kinh điển)

Giống như Zero Value của các kiểu dữ liệu khác, Zero Value của một con trỏ chưa được gán địa chỉ là nil.

Nếu bạn cố gắng Dereference (dùng dấu *) một con trỏ nil để lấy hoặc gán giá trị, Go sẽ ném ra lỗi panic: invalid memory address or nil pointer dereference và đánh sập ứng dụng ngay lập tức.

Go

func main() {
    var userPtr *string // Đang là nil
    
    // LỖI FATAL PANIC: Bạn không thể gán giá trị vào một địa chỉ không tồn tại
    // *userPtr = "Admin" 
    
    // Cách xử lý đúng 1: Kiểm tra nil trước khi thao tác
    if userPtr != nil {
        fmt.Println(*userPtr)
    }
    
    // Cách xử lý đúng 2: Cấp phát vùng nhớ bằng hàm new()
    var safePtr = new(string) // Tạo một vùng nhớ chứa chuỗi rỗng và trả về con trỏ
    *safePtr = "Admin"        // Hoạt động hoàn hảo
}

5. Khi nào NÊN và KHÔNG NÊN dùng con trỏ?

Trong quá trình thiết kế các hệ thống như backend phục vụ cho thương mại điện tử hoặc logistics, việc quyết định dùng con trỏ hay truyền tham trị ảnh hưởng lớn đến hiệu năng:

NÊN DÙNG con trỏ khi:

  1. Cần thay đổi trạng thái gốc (Mutation): Hàm cần sửa đổi dữ liệu của biến được truyền vào (như hàm updateSalary ở trên).

  2. Struct có kích thước lớn: Tránh chi phí copy (overhead).

  3. Hỗ trợ Interface nil: Khi map dữ liệu từ Database hoặc JSON, đôi khi bạn cần phân biệt một trường có giá trị là 0 hay một trường null (không có giá trị). Dùng con trỏ *int cho phép trường đó nhận giá trị nil, trong khi int thì không thể.

KHÔNG NÊN DÙNG con trỏ khi:

  1. Các kiểu dữ liệu cơ bản (Primitives): int, float, bool, string. Chúng rất nhỏ, copy còn nhanh hơn là chi phí dereference vùng nhớ.

  2. Slice, Map, Channel: Bản thân các cấu trúc này đã là những "Reference types" chứa con trỏ ngầm bên trong (như đã phân tích ở Bài 5). Truyền Slice vào hàm thực chất là copy cái Header cực nhẹ của nó, dữ liệu Underlying Array bên dưới không bị copy. Việc dùng con trỏ cho Slice (ví dụ: *[]int) là dư thừa và làm rối code trong 99% trường hợp.


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í