Różnica między __proto__ a prototype

programowanie obiektowe javascript

Programowanie obiektowe w języku Javascript oparte jest o prototypy. Z jednej strony jest to inny mechanizm niż obiektowość oparta o klasy, z drugiej strony mechanizm potężniejszy i trudniejszy w zrozumieniu.

Programowanie obiektowe w języku Javascript

Obiektowość oparta o prototypy działa inaczej niż to co mogłeś poznać w językach, w których obiektowość oparta jest o klasy, takich jak Java czy C#.

W Javie, aby stworzyć obiekt, musimy najpierw stworzyć klasę. Klasa definiuje atrybuty i metody jakie będzie posiadał w przyszłości obiekt. Jedynym sposobem na stworzenie obiektu jest wywołanie konstruktora zdefiniowanego w klasie. W trakcie trwania programu nie możliwe jest dodawania nowych atrybutów lub metod do obiektu.

W języku Javascript wygląda to troche inaczej. Możemy tworzyć obiekty bez klasy, a każdy obiekt ma swój prototyp. W czasie trwania programu prototyp może zostać zmodyfikowany i zmienić zachowanie innego obiektu.

Nadszedł czas by zrozumieć jak na prawdę działa programowanie obiektowe w Javascript.

Uwaga

Od czasu ES6 __proto__ jest już tylko getterem dla [[prototype]]. Zostało to tak zrobione, aby zachować kompatybilność wsteczną. Innymi sposobami dostępu do [[prototype]] jest skorzystanie z Reflect.getPrototypeOf lub Object.getPrototypeOf.

Nie wpływa to jednak na wartość merytoryczną tego wpisu.

Punkt wyjścia

Zaczniemy od początku, czyli od stworzenia obiektów w sposób, który zna każdy.

const tom = {
  firstName: 'Tom',
  lastName: 'Hanks',
  sayHello() {
    console.log(
      `Hello, I'm ${this.firstName} ${this.lastName}`
    )
  }
};

const leo = {
  firstName: 'Leonardo',
  lastName: 'DiCaprio',
  sayHello() {
    console.log(
      `Hello, I'm ${this.firstName} ${this.lastName}`
    )
  }
};

Stworzyłem tutaj dwie osoby, które mają imię, nazwisko i potrafią się przedstawić. Łatwo zauważyć, że łamię tutaj zasadę DRY – Don’t Repeat Yourself. W obu obiektach definiuję taką samą metodę sayHello().

Rozwiązaniem tego problemu jest stworzenie abstrakcji, czyli opakowanie tworzenia osób w funkcję.

Funkcje tworzące obiekty

function createPerson(firstName, lastName) {
  const newPerson = {
    firstName,
    lastName,
    sayHello() {
       console.log(
         `Hello, I'm ${this.firstName} ${this.lastName}`
       );
    }
  };

  return newPerson;
}

const tom = createPerson('Tom', 'Hanks');
const leo = createPerson('Leonardo', 'DiCaprio');

Dzięki wydzieleniu tworzenia osób do funkcji createPerson nie łamiemy już zasady DRY. Dodanie nowych metod ograniczy się do wprowadzenia zmiany tylko w obrębie createPerson. Kod jest czystszy i bardziej otwarty na zmiany, ale nie jest optymalny.

Problemem jest to, że dla każdego nowo tworzonego obiektu trzeba stworzyć w pamięci nową metodę sayHello(). Może się to wydawać znikomym problemem, jednak staję się on zauważalny po zwiększeniu skali.

Wyobraź sobie sytuację, że obiekt person ma 20 różnych metod, a nam potrzeba 1000 takich obiektów.

1000 * 20 = 20 000 zapisanych w pamięci metod

Zakładając, że każda taka metoda zajmuje 8 bajtów, potrzeba 160 000 bajtów na przechowanie tych samych metod. Optymalnie było by je przechować w jednym miejscu (gdzie zajmowałyby tylko 160 bajtów) do którego każdy obiekt w razie potrzeby by się odwoływał.

Czym jest __proto__

W języku Javascript istnieje kilka możliwości tworzenia obiektów. Mamy na przykład najczęściej wykorzystywany object literal.

const a = {};

Możemy również wykorzystać object constructor i korzystając ze słowa kluczowego new stworzyć taki sam obiekt jak wyżej.

const b = new Object();

Object.create() jako argument przyjmuje inny obiekt lub null. Ta metoda ma takie samo zachowanie jak Object constructor i Object literal, jeśli przekażemy do niej pusty obiekt. Każda z poniższych instrukcji robi (prawie) to samo.

const a = {} // => {}
const b = new Object() // => {}
const c = Object.create({}) // => {}

W momencie gdy przekażemy do Object.create() niepusty obiekt, dzieją się ciekawe rzeczy, których na pierwszy rzut oka nie widać.

const test = {
  sayHello() {
    console.log('Hello?');
  }
}
const newObject = Object.create(test)
console.log(newObject) // => {}
newObject.sayHello() // => Hello?

Stworzyłem obiekt test, który ma metodę: sayHello(). Następnie stworzyłem newObject korzystając z Object.create i przekazując obiekt test jako argument.

W chwili gdy wyświetliłem newObject, okazało się, że jest on tak samo pusty jak obiekty, które tworzyłem chwilę wcześniej.

Następnie wywołałem metodę sayHello() na obiekcie newObject. Chwilę wcześniej wyświetliłem ten obiekt i widziałem, że nie ma on takiej metody, a mimo wszystko operacja się powiodła i na konsoli pojawiło się Hello?.

Dlaczego tak się stało?

Po dość długim wstępie przechodzimy do meritum tego wpisu. Każdy stworzony obiekt posiada atrybut __proto__, którego domyślną wartością jest Object.prototype.

Kolejnym faktem jest, że w chwili gdy Javascript nie znajdzie szukanego atrybutu bądź metody w obiekcie to zagląda do __proto__ tego obiektu.

Kiedy używamy Object.create i przekazujemy jako argument inny obiekt to ustawiamy przekazany obiekt jako __proto__ dla nowo tworzonego obiektu.

Wywołując Object.create(null), ustawiamy __proto__ jako null. Oznacza to, że nasz obiekt jest czysty i nie ma dostępu do metod znajdujących się w Object.prototype takich jak na przykład toString.

Odpowiadając na pytanie zadane w nagłówku tej części: każdy obiekt posiada atrybut __proto__ którego wartość jest obiektem. __proto__ jest miejscem w którym Javascript będzie kontynuował poszukiwania jeśli nie znajdzie atrybutu w danym obiekcie.

Co tak na prawdę robi Object.create()

Czasem warto stać się na chwilę interpreterem i przeanalizować kod linijka po linijce. Pozwala to na lepsze zrozumienie tego co robimy.

const personFunctions = {
  sayHello() {
    console.log(
      `Hello, I'm ${this.firstName} ${this.lastName}`
    )
  }
}

const tom = Object.create(personFunctions);
tom.firstName = 'Tom';
tom.lastName = 'Hanks';

tom.sayHello() // => Hello, I'm Tom Hanks

A więc po kolei:

  1. Tworzę obiekt personFunctions, który ma tylko jeden atrybut: sayHello. Wartością sayHello jest funkcja.
  2. Tworzę obiekt tom używając Object.create(personFunctions). Javascript zapisuje personFunctions jako __proto__ obiektu tom.
  3. Dodaję do obiektu tom atrybut firstName i przypisuję mu wartość Tom.
  4. Dodaję do obiektu tom atrybut lastName i przypisuję mu wartość Hanks.
  5. Wywołuję na obiekcie tom metodę sayHello().
    1. Javascript sprawdza czy obiekt tom posiada atrybut sayHello. Obiekt tom nie posiada takiego atrybutu. W punkcie 3 i 4 dodałem do obiektu tylko atrybuty firstName i lastName. Nigdy nie dodałem do niego atrybutu sayHello.
    2. Jeśli Javascript nie znajdzie danego atrybutu, idzie poziom wyżej w czymś co jest nazywane __proto__ chain. Co jest kolejne w tym łańcuchu? Następnym miejscem, w którym Javascript będzie poszukiwał danego atrybutu jest tom.__proto__, które jest po prostu obiektem personFunctions.
    3. Javascript zagląda do obiektu personFunctions i zauważa, że jest tam funkcja sayHello() i wywołuje ją.

Podsumowując, wywołując Object.create(personFunctions), dzieją się dwie rzeczy:

  1. Tworzony i zwracany jest pusty obiekt {}
  2. Do atrybutu __proto__ nowo stworzonego obiektu, przypisywany jest argument przekazany do Object.create() czyli w tym przypadku personFunctions.

W ten sposób tworzymy łańcuch poszukiwań. Tutaj ten łańcuch wygląda następująco.

console.log(tom) // => tom
console.log(tom.__proto__) // => personFunctions
console.log(tom.__proto__.__proto__) // => Object.prototype

Jak działa new

Skorzystanie z Object.create sprawia, że ograniczamy zużycie pamięci, ponieważ funkcjonalności tworzymy tylko raz a potem każdy obiekt ma do nich referencję – __proto__

const personFunctions = {
  sayHello() {
    console.log(
      `Hello, I'm ${this.firstName} ${this.lastName}`
    );
  }
};

function createPerson(firstName, lastName) {
  const newPerson = Object.create(personFunctions);
  newPerson.firstName = firstName;
  newPerson.lastName = lastName;
  return newPerson;
}

const tom = createPerson('Tom', 'Hanks');
const leo = createPerson('Leonardo', 'DiCaprio');

Ten kod, pomimo że już całkiem niezły, nadal da się udoskonalić. Można wykorzystać słowo kluczowe new do zautomatyzowania pewnych czynności.

function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}

Person.prototype.sayHello = function() {
  console.log(`Hello, I'm ${this.firstName} ${this.lastName}`);
}

const tom = new Person('Tom', 'Hanks');
const leo = new Person('Leonardo', 'DiCaprio');

Ten kod mocno różni się od poprzedniej wersji, ale po kolei.

Zmiana nazwy funkcji z createPerson na Person – funkcje tworzące obiekty nazywa się konstruktorami. Konwencja mówi, że nazwą konstruktora, z którego korzysta się używając słowa kluczowego new, powinien być rzeczownik zaczynający się z wielkiej litery. W ten sposób odróżniamy je od innych funkcji, ponieważ new zmienia zachowanie wywoływanych funkcji na dwa sposoby:

  1. Automatycznie tworzy nowy obiekt i przypisuje go do this. Ten obiekt jest automatycznie zwracany.
  2. Przypisuje do __proto__ obiekt znajdujący się pod Person.prototype (jest to obiekt, który posiada atrybut constructor wskazujący na funkcję Person)

Można sobie wyobrazić, że poprzez użycie new dodajemy niewidzialny kod do funkcji. Zauważ, że jest to po prostu coś co wcześniej musieliśmy napisać sami.

function Person(firstName, lastName) {
  // this = Object.create(Person.prototype)
  this.firstName = firstName;
  this.lastName = lastName;
  // return this
}

Spójrz teraz na linijkę poniżej

Person.prototype.sayHello = function() {};

Każda funkcja jest równocześnie obiektem. Można ująć to inaczej – funkcja to tak na prawdę obiekt, który można wywołać. Funkcje są wyjątkowe dlatego, że posiadają atrybut prototype. Wartością prototype jest obiekt, a jego jedynym przeznaczeniem jest zostanie przypisanym jako __proto__ nowo tworzonego obiektu.

Klasy ES6

W ECMAScript2015 wprowadzono słowo kluczowe class. Jest ono tylko lukrem składniowym. Pod spodem działa to zupełnie tak samo jak to co robiliśmy wcześniej. constructor jest funkcją Person, a sayHello jest dodawane do prototype tej funkcji.

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  sayHello() {
    console.log(`Hello, I'm ${this.firstName} ${this.lastName}`);
  }
}

const tom = new Person('Tom', 'Hanks');

Podsumowanie

__proto__ posiada każdy obiekt i jeśli Javascript nie znajdzie atrybutu w danym obiekcie, to szuka dalej poruszając się w dól po __proto__ chain dopóki nie trafi na null.

prototype posiadają tylko funkcje (definiowane przy pomocy słowa kluczowego function). Poprzedzając wywołanie funkcji słowem kluczowym new, tworzymy obiekt, do którego __proto__ zostaje przypisana wartość prototype.

Jeśli chcesz bardziej zgłębić ten temat, mogę Ci polecić kurs: Will Sentance – JavaScript: The Hard Parts. Po jego kursie programowanie obiektowe w Javascript nie będzie miało przed tobą tajemnic, ponieważ Will jest mistrzem tłumaczenia trudnych kwestii w prosty sposób.

Możesz otrzymać dostęp do Frontend Masters za darmo jeśli jesteś studentem, sprawdź GitHub Student Developer Pack.

Jeśli chcesz dogłębniej poznać Javascript to sprawdź mój inny wpis – Jak naprawdę działa closure?

Pobierz darmowy ebook zawierający 20 pytań, które możesz usłyszeć na rozmowie kwalifikacyjnej