Примечание переводчика: Тема наследования в JavaScript является одной из самых тяжелых для новичков. С добавлением нового синтаксиса с ключевым словом class, понимание наследования явно не стало проще, хотя кардинально нового ничего не появилось. В данной статье не затрагиваются нюансы реализации прототипного наследования в JavaScript, поэтому если у читателя возникли вопросы, то рекомендую прочитать следующие статьи: Основы и заблуждения насчет JavaScript и Понимание ООП в JavaScript [Часть 1]

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

JavaScript является очень мощным языком. Настолько мощным, что в нем сосуществует множество различных способов проектирования и создания объектов. У каждого способа есть свои плюсы и минусы и я бы хотел помочь новичкам разобраться в этом. Это продолжение моего предыдущего поста, Хватит «классифицировать» JavaScript. Я получил много вопросов и комментариев с просьбами привести примеры, и для именно этой цели я решил написать эту статью.

JavaScript использует прототипное наследование


Это означает, что в JavaScript объекты наследуются от других объектов. Простые объекты в JavaScript, созданные с использованием {} фигурных скобок, имеют только один прототип: Object.prototype. Object.prototype, в свою очередь тоже объект, и все свойства и методы Object.prototype доступны для всех объектов.

Массивы, созданные с помощью [] квадратных скобок, имеют несколько прототипов, в том числе Object.prototype и Array.prototype. Это означает, что все свойства и методы Object.prototype и Array.prototype доступны для всех массивов. Одноименные свойства и методы, например .valueOf и .ToString, вызываются из ближайшего прототипа, в этом случае из Array.prototype.

Определения прототипа и создание объектов


Способ 1: Шаблон конструктор


JavaScript имеет особый тип функции называемых конструкторами, которые действуют так же, как и конструкторы в других языках. Функции-конструкторы вызываются только с помощью ключевого слова new и связывают создаваемый объект с контекстом функции-конструктора через ключевое слово this. Типичный конструктор может выглядеть следующим образом:
function Animal(type){
  this.type = type;
}
Animal.isAnimal = function(obj, type){
  if(!Animal.prototype.isPrototypeOf(obj)){
    return false;
  }
  return type ? obj.type === type : true;
};

function Dog(name, breed){
  Animal.call(this, "dog");
  this.name = name;
  this.breed = breed;
}
Object.setPrototypeOf(Dog.prototype, Animal.prototype);
Dog.prototype.bark = function(){
  console.log("ruff, ruff");
};
Dog.prototype.print = function(){
  console.log("The dog " + this.name + " is a " + this.breed);
};

Dog.isDog = function(obj){
  return Animal.isAnimal(obj, "dog");
};

Использование этого конструктора выглядит также как и создание объекта в других языках:
var sparkie = new Dog("Sparkie", "Border Collie");

sparkie.name;    // "Sparkie"
sparkie.breed;   // "Border Collie"
sparkie.bark();  // console: "ruff, ruff"
sparkie.print(); // console: "The dog Sparkie is a Border Collie"

Dog.isDog(sparkie); // true

bark и print методы прототипа, которые применяются для всех объектов созданных с помощью конструктора Dog. Свойства name и breed инициализируются в конструкторе. Это общепринятая практика, когда все методы определяются в прототипе, а свойства инициализируются конструктором.

Способ 2: Определение класса в ES2015 (ES6)


Ключевое слово class было зарезервировано в JavaScript с самого начала и вот наконец-то пришло время его использовать. Определения классов в JavaScript схоже с другими языками.
class Animal {
  constructor(type){
    this.type = type;
  }
  static isAnimal(obj, type){
    if(!Animal.prototype.isPrototypeOf(obj)){
      return false;
    }
    return type ? obj.type === type : true;
  }
}

class Dog extends Animal {
  constructor(name, breed){
    super("dog");
    this.name = name;
    this.breed = breed;
  }
  bark(){
    console.log("ruff, ruff");
  }
  print(){
    console.log("The dog " + this.name + " is a " + this.breed);
  }
  static isDog(obj){
    return Animal.isAnimal(obj, "dog");
  }
}

Многие люди считают этот синтаксис удобным, потому что он объединяет в одном блоке конструктор и объявление статичных и прототипных методов. Использование точно такое же, как и в предыдущем способе.
var sparkie = new Dog("Sparkie", "Border Collie");

Способ 3: Явное объявление прототипа, Object.create, фабричный метод


Этот способ показывает, что на самом деле новый синтаксис с ключевым словом class использует прототипное наследование. Также этот способ позволяет создать новый объект без использования оператора new.
var Animal = {
  create(type){
    var animal = Object.create(Animal.prototype);
    animal.type = type;
    return animal;
  },
  isAnimal(obj, type){
    if(!Animal.prototype.isPrototypeOf(obj)){
      return false;
    }
    return type ? obj.type === type : true;
  },
  prototype: {}
};

var Dog = {
  create(name, breed){
    var proto = Object.assign(Animal.create("dog"), Dog.prototype);
    var dog = Object.create(proto);
    dog.name = name;
    dog.breed = breed;
    return dog;
  },
  isDog(obj){
    return Animal.isAnimal(obj, "dog");
  },
  prototype: {
    bark(){
      console.log("ruff, ruff");
    },
    print(){
      console.log("The dog " + this.name + " is a " + this.breed);
    }
  }
};

Этот синтаксис удобен, потому что прототип объявляется явно. Понятно что определено в прототипе, а что определено в самом объекте. Метод Object.create удобен, потому что он позволяет создать объект от указанного прототипа. Проверка с помощью .isPrototypeOf по-прежнему работает в обоих случаях. Использование разнообразно, но не чрезмерно:
var sparkie = Dog.create("Sparkie", "Border Collie");

sparkie.name;    // "Sparkie"
sparkie.breed;   // "Border Collie"
sparkie.bark();  // console: "ruff, ruff"
sparkie.print(); // console: "The dog Sparkie is a Border Collie"

Dog.isDog(sparkie); // true

Способ 4: Object.create, фабрика верхнего уровня, отложенный прототип


Этот способ является небольшим изменение способа 3, где сам класс является фабрикой, в отличии от случая когда класс является объектом с фабричным методом. Похоже, на пример конструктора (способ 1), но использует фабричный метод и Object.create.
function Animal(type){
  var animal = Object.create(Animal.prototype);
  animal.type = type;
  return animal;
}
Animal.isAnimal = function(obj, type){
  if(!Animal.prototype.isPrototypeOf(obj)){
    return false;
  }
  return type ? obj.type === type : true;
};
Animal.prototype = {};

function Dog(name, breed){
  var proto = Object.assign(Animal("dog"), Dog.prototype);
  var dog = Object.create(proto);
  dog.name = name;
  dog.breed = breed;
  return dog;
}
Dog.isDog = function(obj){
  return Animal.isAnimal(obj, "dog");
};
Dog.prototype = {
  bark(){
    console.log("ruff, ruff");
  },
  print(){
    console.log("The dog " + this.name + " is a " + this.breed);
  }
};

Этот способ интересен тем, что похож на первой способ, но не требует ключевого слова new и работает с оператором instanceOf. Использование такое же, как и в первом способе, но без использования ключевого слова new:
var sparkie = Dog("Sparkie", "Border Collie");

sparkie.name;    // "Sparkie"
sparkie.breed;   // "Border Collie"
sparkie.bark();  // console: "ruff, ruff"
sparkie.print(); // console: "The dog Sparkie is a Border Collie"

Dog.isDog(sparkie); // true


Сравнение


Способ 1 против Способа 4


Существует довольно мало причин, для того чтобы использовать Способ 1 вместо Способа 4. Способ 1 требует либо использование ключевого слова new, либо добавление следующей проверки в конструкторе:
if(!(this instanceof Foo)){ 
  return new Foo(a, b, c);
}

В этом случае проще использовать Object.create с фабричным методом. Вы также не можете использовать функции Function#call или Function#apply с функциями-конструкторами, потому что они переопределяют контекст ключевого слова this. Проверка выше, может решить и эту проблему, но если вам нужно работать с неизвестным заранее количеством аргументов, вы должны использовать фабричный метод.

Способ 2 против Способа 3


Те же рассуждения о конструкторах и операторе new, что были упомянуты выше, применимы и в этом случае. Проверка с помощью instanceof необходима, если используется новый синтаксис class без использования оператора new или используются Function#call или Function#apply.

Мое мнение


Программист должен стремиться к ясности своего кода. Синтаксис Способа 3 очень четко показывает, что именно происходит на самом деле. Он также позволяет легко использовать множественное наследование и стековое наследования. Так как оператор new нарушает принцип открытости/закрытости из-за несовместимости с apply или call, его следует избегать. Ключевое слово class скрывает прототипный характер наследования в JavaScript за маской системы классов.
«Простое лучше мудреного», и использование классов, потому что оно считается более «изощренным» является просто ненужной, технической головомойкой.

Использование Object.create является более выразительным и ясным, чем использование связки new и this. Кроме того, прототип хранится в объекте, который может быть вне контекста самой фабрики, и таким образом может быть более легко изменен и расширен добавлением методов. Прям как классы в ES6.
Ключевое слово class, возможно будет наиболее пагубной чертой в JavaScript. Я испытываю огромное уважение к блестящим и очень трудолюбивым людям, которые были вовлечены в процесс написания стандарта, но даже блестящие люди иногда делают неправильные вещи. — Eric Elliott

Добавление чего-то ненужного и возможно пагубного, противоречащего самой природе языка является необдуманным и ошибочным.
Если вы решите использовать class, я искренне надеюсь, что мне никогда не придется работать с вашим кодом. На мой взгляд, разработчики должны избегать использования конструкторов, class и new, и использовать методы, которые более естественны парадигме и архитектуре языка.

Глоссарий


Object.assign(a, b) копирует все перечислимые (enumerable) свойства объекта b в объект a, а затем возвращает объект a
Object.create(proto) создает новый объект от указанного прототипа proto
Object.setPrototypeOf(obj, proto) меняет внутреннее свойство [[Prototype]] объекта obj на proto

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


  1. k12th
    02.08.2015 11:26
    +4

    Классы это удобно. Человеческое мышление так устроено: мы категоризируем вещи.
    Ну и если мне в приложении нужно будет n кошек и m собак, я сделаю три класса (Animal, Dog и Cat с очевидными отношениями), а не буду городить громазду на прототипах и фабриках. А если мне будет нужна одна кошка и одна собака, то я еще подумаю, как я буду эти, смахивающие на синглтоны, объекты покрывать тестами.


    1. m1el
      02.08.2015 15:35

      Классическая проблема наследования.

      Проблема в том, что то как мы «категоризируем» вещи плохо ложится на программирование.

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


      1. k12th
        02.08.2015 15:43

        Эта проблема возникает только в языках с плохой рефлексией, имхо.


        1. m1el
          02.08.2015 15:48

          Но эта проблема придумывания «красивых» иерхаических зависимостей существует независимо от того есть ли рефлексия или нет.


          1. k12th
            02.08.2015 15:52

            Не без этого, согласен. Но в языках с динамической типизацией и/или рефлексией проблемы типа «положить все в один контейнер, потом вызвать специфичный для каждого объекта метод» либо нет, либо она решается достаточно просто. В том же JS (а мы сейчас про него, а не про С++) достаточно instanceof.


  1. rajdee
    02.08.2015 11:42

    А разве конструктор в сабклассе Dog, не должен быть таким?

      constructor(type, name, breed){
        super(type);
        this.name = name;
        this.breed = breed;
      }
    


    1. Baidaly
      02.08.2015 20:45

      В каком именно способе? Для Способа 2 он так и выглядит.


      1. rajdee
        03.08.2015 00:29

        во втором способе, сейчас там такой конструктор:

          constructor(name, breed){
            super("dog");
            this.name = name;
            this.breed = breed;
          }
        


        1. Baidaly
          03.08.2015 03:49

          Но ведь тогда type надо будет передавать при создании каждого экземпляра.

          var sparkie = new Dog("dog", "Sparkie", "Border Collie");
          

          Я понимаю, что возникает вопрос, а что делать с type в последующих подклассах Dog. Но в данном случае, мне кажется, что автор просто хотел привести простой пример того как использовать ключевое слово class.


        1. Baidaly
          03.08.2015 03:50

          Мне просто интересно, а Вам какой Способ больше импонирует?


  1. Quadratoff
    02.08.2015 13:40

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

    function Parent(property){
        this.property = property;
        this.method = function(){}
    }
    
    function Child(){/* extends */Parent
        .apply(this, arguments);
    
        var privateVar = "";
        this.getPrivate = function(){return privateVar}
    }
    
    var child = new Child( 11 );
    


    1. vintage
      02.08.2015 18:10
      +1

      У вас для каждого приватного метода каждого инстанса создаётся замыкание. Это приводит к лишнему потреблению памяти и снижению скорости. При создании большого числа объектов это может дать печальный эффект.


      1. Quadratoff
        02.08.2015 23:25
        -1

        методы я обычно выношу:

        function Parent(property){
            this.property = property;
            this.method = method;
        }
        function method(){}
        

        а классы с предполагаемо большим количеством инстансов оформляю с использованием прототипов (таких классов обычно не много получается).


        1. vintage
          03.08.2015 14:45

          Такие «вынесенные» методы уже не имеют доступа к «приватным» полям.


      1. nep
        27.08.2015 12:57

        Помогите, пожалуйста, разобраться.
        А есть ли реальная потребность создавать такое большое количество инстансов, что это может привести к излишнему потреблению памяти? Можете привести какой-нибудь реальный пример?


        1. vintage
          27.08.2015 22:59

          У меня была задача в реальном времени рисовать список задач на десятки тысяч штук. У каждой задачи есть название, статус, список тегов, список подзадач, дата начала, дата завершения и ещё пачка других данных. По всем этим данным нужно было в реальном времени строить различные сортировки/группировки. В общем, при открытии такого списка требовалось держать в памяти сотни тысяч объектов.


          1. nep
            28.08.2015 07:57

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

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


            1. vintage
              28.08.2015 10:18
              +1

              Если есть возможность, то стоит. Так как когда появятся «нетипичные требования» придётся переписывать пол приложения для оптимизации.


              1. nep
                28.08.2015 10:25

                Логика понятна.
                Спасибо за ответ.


  1. vintage
    02.08.2015 18:16
    +2

    Первый и второй способы наиболее быстрые, потребляют минимум памяти и легко статически анализируются (что даёт адекватные подсказки в IDE, подсветку ошибок и опять же лучшую JIT-оптимизацию).


    1. Tramway
      02.08.2015 19:19

      Cкорость и экономность — это уже отдельный вопрос, оптимизация досигается за счет деталей реализации VM. В v8, например, есть такая штука как hidden classes. Очень много кода написано с использованием первого способа, вероятно потому так и оптимизировано. Стандартный конфликт: «написать чтобы работало быстро и мало кушало» против «написать все нормально, чтобы там SOLID и т.п.».


      1. vintage
        03.08.2015 07:31

        В первых двух способах нет такого конфликта.


        1. rock
          04.08.2015 05:56

          1. vintage
            04.08.2015 09:00

            Там речь про динамическое изменение прототипа. Эта фича не даёт инлайнить метод предка из-за чего снижает производительность. Тем не менее возможность в рантайме похачить методы предка — не относится ни к «всё нормально», ни к «SOLID». Так что правильно там возмущаются, что не стоит ради неё снижать производительность.


            1. vintage
              04.08.2015 09:15

              Кроме того, проблема с производительностью легко решается, если кешировать прототип: jsperf.com/6to5-inherit/2


              1. rock
                04.08.2015 12:34

                Причем здесь SOLID, который был упомянут только для примера? Либо «быстро», либо «правильно», «правильная» реализация второго способа быстрой быть не может без оптимизаций движка, коих на данный момент нет. Ваш «method right» совсем не «right» — кэшировать прототип, согласно стандарту, нельзя, он может быть изменен, его необходимо получать динамически, в чём и проблема. У вас — применение первого способа вместо второго. Как и loose mode.


                1. vintage
                  04.08.2015 18:37

                  Пофиксил: jsperf.com/6to5-inherit/3


                  1. rock
                    04.08.2015 20:45

                    Касательно прототипа — корректно, касательно получения свойства — нет. Вы думаете, зачем нужен хелпер _get? Это реализация внутреннего метода [[Get]], который дополнительным аргументом принимает Receiver — контекст исполнения геттера. У вас геттер, которым может оказаться свойство прототипа родителя, будет исполнен в контексте прототипа родителя. Простой пример выводит 2, undefined. Без запуска геттера в контексте инстанса будет изменен прототип родителя и выведено 1, 2.


                    1. vintage
                      04.08.2015 21:15

                      Довольно странный у вас пример, не относящийся к «нормальному программированию». Вот это более реалистично.


                      1. rock
                        04.08.2015 21:33

                        Странный, но по стандарту должен работать. Я не защищаю логику классов по умолчанию в Babel, а объясняю, почему оно сделано так, а не иначе (иначе зачем бы я создавал ту ищью? :) ). В V8 абсолютно та же ситуация. Не хотите такого оверхеда из-за совсем уж невероятных кейсов — используйте loose mode — получите примерно тот же код, что предлагали изначально, только код этот скорее относится к первому способу из данной статьи, а не второму. Да и, по крайней мере, пока, второй способ не может претендовать на хорошую оптимизацию, как вы утверждали.


                        1. vintage
                          05.08.2015 07:15

                          Да, мы отвлеклись :-) Плюсы последних 2 способов наследования не перекрывают их минусов, так что не вижу смысла их предпочитать.


  1. Large
    03.08.2015 10:41

    Переопределение прототипа не дает возможности использовать obj.constructor, так что лучше так не делать.


    1. vintage
      03.08.2015 14:46

      prototype.constructor обычно тоже переопределяют.


      1. Large
        03.08.2015 18:37

        Я не говорю, что это проблема, но в даном коде этого нет.


  1. sAntee
    03.08.2015 14:26
    +5

    а уж как я то надеюсь, что мне никогда не придется поддерживать код автора…