Однажды в тайге

Эта таинственная история рассказывает о том, как два брата акробата программиста Чук и Гек начали делать свой проект на SwiftUI и столкнулись с неведомым! Как Optional притворялся View и к чему это привело.

Ничто не предвещало...

Однажды Чук и Гек решили сделать свой пет-проект и чтобы дело шло быстрее, поделили обязанности - Гек делал кастомные вьюхи, а Чук собирал из них экраны.
Как то понадобилась одна простая штука: вью, состоящая из двух элементов, расположенных один над другим, второй - опционален. Если второго нет, то рамка вокруг вьюхи должна быть зеленая, а если есть - синяя.
Посидел Гек, подумал и выдал такое, с использованием дженериков:

struct UltraView<T1: View, T2: View>: View {
    let title: T1
    let description: T2? // вьюхи может и не быть же, верно?

  	// базовый конструктор
    init(title: T1, description: T2) {
        self.title = title
        self.description = description
    }

  	// сокращенный вариант конструктора, когда второго элемента нет
    init(title: T1) where T2 == EmptyView {
        self.title = title
        self.description = nil
    }

    var body: some View {
        let color = description == nil ? Color.blue : Color.green

        VStack {
            title

            if let description = description {
                description
            }
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color) // маленький хелпер для рисования рамки
    }
}

Протестировал:

@ViewBuilder func geksTest() -> some View {
    UltraView(title: Text("чук рулит"))
    UltraView(title: Text("гек норм"), description: Text("потому что брат").font(.footnote))
}
Результат теста Гека
Результат теста Гека

С чувством выполненного долга, отдал код брату, пошел на кухню ставить самовар. Сидит, кайфует. И тут слышит, Чук зовет:
- "Эй, брат, фигня какая-то, ты какулю сделал!"

Гек откладывает сушку и идет к брату и видит:

- "Ну", - говорит - "зачем ты пустую вью передал? .... Хотя, где тогда спейсинг? А ну, покажи-ка, брат, код!"

- "У меня", - говорит Чук, - "тут возникла потребность часто отображать надпись с пояснением и я сделал функцию-хелпер." - и показывает код:

struct Helper {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView(
          title: Text(title), 
          description: description.map { Text($0).font(.footnote) })
      		//                       ^ просто трансформируем опционал в Text
    }
}

@ViewBuilder func chuksTest() -> some View {
    Helper.ultraView(title: "чук рулит")
    Helper.ultraView(title: "гек норм", description: "потому что брат")
}

Гек схватился за сердце:
- "Как ты это сделал? Это же противозаконно! Как ты засунул Optional в дженерик, который требует View ?"

После двух чашек успокоительного для Гека братья обнаружили, что внезапно:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension Optional : View where Wrapped : View {
    public typealias Body = Never
}

После этого возник вопрос...

Что делать?

Очевидно, что поведение заложено разработчиками SwiftUI и этого не изменить. Дамп выдает примерно такое:

Optional<Optional<Text>>:
	- some: Optional<Text>:
  	- none

"Но что же делать"? - рассуждал Гек. - "Изначальная концепция разваливается из-за того, что мы можем передать nil и он влезет по констрейтам дженерика, а моя вьюха нужна и должна предсказуемо работать. Просто проверить, что это опционал нельзя - к какому типу Optional приводить тип, если он дженерик, а мы только знаем, что T2 может быть опционалом? Придется искать обходные пути."

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

Сказано - сделано:

struct UltraView<T1: View, T2: View>: View {
    let title: T1
    let description: (() -> T2)?

    init(title: T1, description: (() -> T2)? = nil) {
        self.title = title
        self.description = description
    }

  	// этот конструктор - для сокращенных записей, 
  	// когда не опциональная вьюха и можно не оборачивать в скобки
    init(title: T1, description: @escaping @autoclosure () -> T2) {
        self.title = title
        self.description = description
    }

    init(title: T1) where T2 == EmptyView? {
        self.title = title
        self.description = nil
    }

    var body: some View {
        let color = description == nil ? Color.blue : Color.green

        VStack {
            title

            if let description = description {
                description()
            }
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }
}

struct Helper {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView(title: Text(title),
                   description: description
                    .map { str in { Text(str).font(.footnote) } })
    }
}

Работает! Однако, как говорится, есть нюансы - если кложура вернет в свою очередь опционал, нам это не особо поможет :-(

Братья стали копать и Гек выдал такой вариант:

protocol OptionalType {
    var isNil: Bool { get }
}

extension Optional: OptionalType {
    var isNil: Bool {
        if self == nil {
            return true
        } else {
            // рекурсивно ищем, потому что вложенность может
            return (self! as? OptionalType)?.isNil ?? false
        }
    }
}

После рефакторинга получилось вот так:

struct UltraView<T1: View, T2: View>: View {
    let title: T1
    let description: T2

    init(title: T1, description: T2) {
        self.title = title
        self.description = description
    }

    init(title: T1) where T2 == EmptyView? {
        self.title = title
        self.description = nil
    }

    var body: some View {
      	// штош...
        let color = !hasDescription ? Color.blue : Color.green

        VStack{
            title
          
          	// бессмысленно проверять на nil, по понятным причинам.
          	// однако вью и так не отрисуется и места не займет и спейсинг не появится
            description 
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }

  	// немного черной магии
    var hasDescription: Bool {
        guard let opt = description as? OptionalType else { return true }

        return !opt.isNil
    }
}

Проверили - работает! Ура! Даже если получаются вложенные вьюхи. Однако, что-то смущало Гека... И задумчивый, он пошел спать.

Гештальт Гека

Ночью Гек не мог уснуть. Ему не давало покоя решение - грязновато как-то. Этот экстеншен полностью переопределяет функционал опционала по всему приложению. Гек прокрался на кухню, открыл ноутбук и начал свой гештальтовый R&D.

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

...

Проснулся Чук от неразборчивого бомотанья Гека. Он встал, пошел на кухню и увидел брата, уставившегося красными глазами в экран и приговаривающего:
- "Как?! Как это работает?!"
- "Брат, ты чего?" - спросил Чук.

Гек молча показал на экран, где был выделен код:

var hasDescription: Bool {
		!(description is Never?) // WHY?!
}

Время собирать камни

Выводы из сей басни таковы, мой маленький друг:

  1. Писать свои вью с учетом опциональности входящих параметров (тоже вью) бесполезно. Максимум, что получится - вложенные опционалы

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

  3. Можно использовать свифтовое поведение - приведение к Optional<Never>. Однако, оно тоже разворачивает только первый уровень вложенных опционалов в случае сложной структуры вьюх.

  4. Можно реализовать некрасивое, но рабочее всегда решение - определять, что самая вложенная вью - не опционал. (см. решение Чука с OptionalType)

Вообще, в зависимости от целей, придется решать - нужно определять только входной параметр как nil или всю потенциальную иерархию.

Пример почти синтетический, но встретился в реальном проекте. В конце мы остановились пока на варианте 3 - как говорится "swift only solution", но с ремаркой в коде, что черт его знает, не изменится ли это в будущем. Вероятно, прийдем к варианту 4.

Однако до сих пор мы маемся вопросом, почему каст к Optional<Never> работает с любым типом и успешен только в случае nil. Мы пришли к выводу, что это какая-то особенность компилятора. Никаких материалов навскидку не нашли. Однако, если кто-то сможет подсказать, где про это почитать, буду признательна.

Полный пример для Swift Playground
//: A UIKit based Playground for presenting user interface

import SwiftUI
import PlaygroundSupport

// helper

extension View {
    func border(_ color: Color) -> some View {
        background(Color.white)
            .padding(1)
            .background(color)
    }
}

// base sample

struct UltraView1<T1: View, T2: View>: View {
    let title: T1
    let description: T2?

    init(title: T1, description: T2) {
        self.title = title
        self.description = description
    }

    init(title: T1) where T2 == EmptyView {
        self.title = title
        self.description = nil
    }

    var body: some View {
        let color = description == nil ? Color.blue : Color.green

        VStack {
            title

            if let description = description {
                description
            }
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }
}

struct Helper1 {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView1(title: Text(title), description: description.map { Text($0).font(.footnote) })
    }
}

// fix

// closures

struct UltraView2<T1: View, T2: View>: View {
    let title: T1
    let description: (() -> T2)?

    init(title: T1, description: (() -> T2)? = nil) {
        self.title = title
        self.description = description
    }

    init(title: T1, description: @escaping @autoclosure () -> T2) {
        self.title = title
        self.description = description
    }

    init(title: T1) where T2 == EmptyView? {
        self.title = title
        self.description = nil
    }

    var body: some View {
        let color = description == nil ? Color.blue : Color.green

        VStack {
            title

            if let description = description {
                description()
            }
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }
}

struct Helper2 {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView2(title: Text(title),
                   description: description
                    .map { str in { Text(str).font(.footnote) } })
    }
}

// use protocol

protocol OptionalType {
    var isNil: Bool { get }
}

extension Optional: OptionalType {
    var isNil: Bool {
        if self == nil {
            return true
        } else {
            // recursive
            return (self! as? OptionalType)?.isNil ?? false
        }
    }
}

struct UltraView3<T1: View, T2: View>: View {
    let title: T1
    let description: T2

    init(title: T1, description: T2) {
        self.title = title
        self.description = description
    }

    init(title: T1) where T2 == EmptyView? {
        self.title = title
        self.description = nil
    }

    var body: some View {
        let color = !hasDescription ? Color.blue : Color.green

        VStack{
            title
            description
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }

    var hasDescription: Bool {
        guard let opt = description as? OptionalType else { return true }

        return !opt.isNil
    }
}

struct Helper3 {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView3(title: Text(title), description: description.map { Text($0).font(.footnote) })
    }
}

// only swift

struct UltraView4<T1: View, T2: View>: View {
    let title: T1
    let description: T2

    init(title: T1, description: T2) {
        self.title = title
        self.description = description
    }

    init(title: T1) where T2 == EmptyView? {
        self.title = title
        self.description = nil
    }

    var body: some View {
        let color = !hasDescription ? Color.blue : Color.green

        VStack {
            title
            description
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }

    var hasDescription: Bool {
        !(description is Never?) // WHY?!
    }
}

struct Helper4 {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView4(title: Text(title), description: description.map { Text($0).font(.footnote) })
    }
}

// preview

// переключение примеров производится изменением тайпалиасов ниже
typealias Helper = Helper1
typealias UltraView = UltraView1

@ViewBuilder func geksTest() -> some View {
    UltraView(title: Text("чук рулит"))
    UltraView(title: Text("гек норм"), description: Text("потому что брат").font(.footnote))
}

@ViewBuilder func chuksTest() -> some View {
    Helper.ultraView(title: "чук рулит")
    Helper.ultraView(title: "гек норм", description: "потому что брат")
}

struct Preview: View {
    var body: some View {
        VStack(spacing: 20) {
            geksTest()

            Divider()

            chuksTest()
        }
        .padding()
    }
}

PlaygroundPage.current.setLiveView(Preview())

/// WHY? Компилятор считает все `.none` - это отдельный тип, который никогда не используется (`Never`)? Но `Optional` - дженерик с конкретным типом, а не `Never`. Или, может, это баг компилятора? Или `nil` просто может кастоваться к любому типу, в том числе и `Never`? Но ведь `T2` во время компиляции заведомо не `Never`

// MARK: -

let an: Int? = nil
let bn: Int? = 1

an is Never?
bn is Never?

UPD: Спасибо @Tyranronза наводку: https://github.com/apple/swift/blob/main/docs/DynamicCasting.md#optionals

Nil Casting: if T and U are any two types, then Optional<T>.none is Optional<U> == true

Успешным будет не только каст к Never?, но и к любому другому опционалу.

let i: Int? = nil
i is String? // <- тут что будет

Основной вопрос, поднятый в статье, так и не нашел пока пока объяснения

Комментарии (16)


  1. abesmon
    26.12.2021 17:22

    10 Never'ов из 10!


  1. MFilonen2
    26.12.2021 17:53

    То, что опционал это View – очевидный костыль. В ResultBuilder’ах же есть встроенный способ обработки условий buildIf…


    Вот вам и «начать все с чистого листа»…


    1. xbitstream Автор
      26.12.2021 18:26

      Очевидно, что это сделано для их собственного ViewBuilder'а, но он тоже резалт билдер. Хрен знает, что у них там не влезло и куда.

      Но ведь ещё и каст к Optional<Never> сделан такой зачем-то.


      1. MFilonen2
        26.12.2021 19:15

        Сведение кOptional<Never> не является особым поведением для View.
        Optional<Never> может принимать только значение nil. Соответственно, .none любого типа есть Optional<Never>, а .some любого типа не Optional<Never>.


        1. xbitstream Автор
          26.12.2021 19:33

          Я не говорила, что это особое поведение. Я недоумеваю, что могло привести к тому, что они объявили опционал вьюхой. Это раз.

          Два - .some у опционала тоже может быть ещё одним опционалом и так далее. Но главный вопрос в том, что это дженерик тип. И что опционалы нельзя просто так друг к другу кастовать. Потому что даже .none несёт информацию, чей же конкретно он .none. Например .none у Optional<Int> не то же самое, что у Optional<String>. На этапе компиляции, конечно же. Если в рантайме, то, видимо, поэтому и возможен успешный каст любого нила к нилу от невер.


          1. MFilonen2
            26.12.2021 19:52

            Да, правила различны во время компиляции и выполнения.
            Во время выполнения забывается тип дженерика, если он не важен в контексте значения энума.
            Но это не такой type erasure как в Java, потому что если тип таки значим, так не выйдет.


          1. Tyranron
            26.12.2021 20:27

            А в чём проблема с тем что Optional это View, если содержимое тоже View? Это обычное прокидывание свойств вверх по враперам, и улучшает полиморфизм.

            Never тип - это особый тип: https://en.wikipedia.org/wiki/Bottom_type

            В такие типы позволяется кастить что угодно, ибо они не населены (нельзя создать значение этого типа, соответственно, эта ветвь кода никогда не будет вызвана в рантайме).

            Дальше, Optional<Never> - это тип имеет всего одно значение none (ах да, в Swift это то же что и nil).

            Соответственно, через Never? суть выражается протокол (или неявный родительский тип), требующий быть nil.

            nil, some(nil), и так далее, просто удовлетворяют этому протоколу.

            Можно сказать что это хитрый и законный способ заабьюзить систему типов (автоприведение к боттому) для определения IsNil протокола. Это не особенность реализации компилятора, и рантайм тут совершенно ни при чём.


            1. xbitstream Автор
              26.12.2021 21:03
              +1

              А в чём проблема с тем что Optional это View, если содержимое тоже View? Это обычное прокидывание свойств вверх по враперам, и улучшает полиморфизм.

              Проблема в том, что это просто явный костыль. Кроме того, у вью должно быть быть бади, из-за чего Never тоже явно объявлен View. Но главное, конечно - зачем? Если я на уровне дженериков хочу явно различить опционал и другой любой тип, то данное решение меня лишает такой возможности. Субъективно, я не вижу улучшения полиморфизма так как вообще не понимаю конечной цели. Почему тогда опционал это не инт? Или не AnyObject?

              В такие типы позволяется кастить что угодно, ибо они не населены (нельзя
              создать значение этого типа, соответственно, эта ветвь кода никогда не
              будет вызвана в рантайме).

              Да, именно так. Он не имеет значений. Но кастить к нему нельзя ничего.

              1 as? Never // == nil: warning
              // Cast from 'Int' to unrelated type 'Never' always fails

              Дальше, Optional<Never> - это тип имеет всего одно значение none (ах да, в Swift это то же что и nil).

              Это спекуляция. У опционала два значения. Другое дело, что в этом случае одно из них невозможно создать и его никогда не будет в рантайме. Покажите, пожалуйста документацию этого факта, что все нил - это опционал невера. Не надо ехидничать, пожалуйста, я искренне не понимаю работы этого и просила скинуть в меня ссылку в статье.


              1. Tyranron
                26.12.2021 22:47
                +1

                из-за чего Never тоже явно объявлен View

                Это нормально. Как я уже писал - боттом может быть чем угодно, и реализовывать что угодно, ведь его значение всё равно никогда нельзя создать. На то он и боттом, это соответствует его дизайну.

                По факту, это должно автоматически работать, но видимо ещё не сделали:

                https://github.com/apple/swift-evolution/blob/master/proposals/0102-noreturn-bottom-type.md#never-as-a-universal-bottom-subtype

                Кроме того, у вью должно быть быть бади

                А вот это уже не норм. Я как-то упустил из виду что там:

                public typealias Body = Never

                В таком виде оно действительно бесполезно. Я ожидал бы там увидеть что-то типа Wrapped.Body (не силён в синтаксисе Swift'а).

                Почему влепили именно Never - сложно понять мотивацию. Уже ли не в void* полиморфизм они пытаются? >_<

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

                У опционала два значения. Другое дело, что в этом случае одно из них невозможно создать и его никогда не будет в рантайме.

                Optional - не тип, а конструктор типа, и у него значений быть не может. Optional<Int> - это уже тип, и у него 2 значения. Optional<Never> - это тоже тип, и у него всего одно значение.

                Покажите, пожалуйста документацию этого факта, что все нил - это опционал невера.

                Там действительно дело не в Neverах как я ожидал, просто семантика is довольно хитрая для Optionalов:

                https://github.com/apple/swift/blob/main/docs/DynamicCasting.md#optionals

                Nil Casting: if T and U are any two types, then Optional<T>.none is Optional<U> == true

                Удачи в поисках "Motivation" раздела RFC для таких подводных камней =)

                Это спекуляция.

                Не надо ехидничать, пожалуйста

                Цели издеваться над вами у меня нет. Я вообще мимокрокодил и Swift не знаю, но с Neverами бодался много в других языках. Уповая на то, что разработчики Swift'а более менее следуют "общим практикам", лишь подкидываю идеи чем бы этого могло быть. Конечно же всё это спекуляции. Я и не претендовал на истину.


                1. Tyranron
                  26.12.2021 23:08

                  Удачи в поисках "Motivation" раздела RFC для таких подводных камней =)

                  Вроде они и сами недовольны таким решением:

                  https://github.com/apple/swift-evolution/blob/master/proposals/0083-remove-bridging-from-dynamic-casts.md#removing-special-case-optional-and-container-handling-from-dynamic-casts


                1. xbitstream Автор
                  27.12.2021 12:45

                  Optional - не тип, а конструктор типа, и у него значений быть не может. Optional<Int> - это уже тип, и у него 2 значения. Optional<Never> - это тоже тип, и у него всего одно значение.

                  В свифте - это тип. Енам. Дженерик. С двумя значениями. ¯\_(ツ)_/¯


                  1. Tyranron
                    27.12.2021 21:24
                    +1

                    Нет, это не тип.

                    Если Вы имеете в виду Optional<T> - это да, тип, но этот тип конструируется параметризацией Optional дженериком T (тайп параметром).

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

                    Аналогично и с Array. Пока не заполнены все "дырки", неважно чем, конкретными типами или тайп параметрами - у нас нет типа, есть только конструктор типа.


            1. xbitstream Автор
              26.12.2021 21:20

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

              Ну то есть я и так могу сделать Optional<T2> (изначально решение как раз на это и полагалось), но зачем делать так, чтобы Optional сам был вью и мог стать этим самым T2 - я не понимю.

              Пример проблемы, которая из этого проистекает описан как раз в этой статье.


              1. Tyranron
                26.12.2021 23:03

                но зачем делать так, чтобы Optional сам был вью и мог стать этим самым T2 - я не понимю.

                К примеру, если брать широкими мазками, то если логикой какой-то generic third-party View не предусмотрено Optional поле, а вы хотите отобразить в ней ток 2 из 3 передаваемых снаружи вьюх, то третью вы можете передать как nil, и получить желаемое поведение, не переписывая компонент и не матеря других разрабов. E - extensibility.

                То, что возможно подобная задумка (опять спекуляция ^_^), плохо легла конкретно на ваш кейс - вопрос отдельный. И вообще, так ли оно работает на самом деле в Swift?


  1. nikita_dol
    28.12.2021 10:15

    assert(UltraView(title: Text("чук рулит")) is UltraView1<Text, EmptyView>)
    assert(Helper.ultraView(title: "чук рулит") is UltraView1<Text, Optional<Text>>)

    И соответственно

    var description: Optional<Text>? = nil
    assert(description == nil)
    
    description = Optional.some(nil)
    assert(description != nil)



    Вывод: методHelper.ultraViewисспользует дженерики правильно, но ожидания - не правильные


    1. xbitstream Автор
      28.12.2021 13:39

      Это почему же неправильные ожидания? :-)