`any` thật ra tốn những gì
Khi Swift bắt đầu đòi phải viết any trước một kiểu protocol, phản ứng đầu tiên của tôi là thấy nó
rườm rà. any Shape chẳng nói thêm gì so với Shape, mà giờ lại có thêm một từ khóa chắn đường.
Tôi đã sai, và cái từ khóa ấy đang làm đúng việc mà nó được thêm vào để làm: khiến một cái giá vốn vô hình trở nên nhìn thấy được.
Hai cách nhận một protocol
Hai dòng này trông giống nhau và biên dịch ra hai thứ hoàn toàn khác nhau.
func render(_ shape: any Shape) { … } // existential
func render(_ shape: some Shape) { … } // generic (tham số mờ)
Bản generic được chuyên biệt hóa. Tại chỗ gọi, trình biên dịch biết kiểu cụ thể, sinh code riêng cho
nó, và nội tuyến được mọi thứ. Gọi shape.area() là một lời gọi trực tiếp.
Bản existential nhận vào một cái hộp. any Shape là một giá trị thuộc kiểu chưa biết, nên trình biên
dịch phải lưu nó một cách đồng nhất và điều phối qua một witness table. Nó không thể biết lúc biên
dịch rằng area() nào sẽ chạy.
Cái hộp trông như thế nào
Một existential được bố trí thành ba từ máy lưu trực tiếp, cộng thêm các con trỏ tới metadata của kiểu và tới protocol witness table — tổng cộng năm từ máy trên nền tảng 64-bit.
MemoryLayout<any Shape>.size // 40
MemoryLayout<Circle>.size // ví dụ 8
Nếu giá trị cụ thể vừa trong ba từ máy, nó nằm ngay trong hộp. Nếu không vừa, Swift cấp phát nó trên heap và cái hộp giữ một con trỏ.
Đó mới là phần đáng quan tâm. Một struct có bốn thuộc tính Double thì nặng 32 byte, không vừa
trong 24, nên đưa một cái vào any sẽ cấp phát. Trong một vòng lặp, đó là một lần malloc cho mỗi
phần tử:
struct Point3D { var x, y, z, w: Double } // 32 byte
let shapes: [any Shape] = points.map { $0 } // cấp phát heap cho từng phần tử
Cảnh báo
Ngưỡng ba từ máy là một chi tiết cài đặt chứ không phải một lời bảo đảm, và nó không phải thứ để
bạn thiết kế bám theo. Điều rút ra hữu ích là đóng hộp một kiểu giá trị lớn thì không miễn phí,
và [any Protocol] trên một struct to là một hình dạng hiệu năng khác hẳn [KiểuCụThể].
Khi nào existential là đúng
Tôi muốn nói rõ rằng any không phải một mùi code xấu. Nó là công cụ duy nhất cho một tập hợp thật
sự không đồng nhất:
var shapes: [any Shape] = [Circle(radius: 3), Square(side: 4), Triangle(...)]
Generic không diễn đạt được điều đó. [some Shape] nghĩa là “một mảng của đúng một kiểu cụ thể mà
tôi không gọi tên”, một thứ hoàn toàn khác — mọi phần tử phải cùng kiểu.
Existential cũng là câu trả lời đúng cho những thuộc tính lưu trữ cần thay đổi lúc chạy, cho các sổ đăng ký plugin, và cho mọi ranh giới API nơi kiểu cụ thể được cố ý giấu khỏi bên gọi.
Khi nào generic là câu trả lời tốt hơn
Trường hợp tôi thấy viết sai nhiều nhất là một tham số hàm:
// đóng hộp ở mọi lời gọi, điều phối qua witness table
func totalArea(of shapes: [any Shape]) -> Double {
shapes.reduce(0) { $0 + $1.area() }
}
// chuyên biệt hóa theo từng chỗ gọi, nội tuyến được
func totalArea(of shapes: [some Shape]) -> Double {
shapes.reduce(0) { $0 + $1.area() }
}
Cả hai đọc giống hệt nhau. Bản thứ hai nhanh hơn và không bắt bên gọi phải đóng hộp gì cả, và thứ duy nhất nó từ bỏ là trường hợp không đồng nhất — thứ mà phần lớn các hàm vốn không cần.
Quy tắc của tôi bây giờ: bắt đầu bằng some, và chuyển sang any khi trình biên dịch nói cho bạn
biết rằng thật sự cần đến nó. Cách đó đảo ngược thói quen cũ, nơi chỉ mỗi cái tên protocol đã nghĩa
là existential mà chẳng ai để ý.
Cái bẫy tự tuân thủ
Lý do còn lại để hiểu cái hộp là existential không tuân thủ chính protocol của nó, và thông báo lỗi khi chuyện này cắn thì nổi tiếng là vô dụng.
protocol Shape {
func scaled(by factor: Double) -> Self
}
let shape: any Shape = Circle(radius: 3)
let bigger = shape.scaled(by: 2) // lỗi, trước Swift 5.7
Self trong một yêu cầu nghĩa là “kiểu cụ thể”, và cái hộp thì không biết đó là kiểu gì lúc biên
dịch. Swift đời mới xử lý được nhiều trường hợp như vậy bằng cách mở existential một cách ngầm định,
nên đúng ví dụ này giờ chạy được — nhưng giới hạn nền tảng vẫn còn đó, và các protocol có yêu cầu
associatedtype vẫn không dùng được như existential thuần nếu chưa ràng buộc các associated type.
Cách chữa gần như luôn là nhận vào một generic:
func doubled<S: Shape>(_ shape: S) -> S {
shape.scaled(by: 2)
}
Giờ Self là S, đã biết tại chỗ gọi, và cả vấn đề biến mất.
Tôi đã thật sự đổi những gì
Sau khi rà một codebase với ý này trong đầu, các thay đổi đều nhỏ và mang tính máy móc:
- Các tham số hàm đi từ
any Psangsome Pở mọi chỗ mà thân hàm không cần tính không đồng nhất. Khoảng bốn mươi chỗ, và không chỗ nào đòi hỏi thay đổi gì thêm. - Các thuộc tính lưu trữ giữ nguyên
any P— đó đúng là việc của chúng. - Hai mảng
[any P]trên các struct lớn được đổi thành mảng của một enum cụ thể, vì chỉ có ba kiểu khả dĩ và một enum diễn đạt điều đó tốt hơn một protocol.
Không thay đổi nào trong số đó hiện ra trên bản đo hiệu năng. Đó là cái kết thành thật: phần lợi ở
đây là có thật nhưng nhỏ, và giá trị thực sự của any là nó khiến tôi để ý xem những trừu tượng nào
đang thật sự gánh việc và những cái nào chỉ là thói quen.