Tworzenie zakresu w JavaScript - dziwna składnia

129

Na liście mailingowej es-omówienia natknąłem się na następujący kod:

Array.apply(null, { length: 5 }).map(Number.call, Number);

To produkuje

[0, 1, 2, 3, 4]

Dlaczego jest to wynikiem działania kodu? Co tu się dzieje?

Benjamin Gruenbaum
źródło
2
IMO Array.apply(null, Array(30)).map(Number.call, Number)jest łatwiejsze do odczytania, ponieważ unika udawania, że ​​zwykły obiekt jest tablicą.
fncomp
10
@fncomp Proszę nie używać albo faktycznie stworzyć zakres. Nie tylko jest wolniejsze niż proste podejście - nie jest tak łatwe do zrozumienia. Trudno zrozumieć składnię (cóż, naprawdę API, a nie składnię) tutaj, co sprawia, że ​​jest to interesujące pytanie, ale straszny kod produkcyjny IMO.
Benjamin Gruenbaum
Tak, nie sugerując, że ktoś go używa, ale pomyślałem, że nadal jest łatwiejszy do odczytania w stosunku do wersji dosłownej obiektu.
fncomp
1
Nie jestem pewien, dlaczego ktokolwiek miałby to robić. Czas potrzebny na utworzenie tablicy w ten sposób można było zrobić w nieco mniej seksowny, ale znacznie szybszy sposób: jsperf.com/basic-vs-extreme
Eric Hodonsky

Odpowiedzi:

265

Zrozumienie tego „hackowania” wymaga zrozumienia kilku rzeczy:

  1. Dlaczego po prostu nie robimy Array(5).map(...)
  2. Jak Function.prototype.applyobsługuje argumenty
  3. Jak Arrayobsługuje wiele argumentów
  4. Jak Numberfunkcja obsługuje argumenty
  5. Co Function.prototype.callrobi

Są to raczej zaawansowane tematy w javascript, więc będzie to więcej niż raczej długie. Zaczniemy od góry. Brać się do rzeczy!

1. Dlaczego nie tylko Array(5).map?

Czym tak naprawdę jest tablica? Zwykły obiekt zawierający klucze całkowite, które są mapowane na wartości. Ma inne specjalne cechy, na przykład magiczną lengthzmienną, ale w swej istocie jest to zwykła key => valuemapa, jak każdy inny obiekt. Pobawmy się trochę tablicami, dobrze?

var arr = ['a', 'b', 'c'];
arr.hasOwnProperty(0); //true
arr[0]; //'a'
Object.keys(arr); //['0', '1', '2']
arr.length; //3, implies arr[3] === undefined

//we expand the array by 1 item
arr.length = 4;
arr[3]; //undefined
arr.hasOwnProperty(3); //false
Object.keys(arr); //['0', '1', '2']

Dochodzimy do wewnętrznej różnicy między liczbą elementów w tablicy arr.length, a liczbą key=>valueodwzorowań, które ma tablica, która może być inna niż arr.length.

Rozszerzanie tablicy przez arr.length nie tworzy żadnych nowych key=>valuemapowań, więc nie jest tak, że tablica ma niezdefiniowane wartości, nie ma tych kluczy . A co się dzieje, gdy próbujesz uzyskać dostęp do nieistniejącej nieruchomości? Masz undefined.

Teraz możemy trochę unieść głowy i zobaczyć, dlaczego funkcje takie jak arr.mapnie obejmują tych właściwości. Gdyby arr[3]była po prostu niezdefiniowana, a klucz istniał, wszystkie te funkcje tablicowe po prostu przeszłyby przez to jak każda inna wartość:

//just to remind you
arr; //['a', 'b', 'c', undefined];
arr.length; //4
arr[4] = 'e';

arr; //['a', 'b', 'c', undefined, 'e'];
arr.length; //5
Object.keys(arr); //['0', '1', '2', '4']

arr.map(function (item) { return item.toUpperCase() });
//["A", "B", "C", undefined, "E"]

Celowo użyłem wywołania metody, aby dalej udowodnić, że samego klucza nigdy nie było: wywołanie undefined.toUpperCasespowodowałoby błąd, ale tak się nie stało. Aby udowodnić, że :

arr[5] = undefined;
arr; //["a", "b", "c", undefined, "e", undefined]
arr.hasOwnProperty(5); //true
arr.map(function (item) { return item.toUpperCase() });
//TypeError: Cannot call method 'toUpperCase' of undefined

A teraz dochodzimy do mojego punktu: jak Array(N)się sprawy mają. Sekcja 15.4.2.2 opisuje ten proces. Jest kilka mumbo jumbo, na których nam nie zależy, ale jeśli uda ci się czytać między wierszami (lub możesz mi po prostu zaufać, ale nie rób tego), sprowadza się to w zasadzie do tego:

function Array(len) {
    var ret = [];
    ret.length = len;
    return ret;
}

(działa przy założeniu (co jest sprawdzane w rzeczywistej specyfikacji), że lenjest poprawnym uint32, a nie dowolną liczbą wartości)

Teraz możesz zobaczyć, dlaczego działanie Array(5).map(...)nie zadziałałoby - nie definiujemy lenelementów w tablicy, nie tworzymy key => valuemapowań, po prostu zmieniamy lengthwłaściwość.

Skoro mamy to już na uboczu, spójrzmy na drugą magiczną rzecz:

2. Jak Function.prototype.applydziała

Po applyprostu pobiera tablicę i rozwija ją jako argumenty wywołania funkcji. Oznacza to, że następujące są prawie takie same:

function foo (a, b, c) {
    return a + b + c;
}
foo(0, 1, 2); //3
foo.apply(null, [0, 1, 2]); //3

Teraz możemy ułatwić proces sprawdzania, jak applydziała, po prostu rejestrując argumentsspecjalną zmienną:

function log () {
    console.log(arguments);
}

log.apply(null, ['mary', 'had', 'a', 'little', 'lamb']);
 //["mary", "had", "a", "little", "lamb"]

//arguments is a pseudo-array itself, so we can use it as well
(function () {
    log.apply(null, arguments);
})('mary', 'had', 'a', 'little', 'lamb');
 //["mary", "had", "a", "little", "lamb"]

//a NodeList, like the one returned from DOM methods, is also a pseudo-array
log.apply(null, document.getElementsByTagName('script'));
 //[script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script, script]

//carefully look at the following two
log.apply(null, Array(5));
//[undefined, undefined, undefined, undefined, undefined]
//note that the above are not undefined keys - but the value undefined itself!

log.apply(null, {length : 5});
//[undefined, undefined, undefined, undefined, undefined]

Łatwo jest udowodnić moje roszczenie w przedostatnim przykładzie:

function ahaExclamationMark () {
    console.log(arguments.length);
    console.log(arguments.hasOwnProperty(0));
}

ahaExclamationMark.apply(null, Array(2)); //2, true

(tak, gra słów przeznaczona). key => valueMapowanie nie może istnieć w tablicy mijaliśmy na celu apply, ale na pewno istnieje w argumentszmiennej. Z tego samego powodu działa ostatni przykład: klucze nie istnieją w przekazywanym obiekcie, ale istnieją w arguments.

Dlaczego? Spójrzmy na sekcję 15.3.4.3 , gdzie Function.prototype.applyjest zdefiniowana. Przeważnie rzeczy, na których nam nie zależy, ale oto interesująca część:

  1. Niech len będzie wynikiem wywołania metody wewnętrznej [[Get]] argArray z argumentem „length”.

Co w zasadzie oznacza: argArray.length. Następnie specyfikacja wykonuje prostą forpętlę na lengthelementach, tworząc a listz odpowiednich wartości ( listjest to jakieś wewnętrzne voodoo, ale w zasadzie jest to tablica). Pod względem bardzo, bardzo luźnego kodu:

Function.prototype.apply = function (thisArg, argArray) {
    var len = argArray.length,
        argList = [];

    for (var i = 0; i < len; i += 1) {
        argList[i] = argArray[i];
    }

    //yeah...
    superMagicalFunctionInvocation(this, thisArg, argList);
};

Więc wszystko, czego potrzebujemy, aby naśladować obiekt argArrayw tym przypadku, to obiekt z lengthwłaściwością. Teraz możemy zobaczyć, dlaczego wartości są niezdefiniowane, ale klucze nie są włączone arguments: Tworzymy key=>valueodwzorowania.

Uff, więc to nie mogło być krótsze niż poprzednia część. Ale kiedy skończymy, będzie ciasto, więc bądź cierpliwy! Jednak po następnej sekcji (która będzie krótka, obiecuję) możemy rozpocząć analizę wyrażenia. Jeśli zapomniałeś, pytanie brzmiało, jak działa to:

Array.apply(null, { length: 5 }).map(Number.call, Number);

3. Jak Arrayobsługuje wiele argumentów

Więc! Widzieliśmy, co się dzieje, gdy przekazujesz lengthargument do Array, ale w wyrażeniu przekazujemy kilka rzeczy jako argumenty ( undefineddokładniej tablica 5 ). Sekcja 15.4.2.1 mówi nam, co robić. Ostatni akapit to wszystko, co się dla nas liczy, i jest sformułowany naprawdę dziwnie, ale sprowadza się do:

function Array () {
    var ret = [];
    ret.length = arguments.length;

    for (var i = 0; i < arguments.length; i += 1) {
        ret[i] = arguments[i];
    }

    return ret;
}

Array(0, 1, 2); //[0, 1, 2]
Array.apply(null, [0, 1, 2]); //[0, 1, 2]
Array.apply(null, Array(2)); //[undefined, undefined]
Array.apply(null, {length:2}); //[undefined, undefined]

Tada! Otrzymujemy tablicę kilku niezdefiniowanych wartości i zwracamy tablicę tych niezdefiniowanych wartości.

Pierwsza część wyrażenia

Na koniec możemy odszyfrować:

Array.apply(null, { length: 5 })

Widzieliśmy, że zwraca tablicę zawierającą 5 niezdefiniowanych wartości, z wszystkimi kluczami.

Teraz przejdźmy do drugiej części wyrażenia:

[undefined, undefined, undefined, undefined, undefined].map(Number.call, Number)

Będzie to łatwiejsza, nieskomplikowana część, ponieważ nie polega ona tak bardzo na niejasnych hackach.

4. Jak Numbertraktuje dane wejściowe

Wykonanie Number(something)( sekcja 15.7.1 ) konwertuje somethingna liczbę i to wszystko. Sposób, w jaki to robi, jest nieco zawiły, szczególnie w przypadku łańcuchów, ale operacja jest zdefiniowana w sekcji 9.3 na wypadek, gdybyś był zainteresowany.

5. Gry Function.prototype.call

calljest applybratem, określonym w sekcji 15.3.4.4 . Zamiast pobierać tablicę argumentów, po prostu pobiera otrzymane argumenty i przekazuje je dalej.

Sprawy stają się interesujące, gdy łączysz więcej niż jeden callrazem, podkręć dziwne do 11:

function log () {
    console.log(this, arguments);
}
log.call.call(log, {a:4}, {a:5});
//{a:4}, [{a:5}]
//^---^  ^-----^
// this   arguments

To jest całkiem warte, dopóki nie zrozumiesz, co się dzieje. log.calljest po prostu funkcją, równoważną callmetodzie dowolnej innej funkcji i jako taka ma również callsamą metodę:

log.call === log.call.call; //true
log.call === Function.call; //true

A co robi call? Przyjmuje wiele thisArgargumentów i wywołuje swoją funkcję nadrzędną. Możemy to zdefiniować za pomocą apply (znowu bardzo luźny kod, nie zadziała):

Function.prototype.call = function (thisArg) {
    var args = arguments.slice(1); //I wish that'd work
    return this.apply(thisArg, args);
};

Prześledźmy, jak to się dzieje:

log.call.call(log, {a:4}, {a:5});
  this = log.call
  thisArg = log
  args = [{a:4}, {a:5}]

  log.call.apply(log, [{a:4}, {a:5}])

    log.call({a:4}, {a:5})
      this = log
      thisArg = {a:4}
      args = [{a:5}]

      log.apply({a:4}, [{a:5}])

Późniejsza część lub .mapwszystko

To jeszcze nie koniec. Zobaczmy, co się stanie, gdy dostarczysz funkcję do większości metod tablicowych:

function log () {
    console.log(this, arguments);
}

var arr = ['a', 'b', 'c'];
arr.forEach(log);
//window, ['a', 0, ['a', 'b', 'c']]
//window, ['b', 1, ['a', 'b', 'c']]
//window, ['c', 2, ['a', 'b', 'c']]
//^----^  ^-----------------------^
// this         arguments

Jeśli sami nie podamy thisargumentu, domyślnie window. Zwróć uwagę na kolejność, w jakiej argumenty są dostarczane do naszego wywołania zwrotnego, i zróbmy to jeszcze raz aż do 11:

arr.forEach(log.call, log);
//'a', [0, ['a', 'b', 'c']]
//'b', [1, ['a', 'b', 'c']]
//'b', [2, ['a', 'b', 'c']]
// ^    ^

Whoa whoa whoa ... cofnijmy się trochę. Co tu się dzieje? Jak widać w sekcji 15.4.4.18 , gdzie forEachjest zdefiniowane, dzieje się co następuje:

var callback = log.call,
    thisArg = log;

for (var i = 0; i < arr.length; i += 1) {
    callback.call(thisArg, arr[i], i, arr);
}

Tak więc otrzymujemy:

log.call.call(log, arr[i], i, arr);
//After one `.call`, it cascades to:
log.call(arr[i], i, arr);
//Further cascading to:
log(i, arr);

Teraz możemy zobaczyć, jak .map(Number.call, Number)działa:

Number.call.call(Number, arr[i], i, arr);
Number.call(arr[i], i, arr);
Number(i, arr);

Co zwraca transformację ibieżącego indeksu na liczbę.

Podsumowując,

Ekspresja

Array.apply(null, { length: 5 }).map(Number.call, Number);

Działa w dwóch częściach:

var arr = Array.apply(null, { length: 5 }); //1
arr.map(Number.call, Number); //2

Pierwsza część tworzy tablicę 5 niezdefiniowanych elementów. Druga przechodzi przez tę tablicę i pobiera jej indeksy, co daje tablicę indeksów elementów:

[0, 1, 2, 3, 4]
Zirak
źródło
@Zirak Proszę o pomoc w zrozumieniu następujących kwestii ahaExclamationMark.apply(null, Array(2)); //2, true. Dlaczego wraca 2i trueodpowiednio? Czy nie podajesz tu tylko jednego argumentu tj. Array(2)?
Geek
4
@Geek Przekazujemy tylko jeden argument apply, ale ten argument jest „rozdzielany” na dwa argumenty przekazywane do funkcji. Łatwiej to widać na pierwszych applyprzykładach. Pierwsza console.logpokazuje, że rzeczywiście otrzymaliśmy dwa argumenty (dwa elementy tablicy), a druga console.logpokazuje, że tablica ma key=>valueodwzorowanie w pierwszym gnieździe (jak wyjaśniono w pierwszej części odpowiedzi).
Zirak
4
Ze względu na (niektóre) prośby możesz teraz cieszyć się wersją audio: dl.dropboxusercontent.com/u/24522528/SO-answer.mp3
Zirak
1
Zauważ, że przekazanie NodeList, która jest obiektem hosta, do metody natywnej, takiej jak w, log.apply(null, document.getElementsByTagName('script'));nie jest wymagane do działania i nie działa w niektórych przeglądarkach, a także [].slice.call(NodeList)do przekształcenia NodeList w tablicę nie będzie działać w nich.
RobG
2
Jedna korekta: thisdomyślnie tylko Windoww trybie nieostrym.
ComFreek
21

Disclaimer : To jest bardzo formalny opis powyższym kodzie - to jak ja wiem, jak to wytłumaczyć. Aby uzyskać prostszą odpowiedź - sprawdź świetną odpowiedź Ziraka powyżej. To jest bardziej szczegółowa specyfikacja na twojej twarzy i mniej "aha".


Dzieje się tu kilka rzeczy. Rozbijmy to trochę.

var arr = Array.apply(null, { length: 5 }); // Create an array of 5 `undefined` values

arr.map(Number.call, Number); // Calculate and return a number based on the index passed

W pierwszym wierszu konstruktor tablicy jest wywoływany jako funkcja z Function.prototype.apply.

  • thisWartość null, która nie ma znaczenia dla konstruktora Array ( thisjest taka sama thisjak w związku według 15.3.4.3.2.a.
  • Następnie new Arraynazywane jest przekazaniem obiektu z lengthwłaściwością - co powoduje, że obiekt jest tablicą podobną do wszystkiego, do czego ma to znaczenie, z .applypowodu następującej klauzuli w .apply:
    • Niech len będzie wynikiem wywołania metody wewnętrznej [[Get]] argArray z argumentem „length”.
  • Jako takie, .applyprzechodzi od 0 do argumentów .length, ponieważ powołanie [[Get]]na { length: 5 }z wartościami od 0 do 4 plony undefinedkonstruktor tablica jest wywoływana z pięciu argumentów, których wartość jest undefined(uzyskanie własności nierejestrowanej obiektu).
  • Konstruktor tablicy jest wywoływany z 0, 2 lub więcej argumentami . Właściwość length nowo utworzonej tablicy jest ustawiana na liczbę argumentów zgodnie ze specyfikacją i wartości na te same wartości.
  • W ten sposób var arr = Array.apply(null, { length: 5 });tworzy listę pięciu niezdefiniowanych wartości.

Uwaga : Zwróć uwagę na różnicę między Array.apply(0,{length: 5})i Array(5), pierwszy tworzy pięciokrotnie prymitywny typ wartości, undefineda drugi tworzy pustą tablicę o długości 5. W szczególności ze względu na .mapzachowanie (8.b) i konkretnie [[HasProperty].

Zatem powyższy kod w zgodnej specyfikacji jest taki sam, jak:

var arr = [undefined, undefined, undefined, undefined, undefined];
arr.map(Number.call, Number); // Calculate and return a number based on the index passed

Teraz przejdźmy do drugiej części.

  • Array.prototype.mapwywołuje funkcję zwrotną (w tym przypadku Number.call) na każdym elemencie tablicy i używa określonej thiswartości (w tym przypadku ustawiając thiswartość na `Number).
  • Drugim parametrem wywołania zwrotnego w mapie (w tym przypadku Number.call) jest indeks, a pierwszym jest ta wartość.
  • Oznacza to, że Numberjest wywoływana z thisas undefined(wartością tablicy) i indeksem jako parametrem. Jest to więc w zasadzie to samo, co mapowanie każdego undefinedz nich na indeks tablicy (ponieważ wywołanie Numberwykonuje konwersję typu, w tym przypadku z numeru na numer bez zmiany indeksu).

Zatem powyższy kod przyjmuje pięć niezdefiniowanych wartości i odwzorowuje każdą z nich na jej indeks w tablicy.

Dlatego otrzymujemy wynik do naszego kodu.

Benjamin Gruenbaum
źródło
1
Dokumentacja : specyfikacja działania mapy: es5.github.io/#x15.4.4.19 , Mozilla ma przykładowy skrypt działający zgodnie z tą specyfikacją pod adresem developer.mozilla.org/en-US/docs/Web/JavaScript/ Odniesienie /…
Patrick Evans,
1
Ale dlaczego działa tylko z, Array.apply(null, { length: 2 })a nie, Array.apply(null, [2])które również wywoływałyby Arraykonstruktor przekazujący 2jako wartość długości? skrzypce
Andreas
@Andreas Array.apply(null,[2])jest podobne do Array(2)tego, że tworzy pustą tablicę o długości 2, a nie tablicę zawierającą wartość pierwotną undefineddwa razy. Zobacz moją ostatnią zmianę w notatce po pierwszej części, daj mi znać, czy jest wystarczająco jasna, a jeśli nie, wyjaśnię to.
Benjamin Gruenbaum
Nie zrozumiałem, jak to działa przy pierwszym uruchomieniu ... Po drugim czytaniu ma to sens. {length: 2}udaje tablicę z dwoma elementami, które Arraykonstruktor wstawiłby do nowo utworzonej tablicy. Ponieważ nie ma prawdziwej tablicy uzyskującej dostęp do nieobecnych elementów, undefinedktóre są następnie wstawiane. Niezła sztuczka :)
Andreas
5

Jak powiedziałeś, pierwsza część:

var arr = Array.apply(null, { length: 5 }); 

tworzy tablicę 5 undefinedwartości.

Druga część to wywołanie mapfunkcji tablicy, która przyjmuje 2 argumenty i zwraca nową tablicę o tym samym rozmiarze.

Pierwszym argumentem, który mapprzyjmuje, jest w rzeczywistości funkcja do zastosowania na każdym elemencie tablicy, oczekuje się, że będzie to funkcja, która przyjmuje 3 argumenty i zwraca wartość. Na przykład:

function foo(a,b,c){
    ...
    return ...
}

jeśli jako pierwszy argument przekażemy funkcję foo, zostanie ona wywołana dla każdego elementu z

  • a jako wartość bieżącego iterowanego elementu
  • b jako indeks bieżącego iterowanego elementu
  • c jako całą oryginalną tablicę

Drugi argument, który mapprzyjmuje, jest przekazywany do funkcji, którą przekazujesz jako pierwszy argument. Ale nie byłoby to a, b ani c w przypadku foo, byłoby this.

Dwa przykłady:

function bar(a,b,c){
    return this
}
var arr2 = [3,4,5]
var newArr2 = arr2.map(bar, 9);
//newArr2 is equal to [9,9,9]

function baz(a,b,c){
    return b
}
var newArr3 = arr2.map(baz,9);
//newArr3 is equal to [0,1,2]

i jeszcze jeden, żeby było jaśniej:

function qux(a,b,c){
    return a
}
var newArr4 = arr2.map(qux,9);
//newArr4 is equal to [3,4,5]

A co z Number.call?

Number.call jest funkcją, która przyjmuje 2 argumenty i próbuje przeanalizować drugi argument na liczbę (nie jestem pewien, co robi z pierwszym argumentem).

Ponieważ drugim mapprzekazywanym argumentem jest indeks, wartość, która zostanie umieszczona w nowej tablicy pod tym indeksem, jest równa indeksowi. Podobnie jak funkcja bazw powyższym przykładzie. Number.callspróbuje przeanalizować indeks - w naturalny sposób zwróci tę samą wartość.

Drugi argument, który przekazałeś mapfunkcji w swoim kodzie, w rzeczywistości nie ma wpływu na wynik. Popraw mnie, jeśli się mylę, proszę.

Tal Z
źródło
1
Number.callnie jest specjalną funkcją, która analizuje argumenty na liczby. To jest sprawiedliwe === Function.prototype.call. Tylko drugi argument, funkcja, która zostanie przekazana jako this-value do call, jest istotne - .map(eval.call, Number), .map(String.call, Number)i .map(Function.prototype.call, Number)wszystkie są równoważne.
Bergi
0

Tablica to po prostu obiekt zawierający pole „length” i niektóre metody (np. Push). Zatem arr in var arr = { length: 5}jest w zasadzie tym samym, co tablica, w której pola 0..4 mają wartość domyślną, która jest niezdefiniowana (czyli arr[0] === undefineddaje wartość true).
Jeśli chodzi o drugą część, map, jak sama nazwa wskazuje, odwzorowuje jedną tablicę na nową. Czyni to przechodząc przez oryginalną tablicę i wywołując funkcję mapowania na każdym elemencie.

Pozostało tylko przekonać cię, że wynikiem funkcji mapowania jest indeks. Sztuczka polega na użyciu metody o nazwie „call” (*), która wywołuje funkcję z małym wyjątkiem, że pierwszy parametr jest ustawiony jako kontekst „ten”, a drugi staje się pierwszym parametrem (i tak dalej). Przypadkowo, gdy wywoływana jest funkcja odwzorowania, drugim parametrem jest indeks.

Wreszcie wywołana metoda to Number "Class", a jak wiemy w JS, "Class" to po prostu funkcja, a ta (Number) oczekuje, że pierwszym parametrem będzie wartość.

(*) znajduje się w prototypie funkcji (a Number to funkcja).

MASHAL

shex
źródło
1
Między [undefined, undefined, undefined, …]a new Array(n)lub jest ogromna różnica {length: n}- te ostatnie są nieliczne , czyli nie mają elementów. Jest to bardzo istotne dla mapi dlatego Array.applyużyto dziwnego .
Bergi