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

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

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

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

Ответ 1

Не ошибка

Во-первых, это не ошибка в 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();

Ответ 2

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

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

Base property initializers
Derived property initializers
Base constructor
Derived constructor

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

- В настоящее время компилятор typescript испускает инициализацию свойств в конструкторе

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

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();