Go Zero to Hero - Bài 32: Phương thức (Methods) của Struct
Mặc dù Golang không phải là một ngôn ngữ hướng đối tượng thuần túy (Object-Oriented Programming) như Java hay C++, nó vẫn cho phép bạn gắn các hành vi (hàm) vào trong một cấu trúc dữ liệu (Struct). Những hàm đặc biệt này được gọi là Method (Phương thức).
1. Cú pháp khai báo Method: Sợi dây Receiver
Sự khác biệt duy nhất giữa một Hàm bình thường (Function) và một Phương thức (Method) nằm ở phần nằm giữa từ khóa func và tên hàm. Đó chính là Receiver.
Cú pháp chuẩn: func (tên_biến Kiểu_Struct) Tên_Method() Kiểu_Trả_về { ... }
Hãy xem cách chúng ta định nghĩa hành vi cho một chiếc thẻ thông minh (SmartCard) dùng trong hệ thống thu phí tự động (AFC):
package main
import "fmt"
// 1. Định nghĩa Struct (Dữ liệu)
type SmartCard struct {
CardID string
Balance float64
}
// 2. Định nghĩa Method (Hành vi)
// (c SmartCard) chính là Receiver. Nó báo cho Go biết hàm GetStatus này "thuộc về" Struct SmartCard.
func (c SmartCard) GetStatus() string {
return fmt.Sprintf("Thẻ %s đang có số dư: %.0f VNĐ", c.CardID, c.Balance)
}
func main() {
// Khởi tạo thẻ
myCard := SmartCard{CardID: "CARD-9988", Balance: 50000}
// Gọi Method bằng dấu chấm (.) y hệt như các ngôn ngữ OOP khác
fmt.Println(myCard.GetStatus())
}
💡 Nhờ có Receiver, thay vì phải viết một hàm rời rạc kiểu
GetStatus(c SmartCard), chúng ta có thể gọimyCard.GetStatus(). Code trở nên xuôi tai, thuận mắt và mang đậm tính đóng gói (Encapsulation).
2. Value Receiver vs Pointer Receiver: Trận chiến sống còn
Đến đây, kiến thức về truyền tham trị và tham chiếu sẽ quyết định sự sống còn của dữ liệu trong hệ thống. Bạn có hai cách để khai báo Receiver:
Kịch bản 1: Value Receiver (Tham trị) - Chỉ Đọc (Read-only)
Khi bạn dùng Receiver là tham trị (c SmartCard), mỗi khi bạn gọi method, Go sẽ copy toàn bộ Struct đó và ném vào trong method. Mọi nỗ lực thay đổi dữ liệu bên trong method này đều vô nghĩa đối với Struct gốc.
// ❌ Method này dùng Value Receiver, nó chỉ sửa trên bản COPY
func (c SmartCard) FakeDeduct(amount float64) {
c.Balance -= amount
fmt.Println("[Trong hàm] Số dư:", c.Balance)
}
func main() {
card := SmartCard{CardID: "CARD-111", Balance: 100000}
card.FakeDeduct(20000)
fmt.Println("[Ngoài hàm] Số dư thực tế:", card.Balance) // Vẫn là 100000! Giao dịch lỗi logic!
}
Kịch bản 2: Pointer Receiver (Tham chiếu) - Cho phép Thay đổi (Mutate)
Để thực sự trừ tiền thẻ gốc trong bộ nhớ, bạn BẮT BUỘC phải dùng con trỏ ở phần Receiver: (c *SmartCard).
import "errors"
// ✅ Method này dùng Pointer Receiver (Chú ý dấu *). Nó tương tác thẳng với vùng nhớ gốc.
func (c *SmartCard) Deduct(amount float64) error {
if c.Balance < amount {
return errors.New("số dư không đủ để qua cổng")
}
c.Balance -= amount // Trừ thẳng vào thẻ gốc
return nil
}
func main() {
card := SmartCard{CardID: "CARD-222", Balance: 100000}
// Mặc dù card là Value, nhưng khi gọi method có Pointer Receiver,
// Go sẽ ĐỦ THÔNG MINH để tự động truyền địa chỉ (&card) vào cho bạn.
err := card.Deduct(15000)
if err != nil {
fmt.Println("Lỗi:", err)
} else {
fmt.Println("Thanh toán thành công. Số dư còn:", card.Balance) // Đã trừ thành 85000
}
}
Quy tắc vàng của Gopher:
- Nếu method cần sửa đổi (mutate) trạng thái của Struct -> Dùng Pointer Receiver.
- Nếu Struct chứa dữ liệu rất lớn (hàng chục fields) -> Dùng Pointer Receiver để tránh tốn RAM copy dữ liệu mỗi lần gọi hàm.
- Để code đồng nhất, nếu một Struct đã có 1 method dùng Pointer Receiver, thì hãy dùng Pointer Receiver cho TẤT CẢ các method khác của Struct đó, kể cả những method chỉ dùng để đọc dữ liệu.
3. Tại sao không dùng Hàm (Function) bình thường cho rồi?
Nhiều người sẽ thắc mắc: Gắn Receiver làm gì cho phức tạp, cứ truyền tham số vào hàm bình thường không được sao? (Ví dụ: func Deduct(c *SmartCard, amount float64)).
Sự khác biệt nằm ở Kiến trúc phần mềm (Clean Architecture):
- Namespacing (Tránh đụng hàng): Trong hệ thống, bạn có thể có hàng chục thực thể cần hành vi "Thanh toán". Nếu viết hàm rời, bạn sẽ phải đặt tên dài ngoằng kiểu
DeductSmartCard(),DeductEWallet(),DeductBank(). Nhưng với Method, bạn chỉ cần gọicard.Deduct(),wallet.Deduct(). Tên hàm cực kỳ ngắn gọn và gắn chặt vào ngữ cảnh của thực thể. - Dọn đường cho Interface: Đây là lý do lớn nhất. Trong Go, Interface (Giao diện) không định nghĩa dữ liệu, nó chỉ định nghĩa "Các tập hợp hành vi (Methods)". Nếu bạn không viết Method bằng Receiver, bạn sẽ không bao giờ sử dụng được Interface để áp dụng tính Đa hình (Polymorphism) — thứ vũ khí quan trọng nhất để thiết kế các Service tách rập (Decoupled) trong Backend.
Tổng kết
Bạn đã nắm giữ bí quyết để định hình nên tính Hướng đối tượng của Go:
- Cú pháp: Đặt
(tên_biến Kiểu_Struct)ngay trước tên hàm để biến nó thành Method. - Pointer Receiver (
*Struct): Dùng để thay đổi dữ liệu của thực thể gốc hoặc tối ưu bộ nhớ. Khuyên dùng trong 95% trường hợp làm Backend. - Value Receiver (
Struct): Dùng khi Struct cực nhỏ và bạn cam kết hàm đó chỉ đọc (Read-only), không làm thay đổi trạng thái gốc.
All rights reserved