Однажды в тайге
Эта таинственная история рассказывает о том, как два брата акробата программиста Чук и Гек начали делать свой проект на 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?!
}
Время собирать камни
Выводы из сей басни таковы, мой маленький друг:
Писать свои вью с учетом опциональности входящих параметров (тоже вью) бесполезно. Максимум, что получится - вложенные опционалы
Решить проблему может использование кложур - они могут быть опциональны. И они сами по себе точно не вью. Но это требует соблюдения контракта - более сложный синтаксис и доверие, что достаточно проверить только первый уровень матрешки.
Можно использовать свифтовое поведение - приведение к
Optional<Never>
. Однако, оно тоже разворачивает только первый уровень вложенных опционалов в случае сложной структуры вьюх.Можно реализовать некрасивое, но рабочее всегда решение - определять, что самая вложенная вью - не опционал. (см. решение Чука с
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
andU
are any two types, thenOptional<T>.none is Optional<U> == true
Успешным будет не только каст к Never?
, но и к любому другому опционалу.
let i: Int? = nil
i is String? // <- тут что будет
Основной вопрос, поднятый в статье, так и не нашел пока пока объяснения
Комментарии (16)
MFilonen2
26.12.2021 17:53То, что опционал это View – очевидный костыль. В ResultBuilder’ах же есть встроенный способ обработки условий buildIf…
Вот вам и «начать все с чистого листа»…
xbitstream Автор
26.12.2021 18:26Очевидно, что это сделано для их собственного ViewBuilder'а, но он тоже резалт билдер. Хрен знает, что у них там не влезло и куда.
Но ведь ещё и каст к Optional<Never> сделан такой зачем-то.
MFilonen2
26.12.2021 19:15Сведение к
Optional<Never>
не является особым поведением для View.Optional<Never>
может принимать только значение nil. Соответственно, .none любого типа естьOptional<Never>
, а .some любого типа неOptional<Never>
.xbitstream Автор
26.12.2021 19:33Я не говорила, что это особое поведение. Я недоумеваю, что могло привести к тому, что они объявили опционал вьюхой. Это раз.
Два - .some у опционала тоже может быть ещё одним опционалом и так далее. Но главный вопрос в том, что это дженерик тип. И что опционалы нельзя просто так друг к другу кастовать. Потому что даже .none несёт информацию, чей же конкретно он .none. Например .none у Optional<Int> не то же самое, что у Optional<String>. На этапе компиляции, конечно же. Если в рантайме, то, видимо, поэтому и возможен успешный каст любого нила к нилу от невер.
MFilonen2
26.12.2021 19:52Да, правила различны во время компиляции и выполнения.
Во время выполнения забывается тип дженерика, если он не важен в контексте значения энума.
Но это не такой type erasure как в Java, потому что если тип таки значим, так не выйдет.
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
протокола. Это не особенность реализации компилятора, и рантайм тут совершенно ни при чём.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
).Это спекуляция. У опционала два значения. Другое дело, что в этом случае одно из них невозможно создать и его никогда не будет в рантайме. Покажите, пожалуйста документацию этого факта, что все нил - это опционал невера. Не надо ехидничать, пожалуйста, я искренне не понимаю работы этого и просила скинуть в меня ссылку в статье.
Tyranron
26.12.2021 22:47+1из-за чего Never тоже явно объявлен View
Это нормально. Как я уже писал - боттом может быть чем угодно, и реализовывать что угодно, ведь его значение всё равно никогда нельзя создать. На то он и боттом, это соответствует его дизайну.
По факту, это должно автоматически работать, но видимо ещё не сделали:
Кроме того, у вью должно быть быть бади
А вот это уже не норм. Я как-то упустил из виду что там:
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
andU
are any two types, thenOptional<T>.none is Optional<U> == true
Удачи в поисках "Motivation" раздела RFC для таких подводных камней =)
Это спекуляция.
Не надо ехидничать, пожалуйста
Цели издеваться над вами у меня нет. Я вообще мимокрокодил и Swift не знаю, но с
Never
ами бодался много в других языках. Уповая на то, что разработчики Swift'а более менее следуют "общим практикам", лишь подкидываю идеи чем бы этого могло быть. Конечно же всё это спекуляции. Я и не претендовал на истину.Tyranron
26.12.2021 23:08Удачи в поисках "Motivation" раздела RFC для таких подводных камней =)
Вроде они и сами недовольны таким решением:
xbitstream Автор
27.12.2021 12:45Optional
- не тип, а конструктор типа, и у него значений быть не может.Optional<Int>
- это уже тип, и у него 2 значения.Optional<Never>
- это тоже тип, и у него всего одно значение.В свифте - это тип. Енам. Дженерик. С двумя значениями. ¯\_(ツ)_/¯
Tyranron
27.12.2021 21:24+1Нет, это не тип.
Если Вы имеете в виду
Optional<T>
- это да, тип, но этот тип конструируется параметризациейOptional
дженерикомT
(тайп параметром).Сам по себе
Optional
, без подстановки туда чего-угодно, не может быть типом, соответственно, нельзя его использовать в сигнатурах типов не указав параметр.Аналогично и с
Array
. Пока не заполнены все "дырки", неважно чем, конкретными типами или тайп параметрами - у нас нет типа, есть только конструктор типа.
xbitstream Автор
26.12.2021 21:20Внимательно перечитала первый абзац коментария. Проблемы в существовании опционалов каких либо типов - нет. Проблема в том, что опционалу дается дополнительное поведение. Зачем?
Ну то есть я и так могу сделать Optional<T2> (изначально решение как раз на это и полагалось), но зачем делать так, чтобы Optional сам был вью и мог стать этим самым T2 - я не понимю.
Пример проблемы, которая из этого проистекает описан как раз в этой статье.
Tyranron
26.12.2021 23:03но зачем делать так, чтобы Optional сам был вью и мог стать этим самым T2 - я не понимю.
К примеру, если брать широкими мазками, то если логикой какой-то generic third-party
View
не предусмотреноOptional
поле, а вы хотите отобразить в ней ток 2 из 3 передаваемых снаружи вьюх, то третью вы можете передать какnil
, и получить желаемое поведение, не переписывая компонент и не матеря других разрабов. E - extensibility.То, что возможно подобная задумка (опять спекуляция ^_^), плохо легла конкретно на ваш кейс - вопрос отдельный. И вообще, так ли оно работает на самом деле в Swift?
nikita_dol
28.12.2021 10:15assert(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
исспользует дженерики правильно, но ожидания - не правильные
abesmon
10 Never'ов из 10!