Trang chủ

`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 P sang some 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.