Klasy i obiekty. Programowanie obiektowe.

Obiekty w JavaScript to podstawowe struktury danych służące do przechowywania kolekcji właściwości powiązanych z wartościami. Właściwość (property) to para klucz-wartość. Kluczem jest zazwyczaj string (lub symbol), a wartością dowolny typ danych (liczba, tekst, funkcja, inny obiekt, itd.). Obiekty pozwalają modelować bardziej złożone byty, jak np. użytkownik, samochód, ustawienia aplikacji itp., grupując powiązane informacje pod jedną nazwą. W tej sekcji nauczymy się tworzyć obiekty, odczytywać i zmieniać ich właściwości, definiować metody (funkcje będące właściwościami obiektu) i zrozumiemy, jak działa słowo kluczowe this. Omówimy też zagnieżdżanie obiektów oraz niektóre operacje na obiektach (iteracja, kopiowanie, łączenie).

Tworzenie i właściwości obiektu

Najprostszym sposobem utworzenia obiektu jest użycie literału obiektu, czyli par klucz:wartość ujętych w nawiasy klamrowe { }. Przykładowo:

 const person = {
   name: "Jan",
   age: 30
 };

Powyżej zdefiniowaliśmy obiekt person z dwoma właściwościami:

  • klucz "name" z wartością "Jan" (łańcuch znaków),
  • klucz "age" z wartością 30 (liczba).

Klucze są tutaj napisami (JS automatycznie traktuje identyfikatory właściwości jako stringi, o ile są poprawnymi identyfikatorami). Można też ująć klucz w cudzysłów explicite, zwłaszcza gdy zawiera spacje lub znaki specjalne, np. {"full name": "Jan Kowalski"} byłoby poprawne (choć do takiej właściwości trudniej się odwołać przez kropkę, o czym za moment). Wartości mogą być różnych typów – w tym inne obiekty lub funkcje.

Obiekt możemy też stworzyć za pomocą konstruktora new Object() i potem dynamicznie dodawać właściwości, ale literał { } jest krótszy i częściej używany.

Dostęp do właściwości obiektu

Do właściwości obiektu w JavaScript odwołujemy się na dwa sposoby:

  1. Notacja kropkowaobiekt.klucz
  2. Notacja nawiasowaobiekt["klucz"]

Kontynuując przykład obiektu person:

 console.log(person.name);    // "Jan"
 console.log(person.age);     // 30

 // To samo za pomocą notacji nawiasowej:
 console.log(person["name"]); // "Jan"

Notacja kropkowa jest wygodna i często używana, ale ma ograniczenie: działa tylko z takimi nazwami właściwości, które są poprawnymi identyfikatorami w JavaScript (czyli nie zawierają spacji, nie zaczynają się od cyfry itp.). Gdy nazwa właściwości jest nietypowa, albo jest w zmiennej, należy użyć notacji nawiasowej. Np.:

 person["full name"] = "Jan Kowalski";     // dodajemy nową właściwość ze spacją w nazwie
 console.log(person["full name"]);        // "Jan Kowalski"

 const prop = "age";
 console.log(person[prop]);              // 30 (odwołanie poprzez nazwę w zmiennej)

W powyższym kodzie pokazaliśmy dwa przypadki, gdzie notacja [] jest niezbędna: gdy klucz zawiera spację oraz gdy klucz jest przechowywany w zmiennej (prop). Notacja nawiasowa przyjmuje ciąg znaków (string) lub symbol jako klucz i zwraca odpowiadającą wartość (lub pozwala ustawić wartość).

Jeśli spróbujemy odczytać właściwość, która nie istnieje, otrzymamy undefined. Można to sprawdzić operatorem in (np. "age" in person zwróci true, "salary" in person zwróci false) lub porównując do undefined.

Metody obiektu i słowo kluczowe this

Jeśli jako wartość właściwości obiektu przypiszemy funkcję, nazywamy taką właściwość metodą obiektu. Metody pozwalają obiektom na posiadanie „zachowań”, operacji związanych z nimi. Np. obiekt person może mieć metodę greet wypisującą powitanie.

Przykład dodania metody do obiektu:

 person.greet = function() {
   console.log("Cześć, jestem " + this.name + "!");
 };

 person.greet(); // Cześć, jestem Jan!

Tutaj dodaliśmy właściwość greet, która jest funkcją. Wywołujemy ją poprzez person.greet(). Zauważ użycie słowa kluczowego this wewnątrz metody. this odnosi się do bieżącego obiektu, na którym metoda została wywołana. W naszym przypadku wywołanie person.greet() sprawia, że wewnątrz funkcji greet słowo this wskazuje na obiekt person. Dlatego this.name w środku funkcji zwróci person.name („Jan”). W efekcie metoda wypisuje komunikat ze wskazaniem imienia obiektu.

Kilka uwag dotyczących metod i this:

  • Możemy definiować metody od razu przy tworzeniu obiektu literatem:
 const calculator = {      x: 5, y: 3, sum: function() { 
    return this.x + this.y; 
    } 
 }; 
 console.log(calculator.sum()); // 8

W powyższym przykładzie obiekt calculator ma właściwości liczbowe x i y oraz metodę sum zwracającą ich sumę. this.x odnosi się do calculator.x.

  • Istnieje skrócona składnia zapisu metod w literałach obiektów (ES6): zamiast sum: function() { ... } można napisać sum() { ... } – efekt jest ten sam, krótszy zapis.
  • this zawsze zależy od kontekstu wywołania funkcji. Jeżeli oddzielimy funkcję od obiektu, np. const tylkoFunkcja = person.greet; i wywołamy tylkoFunkcja(), to w środku tej funkcji this nie będzie już wskazywał na person (w trybie strict będzie undefined, a w trybie niestrict wskazywałby na obiekt globalny). Dlatego ważne jest, jak wywoływana jest metoda – czy jako obiekt.metoda(), czy samodzielnie.
  • Funkcje strzałkowe, jak wspomniano wcześniej, nie mają własnego this. Gdybyśmy zdefiniowali greet jako funkcję strzałkową: greet: () => { console.log(this.name) } wewnątrz person, to this w niej byłby rozwiązywany leksykalnie – zapewne wskazywałby na window lub undefined (jeśli używamy modułów/strict mode), a nie na obiekt person. Dlatego do metod obiektu najlepiej używać zwykłych funkcji, aby this wskazywało na obiekt.

Modyfikacja i zagnieżdżone obiekty

Obiekty w JavaScript są mutowalne, co oznacza, że po utworzeniu obiektu możemy dodawać, usuwać czy zmieniać jego właściwości w trakcie działania programu.

  • Dodawanie/edytowanie właściwości: Odbywa się to poprzez proste przypisanie wartości do klucza, używając notacji kropkowej lub nawiasowej.
 person.job = "Programista"; // dodajemy nową właściwość job  person.age = 31; // edytujemy istniejącą właściwość age

Po tych operacjach obiekt person ma dodatkowo właściwość job oraz zmienioną wartość age.

  • Usuwanie właściwości: Służy do tego operator delete.
 delete person["full name"]; // usuwa właściwość 'full name' z obiektu  delete person.job; // usuwa właściwość 'job'

Po użyciu delete danej właściwości już nie będzie w obiekcie (uwaga: operator ten działa tylko na własne właściwości obiektu; nie usuwa ewentualnych właściwości dziedziczonych po prototypie).

Obiekty mogą być zagnieżdżone, tzn. właściwości obiektu mogą same być obiektami (lub tablicami). Pozwala to tworzyć struktury drzewiaste reprezentujące bardziej złożone dane. Na przykład:

 const company = {
   name: "ABC Corp",
   address: {
     city: "Warszawa",
     zip: "00-001",
   },
   employees: [
     { name: "Jan", position: "CEO" },
     { name: "Anna", position: "Developer" }
   ]
 };

 console.log(company.address.city);         // "Warszawa"
 console.log(company.employees[1].name);    // "Anna"

W powyższym obiekcie company właściwość address jest obiektem z kolejnymi własnymi właściwościami city i zip. Z kolei employees jest tablicą zawierającą obiekty reprezentujące pracowników. Dostęp do zagnieżdżonych danych uzyskujemy przez łańcuch notacji kropkowej/nawiasowej: np. company.employees[1].name najpierw pobiera drugi element tablicy employees (o indeksie 1), a następnie jego pole name.

Zagnieżdżone obiekty modyfikujemy podobnie jak płaskie:

 company.address.street = "Ul. Długa 5";       // dodajemy ulicę do adresu
 company.employees[0].position = "CTO";       // zmieniamy stanowisko pierwszego pracownika

Trzeba tylko uważać, by odwołując się do głębokich właściwości, mieć pewność że po drodze żaden „poziom” nie jest undefined (w przeciwnym razie dostaniemy błąd typu Cannot read property ... of undefined). W nowszych wersjach JS istnieje opcja bezpiecznego dostępu łańcuchowego ?. (optional chaining), ale to temat na inny raz.

Operacje na obiektach

Poza bezpośrednim odczytem i zapisem właściwości, często potrzebujemy wykonać różne operacje ogólne na obiekcie:

  • Iteracja po właściwościach: Do przechodzenia po wszystkich własnych właściwościach obiektu można użyć pętli for…in.
 for (let key in person) {      if (person.hasOwnProperty(key)) {
     console.log(key, ":", person[key]); 
     } 
 }

Pętla for...in iteruje po kluczach (nazwach właściwości) obiektu person. Użycie hasOwnProperty zapewnia, że pomijamy ewentualne właściwości dziedziczone z prototypu. Wewnątrz pętli możemy użyć person[key], by uzyskać wartość danej właściwości.
Uwaga: Kolejność iteracji for...in nie jest gwarantowana (choć w praktyce przeglądarki zwykle zwracają w kolejności dodawania dla zwykłych właściwości liczbowych i potem nieliczbowych).

  • Pobranie listy kluczy, wartości: Obiekt Object dostarcza metody statyczne do tego celu:
    Object.keys(person) zwróci tablicę wszystkich kluczy (np. ["name","age","greet"]),
    Object.values(person) zwróci tablicę wszystkich wartości (np. ["Jan", 31, function]),
    Object.entries(person) zwróci tablicę par [klucz, wartość] (np. [ ["name","Jan"], ["age",31], ["greet", ƒ] ]).
    Te metody są wygodne, gdy chcemy np. policzyć właściwości lub przekształcić obiekt w inną strukturę.
  • Kopiowanie obiektów: Ponieważ obiekty są przekazywane przez referencję, proste przypisanie const obj2 = obj1;nie tworzy kopii, a jedynie nowy odnośnik do tego samego obiektu. Aby skopiować obiekt (płytka kopia, tzw. shallow copy), można użyć:
    • operatora spread: const kopia = {...person}; – tworzy nowy obiekt, rozpakowując wszystkie własności person do niego,
    • lub Object.assign: const kopia = Object.assign({}, person); – efekt ten sam co spread.
      Obie te metody skopiują wartości właściwości, ale jeśli któraś z nich jest obiektem zagnieżdżonym, to skopiuje się tylko referencja (czyli kopia będzie współdzielić z oryginałem zagnieżdżony obiekt). To właśnie oznacza kopia płytka. Głębokie kopiowanie wymaga dodatkowych technik (np. rekurencji, funkcji z biblioteki, czy pośrednio JSON.stringify/parse, z pewnymi ograniczeniami).
  • Łączenie/rozszerzanie obiektów: Operator spread {...obj1, ...obj2} można też użyć do połączenia własności dwóch obiektów w jeden. Jeśli występują te same klucze, własność z obiektu po prawej stronie nadpisze tę z lewej. Przykład:
 const objA = { x: 1, y: 2 };    const objB = { y: 3, z: 4 };
 const objC = { ...objA, ...objB };
 console.log(objC); // { x: 1, y: 3, z: 4 }

W wyniku objC zawiera kombinację objA i objB. Własność y została nadpisana wartością z objB.
Alternatywnie można użyć Object.assign(objA, objB), co dołączy objB do objA (modyfikując objA).

  • Inne operacje: Sprawdzanie istnienia właściwości ("key" in obj lub obj.hasOwnProperty("key")), usuwanie właściwości (delete obj.key – już omówione), blokowanie zmian obiektu (Object.freeze(obj) aby uczynić obiekt niezmiennym), itp. Te zaawansowane operacje są przydatne w określonych sytuacjach, ale wykraczają poza podstawy.

Podsumowując, obiekty w JavaScript są wszechstronne i można je dynamicznie tworzyć oraz modyfikować. Ponieważ są przekazywane przez referencję, należy być ostrożnym przy ich kopiowaniu i porównywaniu (porównanie dwóch różnych obiektów nawet o identycznej zawartości zwróci false, bo porównują się referencje, nie wartości).