Création d'une plage en JavaScript - syntaxe étrange

129

J'ai rencontré le code suivant dans la liste de diffusion es-discuss:

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

Cela produit

[0, 1, 2, 3, 4]

Pourquoi est-ce le résultat du code? Qu'est-ce qu'il se passe ici?

Benjamin Gruenbaum
la source
2
IMO Array.apply(null, Array(30)).map(Number.call, Number)est plus facile à lire car il évite de prétendre qu'un objet simple est un tableau.
fncomp
10
@fncomp S'il vous plaît ne pas utiliser soit pour réellement créer une gamme. Non seulement c'est plus lent que l'approche simple, mais ce n'est pas aussi facile à comprendre. Il est difficile de comprendre la syntaxe (enfin, vraiment l'API et non la syntaxe) ici, ce qui en fait une question intéressante mais un code de production terrible IMO.
Benjamin Gruenbaum
Oui, je ne suggère à personne de l'utiliser, mais j'ai pensé qu'il était encore plus facile à lire, par rapport à la version littérale de l'objet.
fncomp
1
Je ne sais pas pourquoi quelqu'un voudrait faire cela. Le temps qu'il faut pour créer le tableau de cette façon aurait pu être fait de manière légèrement moins sexy mais beaucoup plus rapide: jsperf.com/basic-vs-extreme
Eric Hodonsky

Réponses:

263

Comprendre ce "hack" nécessite de comprendre plusieurs choses:

  1. Pourquoi nous ne faisons pas que Array(5).map(...)
  2. Comment Function.prototype.applygère les arguments
  3. Comment Arraygère plusieurs arguments
  4. Comment la Numberfonction gère les arguments
  5. Que Function.prototype.callfait

Ce sont des sujets assez avancés en javascript, donc ce sera plus que plutôt long. Nous allons commencer par le haut. Bouclez votre ceinture!

1. Pourquoi pas simplement Array(5).map?

Qu'est-ce qu'un tableau, vraiment? Un objet normal, contenant des clés entières, qui correspondent à des valeurs. Il a d'autres fonctionnalités spéciales, par exemple la lengthvariable magique , mais au fond, c'est une key => valuecarte régulière , comme n'importe quel autre objet. Jouons un peu avec les tableaux, d'accord?

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']

Nous arrivons à la différence inhérente entre le nombre d'éléments dans le tableau arr.length, et le nombre de key=>valuemappages du tableau, qui peut être différent de arr.length.

Le développement du tableau via arr.length ne crée pas de nouveaux key=>valuemappages, donc ce n'est pas que le tableau a des valeurs non définies, il n'a pas ces clés . Et que se passe-t-il lorsque vous essayez d'accéder à une propriété inexistante? Vous obtenez undefined.

Maintenant, nous pouvons lever un peu la tête et voir pourquoi des fonctions comme arr.mapne pas marcher sur ces propriétés. Si elle arr[3]était simplement indéfinie et que la clé existait, toutes ces fonctions de tableau la parcourraient comme toute autre valeur:

//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"]

J'ai intentionnellement utilisé un appel de méthode pour prouver davantage le fait que la clé elle-même n'était jamais là: l'appel undefined.toUpperCaseaurait soulevé une erreur, mais ce n'est pas le cas. Pour prouver que :

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

Et maintenant nous arrivons à mon point: comment Array(N)ça va. La section 15.4.2.2 décrit le processus. Il y a un tas de jumbo mumbo dont nous ne nous soucions pas, mais si vous parvenez à lire entre les lignes (ou vous pouvez simplement me faire confiance sur celui-ci, mais ne le faites pas), cela se résume essentiellement à ceci:

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

(fonctionne sous l'hypothèse (qui est vérifiée dans la spécification réelle) qui lenest un uint32 valide, et pas n'importe quel nombre de valeur)

Alors maintenant, vous pouvez voir pourquoi cela Array(5).map(...)ne fonctionnerait pas - nous ne définissons pas les lenéléments sur le tableau, nous ne créons pas les key => valuemappages, nous modifions simplement la lengthpropriété.

Maintenant que nous avons cela de côté, regardons la deuxième chose magique:

2. Comment Function.prototype.applyfonctionne

Ce qui applyfait, c'est prendre un tableau et le dérouler en tant qu'arguments d'un appel de fonction. Cela signifie que les éléments suivants sont à peu près les mêmes:

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

Maintenant, nous pouvons faciliter le processus de voir comment applyfonctionne en enregistrant simplement la argumentsvariable spéciale:

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]

Il est facile de prouver ma réclamation dans l'avant-dernier exemple:

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

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

(oui, jeu de mots prévu). Le key => valuemappage n'a peut-être pas existé dans le tableau auquel nous sommes passés apply, mais il existe certainement dans la argumentsvariable. C'est la même raison pour laquelle le dernier exemple fonctionne: les clés n'existent pas sur l'objet que nous passons, mais elles existent dans arguments.

Pourquoi donc? Regardons la section 15.3.4.3 , où Function.prototype.applyest définie. Surtout des choses qui ne nous intéressent pas, mais voici la partie intéressante:

  1. Soit len ​​le résultat de l'appel de la méthode interne [[Get]] de argArray avec l'argument "length".

Ce qui signifie essentiellement: argArray.length. La spécification procède ensuite à une simple forboucle sur les lengthéléments, en faisant un listdes valeurs correspondantes ( listest un vaudou interne, mais c'est fondamentalement un tableau). En termes de code très, très lâche:

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);
};

Donc, tout ce dont nous avons besoin pour imiter un argArraydans ce cas est un objet avec une lengthpropriété. Et maintenant, nous pouvons voir pourquoi les valeurs ne sont pas définies, mais les clés ne le sont pas arguments: nous créons les key=>valuemappages.

Ouf, cela n'aurait peut-être pas été plus court que la partie précédente. Mais il y aura du gâteau quand nous aurons fini, alors soyez patient! Cependant, après la section suivante (qui sera courte, je le promets), nous pouvons commencer à disséquer l'expression. Au cas où vous auriez oublié, la question était de savoir comment fonctionne ce qui suit:

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

3. Comment Arraygère plusieurs arguments

Alors! Nous avons vu ce qui se passe lorsque vous passez un lengthargument à Array, mais dans l'expression, nous passons plusieurs choses en arguments (un tableau de 5 undefined, pour être exact). La section 15.4.2.1 nous dit quoi faire. Le dernier paragraphe est tout ce qui compte pour nous, et il est formulé de manière très étrange, mais il se résume en quelque sorte à:

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! Nous obtenons un tableau de plusieurs valeurs indéfinies, et nous renvoyons un tableau de ces valeurs indéfinies.

La première partie de l'expression

Enfin, nous pouvons déchiffrer ce qui suit:

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

Nous avons vu qu'il renvoie un tableau contenant 5 valeurs indéfinies, avec des clés toutes existantes.

Passons maintenant à la deuxième partie de l'expression:

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

Ce sera la partie la plus facile et non alambiquée, car elle ne repose pas tellement sur des hacks obscurs.

4. Comment Numbertraite l'entrée

Faire Number(something)( section 15.7.1 ) convertit somethingen nombre, et c'est tout. Comment faire cela est un peu compliqué, en particulier dans le cas des chaînes, mais l'opération est définie dans la section 9.3 au cas où cela vous intéresserait.

5. Jeux de Function.prototype.call

callest applyle frère de, défini à la section 15.3.4.4 . Au lieu de prendre un tableau d'arguments, il prend simplement les arguments reçus et les transmet.

Les choses deviennent intéressantes lorsque vous en chaînez plus d'un callensemble, augmentez l'étrange à 11:

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

Cela vaut vraiment la peine tant que vous ne comprenez pas ce qui se passe. log.callest juste une fonction, équivalente à la callméthode de toute autre fonction , et en tant que telle, a également une callméthode sur elle-même:

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

Et que fait call-on? Il accepte un thisArget un tas d'arguments et appelle sa fonction parente. Nous pouvons le définir via apply (encore une fois, un code très lâche, ne fonctionnera pas):

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

Voyons comment cela se passe:

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}])

La dernière partie, ou le .mapde tout

Ce n'est pas encore fini. Voyons ce qui se passe lorsque vous fournissez une fonction à la plupart des méthodes de tableau:

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

Si nous ne fournissons pas d' thisargument nous-mêmes, la valeur par défaut est window. Prenez note de l'ordre dans lequel les arguments sont fournis à notre rappel, et répétons-le jusqu'à 11:

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

Whoa whoa whoa ... reculons un peu. Que se passe t-il ici? Nous pouvons voir dans la section 15.4.4.18 , où forEachest défini, ce qui suit à peu près se produit:

var callback = log.call,
    thisArg = log;

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

Donc, nous obtenons ceci:

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);

Maintenant, nous pouvons voir comment .map(Number.call, Number)fonctionne:

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

Ce qui renvoie la transformation de i, l'index actuel, en un nombre.

En conclusion,

L'expression

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

Fonctionne en deux parties:

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

La première partie crée un tableau de 5 éléments non définis. Le second parcourt ce tableau et prend ses indices, ce qui donne un tableau d'indices d'élément:

[0, 1, 2, 3, 4]
Zirak
la source
@Zirak S'il vous plaît, aidez-moi à comprendre ce qui suit ahaExclamationMark.apply(null, Array(2)); //2, true. Pourquoi revient-il 2et truerespectivement? Ne passez-vous pas un seul argument, c'est-à-dire Array(2)ici?
Geek
4
@Geek Nous ne passons qu'un seul argument à apply, mais cet argument est "splatté" en deux arguments passés à la fonction. Vous pouvez le voir plus facilement dans les premiers applyexemples. Le premier console.logmontre alors qu'en effet, nous avons reçu deux arguments (les deux éléments du tableau), et le second console.logmontre que le tableau a un key=>valuemappage dans le 1er slot (comme expliqué dans la 1ère partie de la réponse).
Zirak du
4
En raison de (certaines) demandes , vous pouvez maintenant profiter de la version audio: dl.dropboxusercontent.com/u/24522528/SO-answer.mp3
Zirak
1
Notez que passer une NodeList, qui est un objet hôte, à une méthode native comme dans log.apply(null, document.getElementsByTagName('script'));n'est pas nécessaire pour fonctionner et ne fonctionne pas dans certains navigateurs, et [].slice.call(NodeList)transformer une NodeList en un tableau ne fonctionnera pas non plus.
RobG
2
Une correction: par thisdéfaut uniquement Windowen mode non strict.
ComFreek
21

Avertissement : Ceci est une description très formelle du code ci-dessus - c'est ainsi que je sais comment l'expliquer. Pour une réponse plus simple, consultez la bonne réponse de Zirak ci-dessus. Ceci est une spécification plus approfondie dans votre visage et moins "aha".


Plusieurs choses se passent ici. Disons-le un peu.

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

Dans la première ligne, le constructeur de tableau est appelé en tant que fonction avec Function.prototype.apply.

  • La thisvaleur est nullqui n'a pas d'importance pour le constructeur Array ( thisest la même thisque dans le contexte selon 15.3.4.3.2.a.
  • Ensuite, new Arrayon appelle passer un objet avec une lengthpropriété - qui fait de cet objet un tableau comme pour tout ce qui compte en .applyraison de la clause suivante dans .apply:
    • Soit len ​​le résultat de l'appel de la méthode interne [[Get]] de argArray avec l'argument "length".
  • En tant que tel, .applyest le passage d' arguments de 0 à .length, étant donné que l' appel [[Get]]sur { length: 5 }les valeurs 0 à 4 rendements undefineddu constructeur de tableau est appelé avec cinq arguments dont la valeur est undefined(obtenir une propriété d'un objet non déclaré).
  • Le constructeur de tableau est appelé avec 0, 2 arguments ou plus . La propriété length du tableau nouvellement construit est définie sur le nombre d'arguments selon la spécification et les valeurs sur les mêmes valeurs.
  • var arr = Array.apply(null, { length: 5 });Crée ainsi une liste de cinq valeurs non définies.

Remarque : Notez ici la différence entre Array.apply(0,{length: 5})et Array(5), le premier créant cinq fois le type de valeur primitive undefinedet le second créant un tableau vide de longueur 5. Plus précisément, à cause du .mapcomportement de (8.b) et spécifiquement [[HasProperty].

Ainsi, le code ci-dessus dans une spécification conforme est le même que:

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

Passons maintenant à la deuxième partie.

  • Array.prototype.mapappelle la fonction de rappel (dans ce cas Number.call) sur chaque élément du tableau et utilise la thisvaleur spécifiée (dans ce cas, définissant la thisvaleur sur `Number).
  • Le deuxième paramètre du rappel dans la carte (dans ce cas Number.call) est l'index, et le premier est la valeur this.
  • Cela signifie qu'il Numberest appelé avec thisas undefined(la valeur du tableau) et l'index comme paramètre. C'est donc fondamentalement la même chose que de mapper chacun undefinedà son index de tableau (puisque l'appel Numbereffectue une conversion de type, dans ce cas, du nombre au nombre ne change pas l'index).

Ainsi, le code ci-dessus prend les cinq valeurs non définies et mappe chacune à son index dans le tableau.

C'est pourquoi nous obtenons le résultat dans notre code.

Benjamin Gruenbaum
la source
1
Pour les documents: Spécification du fonctionnement de la carte: es5.github.io/#x15.4.4.19 , Mozilla a un exemple de script qui fonctionne selon cette spécification sur developer.mozilla.org/en-US/docs/Web/JavaScript/ Référence /…
Patrick Evans
1
Mais pourquoi ne fonctionne-t-il qu'avec Array.apply(null, { length: 2 })et non avec Array.apply(null, [2])qui appellerait également le Arrayconstructeur passant 2comme valeur de longueur? fiddle
Andreas
@Andreas Array.apply(null,[2])est comme Array(2)qui crée un tableau vide de longueur 2 et non un tableau contenant la valeur primitive undefineddeux fois. Voir ma dernière modification dans la note après la première partie, faites-moi savoir si elle est suffisamment claire et sinon je clarifierai cela.
Benjamin Gruenbaum
Je n'ai pas compris comment cela fonctionne sur la première manche ... Après la deuxième lecture, c'est logique. {length: 2}simule un tableau avec deux éléments que le Arrayconstructeur insérerait dans le tableau nouvellement créé. Comme il n'y a pas de tableau réel accédant aux éléments non présents, le rendement undefinedest alors inséré. Nice trick :)
Andreas
5

Comme vous l'avez dit, la première partie:

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

crée un tableau de 5 undefinedvaleurs.

La seconde partie appelle la mapfonction du tableau qui prend 2 arguments et renvoie un nouveau tableau de même taille.

Le premier argument qui mapprend est en fait une fonction à appliquer sur chaque élément du tableau, on s'attend à ce que ce soit une fonction qui prend 3 arguments et renvoie une valeur. Par exemple:

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

si nous passons la fonction foo comme premier argument, elle sera appelée pour chaque élément avec

  • a comme valeur de l'élément itéré courant
  • b comme index de l'élément itéré courant
  • c comme l'ensemble du tableau d'origine

Le deuxième argument qui mapprend est passé à la fonction que vous passez comme premier argument. Mais ce ne serait pas a, b, ni c en cas de foo, ça le serait this.

Deux exemples:

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]

et un autre juste pour que ce soit plus clair:

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

Alors qu'en est-il de Number.call?

Number.call est une fonction qui prend 2 arguments et essaie d'analyser le deuxième argument en un nombre (je ne suis pas sûr de ce qu'il fait avec le premier argument).

Étant donné que le deuxième argument qui mappasse est l'index, la valeur qui sera placée dans le nouveau tableau à cet index est égale à l'index. Tout comme la fonction bazde l'exemple ci-dessus. Number.callessaiera d'analyser l'index - il renverra naturellement la même valeur.

Le deuxième argument que vous avez passé à la mapfonction dans votre code n'a pas réellement d'effet sur le résultat. Corrigez-moi si je me trompe, s'il vous plaît.

Tal Z
la source
1
Number.calln'est pas une fonction spéciale qui analyse les arguments en nombres. C'est juste === Function.prototype.call. Seul le second argument, la fonction qui est passé comme this-value à call, est pertinente - .map(eval.call, Number), .map(String.call, Number)et .map(Function.prototype.call, Number)sont tous équivalents.
Bergi
0

Un tableau est simplement un objet comprenant le champ 'length' et certaines méthodes (par exemple push). Donc arr in var arr = { length: 5}est fondamentalement le même qu'un tableau où les champs 0..4 ont la valeur par défaut qui n'est pas définie (c'est-à-dire qui arr[0] === undefineddonne vrai).
Quant à la deuxième partie, mappez, comme son nom l'indique, mappe d'un tableau à un nouveau. Il le fait en parcourant le tableau d'origine et en invoquant la fonction de mappage sur chaque élément.

Il ne reste plus qu'à vous convaincre que le résultat de la fonction de mappage est l'index. L'astuce consiste à utiliser la méthode nommée 'call' (*) qui invoque une fonction à la petite exception que le premier paramètre est défini comme étant le contexte 'this' et le second devient le premier paramètre (et ainsi de suite). Par coïncidence, lorsque la fonction de mappage est appelée, le deuxième paramètre est l'index.

Last but not least, la méthode qui est invoquée est le Number "Class", et comme nous le savons dans JS, une "Class" est simplement une fonction, et celle-ci (Number) s'attend à ce que le premier paramètre soit la valeur.

(*) trouvé dans le prototype de Function (et Number est une fonction).

MASHAL

shex
la source
1
Il y a une énorme différence entre [undefined, undefined, undefined, …]et new Array(n)ou {length: n}- ces derniers sont rares , c'est-à-dire qu'ils n'ont aucun élément. C'est très pertinent pour map, et c'est pourquoi l'impair a Array.applyété utilisé.
Bergi