为啥在基类构造函数中看不到派生类属性值?

Posted

技术标签:

【中文标题】为啥在基类构造函数中看不到派生类属性值?【英文标题】:Why are derived class property values not seen in the base class constructor?为什么在基类构造函数中看不到派生类属性值? 【发布时间】:2017-09-21 14:21:57 【问题描述】:

我写了一些代码:

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 属性,因此我观察到构造函数中的错误值。

这是一个错误吗?怎么了?为什么会这样?我应该怎么做?

【问题讨论】:

解决方案:不要在构造函数中执行副作用 【参考方案1】:

不是错误

首先,这不是 TypeScript、Babel 或您的 JS 运行时中的错误。

为什么要这样

您的第一个跟进可能是“为什么不这样做正确!?!?”。让我们来看看 TypeScript emit 的具体情况。实际答案取决于我们为哪个版本的 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));

基类 emit 毫无争议是正确的 - 字段被初始化,然后构造函数主体运行。您当然不想要相反的情况 - 在运行构造函数主体 before 初始化字段意味着在构造函数 after 之前您无法看到字段值,这不是什么任何人都想要。

派生类发出正确吗?

不,你应该交换订单

很多人会争辩说,派生类 emit 应该是这样的:

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

    // RUN THE BASE CLASS CTOR
    _super();

这是非常错误的,原因有很多:

它在 ES6 中没有对应的行为(见下一节) myColor 的值 'red' 将立即被基类值“blue”覆盖 派生类字段初始化程序可能会调用依赖于基类初始化的基类方法。

关于最后一点,请考虑以下代码:

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

不,因为它不起作用。在派生类中调用super 之前引用this 是非法的。它根本无法以这种方式工作。

ES7+:公共字段

控制 javascript 的 TC39 委员会正在研究将字段初始化器添加到该语言的未来版本中。

您可以read about it on GitHub 或read the specific issue about initialization order。

OOP 复习:构造函数的虚拟行为

所有 OOP 语言都有一个通用准则,有些是明确强制执行的,有些是按照约定隐式执行的:

不要从构造函数调用虚方法

例子:

C#Virtual member call in a constructor C++ Calling virtual functions inside constructors 蟒蛇Calling member functions from a constructor Java Is it OK to call abstract method from constructor in Java?

在 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 模式,但您需要小心从中观察虚拟行为并且不要在派生的@987654346 中执行操作@ 需要对基类进行完整初始化的方法:

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

【讨论】:

不使用 ES3/ES5 转译器输出进行解释,将类字段初始化器脱糖为适当的显式构造函数就足够了 我会说,解释这么简单的事情的方式非常冗长。这只是'超级总是先行'。 “ES7”这个词已经过时了,现在是 ES.next。考虑到这是无偿的自我回答问题,原始问题中的示例不是很有说服力。常规问题可能会被否决,因为它无法得到建设性的答案,sn-p 缺乏上下文并且不清楚为什么 OP 会做他/她所做的事情。 我写这篇文章是因为人们在 TypeScript GitHub 问题跟踪器github.com/Microsoft/TypeScript/issues/1617 上无休止地对此感到困惑,并拒绝接受简单的解释(我的“超级优先”评论)目前有 7 个“不满意”的反应) 根据开发人员的需要,另一种可能的解决方案是使用 InversifyJS 和 IoC 通过构造注入的属性来初始化他们需要的任何类。但这并不意味着应该注入所有内容,具体取决于用例。 如果“人们非常困惑”,则意味着语言语法非常混乱……虽然这在与 ES6 类的向后兼容性方面是有道理的,但从开发人员的角度来看却没有意义。技术上正确和有用是不同的事情。【参考方案2】:

我会恭敬地说这实际上是一个错误

通过做意想不到的事情,这是破坏常见类扩展用例的不良行为。这是支持您的用例并且我认为更好的初始化顺序:

Base property initializers
Derived property initializers
Base constructor
Derived constructor

问题/解决方案

- typescript 编译器当前在构造函数中发出属性初始化

这里的解决方案是将属性初始化与构造函数的调用分开。 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();

【讨论】:

您提议的发射中断 instanceof 并且还假设所有基类都将使用 TypeScript 编写,但事实并非如此。 嗯,你是对的 instanceof。在编译时将类名换成辅助类名会有什么问题吗?比如,编译器会将instanceof Derived 替换为instanceof _Derived 对于扩展 3rd 方库,没有办法控制初始化顺序,所以它会像现在一样运行。 所以现在你有一个 TypeScript 类的初始化顺序,其中类和基类在同一个编译中,当它们不在时,初始化顺序不同。而且您必须在所有位置重写类名,告诉 JS 使用者您的代码(有时!)引用 _Derived 而不是 Derived 而且它仍然不符合建议的 ECMAScript 字段初始化顺序,因此当该功能在您的运行时中时,您的类会根据它是否被下级转译而改变行为

以上是关于为啥在基类构造函数中看不到派生类属性值?的主要内容,如果未能解决你的问题,请参考以下文章

如何在基类构造函数中使用派生类成员

派生类在基类中删除时是不是会有隐式复制构造函数或赋值运算符?

基类构造函数的派生类成员初始化

在基类体内声明但通过派生类调用的函数的内联

请问含有多个对象成员的派生类的构造函数执行时不是先执行基类么?为啥这个先输出的是“正式生是”这个

生成一个派生类对象时,调用基类和派生类构造函数按啥次序