Ràng buộc, chuyên biệt hoá, và cái giá của từng thứ
Generic cho phép viết một thuật toán một lần mà vẫn giữ trọn thông tin kiểu. Khác với cơ chế xoá kiểu của Java hay macro của C, generic trong Swift được kiểm tra kiểu đúng một lần ngay tại chỗ định nghĩa, rồi có thể được chuyên biệt hoá cho từng kiểu cụ thể tại nơi gọi — bạn chỉ phải bảo trì một bản cài đặt, mà mã máy sinh ra thường chặt chẽ như thể bạn viết tay riêng cho kiểu đó.
func firstIndex<C: Collection>(of element: C.Element, in collection: C) -> C.Index?
where C.Element: Equatable
{
var index = collection.startIndex
while index != collection.endIndex {
if collection[index] == element { return index }
collection.formIndex(after: &index)
}
return nil
}
Hãy đọc các ràng buộc như một giao diện. C: Collection nói có sẵn những thao tác nào; mệnh đề
where nói các phần tử so sánh được với nhau. Thứ gì không được nêu ra thì không dùng được — và đó
chính là điểm mấu chốt, vì nó cũng là thứ cho phép trình biên dịch kiểm tra thân hàm một lần thay vì
kiểm tra lại ở từng nơi gọi.
Hai cách viết gần như giống nhau:
extension Array where Element: Numeric {
func total() -> Element { reduce(0, +) }
}
extension Collection where Element: Numeric {
func total() -> Element { reduce(0, +) }
}
Bản thứ hai chạy được trên Array, Set, ArraySlice, Range, String.UTF8View và mọi
collection mà bất kỳ ai viết ra. Hãy ràng buộc vào protocol ít cụ thể nhất còn đủ cho những gì thân
hàm thật sự làm. Nó không tốn gì thêm mà nhân rộng phạm vi sử dụng của đoạn code.
Một kiểu generic chỉ tuân thủ protocol khi tham số kiểu của nó tuân thủ:
struct Pair<T> {
let first: T
let second: T
}
extension Pair: Equatable where T: Equatable {}
extension Pair: Hashable where T: Hashable {}
extension Pair: Codable where T: Codable {}
Pair<Int> giờ là Hashable và làm khoá từ điển được; Pair<(Int) -> Void> thì không, và trình
biên dịch sẽ báo ngay tại chỗ dùng sai thay vì hỏng sâu bên trong một bảng băm. Array cũng vận
hành đúng như vậy — [Int] là Equatable, [() -> Void] thì không.
Trả về some Collection giấu kiểu cụ thể đi mà vẫn giữ tính tĩnh:
func evens(upTo limit: Int) -> some Collection<Int> {
stride(from: 0, to: limit, by: 2).lazy.map { $0 }
}
Bên gọi có trọn vẹn hiệu năng và dùng được mọi phương thức của Collection, nhưng không thể phụ
thuộc vào việc giá trị trả về là LazyMapSequence<StrideTo<Int>, Int> — nhờ vậy bạn đổi cài đặt mà
không làm gãy ai. Đó chính xác là thứ any Collection<Int> không hứa được, và cũng chính xác là thứ
một kiểu trả về cụ thể không giấu được.
Mẹo
Một phép thử hữu ích: nếu đổi map thành compactMap bên trong mà bên gọi gãy, thì kiểu trả về
đang quá cụ thể. Nếu một vòng lặp nóng lộ ra lời gọi động mà bạn không ngờ tới, thì nó đang quá
existential.
Trước Swift 5.9, “một hàm nhận số lượng tuỳ ý các đối số khác kiểu nhau” nghĩa là viết cùng một
overload bảy lần — đó là lý do thư viện chuẩn từng chỉ có zip cho hai sequence và không có gì cho
ba. Parameter pack giải quyết chuyện đó:
struct Tuple<each T> {
let values: (repeat each T)
init(_ values: repeat each T) {
self.values = (repeat each values)
}
}
func allEqual<each T: Equatable>(_ left: repeat each T, _ right: repeat each T) -> Bool {
for (l, r) in repeat (each left, each right) where l != r { return false }
return true
}
each T là một gói kiểu; repeat khai triển trên gói đó. Bạn sẽ không viết chúng thường xuyên,
nhưng sẽ đọc chúng trong code thư viện, và chúng giải thích vì sao một số chữ ký trong thư viện
chuẩn tự dưng mọc thêm repeat each.
Chuyên biệt hoá là lý do generic trong Swift chạy nhanh, và nó có giá: trình biên dịch sinh ra một
bản sao riêng của thân hàm cho từng kiểu cụ thể mà nó chuyên biệt hoá. Một module dùng generic dày
đặc có thể phình kích thước nhị phân thấy rõ và làm chậm quá trình build. Khi một hàm generic vừa
lớn, vừa hiếm khi nóng, lại vừa dùng với nhiều kiểu, việc cố ý đẩy nó qua existential — any Shape
thay vì some Shape — đánh đổi một chút tốc độ lấy rất nhiều dung lượng code. Hãy đo trước khi cho
rằng mình cần cái nào.