Почему значения свойств производного класса не видны в конструкторе базового класса?

Я написал код:

class Base {
    // Default value
    myColor = 'blue';

    constructor() {
        console.log(this.myColor);
    }
}

class Derived extends Base {
     myColor = 'red'; 
}

// Prints "blue", expected "red"
const x = new Derived();

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

Это ошибка? Что случилось? Почему это происходит? Что мне делать вместо этого?


person Ryan Cavanaugh    schedule 24.04.2017    source источник
comment
Решение: не выполнять побочные эффекты в конструкторах   -  person Bergi    schedule 24.04.2017


Ответы (2)


Не ошибка

Во-первых, это не ошибка в TypeScript, Babel или вашей среде выполнения JS.

Почему так должно быть

Первый вопрос, который у вас может возникнуть, — «Почему бы не сделать это правильно!?!?». Давайте рассмотрим конкретный случай испускания TypeScript. Фактический ответ зависит от того, для какой версии ECMAScript мы создаем код класса.

Эмиссия нижнего уровня: ES3/ES5

Давайте рассмотрим код, созданный TypeScript для ES3 или ES5. Я упростил + немного аннотировал это для удобства чтения:

var Base = (function () {
    function Base() {
        // BASE CLASS PROPERTY INITIALIZERS
        this.myColor = 'blue';
        console.log(this.myColor);
    }
    return Base;
}());

var Derived = (function (_super) {
    __extends(Derived, _super);
    function Derived() {
        // RUN THE BASE CLASS CTOR
        _super();

        // DERIVED CLASS PROPERTY INITIALIZERS
        this.myColor = 'red';

        // Code in the derived class ctor body would appear here
    }
    return Derived;
}(Base));

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

Является ли производный класс правильным?

Нет, вы должны поменять порядок

Многие люди утверждают, что эмиссия производного класса должна выглядеть так:

    // DERIVED CLASS PROPERTY INITIALIZERS
    this.myColor = 'red';

    // RUN THE BASE CLASS CTOR
    _super();

Это очень неправильно по целому ряду причин:

  • У него нет соответствующего поведения в ES6 (см. следующий раздел).
  • Значение 'red' для myColor будет немедленно перезаписано значением базового класса «синий».
  • Инициализатор поля производного класса может вызывать методы базового класса, которые зависят от инициализации базового класса.

Что касается последнего пункта, рассмотрите этот код:

class Base {
    thing = 'ok';
    getThing() { return this.thing; }
}
class Derived extends Base {
    something = this.getThing();
}

Если бы инициализаторы производного класса выполнялись до инициализаторов базового класса, Derived#something всегда было бы undefined, хотя очевидно, что должно быть 'ok'.

Нет, вы должны использовать машину времени

Многие другие люди возразят, что нужно сделать туманное что-то еще, чтобы Base знал, что Derived имеет инициализатор поля.

Вы можете написать примеры решений, которые зависят от знания всего кода, который нужно запустить. Но TypeScript/Babel/etc не может гарантировать, что это существует. Например, Base может находиться в отдельном файле, где мы не можем видеть его реализацию.

Эмиссия нижнего уровня: ES6

Если вы этого еще не знали, пришло время узнать: классы не являются функцией TypeScript. Они являются частью ES6 и имеют определенную семантику. Но классы ES6 не поддерживают инициализаторы полей, поэтому они преобразуются в код, совместимый с ES6. Это выглядит так:

class Base {
    constructor() {
        // Default value
        this.myColor = 'blue';
        console.log(this.myColor);
    }
}
class Derived extends Base {
    constructor() {
        super(...arguments);
        this.myColor = 'red';
    }
}

Вместо

    super(...arguments);
    this.myColor = 'red';

Должны ли мы иметь это?

    this.myColor = 'red';
    super(...arguments);

Нет, потому что это не работает. Недопустимо ссылаться на this перед вызовом super в производном классе. Это просто не может работать таким образом.

ES7+: Публичные поля

Комитет TC39, контролирующий JavaScript, изучает возможность добавления инициализаторов полей в будущую версию языка.

Вы можете прочитать об этом на GitHub или ознакомьтесь с конкретным вопросом о порядке инициализации.

Обновление ООП: виртуальное поведение от конструкторов

Все языки ООП имеют общее руководство, некоторые из которых применяются явно, некоторые неявно по соглашению:

Не вызывать виртуальные методы из конструктора

Примеры:

В JavaScript мы должны немного расширить это правило.

Не наблюдайте виртуальное поведение от конструктора

а также

Инициализация свойства класса считается виртуальной

Решения

Стандартное решение — преобразовать инициализацию поля в параметр конструктора:

class Base {
    myColor: string;
    constructor(color: string = "blue") {
        this.myColor = color;
        console.log(this.myColor);
    }
}

class Derived extends Base {
    constructor() {
        super("red");
     }
}

// Prints "red" as expected
const x = new Derived();

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

class Base {
    myColor: string;
    constructor() {
        this.init();
        console.log(this.myColor);
    }
    init() {
        this.myColor = "blue";
    }
}

class Derived extends Base {
    init() {
        super.init();
        this.myColor = "red";
    }
}

// Prints "red" as expected
const x = new Derived();
person Ryan Cavanaugh    schedule 24.04.2017
comment
Вместо того, чтобы использовать вывод транспилятора ES3/ES5 для объяснения, этого должно быть достаточно, чтобы преобразовать инициализатор поля класса в правильный явный конструктор. - person Bergi; 24.04.2017
comment
Я бы сказал, что это довольно многословный способ объяснить такую ​​простую вещь. Это просто «супер всегда идет первым». Термин «ES7» устарел, теперь это ES.next. Учитывая, что это вопрос, на который вы сами отвечаете бесплатно, пример в исходном вопросе не очень красноречив. Обычный вопрос, вероятно, будет отклонен, потому что он не может получить конструктивный ответ, во фрагменте отсутствует контекст, и неясно, почему ОП делает то, что он / она делает. - person Estus Flask; 24.04.2017
comment
Я написал это, потому что люди бесконечно запутались в системе отслеживания ошибок TypeScript GitHub github.com/Microsoft/TypeScript/issues/1617 и отказываюсь принимать простое объяснение (мой супер-первый комментарий там в настоящее время находится с 7 реакциями "палец вниз") - person Ryan Cavanaugh; 24.04.2017
comment
Еще одно возможное решение в зависимости от потребностей разработчика — использовать InversifyJS и IoC для инициализации любого класса, который им нужен, со свойствами, введенными при построении. Но опять же это не значит, что все нужно инжектить, зависит от варианта использования. - person juan garcia; 14.02.2018
comment
Ряд объектно-ориентированных языков (Java, C#/VB.NET, C++ и т. д.) требуют, чтобы сначала вызывался/выполнялся ctor базового типа (и другая инициализация). В этом аспекте TS ведет себя в соответствии с общепринятыми шаблонами подтипов ... при условии, что существуют языки с контрпримерами и [злоупотребление] использованием таких разных конструкций, как они позволяют:} - person user2864740; 25.08.2018
comment
если «люди очень запутались», это означает, что синтаксис языка очень запутан... Хотя это имеет смысл в отношении обратной совместимости с классами ES6, это не имеет смысла с точки зрения разработчика. Быть технически правильным и быть полезным — разные вещи. - person Jack Murphy; 09.02.2019
comment
Только что столкнулся с этой проблемой (в год от Рождества Христова 2021). Хотя я (как и многие другие) нахожу странным, что ООП Typescript работает таким образом, спасибо @RyanCavanaugh за предоставленный рабочий пример, который остальные могут обобщить. - person semore_1267; 05.05.2021

Я бы с уважением сказал, что это, на самом деле, ошибка

Делая неожиданные вещи, это нежелательное поведение, которое нарушает распространенные варианты использования расширения класса. Вот порядок инициализации, который будет поддерживать ваш вариант использования, и я бы сказал, что он лучше:

Base property initializers
Derived property initializers
Base constructor
Derived constructor

Проблемы / Решения

- Компилятор машинописного текста в настоящее время выдает инициализацию свойств в конструкторе

Решение здесь состоит в том, чтобы отделить инициализацию свойств от вызова функций конструктора. C# делает это, хотя базовые свойства начинаются после производных свойств, что также противоречит здравому смыслу. Это может быть достигнуто путем создания вспомогательных классов, чтобы производный класс мог инициализировать базовый класс в произвольном порядке.

class _Base {
    ctor() {
        console.log('base ctor color: ', this.myColor);
    }

    initProps() {
        this.myColor = 'blue';
    }
}
class _Derived extends _Base {
    constructor() {
        super();
    }

    ctor() {
        super.ctor();
        console.log('derived ctor color: ', this.myColor);
    }

    initProps() {
        super.initProps();
        this.myColor = 'red';
    }
}

class Base {
    constructor() {
        const _class = new _Base();
        _class.initProps();
        _class.ctor();
        return _class;
    }
}
class Derived {
    constructor() {
        const _class = new _Derived();
        _class.initProps();
        _class.ctor();
        return _class;
    }
}

// Prints:
// "base ctor color: red"
// "derived ctor color: red"
const d = new Derived();

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

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

class Base {
    protected numThings = 5;

    constructor() {
        console.log('math result: ', this.doMath())
    }

    protected doMath() {
        return 10/this.numThings;
    }
}

class Derived extends Base {
    // Overrides. Would cause divide by 0 in base if we weren't overriding doMath
    protected numThings = 0;

    protected doMath() {
        return 100 + this.numThings;
    }
}

// Should print "math result: 100"
const x = new Derived();
person frodo2975    schedule 02.10.2018
comment
Предлагаемая вами эмиссия нарушает instanceof, а также предполагает, что все базовые классы будут написаны на TypeScript, что не так. - person Ryan Cavanaugh; 02.10.2018
comment
Хм, вы правы насчет instanceof. Будут ли какие-либо проблемы с простой заменой имени класса на имя вспомогательного класса во время компиляции? Например, компилятор заменит instanceof Derived на instanceof _Derived. - person frodo2975; 02.10.2018
comment
Для расширения сторонних библиотек нет возможности контролировать порядок инициализации, поэтому он будет работать так, как сегодня. - person frodo2975; 02.10.2018
comment
Итак, теперь у вас есть один порядок инициализации для классов TypeScript, когда класс и базовый класс находятся в одной компиляции, и другой порядок инициализации, когда они не совпадают. И вы должны переписать имя класса во всех местах, и сказать потребителям JS вашего кода (иногда!) ссылаться на _Derived вместо Derived. - person Ryan Cavanaugh; 02.10.2018
comment
И он по-прежнему не соответствует предложенному порядку инициализации полей ECMAScript, поэтому, когда эта функция находится в вашей среде выполнения, ваш класс меняет поведение в зависимости от того, передается ли он на более низкий уровень или нет. - person Ryan Cavanaugh; 02.10.2018
comment
Я не слишком беспокоюсь о потребителях js, потому что typescript уже поддерживает исходную карту. Я бы не ожидал, что кто-то будет читать испускаемый вывод очень часто. Typescript уже делает несколько сложных вещей для реализации наследования. Если ECMAScript идет с другим порядком, то я согласен, что машинописный текст должен идти с ним для согласованности, но было бы здорово, если бы они шли с этим порядком. - person frodo2975; 02.10.2018