Hình dung tuyển người theo hai cách. Cách của Java: ứng viên phải cầm sẵn tấm thẻ hội viên ghi rõ "tôi là Noi" (implements Noi) — khai trước, mang theo mình. Cách của Go: nhà tuyển dụng viết một bản mô tả công việc ("tôi cần người biết Noi()"), và bất kỳ ai làm được đúng việc đó là đủ tư cách, chẳng cần thẻ nào. Đây là ý tưởng tôi thấy hay nhất của Go, và cũng là thứ khác Java nhiều nhất.
Không có implements
type Noi interface{ Noi() string }
type Cho struct{ Ten string }
func (c Cho) Noi() string { return c.Ten + ": gâu" }
var n Noi = Cho{"Milu"} -> Milu: gâu
Cho không khai rằng nó cài đặt Noi. Nó chỉ tình cờ có method đúng tên và đúng chữ ký, và thế là đủ — làm được việc thì nhận, không cần trình thẻ. Trình biên dịch kiểm lúc gán.
Nghe như chuyện nhỏ, nhưng nó đảo ngược quan hệ phụ thuộc.
Interface thuộc về phía người dùng
Ở Java, interface do người viết thư viện định nghĩa, và người dùng phải nhận lấy — tấm thẻ do nghiệp đoàn phát. Muốn mock một lớp của thư viện bên thứ ba mà tác giả không tách interface? Không làm được.
Ở Go, bạn khai interface, cho chính nhu cầu của bạn — nhà tuyển dụng tự viết bản mô tả công việc:
// package của bạn
type LuuTru interface {
Luu(ctx context.Context, k string, v []byte) error
}
Bất kỳ kiểu nào có method đó — của bạn, của thư viện, của một SDK bạn không sửa được — đều dùng được. Tác giả thư viện không cần biết interface của bạn tồn tại, y như ứng viên chẳng cần biết bản mô tả công việc của bạn ra đời.
Đây là câu trả lời trực tiếp cho lời khuyên "đừng mock thứ bạn không sở hữu" ở sê-ri Java: trong Go, bạn bọc nó bằng interface của mình và vấn đề biến mất.
Quy tắc cộng đồng hay nhắc: "Accept interfaces, return structs" — hàm nhận interface (để linh hoạt), trả về struct cụ thể (để người gọi thấy đủ thông tin). Tuyển theo năng lực, nhưng giao ra một con người cụ thể.
Interface nên nhỏ
Thư viện chuẩn là ví dụ mẫu:
error : 1 method
io.Reader : 1 method
io.Writer : 1 method
fmt.Stringer : 1 method
sort.Interface: 3 method
io.Reader chỉ có Read(p []byte) (n int, err error). Và vì nó nhỏ, mọi thứ đều thoả mãn nó: tệp, kết nối mạng, bộ đệm trong bộ nhớ, tệp nén, thân HTTP request. Bạn viết một hàm nhận io.Reader và nó làm việc được với tất cả. Bản mô tả công việc càng ngắn, càng nhiều người hợp.
Câu châm ngôn của cộng đồng: "The bigger the interface, the weaker the abstraction."
Trong thực tế, interface một hoặc hai method là phổ biến nhất. Thấy interface mười method thì gần như chắc chắn nó đang mô tả một lớp cụ thể chứ không phải một hành vi — một bản mô tả dài mười yêu cầu thật ra đang tả đúng một con người.
Nhúng interface
type Doc interface{ Doc() string }
type Ghi interface{ Ghi(string) }
type DocGhi interface { Doc; Ghi }
Ghép interface nhỏ thành lớn mà không khai lại method. Thư viện chuẩn dùng nhiều: io.ReadWriter, io.ReadCloser, io.ReadWriteSeeker đều dựng theo kiểu này.
Kiểm tra lúc biên dịch
Vì không có implements, bạn mất một thứ: trình biên dịch không nhắc khi bạn định cài đặt interface mà viết sai tên method. Mã chỉ hỏng ở chỗ gán, có thể ở package khác.
Cách chuẩn để lấy lại:
var _ Noi = (*Meo)(nil)
Dòng này không tạo biến nào (tên là _), không tốn gì lúc chạy, nhưng báo lỗi biên dịch ngay tại tệp định nghĩa nếu *Meo không thoả mãn Noi — như đính một phiếu kiểm tra năng lực ngay vào hồ sơ, để biết ngay lúc tuyển chứ không phải lúc đã vào việc.
Đặt nó ngay dưới định nghĩa kiểu. Bạn sẽ thấy dòng này trong hầu hết thư viện Go nghiêm túc.
Khi nào đừng dùng interface
Người đến từ Java hay tạo interface cho mọi thứ theo phản xạ. Trong Go, đó là mã thừa.
Đừng tạo interface khi chỉ có một cài đặt và bạn không cần thay thế nó trong test. Trả về struct. Cần interface sau này thì thêm — không phá vỡ gì cả, vì thoả mãn là ngầm định.
Đừng tạo interface "cho đủ tầng". UserService + UserServiceImpl là mẫu Java, và trong Go nó chỉ làm mã dài ra.
Đừng gói interface vào cùng package với cài đặt trừ khi thật sự cần. Đặt nó ở nơi dùng.
Cái giá phải trả
Interface không miễn phí:
Lời gọi qua interface không nội tuyến hoá được trong phần lớn trường hợp, nên nó chặn một loạt tối ưu của trình biên dịch.
Giá trị nằm trong interface thường phải lên heap, tạo thêm việc cho bộ thu gom rác.
Kích thước 16 byte — hai con trỏ, như tôi đo ở bài đo cho loạt này.
Với mã thường thì không đáng bận tâm. Với vòng lặp nóng hàng triệu lần thì có, và bài 52 sẽ đo.
Muốn kiểm một kiểu có "làm được việc" nào đó không, đính thử phiếu kiểm tra ngay dưới nó trong ba mươi giây:
var _ fmt.Stringer = (*KieuCuaBan)(nil)
Nếu biên dịch được, kiểu đó đã có String() và fmt.Println đang gọi nó. Nếu không, bạn vừa biết mình đang in ra dạng mặc định {trường1 trường2} — và có lẽ nên viết String().
Mẫu số chung
Cái Go làm ở đây có tên hẳn hoi trong lý thuyết kiểu: định kiểu theo cấu trúc (structural typing), đối lập với định kiểu theo danh định (nominal typing) của Java/C#/Kotlin, nơi quan hệ phải được khai tên bằng implements. Và nó cắt theo hai trục độc lập, không phải một.
- Theo cấu trúc, kiểm lúc biên dịch: Go — và cả TypeScript, nơi một object khớp interface chỉ cần đúng hình dạng, không cần
implements. - Theo cấu trúc, kiểm lúc chạy: "duck typing" của Python/Ruby — hợp lệ cho tới khi gọi tới mới nổ.
- Theo danh định, kiểm lúc biên dịch: Java/C# — phải cầm thẻ.
Biết một ngôn ngữ nằm ở ô nào là đoán được cả độ linh hoạt lẫn kiểu hỏng của nó. Và phần thưởng lớn nhất của "interface do người dùng khai" là biến Nguyên lý Đảo ngược Phụ thuộc thành gần như miễn phí: bên dùng tự nêu hợp đồng nó cần, nên bọc được cả mã bạn không sở hữu — đúng chỗ "mock thứ không sở hữu" tan biến. Cái giá: mất tín hiệu tường minh "tôi cố ý cài đặt X" (lấy lại bằng var _ I = ...), và có thể khớp tình cờ — một kiểu thoả mãn interface do trùng hợp, thứ mà định kiểu danh định chặn được.
Sợi chỉ chung đáng mang theo: đặt interface ở nơi nó được dùng, giữ nó gọn đúng một hai method mà bên dùng thật sự gọi, và để việc "có hợp không" được phát hiện ra — người cung cấp không nên phải đoán trước mọi trừu tượng mà người gọi nó sau này sẽ cần.
Ngày mai: any, type assertion và type switch — khi nào cần thoát khỏi hệ thống kiểu.
Bài tập làm thử
Bài 1 (đọc hiểu). Đoạn mã sau có biên dịch và chạy đúng được không, dù Cho không hề khai báo "tôi cài đặt Noi" ở bất kỳ đâu?
type Noi interface{ Noi() string }
type Cho struct{ Ten string }
func (c Cho) Noi() string { return c.Ten + ": gâu" }
func main() {
var n Noi = Cho{"Milu"}
fmt.Println(n.Noi())
}
Đáp án
Có, chạy đúng và in ra Milu: gâu. Go dùng định kiểu theo cấu trúc (structural typing): Cho không cần khai implements Noi như Java — nó chỉ cần có method đúng tên (Noi) và đúng chữ ký (() string) là tự động thoả mãn interface Noi. Trình biên dịch kiểm điều này ngay tại chỗ gán (var n Noi = Cho{"Milu"}), không cần khai báo tường minh nào ở nơi định nghĩa Cho.
Bài 2 (sửa lỗi/thêm bảo vệ). Bạn định nghĩa Meo để cài đặt interface Noi, nhưng gõ nhầm tên method thành noi (chữ thường) thay vì Noi. Lỗi này không bị bắt ngay tại nơi định nghĩa Meo mà chỉ lộ ra ở một package khác, lúc gán vào biến Noi. Viết một dòng mã đặt ngay dưới định nghĩa Meo để bắt lỗi này ngay tại chỗ định nghĩa.
type Meo struct{ Ten string }
func (m Meo) noi() string { return m.Ten } // gõ nhầm, phải là Noi
Đáp án
var _ Noi = (*Meo)(nil)
Dòng này không tạo biến nào (tên _), không tốn gì lúc chạy, nhưng báo lỗi biên dịch ngay tại tệp định nghĩa nếu *Meo (hoặc Meo, tuỳ receiver) không thoả mãn Noi — ví dụ ở đây sẽ báo lỗi vì Meo chỉ có noi() chứ không có Noi(). Đặt dòng này ngay dưới định nghĩa kiểu là thói quen chuẩn của thư viện Go nghiêm túc, thay thế cho việc thiếu cơ chế implements tường minh.
Bài 3 (vận dụng thực tế). Bạn muốn viết một hàm dùng một SDK bên thứ ba (bạn không sở hữu mã nguồn và tác giả không tách interface nào), nhưng cần mock nó trong test đơn vị. Theo nguyên tắc "interface thuộc về phía người dùng" trong bài, hãy mô tả cách giải quyết (không cần code đầy đủ, chỉ cần nêu đúng cách tiếp cận).
Đáp án
Tự khai một interface nhỏ trong package của mình, chỉ liệt kê đúng (những) method thực sự cần dùng từ SDK đó — ví dụ type LuuTru interface { Luu(ctx context.Context, k string, v []byte) error }. Vì Go thoả mãn interface ngầm định, kiểu cụ thể của SDK (mà bạn không sở hữu và không sửa được) sẽ tự động thoả mãn interface này miễn nó có đúng method đó — tác giả SDK không cần biết interface của bạn tồn tại. Trong test, chỉ cần viết một struct giả (mock) nhỏ cài đặt đúng interface đó để thay thế. Đây chính là câu trả lời của Go cho lời khuyên "đừng mock thứ bạn không sở hữu" — vấn đề biến mất vì bạn bọc SDK bằng interface của chính mình.
Bài 4 (bẫy/đánh đổi). Bài viết nêu ba trường hợp không nên tạo interface trong Go. Cho tình huống: dự án có UserService (interface) và UserServiceImpl (struct duy nhất cài đặt nó), và interface này chỉ dùng trong đúng một nơi, không hề dùng để test hay thay thế. Theo bài, đây có phải thiết kế tốt trong Go không? Giải thích.
Đáp án
Không phải thiết kế tốt trong Go. Đây đúng là ví dụ "tạo interface cho đủ tầng" mà bài viết chỉ ra là mẫu của Java, và trong Go nó chỉ làm mã dài ra không cần thiết. Quy tắc: đừng tạo interface khi chỉ có một cài đặt và không cần thay thế nó trong test — nên trả về struct cụ thể (UserServiceImpl) trực tiếp. Cần interface sau này (ví dụ khi có cài đặt thứ hai, hoặc cần mock trong test) thì thêm — việc này không phá vỡ gì cả vì thoả mãn interface là ngầm định.
Bài 5 (đọc hiểu — interface nhỏ). Vì sao io.Reader — chỉ có đúng một method Read(p []byte) (n int, err error) — lại được coi là một thiết kế mẫu mực theo câu châm ngôn "The bigger the interface, the weaker the abstraction"? Liên hệ với việc "interface mười method gần như chắc chắn đang mô tả một lớp cụ thể".
Đáp án
Vì interface càng nhỏ (càng ít method phải cài đặt), càng nhiều kiểu khác nhau tự nhiên thoả mãn nó mà không cần chủ ý — tệp, kết nối mạng, bộ đệm trong bộ nhớ, tệp nén, thân HTTP request đều thoả mãn io.Reader chỉ với một method Read. Một hàm nhận io.Reader làm việc được với tất cả các nguồn dữ liệu đó. Ngược lại, một interface có mười method đòi hỏi một kiểu phải cài đặt đúng cả mười hành vi cụ thể đó — điều này hiếm khi xảy ra "tình cờ", nên thực chất interface đó đang mô tả chính xác một lớp cụ thể (concrete class) chứ không còn là một trừu tượng hoá chung cho nhiều kiểu khác nhau nữa.