По всем замечаниям, связанным с переводом, обращайтесь в личку.
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)
rajdee
02.08.2015 11:42А разве конструктор в сабклассе Dog, не должен быть таким?
constructor(type, name, breed){ super(type); this.name = name; this.breed = breed; }
Baidaly
02.08.2015 20:45В каком именно способе? Для Способа 2 он так и выглядит.
rajdee
03.08.2015 00:29во втором способе, сейчас там такой конструктор:
constructor(name, breed){ super("dog"); this.name = name; this.breed = breed; }
Baidaly
03.08.2015 03:49Но ведь тогда type надо будет передавать при создании каждого экземпляра.
var sparkie = new Dog("dog", "Sparkie", "Border Collie");
Я понимаю, что возникает вопрос, а что делать с type в последующих подклассах Dog. Но в данном случае, мне кажется, что автор просто хотел привести простой пример того как использовать ключевое слово class.
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 );
vintage
02.08.2015 18:10+1У вас для каждого приватного метода каждого инстанса создаётся замыкание. Это приводит к лишнему потреблению памяти и снижению скорости. При создании большого числа объектов это может дать печальный эффект.
Quadratoff
02.08.2015 23:25-1методы я обычно выношу:
function Parent(property){ this.property = property; this.method = method; } function method(){}
а классы с предполагаемо большим количеством инстансов оформляю с использованием прототипов (таких классов обычно не много получается).
nep
27.08.2015 12:57Помогите, пожалуйста, разобраться.
А есть ли реальная потребность создавать такое большое количество инстансов, что это может привести к излишнему потреблению памяти? Можете привести какой-нибудь реальный пример?vintage
27.08.2015 22:59У меня была задача в реальном времени рисовать список задач на десятки тысяч штук. У каждой задачи есть название, статус, список тегов, список подзадач, дата начала, дата завершения и ещё пачка других данных. По всем этим данным нужно было в реальном времени строить различные сортировки/группировки. В общем, при открытии такого списка требовалось держать в памяти сотни тысяч объектов.
nep
28.08.2015 07:57Не хочу показаться занудным, но этот случай мне кажется нетипичным. Детальных условий задачи я, конечно, не знаю, может на самом деле была такая необходимость все это делать на клиенте силами js, не привлекая сервер.
Под нетипичные задачи всегда приходится как-нибудь да оптимизировать код.
Мне хочется понять, реально ли необходимо заморачиваться на экономию памяти в замыканиях при решении обычных задач.
vintage
02.08.2015 18:16+2Первый и второй способы наиболее быстрые, потребляют минимум памяти и легко статически анализируются (что даёт адекватные подсказки в IDE, подсветку ошибок и опять же лучшую JIT-оптимизацию).
Tramway
02.08.2015 19:19Cкорость и экономность — это уже отдельный вопрос, оптимизация досигается за счет деталей реализации VM. В v8, например, есть такая штука как hidden classes. Очень много кода написано с использованием первого способа, вероятно потому так и оптимизировано. Стандартный конфликт: «написать чтобы работало быстро и мало кушало» против «написать все нормально, чтобы там SOLID и т.п.».
vintage
03.08.2015 07:31В первых двух способах нет такого конфликта.
rock
04.08.2015 05:56Я бы так не сказал. V8, Babel (собственно, одна из причин появления loose mode).
vintage
04.08.2015 09:00Там речь про динамическое изменение прототипа. Эта фича не даёт инлайнить метод предка из-за чего снижает производительность. Тем не менее возможность в рантайме похачить методы предка — не относится ни к «всё нормально», ни к «SOLID». Так что правильно там возмущаются, что не стоит ради неё снижать производительность.
vintage
04.08.2015 09:15Кроме того, проблема с производительностью легко решается, если кешировать прототип: jsperf.com/6to5-inherit/2
rock
04.08.2015 12:34Причем здесь SOLID, который был упомянут только для примера? Либо «быстро», либо «правильно», «правильная» реализация второго способа быстрой быть не может без оптимизаций движка, коих на данный момент нет. Ваш «method right» совсем не «right» — кэшировать прототип, согласно стандарту, нельзя, он может быть изменен, его необходимо получать динамически, в чём и проблема. У вас — применение первого способа вместо второго. Как и loose mode.
vintage
04.08.2015 18:37Пофиксил: jsperf.com/6to5-inherit/3
rock
04.08.2015 20:45Касательно прототипа — корректно, касательно получения свойства — нет. Вы думаете, зачем нужен хелпер
_get
? Это реализация внутреннего метода [[Get]], который дополнительным аргументом принимаетReceiver
— контекст исполнения геттера. У вас геттер, которым может оказаться свойство прототипа родителя, будет исполнен в контексте прототипа родителя. Простой пример выводит 2,undefined
. Без запуска геттера в контексте инстанса будет изменен прототип родителя и выведено 1, 2.vintage
04.08.2015 21:15Довольно странный у вас пример, не относящийся к «нормальному программированию». Вот это более реалистично.
rock
04.08.2015 21:33Странный, но по стандарту должен работать. Я не защищаю логику классов по умолчанию в Babel, а объясняю, почему оно сделано так, а не иначе (иначе зачем бы я создавал ту ищью? :) ). В V8 абсолютно та же ситуация. Не хотите такого оверхеда из-за совсем уж невероятных кейсов — используйте loose mode — получите примерно тот же код, что предлагали изначально, только код этот скорее относится к первому способу из данной статьи, а не второму. Да и, по крайней мере, пока, второй способ не может претендовать на хорошую оптимизацию, как вы утверждали.
vintage
05.08.2015 07:15Да, мы отвлеклись :-) Плюсы последних 2 способов наследования не перекрывают их минусов, так что не вижу смысла их предпочитать.
k12th
Классы это удобно. Человеческое мышление так устроено: мы категоризируем вещи.
Ну и если мне в приложении нужно будет n кошек и m собак, я сделаю три класса (Animal, Dog и Cat с очевидными отношениями), а не буду городить громазду на прототипах и фабриках. А если мне будет нужна одна кошка и одна собака, то я еще подумаю, как я буду эти, смахивающие на синглтоны, объекты покрывать тестами.
m1el
Классическая проблема наследования.
Проблема в том, что то как мы «категоризируем» вещи плохо ложится на программирование.
Поэтому мы придумываем строго определенные абстракции, которые можно использовать.
k12th
Эта проблема возникает только в языках с плохой рефлексией, имхо.
m1el
Но эта проблема придумывания «красивых» иерхаических зависимостей существует независимо от того есть ли рефлексия или нет.
k12th
Не без этого, согласен. Но в языках с динамической типизацией и/или рефлексией проблемы типа «положить все в один контейнер, потом вызвать специфичный для каждого объекта метод» либо нет, либо она решается достаточно просто. В том же JS (а мы сейчас про него, а не про С++) достаточно
instanceof
.