Trzy lukrowane pączki leżące na blacie pełnym kolorowej posypki.

Spread, rest i default – Powtórka przed ReactJS #4

Funkcje wykorzystujące tablice to chleb powszedni każdego programisty JavaScript. Mając to na uwadze, komitet TC39 zatroszczył się o wprowadzenie kilku użytecznych usprawnień, które rozszerzają nasze możliwości. Oprócz nowinek, zastąpimy stare rozwiązania bardziej eleganckim kodem. Panie i Panowie, oto bohaterowie dzisiejszego wpisu: spread, rest i default.

Jako że tematyka jest stosunkowo prosta i intuicyjna, to skoncentruje się na omawianiu konkretnych, praktycznych zastosowań. Osoby czujące niedosyt teorytycznych wywodów (anyone?) odsyłam do materiałów źródłowych. Jeżeli miałeś w przeszłości styczność z tablicami, to na pewno szybko podchwycisz o co się rozchodzi z tym całym …wielokropkowym zamieszaniem.

Co omówię tym razem? Zobaczmy:

  1. Spread operator – jak to działa?
  2. Przykłady zastosowania spread operatora
  3. Parametry rest – jak to działa?
  4. Przykłady zastosowania parametrów rest
  5. Wartości domyślne

Uwaga, nie obyło się bez cotygodniowej niespodzianki. Na końcu wpisu czekają na Was odnośniki do zadań praktycznych ;).

Spread – czym jest ten operator?

Spread operator pozwala rozprowadzić poszczególne elementy obiektu iterowalnego (najczęstszym przedstawicielem tego gatunku są tablice). W przypadku funkcji, mamy taką możliwość, gdy oczekiwane jest zero lub więcej argumentów. To samo tyczy się elementów dla literałów tablicowych. Bardzo możliwe, że wraz z zakończeniem prac nad specyfikacją ES7 (obecnie znajduje się w trzecim etapie), zostanie wprowadzona możliwość wykorzystywania spread dla literałów obiektowych.

Składnia operatatora spread składa się z wielokropka ..., po którym odwołujemy się do identyfikatora lub literału.

Spread, w zależności od kontekstu, zamienia elementy obiektu iterowalnego w argumenty wywoływanej funkcji lub w elementy nowej tablicy.

console.log(...[1, 2, 3]); // 1 2 3

myFunction(...iterableObj); // Wywołanie funkcj

[...iterableObj, 4, 5, 6]; // Literał tablicowy

let objClone = { ...obj }; // Literał obiektowy (ES7, stage 3 draft)

Przykłady zastosowania spread operatora

Spread operator znajduje szerokie zastosowanie w pracy z funkcjami. Wśród książkowych przykładów króluje Math.max(). Metoda ta przyjmuje dowolną ilość argumentów, ale nie działa z tablicami. W przeszłości była to świetna okazja do napisania własnej funkcji obliczającej największy element tablicy. Teraz możemy wyręczyć się spread operatorem.

Math.max(-1, 5, 11, 3); // 11
Math.max([-1, 5, 11, 3]); // NaN
Math.max(...[-1, 5, 11, 3]); // 11

Nic nie stoi na przeszkodzie, aby podczas jednego wywołania przekazać do funkcji większą liczbę tablic.

const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];

Math.max(...arr1, ...arr2); // 6

Można również łaczyć taka składnię z użyciem wartości prymitywnych.

Math.max(0, ...arr1, 2, ...arr2, 8); // 8

Najpopularniejszym (i najszybszym) sposobem na stworzenie kopii tablicy jest wykorzystanie metody Array.prototype.slice. Jeżeli zależy nam na poprawie czytelności, możemy rozważyć zastosowanie spread opearatora.

const arr = [1, 2, 3];
const arr2 = arr.slice();
const arr3 = [...arr];

Warto pamiętać: obiekty wewnątrz tablicy są referencjami, więc nie otrzymujemy kopii ‚per se’.

Spread operator pozwala na proste łączenie tablic. Dzięki temu, może służyć jako czytelniejsza alternatywa dla Array.prototype.concat.

let arr = [3, 5, 1];
let arr2 = [8, 9, 15];

let merged = [0, ...arr, 2, ...arr2]; // [0, 3, 5, 1, 2, 8, 9, 15]

Ten sam efekt złączenia kilku tablic możemy osiągnąć wykorzystując Array.prototype.push wraz z spread operatorem.

// ES5 – apply():
var arr1 = ['a', 'b'];
var arr2 = ['c', 'd'];

arr1.push.apply(arr1, arr2); // ['a', 'b', 'c', 'd']

// ES6 – spread operator:
const arr1 = ['a', 'b'];
const arr2 = ['c', 'd'];

arr1.push(...arr2); // ['a', 'b', 'c', 'd']

Spread operator można stosować w połączeniu z wywołaniem konstruktora.

const myBday = new Date(...[1993, 11, 16]); // Thu Dec 16 1993 00:00:00 GMT+0100 (Środkowoeuropejski czas stand.)

Możemy wykonać prostą konwersję z zbioru (ang. Set) do tablicy.

const set = new Set([11, -1, 6]);
const arr = [...set]; // [11, -1, 6]

Podobnie sprawy mają się w przypadku konwersji NodeList/arguments.

[...document.querySelectorAll('div')] 

Ten sam efekt można otrzymać dzięki Array.from(). Ze względu na czytelność, zachęcam do wykorzystywania tej metody zamiast spread operatora.

Łącząc ze sobą działanie z konstruktorami i konwersję z zbiorów, możemy łatwo uzyskać tablicę z unikatowymi wartościami.

 const arr = [7, 3, 1, 3, 3, 7];
 console.log([...new Set(arr)]); // [7, 3, 1]

Skoro spread działa na wszystkich obiektach iterowalnych, to nie zaszkodzi potraktować nim jakiś łańcuch. W ten sposób otrzymamy tablicę wszystkich znaków.

Const algoSmart = 'AlgoSmart';

console.log([...algoSmart]); // ['A', 'l', 'g', 'o', 'S', 'm', 'a', 'r', 't'];

Parametry rest – jak to działa?

Parametry rest pozwalają zebrać nieokreśloną ilość argumentów jako tablicę. Tak samo jak w przypadku spread operatora, używamy w tym celu wielokropka.

function format(pattern, ...params) {
    return {pattern, params};
}
format(1, 2, 3); // { pattern: 1, params: [ 2, 3 ] }
format(); // { pattern: undefined, params: [] }

Jak widać, mimo takiej samej składni, parametry rest mają odwrotne działanie względem swojego kuzyna, spread operatora. Co decyduje o tym jaki efekt będzie miało zastosowanie wielokropka? Kontekst operacji. Rest w przypadku wywołania funkcji i destrukturyzacji tablicy zostanie użyte do pobrania listy przekazanych argumentów. Spread znajduje zastosowanie przy konstruowaniu tablicy. Do tego, podczas wywołania funkcji, umożliwia wypełnienie jej argumentów elementami tablicy.

// Rest

// Pobieranie listy przekazanych argumentów
function countArguments(...args) {  
  return args.length;
}

countArguments('welcome', 'to', 'Earth'); // => 3  

// Destrukturyzacja tablicy
let otherSeasons, autumn;  
[autumn, ...otherSeasons] = ['autumn', 'winter', 'spring', 'summer'];
console.log(otherSeasons);      // => ['winter', 'spring', 'summer']  

// Spread

// Konstrukcja tablicy
let warmSeasons = ['spring', 'summer'];
let coldSeasons = ['autumn', 'winter'];
let seasons = [...warmSeasons, ...coldSeasons]; ['autumn', 'winter', 'spring', 'summer'];

// Argumenty funkcji z tablicy feat. pogoda w Polsce
coldSeasons.push(...warmSeasons); // 
console.log(coldSeasons); // ['autumn', 'winter', 'spring', 'summer'];

Mam nadzieję, że rozróżnienie tych dwóch funkcjonalności nie stanowi już problemu.

Przykłady zastosowania rest operatora?

Najbardziej intuicyjnym zastosowaniem parametrów rest jest zastąpienie obiektu arguments.

// ES5 - arguments
function concat () { 
	return Array.prototype.slice.call(arguments).join(' ') 
} 

var result = concat('Jak', 'to', 'działa?'); 
console.log(result) // <- 'Jak to działa?' 

// ES6 - rest												
function concat (...words) { 
	return words.join(' ') 
} 
var result = concat('Teraz', 'widzę!');
console.log(result) // <- 'Teraz widzę!' 

Nawet tak prosta deklaracja zyskała na czytelności. Od razu wiadomo, co mamy przekazać do funkcji i co otrzymamy z powrotem. Jak wiele możemy zyskać zobrazuje bardziej skomplikowany przykład autorstwa Nicolása Bevacqua’y.

// ES5 - arguments
function sum () { 
  var numbers = Array.prototype.slice.call(arguments) // 
  var multiplier = numbers.shift() 
  var base = numbers.shift() 
  var sum = numbers.reduce((accumulator, num) => accumulator + num, base) 
  return multiplier * sum;
} 
var total = sum(2, 6, 10, 8, 9) 
console.log(total) // <- 66 

// ES6 - rest
function sum (multiplier, base, ...numbers) { 
  var sum = numbers.reduce((accumulator, num) => accumulator + num, base) 
	return multiplier * sum 
} 
var total = sum(2, 6, 10, 8, 9) 
console.log(total) // <- 66 

Otrzymaliśmy ściśle lepszy kod, tzn. zredukowaliśmy długość kodu, zyskując na czytelności. Tak się żyje.

Różnice pomiędzy parametrami rest i obiektem arguments.

  • Parametry rest to tylko te, które nie otrzymały oddzielnej nazwy. Arguments przechowuje wszystkie argumenty przekazane do funkcji.
  • Obiekt arguments nie jest prawdziwą tablicą, parametry rest są instancjami Array. W praktyce pozwala to na bezpośrednie zastosowanie metod pokroju map, forEach itd.

Zastosowanie parametrów rest zamiast obiektu arguments, w wielu przypadkach, pozwala na zredukowanie linijek kodu. Rozważmy jeden z wyjątków.

function foo(x=0, y=0) {
    console.log(`Liczba argumentów to: ${arguments.length}`);
    ···
}

Jak widać, pozostanie przy arguments daje nam możliwość ustawienia wartości domyślnych dla parametrów. W przypadku rest nie jest to takie proste, ale znajomość destrukturyzacji otwiera nam wiele ścieżek.
function foo(...args) {
    let [x=0, y=0] = args;
    console.log(`Liczba argumentów to: ${args.length}`);
    ···
}

Warto przypomnieć, że omawiając funkcje strzałkowe, jako jeden z minusów wskazałem brak dostępu do obiektu arguments. Parametry rest niwelują ten problem.

Co do błędów początkującego, parametry rest zbierają wszystkie pozostałe argumenty, więc następujący zapis nie ma sensu:

function f(arg1, ...rest, arg2) { 
  // error
}

Rest możemy wykorzystać podczas destrukturyzacji obiektów i tablic.

let { x, y, ...z } = { x: 1, y: 2, a: 3, b: 4 };
console.log(x); // 1
console.log(y); // 2
console.log(z); // { a: 3, b: 4 }

Same parametry rest również można poddawać destrukturyzacji.

function f(...[a, b, c]) {
  return a + b + c;
}

f(1)          // NaN  - 1 + undefined + undefined
f(1, 2, 3)    // 6
f(1, 2, 3, 4) // 6 - ostatni parametr nie jest uwzględniony podczas destrukturyzacji

console.log(1, 2, 3) // <- '1 2 3' 
console.log(...[1, 2, 3]) // <- '1 2 3' 

W tym przykładzie, parametr rest pozwala na zebranie wszystkich parametrów następujących po pierwszym, nazwanym parametrze. Następnie za pomocą funkcji map, możemy przemnożyć wszystkie elementy przez pierwszy parametr. Parame

function multiply(multiplier, ...theArgs) {
  return theArgs.map(function(element) {
    return multiplier * element;
  });
}

var arr = multiply(2, 1, 2, 3); 
console.log(arr); // [2, 4, 6]

Parametry rest, aż proszą się o korzystanie z dobrodziejstw programowania funkcyjnego. Jeżeli nie miałeś/aś jeszcze styczności z metodami filter, map i reduce, to bez obaw. Ich działanie przybliżę w jednym z nadchodzących wpisów.

Wartości domyślne

Na deser zostawiłem najprostszą z omawianych dzisiaj funkcjonalności. Zanim do niej przejdziemy, przeanalizujmy czego możemy spodziewać się w starszym kodzie. Praktycznie każdy zna stary, dobry patent z ustawianiem domyślnej wartości parametru za pomocą operatora ||.

function foo(x,y) {
    x = x || 11;
    y = y || 31;

    console.log( x + y );
}

Jednak to rozwiązanie ma jedną zasadnicza słabą stronę. Jeżeli umyślnie przekażemy do funkcji wartość, która jest przetwarzana jako ‚falsy’, to wbrew intencjom otrzymamy wartość domyślną.

	foo(0, 42);  // 53 zamiast 42

Możemy przebudować nasz kod z troska o właściwą interpretację ze strony silnika.

function foo(x,y) {
  x = (x !== undefined) ? x : 11;
  y = (y !== undefined) ? y : 31;

  console.log( x + y );
}

foo(0, 42);  // 42
foo(undefined, 6);  // 17

Wszystko fajnie, działa jak powinno (chyba, że przewidujemy intencjonalne wykorzystanie wartości undefined…). Tyle, że naprodukowaliśmy dużo dodatkowego kodu, który bardziej koncentruje się na walce z językiem niż rozwiązywaniu problemu.

Po raz kolejny ES6 wyszło na przeciw naszym potrzebom. Zobaczmy jak prezentują się wartości domyślne.

function foo(x = 11, y = 31) {
        console.log( x + y );
}
foo();  // 42
foo(5, 6);  // 11
foo(0, 42);  // 42

foo(5);  // 36 - niejawne undefined aktywuje wartość domyślną.
foo(5, undefined);  // 36 - tak samo w przypadku jawnego undefined.
foo(undefined, 6); // 17 

foo(5, null); // 5  - null jest interpretowany jako 0
foo(null, 6);  // 6  

Z powodu składni nie możemy stosować wartości domyślnych w połączeniu z spread/rest. Jak zaprezentowałem w przypadku restów, możemy ratować się destrukturyzacją.

Wartości domyślne mogą być dowolnym wyrażeniem, nawet wywołaniem funkcji.

var multiplier = 3;

function foo(x = 2 * multiplier) {
		return x;
}

foo(); // 6

Należy zachować ostrożność, skomplikowane operacje mogą doprowadzić do nieprzewidzianych komplikacji.

let w = 1, z = 2;

function foo( x = w + 1, y = x + 1, z = z + 1 ) {
        console.log( x, y, z );
}

foo(); // ReferenceError

Parametry posiadają swój własny zakres. W związku z tym następuje kilka następujących subtelności: w z wyrażenia w + 1 jest pierw wyszukiwane w zakresie parametrów. Nie zostaje odnalezione, więc silnik przechodzi do zewnętrznego zakresu skąd pobiera wartość zmiennej w. Nastepnie x użyte w wartości domyślnej dla parametru y zostaje odnalezione w zakresie parametrów i stamtąd zostaje pobrana wartość. Problem pojawia się, gdy użyjemy tego samego identyfikatora co obliczany parametr – jak w przypadku z. Wtedy silnik zauważa, że z nie zostało jeszcze niezainicjalizowane wewnątrz zakresu parametrów. Sprzecznie z intuicją, zamiast szukać wartość w zakresie otaczającym, wyrzuca ReferenceError.

Podsumowanie

Wow. Wyszedłem z błędnego założenia, że to będzie najkrótszy z dotychczasowych wpisów. W końcu ile można ględzić o trzech stosunkowo prostych funkcjonalnościach. Nic bardziej mylnego. To, co proste i intuicyjne doczekuje się największej uwagi. Liczba proponowanych zastosowań przerosła moje najśmielsze oczekiwania. Coś czuję, że podobnie jak ja, będziecie wciskać spread i rest gdzie tylko się da. Wszelkie zażalenia, związane z uwagami seniorów podczas code review, możecie kierować na moją skrzynkę mailową ;).

Zachęcam do śledzenia bloga na facebooku. Wróciłem do codziennego korzystania z Twittera. Zapraszam do ćwierkania :).

W przyszłym tygodniu będzie o obietnicach. Na blogowe moralizowanie (jeszcze) brakuje mi czasu, więc skupimy się na programowaniu asynchronicznym.

Zadania praktyczne

Każdy z odnośników prowadzi do edytora online z treścią zadania i przygotowanym środowiskiem. Poćwicz, co to by zdobyta wiedza nie uleciała w eter.

Źródła

Zdjęcie tytułowe autorstwa:

Patrick Fore