Nil nie istnieje. Dlaczego powinieneś zrezygnować z nil?
Ucząc się nowych rzeczy najłatwiej przyswajamy wiedzę, która coś już nam przypomina. I tak w tym co “nowe” odnajdujemy to co “stare” i widzimy to co nowe jak coś starego. Samo to nie jest ani dobre, ani złe tak długo, jak jest w nas wola na zerwanie kurtyny i zobaczenie tego, co jest za nią. Jak się nam to nie spodoba albo będzie mało przydatne, to trudno. Ale teraz już wiemy i to my o tym decydujemy a nie ktoś za nas.
Łukasz Stocki. iOS Developer w Veepee. Swoją przygodę z platformą Apple zaczynał wraz z iOS5. Jest jednym z administratorów grup Facebook-owych dla iOS Developerów oraz z ogłoszeniami o pracę. Współtworzy Lekko Technologiczny kanał na YouTube, na którym, razem z przyjaciółmi, bez nadęcia starają się przybliżyć różne pomysły z programowania.
Spis treści
Nil czy .none?
Czym różnią się od siebie te funkcje:
func i1() -> Int? { 42 } func i2() -> Int? { .some(42) } func i3() -> Int? { nil } func i4() -> Int? { .none }
Nie licząc paru znaczków to… niczym. Raz zwracamy “coś” (.some) a innym razem wydawałoby się, że coś innego (Int 42). Trochę niżej zwracamy znajome dla każdego, kto się zetknął z ObjC “nil” a jeszcze niżej “.none”.
Czas powoli wyeliminować kawałek tego składniowego cukru:
func i5() -> Optional<Int> { 42 } func i6() -> Optional<Int> { .some(42) } func i7() -> Optional<Int> { nil } func i8() -> Optional<Int> { .none }
Typ każdej z tych funkcji jest dokładnie taki sam. Jednak implementacje jawnie się różnią. Dlaczego z jakiegoś powodu nil to none, a 42 to some?
Co więcej, skoro twierdzę, że “nil” nie istnieje to jakim cudem mogę napisać coś takiego:
i3() == .none // true i4() == nil // true nil == () // false
Wszystkiemu “winne” ObjC
Dla kogoś, kto swoją przygodę z programowaniem na platformę Apple zaczynał jakiś czas temu, “nic” mogło oznaczać kilka rzeczy. Najczęściej natomiast obracało się dookoła specjalnej wartości w pamięci o adresie 0x0. Ten wyróżniony fragment/adres oznaczał właśnie “nic” i ma swoje słowo kluczowe “nil”. Konkretniej to, że to co teraz mamy (z czym pracujemy) to jest właśnie “nic”.
Inne języki jeszcze mają ten problem, że gdy mają do czynienia “z niczym” to crashują aplikację. Przestraszne “null pointer exception” lub podobne historie. Jednak nie w ObjC. W runtime-ie jest sprawdzane czy wiadomość/message jest wysyłana do “nil” i jak tak to jest zwracany “nil”. Efekt jest taki, że można “bezpiecznie” pracować z “nil”em. Bez obawy wywalenia aplikacji. To spowodowało, że wiele API w ObjC po prostu może zwrócić bądź przyjąć nil.
Teraz na białym rumaku wpada Swift. I on nie ma czegoś takiego jak nil. Ma natomiast Optional, który jest monadą, ale jak się o tym zaczyna mówić to wiele osób blednie ze strachu. Do tego jest to generyk. Same z tym problemy a chcemy przecież, aby deweloperzy jak najszybciej zaadaptowali nowy język.
Trzeba było dać coś, co ludzie znają. Coś z czym pracują już teraz. Trzeba tego Optionala osłodzić.
Nasz własny nil
Poszukajmy śladów w dokumentacji:
Protocol
ExpressibleByNilLiteral
A type that can be initialized using the nil literal, nil.
Overview
nil has a specific meaning in Swift—the absence of a value. Only the Optional type conforms to ExpressibleByNilLiteral. ExpressibleByNilLiteral conformance for types that use nil for other purposes is discouraged.
Widać, że ten protokół jest dla “czegoś”, co można zainicjalizować z “nil literal” oraz, że tylko typ Optional konformuje do tego protokołu. To drugie akurat nie jest do końca prawdą, ale rozwikłajmy, czym jest ten “nil literal”.
W dokumentacji do języka Swift znajdziemy fragment o “Lexical Structure”, czyli to jakie znaczki możemy wpisać, aby można to było zinterpretować jako “kompilujący się kod (wybaczcie uproszczenie). I tam właśnie poniżej jest zdefiniowane, co to jest “nil literal”. Nie będę już Was trzymać w napięciu. Więc jest to: “nil”.
Za radą Włodka Markowicza połączmy kropki. Więc gdy w kodzie występuje ciąg znaków “nil” to kompilator wstawia jedyną “rzecz” jaką może, czyli Optionala.
Ponieważ zakazany owoc smakuje najlepiej to napiszemy sami własną wersję Optionala.
enum MyOptional<Wrapped> { case some(Wrapped) case none }
Jest to dokładnie taka sama implementacja Optionala jak w bibliotece standardowej.
extension MyOptional: ExpressibleByNilLiteral { init(nilLiteral: ()) { self = .none } } func mi1() -> MyOptional<Int> { .some(42) } // Teraz mogę zwracać nil i dostać mój typ func mi2() -> MyOptional<Int> { nil }
A co z fragmentami, które pozwalają mi na porównanie do nil-a?
struct _MyOptionalNilComparisonType: ExpressibleByNilLiteral { init(nilLiteral: ()) {} } extension MyOptional { static func ~=(lhs: _MyOptionalNilComparisonType, rhs: MyOptional<Wrapped>) -> Bool { switch rhs { case .some: return false case .none: return true } } static func ==(lhs: MyOptional<Wrapped>, rhs: _MyOptionalNilComparisonType) -> Bool { switch lhs { case .some: return false case .none: return true } } static func ==(lhs: _MyOptionalNilComparisonType, rhs: MyOptional<Wrapped>) -> Bool { switch rhs { case .some: return false case .none: return true } } static func !=(lhs: MyOptional<Wrapped>, rhs: _MyOptionalNilComparisonType) -> Bool { switch lhs { case .some: return true case .none: return false } } static func !=(lhs: _MyOptionalNilComparisonType, rhs: MyOptional<Wrapped>) -> Bool { switch rhs { case .some: return true case .none: return false } } }
Stworzyłem coś czego używam w operatorach porównania i pattern machingu. Dzięki temu nie ma znaczenia czy porównuje instancje MyOptional
do nil
z prawej czy z lewej strony. Kompilator jest w stanie dopasować odpowiednią kombinację i utworzyć instancję tej specjalnej struktury.
SIL
Tyle teorii, ale czy możemy to działanie kompilatora zobaczyć w praktyce? Oczywiście, że tak! Kompilator Swift-a zanim “wypluje” binarkę przechodzi przez kilka faz. Chodzi o to, że na poszczególnych etapach pewne optymalizacje i analizy dokonuje się łatwiej. Też wymieniając jedną część możemy ten sam kod przeportować na inną platformę.
Tu właśnie wkracza SIL, Swift Intermediate Language. Na tej warstwie zobaczymy w czytelniejszy sposób niż assembly “co się właściwie dzieje”. Aby też szło to łatwiej użyjemy narzędzia o nazwie SILInspector.
Zaczynamy. Jak właściwie jest kompilowana taka funkcja:
func mystery() -> Int? { 42 }
Otrzymujemy coś takiego:
Wygląda dziwnie, ale nie jest to takie straszne. Pierwszym krokiem jest odszukanie funkcji “mystery”:
// mystery() sil hidden @main.mystery() -> Swift.Int? : $@convention(thin) () -> Optional<Int> { bb0: %0 = integer_literal $Builtin.Int64, 42 // user: %1 %1 = struct $Int (%0 : $Builtin.Int64) // user: %2 %2 = enum $Optional<Int>, #Optional.some!enumelt.1, %1 : $Int // user: %3 return %2 : $Optional<Int> // id: %3 } // end sil function 'main.mystery() -> Swift.Int?'
I teraz po nitce do kłębka.
- ostatnia linijka zwraca coś, co jest pod rejestrem %2 i ma typ Optional,
- %2 przechowuje wartość o typie Optional, konkretnie utworzonego przy pomocy “some” i wpakowana jest do tego wartość z rejestru %1,
- %1 to tworzenie instancji struktury Int z czegoś co jest w rejestrze %0,
%0 to “integer literal”, który jest tworzony z “42”.
Tak właśnie działa ten magiczny “boxing” do Optionala. Natomiast nas interesuje co w sytuacji, gdy zwracany jest nil. Funkcja mystery wygląda teraz tak:
func mystery() -> Int? { nil }
A wygenerowany SIL tak:
// mystery() sil hidden @main.mystery() -> Swift.Int? : $@convention(thin) () -> Optional<Int> { bb0: %0 = enum $Optional<Int>, #Optional.none!enumelt // user: %1 return %0 : $Optional<Int> // id: %1 } // end sil function 'main.mystery() -> Swift.Int?'
Po prostu zwracany jest Optional.none! Żadnego nil-a tu nie widać, bo po prostu nie istnieje. Dlatego też nie można użyć literału “nil” samego. Kompilator musi znać typ ponieważ musi wiedzieć jakiego typu ma być Optional (Int?, String? etc.). To też ludzie mają na myśli, gdy mówią o tym, że “Swift jest bezpieczny”.
Podsumowanie
Nil nie istnieje… tak jakby. Jest to tylko cukier składniowy, aby przesiadka z ObjC na Swift była mniej bolesna(?). Moim osobistym zdaniem zwracanie .none
jest czytelniejsze od nil
.
Nil niesie ze sobą cały bagaż doświadczeń i skojarzeń. Gdzie .none
takich nie posiada. Precyzyjnie mówi o “braku wartości konkretnego typu”. Z drugiej strony bardzo doceniam to, że dla Optional-i nie muszę opakowywać wszystkiego w .some
, gdy zwracana jest jakaś wartość lub przypisywana do zmiennej.
Jedno małe ale…
Uważam, że takie kółka treningowe są mało fortunne. Jest masa typów, które na nieco wyższym poziomie są podobne do Optional, a jednak tego cukru nie posiadają. Z Optionalem pracuje się tak samo jak z Either, Result, Try, Future… itd. Ponieważ Optional ma ten cukier i specjalną składnie, patrz if let etc., to tracimy tą intuicję, jak pracować z tymi “innymi” typami. Przez to Optional jest “naznaczony” a nie różni go nic (suchar niezamierzony) od pozostałych.
Może powoli czas rzucić to ObjC i zrezygnować z nil? Szczególnie, że jeżeli ktoś nie ma tego bagażu doświadczeń? Do rozważenia przez każdego we własnym zakresie, bo przecież na koniec dnia nic nie ma znaczenia i używamy tego co nam pasuje bardziej.
Linki dla dociekliwych:
- Specjalna wiedza kompilatora
- Jak Optional konformuje do ExpressibleByNilLiteral
- Jak to się dzieje, że w Debug zobaczymy “nil” a nie “none”
- Jak operatory porównania są zdefiniowane na Optional-u
Zdjęcie główne artykułu pochodzi z unsplash.com.