[Перевод] Проблема конструкторов JavaScript и три способа её решения

Введение


Как известно, создать новый объект в JavaScript можно используя функцию-конструктор следующего вида:

function Fubar (foo, bar) {
  this._foo = foo;
  this._bar = bar;
}

var snafu = new Fubar("Situation Normal", "All Fsked Up");


Когда мы вызываем функцию-конструктор при помощи ключевого слова new , то получаем новый объект, а контекст его конструктора устанавливается на сам объект. Если мы явно не возвращаем ничего из конструктора, то получаем сам объект в качестве результата. Таким образом, тело функции конструктора используется для инициализации вновь созданного объекта, прототипом которого будет содержимое свойства prototype конструктора, так что можно писать следующим образом:

Fubar.prototype.concatenated = function () {
  return this._foo + " " + this._bar;
}

snafu.concatenated()
  //=> 'Situation Normal All Fsked Up'


Используя оператор instanceof можно убедиться в том, что объект был создан при помощи определенного конструктора:

snafu instanceof Fubar
  //=> true


(Заставить работать instanceof «неправильно» возможно при в случаях с более продвинутыми идиомами, или же если вы — вредный тролль, собирающий исключения языка программирования и получающий наслаждение, истязая ими соискателей на собеседованиях. Однако, для наших целей instanceof работает достаточно хорошо.)

Проблема


Что происходит, если мы вызываем конструктор, случайно упустив ключевое слово new ?

var fubar = Fubar("Fsked Up", "Beyond All Recognition");

fubar
  //=> undefined


Чарльз-Зигмунд-Хуан!? Мы вызвали обычную функцию, которая ничего не возвращает, так что fubar будет undefined. Это не то, что нам нужно, даже хуже, потому что:

_foo
  //=> 'Fsked Up'


JavaScript устанавливает контекст в глобальную область видимости для выполнения обычной функции, так что мы только что туда намусорили. Ну это ещё как-то можно поправить:

function Fubar (foo, bar) {
  "use strict"

  this._foo = foo;
  this._bar = bar;
}

Fubar("Situation Normal", "All Fsked Up");
  //=> TypeError: Cannot set property '_foo' of undefined


Хотя использование «use strict» часто опускается в коде и в книгах, на продакшене его использование можно назвать практически обязательным из-за случаев, вроде описанного выше. Тем не менее, конструкторы, не предоставляющие возможность вызвать себя без ключевого слова new, являются потенциальной проблемой.

Так что же мы можем сделать с этим?

Решение №1 — автонаследование


Дэвид Херман объясняет автонаследование в своей книге Effective JavaScript. Когда мы вызываем конструктор при помощи new, псевдо-переменная this указывает на новый экземпляр нашего так-называемого «класса». Это можно использовать для того, чтобы определить: был ли вызван конструктор при помощи кодового слова new.

function Fubar (foo, bar) {
  "use strict"

  var obj,
      ret;

  if (this instanceof Fubar) {
    this._foo = foo;
    this._bar = bar;
  }
  else return new Fubar(foo, bar);
}

Fubar("Situation Normal", "All Fsked Up");
  //=>
    { _foo: 'Situation Normal',
      _bar: 'All Fsked Up' }


Зачем делать так, чтобы оно работало без new? Одна из проблем, которые этот подход решает — невозможность вызова new Fubar(...). Рассмотрим пример:

function logsArguments (fn) {
  return function () {
    console.log.apply(this, arguments);
    return fn.apply(this, arguments)
  }
}

function sum2 (a, b) {
  return a + b;
}

var logsSum = logsArguments(sum2);

logsSum(2, 2)
  //=>
    2 2
    4


logsArguments декорирует функцию, логирующую свои аргументы, возвращая результат её вызова. Попробуем сделать то же самое при помощи Fubar:

function Fubar (foo, bar) {
  this._foo = foo;
  this._bar = bar;
}
Fubar.prototype.concatenated = function () {
  return this._foo + " " + this._bar;
}

var LoggingFubar = logsArguments(Fubar);

var snafu = new LoggingFubar("Situation Normal", "All Fsked Up");
  //=> Situation Normal All Fsked Up

snafu.concatenated()
  //=> TypeError: Object [object Object] has no method 'concatenated'


Это не работает, потому что snafu является экземпляром LoggingFubar, а не Fubar. Но если использовать автонаследование в Fubar:

function Fubar (foo, bar) {
  "use strict"

  var obj,
      ret;

  if (this instanceof Fubar) {
    this._foo = foo;
    this._bar = bar;
  }
  else {
    obj = new Fubar();
    ret = Fubar.apply(obj, arguments);
    return ret === undefined
           ? obj
           : ret;
  }
}
Fubar.prototype.concatenated = function () {
  return this._foo + " " + this._bar;
}

var LoggingFubar = logsArguments(Fubar);

var snafu = new LoggingFubar("Situation Normal", "All Fsked Up");
  //=> Situation Normal All Fsked Up

snafu.concatenated()
  //=> 'Situation Normal All Fsked Up'


Теперь это работает, хотя, конечно же, snafu является экземпляром Fubar, а не LoggingFubar. Нельзя точно сказать, то ли это, чего мы добивались. Этот способ нельзя назвать более чем полезной абстракцией, не лишенной утечек, ровно как и нельзя сказать, что он «просто работает», хоть благодаря ему и становятся возможными некоторые вещи, которые при других подходах реализовать гораздо более сложно.

Решение №2 — использование перегруженной функции


Функция, проверяющая, является ли объект экземпляром определенного класса, может быть очень полезной. Если вас не пугает идея, что одна функция может делать две разные вещи, то можно сделать так, чтобы конструктор выполнял проверку на собственный instanceof .

function Fubar (foo, bar) {
  "use strict"

  if (this instanceof Fubar) {
    this._foo = foo;
    this._bar = bar;
  }
  else return arguments[0] instanceof Fubar;
}

var snafu = new Fubar("Situation Normal", "All Fsked Up");

snafu
  //=>
    { _foo: 'Situation Normal',
      _bar: 'All Fsked Up' }

Fubar({})
  //=> false
Fubar(snafu)
  //=> true


Это дает возможность использовать конструктор как фильтр

var arrayOfSevereProblems = problems.filter(Fubar);


Решение №3 — выжечь огнем


Если насущной необходимости в авто-наследовании нет, а использование перегруженных функций по какой-либо причине не подходит, нам всё же может понадобиться способ избежать случайного вызова конструктора без использования ключевого слова new. Пускай
"use strict" и помогает, но и это не панацея. В этом режиме не будет выдана ошибка, если не попытаться записать значение в глобальную область видимости, и если мы попытаемся что-то сделать, прежде чем записать-таки упомянутое значение, это произойдет не смотря ни на что.

Может быть, гораздо лучше взять дело в свои руки? Оливер Шеррер предлагает такое решение:

function Fubar (foo, bar) {
  "use strict"

  if (!(this instanceof Fubar)) {
      throw new Error("Fubar needs to be called with the new keyword");
  }

  this._foo = foo;
  this._bar = bar;
}

Fubar("Situation Normal", "All Fsked Up");
  //=> Error: Fubar needs to be called with the new keyword


Проще и безопаснее, чем просто полагаться на "use strict". Если необходимо сделать проверку на собственный instanceof , можно обернуть её в конструктор как метод функции:

Fubar.is = function (obj) {
  return obj instanceof Fubar;
}

var arrayOfSevereProblems = problems.filter(Fubar.is);


Заключение


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

Оргинал статьи автора можно найти здесь.

К статье прилагается обсуждение на реддите.
Поделиться публикацией

Похожие публикации

Комментарии 39
    +7
    Какая-то надуманная проблема, хотя у меня базовый конструктор кидает exception если new забываешь, ибо нефик.
      +5
      Давно применяю паттерн, который я называю «универсальный конструктор»:

      function MyType (foo, bar)
      {
        var instance = Object.create(MyType.prototype);
      
        instance._foo = foo;
        instance._bar = bar;
      
        return instance;
      }
      // работают оба варианта:
      new MyType(1, 2);
      MyType(1, 2);
      


      До этого использовал следующий паттерн:

      function MyType (foo, bar)
      {
        if (this instanceof MyType)
        {
          this._foo = foo;
          this._bar = bar;
        }
        else
        {
          return new MyType(foo, bar);
        }
      }
      

      Также работает в обоих вариантах, но чувствительно к количеству аргументов и обвязка громоздкая.
        +1
        Если во втором варианте в сáмом начале «if(!( this instanceof MyType )) return new MyType(foo, bar)» записывать, то тогда эта однострочная обвязка ужé не покажется громоздкою, к тому же порядок и количество аргументов в ней куда проще будет приводить в соответствие с их определением, после слóва «function» записанным, потому что тогда слово это будет на предыдущей строке — ближе некуда, как говорится.
          +1
          Вполне согласен с вами. Пожалуй, в своё время я так и писал (как вы указываете). Здесь, потому что это пример, вышло немножко академично.
        +10
        Дэвид Херман объясняет автонаследование в своей книге Effective JavaScript.
        А чтобы кто-нибудь случайно не подумал, что именно Херман и придумал этот трюк, я посвящу три абзаца рассмотрению хронологии событий.

        Выход этой книги Хермана состоялся в 2012 году — или, по крайней мере, именно в декабре 2012 года был выложен исходный код её примеров на Гитхабе её автором.

        Рассуждение о самовызывающемся конструкторе Джона Резига было выложено мною на Хабрахабре в декабре 2011 года и содержало гиперссылку на блогозапись, которую Резиг оставил у себя во блоге в декабре 2007 года.

        С тех давних пор прошло без малого семь лет, и появление конструктора с необязательным «new» можно было заметить в нескольких произведениях Резига, из которых наиболее известна библиотека jQuery, разработка которой была именно им начата.
          –1
          люто бешено плюсую
          +3
          Случайно забыть «new» перед конструктором, это как случайно забыть «var» перед объявлением переменной. Причём с «var» легче пропустить точку с запятой вместо обычной запятой после редактирования.
            +6
            Именно, на "use strict" нужно было закончить статью.
              –2
              Вот-вот. И JSHint ругнется на вызов конструктора без new.
                –1
                По мне так можно было бы ещё добавить одно предложение с призывом писать юнит тесты, если вы невнимательны и можете пропускать в коде new и код не проходит ревью.

                Но нет же, сделают библиотеку, которая разовьётся во фреймворк, который форкнут и будут развивать независимо, и спустя какое-то время будет организована конференция по использованию этого фреймворка, а на хабре его фанаты потребуют создать отдельный хаб.
                0
                Постоянно попадаюсь на это, т.к. в основном пишу на Python, где создание экземпляра выглядит так:
                foo = Foo()
                

                А при работе с тем же Backbone вылетают совсем неадекватные ошибки, например:
                var my_model = Model();
                // Error: _r or some other minified shit is undefined
                +1
                4ый способ: jshint(newcap), unit-tests и, как сказали, «use strict» — при таком наборе пропустить `new` просто невозможно, при этом нет хаков в конструкторах. Ну а использование `new` по назначению повышает выразительность кода.
                  +1
                  Кажется, первый и второй способы приводят к ухудшению кода (поддержка различных «написаний»).
                  Третий лучше, но задолбаешься писать везде проверки.

                  use strict — лучший вариант
                    –1
                    Для случая с динамическим количеством аргументов:
                    Можно убрать танцы с obj = new Fubar; Fubar.apply(obj, args) и написать не очень изящную, но функциональную строчку:

                    var fubar;
                    fubar = new (Function.prototype.bind.apply(Fubar, [null].concat(arguments)));
                    


                    Сравнение
                      0
                      Если я не ошибаюсь, можно даже и [].concat писать
                        +3
                        Всмысле без null? Если да, то ошибаетесь, нельзя — тогда первый элемент списка аргументов станет контекстом для результирующей функции. Если аргументов у конструктора нет, то да, можно просто передать пустой массив или вообще ничего. =)
                        +2
                        0
                        А вообще, в случае с динамическим количеством аргументов, лучше поступить так:

                        var that = this instanceof MyClass ? this : Object.create(MyClass.prototype);
                        

                        и дальше работать с that. Передача объекта arguments в другую функцию приводит к деоптимизации функции.
                          +1
                          Можете пояснить фразу про деоптимизацию? Почему? В моем примере имелся в виду результат Array.prototype.slice.call(arguments, 0); извиняюсь за неточность )
                            +2
                            Здесь, например, расписано. С объектом arguments лучше вообще дел не иметь, а если и иметь — только получение элемента по индексу, длины и передача в apply.
                              0
                              Мерси боку!
                          +1
                          Простите, но это не изящная строчка, а адовый костыль.

                          Вообще, проблема топика надуманная.
                          Если используешь конструктор, напиши new и точка. Если фабрика — не пиши. Проблем нет.
                          Забыл/лень — ССЗБ.
                            0
                            В чем костыльность и адовость?
                            Вам знакомо понятие reflection из других языков программирования? Это тоже костыль?

                            Мне кажется, вы не совсем поняли проблему, но пытаетесь раскритиковать решение.
                              +1
                              Извините, когда я читал комментарий, то пропустил «не» перед «очень».

                              Я прекрасно понимаю «проблему».
                              Она, как обычно, в головах. За пропущенный new надо давать по рукам, а не копипастить костыли из конструктора в конструктор.
                                0
                                Именно проблему, а не «проблему». Речь идет не только про баги и забывчивость разработчиков, но, например и в частности в моем примере, про абстрактную фабрику, которая не знает конструктор, экземпляр которого она создает. По сему, у конструктора может быть разное количество аргументов.
                                  0
                                  Я понимаю, почему было написано именно так.
                                  Мой поинт в том, что эта фабрика вообще не нужна =)

                                  Тем более, что в таком виде она сильно неэффективна, можно и нужно ее оптимизировать в производительности, а это уже не одна строчка.
                                    0
                                    Почему не нужна? IoC контейнер как на js будете писать? ) В более пропаханных языках есть reflection, который позволяет вызывать метод типа newInstance(args) у рефлекшна класса.В js пока что это можно сделать указанными выше способами.
                                      0
                                      Потому что программист должен уметь пользоваться интерфейсом. Если интерфейс — конструктор, пользуйся им как конструктором.

                                      Никак не буду. Мне хватает обычного конструктора. Вспоминается поговорка про самовар.
                          0
                          Какие-то вы слишком добрые. Если фунция — исключительно конструктор, то нечего вызывать её как просто функцию.

                          function Fubar (foo, bar){
                            if (!(this instanceof Fubar)){
                              throw new TypeError('Fubar is a constructor');
                            }
                            /* … */
                          }
                          

                          Кстати, вызывать this._superclass.apply(this, arguments) это не помешает.
                            0
                            Если фунция — исключительно конструктор, то нечего вызывать её как просто функцию.

                            Я бы не был столь критичен. Предположим, у нас есть фабрика, например,

                            function Dict(props){
                              var dict = Object.create(null);
                              return Object.assign(dict, props);
                            }
                            

                            которая со временем превращается в полноценный конструктор

                            function Dict(props){
                              Object.assign(this, props);
                            }
                            Dict.prototype = Object.create(null);
                            Dict.prototype[Symbol.iterator] = function(){ /* ... */}
                            

                            Нам что, перелопачивать сотни кода, добавляя new? :)

                            Или другая проблема:

                            // Обычный конструктор
                            array.map(function(it){
                              return new MyConstructor(it);
                            });
                            // Самовызавыющийся конструктор
                            array.map(MyConstructor);
                            

                            Хотя для решения последней предлагают универсальный фабричный метод:

                            Object.defineProperty(Function.prototype, 'new', {
                              get: function(){
                                var Ctor = this;
                                return function(...args){
                                  return new Ctor(...args);
                                }
                              }
                            });
                            // ...
                            array.map(MyConstructor.new);
                            
                              +1
                              Странные проблемы:
                              — Фабрика должна создавать и проводить другие операции над обьектами через статические методы: `Dict.create` `Dict.resolve` и так далее. Это более гибкое и явное поведение. А если уж там что то себе передумали в архитектуре и захотели убрать фабрику, то да, нужно перелопачивать код. Даже с простым рефакторингом нужно перелопачивать код, а уж с архитектурой тем более нужно.

                              `array.map(x => new MyConstructor(x))` всё же лучше — кода не больше, поведение явное, нет зависимостей от стороннего АПИ в прототипах.
                                0
                                Может проблемы и странные, но встречаются довольно часто.

                                Фабрика должна создавать и проводить другие операции над обьектами через статические методы: `Dict.create` `Dict.resolve` и так далее.

                                Да ладно? :) Всегда мечтал для создания простого ассоциативного массива писать Dict.resolve({/* ... */}).

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

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

                                нет зависимостей от стороннего АПИ в прототипах

                                Собственно, ссылка на предложение добавления этого метода в стандарт ниже.
                                  0
                                  В этом-то и заключается выразительность кода. Каждое название метода, что-то да обозначает, `resolve`, например, больше значит, что создание объекта зависит от внешних факторов, тогда как `create` действует внутри по строгому алгоритму без внешних параметров.
                                  Далее; у вас `Dict` изначально был функциональным/фабричным методом, а назван существительным. И тут совсем уж не понятно, что он делает — создаёт объект Dict, так почему без new, а если не создает Dict, тогда что же создает; а может вообще ничего не создает, а мутирует аргумент. Вообщем, со стороны возникает много вопросов.

                                  Более того, система была бы более гибкой, сами же видите, что у вас бы возникли проблемы с созданием через конструктор (если бы не хак). Поэтому используя `Dict.create`, например, можно было бы смело сделать создание через конструктор, не боясь поломать обратную совместимость.

                                  В любом случае, это лишь мое мнение, и я вовсе не хочу вам его навязывать; лишь поделился размышлениями.
                                    0
                                    Почему Dict, а не Dict.createне ко мне. В любом случае, не менее выразительно и, главное, куда более кратко.
                                    создаёт объект Dict, так почему без new
                                    Может потому, как в данном случае он создаёт объект без прототипа, а конструкторы так не умеют? :) Да мало ли почему — фабрика.
                                    сами же видите, что у вас бы возникли проблемы с созданием через конструктор
                                    Возникли бы, если не использовать самовызывающийся конструктор. Без всяких хаков.
                                    Вообщем, со стороны возникает много вопросов.
                                    А на аналогичные Object или Array не возникают. Или что там у нас нонче самое популярное, jQuery? Сама очевидность в плане возвращаемого значения основного метода.
                                      0
                                      Это они там только предлагают это как «шорткат», но это ни в коем случае, также как и с jQuery, не отменяет того что у фабрик должны быть фабричные методы :) Просто jQuery, стандартный API могут вводить некоторые послабления в угоду краткости, так как всем в принципе известно их поведение, а вот если я пишу код для себя и коллег, то тут следует быть более конкретным. А штуки как «самовызывающий конструктор» скрывает от «читателя» свое поведение, поэтому данный трюк считаю даже немного вредным.
                                0
                                Хм, я так понял, что в статье решает другая проблема — забытый new, т.е., конструктор вызывается как функция. Вы же приводите пример обратного: из функция вызывается как конструктор. Никто же не мешает чуть-чуть подправить исходную функцию Dict:

                                function Dict(props){
                                  var dict = Object.create(Dict.prototype);
                                  return Object.assign(dict, props);
                                }
                                Dict.prototype = …
                                


                                И всё. Функция возврящает объект, поэтому результаты вызова как функции и как конструктора одинаковы.

                                Кстати, про Function.prototype.new, начиная с ES6 мы же вроде как можем fn[Symbol.create]() (ссылка на черновик спеки). Или нет? Если я неправ, то буду рад, если вы меня поправите, пионеров ES6 ещё не так уж много.
                                  0
                                  Собственно, это был ответ на ваш комментарий о том, что «нечего вызывать конструктор без new», статья здесь почти не причем.

                                  Никто не мешает чуть-чуть подправить исходную функцию, но, ИМХО, если функции создаёт инстанс из своего прототипа — она должна быть конструктором. Да и в актуальных движках new на подобном значительно быстрее Object.create.

                                  Метод ко ключу Symbol.create создаёт объект из прототипа конструктора и устанавливает, если нужно, скрытые свойства, а не вызывает внутренний метод [[Construct]]. Он предназначен, скорее, для наследования встроенных конструкторов. Его судьба в ES6 туманна. А пример с Function.prototype.new отсюда.
                                    +1
                                    Всё, нет больше @@create. Хотя в tc39 решили его грохнуть еще 24 сентября.

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

                              Самое читаемое