Giới thiệu
Nếu bạn đang chuyển sang Golang từ một ngôn ngữ khác như Java, C# hay bất kỳ ngôn ngữ nào khác, có thể bạn đã gặp phải một đoạn mã khiến bạn phải vò đầu bứt tai. Đoạn mã này trông như sau:
go
package main
import "fmt"
// 1. Kiểu cụ thể
type A struct {
abc string
}
// 2. Phương thức gắn liền với kiểu
func (a *A) f1() {
fmt.Println("hàm f1: ", a.abc)
}
// 3. Giao diện trừu tượng
type I interface {
f1()
}
// 4. Hàm tiêu thụ
func wantsI(i I) {
i.f1()
}
// 5. Thực thi
func main() {
a := &A{}
a.abc = "giá trị thử nghiệm"
// ➡ Câu hỏi "phép thuật" nằm ngay đây: ⬅
wantsI(a)
fmt.Println("hàm đã được thực thi!...")
}
Khi bạn chạy đoạn mã này, nó hoạt động hoàn hảo và in ra:
hàm f1: giá trị thử nghiệm
hàm đã được thực thi!...
Điều này dẫn đến câu hỏi chính mà nhiều lập trình viên Go mới gặp phải:
Tại sao điều này lại hoạt động? Hàm wantsI
rõ ràng chỉ chấp nhận tham số có kiểu I
(giao diện). Tuy nhiên, chúng ta đã truyền vào a
, là một biến có kiểu *A
(con trỏ đến một struct). Chúng ta chưa bao giờ viết "struct A thực hiện I."
Làm thế nào mà một hàm mong đợi một giao diện lại có thể chấp nhận một struct dường như không có kết nối nào với nó?
Câu trả lời nằm ở tính năng mạnh mẽ nhất và đặc trưng nhất của ngôn ngữ Go: triển khai giao diện ngầm định.
Hãy cùng phân tích cách mà các kiểu cụ thể (struct) ngầm định thỏa mãn các kiểu trừu tượng (giao diện) & Khai thác sức mạnh của tính đa hình trong Go.
🧱 Các thành phần cơ bản: Kiểu cụ thể vs. Kiểu trừu tượng
Đầu tiên, bạn cần hiểu hai loại kiểu khác nhau mà bạn đã tạo ra.
1. Kiểu cụ thể: struct A
Một "kiểu cụ thể" là một bản thiết kế cho một vật thể thực tế mà chứa dữ liệu.
-
Định nghĩa: Struct
A
của bạn là một bản thiết kế cụ thể. Nó mô tả một khối bộ nhớ sẽ được tạo ra để chứa chính xác một mảnh dữ liệu: mộtstring
có tên làabc
. -
Ví dụ: Khi bạn viết
a := &A{}
, bạn đang xây dựng một thực thể thực tế từ bản thiết kế này. Nó là một thứ cụ thể, hữu hình trong bộ nhớ của chương trình bạn. Hãy nghĩ về nó như một công nhân cụ thể mà bạn vừa tuyển dụng.
2. Kiểu trừu tượng: interface I
Một "kiểu trừu tượng," hay giao diện, không phải là một vật thể. Nó là một tập hợp các quy tắc—một hợp đồng.
-
Định nghĩa: Giao diện của bạn
interface I
không chứa dữ liệu nào cả. Nó chỉ định nghĩa một danh sách các hành vi (phương thức). -
Hợp đồng: Giao diện này nói rằng, "Tôi không quan tâm bạn là gì, bạn chứa dữ liệu gì, hoặc bạn đến từ đâu. Để được coi là
I
bởi biên dịch viên Go, bạn phải cung cấp một hành vi: một phương thức gọi làf1()
không nhận tham số và không trả về gì cả."
✨ Giải thích về phép thuật: Triển khai ngầm định của Go
Đây là toàn bộ phép thuật được giải thích.
Trong các ngôn ngữ như Java
, bạn phải chỉ định rõ ý định của mình: public class MyClass implements MyInterface
. Bạn đang nói với biên dịch viên về ý định của bạn để tuân theo hợp đồng.
Go không hoạt động như vậy. "Đừng nói với tôi những gì bạn dự định làm. Hãy cho tôi thấy những gì bạn có thể làm."
🔄 Quy tắc trong Go: Một kiểu tự động và ngầm định thực hiện một giao diện nếu nó sở hữu tất cả các phương thức mà giao diện yêu cầu.
Hãy áp dụng quy tắc này vào mã của chúng ta:
- Hợp đồng (
interface I
): Yêu cầu một phương thức:f1()
. - Ứng cử viên (
type *A
): Kiểu*A
của chúng ta có tất cả các phương thức yêu cầu không? - Kiểm tra: Có. Chúng ta đã định nghĩa phương thức chính xác này:
func (a *A) f1()
. Tên của nó (f1
) và chữ ký của nó (không có tham số, không trả về gì) hoàn toàn khớp với hợp đồng. - Kết luận: Bởi vì
*A
có phương thứcf1()
, biên dịch viên Go tự động kết luận: "Kiểu *A
thỏa mãninterface I
."
Đó là tất cả. Không có bước thứ năm.
Bởi vì *A
thỏa mãn I
, bất kỳ biến nào có kiểu *A
(như biến a
của bạn) có thể được hợp pháp truyền cho bất kỳ hàm nào yêu cầu một I
(như wantsI
).
Bên trong hàm wantsI
, tham số i
là một giá trị giao diện. Hãy nghĩ về nó như một cái hộp chứa hai thứ:
- Một nhãn xác định kiểu cụ thể thực tế của nó (trong trường hợp này,
*A
). - Dữ liệu thực tế (con trỏ đến struct của bạn).
Khi i.f1()
được gọi, Go nhìn vào trong hộp, thấy loại là *A
, và gọi phương thức cụ thể f1()
gắn liền với *A
.
⚠️ Chi tiết quan trọng: Con trỏ vs. Giá trị (Tập hợp phương thức)
Có một chi tiết quan trọng khác mà bạn đã đúng mặc định. Lưu ý định nghĩa phương thức của bạn:
func (a *A) f1()
Đây là một người nhận con trỏ. Điều này có nghĩa là f1()
không được gắn liền với struct A
mà với một con trỏ đến struct A
(*A
). Điều này xác định cái gọi là tập hợp phương thức.
- Tập hợp phương thức cho kiểu
*A
(con trỏ) bao gồmf1()
.✅ - Tập hợp phương thức cho kiểu
A
(giá trị) không bao gồmf1()
.❌
Điều này có nghĩa là
*A
thực hiệnI
, nhưng kiểuA
thông thường không thực hiện! ✅
Đây là lý do tại sao hàm chính của bạn phải tạo một con trỏ (a := &A{}
) để hoạt động. Nếu bạn cố gắng tạo một giá trị thay thế, chương trình sẽ không biên dịch được.
Ví dụ (Điều không hoạt động)
go
func main() {
// Tạo a_val như một GIÁ TRỊ (kiểu A), không phải là một con trỏ
a_val := A{}
a_val.abc = "giá trị thử nghiệm"
// Dòng này sẽ gây ra lỗi thời gian biên dịch!
wantsI(a_val)
}
Trình biên dịch sẽ không chạy và hiển thị lỗi:
không thể sử dụng a_val (biến kiểu A) làm giá trị I trong tham số 전달 cho wantsI: A không thực hiện I (phương thức f1 có người nhận là con trỏ)
Thông báo lỗi này chính là trình biên dịch nói với bạn rõ ràng: A
không thực hiện I
vì tập hợp phương thức của nó thiếu f1()
, chỉ có trên kiểu con trỏ.
💬 Kết luận
Thiết kế này là sự thiên tài của Go. Giao diện không phải về danh tính hay kế thừa (một thứ gì đó là gì). Chúng tập trung vào hành vi (một thứ gì đó có thể làm gì).
Điều này cho phép bạn viết các hàm như wantsI
hoàn toàn không liên quan đến các cấu trúc dữ liệu cụ thể như A
. Bạn có thể viết 100 struct khác nhau, và miễn là tất cả chúng đều có phương thức f1()
, hàm wantsI
của bạn có thể chấp nhận tất cả mà không bao giờ biết chúng tồn tại. Đây là chìa khóa để viết phần mềm linh hoạt, có thể kiểm tra và bảo trì.
📜 Tài liệu tham khảo
- Giao diện được triển khai ngầm định - go.dev
- Tập hợp phương thức - go.dev