Đây là phiên bản blog của các bài nói chuyện của tôi tại Google Open Source Live:
https://youtu.be/nr8EpUO9jhw?si=jlWTapr6NM6isLgt&embedable=true
và GopherCon 2021:
https://youtu.be/Pae9EeCdy8?si=M-87Eisb2nU1qmJ&embedable=true
Phiên bản Go 1.18 bổ sung một tính năng ngôn ngữ mới quan trọng: hỗ trợ lập trình generic. Trong bài viết này, tôi sẽ không mô tả generics là gì hoặc cách sử dụng chúng. Bài viết này nói về khi nào nên sử dụng generics trong mã Go và khi nào không nên sử dụng chúng.
Để làm rõ, tôi sẽ cung cấp các hướng dẫn chung, không phải quy tắc cứng nhắc. Hãy sử dụng phán đoán của riêng bạn. Nhưng nếu bạn không chắc chắn, tôi khuyên bạn nên sử dụng các hướng dẫn được thảo luận ở đây.
Hãy bắt đầu với một hướng dẫn chung cho việc lập trình Go: viết chương trình Go bằng cách viết mã, không phải bằng cách định nghĩa các kiểu. Khi nói đến generics, nếu bạn bắt đầu viết chương trình bằng cách định nghĩa các ràng buộc tham số kiểu, có lẽ bạn đang đi sai hướng. Hãy bắt đầu bằng cách viết các hàm. Việc thêm tham số kiểu sau này sẽ dễ dàng khi rõ ràng rằng chúng sẽ hữu ích.
Điều đó nói lên, hãy xem xét các trường hợp mà tham số kiểu có thể hữu ích.
Một trường hợp là khi viết các hàm hoạt động trên các kiểu container đặc biệt được định nghĩa bởi ngôn ngữ: slices, maps và channels. Nếu một hàm có tham số với những kiểu đó, và mã hàm không đưa ra bất kỳ giả định cụ thể nào về các kiểu phần tử, thì có thể hữu ích khi sử dụng tham số kiểu.
Ví dụ, đây là một hàm trả về một slice của tất cả các khóa trong một map của bất kỳ kiểu nào:
// MapKeys returns a slice of all the keys in m. // The keys are not returned in any particular order. func MapKeys[Key comparable, Val any](m map[Key]Val) []Key { s := make([]Key, 0, len(m)) for k := range m { s = append(s, k) } return s }
Mã này không giả định bất cứ điều gì về kiểu khóa của map, và nó không sử dụng kiểu giá trị của map chút nào. Nó hoạt động cho bất kỳ kiểu map nào. Điều đó làm cho nó trở thành một ứng viên tốt để sử dụng tham số kiểu.
Giải pháp thay thế cho tham số kiểu đối với loại hàm này thường là sử dụng reflection, nhưng đó là một mô hình lập trình khó khăn hơn, không được kiểm tra kiểu tĩnh tại thời điểm biên dịch, và thường chậm hơn khi chạy.
Một trường hợp khác mà tham số kiểu có thể hữu ích là cho các cấu trúc dữ liệu đa năng. Một cấu trúc dữ liệu đa năng là thứ giống như một slice hoặc map, nhưng không được tích hợp sẵn trong ngôn ngữ, chẳng hạn như danh sách liên kết hoặc cây nhị phân.
Hiện nay, các chương trình cần cấu trúc dữ liệu như vậy thường làm một trong hai điều: viết chúng với một kiểu phần tử cụ thể, hoặc sử dụng kiểu interface. Thay thế một kiểu phần tử cụ thể bằng tham số kiểu có thể tạo ra một cấu trúc dữ liệu tổng quát hơn có thể được sử dụng trong các phần khác của chương trình, hoặc bởi các chương trình khác. Thay thế một kiểu interface bằng tham số kiểu có thể cho phép dữ liệu được lưu trữ hiệu quả hơn, tiết kiệm tài nguyên bộ nhớ; nó cũng có thể cho phép mã tránh các khẳng định kiểu và được kiểm tra kiểu đầy đủ tại thời điểm biên dịch.
Ví dụ, đây là một phần của cấu trúc dữ liệu cây nhị phân có thể trông như thế nào khi sử dụng tham số kiểu:
// Tree is a binary tree. type Tree[T any] struct { cmp func(T, T) int root *node[T] } // A node in a Tree. type node[T any] struct { left, right *node[T] val T } // find returns a pointer to the node containing val, // or, if val is not present, a pointer to where it // would be placed if added. func (bt *Tree[T]) find(val T) **node[T] { pl := &bt.root for *pl != nil { switch cmp := bt.cmp(val, (*pl).val); { case cmp < 0: pl = &(*pl).left case cmp > 0: pl = &(*pl).right default: return pl } } return pl } // Insert inserts val into bt if not already there, // and reports whether it was inserted. func (bt *Tree[T]) Insert(val T) bool { pl := bt.find(val) if *pl != nil { return false } *pl = &node[T]{val: val} return true }
Mỗi nút trong cây chứa một giá trị của tham số kiểu T. Khi cây được khởi tạo với một đối số kiểu cụ thể, các giá trị của kiểu đó sẽ được lưu trữ trực tiếp trong các nút. Chúng sẽ không được lưu trữ dưới dạng kiểu interface.
Đây là một cách sử dụng hợp lý của tham số kiểu vì cấu trúc dữ liệu Tree, bao gồm cả mã trong các phương thức, phần lớn độc lập với kiểu phần tử T.
Cấu trúc dữ liệu Tree cần biết cách so sánh các giá trị của kiểu phần tử T; nó sử dụng một hàm so sánh được truyền vào cho mục đích đó. Bạn có thể thấy điều này ở dòng thứ tư của phương thức find, trong lệnh gọi đến bt.cmp. Ngoài ra, tham số kiểu không quan trọng chút nào.
Ví dụ Tree minh họa một hướng dẫn chung khác: khi bạn cần thứ gì đó như một hàm so sánh, hãy ưu tiên một hàm hơn một phương thức.
Chúng ta có thể đã định nghĩa kiểu Tree sao cho kiểu phần tử được yêu cầu phải có phương thức Compare hoặc Less. Điều này sẽ được thực hiện bằng cách viết một ràng buộc yêu cầu phương thức, có nghĩa là bất kỳ đối số kiểu nào được sử dụng để khởi tạo kiểu Tree sẽ cần có phương thức đó.
Hệ quả sẽ là bất kỳ ai muốn sử dụng Tree với một kiểu dữ liệu đơn giản như int sẽ phải định nghĩa kiểu số nguyên của riêng họ và viết phương thức so sánh của riêng họ. Nếu chúng ta định nghĩa Tree để nhận một hàm so sánh, như trong mã được hiển thị ở trên, thì việc truyền vào hàm mong muốn sẽ dễ dàng. Viết hàm so sánh đó cũng dễ dàng như viết một phương thức.
Nếu kiểu phần tử Tree tình cờ đã có phương thức Compare, thì chúng ta có thể đơn giản sử dụng biểu thức phương thức như ElementType.Compare làm hàm so sánh.
Nói cách khác, việc chuyển đổi một phương thức thành một hàm đơn giản hơn nhiều so với việc thêm một phương thức vào một kiểu. Vì vậy, đối với các kiểu dữ liệu đa năng, hãy ưu tiên một hàm thay vì viết một ràng buộc yêu cầu một phương thức.
Một trường hợp khác mà tham số kiểu có thể hữu ích là khi các kiểu khác nhau cần triển khai một số phương thức chung, và các triển khai cho các kiểu khác nhau đều giống nhau.
Ví dụ, hãy xem xét sort.Interface của thư viện chuẩn. Nó yêu cầu một kiểu triển khai ba phương thức: Len, Swap và Less.
Đây là một ví dụ về một kiểu generic SliceFn triển khai sort.Interface cho bất kỳ kiểu slice nào:
// SliceFn implements sort.Interface for a slice of T. type SliceFn[T any] struct { s []T less func(T, T) bool } func (s SliceFn[T]) Len() int { return len(s.s) } func (s SliceFn[T]) Swap(i, j int) { s.s[i], s.s[j] = s.s[j], s.s[i] } func (s SliceFn[T]) Less(i, j int) bool { return s.less(s.s[i], s.s[j]) }
Đối với bất kỳ kiểu slice nào, các phương thức Len và Swap đều giống hệt nhau. Phương thức Less yêu cầu một phép so sánh, đó là phần Fn trong tên SliceFn. Giống như ví dụ Tree trước đó, chúng ta sẽ truyền vào một hàm khi tạo một SliceFn.
Đây là cách sử dụng SliceFn để sắp xếp bất kỳ slice nào bằng cách sử dụng hàm so sánh:
// SortFn sorts s in place using a comparison function. func SortFn[T any](s []T, less func(T, T) bool) { sort.Sort(SliceFn[T]{s, less}) }
Điều này tương tự như hàm thư viện chuẩn sort.Slice, nhưng hàm so sánh được viết bằng cách sử dụng các giá trị thay vì chỉ số slice.
Sử dụng tham số kiểu cho loại mã này là phù hợp vì các phương thức trông giống hệt nhau cho tất cả các kiểu slice.
(Tôi nên đề cập rằng Go 1.19–không phải 1.18–rất có thể sẽ bao gồm một hàm


