Rozdział 4. Struktury danych: obiekty i tablice

> Dodaj do ulubionych

Ten rozdział poświęcimy na rozwiązanie kilku prostych problemów. W międzyczasie opiszę dwa nowe typy danych — tablice i obiekty — oraz przybliżę Ci kilka związanych z nimi technik.

Rozważmy następującą sytuację: Twoja szalona ciotka Emilia, która podobno mieszka z 50 kotami (nigdy nie udało Ci się ich wszystkich policzyć), regularnie wysyła Ci e-maile, żeby poinformować Cię o swoich przeżyciach. Zwykle otrzymujesz wiadomości tego rodzaju:

Drogi siostrzeńcu,

Twoja matka powiedziała mi, że zacząłeś wykonywać akrobacje ze spadochronem. Czy to prawda? Uważaj na siebie, młody człowieku! Pamiętasz, co się przytrafiło mojemu mężowi? A to było tylko drugie piętro!

A tak w ogóle, u mnie sporo się dzieje. Cały tydzień próbowałam zwrócić na siebie uwagę Pana Kowalskiego, tego miłego jegomościa, który wprowadził się do mieszkania obok, ale wydaje mi się, że on nie lubi kotów. A może ma na nie alergię? Następnym razem, gdy się z nim spotkam położę mu na ramieniu Grubego Igora, ciekawe co zrobi.

A jeśli chodzi o ten przekręt, o którym pisałam wcześniej, to wszystko idzie, jak po maśle. Otrzymałam już pięć „zapłat” i tylko jedną skargę. Ale zaczyna mnie dręczyć sumienie. Pewnie masz rację, że to może być nielegalne.

(… itd. …)

Całuję, Ciocia Emilia

odeszli 04.27.2006: Black Leclère

urodzeni 04.05.2006 (matka Lady Penelope): Red Lion, Doctor Hobbles 3, Little Iroquois

Aby zrobić przyjemność starszej pani, mógłbyś zawsze wtrącić jakieś pytanie o jej koty, w stylu „P.S. Mam nadzieję, że Doktor Hobbles 2. dobrze bawił się na swoich urodzinach w niedzielę!” albo „Jak się miewa staruszka Penelopa?. Ma już pięć lat, prawda?”. Zachowując takt raczej nie pytałbyś o zdechłe koty. Masz już pokaźny zbiór starych e-maili od ciotki i na szczęście na końcu każdego z nich ciotka zamieściła informację o zdechłych i nowo narodzonych kotach w dokładnie takim samym formacie.

Nie masz jednak ochoty przeglądać tych wszystkich maili. Całe szczęście, że właśnie szukaliśmy jakiegoś przykładowego problemu do rozwiązania. Skoro tak, to spróbujemy napisać program rozwiązujący opisany problem. Zaczniemy od napisania programu zwracającego listę kotów, które nadal są żywe od ostatniego e-maila.

Żeby ubiec Twoje pytanie, wyjaśnię, że na początku Waszej korespondencji ciotka Emilia miała tylko jednego kota, o imieniu Spot. (w tamtych czasach ciotka preferowała jeszcze dość konwencjonalne nazwy).


Kocie oczy

Program o wiele łatwiej jest napisać, gdy ma się przynajmniej mgliste pojęcie, do czego ma służyć. Dlatego poniżej przedstawiam plan aplikacji:

  1. Program rozpocznie działanie ze zbiorem kocich imion zawierającym tylko pozycję Spot.
  2. Program przejrzy wszystkie e-maile w chronologicznej kolejności.
  3. Program wyszuka akapity zaczynające się od słowa „urodzeni” lub „odeszli”.
  4. Program doda imiona z akapitów zaczynających się od słowa „urodzeni” do naszego zbioru.
  5. Program usunie imiona z akapitów zaczynających się od słowa „odeszli” z naszego zbioru.

Pobieranie imion z akapitów będzie odbywać się następująco:

  1. Znalezienie w akapicie dwukropka.
  2. Pobranie tego, co znajduje się za dwukropkiem.
  3. Podzielenie pobranego tekstu na poszczególne imiona wg przecinków.

Może się wydawać trochę ryzykowne zawierzenie, że ciotka Emilia zawsze stosuje dokładnie ten sam format i nigdy nie zapomina ani nie robi błędów w imionach, ale taka już właśnie ta ciotka jest.


Własności

Najpierw opowiem Ci o własnościach. Z wieloma wartościami w języku JavaScript powiązane są inne wartości. Te powiązania nazywają się własnościami. Każdy łańcuch ma własność o nazwie length, która odnosi się do liczby oznaczającej, z ilu znaków ten łańcuch się składa.

Dostęp do własności można uzyskać na dwa sposoby:

var text = "fioletowa mgiełka";
show(text["length"]);
show(text.length);

Drugi z przedstawionych rodzajów zapisu jest skrótem pierwszego i można go stosować tylko wtedy, gdy nazwa własności mogłaby być poprawną nazwą zmiennej ― nie zawiera spacji ani znaków specjalnych oraz nie zaczyna się od cyfry.

Wartości null i undefined nie mają żadnych własności. Próba odczytania własności jednej z nich zakończy się spowodowaniem błędu. Jeśli chcesz zobaczyć, jakie powiadomienia o błędach mogą wyświetlać przeglądarki (w niektórych te komunikaty wyglądają bardzo tajemniczo), gdy napotkają taki kod, wykonaj poniższy program.

var nothing = null;
show(nothing.length);

Obiekty

Własności wartości łańcuchowej nie można zmieniać. Własność length to tylko jedna z wielu własności i nie można żadnych usuwać ani dodawać.

Z wartościami typu obiektowego jest jednak inaczej. Ich najważniejszą rolą jest właśnie przechowywać inne wartości. Można powiedzieć, że wartości te mają zestaw przyssawek w postaci własności. Można ja modyfikować, usuwać, a nawet dodawać nowe.

Obiekt można zapisać następująco:

var cat = {colour: "grey", name: "Spot", size: 46};
cat.size = 47;
show(cat.size);
delete cat.size;
show(cat.size);
show(cat);

Podobnie jak zmienne, każda własność związana z obiektem ma tekstową etykietę. Pierwsza z powyższych instrukcji tworzy obiekt, w którym znajduje się własność "colour" odnosząca się do łańcucha "grey", własność "name" odnosząca się do łańcucha "Spot" oraz własność "size" odnosząca się do liczby 46. Druga instrukcja przypisuje własności size nową wartość, co robi się w taki sam sposób, jak modyfikacja wartości zmiennej.

Słowo kluczowe delete usuwa własności. Próba odczytu nieistniejącej własności powoduje zwrócenie wartości undefined.

Jeżeli operator = zostanie użyty do ustawienia własności, która jeszcze nie istnieje, to taka własność zostanie utworzona i dodana do obiektu.

var empty = {};
empty.notReally = 1000;
show(empty.notReally);

Własności, których nazwy nie mogłyby zostać użyte jako nazwy zmiennych muszą przy tworzeniu obiektu znajdować się w cudzysłowach, a gdy się ich potem używa, trzeba używać kwadratowych nawiasów:

var thing = {"gabba gabba": "hey", "5": 10};
show(thing["5"]);
thing["5"] = 20;
show(thing[2 + 3]);
delete thing["gabba gabba"];

Jak widać, w nawiasach kwadratowych może znajdować się dowolne wyrażenie. Jest ono konwertowane na łańcuch, aby można było określić nazwę własności, do której się odnosi. Jako nazw własności można używać nawet zmiennych:

var propertyName = "length";
var text = "mainline";
show(text[propertyName]);

Do sprawdzenia czy obiekt ma określoną własność służy operator in. Zwraca on wartość logiczną.

var chineseBox = {};
chineseBox.content = chineseBox;
show("content" in chineseBox);
show("content" in chineseBox.content);

Gdy w konsoli wyświetlone są wartości obiektów, można je kliknąć, aby zbadać ich własności. Powoduje to zamianę okna wyjściowego na okno inspekcji. Kliknięcie znajdującego się w prawym górnym rogu tego okna znaku „x” powoduje powrót do okna wyjściowego, natomiast strzałka służy do przejścia do własności poprzednio badanego obiektu.

show(chineseBox);

Ćwiczenie 4.1

W rozwiązaniu problemu z kotami wymienione zostało słowo „zbiór”. Zbiór to zestaw wartości, w którym żadna wartość nie może się powtórzyć. Jeśli imiona są łańcuchami, czy wiesz, jak użyć obiektu do reprezentowania zbioru imion?

Pokaż jak dodać i usunąć imię z tego zbioru oraz jak sprawdzić, czy dane imię w nim występuje.


Jak widać, wartości obiektów mogą się zmieniać. Typy wartości opisane w rozdziale 2 są niezmienne, tzn. nie można zmienić istniejących wartości tych typów. Można je łączyć i tworzyć z nich nowe wartości, ale jeśli weźmiemy dowolną wartość łańcuchową, to znajdującego się w niej tekstu nie możemy zmienić. Natomiast w obiektach treść wartości można zmieniać poprzez zmianę ich własności.

Jeśli mamy dwie liczby 120 i 120, to praktycznie zawsze możemy je uważać za dokładnie tę samą liczbę. W przypadku obiektów posiadanie dwóch referencji do tego samego obiektu i posiadanie dwóch różnych obiektów zawierających takie same własności to nie to samo. Rozważmy poniższy przykład:

var object1 = {value: 10};
var object2 = object1;
var object3 = {value: 10};

show(object1 == object2);
show(object1 == object3);

object1.value = 15;
show(object2.value);
show(object3.value);

object1 i object2 to dwie zmienne mające tę samą wartość. Tak naprawdę jest tylko jeden obiekt i dlatego zmiana wartości obiektu object1 powoduje również zmianę wartości obiektu object2. Zmienna object3 wskazuje inne obiekt, który początkowo ma takie same własności, jak object1, ale jest osobnym obiektem.

Operator == języka JavaScript przy porównywaniu obiektów zwraca wartość true tylko wtedy, gdy oba argumenty są dokładnie tą samą wartością. Wynik porównywania różnych obiektów o identycznej zawartości będzie negatywny (false). W niektórych przypadkach jest to przydatne, a w innych niepraktyczne.


Wartości obiektowe mogą być używane do wielu różnych celów. Tworzenie zbioru to tylko jeden z nich. W tym rozdziale poznasz jeszcze kilka zastosowań tych struktur, a kolejne ważne sposoby ich użycia zostały opisane w rozdziale 8.

W planie rozwiązania problemu z kotami ― w istocie lepiej mówić na to algorytm, dzięki czemu inni będą myśleli, że wiemy o czym mówimy ― w algorytmie, jest mowa o przejrzeniu wszystkich e-maili znajdujących się w archiwum. Jak wygląda te archiwum? I gdzie się znajduje?

Drugim z tych pytań na razie się nie przejmuj. W rozdziale 14 poznasz kilka sposobów importowania danych do programów, a na razie przyjmiemy, że e-maile w jakiś magiczny sposób stały się dostępne. W komputerach czarowanie jest naprawdę łatwe.


Sposób przechowywania archiwum jest jednak ciekawą kwestią. W archiwum znajduje się pewna liczba e-maili. Wiadomość e-mail, co oczywiste, może być łańcuchem. W związku z tym całe archiwum można by było umieścić w jednym wielkim łańcuchu, ale to by było niepraktyczne. Potrzebujemy kolekcji osobnych łańcuchów.

Do przechowywania kolekcji łańcuchów dobrze nadają się obiekty. Można np. utworzyć obiekt w ten sposób:

var mailArchive = {"Pierwszy e-mail": "Drogi siostrzeńcu, ...",
                   "Drugi e-mail": "..."
                   /* itd. ... */};

Ale w ten sposób trudno by było przejrzeć e-maile od początku do końca, bo skąd program ma wiedzieć, jakie są nazwy własności? Z tym problemem można sobie poradzić stosując przewidywalne nazwy własności:

var mailArchive = {0: "Drogi siostrzeńcu, ... (mail 1)",
                   1: "(mail 2)",
                   2: "(mail 3)"};

for (var current = 0; current in mailArchive; current++)
  print("Przetwarzanie e-maila nr ", current, ": ", mailArchive[current]);

Tablice

Mamy szczęście, że istnieje specjalny rodzaj obiektów przeznaczony właśnie do takich zastosowań. Jest to tablica, która dodatkowo zawiera pewne udogodnienia, jak np. własność length pozwalająca sprawdzić, ile wartości się w niej znajduje oraz obsługuje różne przydatne rodzaje operacji.

Nowe tablice tworzy się przy użyciu kwadratowych nawiasów ([ i ]):

var mailArchive = ["e-mail 1", "e-mail 2", "e-mail 3"];

for (var current = 0; current < mailArchive.length; current++)
  print("Przetwarzanie e-maila nr ", current, ": ", mailArchive[current]);

W tym przykładzie numery elementów nie są definiowane bezpośrednio. Pierwszemu automatycznie przypisywany jest numer 0, drugiemu — 1 itd.

Dlaczego numerowanie zaczyna się od 0? Ludzie zwykle zaczynają liczyć od 1. Jednak w przypadku kolekcji elementów bardziej praktyczne jest rozpoczynanie liczenia od 0. Po prostu zaakceptuj to, a z czasem się przyzwyczaisz.

Skoro numeracja rozpoczyna się od 0, to znaczy, że w kolekcji X elementów ostatni element ma numer X - 1. Dlatego właśnie w pętli for w powyższym przykładzie znajduje się warunek current < mailArchive.length. Na pozycji mailArchive.length nie ma żadnego elementu, a więc gdy zmienna current uzyska tę wartość, kończymy iterowanie.


Ćwiczenie 4.2

Napisz funkcję o nazwie range przyjmującą jako argument liczbę całkowitą i zwracającą tablicę wszystkich liczb od 0 do tej liczby włącznie.

Pustą tablicę można utworzyć pisząc []. Pamiętaj też, że własności do obiektów, a więc też i tablic, można dodawać przypisując im wartości za pomocą operatora =. Własność length jest aktualizowana automatycznie, gdy są dodawane kolejne elementy.

function range(upto) {
  var result = [];
  for (var i = 0; i <= upto; i++)
    result[i] = i;
  return result;
}
show(range(4));

Zamiast zmienną pętlową nazywać counter albo current, jak było do tej pory, tym razem nadałem jej nazwę i. Stosowanie jednoliterowych nazw, zazwyczaj i, j lub k, dla zmiennych pętlowych jest szeroko przyjętym zwyczajem wśród programistów. Źródeł jego powstania należy upatrywać przede wszystkim w lenistwie: każdy woli wpisać jedną literę zamiast siedmiu, a nazwy typu counter albo current i tak niewiele wyjaśniają, do czego dana zmienna służy.

Jeśli jednak w programie znajdzie się zbyt dużo jednoliterowych nazw zmiennych, to zrozumienie sposobu jego działania może stać się strasznie trudne. We własnych programach staram się tak krótkich nazw używać tylko w kilku typowych przypadkach. Należą do nich także niezbyt rozbudowane pętle. Jeśli pętla zawiera inną pętlę, która również ma zmienną o nazwie i, wewnętrzna pętla zmodyfikuje zmienną używaną przez zewnętrzną pętlę i nastąpi wielka awaria. W wewnętrznej pętli można by było zatem użyć nazwy j, ale ogólnie rzecz biorąc przyjmuje się, że jeśli pętla jest rozbudowana, powinno się użyć jakiejś znaczącej nazwy zmiennej, aby łatwiej było zrozumieć sposób działania całej konstrukcji.


Zarówno obiekty łańcuchowe jak i tablicowe oprócz własności length zawierają jeszcze kilka innych własności odnoszących się do wartości funkcyjnych.

var doh = "Doh";
print(typeof doh.toUpperCase);
print(doh.toUpperCase());

Każdy łańcuch ma własność toUpperCase. Własność ta zwraca kopię łańcucha, w której wszystkie litery są wielkie. Istnieje też własność toLowerCase. Zgadnij do czego służy.

Zwróć też uwagę, że mimo iż w wywołaniu toUpperCase nie przekazano żadnych argumentów, funkcja ta w jakiś sposób uzyskała dostęp do łańcucha "Doh", wartości, której jest własnością. Szczegółowo działanie tego mechanizmu jest opisane w rozdziale 8.

Własności zawierające funkcje nazywają się metodami, a więc toUpperCase jest metodą obiektu łańcuchowego.

var mack = [];
mack.push("Mack");
mack.push("the");
mack.push("Knife");
show(mack.join(" "));
show(mack.pop());
show(mack);

Metoda push, która jest związana z tablicami, służy do dodawania wartości do tych struktur. Można by jej było użyć w ostatnim ćwiczeniu zamiast instrukcji result[i] = i. Istnieje też metoda pop, która jest przeciwieństwem metody push: usuwa i zwraca ostatnią wartość tablicy. Metoda join tworzy pojedynczy długi łańcuch z tablicy łańcuchów. Parametr jej wywołania jest wstawiany między wartościami tablicy.


Wracając do kotów, wiemy już, że do przechowywania archiwum e-maili doskonale nada się tablica. Na tej stronie tablicę tę można magicznie pobrać za pomocą funkcji retrieveMails. Przejrzenie e-maili i ich przetworzenie nie będzie już teraz wielkim wyzwaniem:

var mailArchive = retrieveMails();

for (var i = 0; i < mailArchive.length; i++) {
  var email = mailArchive[i];
  print("Przetwarzanie e-maila nr ", i);
  // Jakieś działania...
}

Wybraliśmy też sposób reprezentacji zbioru kotów, które wciąż żyją. Zatem następnym problemem jest znalezienie w wiadomości e-mail akapitów zaczynających się od słów "urodzeni" lub "odeszli".


Od razu nasuwa się pytanie, czym jest akapit. W tym przypadku odpowiedź wartość łańcuchowa nie będzie pomocna, ponieważ w języku JavaScript tekstem jest po prostu „szereg znaków”, a więc musimy sami zdefiniować akapity bazując na tym, co mamy.

Wcześniej pokazałem Ci, że istnieje coś takiego, jak znak nowego wiersza. Zazwyczaj znak ten jest używany do oddzielania akapitów. W związku z tym za akapit będziemy uznawać część wiadomości e-mail, której początek wyznacza znak nowego wiersza lub początek treści, a koniec określa kolejny znak nowego wiersza lub koniec treści.

Nie musimy nawet samodzielnie pisać algorytmu do dzielenia łańcucha na akapity. Łańcuchy mają gotową metodę o nazwie split, która jest prawie dokładnym przeciwieństwem metody join w tablicach. Metoda ta tnie łańcuch na fragmenty, które zapisuje w elementach tablicy, a jako znaku podziału używa łańcucha przekazanego jej jako argument.

var words = "Cities of the Interior";
show(words.split(" "));

W związku z tym do podzielenia wiadomości e-mail na akapity możemy zastosować cięcie wg znaków nowego wiersza ("n").


Ćwiczenie 4.3

Metody split i join nie są swoimi dokładnymi przeciwieństwami. Instrukcja string.split(x).join(x) zawsze zwróci oryginalną wartość, ale array.join(x).split(x) nie. Potrafisz podać przykład tablicy, dla której instrukcja .join(" ").split(" ") zwróci inną wartość?


Akapity nie rozpoczynające się od słów „urodzeni” i „odeszli” mogą zostać zignorowane. Jak sprawdzić, czy łańcuch zaczyna się od określonego słowa? Za pomocą metody charAt można pobrać wybraną literę z łańcucha. Instrukcja x.charAt(0) zwraca pierwszą literę, 1 — drugą itd. Jednym ze sposobów na sprawdzenie, czy łańcuch zaczyna się od słowa „urodzeni” jest napisanie takiego kodu:

var paragraph = "urodzeni 15-11-2003 (matka Spot): White Fang";
show(paragraph.charAt(0) == "u" && paragraph.charAt(1) == "r" &&

     paragraph.charAt(2) == "o" && paragraph.charAt(3) == "d") && paragraph.charAt(4) == "z" && paragraph.charAt(5) == "e" && paragraph.charAt(6) == "n" && paragraph.charAt(7) == "i";

Ale to nie jest eleganckie rozwiązanie ― wyobraź sobie sprawdzanie słów składających się z jeszcze większej liczby liter. Możesz się tu jednak czegoś nauczyć: jeśli wiersz kodu staje się zbyt długi, można go podzielić na kilka wierszy. Aby tekst programu był przejrzysty, można wyrównać początek nowego wiersza z pierwszym podobnym elementem poprzedniego wiersza.

Łańcuchy mają też metodę o nazwie slice. Metoda ta kopiuje fragment łańcucha zaczynając od miejsca określonego liczbowo w pierwszym argumencie i kończąc przed znakiem znajdującym się na pozycji wyznaczonej przez drugi argument (tez znak nie jest wliczany). Przy jej użyciu nasz test możemy zapisać krócej.

show(paragraph.slice(0, 8) == "urodzeni");

Ćwiczenie 4.4

Napisz funkcję o nazwie startsWith, która pobiera dwa argumenty łańcuchowe. Niech zwraca wartość true, gdy pierwszy argument zaczyna się od znaków znajdujących się w drugim argumencie i false w przeciwnym przypadku.


Co się dzieje, gdy metody charAt i slice zostaną użyte do pobrania nieistniejącego fragmentu łańcucha? Czy funkcja startsWith będzie działać nawet wtedy, gdy szukany łańcuch (pattern) będzie dłuższy od łańcucha, w którym ma być szukany?

show("Pip".charAt(250));
show("Nop".slice(1, 10));

Metoda charAt dla nieistniejącego znaku zwraca "", a slice po prostu ignoruje tę część, która nie istnieje.

A zatem odpowiedź na postawione pytanie brzmi „tak, funkcja startsWith będzie działać”. W wywołaniu startsWith("Idioci", "najbardziej szanowani koledzy"), wywołanie metody slice zawsze zwróci łańcuch krótszy od pattern, ponieważ argument string nie zawiera wystarczająco dużo znaków. Z tego powodu wynikiem porównywania przy użyciu operatora == będzie false, czyli to, co powinno.

Zawsze warto chwilę zastanowić się nad nienormalnymi (ale poprawnymi) danymi wejściowymi do programu. Są to tzw. przypadki brzegowe i wiele programów, które działają doskonale na wszystkich „normalnych” danych wejściowych fiksuje właśnie na tych przypadkach.


Z problemu z kotami nierozwiązana pozostała już tylko kwestia pobierania imion z akapitów. Opis algorytmu wyglądał tak:

  1. Znalezienie w akapicie dwukropka.
  2. Pobranie tego, co znajduje się za dwukropkiem.
  3. Podzielenie pobranego tekstu na poszczególne imiona wg przecinków.

Dotyczy to zarówno akapitów zaczynających się od słowa "odeszli" jak i od słowa "urodzeni". Dobrym pomysłem jest zapisanie tego algorytmu jako funkcji, aby można go było używać w kodzie obsługującym oba rodzaje akapitów.


Ćwiczenie 4.5

Potrafisz napisać funkcję o nazwie catNames, która jako argument pobiera akapit i zwraca tablicę imion?

Łańcuchy mają metodę indexOf, za pomocą której można znaleźć pozycję pierwszego wystąpienia znaku lub podłańcucha w łańcuchu. Ponadto metoda slice, gdy przekaże się jej tylko jeden argument zwraca część łańcucha od określonej w tym argumencie pozycji do końca.

Pomóc może Ci użycie konsoli do zbadania sposobu, w jaki działają funkcje. Wpisz np. "foo: bar".indexOf(":") i zobacz, co się stanie.


Pozostało więc tylko poskładać wszystkie części w jedną całość. Oto jeden ze sposobów:

var mailArchive = retrieveMails();
var livingCats = {"Spot": true};

for (var mail = 0; mail < mailArchive.length; mail++) {
  var paragraphs = mailArchive[mail].split("n");
  for (var paragraph = 0;
       paragraph < paragraphs.length;
       paragraph++) {
    if (startsWith(paragraphs[paragraph], "urodzeni")) {
      var names = catNames(paragraphs[paragraph]);
      for (var name = 0; name < names.length; name++)
        livingCats[names[name]] = true;
    }
    else if (startsWith(paragraphs[paragraph], "odeszli")) {
      var names = catNames(paragraphs[paragraph]);
      for (var name = 0; name < names.length; name++)
        delete livingCats[names[name]];
    }
  }
}

show(livingCats);

To dość długi i skomplikowany kod. Zaraz spróbujemy sprawić, aby wyglądał trochę klarowniej. Ale najpierw spójrz na wyniki. Wiemy, jak sprawdzić czy określony kot żyje:

if ("Spot" in livingCats)
  print("Spot żyje!");
else
  print("Dobra stara Spot, niech spoczywa w pokoju.");

A jak wyświetlić listę wszystkich żyjących kotów? Słowo kluczowe in, gdy zostanie użyte w połączeniu z for nieco zmienia swoje znaczenie:

for (var cat in livingCats)
  print(cat);

Powyższa pętla przegląda nazwy własności w obiekcie, dzięki czemu możemy zrobić listę wszystkich imion znajdujących się w naszym zbiorze.


Niektóre fragmenty kodu wyglądają, jak gęsta dżungla. Dotyczy to także naszego rozwiązania kociego problemu. Jednym ze sposobów na poprawienie czytelności kodu jest dodanie do niego trochę pustych wierszy. Teraz kod wygląda lepiej, ale to nie rozwiązuje całkowicie problemu.

Żeby osiągnąć sukces, powinniśmy ten kod podzielić. Napisaliśmy już dwie funkcje pomocnicze, startsWith i catNames, z których każda rozwiązuje niewielki i dający się ogarnąć myślą fragment problemu. Możemy dalej rozwijać to podejście.

function addToSet(set, values) {
  for (var i = 0; i < values.length; i++)
    set[values[i]] = true;
}

function removeFromSet(set, values) {
  for (var i = 0; i < values.length; i++)
    delete set[values[i]];
}

Te dwie funkcje dodają imiona do zbioru i je z niego usuwają. Dzięki nim możemy pozbyć się dwóch najgłębiej położonych pętli z rozwiązania:

var livingCats = {Spot: true};

for (var mail = 0; mail < mailArchive.length; mail++) {
  var paragraphs = mailArchive[mail].split("n");
  for (var paragraph = 0;
       paragraph < paragraphs.length;
       paragraph++) {
    if (startsWith(paragraphs[paragraph], "urodzeni"))
      addToSet(livingCats, catNames(paragraphs[paragraph]));
    else if (startsWith(paragraphs[paragraph], "odeszli"))
      removeFromSet(livingCats, catNames(paragraphs[paragraph]));
  }
}

Całkiem nieźle, jeśli mogę sam siebie pochwalić.

Dlaczego funkcje addToSet i removeFromSet pobierają zbiór jako argument? Równie dobrze mogłyby bezpośrednio używać zmiennej livingCats. Ale dzięki zastosowanemu podejściu nie są ściśle związane z tym jednym problemem. Gdyby funkcja addToSet bezpośrednio operowała na zmiennej livingCats, musiałaby się nazywać addCatsToCatSet lub jakoś podobnie. Dzięki takiej budowie, jak ma teraz jest bardziej ogólna.

Funkcje warto pisać w taki sposób nawet wtedy, gdy nie planuje się ich kiedykolwiek używać do innych celów, co jest całkiem możliwe. Dzięki temu, że są „samowystarczalne”, można je czytać i zrozumieć bez potrzeby dowiadywania się, czym jest jakaś zewnętrzna zmienna o nazwie livingCats.

Te funkcje nie są czyste, ponieważ zmieniają obiekt, który zostaje im przekazany jako argument set. To sprawia, że są trochę trudniejsze od prawdziwych czystych funkcji, ale i tak o wiele mniej skomplikowane niż funkcje, które jak szaleniec zmieniają każdą wartość i zmienną, jaką mają ochotę zmienić.


Kontynuujemy omawianie algorytmu:

function findLivingCats() {
  var mailArchive = retrieveMails();
  var livingCats = {"Spot": true};

  function handleParagraph(paragraph) {
    if (startsWith(paragraph, "urodzeni"))
      addToSet(livingCats, catNames(paragraph));
    else if (startsWith(paragraph, "odeszli"))
      removeFromSet(livingCats, catNames(paragraph));
  }

  for (var mail = 0; mail < mailArchive.length; mail++) {
    var paragraphs = mailArchive[mail].split("n");
    for (var i = 0; i < paragraphs.length; i++)
      handleParagraph(paragraphs[i]);
  }
  return livingCats;
}

var howMany = 0;
for (var cat in findLivingCats())
  howMany++;
print("Jest ", howMany, " kotów.");

Teraz cały algorytm znajduje się w funkcji. Dzięki temu po zakończeniu działania nie pozostawi bałaganu. Zmienna livingCats jest teraz lokalna w funkcji, a więc istnieje tylko w czasie, gdy ta funkcja jest wykonywana. Kod potrzebujący tego zbioru może wywołać funkcję findLivingCats i użyć jej wartości zwrotnej.

Ponadto wydawało mi się, że utworzenie osobnej funkcji handleParagraph również sprawi, że kod będzie bardziej przejrzysty. Jest ona jednak ściśle związana z kocim algorytmem i w innych sytuacjach byłaby nieprzydatna. Ponadto potrzebny jest jej dostęp do zmiennej livingCats. To wszystko sprawia, że funkcja ta doskonale nadaje się do zdefiniowania w innej funkcji. Umieszczając ją w funkcji findLivingCats podkreślamy, że jest przydatna tylko w niej i udostępniamy jej zmienne tej funkcji nadrzędnej.

To rozwiązanie jest tak naprawdę większe od poprzedniego. Ale jest za to klarowniejsze i chyba się zgodzisz, że bardziej czytelne.


Program nadal ignoruje wiele informacji znajdujących się w e-mailach. Można w nich znaleźć daty urodzin, daty śmierci oraz imiona matek.

Zaczniemy od dat. Jaki jest najlepszy sposób ich przechowywania? Moglibyśmy utworzyć obiekt z własnościami year, month i day i w nich zapisać odpowiednie liczby.

var when = {year: 1980, month: 2, day: 1};

Słowo kluczowe new

Ale w języku JavaScript dostępny jest gotowy obiekt do przechowywania tego typu danych. Można go utworzyć przy użyciu słowa kluczowego new:

var when = new Date(1980, 1, 1);
show(when);

Do tworzenia wartości obiektowych można używać słowa kluczowego new, podobnie jak nawiasów klamrowych z dwukropkami. Jednak zamiast podawać nazwy i wartości wszystkich własności, w tym przypadku obiekt tworzy się przy użyciu funkcji. Dzięki temu możliwe jest opracowanie standardowych procedur tworzenia obiektów. Funkcje tego typu nazywają się konstruktorami, a techniki ich tworzenia poznasz w rozdziale 8.

Konstruktora Date można używać na różne sposoby.

show(new Date());
show(new Date(1980, 1, 1));
show(new Date(2007, 2, 30, 8, 20, 30));

Jak widać, w obiektach tych można przechowywać zarówno godziny jak i daty. Jeśli nie przekaże się żadnych argumentów, zostanie utworzony obiekt zawierający bieżącą datę i godzinę. Jeśli się je zdefiniuje, to można za ich pomocą utworzyć obiekt zawierający wybraną datę i godzinę. Argumenty te kolejno oznaczają rok, miesiąc, dzień, godzinę, minutę, sekundę oraz milisekundę. Cztery ostatnie argumenty są opcjonalne i jeśli nie zostaną zdefiniowane, nadawana jest im wartość 0.

Miesiące w tych obiektach są numerowane od 0 do 11, co może powodować pomyłki. Co ciekawe, numeracja dni zaczyna się od 1.


Zawartość obiektu Date można zbadać przy użyciu metod get....

var today = new Date();
print("Rok: ", today.getFullYear(), ", miesiąc: ",
      today.getMonth(), ", dzień: ", today.getDate());
print("Godzina: ", today.getHours(), ", minuta: ",
      today.getMinutes(), ", sekunda: ", today.getSeconds());
print("Dzień tygodnia: ", today.getDay());

Wszystkie te metody oprócz getDay mają również odpowiednik z przedrostkiem set, który służy do zmieniania wartości obiektu.

Wewnątrz obiektu data jest reprezentowana w postaci liczby milisekund, jaka upłynęła od 1 stycznia 1970 r. Domyślasz się pewnie, że to całkiem spora liczba.

var today = new Date();
show(today.getTime());

Jedną z najczęściej wykonywanych operacji na datach jest porównywanie.

var wallFall = new Date(1989, 10, 9);
var gulfWarOne = new Date(1990, 6, 2);
show(wallFall < gulfWarOne);
show(wallFall == wallFall);
// ale
show(wallFall == new Date(1989, 10, 9));

Wyniki porównywania dat za pomocą operatorów <, >, <= oraz >= są prawidłowe. Gdy obiekt daty porówna się z nim samym za pomocą operatora ==, zwrócona zostanie wartość true, co również jest dobre. Jeśli jednak za pomocą operatora == porówna się dwa różne obiekty daty zawierające tę samą datę, zostanie zwrócony wynik false. Dlaczego?

Już wcześniej napisałem, że operator == zawsze zwraca wartość false, gdy porównywane są dwa różne obiekty, nawet jeżeli zawierają one identyczne własności. Jest to trochę niezgrabne i mylące rozwiązanie, ponieważ logicznie rzecz biorąc można się spodziewać, że operatory >= i == powinny działać podobnie. Aby sprawdzić czy dwie daty są sobie równe, można napisać taki kod:

var wallFall1 = new Date(1989, 10, 9),
    wallFall2 = new Date(1989, 10, 9);
show(wallFall1.getTime() == wallFall2.getTime());

Oprócz daty i godziny obiekty Date zawierają dodatkowo informację o strefie czasowej. Gdy w Amsterdamie jest trzynasta, to w niektórych porach roku w Londynie jest południe, a w Nowym Jorku siódma. Godziny można zatem porównywać tylko, gdy weźmie się pod uwagę strefę czasową. Za pomocą funkcji getTimezoneOffset obiektu Date można sprawdzić, o ile minut godzina zawarta w tym obiekcie różni się od czasu GMT (Greenwich Mean Time).

var now = new Date();
print(now.getTimezoneOffset());

Ćwiczenie 4.6
"odeszli 27.04.2006: Black Leclère"

Data zawsze znajduje się w tym samym miejscu akapitu. Jak fajnie. Napisz funkcję o nazwie extractDate pobierającą taki akapit jako argument i wydobywającą z niego datę oraz zwracającą ją w obiekcie daty.


Od tej pory zapisywanie kotów będzie przebiegało inaczej. Zamiast tylko umieścić wartość true w zbiorze, teraz będziemy zapisywać obiekt z informacjami o kocie. Gdy kot zdechnie, nie będziemy go usuwać ze zbioru, tylko dodamy do obiektu własność death, w której zapiszemy datę śmierci zwierzęcia.

Z tego powodu funkcje addToSet i removeFromSet stały się bezużyteczne. Potrzebujemy czegoś podobnego, ale to coś musi dodatkowo zapisywać datę urodzenia i imię matki.

function catRecord(name, birthdate, mother) {
  return {name: name, birth: birthdate, mother: mother};
}

function addCats(set, names, birthdate, mother) {
  for (var i = 0; i < names.length; i++)
    set[names[i]] = catRecord(names[i], birthdate, mother);
}
function deadCats(set, names, deathdate) {
  for (var i = 0; i < names.length; i++)
    set[names[i]].death = deathdate;
}

catRecord to osobna funkcja służąca do tworzenia tych magazynowych obiektów. Może być przydatna też w innych sytuacjach, jak np. utworzenie obiektu dla Spot. Słowo „Record” jest często używane w nazwach tego rodzaju obiektów, które służą do grupowania określonej ograniczonej liczby wartości.


Spróbujmy więc pobrać imiona kocich matek z akapitów.

"urodzeni 15/11/2003 (matka Spot): White Fang"

Oto jeden z możliwych sposobów…

function extractMother(paragraph) {
  var start = paragraph.indexOf("(matka ") + "(matka ".length;
  var end = paragraph.indexOf(")");
  return paragraph.slice(start, end);
}

show(extractMother("urodzeni 15/11/2003 (matka Spot): White Fang"));

Zwróć uwagę, że pozycja startowa musi zostać dostosowana do długości słowa "(matka ", ponieważ indexOf zwraca pozycję początku wzorca, a nie jego końca.


Ćwiczenie 4.7

Działanie wykonywane przez funkcję extractMother można wyrazić w bardziej ogólny sposób. Napisz funkcję o nazwie between, która pobiera trzy argumenty łańcuchowe. Funkcja ta niech zwraca część pierwszego argumentu, która występuje między wzorcami znajdującymi się w drugim i trzecim argumencie.

Na przykład wynikiem wywołania between("urodzeni 15/11/2003 (matka Spot): White Fang", "(matka ", ")") powinien być łańcuch "Spot".

A wynikiem wywołania between("bu ] boo [ bah ] gzz", "[ ", " ]") powinien być łańcuch "bah".

Drugi z wymienionych przypadków łatwiej będzie zaimplementować wiedząc, że funkcji indexOf można przekazać drugi, opcjonalny, argument określający, w którym miejscu ma się rozpocząć szukanie.


Dzięki funkcji between można uprościć funkcję extractMother:

function extractMother(paragraph) {
  return between(paragraph, "(matka ", ")");
}

Ulepszona wersja kociego algorytmu wygląda teraz tak:

function findCats() {
  var mailArchive = retrieveMails();
  var cats = {"Spot": catRecord("Spot", new Date(1997, 2, 5),
              "nieznany")};

  function handleParagraph(paragraph) {
    if (startsWith(paragraph, "urodzeni"))
      addCats(cats, catNames(paragraph), extractDate(paragraph),
              extractMother(paragraph));
    else if (startsWith(paragraph, "odeszli"))
      deadCats(cats, catNames(paragraph), extractDate(paragraph));
  }

  for (var mail = 0; mail < mailArchive.length; mail++) {
    var paragraphs = mailArchive[mail].split("n");
    for (var i = 0; i < paragraphs.length; i++)
      handleParagraph(paragraphs[i]);
  }
  return cats;
}

var catData = findCats();

Mając te dodatkowe dane możemy w końcu połapać się w kotach ciotki Emilii. Poniższa funkcja może być przydatna:

function formatDate(date) {
  return date.getDate() + "/" + (date.getMonth() + 1) +
         "/" + date.getFullYear();
}

function catInfo(data, name) {
  if (!(name in data))
    return "Kot o imieniu " + name + " nie jest znany światu.";

  var cat = data[name];
  var message = name + ", urodzony " + formatDate(cat.birth) +
                " z matki  " + cat.mother;
  if ("death" in cat)
    message += ", zdechł dnia " + formatDate(cat.death);
  return message + ".";
}

print(catInfo(catData, "Fat Igor"));

Pierwsza instrukcja return w funkcji catInfo służy jako wyjście awaryjne. Jeśli o wybranym kocie nie ma żadnych danych, reszta funkcji jest bez znaczenia, w związku z czym od razu zwracamy wartość, aby wstrzymać dalsze niepotrzebne wykonywanie kodu.

Kiedyś niektórzy programiści funkcje zawierające kilka instrukcji return uważali za ciężki grzech. Chodziło im o to, że wówczas trudno jest określić, która część kodu zostanie wykonana, a która nie. W rozdziale 5 poznasz techniki, dzięki którym argumenty używane przez tych programistów stały się mniej lub bardziej nieaktualne, chociaż wciąż od czasu do czasu można spotkać osoby krytykujące taki sposób użycia instrukcji return.


Ćwiczenie 4.8

Funkcja formatDate używana przez funkcję catInfo nie dodaje zera przed jednocyfrowymi numerami miesięcy i dni. Napisz jej nową wersję, która będzie to robić.


Ćwiczenie 4.9

Napisz funkcję o nazwie oldestCat przyjmującą jako argument obiekt zawierający dane kotów i zwracającą nazwę najstarszego żyjącego kota.


Skoro wiesz już jak posługiwać się tablicami, pokażę Ci jeszcze coś innego. Gdy wywoływana jest jakakolwiek funkcja, w środowisku, w którym działa tworzona jest specjalna zmienna o nazwie arguments. Zmienna ta odwołuje się do obiektu, który przypomina tablicę. Pierwszy argument jest własnością 0, drugi argument jest własnością 1 itd. dla wszystkich argumentów, jakie zostały przekazane funkcji. Ponadto zmienna ta ma własność length.

Obiekt ten nie jest jednak prawdziwą tablicą, nie ma takich metod, jak push i nie aktualizuje automatycznie swojej własności length, gdy zostanie do niego coś dodane. Nie udało mi się dowiedzieć, czemu nie, ale należy o tym pamiętać.

function argumentCounter() {
  print("Przekazałeś mi ", arguments.length, " argumentów.");
}
argumentCounter("Śmierć", "Głód", "Zaraza");

Niektóre funkcje, jak np. print, mogą przyjmować nieograniczoną liczbę argumentów. Funkcje te zazwyczaj przeglądają za pomocą pętli zawartość obiektu arguments i wykonują na niej jakieś działania. Są też funkcje przyjmujące argumenty opcjonalne, którym jeśli nie zostaną zdefiniowane przez wywołującego, zostają przypisane jakieś domyślne wartości.

function add(number, howmuch) {
  if (arguments.length < 2)
    howmuch = 1;
  return number + howmuch;
}

show(add(6));
show(add(6, 4));

Ćwiczenie 4.10

Rozszerz funkcję range z ćwiczenia 4.2, aby przyjmowała drugi argument, który jest opcjonalny. Jeśli zostanie przekazany tylko jeden argument, funkcja powinna działać tak, jak wcześniej, tzn. tworzyć zakres od 0 do podanej liczby. Jeśli natomiast zostaną podane dwa argumenty, pierwszy powinien określać początek przedziału, a drugi — koniec.


Ćwiczenie 4.11

Może pamiętasz poniższy wiersz kodu z wprowadzenia:

print(sum(range(1, 10)));

Funkcję range już mamy. Do działania potrzebna jest nam jeszcze tylko funkcja sum. Funkcja ta przyjmuje tablicę liczb i zwraca ich sumę. Napisz ją. Nie powinna Ci sprawić problemów.


W rozdziale 2 poruszone zostały funkcje Math.max i Math.min. Teraz już wiesz, że są to tak naprawdę własności max i min obiektu o nazwie Math. Jest to kolejna ważna rola obiektów: są to magazyny powiązanych ze sobą wartości.

W obiekcie Math znajduje się wiele wartości i gdyby je wszystkie zamiast w obiekcie umieszczono bezpośrednio w globalnym środowisku, to zostałoby ono, jak to się mówi, zaśmiecone. Im więcej nazw jest zajętych, tym większe ryzyko, że nazwa jakiejś zmiennej zostanie przypadkowo nadpisana. Na przykład nazwa max może być dość popularna.

W większości języków programowania użycie zajętej nazwy zmiennej jest niemożliwe albo wyświetlane jest ostrzeżenie, gdy ktoś próbuje takiej nazwy użyć. W JavaScripcie tak nie jest.

W każdym bądź razie obiekt Math zawiera masę rozmaitych funkcji i stałych matematycznych. Znajdują się w nim implementacje wszystkich funkcji trygonometrycznych ― cos, sin, tan, acos, asin oraz atan. Dostępne są też stałe π i e, które zapisane są wielkimi literami (PI i E) — wielkich liter kiedyś modnie używało się do zapisywania nazw stałych. Funkcja pow jest dobrym zamiennikiem dla naszych funkcji power. Funkcja ta dodatkowo akceptuje ujemne i ułamkowe wykładniki. Funkcja sqrt oblicza pierwiastki kwadratowe. Funkcje max i min zwracają większą i mniejszą z dwóch wartości. Funkcje round, floor i ceil zaokrąglają liczby odpowiednio do najbliższej całkowitej, całkowitej mniejszej oraz całkowitej większej liczby.

Obiekt Math zawiera jeszcze wiele innych wartości, ale ten rozdział jest wstępem do programowania, a nie dokumentacją. Do dokumentacji można zajrzeć, gdy podejrzewa się, że jakiś element w języku istnieje i chce się sprawdzić jego nazwę albo jak dokładnie działa. Niestety nie ma jednej pełnej dokumentacji języka JavaScript. Jest to spowodowane między innymi tym, że powstawał w chaotycznym procesie dodawania rozszerzeń przez różne przeglądarki. Dobrą dokumentacją podstawowego języka jest standard ECMA, ale jest to niezbyt czytelny dokument. W większości przypadków najlepszym źródłem informacji jest portal Mozilla Developer Network.


Może już zastanawiałeś się, jak się dowiedzieć, co dokładnie zawiera obiekt Math:

for (var name in Math)
  print(name);

Ale ten kod nic nie wyświetli. Podobnie będzie z poniższym kodem:

for (var name in ["Huey", "Dewey", "Loui"])
  print(name);

Wyświetlone zostaną tylko cyfry 0, 1 i 2 zamiast nazw length, push albo join, które na pewno tam są. Najwidoczniej niektóre własności obiektów są ukryte. Jest ku temu bardzo dobry powód: wszystkie obiekty mają po kilka metod, np. toString konwertująca obiekt na łańcuch i nie chcielibyśmy ich znaleźć szukając np. kotów zapisanych w obiekcie.

Nie jest dla mnie jasne, dlaczego ukryte są własności obiektu Math. Może ktoś chciał, aby to był obiekt tajemnic.

Wszystkie własności dodawane przez Twoje programy do obiektów są widoczne. Nie da się ich ukryć, a szkoda, bo jak zobaczysz w rozdziale 8, czasami możliwość dodawania do obiektów metod, które nie są widoczne dla instrukcji for/in jest przydatne.


Niektóre własności są przeznaczone tylko do odczytu, co znaczy, że można sprawdzać ich wartości, ale nie można ich modyfikować. Takie są np. własności wartości łańcuchowych.

Inne własności mogą być „aktywne”. Zmodyfikowanie ich powoduje, że coś się dzieje. Na przykład zmniejszenie długości tablicy powoduje usunięcie części jej elementów:

var array = ["Niebo", "Ziemia", "Człowiek"];
array.length = 2;
show(array);

Przypisy

  1. Podejście to ma kilka subtelnych wad, które zostaną omówione w rozdziale 8. W tym rozdziale wystarczy to, co jest.

Autor: Marijn Haverbeke

Źródło: http://eloquentjavascript.net/1st_edition/chapter4.html

Tłumaczenie: Łukasz Piwko

Treść tej strony jest dostępna na zasadach licencji CC BY 3.0

3 komentarze do “Rozdział 4. Struktury danych: obiekty i tablice”

  1. Zadanie 4.6 nie działa, jak chcę go skompilować to wyskakuje błąd: „Invalid Date”

  2. Czy funkcja extractDate nie działa czasami w ograniczonym zakresie?

    extractDate(„odeszli 27-04-2006: Black Leclère”);

    „odeszli” to ciąg 7 znakowy.
    Natomiast:
    „urodzeni” to ciąg 8 znakowy – numberAt(14,4) itd. nie będzie w tym przypadku działać….

Możliwość komentowania została wyłączona.