0

Go Zero to Hero - Bài 29: Truyền tham trị (Value) vs Truyền tham chiếu (Pointer)

Trong Go, việc hiểu rõ cơ chế truyền đối số vào hàm (Pass-by-Value vs Pass-by-Reference) là ranh giới giữa một code sạch, hiệu năng cao và một hệ thống dễ rò rỉ bộ nhớ hay gặp lỗi tiềm ẩn.


1. Chân lý cốt lõi: Trong Go, MỌI THỨ đều là Pass-by-Value

Nếu bạn từng học C++, bạn sẽ biết ngôn ngữ đó hỗ trợ cả truyền tham trị (copy giá trị) và truyền tham chiếu thực sự (truyền chính cái biến đó vào).

Nhưng ở thế giới của Golang, cha đẻ của nó đã đơn giản hóa mọi thứ bằng một nguyên tắc thép: Khi bạn gọi một hàm, Go luôn luôn tạo ra một BẢN SAO (Copy) của các đối số truyền vào.

  • Nếu bạn truyền một biến int (ví dụ số 10), Go copy số 10 đó ra một ô nhớ mới.
  • Nếu bạn truyền một con trỏ &age (ví dụ địa chỉ 0xc00001a0b8), Go copy cái địa chỉ đó ra một ô nhớ mới. Bản thân con trỏ cũng bị copy!

Do đó, khái niệm "Truyền tham chiếu" trong Go thực chất là "Truyền tham trị của một Con trỏ" (Pass by value of a pointer).


2. Truyền tham trị (Value): Khi sự bất biến lên ngôi

Truyền tham trị nghĩa là bạn ném cho hàm một bản sao chép. Dù hàm đó có "xào nấu" bản sao ra sao, dữ liệu gốc của bạn vẫn an toàn tuyệt đối.

Khi nào nên dùng?

  • Các kiểu dữ liệu cơ bản: int, float, bool, string.
  • Các Struct siêu nhỏ: Những cấu trúc dữ liệu chỉ chứa vài trường đơn giản (ví dụ: một tọa độ Point{X, Y} chứa 2 biến số thực).

Lợi ích "Tàng hình": Sức mạnh của Stack Memory

Nhiều lập trình viên sợ truyền tham trị vì ám ảnh từ "Copy gây tốn RAM". Nhưng sự thật là, với những dữ liệu nhỏ, việc copy này diễn ra trên Stack (bộ nhớ tạm của CPU).

Việc đẩy dữ liệu vào và rút dữ liệu ra khỏi Stack nhanh như chớp và không hề làm phiền đến Garbage Collector (GC). CPU cache cực kỳ thích điều này!

// Tọa độ của một máy bán vé TVM
type Coordinate struct {
    Lat float64
    Lng float64
}

// Truyền tham trị (Value). Hàm này sẽ nhận một bản copy của tọa độ.
func calculateDistance(c Coordinate) float64 {
    // Việc xử lý c ở đây cực nhanh, và c sẽ tự biến mất khi hàm kết thúc
    return c.Lat * c.Lng 
}

3. Truyền tham chiếu (Pointer): Vũ khí của kỹ sư hệ thống

Truyền tham chiếu (bằng cách truyền con trỏ *Type) là khi bạn đưa cho hàm cái "chìa khóa" mở cửa vào đúng ô nhớ của biến gốc.

Khi nào BẮT BUỘC phải dùng?

1. Khi bạn muốn THAY ĐỔI (Mutate) trạng thái của biến gốc

Đây là ứng dụng phổ biến nhất. Ví dụ, bạn có một luồng xử lý giao dịch quẹt thẻ, sau khi gọi API thanh toán thành công, bạn phải cập nhật trạng thái của giao dịch đó thành PAID.

type TicketTransaction struct {
    TxnID   string
    Amount  float64
    Status  string
}

// BẮT BUỘC truyền con trỏ (*TicketTransaction) để có thể thay đổi biến gốc
func processPayment(txn *TicketTransaction) {
    // Logic gọi API ngân hàng...
    
    // Cập nhật thẳng vào dữ liệu gốc
    txn.Status = "PAID" 
}

2. Khi dữ liệu quá "Khổng lồ" (Memory Optimization)

Giả sử cái TicketTransaction ở trên không chỉ có 3 trường, mà nó chứa cả chuỗi Base64 của ảnh chụp camera, thông tin mã hóa thẻ, và hàng tá cấu hình khác, nặng tới vài Megabytes.

Nếu bạn truyền tham trị, mỗi lần gọi hàm, Go sẽ copy vài Megabytes đó. Gọi qua 3 hàm là mất đứt hàng chục MB RAM cho một request.

Với con trỏ, bạn chỉ truyền đi đúng một địa chỉ bộ nhớ nặng 8 bytes (trên kiến trúc 64-bit) — siêu nhẹ!

Mặt trái của Pointer: Cơn ác mộng của Garbage Collector

Khi bạn chia sẻ một con trỏ đi qua nhiều hàm, trình biên dịch Go sẽ không dám lưu dữ liệu đó trên Stack nữa (vì không biết khi nào các hàm kia mới dùng xong). Nó sẽ phải đẩy dữ liệu đó lên Heap (bộ nhớ lâu dài).

Dữ liệu trên Heap bắt buộc phải chờ Garbage Collector (GC) đi tuần tra và dọn dẹp. Nếu bạn lạm dụng con trỏ ở mọi nơi (kể cả những struct bé tí), GC của hệ thống sẽ phải hoạt động liên tục, gây ra hiện tượng GC Pause làm giật lag, tăng độ trễ (latency) của API.


4. Bẫy kinh điển: Slice và Map có cần dùng Con trỏ không?

Rất nhiều người thắc mắc: "Slice và Map có chứa hàng ngàn phần tử, vậy tôi có nên truyền *[]int hay *map[string]int vào hàm không?"

Câu trả lời là: KHÔNG! (Trong 99% trường hợp).

Như đã giải phẫu ở các bài trước, bản thân Slice và Map chỉ là những lớp vỏ bọc siêu nhẹ (Header) trỏ tới mảng dữ liệu thực bên dưới:

  • Slice header chỉ nặng 24 bytes (Pointer, Length, Capacity).
  • Map header chỉ là một con trỏ 8 bytes.

Khi bạn truyền tham trị một Slice vào hàm (func process(data []int)), Go chỉ copy 24 bytes của cái Header, chứ KHÔNG copy hàng ngàn phần tử bên dưới. Do đó, nó vừa nhẹ, vừa có thể thay đổi được dữ liệu gốc (vì cả 2 header cùng trỏ chung một mảng ngầm).

💡 Lưu ý: Trừ khi bạn muốn dùng hàm append để làm thay đổi cả Length/Capacity của Slice gốc từ bên trong một hàm khác, lúc đó bạn mới cần truyền con trỏ Slice.


Tổng kết: Quy tắc ngón tay cái (Rule of Thumb)

Để không bao giờ phải lăn tăn khi code, hãy nhớ thần chú sau:

  1. Mặc định dùng Tham trị (Value): Code an toàn, dễ test, không side-effect (tác dụng phụ), cực kỳ thân thiện với GC. Phù hợp với biến cơ bản và Struct nhỏ.
  2. Dùng Tham chiếu (Pointer) khi:
    • Bạn cần sửa giá trị của biến gốc.
    • Biến gốc là một Struct rất lớn (tiết kiệm chi phí copy).
  3. Tuyệt đối không: Dùng con trỏ cho Slice, Map, hay Channel trừ khi có lý do cực kỳ đặc thù. Bản thân chúng đã mang đặc tính của tham chiếu rồi.

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í