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.