Pull to refresh

Comments 120

Довольно логичное поведение (до инициализации инстанса класса-наследника должен отработать конструктор базового класса — а значит, инициализация полей наследника ещё не выполнена) и ьросающийся в глаза smelly code (в конструкторе дёргаете методы, опирающиеся на то, что объект уже готов...)


Жаль, что нынче учат программированию, начиная не с паскаля: в Objective Pascal многие нюансы были бы видны из кода, и при смене языка привычки бы остались.


А решение простое. Убрать из конструктора любую логику, кроме создания объекта. Конструктор должен выполнять ровно одну задачу: заполнить все поля так, чтобы соблюдались инварианты (в случае наследования — те поля, что отличаются от базового класса). Всё прочее выносите в функцию Init или ещё какую и вызывайте её явно.

Самые сильные Frontend разработчики, с котороыми мне довелось работать, именно те, чьим первым языком были Java или C#. Их код чище, шаги продуманы, ну и в целом они умеют писать весьма приятный код.
В общем-то, для понимания этого поведения достаточно представить, во что это всё де-сахарится. Знакомство с другими языками не требуется, только с чуть более ранней версией этого.

Нет, не достаточно. Порядок инициализации зависит от того, как записана переменная. Например getFoo() { return 'foo' } и getFoo = () => { return 'foo '; } будут инициализированы в разном порядке в классе. Было бы хорошо найти где это прописано в спецификации языка, но пока что все тут говорят что это "очевидно".

Они не просто инициальзируются в разном порядке, первый вариант — это объявление метода. Этот метод попадает в прототип. Второй вариант — объявление поля, в которое записывается фунция. Это поле инициализируется в каждом экземпляре класса. Из чего следует еще один забавный момент: если попытаться унаследоваться от такого класса, то через super, такая функция доступна не будет.

Тоесть такой код вызовет ошибку в Java и C#? Или будет работать так же?

Вообще я имел в виду код на js. Но тот же мысленный эксперимент на java или c# (вызвать конструктор базового класса в середине конструктора класса-потомка, после инициализации его полей) даст ту же ошибку: присваивания полей в конструкторе базового класса выполнятся позже и перкроют значения, присвоенные потомком.

gist.github.com/0xff00ff/cc7f486d7b07c8fbff5661d5ab8f55cc

Тоесть этот код вызовет ошибку или не будет использовать значение свойства наследника, ибо в конструкторе оно еще не должно быть инициализировано?
Верно, и в отличии от жс, дотнет ведет себя немного по другому:
1. инициализируется проперти родителя
2. инициализируется проперти наследника
3. выполняется конструктор родителя
4. выполняется конструктор наследника
Странно что в отличии от жс, оба проперти инициализируются до первого конструктора. В этом то и проблема, и именно на нее автор и обратил внимания.
UFO just landed and posted this here
верно, но порядок инициализации уже изменен:
1. инициализация проперти родителя
2. вызов конструктора родителя
3. инициализация проперти наследника
4. вызов конструктора наследника

Вроде мелочь, но все же могут быть моменты которые ударят по пальцам, как в примере с кодом автора. Так что фраза «логичное поведение» не совсем подходит, и что что писали что «надо делать по другому ибо так правильно» тоже не катит, не все не всегда пишут как надо, жс позволяет писать неправильно, закрывает глаза на такое и ведет себя не так как другие языки.
UFO just landed and posted this here
нет, вызвалось бы как я и написал выше, или думаете я на вскидку написал потому что мне так захотелось? И код даже предоставил который может подтвердить то что я говорю. Инициализация в жс не такая же как в c# или php (c этими языками я проверил), именно из-за своей прототипной сути, обьекты создаются по цепочке один за одним. В ооп языках все происходит по другому, это не просто цепочка, это одна сущность. Отсюда и поведение разное.
И я еще раз повторю, жс ведет себя не очевидно для людей которые не изучали как работает жс специально.
UFO just landed and posted this here
Увы но нет, у базового класса свое свойство, а у наследников свои, ключевое слово new это делает возможным.
ibb.co/WWLhcLb — ссылочка на скрин выполнения
gist.github.com/0xff00ff/bf9010cc4ac2e57c8edd5cf87925c1c9 — а это ссылочка на гист который вы можете запустить и проверить что я не написал на вскидку.

А ведь для кого-то это поведение очевидно ;)
UFO just landed and posted this here
Влияет, если не использовать один из конструкторов, что автор и сделал
UFO just landed and posted this here
Так и есть, как бы создать обьект без конструктора невозможно, и в других языках он вызывается неявно, и учитывая что уже все инициализировано до конструтора (любого из них) поведение более очевидное, все слои наследования получают и могут работать со своими данными. Но не в жс, тут инициализация происходит динамически шаг за шагом.
UFO just landed and posted this here
Вот вы кстати правильно подвели нить разговора одним вопросом, ато я все на различия в инициализации смотрел и слона то не заметил :) Да, все верно, из базового класса получать данные наследников не получится.
Но есть и хорошие новости, я знаю язык в котором именно так и работает, в базовом конструкторе используется this наследника, если работаете с наследником и у него нет конструктора. В *язык который нельзя называть*
UFO just landed and posted this here
Вы написали неправильно. Для начал поясню, что это не свойство — это поле. Это важно, потому что свойства — это синтаксический сахар для методов, их можно переопределять, тогда когда поля — это часть памяти объекта, их в .NET переопределять нельзя. Вы можете только изменить значение поля в своей области видимости, или создать новое поле в потомке, и неважно как это поле называется.
Возьмите ваш пример, поменяйте конструктор B и запустите в dotnetfiddle.net:
public B(): base() {
    Console.WriteLine(prop);
    Console.WriteLine(base.prop);
}

или в Main:
B b = new B();
Console.WriteLine(b.prop);
Console.WriteLine(((A)b).prop);

То, что вы назвали его prop в B, не меняет ровным счетом ничего, вы могли бы назвать его любым другим именем с тем же успехом. В этом принципиальное отличие .NET от JS — вы не можете «переопределить» поле.
Да, я вкурсе, модификатор new довольно таки обьясняет ситуацию.
В жс по сути тоже ведь не переопределение идет а создание нового поля.
Я изначально говорил о другом, порядок инициализации в c# и жс разные, но в конечном итоге staticlab правильно заметил что таким образом я отошел от реалий и полез не в те дебри.
А за сервис dotnetfiddle.net спасибо, я как-то и не знал про него.
Нет, порядок следующий:
1. инициализируется проперти родителя
3. выполняется конструктор родителя
2. инициализируется проперти наследника
4. выполняется конструктор наследника
using System;

class Program
{
    static void Main()
    {
        new B();
    }

    class A
    {
        private const string ClassName = nameof(A);
        private const string FieldName = nameof(_value1);

        string _value1 = SetHelper.SetField("Value A-1", ClassName, FieldName);

        public A()
        {
            Console.WriteLine($"Entered {ClassName} constructor, current {FieldName} is \"{_value1}\".");
            _value1 = SetHelper.SetField("Value A-2", ClassName, FieldName);
            Console.WriteLine($"Exiting {ClassName} constructor, current {FieldName} is \"{_value1}\".");
        }
    }

    class B : A
    {
        private const string ClassName = nameof(B);
        private const string FieldName = nameof(_value2);

        string _value2 = SetHelper.SetField("Value B-1", ClassName, FieldName);

        public B()
        {
            Console.WriteLine($"Entered {ClassName} constructor, current {FieldName} is \"{_value2}\".");
            _value2 = SetHelper.SetField("Value B-2", ClassName, FieldName);
            Console.WriteLine($"Exiting {ClassName} constructor, current {FieldName} is \"{_value2}\".");
        }
    }

    static class SetHelper
    {
        public static TМalue SetField<TМalue>(TМalue value, string className, string fieldName)
        {
            Console.WriteLine($"The field {fieldName} of {className} is set to \"{value}\".");

            return value;
        }
    }
}

//The field _value2 of B is set to "Value B-1".
//The field _value1 of A is set to "Value A-1".
//Entered A constructor, current _value1 is "Value A-1".
//The field _value1 of A is set to "Value A-2".
//Exiting A constructor, current _value1 is "Value A-2".
//Entered B constructor, current _value2 is "Value B-1".
//The field _value2 of B is set to "Value B-2".
//Exiting B constructor, current _value2 is "Value B-2".

Оба неправы, но это не имеет значения, учитывая, что базовый класс ни при каких условиях не имеет доступа к полям наследника без вызова виртуальных методов.
dotnetfiddle.net/Y0dpY9
Все правильно, как я и писал, сперва поля, затем конструкторы, в жс шаг за шагом это происходит, базовое поле, базовый конструктор, наследуемое поле, наследуемый конструктор итд по цепочке, что собственно тоже логично исходя их того что это прототипная штука, и оно динамически проходит всю цепочку шаг за шагом
Главное отличие, что в JS вы можете дотянутся до полей потомка из базового «класса», а в .NET без хаков — нет.
Дык в том то и дело что автор не смог, и это его удивило )
Вы правы, зависит от тайминга, так что на эту скользкую дорожку лучше даже не ступать.
Верно, и в отличии от жс, дотнет ведет себя немного по другому:

Разница между шарпом и js тут не из-за порядка инициализации, а из-за того, что в js поле предка и поле потомка — это одно и то же поле, а в шарпе — это два разных поля. По-этому в шарпе (в отличии от js) результат вообще не зависит от порядка инициализации (он мог бы зависеть если бы вы инициализировали поле вызовом метода базового класса, но в field initializers нельзя использовать this/base и, с-но, это одна из двух основных причин по которым их там использовать запрещено).
У вас есть первое поле — с одним значением и второе поле — с другим значением. И не важно, какое из них инициализировано первым, а какое вторым. Значение полей не меняется ни в какой момент.


В случае же js поле сперва инициализируется (в конструкторе базового класса) а потом перезаписывается (в конструкторе производного).

В С# вызвать конструктор базового класса можно только через рефлекшен. Вызов конструктора базового класса выполняется рантаймом при создании инстанса до конструктора класса-потомка.
А это тогда как работает:
    class a
    {
        public string x = "x";
        public a(string s)
        {
            x = s;
        }

        public override string ToString()
        {
            return x;
        }
    }

    class b : a
    {
        public b() : base("b") { }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var x = new b();

            Console.WriteLine(x); //print b
            Console.ReadLine();
        }
    }
Инлайн инициализация в C# это по сути синтаксический сахар, это 100% эквивалент инициализации в конструкторе. Т.е. это то же самое, что
public string x;

public a(string s)
{
    x = "x"
    x = s;
}
Да нет же! Я про вызов конструктора базового класса — его не то что можно вызвать явно через base(), но иногда это придётся делать обязательно.
Конструктор базового класса без рефлекшена в С# вызывать нельзя. Единственный способ вызвать конструктор без рефлекшена — использовать ключевое слов new. Вы просто указали компилятору, какой конструктор базового класса должен быть вызван при инстанцинации, иначе он по умолчанию вызовет конструктор без параметров. Если конструктора без параметров нет, то вы обязаны указать, какой конструктор базового класса должен быть использован, указать — не вызывать.
Единственный способ вызвать конструктор без рефлекшена — использовать ключевое слов new.
Что же тогда делает ключевое слово base?
Что же тогда делает ключевое слово base?

Конструктор через base вызвать нельзя.


т.е. вот такое вот:


    class A
    {
        public string x;
        public A(string s)
        {
            x = s;
        }
    }

    class  B: A
    {
        public B() {
            base("b");
        }
    }

не скомпилируется

Так конечно не скомпилируется.
В моём примере есть правильный вызов конструктора базового класса.
public b() : base("b")

Иначе нельзя, но это именно явный вызов конструктора.
В моём примере есть правильный вызов конструктора базового класса.

Здесь нету вызова конструктора, т.к. указанный вами кусок кода с base не является statement, т.е. не исполняется. Вам же KostaArnorsky выше объяснил все. Указать конструктор базового класса дял конструктора производного — можно. Вызвать конструктор базового класса — нельзя. Он вызывается сам, неявно.

Да, именно что вызов указанного конструктора базового класса.
И да, вызывается он до исполнения конструктора наследника.
Разве нет?

Еще раз, он неявно вызывается. Вызвать его сами вы не можете.
В строчке 'public b(): base("b")' нет вызова конструктора. Это вообще не исполняемый код, это объявление.

Да нет, вполне явно. И код вполне исполняемый. Тут даже точку останова при отладке можно поставить. Так что это вполне явный вызов. Просто нет другого штатного способа вызвать.
Точно так же и конструктор конкретного класса вызывается «неявно» при создании экземпляра, хотя его исполнение происходит вполне явно в конкретном месте кода.
Да нет, вполне явно. И код вполне исполняемый.

Нет не явно, нет не исполняемый. Это часть объявления конструктора, еще раз. Что вам в этом непонятно? Этот код не исполняется ни в какой момент работы программы. base('b') не является валидным statement/expression в c#.


Просто нет другого штатного способа вызвать.

Его ВООБЩЕ нет способа вызвать. Приведенный вами код — это не вызов конструктора базового класса. Это часть объявления конструктора производного.


Точно так же и конструктор конкретного класса вызывается «неявно» при создании экземпляра

При создании экземпляра вы как раз вызываете конструктор явно. Вы пишите expression, который вызывает конструктор.

Этот код не исполняется ни в какой момент работы программы.
Не исполняется, а в отладчик попасть можно прямо перед вызовом именно этого кода?
image

Это часть объявления конструктора производного.
одно не отменяет другого. Вызов происходит при входе в конструктор наследника.

При создании экземпляра вы как раз вызываете конструктор явно
— Нет, вы пишете new, который приводит к вызову конструктора наследника, который приводит к вызову конструктора базового класса…
Он вызывается, но не вами. В спецификации же черным по белому написано. Вы конструктор базового класса вызвать не можете, он будет вызван, без разницы напишите вы base или нет.
Он вызывается, но не вами.

Конструктор любого типа будет вызван «неявно», что не мешает нам вызывать именно тот конструктор, который нам нужен.
Вот статический конструктор вызвать явно и правда нельзя.
ну и ещё в тему
Этот код не исполняется ни в какой момент работы программы.
Посмотрите, во что превращается этот код через ildasm, тут видно, что он именно вызывается и именно там, где он написан:
image
Мне вот интересно, долго еще Javascript разработчики будут удивляться, что все работает именно так, как работать должно и именно так, как написано в документации?
Мне вот интересно, сколько разработчиков полностью знают 12-мегабайтную спецификацию языка?
Я понимаю, что динамически типизированный язык расслабляет, но документация и спецификация — не одно и то же.
Не конфликта ради, а чтобы дополнить статью — можете помочь найти ссылку на документацию по Javascript, в которой бы хотя бы нечётко указывалось на такое поведение?
Я ни в коей мере не считаю себя специалистом высокого класса, но я изучил достаточно много книг и статей, связанных с фронтенд-разработкой, и я не разу не встречал информации по описанной мной теме

Надо будет поискать. Думаю, в спецификации всё есть, но наверняка найдётся и что-то написанное простым языком.


Но я бы сформулировал вопрос иначе: можете ли найти ссылку на документацию, где описано другое поведение?
Очевидно, нет. Значит, закладываться на другое поведение причин не было.
Не знать конкретное поведение в такой ситуации – не проблема. Не знаешь, как будет работать такой код? Просто напиши другой, про который знаешь.

Не знать конкретное поведение в такой ситуации – не проблема. Не знаешь, как будет работать такой код? Просто напиши другой, про который знаешь.

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


Хотелось бы напомнить, что бОльшую часть истории языка Javascript в нём в принципе не было такой сущности, как class, и разработчикам принципиально не нужно было знать поведение системы в таких ситуациях

Напомню также, что традиционная реализация классов для js (определение методов prototype конструктора) придумана очень давно, и позволяет предположить, какое поведение тут будет.


Но, тем не менее, использовать тонкости языка не стоит. Вы можете их знать, а вот тот, кто будет читать ваш код – нет. Старое правило: "пиши код так, будто сопровождать его будет склонный к насилию психопат, знающий, где ты живёшь".


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

Но вы же знаете, что class это синтаксический сахар для prototype? Если знаете, то просто распишите этот сахар с помощью прототипного насследования и всё сразу становится проще.
UFO just landed and posted this here
public instance fields
Кстати public и private proposal нынче объединили в proposal-class-fields.
Человека, который знает все наизусть Я бы уволил первым

А можете дать ссылку на то, где это прописано в спецификации? В https://tc39.es/ecma262/#sec-class-definitions я только вижу грамматики класса, но не то, как он должен инициализироваться.

UFO just landed and posted this here
Я не нашел там записи что проперти должно инициализироваться после конструктора, можете уточнить для недогоняющих?
UFO just landed and posted this here
Но ведь проперти на самом деле инициализируется до конструктора, странно правда?
UFO just landed and posted this here
В документации написано что проперти должны быть инициализированоы после конструктора? Чет я не верю вам.
UFO just landed and posted this here
UFO just landed and posted this here

Согласен. Наткнулся я на эту проблему именно в связи с тем, что в Marionette по сути конструктор ты не объявляешь, а исполнялся и перерабатывался в конструктор как раз метод initialize
Вот только про "не используйте глобальные переменные" только что на заборе не пишут, а вот с проблемой излишней логики в конструкторах я, например, столкнулся впервые

автор, а Вас не смущает, что в классе-наследнике нельзя обращаться к this до тех пор пока не проинициализируется базовый класс (с помощью super)? Вполне логично, что переменные наследников еще не будут проинициализированы.

Я не вижу причины, кроме выбора авторов языка, почему объявленные поля класса-наследника не могут быть инициализированы до вызова конструктора. Поля родительского класса определяются именно до вызова конструктора, и ожидать такое поведение от класса-наследника мне не кажется какой-то несусветной глупостью

Довольно очевидно, на самом деле. Попробуйте по шагам убрать синтаксический сахар:


  1. Заполнение полей вносим внутрь конструктора (в его начало).
    1.1. То же самое для конструктора базового класса.
  2. Вызов конструктора базового класса вносим в начало конструктора производного класса. Как раз попадёт перед заполнением полей (после – нельзя, в этот момент мы уже ожидаем, что объект базового класса готов)
  3. Читаем полученный код.

Вопрос по второму пункту. Почему нельзя? Почему мы в этот момент ожидаем, что объект базового класса уже готов? В памяти нигде нет ОТДЕЛЬНОГО объекта базового класса, есть один объект, в который добавляются свойства и методы.
На мой взгляд, это вполне могло быть реализовано внутри как
this.prop = Subclass.prototype.prop || Baseclass.prototype.prop, а потом уже вызов конструктора с проинициализированными полями.
Да, был выбран другой способ, но это не значит что он очевиден

Да, был выбран другой способ, но это не значит что он очевиден

Просмотрел внимательно (и вы тоже можете) заголовки тикетов; никому не приходило в голову, что текущий способ неочевидный. Никто не заводил такой тикет. Очевидно, что неочевидно это только для вас :)

И вы по-прежнему можете завести такой тикет, несмотря на то, что stage-3 означает, что все основные вопросы уже решены.

https://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/151

Ну и что там неожиданного? Везде пишут не делать так, как вы хотите. Дело конструктора — только инициализация состояния. И инициализировать надо сначала базовый. И так примерно во всех ОО-языках.

Если бы дело конструктора было только в инициализации состояния, любые другие действия вообще запрещалось бы делать на уровне языка. Да и понятие инициализации состояния — довольно размытое, конкретно в моём случае, например, метод this.render просто неудачно назван, он не создаёт никаких dom элементов и не изменяет сторонние объекты, он просто компилирует в себя шаблон.
Это вполне подходит под понятие инициализации состояния, как я считаю


А в приведённых выше ссылках например написано, что для языка C# инициализация переменных класса-наследника происходит ДО вызова конструктора родителя, что уже делает наш спор не таким однозначным.


И само наличие десятков (я привёл маленькую выборку) статей на подобные темы значит именно то, что у других наших коллег тоже возникают вопросы по этому поводу, разве нет?

И само наличие десятков (я привёл маленькую выборку) статей на подобные темы значит именно то, что у других наших коллег тоже возникают вопросы по этому поводу, разве нет?
Да темы не такие уж похожие. И по большей части разъясняют неучам правильные подходы к программированию.
Большая часть вопросов возникает из желания писать код абы как.
Вот в вашем пример есть одно концептуальное недоразумение: у вас template это свойство, а не поле. Но это историческое наследие JS — там просто не было пропертей. И для обращения к полю базового типа надо было к нему явно обращаться. Сейчас добавили синтаксис классов, но способ обращения к элементам и последовательность инициализации никуда не делись.
Это должно быть не «Be Careful», это должно быть «Never ever do this». Я не понимаю, почему это просто не сделали ошибкой компиляции.
Тем не менее весь Turbo Vision (для Pascal) на этом построен — и это реально удобнее, чем в C++ версии…

Попробуйте ответить на свой вопрос сами. Для вашего примера выполните руками пункт 1 (и 1.1) и проведите эксперимент – вставьте вызов конструктора базового класса не в начало. Какую ошибку получим?

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

В JS наследование реализовано с помощью прототипов.
Поэтому в конструкторе наследника сначала полностью конструируется объект базового класса, а потом этот объект дополняется до объекта класса наследника.

Это никак не связано с прототипами.

Убириание синтаксического сахара не поможет. Наприме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

Зря ожидали, потому что эквивалентный код — вот такой:


function A() {
    this.getTwo = () => { return 'two'; }
}
A.prototype.getOne = function () {
    return 'One';
}

Да, я уже понял, что я зря этого ожидал исходя из результатов, которые я получил. Написание правильного эквивалентного кода далеко не самая простая задача, как многие в комментариях тут рассуждают. Мне еще интересно исходя из какой документации/спецификации я могу понять, что правильные код именно ваш. Я конечно могу написать оба и сравнить что они делают, но хочется увидеть официальную спецификацию.

Не знаю насчёт документации, но ИМХО, десахаризация этого кода очевидна исходя из того, какой старый код он призван был заменить.
Просто ненадо выполнять код в конструкторе

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')
Зачем читать документацию, когда её можно не читать, да потом еще и писать по этому поводу статьи на хабре?

Просто кто-то не знает как работает прототипное наследование. Не буду оригинальным — RFM.

А при чём тут прототипное наследование?


Если бы свойство template попало в прототип — то и никакой проблемы бы как раз не было.

Да, виноват, по диагонали код автора прочитал. Там впринципе тупо сделано.


Пожалуй надо было так:


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 все же берется из родителя. В обычном наследовании, однако, результат будет аналогичным.

На самом деле поведение очень даже логичное и понятное. Разберу пример:


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).

Поэтапное создание класса выглядит не совсем так. 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 классов методы и были переменными функциями.
Поэтапное создание класса выглядит не совсем так. 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() в следующем такте исполнения.

И я согласен с автором, что это далеко не очевидно, что инициализация name в B происходит после вызова super().

Это полностью очевидно. Во время инициализации полей наследника предок должен быть инициализирован, т.к. вы имеете право при инициализации вызывать через super методы предка. Если бы предок не был инициализирован, вы бы их вызывать не могли (т.е. методы бы, вообще говоря, вызывались, но возвращали бы дичь, т.к. отрабатывали бы на неинициализированом классе).

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


Но мой основной вопрос всё равно не про это — почему инстанс класса предка сразу бы не инициализировать со значениями полей, переопределёнными в классе-потомке?

Все методы попадают в прототип т.е. все методы уже определены ещё до инициализации любого инстанса. А вот поля в инстансе устанавливаются именно в момент инициализации и именно в порядке из глубины (от самого первого предка).


Но мой основной вопрос всё равно не про это — почему инстанс класса предка сразу бы не инициализировать со значениями полей, переопределёнными в классе-потомке?

По тому, что во первых в 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!
Половина комментаторов нам тут говорят, что при инициализации методы предка дёргать нельзя

Комментаторы вам говорят, что методы дёргать нежелательно, потому что сайд-эффекты в конструкторе всегда были скользким моментом в любой реализации ООП.
Половина комментаторов нам тут говорят, что при инициализации методы предка дёргать нельзя

Методы предка? Конечно можно, с чего бы нет. После вызова super() (а предполагается что он всегда вызывается первым) базовый класс считается инициализированным. А раз он инициализирован — вы можете на нем вызывать что хотите, с чего бы это было проблемой? Вы когда, как в примере, значение поля базового класса в конструкторе меняете — то тоже, формально, вызываете метод предка. Сеттер данного поля.


Но мой основной вопрос всё равно не про это — почему инстанс класса предка сразу бы не инициализировать со значениями полей, переопределёнными в классе-потомке?

Потому что поле не переопределяется. Это одно и то же поле. Оно определено только один раз в базовом классе. В производном классе вы только меняете значение данного поля, причем меняете его в конструкторе производного класса. Запись field = something — это часть конструктора, а не определение.
А раз конструктор производного класса вызывается после конструктора базового — то в конструкторе базового класса поле имеет старое значение.


Это все абсолютно стандартное поведение для ООП-языков.

UFO just landed and posted this here

Как обычно набежала куча любителей самоутверждения чтения документации. Хабру явно не хватает возможности отключать комментарии.


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.

Можно поинтересоваться, какой это язык?
UFO just landed and posted this here
Не вижу в этом ничего хорошего:
class A:
    divisor = 3

    def __init__(self):
        self.res = 15 / self.divisor
        print('A constructor', self.res)

A()

class B(A):
    divisor = 0

B()

Делить на ноль действительно плохо, не делайте так.

Это python и пример просто-напросто показывает, что человек его не знает. Ну потому что:
class BaseTooltip:
    template = 'baseTemplate'

    def __init__(self, content):
        self.render(content)

    def render(self, content):
        print('render:', content, self.template)

base = BaseTooltip('content')

class SpecialTooltip(BaseTooltip):
    template = 'otherTemplate'

derived = SpecialTooltip('otherContent')

SpecialTooltip.template = "I don't know python"

derived.render('finalContent')

#('render:', 'content', 'baseTemplate')
#('render:', 'otherContent', 'otherTemplate')
#('render:', 'finalContent', "I don't know python")


Поля, которые объекту не принадлежат в принципе, разумеется, в конструкторе не инициализируются… а как иначе-то?

Это, вроде как, не совсем то, чего хотел топикстартер…
что человек его не знает

не претендую.


Это, вроде как, не совсем то, чего хотел топикстартер

почему? Вроде именно этого он и хотел. Какие недостатки у такого решения для его задачи?

почему? Вроде именно этого он и хотел.
Всё зависит от того — хочет ли он этот самый template, в какой-то момент, менять.

Какие недостатки у такого решения для его задачи?
Если это свойство не должно меняться, то, в общем-то, никаких. Просто в случае с ES6 (вернее ES6 + куча пропозалов) вы получаете поле объекта, а в python — это-таки поле класса.

Собственно как можно заметить из синтаксиса Python __init__ конструктором, в общем-то, и не является. Потому и вызов конструктора предка нужно осуществлять явно.

Объектная можель Python, в принципе, ближе скорее к Turbo Pascal (про который я уже писал). Тот же __new__ сразу получает финальный класс, никакой «цепочки конструкторов» там в принципе нету.
получаете поле объекта, а в python — это-таки поле класса

ок, в общем-то я и и предлагаю использовать поля класса, на js будет так:


class BaseTooltip {
    static template = 'baseTemplate'
    constructor(content) {
        this.render(content)
    }
    render(content) {
        console.log('render:', content, this.constructor.template)
    }
}

new BaseTooltip('content')

class SpecialTooltip extends BaseTooltip {
    static template = 'otherTemplate'
}

new SpecialTooltip('otherContent')
// render: content baseTemplate
// render: otherContent otherTemplate

Всё зависит от того — хочет ли он этот самый template, в какой-то момент, менять.

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

Все логично. В c++ тоже так. И вообще есть хорошая практика что конструкторы не должны содержать никаких side effect. Но периодически появляются такие статьи как эта

Sign up to leave a comment.

Articles