Классы на прототипах: как работает ООП в JavaScript

Изначально ООП модель JavaScript была основана на прототипной структуре, но для удобства в нем появились классы. Посмотрим, как это работает на деле.

JavaScript – это не классический объектно-ориентированный язык, основанный на классах, однако в нем есть способ для их реализации.

Согласно Википедии класс-ориентированное программирование – это стиль объектно-ориентированного программирования (ООП), в котором наследование происходит через определение классов объектов, а не через сами объекты.

Классовая модель – это самая популярная реализация объектно-ориентированного программирования. Однако ООП в JavaScript основано не на классах, а на прототипах. В этой модели для создания новых объектов используется шаблонный объект – прототип.

Посмотрим на пример кода:

let names = {
    fname: "Dillion",
    lname: "Megida"
}
console.log(names.fname); // Dillion
console.log(names.hasOwnProperty("mname")); // false

В переменную names мы записали объект, у которого есть два свойства – fname и lname – и ни одного метода. Откуда же взялся метод hasOwnProperty?

Дело в том, что объект names не существует сам по себе, у него есть прототип – Object.prototype.

Выведем эту переменную в консоль:

console.log(names);

Вот что мы увидим:

Развернем загадочное свойство __proto__:

Мы можем увидеть конструктор объекта names – функцию Object() – и множество методов под ним. Среди них – и hasOwnProperty. Все эти методы хранятся в прототипе Object, но доступны и самому объекту names.

Другими словами, все объекты в JavaScript создаются с использованием прототипа Object.prototype:

names.__proto__ === Object.prototype; // true

И каждый объект имеет доступ ко всем методам этого прототипа, не обладая ими напрямую.

Свойство __proto__

Каждый объект в JavaScript имеет свойство __proto__, в котором хранится ссылка на другой объект, являющийся его прототипом.

Через это свойство, этот объект и получает доступ к свойствам и методам прототипа.

По умолчанию у всех объектов в свойство __proto__ записана ссылка на Object.prototype. Однако мы можем изменить это – иначе говоря «унаследовать» объект от другого прототипа.

Изменение прототипа

Не следует менять свойство __proto__ напрямую, для этого существуют специальные методы.

Object.create()

function DogObject(name, age) {
    let dog = Object.create(constructorObject);
    dog.name = name;
    dog.age = age;
    return dog;
}
let constructorObject = {
    speak: function(){
        return "I am a dog"
    }
}
let bingo = DogObject("Bingo", 54);
console.log(bingo);

Посмотрим на объект bingo в консоли:

Теперь в свойстве __proto__ находится другая функция-конструктор и метод speak.

Object.create создает новый объект, автоматически устанавливая ему указанный прототип.

Ключевое слово new

function DogObject(name, age) {
    this.name = name;
    this.age = age;
}
DogObject.prototype.speak = function() {
    return "I am a dog";
}
let john = new DogObject("John", 45);

john.__proto__ === DogObject.prototype; // true

Теперь свойство john.__proto__ ссылается на DogObject.prototype. Обратите внимание – не на функцию DogObject (она является только конструктором объектов), а на объект, записанный в ее свойстве prototype.

Данные и методы хранятся только в объектах. Функции-конструкторы вроде DogObject или Object только предлагают удобный способ конфигурации (через this) и создания новых объектов.Функция-конструктор имеет свойство prototype, в котором хранится объект-прототип. По его образу эта функция будет создавать новые объекты.
Прототип объекта, хранящийся в его свойстве __proto__, и объект, хранящийся в свойстве prototype конструктора – одно и то же.

В свою очередь прототип прототипа john ссылается на уже знакомый нам Object.prototype.

DogObject.prototype.__proto__ === Object.prototype;

То, что мы видим, называется цепочкой прототипов. Это основа прототипно-ориентированного программирования. Объект может получить доступ к любому свойству или методу, которое есть у любого звена его прототипной цепочки.

Сколько бы прототипов ни было в этой цепочке, последний почти всегда будет наследовать от исходного Object.prototype. При необходимости можно избавиться от дефолтного прототипа, вызвав метод Object.create и передав ему null. Это бывает полезно для создания словарей, в которых не должно быть ничего лишнего.

Ключевое слово new, которое превращает обычную функцию в конструктор объектов, делает по сути ту же самую вещь, что и Object.create(), только проще.

Функции – тоже объекты

Если вас смутило наличие свойств у функции DogObject, доступных через точку (prototype), то вспомните, что в JS функции – это тоже объекты.

В качестве прототипа они имеют объект Function.prototype, который в свою очередь наследует от Object.prototype.

В Function.prototype содержится много дополнительных свойств и методов, которые наследует от него любая функция (call, apply, isPrototypeOf).

Переходим к классам

Прототипная реализация ООП интересна и имеет свои преимущества, но она не так популярна, как классовая. Поэтому когда в ECMAScript 2015 появилось ключевое слово class, разработчики были очень этому рады.

JS стал походить на классический привычный объектно-ориентированный язык. Однако не будем забывать, что классы в нем – всего лишь синтаксический сахар над теми самыми прототипами. На вид – классы, под капотом – по-прежнему прототипы.

Вот так выглядит обычное создание класса в JavaScript:

class Animals {
    constructor(name, specie) {
        this.name = name;
        this.specie = specie;
    }
    sing() {
        return `${this.name} can sing`;
    }
    dance() {
        return `${this.name} can dance`;
    }
}
let bingo = new Animals("Bingo", "Hairy");
console.log(bingo);

Посмотрим в консоль:

Ничего не напоминает?

Все то же свойство __proto__, которое ссылается на Animals.prototype (-> Object.prototype).

Мы видим, что собственные значения свойств (name и specie) определяются внутри метода constructor. Кроме него создаются дополнительные функции sing и dance – методы прототипа.

Подкапотную реализацию этой конструкции мы разбирали только что – это функция-конструктор + ключевое слово new.

function Animals(name, specie) {
    this.name = name;
    this.specie = specie;
}
Animals.prototype.sing = function(){
    return `${this.name} can sing`;
}
Animals.prototype.dance = function() {
    return `${this.name} can dance`;
}
let Bingo = new Animals("Bingo", "Hairy");

Субклассирование

Субклассирование – это наследование и расширение родительского класса дочерним.

Предположим, вам нужно создать класс Cats. Вы могли бы написать его с нуля, но зачем, если уже есть класс Animals, который реализует часть нужного вам функционала. Вы можете просто унаследовать Cats от Animals и добавить то, чего не хватает (например, цвет усов).

Наследование в класс-ориентированном синтаксисе выглядит так:

class Animals {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }
    sing() {
        return `${this.name} can sing`;
    }
    dance() {
        return `${this.name} can dance`;
    }
} 

class Cats extends Animals {
    constructor(name, age, whiskerColor) {
        super(name, age);
        this.whiskerColor = whiskerColor;
    }
    whiskers() {
        return `I have ${this.whiskerColor} whiskers`;
    }
}
let clara = new Cats("Clara", 33, "indigo");

Объект класса Cats clara может использовать свойства и методы как класса Cats, так и класса Animals.

console.log(clara.sing()); // "Clara can sing"
console.log(clara.whiskers()); // "I have indigo whiskers"


Вот так clara выглядит в консоли:

В свойстве clara.__proto__.constructor лежит класс Cats, через него осуществляется доступ к методу whiskers(). Дальше в цепочке прототипов – класс Animals, с методами sing() и dance(). name и age – это свойства самого объекта.

Перепишем этот код в прототипном стиле с использованием метода Object.create():

function Animals(name, age) {
    let newAnimal = Object.create(animalConstructor);
    newAnimal.name = name;
    newAnimal.age = age;
    return newAnimal;
}
let animalConstructor = {
    sing: function() {
        return `${this.name} can sing`;
    },
    dance: function() {
        return `${this.name} can dance`;
    }
}
function Cats(name, age, whiskerColor) {
    let newCat = Animals(name, age);
    Object.setPrototypeOf(newCat, catConstructor);
    newCat.whiskerColor = whiskerColor;
    return newCat;
}
let catConstructor = {
    whiskers() {
        return `I have ${this.whiskerColor} whiskers`;
    }
}
Object.setPrototypeOf(catConstructor, animalConstructor);
const clara = Cats("Clara", 33, "purple");
clara.sing(); // "Clara can sing"
clara.whiskers(); // "I have purple whiskers"

Метод Object.setPrototypeOf принимает два аргумента (объект, которому нужно установить новый прототип, и собственно сам желаемый прототип).

Функция Animals возвращает объект, прототипом которого является animalConstructor. Функция Cats создает объект с помощью конструктора Animals, но принудительно меняет его прототип на catConstructor, добавляя таким образом новые свойства. catConstructor в свою очередь тоже получает прототипом animalConstructor, чтобы образовалась цепочка прототипного наследования.

Таким образом, обычные животные будут иметь доступ только к методам animalConstructor, а кошки – еще и к catConstructor.

***

JavaScript оказался достаточно гибок, чтобы превратить свое прототипное ООП в классовое для удобства разработчиков.

Больше полезной информации вы можете найти на нашем телеграм-канале «Библиотека фронтендера».

А вам приходилось реализовывать ООП на прототипах в JavaScript?

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

eFusion
01 марта 2020

ТОП-15 книг по JavaScript: от новичка до профессионала

В этом посте мы собрали переведённые на русский язык книги по JavaScript – ...
admin
10 июня 2018

Лайфхак: в какой последовательности изучать JavaScript

Огромный инструментарий JS и тонны материалов по нему. С чего начать? Расск...