Привет! Сегодня хочу поделиться, как мне кажется, очень полезной информацией о такой важной теме, как Opaque types vs Existensial Types vs Generics — что это такое, в чём разница и что выбрать. Я действительно считаю эту тему важной, поскольку это мощнейшие инструменты, которые нам даёт swift, чтобы сделать код более гибким, поддерживаемым, универсальным и без лишнего дублирования. Погнали.
Opaque types
Заглянем в официальную документацию:
Hide implementation details about a value’s type.
Скрыть детали реализации типа значения.
A function or method that returns an opaque type hides its return value’s type information. Instead of providing a concrete type as the function’s return type, the return value is described in terms of the protocols it supports. Opaque types preserve type identity — the compiler has access to the type information, but clients of the module don’t.
Функция или метод, возвращающие непрозрачный тип, скрывают информацию о типе возвращаемого значения. Вместо указания конкретного типа в качестве возвращаемого типа функции возвращаемое значение описывается через протоколы, которым оно соответствует. Непрозрачные типы сохраняют идентичность типа: компилятор имеет доступ к информации о типе, но клиенты модуля — нет.
Вроде всё понятно, но немного размыто. Пойдём в код.
protocol Animal { func makeSound() -> String}final class Cat: Animal { func makeSound() -> String { "Meow" }}final class Dog: Animal { func makeSound() -> String { "Woof" }}func makePet() -> some Animal { // This is opaque type Cat()}let pet = makePet()print(pet.makeSound()) // Meow
Первое, на что стоит обратить внимание, — это ключевое слово some. Именно оно указывает, что перед нами opaque type.
Давайте посмотрим, какой тип имеет переменная pet.
3… 2… 1…
Скрытый текст

Сигнатура функции не показывает, что она возвращает Cat. Мы знаем только, что она возвращает некоторый тип, соответствующий протоколу Animal. Однако для компилятора этот конкретный тип фиксирован — в данном случае это всегда Cat. Другими словами, конкретный тип скрыт от вызывающего кода.
func makePet() -> some Animal { if Bool.random() { Cat() } else { Dog() }}
Это скомпилируется? Не-a!

Почему так? Потому что это означает, что существует только один конкретный тип. Мы не можем возвращать разные типы в зависимости от условия.
Что мы узнали об opaque types?
-
Они обозначаются ключевым словом
some. -
Они представляют один конкретный тип.
-
Этот тип скрыт от вызывающего кода.
-
Компилятору известен конкретный тип.
Existential Types
Когда вы видите тип, записанный как any Protocol, вы имеете дело с existential type, также известным как boxed protocol type.
Как можно предположить по записи any Protocol, existential type может хранить значение любого типа, который соответствует этому протоколу.
Давайте снова вернёмся к нашему примеру с котиками и собачками, но на этот раз будем работать с existential type.
protocol Animal { func makeSound() -> String}final class Cat: Animal { func makeSound() -> String { "Meow" }}final class Dog: Animal { func makeSound() -> String { "Woof" }}func makePet() -> any Animal { if Bool.random() { Cat() } else { Dog() }}let pet = makePet()print(pet.makeSound())
Первое, что мы замечаем: компилятор больше не ругается на условие. С existential type мы можем возвращать разные типы в зависимости от условия. На самом деле это один из основных случаев, когда existential types особенно полезны.
В зависимости от результата Bool.random() мы можем увидеть разный вывод в консоли:
let pet = makePet()print(pet.makeSound()) // Meow or Woof
А что насчет типа константы pet?

Как вы можете видеть, pet имеет тип any Animal.
Позже, когда будем сравнивать existential types и opaque types, мы подробнее разберём, что это означает.
А теперь возникает интересный вопрос: где вообще хранится existential value?
На этапе компиляции нам известен статический тип переменной:
let pet: any Animal
Однако заранее мы не знаем конкретный тип значения внутри. При одном вызове это может быть Cat, при другом — Dog или любой другой тип, соответствующий Animal.
Тогда как Swift может хранить такое значение? Разные типы могут иметь разные размеры и разную структуру в памяти.
Ответ — existential container.
Existential container
any Animal имеет контейнер фиксированного размера, о котором компилятору известно заранее. Для existential, соответствующего одному протоколу, например any Animal, этот контейнер концептуально можно представить как пять машинных слов:
┌──────────────────────────────┐│ Inline value buffer — 3 words│├──────────────────────────────┤│ Type metadata — 1 word │├──────────────────────────────┤│ Witness table — 1 word │└──────────────────────────────┘
На 64-битной платформе одно машинное слово обычно занимает 8 байт, поэтому размер контейнера составляет примерно: 5 × 8 = 40 bytes
Первые три машинных слова образуют inline value buffer.
Если значение достаточно маленькое — обычно, если оно помещается в три машинных слова, — Swift может хранить его непосредственно внутри existential container. Это называется inline storage.
Если значение слишком большое или требования к его выравниванию не позволяют поместить его в inline buffer, Swift вместо этого хранит указатель на отдельно выделенную память. Это называется out-of-line storage.
Таким образом, размер самого значения может различаться, но размер existential container остаётся фиксированным.
Оставшиеся два машинных слова содержат информацию, которая нужна Swift для работы со значением:
-
Type metadata сообщает Swift, какой конкретный тип хранится внутри контейнера во время выполнения.
-
Protocol witness table содержит реализации требований протокола для этого конкретного типа.
Например, когда мы вызываем:
pet.makeSound()
Swift использует witness table из existential container, чтобы найти правильную реализацию makeSound() для конкретного типа, который сейчас хранится внутри.
В нашем примере Cat и Dog — классы. Экземпляр класса уже представлен ссылкой, поэтому existential container хранит ссылку на объект. Эта ссылка помещается в inline buffer, а сам контейнер также хранит metadata для Catили Dog и witness table, описывающую их соответствие протоколу Animal.
Именно поэтому Swift может работать с any Animal, даже если конкретный тип значения заранее неизвестен.
Ещё один важный момент об existential types: вы теряете доступ к методам и свойствам конкретного типа. Это называется type erasure.
Хотя первый элемент фактически является Cat, компилятор видит только any Animal, поэтому доступны лишь требования, объявленные в Animal.
protocol Animal { func makeSound() -> String}final class Cat: Animal { func makeSound() -> String { "Meow" } func purr() { print("Purr") }}let animals: [any Animal] = [Cat(), Dog()]animals[0].makeSound() // OKanimals[0].purr() // Error
Чтобы вызвать purr(), нам нужно выполнить downcast значения обратно к Cat:
if let cat = animals[0] as? Cat { cat.purr() // Purr}
Итак, что мы узнали об existential types?
-
Они обозначаются ключевым словом
any. -
Они могут хранить значение любого конкретного типа, соответствующего протоколу.
-
Разные ветви кода могут возвращать разные типы, соответствующие протоколу, в зависимости от условия.
-
Existential values хранятся в existential container.
-
На этапе компиляции известен статический тип — например,
any Animal. -
Конкретный тип, хранящийся внутри контейнера, определяется во время выполнения и может меняться.
Что выбрать?
Давайте обратимся к документации, чтобы понять, когда и что использовать, потому что там действительно очень прозрачно описан ответ на этот важный вопрос.
Помните, что existential скрывает конкретный тип и предоставляет доступ только к интерфейсу протокола. Конкретный тип известен во время выполнения, но статически неизвестен в месте использования.
Предпочитайте generics или
some Protocol, когда вам нужно сохранить идентичность конкретного типа и при этом не требуется хранить разнородные значения. Opaque types сохраняют идентичность типа, а boxed protocol types — нет.Относитесь к производительности как к компромиссу, а не как к запрету: existential types могут использовать existential container, требовать выделения памяти в heap, reference counting и witness-table dispatch.
Оригинальный текст
-
Be aware that an existential hides the concrete type and exposes only the protocol interface. The concrete type is known at runtime, not statically at the usage site.
-
Prefer generics or some Protocol when you need to preserve concrete type identity and do not need heterogeneous storage. Opaque types preserve identity; boxed protocol types do not.
-
Treat performance as a trade-off, not a ban: existentials can involve an existential container, possible heap allocation, reference counting, and witness-table dispatch.
Любопытные вопросы
-
Давайте посмотрим на массив значений типа
any Animal.
protocol Animal { func makeSound() -> String}final class Cat: Animal { func makeSound() -> String { "Meow" }}final class Dog: Animal { func makeSound() -> String { "Woof" }}let animals: [any Animal] = [ Cat(), Dog(), Cat(), Dog()]for animal in animals { print(animal.makeSound())}// Meow// Woof// Meow// Woof
Прежде всего, это полностью валидный код. В таком массиве можно хранить «разные типы», потому что все они соответствуют протоколу Animal.
Как вы думаете, какой тип будет у этого массива?

2. Можно ли сравнивать associated types в existential types?
Чтобы ответить на этот вопрос, давайте посмотрим, что скажет Xcode.
protocol Animal { associatedtype Food: Equatable var favoriteFood: Food { get }}struct Cat: Animal { let favoriteFood = "Fish"}struct Dog: Animal { let favoriteFood = "Bone"}
Теперь давайте попробуем сравнить любимую еду двух existential values:
func haveSameFavoriteFood( _ lhs: any Animal, _ rhs: any Animal ) -> Bool { lhs.favoriteFood == rhs.favoriteFood // Error }
Почему это не работает?
lhs и rhs могут содержать совершенно разные конкретные типы. Более того, их типы Food тоже могут отличаться:
struct Cat: Animal { let favoriteFood = "Fish" // String}struct Parrot: Animal { let favoriteFood = 42 // Int}
Компилятор не может гарантировать, что lhs.favoriteFood и rhs.favoriteFood имеют один и тот же тип. А сравнить String и Int с помощью == невозможно.
Хорошо… возможно, нам нужны конкретные типы? А что насчёт some Animal?
Давайте попробуем:
func haveSameFavoriteFood( _ lhs: some Animal, _ rhs: some Animal ) -> Bool { lhs.favoriteFood == rhs.favoriteFood // Error }
Нет, всё ещё не работает.
Но почему? У lhs есть конкретный тип, и у rhs тоже есть конкретный тип.
Проблема в том, что нет гарантии, что lhs и rhs имеют один и тот же конкретный тип.
Каждый параметр some Animal вводит свой собственный скрытый тип. Концептуально эта функция выглядит скорее так:
func haveSameFavoriteFood<A: Animal, B: Animal>( _ lhs: A, _ rhs: B ) -> Bool { // A and B can be different types }
Поэтому lhs.Food и rhs.Food всё ещё могут быть разными типами.
Что же мы будем использовать вместо этого?
Generics!
func haveSameFavoriteFood<A: Animal, B: Animal>( _ lhs: A, _ rhs: B ) -> Bool where A.Food == B.Food { lhs.favoriteFood == rhs.favoriteFood }
Теперь компилятор знает, что Food у обоих животных имеет один и тот же конкретный тип.
Ключевая мысль: any Animal стирает информацию об associated type, а generics сохраняют её и позволяют связать типы с помощью constraints.
ссылка на оригинал статьи https://habr.com/ru/articles/1051862/