JavaScript — шаблоны наследования

Original author: Peter Jaszkowiak
  • Translation
Примечание переводчика: Тема наследования в 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
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 35

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

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

      Поэтому мы придумываем строго определенные абстракции, которые можно использовать.
        0
        Эта проблема возникает только в языках с плохой рефлексией, имхо.
          0
          Но эта проблема придумывания «красивых» иерхаических зависимостей существует независимо от того есть ли рефлексия или нет.
            0
            Не без этого, согласен. Но в языках с динамической типизацией и/или рефлексией проблемы типа «положить все в один контейнер, потом вызвать специфичный для каждого объекта метод» либо нет, либо она решается достаточно просто. В том же JS (а мы сейчас про него, а не про С++) достаточно instanceof.
      0
      А разве конструктор в сабклассе Dog, не должен быть таким?
        constructor(type, name, breed){
          super(type);
          this.name = name;
          this.breed = breed;
        }
      
        0
        В каком именно способе? Для Способа 2 он так и выглядит.
          0
          во втором способе, сейчас там такой конструктор:
            constructor(name, breed){
              super("dog");
              this.name = name;
              this.breed = breed;
            }
          
            0
            Но ведь тогда type надо будет передавать при создании каждого экземпляра.
            var sparkie = new Dog("dog", "Sparkie", "Border Collie");
            

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

          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
            У вас для каждого приватного метода каждого инстанса создаётся замыкание. Это приводит к лишнему потреблению памяти и снижению скорости. При создании большого числа объектов это может дать печальный эффект.
              –1
              методы я обычно выношу:
              function Parent(property){
                  this.property = property;
                  this.method = method;
              }
              function method(){}
              

              а классы с предполагаемо большим количеством инстансов оформляю с использованием прототипов (таких классов обычно не много получается).
                0
                Такие «вынесенные» методы уже не имеют доступа к «приватным» полям.
                0
                Помогите, пожалуйста, разобраться.
                А есть ли реальная потребность создавать такое большое количество инстансов, что это может привести к излишнему потреблению памяти? Можете привести какой-нибудь реальный пример?
                  0
                  У меня была задача в реальном времени рисовать список задач на десятки тысяч штук. У каждой задачи есть название, статус, список тегов, список подзадач, дата начала, дата завершения и ещё пачка других данных. По всем этим данным нужно было в реальном времени строить различные сортировки/группировки. В общем, при открытии такого списка требовалось держать в памяти сотни тысяч объектов.
                    0
                    Не хочу показаться занудным, но этот случай мне кажется нетипичным. Детальных условий задачи я, конечно, не знаю, может на самом деле была такая необходимость все это делать на клиенте силами js, не привлекая сервер.
                    Под нетипичные задачи всегда приходится как-нибудь да оптимизировать код.

                    Мне хочется понять, реально ли необходимо заморачиваться на экономию памяти в замыканиях при решении обычных задач.
                      +1
                      Если есть возможность, то стоит. Так как когда появятся «нетипичные требования» придётся переписывать пол приложения для оптимизации.
                        0
                        Логика понятна.
                        Спасибо за ответ.
              +2
              Первый и второй способы наиболее быстрые, потребляют минимум памяти и легко статически анализируются (что даёт адекватные подсказки в IDE, подсветку ошибок и опять же лучшую JIT-оптимизацию).
                0
                Cкорость и экономность — это уже отдельный вопрос, оптимизация досигается за счет деталей реализации VM. В v8, например, есть такая штука как hidden classes. Очень много кода написано с использованием первого способа, вероятно потому так и оптимизировано. Стандартный конфликт: «написать чтобы работало быстро и мало кушало» против «написать все нормально, чтобы там SOLID и т.п.».
                  0
                  В первых двух способах нет такого конфликта.
                    0
                      0
                      Там речь про динамическое изменение прототипа. Эта фича не даёт инлайнить метод предка из-за чего снижает производительность. Тем не менее возможность в рантайме похачить методы предка — не относится ни к «всё нормально», ни к «SOLID». Так что правильно там возмущаются, что не стоит ради неё снижать производительность.
                        0
                        Кроме того, проблема с производительностью легко решается, если кешировать прототип: jsperf.com/6to5-inherit/2
                          0
                          Причем здесь SOLID, который был упомянут только для примера? Либо «быстро», либо «правильно», «правильная» реализация второго способа быстрой быть не может без оптимизаций движка, коих на данный момент нет. Ваш «method right» совсем не «right» — кэшировать прототип, согласно стандарту, нельзя, он может быть изменен, его необходимо получать динамически, в чём и проблема. У вас — применение первого способа вместо второго. Как и loose mode.
                            0
                            Пофиксил: jsperf.com/6to5-inherit/3
                              0
                              Касательно прототипа — корректно, касательно получения свойства — нет. Вы думаете, зачем нужен хелпер _get? Это реализация внутреннего метода [[Get]], который дополнительным аргументом принимает Receiver — контекст исполнения геттера. У вас геттер, которым может оказаться свойство прототипа родителя, будет исполнен в контексте прототипа родителя. Простой пример выводит 2, undefined. Без запуска геттера в контексте инстанса будет изменен прототип родителя и выведено 1, 2.
                                0
                                Довольно странный у вас пример, не относящийся к «нормальному программированию». Вот это более реалистично.
                                  0
                                  Странный, но по стандарту должен работать. Я не защищаю логику классов по умолчанию в Babel, а объясняю, почему оно сделано так, а не иначе (иначе зачем бы я создавал ту ищью? :) ). В V8 абсолютно та же ситуация. Не хотите такого оверхеда из-за совсем уж невероятных кейсов — используйте loose mode — получите примерно тот же код, что предлагали изначально, только код этот скорее относится к первому способу из данной статьи, а не второму. Да и, по крайней мере, пока, второй способ не может претендовать на хорошую оптимизацию, как вы утверждали.
                                    0
                                    Да, мы отвлеклись :-) Плюсы последних 2 способов наследования не перекрывают их минусов, так что не вижу смысла их предпочитать.
                0
                Переопределение прототипа не дает возможности использовать obj.constructor, так что лучше так не делать.
                  0
                  prototype.constructor обычно тоже переопределяют.
                    0
                    Я не говорю, что это проблема, но в даном коде этого нет.
                  +5
                  а уж как я то надеюсь, что мне никогда не придется поддерживать код автора…

                  Only users with full accounts can post comments. Log in, please.