Интуитивное объяснение нового типа результата непрозрачности

В этом году на WWDC Apple представила SwiftUI - совершенно новый подход к декларативному созданию пользовательских интерфейсов в Xcode. Чтобы помочь вам начать работу с новым фреймворком, они опубликовали несколько красиво оформленных руководств.

💡 Несколько советов заранее:

Если вы застряли на первом уроке в разделе 1, вероятно, по одной (или по обеим) из следующих причин:

  1. Для правильной работы всех функций SwiftUI необходима бета-версия новой macOS 10.15 (Catalina). (В частности, холст не будет работать без него.) Последней бета-версии Xcode 11 недостаточно. ⚠️
  2. Не отчаивайтесь, если вы не можете понять, как «создать новый проект Xcode с помощью шаблона приложения SwiftUI». Этого нет в выборе шаблона. Это флажок, который необходимо установить на следующем шаге. Оказывается, вы действительно можете прокрутить страницу руководства и получить все необходимые пошаговые инструкции ниже. 😱

Что за "some" вещь?

Следующее, что вы заметите, - это новое ключевое слово some, которое появилось в Swift 5.1. Поначалу это может сбивать с толку. В конце концов, вычисляемое свойство всегда возвращает какое-то значение определенного типа, верно ?!

Очевидно, эта какая-то вещь делает некоторую вещь, и это что-то связано с концепцией, называемой непрозрачные типы. Добавление ключевого слова some перед типом возвращаемого значения указывает, что тип возвращаемого значения является непрозрачным.

Что такое «непрозрачный»?

Непрозрачные типы часто называют обратными универсальными типами. Но это сложно понять без дополнительных объяснений. Итак, давайте попробуем сначала пройти шаг за шагом и вспомнить дженерики.

Дженерики

Универсальные типы - это в основном заполнители, которые можно использовать, когда вы хотите объявить функции, которые работают с несколькими типами. Хорошим примером является функция max в Swift, которая возвращает максимум из двух входных параметров неизвестного типа T.

func max<T>(_ x: T, _ y: T) -> T

В этом объявлении говорится, что оба входных параметра должны иметь один и тот же тип T,, но какой именно тип остается неизвестным. К сожалению, без какой-либо информации о типе параметров мы мало что можем сделать с ними в теле функции. Вот почему мы используем протоколы для описания (или ограничения) того, что универсальный тип может делать, не раскрывая его фактический тип.

func max<T>(_ x: T, _ y: T) -> T where T: Comparable

Здесь используется протокол Comparable, который требует, чтобы каждый соответствующий ему тип реализовывал оператор сравнения:

public protocol Comparable : Equatable {
   static func < (lhs: Self, rhs: Self) -> Bool
   // ...
}

С общим ограничением T: Comparable мы все еще не знаем, что именно такое T, но знаем, что можем сравнить два экземпляра T с оператором ›. Таким образом, мы можем реализовать функцию max.

func max<T>(_ x: T, _ y: T) -> T where T: Comparable {
    if y > x {
        return y
    } else {
        return x
    }
}

Универсальные типы скрывают фактический тип значения внутри реализации функции. Извне, где вы вызываете функцию, вы всегда знаете тип значений, которые вы передаете в качестве параметров, и вы знаете тип возвращаемого значения. (Даже если вы этого не сделаете, потому что запутались, компилятор Swift знает и определяет тип в соответствии с сигнатурой функции. 😉)

Например, мы можем передать два целых числа в функцию max.

let x: Int = 1
let y: Int = 701
let maximum = max(x, y) // type: Int

В результате тип возвращаемого значения maximum также имеет тип Int в соответствии с сигнатурой функции max<T>(_ x: T, y: T) -> T.

Непрозрачные типы

Непрозрачные типы противоположны тем, что внешний мир не знает точно тип возвращаемого значения функции. Но внутри реализации функции вы точно знаете, с какими конкретными типами имеете дело.

Непрозрачный тип похож на яйца киндер-сюрприз.

Поскольку я недавно узнал, что эти леденцы запрещены в США, я быстро опишу, как они выглядят, хотя изображение уже должно произвести хорошее впечатление.

Снаружи все яйца Киндер-сюрприз одинаковы и обладают некоторыми общими свойствами. Каждое яйцо

  • завернутый в алюминиевую фольгу красного и белого цвета с оттиском логотипа,
  • имеет шоколадную яичную скорлупу, состоящую из коричневого и белого слоя и
  • содержит маленький желтый пластиковый контейнер с сюрпризом внутри

Однако содержимое этого желтого пластикового контейнера варьируется от яйца к яйцу. Таким образом, мы можем классифицировать яйца по типу их содержимого. Может быть

  • маленькая головоломка,
  • миниатюрная игрушка или
  • прочная пластиковая фигура.

Давайте смоделируем это в коде! Во-первых, мы определяем протокол для всех яиц-сюрпризов следующим образом:

protocol SurpriseEgg {
    associatedType ContentType
    var foil: Foil { get }
    var chocolateShell: Chocolate { get }
    var container: Container { get }
    var content: ContentType { get }
}

Затем мы создаем конкретные типы, реализующие этот протокол:

struct PuzzleEgg: SurpriseEgg {
    var content: Puzzle
    // ...
}
struct ToyEgg: SurpriseEgg {
    var content: MiniatureToy
    // ...
}
struct FigureEgg: SurpriseEgg {
    var content: PlasticFigure
    // ...
}

Обратите внимание, как связанный тип ContentType, указанный в протоколе, абстрагирует конкретный тип содержимого. Заменяя этот заполнитель фактическим типом контента, мы заполняем пробел и делаем тип конкретным.

А теперь представьте, что вы владелец магазина, который продает яйца «Киндер-сюрприз». Каждую неделю вы покупаете три поддона напрямую у производителя и продаете их своим клиентам. Вам все равно, что внутри этих яиц, и вы действительно не можете сказать, потому что вы не можете заглянуть внутрь. В терминологии Swift: вы не знаете конкретного типа яиц. Если вы выберете конкретное яйцо с поддонов, оно может иметь любой из следующих конкретных типов:

PuzzleEgg
ToyEgg
FigureEgg

Что вы действительно знаете, так это то, что это определенно яйцо Kinder Surprise, т. Е. Оно соответствует протоколу SurpriseEgg. Итак, если у вас есть функция для выбора случайного яйца с поддона, в качестве возвращаемого типа должен быть этот протокол:

func pickRandomEgg() -> SurpriseEgg 

ℹ️ Примечание. Это не будет компилироваться в Swift , и я вернусь к этому через минуту. Просто представьте на мгновение, что он будет компилироваться.

Теперь предположим, что во втором сценарии производитель отсортировал яйца по их конкретному типу, и все яйца на одном поддоне будут одного типа. Вы по-прежнему не знаете конкретный тип каждого поддона, но вы знаете, что, пока вы собираете яйца с одного поддона, их конкретный тип всегда будет одним и тем же.

Именно это делает ключевое слово some в коде:

func pickEggFromPallet1() -> some SurpriseEgg

Добавление этого ключевого слова превращает возвращаемый тип в непрозрачный тип, что означает, что вы и компилятор знаете, что эта функция будет всегда возвращать только один конкретный тип - вы просто никогда не знаешь какой!

И в этом разница между возвратом типа протокола (SurpriseEgg) и непрозрачного типа (some SurpriseEgg) из функции: если бы мы использовали только протокол, функция могла бы (возможно) каждый раз возвращать другой конкретный тип вы называете это - иногда вы получите яйцо с пластиковой фигуркой, иногда с головоломкой, иногда с миниатюрной игрушкой . Если вместо этого мы используем непрозрачный тип результата, мы обеспечиваем, чтобы функция всегда возвращала тот же конкретный тип, например он всегда может возвращать загадку, непрозрачный тип просто скрывает идентичность конкретного типа от вызывающего.

Что помогает? 🤔

Что ж, если мы не знаем, какой именно тип нашей ценности, какой смысл знать, что сейчас она «конкретная»? Разумеется, мы не можем использовать какие-либо функции или свойства, специфичные для конкретного типа!

Да, это правда. Однако есть две тонкие, но важные вещи, которые внезапно становятся возможными с непрозрачными типами:

1. Возврат типов протокола со связанными типами

Теперь мы можем возвращать «общие» типы протоколов из функции (или вычисляемого свойства), а точнее: p протоколы с a ssociated t ypes (также известные как PAT).

Если вы какое-то время работали с протоколами в Swift, скорее всего, вы столкнулись с этой ошибкой компилятора:

Протокол SurpriseEgg может использоваться только как общее ограничение, потому что он имеет требования типа Self или связанные с ним.

Я полагаю, что 95% всех разработчиков Swift не понимают, что это означает. 🤯 К счастью, есть хороший пост в Stackoverflow и еще один в блоге Пола Хадсона, в котором подробно объясняется, что происходит, но это все еще непросто понять. Swift не позволяет нам использовать протоколы со связанными типами в качестве возвращаемых типов.

Мы можем использовать «обычные» протоколы, например, мы могли бы определить Chocolate протокол следующим образом:

protocol Chocolate {
    var weight: Float { get }
    func eat()
}

и использовать этот протокол как возвращаемый тип функции,

func giveMeChocolate() -> Chocolate {
    return RitterSport()
}

(при условии, что тип RitterSport соответствует протоколу Chocolate). Здесь мы создаем экземпляр конкретного типа RitterSport, но поскольку мы указали протокол Chocolate в качестве возвращаемого типа функции, эта информация - более технически выраженная: идентичность типа шоколада - теряется в тот момент, когда значение возвращается.

Однако мы не можем сделать это с протоколом, который имеет связанные типы. Давайте еще раз посмотрим на пример сверху:

protocol SurpriseEgg {
    associatedType ContentType
    var foil: Foil { get }
    var chocolateShell: Chocolate { get }
    var container: Container { get }
    var content: ContentType { get }
}

Мы не можем использовать этот протокол как возвращаемый тип. Как упоминалось ранее, следующее объявление не будет компилироваться и не выдаст нашу «любимую» ошибку:

func pickEgg() -> SurpriseEgg           // 💥 Error

Почему? Поскольку компилятор не может вывести связанный тип из этого определения, и тип возвращаемого значения будет неполным. Однако, когда мы добавляем ключевое слово some и вместо этого возвращаем непрозрачный тип, функция компилируется:

func pickEgg() -> some SurpriseEgg     // ✅ Compiles

Это потому, что с непрозрачными типами компилятор запоминает конкретный тип возвращаемого значения под капотом. Он знает личность конкретного типа. В результате вы можете использовать два (или более) непрозрачных значения, которые были возвращены той же функцией, в другой универсальной функции, для которой требуются два или более входных параметра того же типа. . Об этом весь следующий раздел.

2. Составьте непрозрачные возвращаемые значения с помощью общих функций

Если бы мы хотели поменять местами два яйца-сюрприза на паллете одним и тем же конкретным типом, мы могли бы определить общую функцию, подобную этой:

func swapEggsOfSameType<T: SurpriseEgg>(first: T, second: T)

Вызов этой функции требует не только того, чтобы параметры first и second имели тип, реализующий протокол SurpriseEgg, но этот тип должен быть одинаковым для обоих параметров. Таким образом, несмотря на то, что FigureEgg и PuzzleEgg оба соответствуют протоколу SurpriseEgg, следующий код не будет компилироваться:

let figureEgg = FigureEgg()              // type: FigureEgg
let puzzleEgg = PuzzleEgg()              // type: PuzzleEgg
swapEggsOfSameType(figureEgg, puzzleEgg) // 💥 Error: different type

Даже если бы мы могли использовать протокол SurpriseEgg как возвращаемый тип pickEgg() функции и оба яйца, набранные как SurpriseEgg, а не как их конкретный тип, все равно было бы невозможно вызвать функцию swapEggsOfSameType с этими яйцами. в качестве параметров именно потому, что они могут не быть одного (конкретного) типа.

func pickEgg() -> SurpriseEgg { ... }   //  1️⃣💥 Error
let oneEgg     = pickEgg()              // type: SurpriseEgg
let anotherEgg = pickEgg()              // type: SurpriseEgg
swapEggsOfSameType(oneEgg, anotherEgg)  //  2️⃣💥 Error

Например, oneEgg может быть FigureEgg, а anotherEgg может быть PuzzleEgg. Вот почему компилятор не может разрешить вызов функции в последней строке и выдает ошибку.

Если вместо этого мы используем непрозрачный тип, идентичность возвращаемого типа сохраняется под капотом:

func pickEgg() -> some SurpriseEgg { ... }
let oneEgg     = pickEgg()   // type: SurpriseEgg of specific type X
let anotherEgg = pickEgg()   // type: SurpriseEgg of the same type X
swapEggsOfSameType(oneEgg, anotherEgg)   // ✅ compiles

Компилятор знает, что oneEgg имеет тот же конкретный тип, что и anotherEgg, поэтому он позволяет нам передавать их в качестве параметров функции swapEggsOfSameType.

Типичный пример, когда это имеет первостепенное значение, - это когда вы хотите сравнить два возвращаемых значения друг с другом. Вы можете сравнивать только типы, соответствующие протоколу Equatable, который требует определения оператора ==:

protocol Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool
} 

Self - это заполнитель, похожий на связанный тип, и всегда относится к конкретному типу, который реализует протокол. Вернемся к нашему Chocolate примеру сверху и сделаем его Equatable:

protocol Chocolate: Equatable {
    var weight: Float { get }
    func eat()
}

Если теперь мы получим тягу и вызовем функцию giveMeChocolate дважды,

func giveMeChocolate() -> Chocolate {
    return RitterSport()
}
let someChocolate     = giveMeChocolate()
let someMoreChocolate = giveMeChocolate()

оба возвращаемых значения шоколада someChocolate и someMoreChocolate равноправны, но мы все равно не можем сравнивать их, потому что они могут относиться к разному конкретному типу:

if someChocolate == someMoreChocolate {          // 💥 Error
    print("Give me something different! 🍫")
}

Опять же, если вместо этого мы вернем из функции giveMeChocolate непрозрачный тип, это сравнение внезапно станет возможным:

func giveMeChocolate() -> some Chocolate {
    return RitterSport()
}
let someChocolate     = giveMeChocolate()
let someMoreChocolate = giveMeChocolate()
if someChocolate == someMoreChocolate {          // ✅ compiles
    print("Give me something different! 🍫")
}

Непрозрачные типы в SwiftUI

Теперь, когда мы все жаждем шоколада и яиц Киндер-сюрприз, остается только один вопрос: почему Apple использует непрозрачный тип результата для свойства body своих представлений во всех Руководства по SwiftUI ?

Во-первых, это не требование. Теоретически вы можете всегда возвращать конкретный тип вашего представления вместо использования непрозрачного типа some View. Следующий код будет компилироваться:

struct ContentView: View {
    var body: Text {
        Text("Hello World")
    }
}

Однако сила SwiftUI заключается в его компонуемости. Обычно вы используете представления стека и другие контейнеры, такие как списки, для составления макета из других представлений. Все эти контейнеры являются универсальными типами, и их конкретный тип изменяется каждый раз, когда вы добавляете, удаляете, перемещаете или заменяете представление. Например, давайте посмотрим на следующее простое настраиваемое представление:

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello World")
            Image(systemName: "video.fill")
        }
    }
}

Предполагается, что тип VStack, содержащего текст и изображение

VStack<TupleView<(Text, Image)>>

Если мы хотим изменить макет нашего представления, добавив второй текст прямо под существующим текстом, тип представления стека изменится следующим образом:

VStack<TupleView<(Text, Text, Image)>>

Поэтому, если мы будем использовать конкретный тип представления, которое мы возвращаем из нашего свойства body, нам придется настраивать этот тип вручную каждый раз, когда мы меняем макет. В приведенном выше примере мы перейдем от

var body: VStack<TupleView<(Text, Image)>> { ... }

to

var body: VStack<TupleView<(Text, Text, Image)>> { ... }

что уже может раздражать. Но, скорее всего, мы будем использовать несколько контейнеров. Возможно, мы захотим вложить стек в другой стек и, возможно, этот другой стек в список. Внезапно конкретный тип нашего тела выглядит так: 🤯

List<Never, TupleView<(HStack<TupleView<(VStack<TupleView<(Text, Text)>>, Text)>>, HStack<TupleView<(VStack<TupleView<(Text, Text)>>, Text)>>)>>

Вы, наверное, согласитесь, что в этом нет никакого смысла. Обновление этого типа при изменении одного представления внутри иерархии невероятно утомительно. Вдобавок шрифт вообще не читается. Наш разум не может обработать это в разумные сроки, и все, что нас действительно волнует, это то, что body вернет немного View.

Именно поэтому Apple использует непрозрачный тип представления в своих руководствах и почему вы всегда должны делать то же самое:

var body: some View { ... }

Спасибо за прочтение! ✌️

Уф, у вас получилось! 👏
Если у вас есть вопросы или исправления, оставьте комментарий ниже. Я всегда анонсирую новые статьи через свою учетную запись Twitter @DerHildebrand.