В JavaScript существуют разные способы создания объектов. В частности, речь идёт о конструкциях, использующих ключевое слово class и о так называемых фабричных функциях (Factory Function). Автор материала, перевод которого мы публикуем сегодня, исследует и сравнивает эти две концепции в поисках ответа на вопрос о плюсах и минусах каждой из них.

image

Обзор


Ключевое слово 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».

Уважаемые читатели! Что и почему вам ближе: классы или фабричные функции?

Комментарии (19)


  1. Cyber0x346
    28.03.2018 14:45

    Я выбрал классы с примесью prototype.


    1. alex6636
      29.03.2018 07:40

      Вот потом и разберись в коде. Один выберет классы с примесью, второй без примеси, третий фабрики так далее


  1. RidgeA
    28.03.2018 14:53

    Вот как выглядит описание TodoModel с использованием ключевого слова class:


    Вот — описание того же самого объекта, выполненное средствами фабричной функции


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


  1. chelovekkakvse
    28.03.2018 16:10

    Мне кажется тогда проще использовать прототипирование. Иначе какой-то дуализм выходит.

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


  1. zede
    28.03.2018 16:26

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

    Фабричные функции содействуют использованию композиции вместо наследования, что даёт разработчику более высокий уровень гибкости в плане проектирования приложений.
    , при том что это абсолютно такой же не менее популярный метод и в случае классов, однако «более гибкий», хоть и ограничивает один из подходов. Пункт про безопасность тоже особо весомым не нахожу, ибо если кому-то действительно сильно надо влезть в контекст замыкания, то он может воспользоваться грязным хаком через Function.prototype.toString(). И самое удивительное неужели человек склоняющий к функциональному программированию высказывается против стрелочной нотации которую повсеместно для этого используют. Статья была бы хороша как сравнение подходов, но в итоге в конце она превратилась в сплошное осуждение классов.


    1. Cyber0x346
      28.03.2018 16:48

      Классы медленнее инициализируются(у меня получилось, что иногда даже медленнее чем DOM ready с большим количеством нод. возможно я где-то не так реализовал архитектуру, но проявляется во всех браузерах)… Единственный, пожалуй, весомый недостаток.


    1. jMas
      28.03.2018 19:00

      Все верно подмечено про стрелочные функции, мы теряем название функции в стеке ошибок, что серьезно усложняет понимание в каком месте отвалилось. Их нужно использовать с умом.


      1. iShatokhin
        29.03.2018 13:26

        Все верно подмечено про стрелочные функции, мы теряем название функции в стеке ошибок, что серьезно усложняет понимание в каком месте отвалилось.

        Если присваивать стрелочную функцию переменной, то в стеке отобразится имя этой переменной.

        (() => {
             throw new Error('Some error');
        })();
        
        // Uncaught Error: Some error
        //    at <anonymous>:2:12
        
        const funcName = () => {
            throw new Error('Some error');
        };
        
        funcName ();
        
        // Uncaught Error: Some error
        //    at funcName (<anonymous>:2:11)
        
        (() => {
             throw new Error('Some unnaned func error');
        }).name;
        // ""
        
        funcName.name; 
        // "funcName"
        


        1. justboris
          29.03.2018 14:37

          2018 год на дворе, надо пользоваться асинхронными функциями:


          async function doSomething() {
            await new Promise(resolve => setTimeout(resolve));
          
            throw new Error('test');
          }

          В них стектрейс сохраняется:


          > Error: test
              at doSomething (repl:4:9)
              at <anonymous>


          1. iShatokhin
            29.03.2018 14:48

            1. Зачем в синхронном коде асинхронные функции?
            2. Функция doSomething у вас не стрелочная.


            1. justboris
              29.03.2018 14:52

              Изначально в статье был наброс на стрелочные функции в setTimeout и других асинхронных коллбеках:


              setTimeout(() => {}, 0);

              Я показал, как этого избежать, правильно используя фичи языка.


              Функция doSomething у вас не стрелочная.

              Я придерживаюсь правила: все функции верхнего уровня в модуле — обычные, именованные, вложенные функции — стрелочные.


              1. iShatokhin
                29.03.2018 14:54

                Понятно, но я отвечал не автору статьи, а на комментарий про стектрейс.


  1. Aries_ua
    28.03.2018 19:49

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

    Что касаемо скрытия и иммутабельности, то в чем проблема? Так часто кто-то на проекте делаем манки патч? Не делаются тесты и код ревью? За 10 лет девелопинга не было ни разу такой проблемы. Теоретически может быть… Но на практике — не было.

    И да, наследование. Зачем отказаться от наследования в пользу функционального подхода? Надо уметь пользоваться арсеналом, который предоставляет язык.

    Да, статья, если честно, просто однобока.


    1. babylon
      30.03.2018 03:05
      -1

      Наткнулся на Ваш комментарий и решил сэкономить время на своём


  1. JSmitty
    28.03.2018 20:43

    А можно от последнего подхода сделать еще один шаг и перестать связывать функции и данные в объекты, и перейти к функциональному подходу, если уж так хочется. Тогда this вообще исчезнет как понятие.


  1. justboris
    28.03.2018 21:19

    На прошлой неделе уже публиковался перевод: Элегантные паттерны современного JavaScript: Ice Factory.


    В той статье была хорошо рассказана идея, было интересно. А здесь — сплошные набросы на классический подход.


    Вопрос к переводчику: а в чем был смысл переводить вторую статью почти с таким же контентом?


  1. friday
    29.03.2018 09:42

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


  1. iShatokhin
    29.03.2018 13:08

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

    Тем временем в TC39 добавили private поля.
    github.com/tc39/proposal-class-fields/blob/master/PRIVATE_SYNTAX_FAQ.md

    пример использования в Canary c флагом `Experimental JavaScript`
    image


  1. torbasow
    29.03.2018 16:56

    Например, this теряет контекст во вложенных функциях. Это не только усложняет процесс программирования, подобное поведение ещё и является постоянным источником ошибок.

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