Elementy programowania obiektowego w JavaScript

Cześć! To jest fragment książki JavaScript od podstaw, która ma pomóc w nauce programowania od zera.

Kiedy rozejrzysz się wokół siebie, zapewne dostrzeżesz wiele różnych obiektów - ja widzę monitor, głośniki, filiżankę z kawą, tablet graficzny itp. Każdy z nich ma jakieś funkcje - monitor wyświetla obraz, głośniki emitują dźwięk, tablet pomaga rysować na komputerze. Tak wygląda świat z perspektywy programowania obiektowego.

Programowanie obiektowe to paradygmat — zestaw wzorców, ale również sposób patrzenia na świat. Jest bardzo popularny w programowaniu, choć we współczesnych językach, takich jak JavaScript, często miesza się albo ustępuje miejsca paradygmatowi funkcyjnemu.

Charakterystycznym dla programowania funkcyjnego (w skrócie FP od Functional Programming) jest postrzeganie programu jako zbioru funkcji. Funkcje są stawiane na pierwszym miejscu, traktowane często jako wartości i przekazywane do innych funkcji. Standardowe funkcjonalności ze świata funkcyjnego poznamy w rozdziałach Iteracja po tablicyFunkcje strzałkowe.

Charakterystycznym dla programowania obiektowego (w skrócie OOP od Object-Oriented Programming) jest postrzeganie programu jako zbioru obiektów. Obiekty mają swoje funkcje, cechy, klasy. W tym rozdziale poznamy najistotniejsze elementy programowania obiektowego.

Metody, czyli funkcje jako właściwości

Jak dowiedzieliśmy się podczas omawiania funkcji, mogą one być przechowywane w zmiennych. Analogicznie i obiekty mogą przechowywać funkcje jako właściwości (zmienne w obiekcie). Takie funkcje znane są jako metody.

const user = {
  name: "Marcin",
  sayHello: function() {
    console.log("Cześć, jestem Marcin");
  }
};

user.sayHello(); // Cześć, jestem Marcin

Do tej pory poznaliśmy kilka metod - log jest metodą na obiekcie console, poznaliśmy też obiekt Math i jego metody pow, max itd.

Większość metod potrzebuje odnieść się do obiektu, w którym się znajdują. W powyższym przykładzie metoda sayHello odnosi się do imienia użytkownika. A co gdyby się ono zmieniło? Aby zawsze mieć aktualną wartość, potrzebujemy odnieść się do obiektu, w którym jesteśmy (w tym przypadku user). Używamy do tego specjalnego słówka this wskazującego na obiekt, w którym metoda jest zdefiniowana. Dzięki temu funkcja sayHello zawsze będzie wyświetlała poprawne imię.

const user = {
   name: "Marcin",
   sayHello: function() { 
     console.log("Cześć, jestem " + this.name);
  }
};

user.sayHello(); // Cześć, jestem Marcin
user.name = "Maciek";
user.sayHello(); // Cześć, jestem Maciek

Tego samego sposobu można użyć, aby zmodyfikować ten obiekt.

const user = {
  name: "Marcin",
  changeName: function(newName) {
    this.name = newName;
  }
  sayHello: function() { 
    console.log("Cześć, jestem " + this.name);
  }
};

user.sayHello(); // Cześć, jestem Marcin
user.changeName("Michał");
user.sayHello(); // Cześć, jestem Michał
user.changeName("Marek");
user.sayHello(); // Cześć, jestem Marek

Co istotne, w ten sposób modyfikujemy tylko jeden konkretny obiekt. Obiekty zawierające funkcje często tworzy się przy użyciu innych funkcji, nazywanych fabrykami (ang. factory). Standardowo definiuje się je tak, że tworzą one niezależne od siebie obiekty, jak w poniższym przykładzie.

function makeUser(name) {
  return {
    name: name,
    changeName: function(newName) { 
      this.name = newName;
    }
    sayHello: function() { 
      console.log("Cześć, jestem " + this.name);
    }
  };
}

const user1 = makeUser("Marcin");
const user2 = makeUser("Kamil");

user1.sayHello(); // Cześć, jestem Marcin
user2.sayHello(); // Cześć, jestem Kamil

user1.changeName("Piotr");

user1.sayHello(); // Cześć, jestem Piotr
user2.sayHello(); // Cześć, jestem Kamil

Metody są bardzo istotną częścią programowania, ponieważ używając ich do operowania na obiekcie, mamy gwarancję, że będzie się on zachowywał poprawnie. Przykładowo, wyobraźmy sobie, że mamy obiekt reprezentujący datę 28 lutego 2021, który ma metodę nextDay. Gdybyśmy chcieli sami ustawić kolejną datę poprzez określenie dnia, miesiąca i roku, istnieje spora szansa, że popełnilibyśmy błąd - na przykład przez ustawienie dnia na 29, lub przez zostawienie tego samego miesiąca (28 to ostatni dzień lutego 2021, więc następny dzień to 1 marca 2021). Gdy jednak używamy metody, to na nią przenosimy odpowiedzialność, by zwrócona data była poprawna. To daje programistom ogromną wygodę i stanowi podstawę programowania obiektowego.

Ćwiczenie: Konto bankowe

Stwórz obiekt reprezentujący konto bankowe. Powinien zawierać metody:

  • deposit do dodawania środków na rachunek. Powinna ona zwracać aktualny stan konta po wykonanej operacji.
  • withdraw do wyciągania środków z rachunku. Powinna ona zwracać wyciągniętą kwotę. Jeśli nie ma wystarczająco środków, zwraca tyle, ile jest.
  • currentBalance do pobierania aktualnego stanu rachunku bankowego.

Poniżej przedstawiony jest przykład użycia.

const account = makeBankAccount();
console.log(account.currentBalance()); // 0

const balance = account.deposit(1000);
console.log(balance); // 1000
console.log(account.currentBalance()); // 1000

const withdrawed1 = account.withdraw(300);
console.log(withdrawed1); // 300
console.log(account.currentBalance()); // 700

const withdrawed2 = account.withdraw(1500);
console.log(withdrawed2); // 700
console.log(account.currentBalance()); // 0

Odpowiedzi na końcu książki.

Operator new

Poznany już przez nas sposób definiowania funkcji tworzących obiekty jest właściwie wystarczający. Powstał jednak operator new, który ułatwia i przyspiesza ten proces.

Gdy używamy operatora new, funkcja definiująca obiekt nazywana jest konstruktorem. Przyjęło się konwencję, że zaczyna się ją wielką literą i powinna być nazwą klasy obiektów, które definiuje. Nie umieszczamy więc na jej początku żadnego przedrostka typu "make". Przy użyciu, operator new to wystarczające podkreślenie, że tworzony jest nowy obiekt. Przy definicji to, że funkcja zaczyna się wielką literą, jest sugestią, że prawdopodobnie mamy do czynienia z konstruktorem, a więc powinno się go używać przy pomocy operatora new.

// Konstruktor korzystający z new
function User(name) {
  this.name = name;
  this.changeName = function(newName) {
    this.name = newName;
  };
  this.sayHello = function() {
    console.log("Cześć, jestem " + this.name);
  };
}

const user = new User("Piotr")

// Jest alternatywą do
function makeUser(name) {
  return {
    name: name,
    changeName: function(newName) {
      this.name = newName;
    },
    sayHello: function() {
      console.log("Cześć, jestem " + this.name);
    }
  };
}

const user = makeUser("Piotr");

// Użycie pozostaje takie samo
user.sayHello(); // Cześć, jestem Piotr
user.changeName("Kamil");
user.sayHello(); // Cześć, jestem Kamil

Istnieją różnice między tworzeniem obiektu a użyciem słówka new. Stają się one jednak istotne dopiero przy bardziej zaawansowanym użyciu języka.

Ćwiczenie: Konto bankowe z operatorem new

Zmień definicję konta bankowego z poprzedniego zadania tak, by było ono tworzone przy użyciu operatora new. Poniżej przedstawiony jest przykład użycia.

const account = new BankAccount();
console.log(account.currentBalance()); // 0

const balance = account.deposit(1000);
console.log(balance); // 1000
console.log(account.currentBalance()); // 1000

const withdrawed1 = account.withdraw(300);
console.log(withdrawed1); // 300
console.log(account.currentBalance()); // 700

const withdrawed2 = account.withdraw(1500);
console.log(withdrawed2); // 700
console.log(account.currentBalance()); // 0

Odpowiedzi na końcu książki.

Klasy

Kolejnym uproszczeniem jest użycie słówka kluczowego class i zdefiniowanie klasy obiektów. Omówię tę funkcjonalność bardzo skrótowo, gdyż poza dużymi projektami, raczej rzadko definiuje się je samemu. Bardziej chodzi o to, aby zaznajomić Cię z tym zapisem.

Definicję klasy zaczynamy od słówka class, po czym podajemy nazwę klasy. W ciele klasy definiujemy konstruktor przy użyciu słówka constructor. Jest on funkcją i posiada parametry. W konstruktorze podobnie ustawiamy właściwości. Funkcje natomiast możemy zdefiniować bezpośrednio w ciele klasy. Funkcje te definiujemy bez słówka function - podajemy tylko ich nazwę, parametry w nawiasie okrągłym i ciało w nawiasie klamrowym.

class User {
  constructor(name) {
    this.name = name;
  }
  changeName(newName) { 
    this.name = newName;
  }
  sayHello() { 
    console.log("Cześć, jestem " + this.name);
  }
}

// Jest alternatywą dla
function User(name) {
  this.name = name;
  this.changeName = function(newName) { 
    this.name = newName;
  };
  this.sayHello = function() { 
    console.log("Cześć, jestem " + this.name);
  };
}

// Użycie pozostaje takie samo
const user = new User("Piotr");
user.sayHello(); // Cześć, jestem Piotr
user.changeName("Kamil");
user.sayHello(); // Cześć, jestem Kamil

Istnieją pewne różnice pomiędzy powyższymi alternatywami, ale ujawniają się one dopiero przy zaawansowanych użyciach języka.

Ten sposób definiowania klas jest bardzo podobny do tego, jak się to robi w innych językach, na przykład w Javie czy w Pythonie.

Ćwiczenie: Konto bankowe przez definicję klasy

Zmień definicję konta bankowego z poprzedniego zadania tak, by było ono tworzone przy użyciu operatora class. Poniżej przedstawiony jest przykład użycia.

const account = new BankAccount();
console.log(account.currentBalance()); // 0

const balance = account.deposit(1000);
console.log(balance); // 1000
console.log(account.currentBalance()); // 1000

const withdrawed1 = account.withdraw(300);
console.log(withdrawed1); // 300
console.log(account.currentBalance()); // 700

const withdrawed2 = account.withdraw(1500);
console.log(withdrawed2); // 700
console.log(account.currentBalance()); // 0

Odpowiedzi na końcu książki.