Тип Поста

Объектно-Ориентированный JavaScript – Изучаем ES6 Классы

Объектно-Ориентированный JavaScript – Изучаем ES6 Классы

Часто бывает, что нам нужно представить какую-либо идею или концепцию в нашей программе, будь то двигатель машины, показания температуры или что угодно другое. Представление подобных концепций в коде напрямую можно разделить на две части: данные для обозначения состояния и функции для обозначения поведения. Классы дают нам удобный синтаксис для определения состояния данных и поведения объектов, которые будут представлять наши идеи. Они делают наш код безопасней, гарантируя вызов инициализирующей функции, и благодаря использованию классов становится легче определить фиксированный набор функций, которые взаимодействуют с данными, поддерживая их в актуальном состоянии. И если вы можете думать о чем-то, как об отдельной сущности, то, скорей всего, вы должны определить класс для представления этой сущности в вашей программе.

Давайте рассмотрим этот код, который написан без использования классов. Как много ошибок вы можете найти? Как бы вы их исправили?

// Установим текущую дату на Декабрь 24
let today = {
  month: 12,
  day  : 24
};

let tomorrow = {
  year : today.year,
  month: today.month,
  day  : today.day + 1
};

let dayAfterTomorrow = {
  year : tomorrow.year,
  month: tomorrow.month,
  day  : tomorrow.day + 1 <= 31 ? tomorrow.day + 1 : 1
};

Данные today не являются верными, в них пропущена инициализация года. Было бы гораздо лучше, если мы имели какую-то функцию для инициализации данных, используя которую нельзя забыть или потерять часть данных. Также заметьте, что при добавлении дня, мы производим проверку выхода за пределы значения 31 всего лишь в одном месте, но упустили эту проверку в другом месте. Поэтому было бы хорошо, если мы сможем производить взаимодействия с данными только через определенный набор функций, каждая из которых будет поддерживать валидное состояние данных.

Таким образом, корректная версия с учетом всех вышеприведенных пожеланий, будет выглядеть так:

class SimpleDate {
  constructor( year, month, day ) {
    // Проверяем, являются ли year, month, day валидными данными
    // ...

    // Если это так, то инициализируем их через this
    this._year  = year;
    this._month = month;
    this._day   = day;
  }

  addDays( nDays ) {
    // Увеличиваем this дату количеством переданных дней
    // ...
  }

  getDay() {
    return this._day;
  }
}

// today гарантированно будет валидным и полностью инициализированным
let today = new SimpleDate( 2000, 2, 28 );

// Производим взаимодействие с данным только через определенный набор функций для обеспечения валидного состояния
today.addDays( 1 );
Определим термины:
  • Когда функция связана с классом или объектом, то мы называем её “методом
  • Когда объект создается из класса, то про такой объект говорят, что он является “экземпляром” класса

Конструкторы

Метод constructor является тем специальным средством, с помощью которого можно решить проблему инициализации данных. Его работа заключается в создании экземпляра класса с валидным состоянием, и он будет вызван автоматически, что не даст нам забыть что-то при инициализации объектов.

Храним приватные данные

Обычно, мы стараемся создавать наши классы таким образом, чтобы состояние их данных гарантированно было валидным. Мы используем конструкторы, которые создают только правильные значения, и мы разрабатываем такие методы, результат работы которых также будет валидным. Но пока данные наших классов остаются доступными каждому разработчику, кто-нибудь обязательно их испортит. Защититься от этого можно с помощью создания недоступных данных обычными способами, кроме тех, которые мы предоставим.

Определим термины:
Хранение данных в приватном состоянии для защиты их от нежелательного доступа называется “инкапсуляция

Приватные данные с помощью соглашения

К сожалению, приватные свойства объектов не существуют в JavaScript, но мы можем имитировать их. Наиболее простой способ сделать это – придерживаться определенного соглашения: если перед именем свойства есть знак нижнего подчеркивания (_), или после имени (менее распространенный вариант), то в таком случае, данные рассматриваются в качестве непубличных. Мы использовали такой подход в примере кода выше, и в общем такой способ работает, но технически, к данным все равно может любой желающий получить доступ. Поэтому при таком варианте мы должны полагаться, прежде всего, на свою собственную дисциплину, чтобы делать правильные вещи.

Приватные данные с помощью привилегированных методов

Следующий распространенный способ имитировать приватные свойства объектов является использование обычных переменных в конструкторе, и заключение их в замыкания. Подобный трюк даст нам по-настоящему приватные данные, недоступные снаружи. Но чтобы это сработало, наши методы класса должны быть определены в конструкторе и назначены экземпляру класса.

class SimpleDate {
  constructor( year, month, day ) {
    // Проверяем, являются ли year, month, day валидными данными
    // ...

    // Если это так, то инициализируем их через this
    let _year  = year;
    let _month = month;
    let _day   = day;

    // Методы, определенные в конструкторе заключают переменные в замыкания
    this.addDays = function( nDays ) {
      // Увеличиваем this дату количеством переданных дней
      // ...
    }

    this.getDay = function() {
      return _day;
    }
  }
}

Приватные данные с помощью Symbol()

Symbol() является новой возможностью JavaScript имитировать приватные свойства объектов. Вместо нижнего подчеркивания имен свойств, мы можем использовать уникальные символьные ключи объектов, и наш класс, в таком случае, сможет заключить эти ключи в замыкание. Но здесь есть один недостаток, т.к. еще одной новой возможностью JavaScript является Object.getOwnPropertySymbols, что позволит получить доступ снаружи к созданным символьным ключам, которые мы пытаемся сохранить приватными.

let SimpleDate = (function() {
  let _yearKey  = Symbol();
  let _monthKey = Symbol();
  let _dayKey   = Symbol();

  class SimpleDate {
    constructor( year, month, day ) {
      // Проверяем, являются ли year, month, day валидными данными
      // ...

      // Если это так, то инициализируем их через this
      this[ _yearKey ]  = year;
      this[ _monthKey ] = month;
      this[ _dayKey ]   = day;
    }

    addDays( nDays ) {
      // Увеличиваем this дату количеством переданных дней
      // ...
    }

    getDay() {
      return this[ _dayKey ];
    }
  }

  return SimpleDate;
}());

Приватные данные с помощью Weak Maps

Weak Maps также являются новой особенностью JavaScript, благодаря которой, мы можем хранить приватные свойства объектов в парах ключ/значение, используя наш экземпляр в качестве ключа. В таком случае, наш класс сможет заключить такие ключ/значение в замыкание.
let SimpleDate = (function() {
  let _years  = new WeakMap();
  let _months = new WeakMap();
  let _days   = new WeakMap();

  class SimpleDate {
    constructor( year, month, day ) {
      // Проверяем, являются ли year, month, day валидными данными
      // ...

      // Если это так, то инициализируем их через this
      _years.set( this, year );
      _months.set( this, month );
      _days.set( this, day );
    }

    addDays( nDays ) {
      // Увеличиваем this дату количеством переданных дней
      // ...
    }

    getDay() {
      return _days.get( this );
    }
  }

  return SimpleDate;
}());

Другие модификаторы доступа

Существуют дополнительные уровни видимости данные, кроме “приватные”, которые вы можете найти в других языках. Но JavaScript все еще не предоставляет нам никакого способа добиться этих уровней приватности. И если у вас все же есть такая необходимость, то в таком случает остается полагаться только на соглашения при написании кода и на самодисциплину.

Ссылка на текущий объект

Взглянем снова на метод getDay(). В нем не определяются никакие параметры, но каким же образом этот метод знает объект для которого он был вызван? Когда функция вызывается в качестве метода, используя object.function нотацию, то в таком случае существует неявный аргумент, который используется методом для идентификации объекта, и этот неявный аргумент назначается неявному параметру this. Чтобы показать это на примере, можно разобрать такой код, где мы могли бы отсылать аргумент объекта в явном виде, а не косвенно.

// Получаем ссылку на getDay функцию
let getDay = SimpleDate.prototype.getDay;

getDay.call( today ); // "this" будет "today"
getDay.call( tomorrow ); // "this" будет "tomorrow"

tomorrow.getDay(); // Тоже, что и в последней строке, но "tomorrow" передано неявно

Статические свойства и методы

У нас есть возможность определить данные и функции, которые являются частью класса, но не частью любого экземпляра этого класса. Мы называем это статическими свойствами и методами, и таким образом, у нас будет только одна копия статических свойств, в отличии от новой копии всякий раз, при создании экземпляра.

class SimpleDate {
  static setDefaultDate( year, month, day ) {
    // На статическое свойство можно ссылаться без создания экземпляра 
    SimpleDate._defaultDate = new SimpleDate( year, month, day );
  }

  constructor( year, month, day ) {
    // Если конструктор вызывается без аргументов,
    // тогда инициализируем "this" дату копированием статичной даты по умолчанию
    if( arguments.length === 0 ) {
      this._year  = SimpleDate._defaultDate._year;
      this._month = SimpleDate._defaultDate._month;
      this._day   = SimpleDate._defaultDate._day;

      return;
    }

    // Проверяем, являются ли year, month, day валидными данными
    // ...

    // Если это так, то инициализируем их через this
    this._year  = year;
    this._month = month;
    this._day   = day;
  }

  addDays( nDays ) {
    // Увеличиваем this дату количеством переданных дней
    // ...
  }

  getDay() {
    return this._day;
  }
}

SimpleDate.setDefaultDate( 1970, 1, 1 );

let defaultDate = new SimpleDate();

Подклассы

Очень часто мы имеем в наших классах повторяющийся код, который было бы хорошо объединить для повторного использования. Подклассы дают нам такую возможность – включать состояния и поведения других классов в наш собственный. Такой процесс часто называют “наследованием”, и можно сказать, что наши подклассы “наследуются” от родительского класса, который называется суперклассом. Наследование позволяет избежать дублирования кода, а также упрощает реализацию класса, если ему нужны такие же данные и функции, как и другому классу. К тому же наследование позволит нам заменять подклассы основываясь на общем интерфейсе, предоставляемым суперклассом.

Наследование для избежания повторения

Давайте рассмотрим код без использования наследования:

class Employee {
  constructor( firstName, familyName ) {
    this._firstName  = firstName;
    this._familyName = familyName;
  }

  getFullName() {
    return `${this._firstName} ${this._familyName}`;
  }
}

class Manager {
  constructor( firstName, familyName ) {
    this._firstName        = firstName;
    this._familyName       = familyName;
    this._managedEmployees = [];
  }

  getFullName() {
    return `${this._firstName} ${this._familyName}`;
  }

  addEmployee( employee ) {
    this._managedEmployees.push( employee );
  }
}

Свойства данных _firstName и _familyName, а также метод getFullName повторяются в наших классах. Мы можем устранить такое повторение, если от класса Employee будет наследоваться класс Manager. Когда мы сделаем такое наследование, то состояние и поведение класса Employee будет включено в Manager класс.

Так будет выглядеть версия кода с применением наследования. И обратите внимание на ключевое слово super.

// Класс Manger работает также, как и прежде, но без повторяющегося кода
class Manager extends Employee {
  constructor( firstName, familyName ) {
    super( firstName, familyName );
    this._managedEmployees = [];
  }

  addEmployee( employee ) {
    this._managedEmployees.push( employee );
  }
}

Принцип проектирования наследования

Существуют некоторые принципы проектирования наследования, которые помогут вам определиться, когда наследования является подходящим вариантом. Наследование всегда должно соответствовать таким взаимосвязям: “Б является подклассом A” и “Б работает также, как и А”. Таким образом, в нашем случае: “Manager является Employee” и “Manager работает также, как и Employee”. Поэтому Manager является разновидностью Employee, что позволит нам заменить его экземпляром подкласса и все должно продолжать работать. Разница между нарушением и следованием данным принципам иногда может быть весьма тонкой. Классическим примером такой разницы послужит Rectangle суперкласс и его подкласс Square.

class Rectangle {
  set width( w ) {
    this._width = w;
  }

  get width() {
    return this._width;
  }

  set height( h ) {
    this._height = h;
  }

  get height() {
    return this._height;
  }
}

// Функция, которая взаимодействует с экземпляром Rectangle
function f( rectangle ) {
  rectangle.width  = 5;
  rectangle.height = 4;

  // Проверяем ожидаемый результат
  if( rectangle.width * rectangle.height !== 20 ) {
    throw new Error( 'Ожидаемая площадь прямоугольника (ширина * высота) должна равняться 20' );
  }
}

// Квадрат является прямоугольником... или нет?
class Square extends Rectangle {
  set width( w ) {
    super.width = w;

    // Поддерживаем квадратность 
    super.height = w;
  }

  set height( h ) {
    super.height = h;

    // Поддерживаем квадратность
    super.width = h;
  }
}

// Но может ли прямоугольник заменен квадратом?
f( new Square() ); // Ошибка

Квадрат может быть прямоугольником с математической точки зрения, но поведенчески квадрат не работает, как прямоугольник.

Правило, при котором любое использование экземпляра суперкласса должно быть заменяемо экземпляром подкласса названо Принципом подстановки Барбары Лисков, и это является важной частью разработки объектно-ориентированных классов.

Не злоупотребляйте чрезмерным наследованием

Очень легко впасть в крайность и начать находить общность везде, где только можно, особенно соблазнительно выглядит перспектива создания класса, который предоставляет абсолютную функциональность, даже для опытных разработчиков. Но в наследовании также есть свои недостатки. Стоит помнить, что мы обеспечиваем валидное состояние с помощью манипулирования данными через небольшой набор фиксированных функций. При использовании наследования, мы увеличиваем список функций, которые могут напрямую обрабатывать данные и в дальнейшем, эти функции также будут ответственны за поддерживание валидного состояния. Если слишком много функций могут работать напрямую с данными, то эти данные приобретают такие же негативные свойства, как и глобальные переменные. Использование слишком большого количества наследования может привести к появлению монолитных классов, которые ослабляют инкапсуляцию, их труднее поддерживать и сложнее повторно использовать. Вместо этого, старайтесь создавать небольшие классы, которые воплощают только одну концепцию.

Давайте снова рассмотрим проблему дублирования кода. Можем ли мы решить ее без наследования? Альтернативный подход решения проблемы заключается в использовании связывания объектов через ссылки для создания частичных отношений. Такой подход называется “композиция”.

Вот так будет выглядеть версия кода с использованием композиционной связи manger-employee, вместо наследования:

class Employee {
  constructor( firstName, familyName ) {
    this._firstName  = firstName;
    this._familyName = familyName;
  }

  getFullName() {
    return `${this._firstName} ${this._familyName}`;
  }
}

class Group {
  constructor( manager /* : Employee */ ) {
    this._manager          = manager;
    this._managedEmployees = [];
  }

  addEmployee( employee ) {
    this._managedEmployees.push( employee );
  }
}

В данном случае, Manager – не отдельный класс, а обычный экземпляр класса Employee, ссылка на который содержится в классе Group. И если модель наследования соответствует “Б является подклассом A” взаимоотношениям, то модель композиции относится к “Б принадлежит А” отношениям, т.е Manager принадлежит Group.

Если для выражения концепции нашей программы подходит и наследование, и композиция, то в таком случае следует отдавать предпочтение композиционному подходу.

Наследование с подменой подклассов

При наследовании возможен такой вариант, при котором происходит взаимная замена разных подклассов через интерфейс, предоставляемый общим суперклассом. В функцию, которая ожидает экземпляр суперкласса в качестве аргумента, также может быть передан экземпляр подкласса, и при этом функции нет необходимости знать о каком бы то ни было подклассе. Подмена классов, у которых есть общий суперкласс, часто называется “полиморфизм”.

// Это будет нашим общим суперклассом
class Cache {
  get( key, defaultValue ) {
    let value = this._doGet( key );

    if( value === undefined || value === null ) {
      return defaultValue;
    }

    return value;
  }

  set( key, value ) {
    if( key === undefined || key === null ) {
      throw new Error( 'Неверный аргумент' );
    }

    this._doSet( key, value );
  }

  // Должны быть переопределены
  // _doGet()
  // _doSet()
}

// Подкласс не объявляет новые публичные методы
// Публичный интерфейс полностью определяется в суперклассе
class ArrayCache extends Cache {
  _doGet() {
    // ...
  }

  _doSet() {
    // ...
  }
}

class LocalStorageCache extends Cache {
  _doGet() {
    // ...
  }

  _doSet() {
    // ...
  }
}

// Функции могут полиморфно работать с любым кэшем, благодаря наследованию через интерфейс суперкласса
function compute( cache ) {
  let cached = cache.get( 'result' );

  if( !cached ) {
    let result = // ...
          cache.set( 'result', result );
  }

  // ...
}

compute( new ArrayCache() ); // Используем массив кэша через интерфейс суперкласса
compute( new LocalStorageCache() ); // Используем кэш локального хранилища через интерфейс суперкласса

Больше, чем синтаксический сахар

Довольно-таки часто говорят про JavaScript классы, что они являются синтаксическим сахаром и в большинстве случаев – так оно и есть. Но есть и реальные отличия от того, что мы можем делать с помощью ES6 классов и что не получилось бы в ES5.

Наследования статических свойств

ES5 не позволял нам создавать настоящее наследование между функциями конструкторами. Object.create может создать обычный объект, но не объект функции. Мы имитировали наследование статических свойств вручную копируя их. Теперь с помощью ES6 классов, мы имеем реальную прототипную связь между функцией конструктора подкласса и конструктором суперкласса.

// ES5
function B() {}
B.f = function() {};

function D() {}
D.prototype = Object.create( B.prototype );

D.f(); // Ошибка
// ES6
class B {
  static f() {}
}

class D extends B {}

D.f(); // Работает

Встроенные конструкторы могут быть подклассами

Некоторые объекты являются весьма “экзотичными” и не ведут себя, как обычные объекты. Например, массивы, которые подгоняют значение их свойства length, чтобы оно было больше, чем наибольший индекс. В ES5, при попытке использовать Array в качестве подкласса, оператор new мог создать обычный объект для наших подклассов, но он не понимал “необычный” объект нашего суперкласса.

// ES5
function D() {
  Array.apply( this, arguments );
}
D.prototype = Object.create( Array.prototype );

var d  = new D();
d[ 0 ] = 42;

d.length; // 0 - плохо, нет поведения, свойственного массиву

ES6 классы исправляют данную фичу – они изменяются в зависимости от того, когда и кем были созданы. В ES5, объекты распределялись до вызова конструктора подкласса, и подкласс мог передать такой объект в конструктор суперкласса. Теперь же в ES6 классах, объекты распределяются до вызова конструктора суперкласса, и суперкласс делает эти объекты доступными для конструктора подкласса. Это позволяет Array создавать необычные объекты даже тогда, когда мы используем new для нашего подкласса.

// ES6
class D extends Array {}

let d  = new D();
d[ 0 ] = 42;

d.length; // 1 - хорошо, поведение, свойственное массиву

Другие отличия

Существует небольшое разнообразие других, менее значимых отличий. Например, класс конструктора не может быть вызван, как функция. Это защищает от вызова конструктора с помощью ключевого слова new. Также свойство конструктора класса prototype не может быть переназначено. Это может помочь движку JavaScript лучше оптимизировать объекты классов. И наконец, методы классов не имеют свойства prototype. Это улучшает использование памяти, устраняя лишние объекты.

Использование новых особенностей самыми разнообразными способами

Многие из возможностей, описанные в этой статье или ей подобных, являются весьма новыми для JavaScript, и комьюнити разработчиков проводят постоянные эксперименты прямо сейчас в поисках новых способов использования этих возможностей.

Множественное наследование с помощью прокси

Одна из таких экспериментальных возможностей – использование прокси для реализации множественного наследования. Имеющаяся в JavaScript прототипная цепочка позволяет осуществить только лишь одиночное наследование. Объекты могут делегировать только к единственному объекту. Прокси дают нам способ делегировать доступ к другим множествам объектов и их свойств.

let transmitter = {
  transmit() {}
};

let receiver = {
  receive() {}
};

// Создаем прокси объект, который будет перехватывать доступ к свойствам и
// переадресовывать к соответствующему объекту, а затем вернет необходимое значение
let inheritsFromMultiple = new Proxy( [ transmitter, receiver ], {
  get: function( proxyTarget, propertyKey ) {
    const foundParent = proxyTarget.find( parent => parent[ propertyKey ] !== undefined );

    return foundParent && foundParent[ propertyKey ];
  }
} );

inheritsFromMultiple.transmit(); // работает
inheritsFromMultiple.receive(); // работает

Можем ли мы применить такой подход к работе с классами? Свойство класса prototype может быть прокси, которое адресует доступ к свойствам во множество других прототипов. JavaScript комьюнити как раз работает над такой возможностью.

Множественное наследование с помощью фабричных классов

Еще один способ, над которым экспериментирует JavaScript комьюнити – генерация классов по требованию. Каждый класс все еще имеет одного родителя, но мы можем сделать цепочку из таких классов необычным способом.

function makeTransmitterClass( Superclass = Object ) {
  return class Transmitter extends Superclass {
    transmit() {}
  };
}

function makeReceiverClass( Superclass = Object ) {
  return class Receiver extends Superclass {
    receive() {}
  };
}

class InheritsFromMultiple extends makeTransmitterClass( makeReceiverClass() ) {}

let inheritsFromMultiple = new InheritsFromMultiple();

inheritsFromMultiple.transmit(); // работает
inheritsFromMultiple.receive(); // работает

Существуют ли другие вообразимые способы использования этих возможностей? Кто знает… Каждый разработчик может попытаться сделать что-то новое и интересное, тем самым оставит свой след в JavaScript мире.

Заключение

Надеюсь, эта статья дала вам понимание работы классов в ES6 и некоторое пояснение сленга, который существует вокруг них. Также не стоит забывать, что некоторые из новых возможностей могут все еще не поддерживаться браузерами, поэтому, возможно, придется воспользоваться транспайлерами, например, Babel для полноценной работы этих возможностей.

При создании статьи были использованы следующие источники:

Поделиться

Оставить комментарий

Вы можете использовать следующие HTML-теги:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>
Обязательно к заполнению