[Перевод] Классы и фабричные функции в JavaScript. Что выбрать?
В JavaScript существуют разные способы создания объектов. В частности, речь идёт о конструкциях, использующих ключевое слово class
и о так называемых фабричных функциях (Factory Function). Автор материала, перевод которого мы публикуем сегодня, исследует и сравнивает эти две концепции в поисках ответа на вопрос о плюсах и минусах каждой из них.
Обзор
Ключевое слово class
появилось в ECMAScript 2015 (ES6), в результате теперь у нас есть два конкурирующих паттерна создания объектов. Для того чтобы их сравнить, я опишу один и тот же объект (TodoModel
), пользуясь синтаксисом классов, и применив фабричную функцию.
Вот как выглядит описание TodoModel
с использованием ключевого слова class
:
class TodoModel {
constructor(){
this.todos = [];
this.lastChange = null;
}
addToPrivateList(){
console.log("addToPrivateList");
}
add() { console.log("add"); }
reload(){}
}
Вот — описание того же самого объекта, выполненное средствами фабричной функции:
function TodoModel(){
var todos = [];
var lastChange = null;
function addToPrivateList(){
console.log("addToPrivateList");
}
function add() { console.log("add"); }
function reload(){}
return Object.freeze({
add,
reload
});
}
Рассмотрим особенности этих двух подходов к созданию классов.
Инкапсуляция
Первая особенность, которую можно заметить, сравнивая классы и фабричные функции, заключается в том, что все члены, поля и методы объектов, создаваемых с помощью ключевого слова class
, общедоступны.
var todoModel = new TodoModel();
console.log(todoModel.todos); //[]
console.log(todoModel.lastChange) //null
todoModel.addToPrivateList(); //addToPrivateList
При использовании фабричных функций общедоступно только то, что мы сознательно открываем, всё остальное скрыто внутри полученного объекта.
var todoModel = TodoModel();
console.log(todoModel.todos); //undefined
console.log(todoModel.lastChange) //undefined
todoModel.addToPrivateList(); //taskModel.addToPrivateList
is not a function
Иммутабельность API
После того, как объект создан, я ожидаю, что его API не будет меняться, то есть, жду от него иммутабельности. Однако мы можем легко изменить реализацию общедоступных методов объектов, созданных с помощью ключевого слова class
.
todoModel.reload = function() { console.log("a new reload"); }
todoModel.reload(); //a new reload
Эту проблему можно решить, вызывая Object.freeze(TodoModel.prototype)
после объявления класса, или используя декоратор для «заморозки» классов, когда он будет поддерживаться.
С другой стороны, API объекта, созданного с помощью фабричной функции, иммутабельно. Обратите внимание на использование команды Object.freeze()
для обработки возвращаемого объекта, который содержит лишь общедоступные методы нового объекта. Закрытые данные этого объекта могут быть модифицированы, но сделать это можно только посредством этих общедоступных методов.
todoModel.reload = function() { console.log("a new reload"); }
todoModel.reload(); //reload
Ключевое слово this
Объекты, создаваемые с помощью ключевого слова class
, подвержены давней проблеме потери контекста this
. Например, this
теряет контекст во вложенных функциях. Это не только усложняет процесс программирования, подобное поведение ещё и является постоянным источником ошибок.
class TodoModel {
constructor(){
this.todos = [];
}
reload(){
setTimeout(function log() {
console.log(this.todos); //undefined
}, 0);
}
}
todoModel.reload(); //undefined
А вот как this
теряет контекст при использовании соответствующего метода в событии DOM:
$("#btn").click(todoModel.reload); //undefined
Объекты, созданные с помощью фабричных функций, от подобной проблемы не страдают, так как тут ключевое слово this
не используется.
function TodoModel(){
var todos = [];
function reload(){
setTimeout(function log() {
console.log(todos); //[]
}, 0);
}
}
todoModel.reload(); //[]
$("#btn").click(todoModel.reload); //[]
Ключевое слово this и стрелочные функции
Стрелочные функции частично решают проблемы, связанные с потерей контекста this
при использовании классов, но, в то же время, они создают новую проблему. А именно, при использовании стрелочных функций в классах ключевое слово this
больше не теряет контекст во вложенных функциях. Однако this
теряет контекст при работе с событиями DOM.
Я переработал класс TodoModel
с использованием стрелочных функций. Стоит отметить, что в процессе рефакторинга, при замене обычных функций на стрелочные, мы теряем кое-что важное для читаемости кода: имена функций. Взгляните на следующий пример.
//имя указывает на цель использования функции
setTimeout(function renderTodosForReview() {
/* code */
}, 0);
//код менее понятен при использовании стрелочной функции
setTimeout(() => {
/* code */
}, 0);
При использовании стрелочных функций мне приходится читать текст функции для того, чтобы понять, что именно она делает. Мне же хотелось бы прочесть имя функции и понять её суть, а не читать весь её код. Конечно, можно обеспечить хорошую читабельность кода и при использовании стрелочных функций. Например, можно завести привычку использовать стрелочные функции так:
var renderTodosForReview = () => {
/* code */
};
setTimeout(renderTodosForReview, 0);
Оператор new
При создании объектов на основе классов нужно использовать оператор new
. А при создании объектов с помощью фабричных функций new
не требуется. Однако если использование new
улучшит читаемость кода, данный оператор можно использовать и с фабричными функциями, вреда от этого не будет.
var todoModel= new TodoModel();
При использованииnew
с фабричной функцией функция просто вернёт созданный ей объект.
Безопасность
Предположим, что приложение использует объект User
для работы с механизмами авторизации. Я создал пару таких объектов, используя оба описываемых здесь подхода.
Вот описание объекта User
с использованием класса:
class User {
constructor(){
this.authorized = false;
}
isAuthorized(){
return this.authorized;
}
}
const user = new User();
Вот как выглядит тот же объект, описанный средствами фабричной функции:
function User() {
var authorized = false;
function isAuthorized(){
return authorized;
}
return Object.freeze({
isAuthorized
});
}
const user = User();
Объекты, создаваемые с использованием ключевого слова class
, уязвимы к атакам в том случае, если у злоумышленника имеется ссылка на объект. Так как все свойства всех объектов общедоступны, атакующий может использовать другие объекты для получения доступа к тому объекту, в котором он заинтересован.
Например, получить соответствующие права можно прямо из консоли разработчика, если переменная user
является глобальной. Для того чтобы в этом убедиться, откройте код примера и модифицируйте переменную user
из консоли.
Этот пример подготовлен с помощью ресурса Plunker. Для того, чтобы получить доступ к глобальным переменным, измените контекст в закладке консоли с top
на plunkerPreviewTarget(run.plnkr.co/)
.
user.authorized = true; //доступ к закрытому свойству
user.isAuthorized = function() { return true; } //переопределение API
console.log(user.isAuthorized()); //true
Модификация объекта с помощью консоли разработчика
Объект, созданный с помощью фабричной функции, нельзя изменить извне.
Композиция и наследование
Классы поддерживают и наследование, и композицию объектов.
Я создал пример наследования, в котором класс SpecialService
является наследником класса Service
.
class Service {
log(){}
}
class SpecialService extends Service {
logSomething(){ console.log("logSomething"); }
}
var specialService = new SpecialService();
specialService.log();
specialService.logSomething();
При использовании фабричных функций наследование не поддерживается, тут можно пользоваться лишь композицией. Как вариант, можно использовать команду Object.assign()
для копирования всех свойств из существующих объектов. Например, предположим, что нам надо повторно использовать все члены объекта Service
в объекте SpecialService
.
function Service() {
function log(){}
return Object.freeze({
log
});
}
function SpecialService(args){
var standardService = args.standardService;
function logSomething(){
console.log("logSomething");
}
return Object.freeze(Object.assign({}, standardService, {
logSomething
}));
}
var specialService = SpecialService({
standardService : Service()
});
specialService.log();
specialService.logSomething();
Фабричные функции содействуют использованию композиции вместо наследования, что даёт разработчику более высокий уровень гибкости в плане проектирования приложений.
При использовании классов тоже можно предпочесть композицию наследованию, на самом деле, это всего лишь архитектурные решения, касающиеся повторного использования существующего поведения.
Память
Использование классов способствует экономии памяти, так как они реализованы на базе системы прототипов. Все методы создаются лишь один раз, в прототипе, ими пользуются все экземпляры класса.
Дополнительные затраты памяти, которая потребляется объектами, создаваемыми с помощью фабричных функций, заметны лишь при создании тысяч схожих объектов.
Вот страница, использованная для выяснения затрат памяти, характерных для использования фабричных функций. Вот результаты, полученные в Chrome для различного количества объектов с 10 и 20 методами.
Затраты памяти (в Chrome)
ООП-объекты и структуры данных
Прежде чем продолжать анализ затрат памяти, следует разграничить два вида объектов:
- ООП-объекты
- Объекты с данными (структуры данных).
Объекты предоставляют поведение и скрывают данные.
Структуры данных предоставляют данные, но не обладают сколько-нибудь значительным поведением.
Роберт Мартин, «Чистый код».
Взглянем на уже знакомый вам пример объекта TodoModel
для того, чтобы разъяснить разницу между объектами и структурами данных.
function TodoModel(){
var todos = [];
function add() { }
function reload(){ }
return Object.freeze({
add,
reload
});
}
Объект TodoModel
ответственен за хранение списка объектов todo
и за управление ими. TodoModel
— это ООП-объект, тот самый, который предоставляет поведение и скрывает данные. В приложении будет лишь один его экземпляр, поэтому при его создании с использованием фабричной функции дополнительных затрат памяти не потребуется.
Объекты, хранящиеся в массиве todos
— это структуры данных. В программе может быть множество таких объектов, но это — обычные JavaScript-объекты. Мы не заинтересованы в том, чтобы делать их методы закрытыми. Скорее мы стремимся к тому, чтобы все их свойства и методы были бы общедоступными. В результате все эти объекты будут построены с использованием прототипной системы, благодаря чему нам удастся сэкономить память. Их можно создавать с помощью обычного объектного литерала или командой Object.create()
.
Компоненты пользовательского интерфейса
В приложениях могут быть сотни или тысячи экземпляров компонентов пользовательского интерфейса. Это — та ситуация, в которой нужно найти компромисс между инкапсуляцией и экономией памяти.
Компоненты будут создаваться в соответствии с методами, принятыми в используемом фреймворке. Например, в Vue используются объектные литералы, в React — классы. Каждый член объекта-компонента будет общедоступным, но, благодаря использованию прототипной системы, применение таких объектов позволит экономить память.
Две противоположные парадигмы ООП
В более широком смысле, классы и фабричные функции демонстрируют битву двух противоположных парадигм объектно-ориентированного программирования.
ООП, основанное на классах, в применении к JavaScript, означает следующее:
- Все объекты в приложении описывают, используя синтаксис классов, применяя типы, задаваемые классами.
- Для написания программ ищут язык со статической типизацией, код на котором затем транспилируют в JavaScript.
- В ходе разработки используют интерфейсы.
- Применяют композицию и наследование.
- Функциональное программирование используют совсем мало, или почти не проявляют к нему интереса.
ООП без использования классов сводится к следующему:
- Типы, определяемые разработчиком, не используются. В этой парадигме нет места чему-то вроде
instanceof
. Все объекты создают с помощью объектных литералов, некоторые из них — с общедоступными методами (ООП-объекты), некоторые — с общедоступными свойствами (структуры данных). - В ходе разработки применяется динамическая типизация.
- Интерфейсы не используются. Разработчика интересует лишь то, имеет ли объект необходимое ему свойство. Такой объект можно создать с помощью фабричной функции.
- Применяется композиция, но не наследование. При необходимости все члены одного объекта копируют в другой, используя
Object.assign()
. - Используется функциональное программирование.
Итоги
Сильная сторона классов заключается в том, что они хорошо знакомы программистам, пришедшим в JS из языков, разработка на которых основана на классах. Классы в JS представляют собой «синтаксический сахар» для прототипной системы. Однако, проблемы с безопасностью и использование this
, ведущее к постоянным ошибкам из-за потери контекста, ставят классы на второе место в сравнении с фабричными функциями. В порядке исключения к классам прибегают в тех случаях, когда они применяются в используемом фреймворке, например — в React.
Фабричные функции — это не только инструмент для создания защищённых, инкапсулированных и гибких ООП-объектов. Этот подход к созданию классов, кроме того, открывает дорогу для новой, уникальной для JavaScript, парадигмы программирования.
Позволю себе в заключение этого материала процитировать Дугласа Крокфорда: «Я думаю, что ООП без классов — это подарок человечеству от JavaScript».
Уважаемые читатели! Что и почему вам ближе: классы или фабричные функции?