Сегодня у меня была небольшая задачка на рефакторинг JS кода, и я натолкнулся на неожиданную особенность языка, о которой на протяжении 7 лет своего опыта программирования на этом ненавистном многими языке не задумывался и не сталкивался.
К тому же, я ничего не смог найти ни в русскоязычном, ни в английском интернете, в связи с чем решился опубликовать эту не очень длинную, не самую интересную, но полезную заметку.
Чтобы не пользоваться традиционными и бессмысленными константами foo/bar
, покажу непосредственно на примере, который был у нас в проекте, но всё же без кучи внутренней логики и с фейковыми значениями. Помните, что всё равно примеры получились довольно синтетические
Наступаем на грабли
Итак, у нас есть класс:
class BaseTooltip {
template = 'baseTemplate'
constructor(content) {
this.render(content)
}
render(content) {
console.log('render:', content, this.template)
}
}
const tooltip = new BaseTooltip('content')
// render: content baseTemplate
Всё логично
А потом нам понадобилось создать другой тип тултипов, в котором изменяется поле template
class SpecialTooltip extends BaseTooltip {
template = 'otherTemplate'
}
И вот тут меня ждал сюрприз, потому что при создании объекта нового типа происходит следующее
const specialTooltip = new SpecialTooltip('otherContent')
// render: otherContent baseTemplate
// ^ СТРАННО
Метод render вызвался со значением BaseTooltip.prototype.template
, а не с SpecialTooltip.prototype.template
, как я ожидал.
Наступаем на грабли внимательнее, снимая на видео
Поскольку chrome DevTools не умеют дебажить присваивание полей класса, то чтобы разобраться в происходящем, приходится прибегать к ухищрениям. С помощью небольшого хелпера логируем момент присваивания переменной
function logAndReturn(value) {
console.log(`set property=${value}`)
return value
}
class BaseTooltip {
template = logAndReturn('baseTemplate')
constructor(content) {
console.log(`call constructor with property=${this.template}`)
this.render(content)
}
render(content) {
console.log(content, this.template)
}
}
const tooltip = new BaseTooltip('content')
// set property=baseTemplate
// called constructor BaseTooltip with property=baseTemplate
// render: content baseTemplate
И когда мы применим этот подход к наследуемому классу, получим следующее странное:
class SpecialTooltip extends BaseTooltip {
template = logAndReturn('otherTemplate')
}
const tooltip = new SpecialTooltip('content')
// set property=baseTemplate
// called constructor SpecialTooltip with property=baseTemplate
// render: content baseTemplate
// set property=otherTemplate
Я был уверен, что сначала инициализируются поля объекта, а потом вызывается остальная часть конструктора. Оказывается, что всё хитрее.
Наступаем на грабли, покрасив черенок
Усложним ситуацию, добавив в конструктор ещё один параметр, который присвоим нашему объекту
class BaseTooltip {
template = logAndReturn('baseTemplate')
constructor(content, options) {
this.options = logAndReturn(options) // <--- новое поле
console.log(`called constructor ${this.constructor.name} with property=${this.template}`)
this.render(content)
}
render(content) {
console.log(content, this.template, this.options) // <--- поменяли вывод
}
}
class SpecialTooltip extends BaseTooltip {
template = logAndReturn('otherTemplate')
}
const tooltip = new SpecialTooltip('content', 'someOptions')
// в результате вообще путаница:
// set property=baseTemplate
// set property=someOptions
// called constructor SpecialTooltip with property=baseTemplate
// render: content baseTemplate someOptions
// set property=otherTemplate
И только такой способ дебага (хорошо что не алертами) немножко прояснил мне происходящее
Раньше этот код был написан на фреймворке Marionette и выглядел (условно) так
const BaseTooltip = Marionette.Object.extend({
template: 'baseTemplate',
initialize(content) {
this.render(content)
},
render(content) {
console.log(content, this.template)
},
})
const SpecialTooltip = BaseTooltip.extend({
template: 'otherTemplate'
})
При использовании Marionette всё работало так, как я ожидал, то есть метод render
вызывался с указанным в классе значением template
, но при переписывании логики модуля на ES6 в лоб и вылезла описанная в статье проблема
Считаем шишки
Итог:
При создании объекта наследованного класса порядок происходящего следующий:
- Инициализация полей объекта из объявления наследуемого класса
- Выполнение конструктора наследуемого класса (в том числе инициализация полей внутри конструктора)
- Только после этого инициализация полей объекта из текущего класса
- Выполнение конструктора текущего класса
Возвращаем грабли в сарай
Конкретно в моей ситуации проблему можно решить или через миксины или передавая template в конструктор, но когда логика приложения требует переопределять большое количество полей, это становится довольно грязным способом.
Было бы классно прочитать в комментариях ваши предложения о том, как элегантно решить возникшую проблему
Комментарии (56)
mrTyler
26.07.2019 00:10+2Мне вот интересно, долго еще Javascript разработчики будут удивляться, что все работает именно так, как работать должно и именно так, как написано в документации?
v1vendi Автор
26.07.2019 00:24-1Мне вот интересно, сколько разработчиков полностью знают 12-мегабайтную спецификацию языка?
mrTyler
26.07.2019 00:27+2Я понимаю, что динамически типизированный язык расслабляет, но документация и спецификация — не одно и то же.
v1vendi Автор
26.07.2019 00:34Не конфликта ради, а чтобы дополнить статью — можете помочь найти ссылку на документацию по Javascript, в которой бы хотя бы нечётко указывалось на такое поведение?
Я ни в коей мере не считаю себя специалистом высокого класса, но я изучил достаточно много книг и статей, связанных с фронтенд-разработкой, и я не разу не встречал информации по описанной мной темеaamonster
26.07.2019 00:41Надо будет поискать. Думаю, в спецификации всё есть, но наверняка найдётся и что-то написанное простым языком.
Но я бы сформулировал вопрос иначе: можете ли найти ссылку на документацию, где описано другое поведение?
Очевидно, нет. Значит, закладываться на другое поведение причин не было.
Не знать конкретное поведение в такой ситуации – не проблема. Не знаешь, как будет работать такой код? Просто напиши другой, про который знаешь.v1vendi Автор
26.07.2019 00:57Не знать конкретное поведение в такой ситуации – не проблема. Не знаешь, как будет работать такой код? Просто напиши другой, про который знаешь.
Я вот не знал, как будет работать такой код. Разобрался и поделился с сообществом тем, что на мой взгляд может быть кому-то полезно. А мог бы написать другой код, про который знаю, и кто-то мне бы потом рассказывал, что, мол, если бы я начинал с Паскаля, мне не пришлось бы изобретать велосипеды.
Хотелось бы напомнить, что бОльшую часть истории языка Javascript в нём в принципе не было такой сущности, как
class
, и разработчикам принципиально не нужно было знать поведение системы в таких ситуацияхaamonster
26.07.2019 01:04Напомню также, что традиционная реализация классов для js (определение методов prototype конструктора) придумана очень давно, и позволяет предположить, какое поведение тут будет.
Но, тем не менее, использовать тонкости языка не стоит. Вы можете их знать, а вот тот, кто будет читать ваш код – нет. Старое правило: "пиши код так, будто сопровождать его будет склонный к насилию психопат, знающий, где ты живёшь".
Т.е. в вашем случае разобраться в поведении – хорошая идея (вы повысили свою и не только свою квалификацию), а вот использовать это в коде – нежелательно (лучше оставить конструктор простым)
IkaR49
26.07.2019 21:39Но вы же знаете, что class это синтаксический сахар для prototype? Если знаете, то просто распишите этот сахар с помощью прототипного насследования и всё сразу становится проще.
staticlab
26.07.2019 01:03+2Следует помнить, что public instance fields сейчас находятся в stage 3. Соответственно, официально в спецификацию языка не внесены, однако можно прочитать proposal по этой фиче (попытаться, ага).
dagen
26.07.2019 05:17public instance fields
Кстати public и private proposal нынче объединили в proposal-class-fields.
Nikelandjelo
26.07.2019 19:41А можете дать ссылку на то, где это прописано в спецификации? В https://tc39.es/ecma262/#sec-class-definitions я только вижу грамматики класса, но не то, как он должен инициализироваться.
vvadzim
26.07.2019 00:19class A { constructor() { this.initialize() this.render() } render() { console.log(this.value1 + this.value2) } initialize() { this.value1 = 'A' this.value2 = 'A' } } class B extends A { initialize() { super.initialize() this.value2 = 'B' } } new B()
Выведет `AB`.vvadzim
26.07.2019 00:21-1Но вообще вызывать методы из конструктора так себе идея.
v1vendi Автор
26.07.2019 01:13Согласен. Наткнулся я на эту проблему именно в связи с тем, что в Marionette по сути конструктор ты не объявляешь, а исполнялся и перерабатывался в конструктор как раз метод
initialize
Вот только про "не используйте глобальные переменные" только что на заборе не пишут, а вот с проблемой излишней логики в конструкторах я, например, столкнулся впервые
XenonDev
26.07.2019 00:53+1автор, а Вас не смущает, что в классе-наследнике нельзя обращаться к
this
до тех пор пока не проинициализируется базовый класс (с помощьюsuper
)? Вполне логично, что переменные наследников еще не будут проинициализированы.v1vendi Автор
26.07.2019 01:06-2Я не вижу причины, кроме выбора авторов языка, почему объявленные поля класса-наследника не могут быть инициализированы до вызова конструктора. Поля родительского класса определяются именно до вызова конструктора, и ожидать такое поведение от класса-наследника мне не кажется какой-то несусветной глупостью
aamonster
26.07.2019 01:20Довольно очевидно, на самом деле. Попробуйте по шагам убрать синтаксический сахар:
- Заполнение полей вносим внутрь конструктора (в его начало).
1.1. То же самое для конструктора базового класса. - Вызов конструктора базового класса вносим в начало конструктора производного класса. Как раз попадёт перед заполнением полей (после – нельзя, в этот момент мы уже ожидаем, что объект базового класса готов)
- Читаем полученный код.
v1vendi Автор
26.07.2019 01:31-1Вопрос по второму пункту. Почему нельзя? Почему мы в этот момент ожидаем, что объект базового класса уже готов? В памяти нигде нет ОТДЕЛЬНОГО объекта базового класса, есть один объект, в который добавляются свойства и методы.
На мой взгляд, это вполне могло быть реализовано внутри как
this.prop = Subclass.prototype.prop || Baseclass.prototype.prop, а потом уже вызов конструктора с проинициализированными полями.
Да, был выбран другой способ, но это не значит что он очевиденdagen
26.07.2019 05:13Да, был выбран другой способ, но это не значит что он очевиден
Просмотрел внимательно (и вы тоже можете) заголовки тикетов; никому не приходило в голову, что текущий способ неочевидный. Никто не заводил такой тикет. Очевидно, что неочевидно это только для вас :)
И вы по-прежнему можете завести такой тикет, несмотря на то, что stage-3 означает, что все основные вопросы уже решены.v1vendi Автор
26.07.2019 09:37https://isocpp.org/wiki/faq/strange-inheritance
https://www.codeproject.com/Tips/641610/Be-Careful-with-Virtual-Method
https://lustforge.com/2014/02/08/dont-call-non-final-methods-from-your-constructor-please/
По итогу написания этой статьи, я смог найти довольно немало статей разного возраста про другие ЯП на английском, в которых описывается эта проблема. Это значит, что кому-то ещё это приходило в голову, а значит не так уж это и очевидно
И да, по приведённой Вами ссылке есть тикет на довольно близкую тему
https://github.com/tc39/proposal-class-fields/issues/151Mikluho
26.07.2019 09:59Ну и что там неожиданного? Везде пишут не делать так, как вы хотите. Дело конструктора — только инициализация состояния. И инициализировать надо сначала базовый. И так примерно во всех ОО-языках.
v1vendi Автор
26.07.2019 10:36Если бы дело конструктора было только в инициализации состояния, любые другие действия вообще запрещалось бы делать на уровне языка. Да и понятие инициализации состояния — довольно размытое, конкретно в моём случае, например, метод this.render просто неудачно назван, он не создаёт никаких dom элементов и не изменяет сторонние объекты, он просто компилирует в себя шаблон.
Это вполне подходит под понятие инициализации состояния, как я считаю
А в приведённых выше ссылках например написано, что для языка C# инициализация переменных класса-наследника происходит ДО вызова конструктора родителя, что уже делает наш спор не таким однозначным.
И само наличие десятков (я привёл маленькую выборку) статей на подобные темы значит именно то, что у других наших коллег тоже возникают вопросы по этому поводу, разве нет?
Mikluho
26.07.2019 11:58И само наличие десятков (я привёл маленькую выборку) статей на подобные темы значит именно то, что у других наших коллег тоже возникают вопросы по этому поводу, разве нет?
Да темы не такие уж похожие. И по большей части разъясняют неучам правильные подходы к программированию.
Большая часть вопросов возникает из желания писать код абы как.
Вот в вашем пример есть одно концептуальное недоразумение: у вас template это свойство, а не поле. Но это историческое наследие JS — там просто не было пропертей. И для обращения к полю базового типа надо было к нему явно обращаться. Сейчас добавили синтаксис классов, но способ обращения к элементам и последовательность инициализации никуда не делись.
aamonster
26.07.2019 06:22Попробуйте ответить на свой вопрос сами. Для вашего примера выполните руками пункт 1 (и 1.1) и проведите эксперимент – вставьте вызов конструктора базового класса не в начало. Какую ошибку получим?
Mikluho
26.07.2019 07:24Почему мы в этот момент ожидаем, что объект базового класса уже готов?
Да хотя бы потому, что вы пытаетесь обратиться к полю базового класса для изменения значения — для этого класс уже должен быть проинициализирован, а это происходит после вызова конструктора.
Попробуйте в наследника добавить конструктор и там обратиться к полю — станет яснее.
Nikelandjelo
26.07.2019 19:33-1Убириание синтаксического сахара не поможет. Напримеh:
class A { getOne() { return 'One'; } getTwo = () => { return 'two'; } }
Я ожидал, что
getOne
— это синтактический сахар, который эквивалентенgetTwo
. Но это не так, они будут инициализированы в разном порядке:
class A { getOne() { return 'one from A'; } getTwo = () => { return 'two from A'; } constructor() { console.log('getOne=' + this.getOne()); console.log('getTwo=' + this.getTwo()); } } class B extends A { getOne() { return 'one from B'; } getTwo = () => { return 'two from B'; } } new B();
Выведет
getOne=one from B getTwo=two from A
mayorovp
26.07.2019 20:01+1Зря ожидали, потому что эквивалентный код — вот такой:
function A() { this.getTwo = () => { return 'two'; } } A.prototype.getOne = function () { return 'One'; }
Nikelandjelo
26.07.2019 20:06Да, я уже понял, что я зря этого ожидал исходя из результатов, которые я получил. Написание правильного эквивалентного кода далеко не самая простая задача, как многие в комментариях тут рассуждают. Мне еще интересно исходя из какой документации/спецификации я могу понять, что правильные код именно ваш. Я конечно могу написать оба и сравнить что они делают, но хочется увидеть официальную спецификацию.
Sirion
26.07.2019 21:10Не знаю насчёт документации, но ИМХО, десахаризация этого кода очевидна исходя из того, какой старый код он призван был заменить.
- Заполнение полей вносим внутрь конструктора (в его начало).
pin2t
26.07.2019 05:13Просто ненадо выполнять код в конструкторе
class BaseTooltip { template = 'baseTemplate' constructor(options) { this.options = options } render(content) { console.log('render:', content, this.template, this.options) } } const tooltip = new BaseTooltip(options) tooltip.render('content')
JustDont
26.07.2019 06:43Зачем читать документацию, когда её можно не читать, да потом еще и писать по этому поводу статьи на хабре?
chelovekkakvse
26.07.2019 10:35-1Просто кто-то не знает как работает прототипное наследование. Не буду оригинальным — RFM.
mayorovp
26.07.2019 10:37А при чём тут прототипное наследование?
Если бы свойство template попало в прототип — то и никакой проблемы бы как раз не было.
chelovekkakvse
26.07.2019 11:55Да, виноват, по диагонали код автора прочитал. Там впринципе тупо сделано.
Пожалуй надо было так:
class BaseTooltip { type = 'baseTooltip'; content; constructor(someField) { this.content = someField; } render() { console.log(this.type, this.content); } } class SpecialTooltip extends BaseTooltip { type = 'specialTooltip'; constructor(content) { super(content); } } const newChild = new SpecialTooltip('child'); newChild.render();
П.С. Однако свойство template все же берется из родителя. В обычном наследовании, однако, результат будет аналогичным.
Myateznik
26.07.2019 16:19+1На самом деле поведение очень даже логичное и понятное. Разберу пример:
class A { name = 'A' constructor() { this.log() } log() { console.log(this.name) } } class B extends A { name = 'B' } new B // A
Почему так происходит?
Всё на самом деле очень просто — в классеA
не указан конструктор, соответственно используется конструктор "по умолчанию" т.е. класс выглядит так:
class B extends A { name = 'B' constructor() { super() } }
Любой наследующий класс должен в своём конструкторе сначала вызывать конструктор родительского класса (делается это через
super()
) и только потом производить необходимые манипуляции с инстансом.
Соответственно поле
name
указанное в классеB
будет установлено только после выполнения конструктора классаA
.
Поэтапное создание инстанса класса
B
будет выглядить так:
1. Object.constructor() -> Object {} 2. A.name = 'A' -> A { name: 'A', log() {...} } 3. A.constructor() -> A { name: 'A', log() {...} } 4. A.log() -> A { name: 'A', log() {...} } 5. B.name = 'B' -> B { name: 'B', log() {...} } 6. B.constructor() -> B { name: 'B', log() {...} }
Т.е. на момент вызова метода
log()
инстанс классаB
содержит в полеname
значениеA
, .
Как добиться ожидаемого в данном примере поведения?
- Есть к примеру способ, который указал vvadzim, но он требует переноса инициализации в отдельный метод.
- Но есть и более простой способ, не требующий переноса инициализации в отдельный метод.
Что это за способ? Ответ: Вызов метода
log()
в микротаске, но как? Есть опять таки два равносильных варианта:
// 1 вариант - Микротаск через Promise class A { name = 'A' constructor() { Promise.resolve().then(() => this.log()) } log() { console.log(this.name) } } // 2 вариант - Микротаск по запросу class A { name = 'A' constructor() { queueMicrotask(() => this.log()) } log() { console.log(this.name) } }
Команда Polymer Project в проекте lit-element по сути так же использует микротаски (через метод _enqueueUpdate).
Nikelandjelo
26.07.2019 19:25Поэтапное создание класса выглядит не совсем так. B.constructor() будет вызван первым. Например если переписать B как:
class B extends A { name = 'B' constructor() { console.log('B constructor start'); super(); console.log('B constructor finish'); } } new B();
То лог будет:
B constructor start
A
B constructor finish
И я согласен с автором, что это далеко не очевидно, что инициализация name в B происходит после вызова super().
К тому же, если переписать код и вместо переменной name использовать метод getName тогда работает так, как автор и ожидал:
class A { getName() { return 'A'; } constructor() { this.log() } log() { console.log(this.getName()); } } class B extends A { getName() { return 'B'; } } new B(); <source> выведет "B". По моему личному мнению это не очевидно, что инициализация переменных и методов в классе происходит в разном порядке. Особенно учитывая что в JS разница между переменной и методом небольшая и в до ES6 классов методы и были переменными функциями.
Myateznik
26.07.2019 20:03Поэтапное создание класса выглядит не совсем так. B.constructor() будет вызван первым.
Совершенно верно, но я указал именно данный порядок, чтобы было яснее, какое значение поля
name
будет во время вызова методаlog()
. Если проще я просто обратную цепочку (из вложения) описал.
Правильнее (подробнее) ваш вариант тогда написать так:
class A { name = 'A' constructor() { console.log('A constructor start') this.log() console.log('A constructor finish') } log() { console.log(this.name) } } class B extends A { name = 'B' constructor() { console.log('B constructor start') super() console.log('B constructor finish') } } new B()
B constructor start A constructor start A A constructor finish B constructor finish
И я согласен с автором, что это далеко не очевидно, что инициализация name в B происходит после вызова super().
И всё же на это указывает как спецификация ECMAScript, так и рантайм, если вы попробуете любым образом обратиться к любому полю/методу или установить поле наследующего класса до вызова
super()
.
class B extends A { constructor() { this.name = 'B' super() } } new B()
Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor at new B (<anonymous>:15:5) at <anonymous>:20:1
Т.е. следующие три варианта эквивалентны за исключением того, что в первом и втором случаях поле устанавливается по семантике [[define]], а в третьем по семантике [[set]].
// Первый class B extends A { name = 'B' } // Второй (эквивалент первого с точностью до принципа установки поля) class B extends A { constructor() { super() Object.defineProperty(this, 'name', { value: 'B' }) } } // Третий class B extends A { constructor() { super() this.name = 'B' } }
К тому же, если переписать код и вместо переменной name использовать метод getName тогда работает так, как автор и ожидал:
В данном случае работает как ожидается только ввиду того, что метод эта та же функция и объявляется он в блоке (скоупе) класса. Как и обычная функция в глобальной области она просто всплывёт вверх и уже к вызову в конструкторе имеет необходимую сигнатуру.
К слову в моем описании поэтапной инициализации инстанса метод log() появился уже на этапе присвоения значения полю
name
.
Т.е. поля и методы класса при объявлении ведут себя как function, var, let, const в любой области видимости.
LEXA_JA правильно указал, что методы попадают в prototype, а поля непосредственно в объект (по сути таким образом и соблюдается стандартный порядок объявления функций и переменных в областях видимости, но с поправкой на специфику классов). Прототип класса уже полностью известен и сформирован на момент создания инстанса.
И ответ на ваш комментарий в соседней ветке: https://github.com/tc39/proposal-class-fields.
Опять таки я указал способы достижения ожидаемого результата, используя микротаск — всё, что нам нужно это вызвать условную функцию
log()
в следующем такте исполнения.
Druu
27.07.2019 05:22И я согласен с автором, что это далеко не очевидно, что инициализация name в B происходит после вызова super().
Это полностью очевидно. Во время инициализации полей наследника предок должен быть инициализирован, т.к. вы имеете право при инициализации вызывать через super методы предка. Если бы предок не был инициализирован, вы бы их вызывать не могли (т.е. методы бы, вообще говоря, вызывались, но возвращали бы дичь, т.к. отрабатывали бы на неинициализированом классе).
v1vendi Автор
27.07.2019 13:21Половина комментаторов нам тут говорят, что при инициализации методы предка дёргать нельзя, а вы утверждаете, что предок инициализируется полностью именно для того, чтобы дёргать его методы.
Но мой основной вопрос всё равно не про это — почему инстанс класса предка сразу бы не инициализировать со значениями полей, переопределёнными в классе-потомке?
Myateznik
27.07.2019 14:44Все методы попадают в прототип т.е. все методы уже определены ещё до инициализации любого инстанса. А вот поля в инстансе устанавливаются именно в момент инициализации и именно в порядке из глубины (от самого первого предка).
Но мой основной вопрос всё равно не про это — почему инстанс класса предка сразу бы не инициализировать со значениями полей, переопределёнными в классе-потомке?
По тому, что во первых в ECMAScript классы это синтаксический сахар, во вторых вложенность не известна и процесс по своей сути итеративный. А методы так же как и функции получают ровно те значения полей и переменных своих областей видимости, которые имеются на момент вызова функции.
Пример ниже по своей сути ведёт себя совершенно так же как и ваш случай с классом.
function greet() { console.log(`Hello, ${name}!`) } function constructorA() { Object() // В случае с классами на данном месте неявно вызывается конструктор объекта (super()). name = 'ninja cat' // Установка условного поля `name` в родительском классе. greet() } function constructorB() { constructorA() // Это место явного вызова super() name = 'world' // Установка условного поля `name` в дочернем классе. } constructorB() // Hello, ninja cat!
Данный пример показывает именно то, как представляется синтаксический сахар класса в рантайме (но на обычных функциях).
А это упрощенный пример, показывающий, что любая функция/метод использует значение переменной скоупа на момент вызова. Обращаю внимание, что на момент объявления функции greet() в скоупе о переменной name вообще ничего не известно.
function greet() { console.log(`Hello, ${name}!`) } let name = 'ninja cat' greet() // Hello, ninja cat! name = 'world' greet() // Hello, world!
Riim
26.07.2019 16:45-1Как обычно набежала куча любителей
самоутверждениячтения документации. Хабру явно не хватает возможности отключать комментарии.
v1vendi на многих ЯП ты бы получил ожидаемый результат:
class BaseTooltip: template = 'baseTemplate' def __init__(self, content): self.render(content) def render(self, content): print('render:', content, self.template) BaseTooltip('content') class SpecialTooltip(BaseTooltip): template = 'otherTemplate' SpecialTooltip('otherContent') # render: content baseTemplate # render: otherContent otherTemplate
, плюс все (которые я видел) обёртки имитирующие классы до ES6 вели себя именно так. Я тоже когда-то попался на этом хоть и заглядываю в спецификацию.
UPD: одно из решений — использование статических свойств с обращением к ним через
this.constructor
.
justboris
26.07.2019 20:19Это довольно известная проблема Backbone (и Marionette тоже). Вот тут на гитхабе есть обсуждение с возможными решениями: https://github.com/jashkenas/backbone/issues/3560
zim32
27.07.2019 16:03Все логично. В c++ тоже так. И вообще есть хорошая практика что конструкторы не должны содержать никаких side effect. Но периодически появляются такие статьи как эта
aamonster
Довольно логичное поведение (до инициализации инстанса класса-наследника должен отработать конструктор базового класса — а значит, инициализация полей наследника ещё не выполнена) и ьросающийся в глаза smelly code (в конструкторе дёргаете методы, опирающиеся на то, что объект уже готов...)
Жаль, что нынче учат программированию, начиная не с паскаля: в Objective Pascal многие нюансы были бы видны из кода, и при смене языка привычки бы остались.
А решение простое. Убрать из конструктора любую логику, кроме создания объекта. Конструктор должен выполнять ровно одну задачу: заполнить все поля так, чтобы соблюдались инварианты (в случае наследования — те поля, что отличаются от базового класса). Всё прочее выносите в функцию Init или ещё какую и вызывайте её явно.
mrTyler
Самые сильные Frontend разработчики, с котороыми мне довелось работать, именно те, чьим первым языком были Java или C#. Их код чище, шаги продуманы, ну и в целом они умеют писать весьма приятный код.
Sirion
В общем-то, для понимания этого поведения достаточно представить, во что это всё де-сахарится. Знакомство с другими языками не требуется, только с чуть более ранней версией этого.
Nikelandjelo
Нет, не достаточно. Порядок инициализации зависит от того, как записана переменная. Например
getFoo() { return 'foo' }
иgetFoo = () => { return 'foo '; }
будут инициализированы в разном порядке в классе. Было бы хорошо найти где это прописано в спецификации языка, но пока что все тут говорят что это "очевидно".LEXA_JA
Они не просто инициальзируются в разном порядке, первый вариант — это объявление метода. Этот метод попадает в прототип. Второй вариант — объявление поля, в которое записывается фунция. Это поле инициализируется в каждом экземпляре класса. Из чего следует еще один забавный момент: если попытаться унаследоваться от такого класса, то через
super
, такая функция доступна не будет.taliban
Тоесть такой код вызовет ошибку в Java и C#? Или будет работать так же?
aamonster
Вообще я имел в виду код на js. Но тот же мысленный эксперимент на java или c# (вызвать конструктор базового класса в середине конструктора класса-потомка, после инициализации его полей) даст ту же ошибку: присваивания полей в конструкторе базового класса выполнятся позже и перкроют значения, присвоенные потомком.