ASI czyli automatyczne wstawianie średników

Specyfikacja ECMAScript zawiera w sobie wiele zaskakujących elementów i mechanizmów, i gorąco polecam się z nią zapoznać. Jeśli język, którym napisano specyfikację wydaje się być odstraszający, to warto przeczytać chociaż serię artykułów ECMA-262-3 in detailECMA-262-5 in detail których autorem jest Dmitry Soshnikov. Dmitry omawia w nich specyfikację ES i sposób działania JavaScriptu, ale robi to w sposób bardzo przystępny w zasadzie dla każdego dociekliwego odbiorcy.

Ja chciałbym jednak dzisiaj skupić się na jednym konkretnym mechanizmie zawartym w specyfikacji ES: Automatic Semicolon Insertion (ASI) – czyli automatycznym wstawianiu średników. Składnia JavaScriptu na pierwszy rzut oka podobna jest z grubsza do składni Javy czy C++ – od razu uwagę na siebie zwracają {, };. Jednak okazuje się, że ES pozwala na coś, co dla progamistów dwóch wymienionych języków byłoby nie do pomyślenia: swobodne pomijanie średnika na końcach wyrażeń.

ASI, WTH?

Na początku swojej kariery programisty JS niejednokrotnie znajdowałem w napisanym przeze mnie kodzie miejsca, w których brakowało średników i zachodziłem w głowę dlaczego ten kod w ogóle bez nich działał. Weźmy prosty przykład:

console.log(‘Hello, world!’)  

Powyższy kod zostanie bez problemu zinterpretowany i uruchomiony we wszystkich znanych mi silnikach JavaScript, pomimo braku średnika na końcu lini. WTH? Moje zdziwinie było ogromne, więc zacząłem szukać skąd się bierze ta pozorna magia. Wtedy pierwszy raz usłyszałem o ASI. Jako osoba, która zaczynała naukę programowania od C, podchodziłem do tego ułatwienia z dużym dystansem, ale jednocześnie próbowałem z niego czasem skorzystać. Może to bardziej w duchu JS – myślałem i uparcie kasowałem wstawiane średniki tak, aby się do tej składni przyzwyczaić. Skoro interpreter sam wstawia je za mnie to co mogłoby pójść źle?

ASI niewystarczająco automatyczne

W ramach testów ASI napisałem kod podobny do tego poniżej:

console.log('test')  
[1,2,3].map(x => x * x)

Co powinno się wydarzyć? Teoretycznie najpierw w konsoli ma pojawić się słowo “test”, a później wykona się mapowanie wartości z tablicy. Tak rzeczywiście by się stało, gdyby po pierwszej linijce znalazł się średnik. Jak więc ten kod interpretowany jest zgodnie ze specyfikacją?

Uncaught TypeError: Cannot read property '3' of undefined  

Że co? Przeanalizujmy co się wydarzyło. 3 na pewno wzięła się stąd, że wyrażenie 1,2,3 daje po prostu 3 (operator przecinka). Nie zaglądając jeszcze do specyfikacji można dojść do wniosku, że kod został więc zinterpretowany w ten sposób:

console.log('test’)[3].map(x => x * x)  

I wszystko jasne! Ta linia oznacza tyle co: Wykonaj funkcję console.log(‘test’), weź zwróconą przez nią wartość i pobierz z niej element leżący pod indeksem 3. Ten element powinien mieć metodę map, którą wykonaj. Wyjątek rzucany jest już po wykonaniu console.log(‘test’), które zwraca undefined. Inny przykład, prosto ze specyfikacji:

a = b + c  
(d + e).print()

interpretowane jako:

a = b + c(d + e).print()  

Warto zauważyć, że zarówno moja oryginalna intencja, jak i sposób zinterpretowania jej zgodnie ze specyfikacją są całkowicie poprawnymi sposobami odczytania tego kodu. Czy da się w związku z tym odróżnić od siebie te dwa przypadki? Tak, ale wyłącznie poprzez wstawienie średnika po pierwszym wyrażeniu. Czy podobnych nieprzewidywalnych przypadków może być więcej? Niestety tak.

Po co jest ASI?

Aby się definitywnie dowiedzieć o co w tym wszystkim chodzi sięgam do sekcji 11.9 specyfikacji ECMAScript 2015. Dociekliwi mogą przeczytać ją w całości, ja tutaj skupię się tylko na istotnych fragmentach. Są dokładnie 3 zasady zgodnie z którym średniki są wstawiane automatycznie (i kilka wyjątków):

  1. Kiedy napotkany znak (zwany offending token) nie jest dozwolony w danym miejscu to średnik wstawiany jest tuż przed tym znakiem pod warunkiem, że spełniony jest jeden z podpunktów:
    • offending token i poprzedni znak są od siebie oddzielone końcem linii
    • offending token to }
    • poprzedni znak to ) i wstawiony średnik byłby zakończeniem instrukcji do-while
  2. Kiedy interpreter napotka koniec kodu źródłowego, ale nie jest w stanie go zrozumieć to średnik wstawiany jest na jego końcu.
  3. Kiedy napotkany jest tzw. restricted production i w miejscu, w którym nie powinno być końca linii znajduje się znak końca linii to średnik jest wstawiany przed tym znakiem.

Pomijam nawet wyjątki od tych reguł, ale łał, czy to już nie brzmi trochę skomplikowanie? I co to w ogóle oznacza w praktyce? Po pierwsze okazuje się, że w JavaScripcie białe znaki zmieniają sposób interpretowania wyrażeń – w odróżnieniu chociażby od wspomnianego C++. Znów bardzo prosty przykład:

123 ‘hello, world!’

123  
‘hello, world!’

Pierwsza linijka kodu jest niepoprawna. Średnik nie zostanie automatycznie wstawiony po 123. W drugim przypadku jednak już tak, gdyż po 123 znalazł się znak końca linii. A więc białe znaki zmieniają sposób, w jaki ten kod jest rozumiany przez silniki JavaScriptu.

ASI zbyt automatyczne

Co jeszcze wynika z tej specyfikacji? Oto jeden z moich ulubionych przykładów kodu, który zaskakuje początkujących:

function fn() {  
    return
    {
        a: 1
    }
}

Co zwróci funkcja fn po wywołaniu? Oczywiście undefined. ASI powoduje, że po return automatycznie dodawany jest średnik. Osoby przyzwyczajone do takiego formatowania kodu niestety muszą się z nim pożegnać – nie da się tego problemu obejść inaczej niż poprzez wstawienie { w tej samej linii co return. Więcej o podobnych haczykach można przeczytać w artykułach The Dangers of JavaScript’s Automatic Semicolon InsertionJavaScript Semicolon Insertion.

Ze średnikiem czy jednak bez?

Są grupy osób, które sugerują aby całkowicie polegać na ASI, na przykład standardjs.com. Głośno również było o wdrożeniu tego podejścia przez zespół rozwijający electron. Rzeczywiście jest prawdą stwierdzenie, że pomijanie średników jest całkiem bezpieczne patrząc jedynie przez pryzmat silników JavaScript. Wszystkie sprawdzone przeze mnie poprawnie implementują ASI i konsekwentnie interpretują kod bez średników w ten sam sposób. W związku z tym, jeśli konsekwentnie podążać za zasadami opisanymi w standardjs.com, nie ma żadnego ryzyka, że kod zostanie nieprawidłowo zinterpretowany. Ponadto należy pamiętać, że ASI działa zawsze, niezależnie od intencji programisty, więc każdy programista JS powinien znać zasady automatycznego wstawiania średników chociażby po to, aby uniknąć sytuacji opisanej w poprzednim akapicie. Więcej: An Open Letter to JavaScript Leaders Regarding Semicolons. Skoro zasady i tak trzeba znać, to czemu by ASI nie wykorzystać?
(EDIT 10.07.2017) Zasady ASI warto znać, jednak jeśli ktoś decyduje się na zrezygnowanie ze stawiania średników to naraża się na całą gamę przypadków i błędów, których standardjs.com nie uwzględnia.

Sprawę może ułatwiać fakt, że większość programistów JS i tak korzysta z tzw. linterów (a jeśli nie korzysta to powinna). Zasady sprawdzania kodu pod względem poprawności można ustawić tak, aby średniki były ściśle wymagane, ale również w ten sposób, aby średników było jak najmniej. O pełną pewną spójność i poprawność kodu pomoże zadbać odpowiednio skonfigurowane narzędzie i dlatego decyzja o używaniue (bądź nie) ASI jest właściwie wyłącznie kwestią preferencji zespołu. (EDIT 10.07.2017) ale mimo wszystko pomijanie średników jest ryzykowne, szczególnie jeśli natrafimy na przypadek, którego linter nie potrafi obsłużyć prawidłowo z powodu buga albo nowej składni w ES. Przykład? Pierwsze z głowy: template stringi, które dodają sporo nowej składni i nowych edge case dla ASI, a które przez długi czas nie były wymienione w standardjs ani obsługiwane przez lintery.

A moim zdaniem…

Czy są jednak jakieś szczególne zalety polegania na ASI? Nie jestem w stanie ich dostrzec. Notabene, specyfikacja ECMAScript wyraźnie mówi, że Automatic Semicolon Insertion jest jedynie próbą zinterpretowania kodu, który zawiera błędy, a przy tym, według specyfikacji, niektóre średniki są obowiązkowe. W związku z powyższym, moim zdaniem poleganie na ASI zmusza do pewnej niekonsekwencji w składni – bo nie znikają wszystkie średniki, a jedynie ich część. Podobne zdanie wyraża również Kyle Simpson w swojej słynnej publikacji You Don’t Know JS:

So, to put it more bluntly, when I hear someone claim that they want to omit „optional semicolons,” my brain translates that claim to „I want to write the most parser-broken program I can that will still work.”

Idąc dalej, pozwolę sobie nawet wysunąć tezę, iż poleganie na ASI to proszenie się o problem. Po pierwsze dlatego, że jego reguły są trudne do zapamiętania i łatwo popełnić błąd. Oczywiście może przed tym chronić odpowiednio skonfigurowany linter, ale mimo to mam wrażenie, że łatwiejsze jest zapamiętanie nielicznych haczyków (np. wspomnianego z return) gdy wstawia się średniki, niż wielu wyjątków gdy zazwyczaj średników się nie stawia. Ponadto zauważyłem, że większość programistów, z którym pracowałem zna składnię C lub podobną i wstawianie średników jest dla nich czymś całkowicie naturalnym. W związku z tym kod bez średników jest jednak znacznie trudniejszy do zrozumienia przez większość osób, a nowi programiści w zespole mają o wiele wyższy próg wejścia do projektu, w którym nie stawia się średników. No i już tak zupełnie subiektywnie: taki kod jest po prostu znacznie mniej czytelny i łatwiej się w nim zgubić. Po co więc używać ASI? Nie widzę żadnych korzyści, a potencjalnych problemów dostrzegam sporo.