0

Go Zero to Hero - Bài 33: Pointer Receiver vs Value Receiver trong Methods

Khi viết Method trong Golang, việc lựa chọn giữa Pointer Receiver (*Struct) và Value Receiver (Struct) không chỉ ảnh hưởng đến hiệu năng mà còn quyết định tính toàn vẹn của dữ liệu trong các hệ thống backend chịu tải cao.


1. Trình biên dịch Go "lừa tình" chúng ta như thế nào?

Trước khi đi vào phân tích chiến thuật, bạn cần phải biết một bí mật: Trình biên dịch của Go cực kỳ thông minh, nhưng chính sự thông minh đó lại làm các Gopher mới vào nghề bị ảo giác.

Trong C++ hay C, nếu bạn có một con trỏ, bạn phải dùng toán tử -> để gọi method. Nếu là tham trị, bạn dùng dấu ..

Nhưng trong Go, dù là Value hay Pointer, bạn đều dùng dấu chấm (.). Go sẽ tự động lấy địa chỉ (address-of) hoặc giải tham chiếu (dereference) ở hậu trường để giúp code bạn chạy được.

package main

import "fmt"

type Ticket struct {
    ID     string
    Status string
}

// Method dùng Pointer Receiver
func (t *Ticket) MarkAsPaid() {
    t.Status = "PAID"
}

// Method dùng Value Receiver
func (t Ticket) PrintInfo() {
    fmt.Println("Vé:", t.ID, "- Trạng thái:", t.Status)
}

func main() {
    // 1. Khởi tạo một Value (KHÔNG PHẢI CON TRỎ)
    myTicket := Ticket{ID: "TK-001", Status: "PENDING"}

    // ẢO GIÁC 1: Gọi Pointer Method từ một Value
    // Bạn gọi MarkAsPaid() trực tiếp trên myTicket. Go không báo lỗi!
    // Thực chất, Go đã âm thầm dịch thành: (&myTicket).MarkAsPaid()
    myTicket.MarkAsPaid()

    // 2. Khởi tạo một Pointer (CON TRỎ)
    pTicket := &Ticket{ID: "TK-002", Status: "PENDING"}

    // ẢO GIÁC 2: Gọi Value Method từ một Pointer
    // pTicket là con trỏ, nhưng vẫn gọi được PrintInfo()
    // Thực chất, Go đã âm thầm dịch thành: (*pTicket).PrintInfo()
    pTicket.PrintInfo()
}

Chính vì Go tự động convert qua lại quá mượt mà, nhiều lập trình viên nghĩ rằng "Dùng cái nào ở Receiver cũng giống nhau". Đó là một sai lầm chết người. Khả năng tự động này CÓ GIỚI HẠN (đặc biệt là khi kết hợp với Interface sau này).


2. Khi nào BẮT BUỘC phải dùng Pointer Receiver (t *Type)?

Đây là vũ khí bạn sẽ dùng ở 95% các dự án thực tế, từ viết logic cho thiết bị phần cứng đến hệ thống Microservices. Bạn BẮT BUỘC dùng Pointer Receiver trong 2 kịch bản sau:

Kịch bản A: Cần thay đổi trạng thái (Mutate State)

Nếu Method của bạn cần thay đổi giá trị của bất kỳ Field nào bên trong Struct, bạn phải dùng con trỏ. Nếu dùng Value Receiver, bạn chỉ đang "gãi ngứa" trên một bản copy tạm thời bị vứt đi ngay khi hàm kết thúc.

type CashBox struct {
    CurrentAmount float64
}

// Phải dùng con trỏ để việc nạp tiền thực sự ghi vào vùng nhớ gốc
func (c *CashBox) Deposit(amount float64) {
    c.CurrentAmount += amount
}

Kịch bản B: Struct quá "khổng lồ" (Tối ưu Memory & GC)

Giả sử bạn có một TicketTransaction chứa mã vé, thời gian, lịch sử mã hóa thẻ, log thiết bị... nặng tới vài Kilobytes.

Nếu bạn dùng Value Receiver (t TicketTransaction), MỖI LẦN bạn gọi method (dù chỉ là method đọc dữ liệu như t.GetID()), Go sẽ phải copy toàn bộ vài Kilobytes đó vào Stack.

Với một hệ thống AFC quét hàng ngàn vé mỗi giây, việc copy dữ liệu vô tội vạ này sẽ khiến CPU phải làm việc thừa thãi, và tốn tài nguyên kinh khủng. Dùng (t *TicketTransaction) thì chi phí truyền vào hàm luôn chỉ là 8 bytes (kích thước của một địa chỉ bộ nhớ).


3. Khi nào NÊN dùng Value Receiver (t Type)?

Nếu Pointer Receiver mạnh như vậy, tại sao Go vẫn sinh ra Value Receiver?

Value Receiver tỏa sáng rực rỡ khi bạn làm việc với những thiết kế đề cao tính Bất biến (Immutability) và An toàn đa luồng (Concurrency Safe).

Kịch bản: Struct siêu nhỏ và mang tính "Thuần dữ liệu"

Bạn nên dùng Value Receiver cho những Struct đóng vai trò như một kiểu dữ liệu cơ bản, không có nhu cầu thay đổi nội tại. Ví dụ kinh điển là tọa độ, cấu hình thời gian, hoặc định dạng tiền tệ.

// Tọa độ GPS của trạm tàu điện
type Point struct {
    Lat float64
    Lng float64
}

// Hàm tính khoảng cách KHÔNG làm thay đổi tọa độ gốc của Point
// Struct Point chỉ có 2 biến float64 (rất nhẹ), việc copy diễn ra cực nhanh trên Stack
func (p Point) DistanceTo(other Point) float64 {
    // Logic tính toán...
    return 0.0 
}

💡 Lợi thế cực lớn: Khi dùng Value Receiver, bản copy này hoàn toàn biệt lập. Bạn có thể cho hàng trăm Goroutines (luồng ngầm) cùng gọi method DistanceTo() cùng một lúc mà không bao giờ sợ bị Data Race (xung đột dữ liệu).


4. "Đạo luật" tối thượng: Sự đồng nhất (Consistency)

Đội ngũ phát triển Go (Google) đã ghi rõ trong tài liệu hướng dẫn chuẩn (Effective Go) một quy tắc vàng về thiết kế Method:

  • Đừng trộn lẫn Value Receiver và Pointer Receiver trong cùng một Struct.
  • Nếu Struct của bạn có một method như (t *Ticket) Pay() bắt buộc phải dùng Pointer Receiver để thay đổi dữ liệu...
  • ...Thì các method chỉ mang tính đọc dữ liệu như GetStatus() hay Print() của Struct đó CŨNG PHẢI dùng Pointer Receiver: (t *Ticket) GetStatus().

Tại sao phải cực đoan như vậy?

Bởi vì sự trộn lẫn sẽ khiến người dùng API (các lập trình viên khác trong team) bị bối rối về hành vi của Struct: "Rốt cuộc cái Struct này có bị thay đổi ngầm hay không?" Hơn nữa, nó sẽ gây ra những rắc rối không thể tháo gỡ khi bạn gán cái Struct "nửa nạc nửa mỡ" đó vào một Interface.


Tổng kết

Bạn chỉ cần khắc cốt ghi tâm 3 nguyên tắc sinh tử này khi viết Method cho Struct:

  1. Dùng Pointer (s *Struct): Khi method cần sửa đổi dữ liệu bên trong Struct, hoặc khi Struct đó chứa rất nhiều trường dữ liệu phức tạp (Lựa chọn mặc định cho Backend).
  2. Dùng Value (s Struct): Khi Struct cực kỳ nhỏ (như tọa độ Point, dải thời gian TimeRange) và method chỉ đóng vai trò tính toán, trả về kết quả chứ không sửa đổi Struct gốc.
  3. Không trộn lẫn: Đã dùng Pointer cho 1 method thì dùng Pointer cho toàn bộ methods của Struct đó. Tính đồng nhất là yếu tố sống còn để code Clean.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.