Swift был разработан для преодоления разрыва между нулевыми затратами абстракций C++ и динамической гибкостью Objective-C. Ранние версии сильно полагались на наследование классов и таблицы виртуальных методов, но введение программирования, ориентированного на протоколы, в Swift 2.0, потребовало более тонкой модели вызова. Команда компиляторов выбрала гибридный подход, где требования протокола (методы, объявленные в теле протокола) используют таблицы свидетелей для полиморфизма времени выполнения, в то время как методы, определенные исключительно в расширениях, разрешаются статически. Это решение основывается на необходимости поддерживать ретроактивное моделирование и типы значений, не жертвуя характеристиками производительности статического вызова.
Разработчики часто предполагают, что предоставление реализации метода в расширении протокола создает "умолчательное" поведение, которое поддающиеся типы могут переопределить полиморфно. Однако Swift выполняет вызовы методов расширения статически на основе типа ссылки во время компиляции, а не типа экземпляра во время выполнения. При использовании экзистенциальных контейнеров (any Protocol) тип компиляции — это сам экзистенциальный контейнер, что приводит к тому, что вызовы разрешаются в реализацию расширения, независимо от каких-либо переопределений в конкретных типах. Это создает подрывные ошибки, когда пользовательские реализации в подклассах или структурах тихо игнорируются в гетерогенных коллекциях.
Для обеспечения истинного динамического полиморфизма метод должен быть объявлен как требование протокола в самом определении протокола. Это заставляет компилятор выделять запись таблицы свидетелей для метода, что позволяет времени выполнения находить правильную реализацию через таблицу свидетелей типа. Для алгоритмов, критически зависящих от производительности, где полиморфизм не нужен, методы должны оставаться в расширениях, чтобы позволить компилятору выполнять инлайнинг или другие статические оптимизации. Swift 5.6+ ввел явный синтаксис с ключевым словом any, чтобы сделать устранение типов экзистенциальной более заметным, служа напоминанием о том, что информация о типе теряется, и статический вызов по умолчанию ссылается на расширение.
protocol Drawable { func draw() // Требование: динамический вызов через таблицу свидетелей } extension Drawable { func draw() { print("По умолчанию") } func render() { print("Статический рендеринг") } // Расширение: только статический вызов } struct Circle: Drawable { func draw() { print("Круг") } func render() { print("Рендеринг круга") } } let shape: any Drawable = Circle() shape.draw() // Печатает "Круг" (динамический вызов) shape.render() // Печатает "Статический рендеринг" (статический вызов - игнорирует версию Circle!)
Мы разрабатывали движок векторной графики, где различные фигуры соответствовали протоколу RenderCommand. Сначала мы добавили метод generatePreview(), исключительно в рамках расширения протокола, чтобы предоставить стандартный растровый миниатюру для всех фигур. Конкретные типы, такие как BezierCurve и Polygon, реализовали свои собственные оптимизированные методы generatePreview(), которые использовали их специфические геометрические свойства для четкого рендеринга. Когда мы хранили эти фигуры в массиве [any RenderCommand] для обработки конвейера рендеринга, мы обнаружили, что вызов generatePreview() для каждого элемента производил одно и то же размытое изображение по умолчанию, а не свои собственные качественные превью.
Мы рассмотрели три различных решения. Во-первых, мы могли бы перенести generatePreview() в определение протокола RenderCommand как формальное требование. Этот подход гарантировал бы динамический вызов через таблицу свидетелей, обеспечивая правильное разрешение метода во время выполнения. Однако это заставило бы каждый тип фигуры явно объявить метод в своем соответствии, хотя мы могли бы уменьшить объем шаблонного кода, оставив реализацию по умолчанию в расширении для типов, которым не нужна настройка.
Во-вторых, мы могли бы реорганизовать наш конвейер для использования обобщений с сигнатурой функции, такой как func process<T: RenderCommand>(commands: [T]), вместо использования экзистенциального [any RenderCommand]. Это сохранило бы статический вызов на правильную реализацию, так как Swift монопризирует обобщения на этапе компиляции, сохраняя информацию о типе. Недостатком было то, что мы больше не могли хранить гетерогенные типы фигур (сочетание BezierCurve и Polygon) в одном массиве без реализации обертки устранения типов, что значительно увеличивало бы сложность кода.
В-третьих, мы могли бы реализовать паттерн Посетитель, чтобы вручную направлять вызовы методов к соответствующему конкретному типу. Это избежало бы полной модификации определения протокола, при этом достигнув полиморфного поведения. Тем не менее, это решение подразумевало собой значительный объем шаблонного кода и создавало нагрузку по обслуживанию каждый раз, когда новые типы фигур добавлялись в систему.
В конечном итоге мы выбрали первое решение, потому что протокол имел внутреннее значение для нашего модуля, и ясность полиморфного поведения была важна для корректности движка рендеринга. Добавление требования не оказало заметного влияния на размер нашего двоичного файла, а небольшая накладная работа по направлению через таблицу свидетелей была незаметна по сравнению с вычислениями рендеринга. После реализации этого изменения генерация превью правильно использовала оптимизированную реализацию каждой фигуры, устранив визуальные артефакты в пользовательском интерфейсе.
Почему подкласс не может переопределить метод, который был определен только в расширении протокола?
Когда метод определяется исключительно в расширении протокола и не объявляется в самом протоколе, Swift не выделяет запись таблицы свидетелей для него. Вызов разрешается статически на этапе компиляции на основе типа ссылки. Если класс соответствует протоколу и определяет метод с той же сигнатурой, он создает новый, не связанный метод, который затеняет метод расширения, а не переопределяет его. Это означает, что при доступе через экзистенциальный протокол (any Protocol) всегда вызывается реализация расширения протокола, игнорируя версию класса. Для достижения полиморфного поведения метод должен быть объявлен в определении протокола, чтобы стать требованием с динамическим вызовом.
Как использование some (неявные результатные типы) вместо any влияет на вызов методов расширения протокола?
С some Drawable конкретный тип известен на этапе компиляции благодаря мономорфизации обобщений в Swift. При вызове метода расширения на неявном типе компилятор может статически вызвать реализацию конкретного типа, потому что информация о типе сохраняется за кулисами, даже если скрыта от вызывающего. В отличие от этого, any Drawable является экзистенциальным контейнером, который стирает конкретный тип, заставляя компилятор использовать реализацию по умолчанию расширения для методов, не являющихся требованиями. Ключевое различие в том, что some сохраняет статический полиморфизм, позволяя компилятору инлайнить или напрямую связываться с правильным методом, тогда как any заставляет искать vtable во времени выполнения только для требований и по умолчанию ссылается на расширение для всего остального.
Каков размер двоичного файла и влияние на производительность преобразования метода расширения в требование протокола?
Преобразование метода расширения в требование протокола добавляет запись в таблицу свидетелей протокола, увеличивая размер двоичного файла примерно на 8 байт за соответствие в архитектуре 64 бита. Каждый соответствующий тип теперь должен заполнить этот слот в своей таблице свидетелей, добавляя небольшую накладную память на тип. С точки зрения производительности требования несут накладные расходы на косвенные вызовы через таблицу свидетелей (одна дополнительная разыменование указателя и переход), тогда как методы расширения могут быть инлайнены или вызваны напрямую без накладных расходов. Тем не менее, потеря инлайнинга для требований часто компенсируется предсказателем ветвлений ЦП, и преимущество правильного полиморфного поведения обычно превышает затраты в масштабе наносекунд на косвенный вызов в большинстве приложений.