繁体   English   中英

ECMAScript 6 类析构函数

[英]ECMAScript 6 class destructor

我知道 ECMAScript 6 有构造函数,但是 ECMAScript 6 有析构函数吗?

例如,如果我在构造函数中将对象的某些方法注册为事件侦听器,我想在对象被删除时将它们删除。

一种解决方案是约定为每个需要这种行为的类创建一个destructor方法并手动调用它。 这将删除对事件处理程序的引用,因此我的对象将真正准备好进行垃圾收集。 否则,由于这些方法,它会留在内存中。

但我希望 ECMAScript 6 有一些本机的东西,可以在对象被垃圾收集之前调用。

如果没有这样的机制,这些问题的模式/约定是什么?

ECMAScript 6 有析构函数这样的东西吗?

不,EcmaScript 6 根本没有指定任何垃圾收集语义[1] ,因此也没有像“破坏”这样的东西。

如果我在构造函数中注册我的一些对象的方法作为事件监听器,我想在我的对象被删除时删除它们

析构函数甚至不会在这里帮助您。 事件侦听器本身仍然引用您的对象,因此在取消注册之前将无法进行垃圾收集。
您实际上正在寻找的是一种注册侦听器而不将它们标记为活动根对象的方法。 (请向您当地的事件源制造商咨询此类功能)。

1):嗯,有一个开始与WeakMapWeakSet对象的规范。 然而,真正的弱引用仍在管道中[1] [2]

我刚刚在搜索析构函数时遇到了这个问题,我认为你的评论中有一个未回答的问题,所以我想我会解决这个问题。

感谢你们。 但是,如果 ECMAScript 没有析构函数,那么好的约定是什么? 我应该创建一个名为 destructor 的方法并在完成对象后手动调用它吗? 还有什么想法吗?

如果你想告诉你的对象你现在已经完成了它并且它应该专门释放它拥有的任何事件监听器,那么你可以创建一个普通的方法来做到这一点。 您可以调用该方法,例如release()deregister()unhook()或类似的任何东西。 这个想法是你告诉对象将自己与它所连接的任何其他东西断开连接(取消注册事件侦听器,清除外部对象引用等......)。 您必须在适当的时候手动调用它。

如果同时您还确保没有其他对该对象的引用,那么您的对象届时将有资格进行垃圾回收。

ES6 确实有 weakMap 和 weakSet ,它们是跟踪一组仍然活着的对象而不影响它们何时可以被垃圾收集的方法,但是当它们被垃圾收集时它不提供任何类型的通知。 它们只是在某个时候从weakMap 或weakSet 中消失(当它们被GC 时)。


仅供参考,您要求的这种类型的析构函数的问题(可能是为什么没有太多调用它)是因为垃圾收集,当一个项目有一个打开的事件处理程序时,它不符合垃圾收集的条件一个活动对象,因此即使有这样的析构函数,在您实际删除事件侦听器之前,它也永远不会在您的情况下被调用。 而且,一旦您删除了事件侦听器,就不需要为此目的使用析构函数。

我想有一个可能的weakListener()不会阻止垃圾收集,但这样的事情也不存在。


仅供参考,这是另一个相关问题为什么垃圾收集语言中的对象析构函数范式普遍存在? . 本讨论涵盖终结器、析构器和处置器设计模式。 我发现看到这三者之间的区别很有用。


2020 年编辑 - 对象终结器提案

有一个阶段 3 EMCAScript 提议在对象被垃圾回收后添加一个用户定义的终结器函数。

可以从此类功能中受益的典型示例是包含打开文件句柄的对象。 如果对象被垃圾回收(因为没有其他代码仍然引用它),那么这个终结器方案允许至少向控制台发送一条消息,表明外部资源刚刚被泄露,并且其他地方的代码应该被修复以防止这个泄漏。

如果您仔细阅读该提案,您会发现它与 C++ 等语言中的成熟析构函数完全不同。 这个终结器在对象已经被销毁之后被调用,你必须预先确定需要将实例数据的哪一部分传递给终结器才能完成它的工作。 此外,此功能并不意味着依赖于正常操作,而是作为调试辅助和针对某些类型的错误的支持。 您可以阅读提案中这些限制的完整说明。

您必须在 JS 中手动“破坏”对象。 创建销毁函数在 JS 中很常见。 在其他语言中,这可能被称为 free、release、dispose、close 等。根据我的经验,虽然它往往是 destroy ,这将解开内部引用、事件并可能传播对子对象的销毁调用。

WeakMaps 在很大程度上是无用的,因为它们不能被迭代,而且这可能要到 ECMA 7 才能使用。 WeakMaps 让你做的所有事情都是从对象本身分离不可见的属性,除了通过对象引用和 GC 进行查找,这样它们就不会干扰它。 这对于缓存、扩展和处理复数很有用,但它对可观察对象和观察者的内存管理并没有真正的帮助。 WeakSet 是 WeakMap 的子集(类似于默认值为 boolean true 的 WeakMap)。

关于是否为 this 或析构函数使用各种弱引用实现存在各种争论。 两者都有潜在的问题,并且析构函数更加有限。

析构函数实际上对观察者/侦听器也可能无用,因为通常侦听器将直接或间接持有对观察者的引用。 析构函数仅在没有弱引用的情况下真正以代理方式工作。 如果您的 Observer 真的只是一个代理,接收其他东西的 Listeners 并将它们放在一个 observable 上,那么它可以在那里做一些事情,但这种事情很少有用。 析构函数更多地用于 IO 相关的事情或在包含范围之外的事情(IE,链接它创建的两个实例)。

我开始研究这个的具体情况是因为我有一个类 A 实例,它在构造函数中接受类 B,然后创建监听 B 的类 C 实例。我总是将 B 实例保持在高处。 AI 有时会丢弃、创建新的、创建许多等等。在这种情况下,析构函数实际上对我有用,但如果我传递 C 实例但删除所有 A 引用然后删除 C 和B 绑定将被破坏(C 已从其下方移除地面)。

在 JS 中没有自动解决方案是痛苦的,但我认为它不容易解决。 考虑这些类(伪):

function Filter(stream) {
    stream.on('data', function() {
        this.emit('data', data.toString().replace('somenoise', '')); // Pretend chunks/multibyte are not a problem.
    });
}
Filter.prototype.__proto__ = EventEmitter.prototype;
function View(df, stream) {
    df.on('data', function(data) {
        stream.write(data.toUpper()); // Shout.
    });
}

附带说明一下,如果没有稍后将介绍的匿名/唯一函数,就很难使事情正常进行。

在正常情况下,实例化会是这样(伪):

var df = new Filter(stdin),
    v1 = new View(df, stdout),
    v2 = new View(df, stderr);

要 GC 这些通常你会将它们设置为 null 但它不会工作,因为它们已经创建了一个以标准输入为根的树。 这基本上就是事件系统所做的。 你给一个孩子一个父母,孩子将自己添加到父母中,然后可能会或可能不会维护对父母的引用。 树是一个简单的例子,但实际上你也可能会发现自己有复杂的图表,尽管很少。

在这种情况下,Filter 以匿名函数的形式向标准输入添加对自身的引用,该函数通过范围间接引用 Filter。 范围引用是需要注意的,并且可能非常复杂。 强大的 GC 可以做一些有趣的事情来分割范围变量中的项目,但这是另一个主题。 理解的关键是,当您创建一个匿名函数并将其作为监听器添加到某事物中时,可观察对象将维护对该函数的引用以及该函数在其上方范围内引用的任何内容(它在) 也将被保留。 视图执行相同的操作,但在执行其构造函数后,子级不会维护对其父级的引用。

如果我将上面声明的任何或所有变量设置为 null 它不会对任何事情产生影响(类似地,当它完成那个“主”范围时)。 它们仍将处于活动状态,并将数据从标准输入传送到标准输出和标准错误。

如果我将它们全部设置为 null,那么如果不清除 stdin 上的事件或将 stdin 设置为 null(假设可以像这样释放它),就不可能将它们删除或 GC。 如果其余代码需要标准输入并且有其他重要事件禁止您执行上述操作,那么您基本上会以这种方式与有效的孤立对象发生内存泄漏。

为了摆脱 df、v1 和 v2,我需要对它们中的每一个调用一个 destroy 方法。 在实现方面,这意味着 Filter 和 View 方法都需要保留对它们创建的匿名侦听器函数的引用以及 observable 并将其传递给 removeListener。

附带说明一下,或者您可以拥有一个可返回索引以跟踪侦听器的 oberable,以便您可以添加原型函数,至少在我看来,这些函数在性能和内存方面应该会更好。 您仍然必须跟踪返回的标识符并传递您的对象以确保侦听器在调用时绑定到它。

销毁函数会增加一些麻烦。 首先是我必须调用它并释放参考:

df.destroy();
v1.destroy();
v2.destroy();
df = v1 = v2 = null;

这是一个小烦恼,因为它需要更多代码,但这不是真正的问题。 当我将这些引用传递给许多对象时。 在这种情况下,您究竟什么时候调用破坏? 您不能简单地将这些交给其他对象。 您最终将通过程序流或其他方式获得破坏链和手动实现跟踪。 你不能开枪就忘记。

此类问题的一个示例是,如果我决定 View 也会在 df 被销毁时调用 destroy 。 如果 v2 仍在销毁 df 将破坏它,因此不能简单地将销毁转发给 df。 相反,当 v1 使用 df 使用它时,它需要告诉 df 它已被使用,这将引发一些计数器或类似于 df。 df 的销毁函数会比计数器减少,并且只有在它为 0 时才真正销毁。这种事情增加了很多复杂性,并增加了很多可能出错的地方,其中最明显的是销毁某些东西,而在某处仍有参考将被使用和循环引用(此时它不再是管理计数器的情况,而是引用对象的映射)。 当您考虑在 JS 中实现自己的引用计数器、MM 等时,它可能是有缺陷的。

如果 WeakSets 是可迭代的,则可以使用:

function Observable() {
    this.events = {open: new WeakSet(), close: new WeakSet()};
}
Observable.prototype.on = function(type, f) {
    this.events[type].add(f);
};
Observable.prototype.emit = function(type, ...args) {
    this.events[type].forEach(f => f(...args));
};
Observable.prototype.off = function(type, f) {
    this.events[type].delete(f);
};

在这种情况下,拥有类还必须保留对 f 的令牌引用,否则它将失败。

如果使用 Observable 而不是 EventListener,那么关于事件监听器的内存管理将是自动的。

而不是在每个对象上调用destroy,这足以完全删除它们:

df = v1 = v2 = null;

如果您没有将 df 设置为 null 它仍然存在,但 v1 和 v2 将自动取消挂钩。

然而,这种方法存在两个问题。

问题之一是它增加了新的复杂性。 有时人们实际上并不想要这种行为。 我可以创建一个非常大的对象链,它们通过事件而不是包含(构造函数范围或对象属性中的引用)相互链接。 最终一棵树,我只需要绕过根部并担心这一点。 释放根可以方便地释放整个事物。 取决于编码风格等的这两种行为都是有用的,当创建可重用的对象时,很难知道人们想要什么,他们做了什么,你做了什么,并且很难解决已经完成的事情。 如果我使用 Observable 而不是 EventListener 则 df 将需要引用 v1 和 v2 或者如果我想将引用的所有权转移给超出范围的其他内容,则必须将它们全部传递。 弱引用之类的东西可以通过将控制从 Observable 转移到观察者来稍微缓解问题,但不会完全解决它(并且需要检查每个发射或事件本身)。 我想如果该行为仅适用于会使 GC 严重复杂化的孤立图,并且不适用于在图外存在实际上是 noops 的引用的情况(仅消耗 CPU 周期,未进行任何更改),则可以解决此问题。

问题二是在某些情况下它是不可预测的,或者迫使 JS 引擎根据需要遍历那些对象的 GC 图,这可能会产生可怕的性能影响(尽管如果它很聪明,它可以避免每个成员都这样做而是 WeakMap 循环)。 如果内存使用量没有达到某个阈值并且对象及其事件不会被删除,则 GC 可能永远不会运行。 如果我将 v1 设置为 null,它可能仍会永远中继到标准输出。 即使它确实得到了 GC,这也是任意的,它可能会继续中继到标准输出任意时间(1 行、10 行、2.5 行等)。

WeakMap 在不可迭代时不关心 GC 的原因是,要访问一个对象,无论如何你都必须引用它,因此要么它没有被 GC,要么没有被添加到地图中。

我不确定我对这种事情的看法。 您有点破坏内存管理以使用可迭代的 WeakMap 方法修复它。 析构函数也可能存在问题二。

所有这一切都引发了几个层次的地狱,所以我建议尝试通过良好的程序设计、良好的实践、避免某些事情等来解决它。但是在 JS 中可能会令人沮丧,因为它在某些方面非常灵活,并且因为它更自然地是异步的和基于事件的,具有大量的控制反转。

还有另一种相当优雅的解决方案,但仍然存在一些潜在的严重挂断。 如果您有一个扩展了可观察类的类,则可以覆盖事件函数。 仅当将事件添加到您自己时,才将您的事件添加到其他可观察对象。 当所有事件都从您身上移除后,再从孩子身上移除您的事件。 您还可以创建一个类来扩展您的可观察类来为您执行此操作。 这样的类可以为空和非空提供挂钩,因此您将在观察自己。 这种方法还不错,但也有挂断。 复杂性增加,性能下降。 您必须保留对您观察到的对象的引用。 至关重要的是,它也不适用于叶子,但如果你破坏叶子,至少中间体会自毁。 这就像链接破坏但隐藏在您已经必须链接的调用后面。 然而,一个很大的性能问题是,每次你的类变得活跃时,你可能必须从 Observable 重新初始化内部数据。 如果此过程需要很长时间,那么您可能会遇到麻烦。

如果您可以迭代 WeakMap,那么您也许可以组合事物(在没有事件时切换到弱,在事件时切换到强),但真正要做的就是将性能问题放在其他人身上。

当涉及到行为时,可迭代的 WeakMap 也有直接的烦恼。 我之前简要提到过具有范围引用和雕刻的函数。 如果我在将侦听器“console.log(param)”挂钩到父级的构造函数中实例化一个子级并且无法持久化父级,那么当我删除对子级的所有引用时,它可以完全释放,因为添加到parent 没有从孩子内部引用任何内容。 这就留下了如何处理 parent.weakmap.add(child, (param) => console.log(param)) 的问题。 据我所知,键是弱的,但不是值,所以 weakmap.add(object, object) 是持久的。 这是我需要重新评估的东西。 对我来说,如果我处理所有其他对象引用,这看起来像是内存泄漏,但我怀疑实际上它基本上通过将其视为循环引用来管理它。 匿名函数要么维护对从父范围产生的对象的隐式引用,以保持一致性,从而浪费大量内存,要么您的行为根据难以预测或管理的情况而变化。 我认为前者实际上是不可能的。 在后一种情况下,如果我在一个类上有一个方法,它只接受一个对象并添加 console.log,即使我返回了该函数并维护了一个引用,当我清除对该类的引用时,它也会被释放。 公平地说,这种特殊场景很少合法地需要,但最终有人会找到一个角度并要求一个可迭代的 HalfWeakMap(释放键和值引用时免费)但也是不可预测的(obj = null 神奇地结束 IO, f = null 神奇地结束 IO,两者都可以在难以置信的距离上实现)。

如果没有这样的机制,这些问题的模式/约定是什么?

术语“清理”可能更合适,但将使用“析构函数”来匹配 OP

假设您完全使用 'function's 和 'var's 编写一些 javascript。 然后你可以使用在try / catch / finally格的框架内编写所有function代码的模式。 finally执行销毁代码。

而不是 C++ 编写具有未指定生命周期的对象类的风格,然后通过任意范围指定生命周期并在范围结束时对~()的隐式调用( ~()在 C++ 中是析构函数),在这个 JavaScript 模式中,对象是函数,作用域就是函数作用域,析构函数就是finally块。

如果您现在认为这种模式本质上是有缺陷的,因为try / catch / finally不包含对 javascript 必不可少的异步执行,那么您是正确的。 幸运的是,自 2018 年以来,异步编程助手对象Promise finally在已经存在的resolvecatch原型函数中添加了一个原型函数。 这意味着需要析构函数的异步作用域可以用Promise对象编写,使用finally作为析构函数。 此外,您可以在调用Promiseasync function中使用try / catch / finally ,带或不带await ,但必须注意,不带 await 调用的Promise将在范围外异步执行,因此在 final then中处理析构函数代码。

在下面的代码中, PromiseAPromiseB是一些没有指定finally函数参数的遗留 API 级别的承诺。 PromiseC确实定义了一个 finally 参数。

async function afunc(a,b){
    try {
        function resolveB(r){ ... }
        function catchB(e){ ... }
        function cleanupB(){ ... }
        function resolveC(r){ ... }
        function catchC(e){ ... }
        function cleanupC(){ ... }
        ...
        // PromiseA preced by await sp will finish before finally block.  
        // If no rush then safe to handle PromiseA cleanup in finally block 
        var x = await PromiseA(a);
        // PromiseB,PromiseC not preceded by await - will execute asynchronously
        // so might finish after finally block so we must provide 
        // explicit cleanup (if necessary)
        PromiseB(b).then(resolveB,catchB).then(cleanupB,cleanupB);
        PromiseC(c).then(resolveC,catchC,cleanupC);
    }
    catch(e) { ... }
    finally { /* scope destructor/cleanup code here */ }
}

我并不是提倡将 javascript 中的每个对象都写成一个函数。 相反,请考虑这样一种情况:您确定了一个范围,该范围确实“希望”在其生命周期结束时调用析构函数。 将该作用域表述为函数对象,使用模式的finally块(或在异步作用域的情况下为finally函数)作为析构函数。 很可能制定该功能对象消除了对非功能类的需求,否则该类将被编写 - 不需要额外的代码,对齐范围和类甚至可能更干净。

注意:正如其他人所写,我们不应该混淆析构函数和垃圾收集。 碰巧 C++ 析构函数通常或主要关注手动垃圾收集,但并非完全如此。 Javascript 不需要手动垃圾收集,但异步范围的生命周期结束通常是(取消)注册事件侦听器等的地方。

“在这里,析构函数甚至帮不了你。事件监听器本身仍然在引用你的对象,所以在它们被取消注册之前它无法被垃圾回收。”

不是这样。 析构函数的目的是允许注册监听器的项目取消注册它们。 一旦一个对象没有其他对它的引用,它将被垃圾收集。

例如,在 AngularJS 中,当一个控制器被销毁时,它可以监听一个销毁事件并做出响应。 这与自动调用析构函数不同,但它很接近,让我们有机会删除在控制器初始化时设置的侦听器。

// Set event listeners, hanging onto the returned listener removal functions
function initialize() {
    $scope.listenerCleanup = [];
    $scope.listenerCleanup.push( $scope.$on( EVENTS.DESTROY, instance.onDestroy) );
    $scope.listenerCleanup.push( $scope.$on( AUTH_SERVICE_RESPONSES.CREATE_USER.SUCCESS, instance.onCreateUserResponse ) );
    $scope.listenerCleanup.push( $scope.$on( AUTH_SERVICE_RESPONSES.CREATE_USER.FAILURE, instance.onCreateUserResponse ) );
}

// Remove event listeners when the controller is destroyed
function onDestroy(){
    $scope.listenerCleanup.forEach( remove => remove() );
}


干得好。 如果订阅/发布对象超出范围并被垃圾收集, Subscribe/Publish对象将自动unsubscribe回调函数。

const createWeakPublisher = () => {
  const weakSet = new WeakSet();
  const subscriptions = new Set();

  return {
    subscribe(callback) {
      if (!weakSet.has(callback)) {
        weakSet.add(callback);
        subscriptions.add(new WeakRef(callback));
      }

      return callback;
    },

    publish() {
      for (const weakRef of subscriptions) {
        const callback = weakRef.deref();
        console.log(callback?.toString());

        if (callback) callback();
        else subscriptions.delete(weakRef);
      }
    },
  };
};

虽然它可能不会在回调函数超出范围后立即发生,或者根本不会发生。 有关更多详细信息,请参阅weakRef文档。 但它对我的用例来说就像一个魅力。

您可能还想查看FinalizationRegistry API 以了解不同的方法。

Javascript 不像 C++ 那样进行解构。 相反,应该使用替代设计模式来管理资源。 这里有几个例子:

您可以限制用户在回调期间使用该实例,之后会自动清理该实例。 (这种模式类似于 Python 中深受喜爱的“with”语句)

connectToDatabase(async db => {
  const resource = await db.doSomeRequest()
  await useResource(resource)
}) // The db connection is closed once the callback ends

当上面的例子过于严格时,另一种选择是只创建显式的清理函数。

const db = makeDatabaseConnection()

const resource = await db.doSomeRequest()
updatePageWithResource(resource)

pageChangeEvent.addListener(() => {
  db.destroy()
})

其他答案已经详细解释了没有析构函数。 但是您的实际目标似乎与事件有关。 您有一个连接到某个事件的对象,并且您希望此连接在对象被垃圾回收时自动消失。 但这不会发生,因为事件订阅本身引用了侦听器函数。 好吧,除非你使用这个漂亮的新WeakRef东西。

这是一个例子:

<!DOCTYPE html>
<html>
  <body>
    <button onclick="subscribe()">Subscribe</button>
    <button id="emitter">Emit</button>
    <button onclick="free()">Free</button>
    <script>

    const emitter = document.getElementById("emitter");
    let listener = null;

    function addWeakEventListener(element, event, callback) {
        // Weakrefs only can store objects, so we put the callback into an object
        const weakRef = new WeakRef({ callback });
        const listener = () => {
            const obj = weakRef.deref();
            if (obj == null) {
                console.log("Removing garbage collected event listener");
                element.removeEventListener(event, listener);
            } else {
                obj.callback();
            }
        };
        element.addEventListener(event, listener);
    }

    function subscribe() {
        listener = () => console.log("Event fired!");
        addWeakEventListener(emitter, "click", listener);
        console.log("Listener created and subscribed to emitter");
    }

    function free() {
        listener = null;
        console.log("Reference cleared. Now force garbage collection in dev console or wait some time before clicking Emit again.");
    }

    </script>
  </body>
</html>

( JSFiddle )

单击订阅按钮会创建一个新的侦听器函数并将其注册到Emit按钮的单击事件中。 因此,之后单击Emit按钮会向控制台打印一条消息。 现在单击Free按钮,它只是将侦听器变量设置为 null,以便垃圾收集器可以删除侦听器。 等待一段时间或在开发者控制台中强制收集垃圾,然后再次单击Emit按钮。 包装侦听器函数现在看到实际侦听器(包装在 WeakRef 中)不再存在,然后从按钮中取消订阅。

WeakRefs 非常强大,但请注意,不能保证您的东西是否以及何时被垃圾收集。

标题中所述问题的答案是FinalizationRegistry ,自 Firefox 79(2020 年 6 月)、Chrome 84 及其衍生版本(2020 年 7 月)、Safari 14.1(2021 年 4 月)和 Node 14.6.0(2020 年 7 月)起可用……但是,本机JS 析构函数可能不是您用例的正确解决方案

function create_eval_worker(f) {
    let src_worker_blob = new Blob([f.toString()], {type: 'application/javascript'});
    let src_worker_url = URL.createObjectURL(src_worker_blob);

    async function g() {
        let w = new Worker(src_worker_url);
        …
    }

    // Run URL.revokeObjectURL(src_worker_url) as a destructor of g
    let registry = new FinalizationRegistry(u => URL.revokeObjectURL(u));
    registry.register(g, src_worker_url);

    return g;
    }
}

警告:

尽可能避免

正确使用 FinalizationRegistry 需要仔细考虑,如果可能,最好避免使用。 何时、如何以及是否发生垃圾收集取决于任何给定 JavaScript 引擎的实现。 您在一个引擎中观察到的任何行为可能在另一个引擎中、在同一引擎的另一个版本中、甚至在同一引擎的相同版本中略有不同的情况下有所不同。

开发人员不应依赖清理回调来实现基本程序逻辑。 清理回调可能有助于减少整个程序过程中的内存使用,但在其他情况下不太可能有用。

一个符合标准的 JavaScript 实现,即使是垃圾回收,也不需要调用清理回调。 何时以及是否这样做完全取决于 JavaScript 引擎的实现。 当一个注册的对象被回收时,它的任何清理回调可能会被调用,或者稍后,或者根本不调用。

——Mozilla 开发者网络

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM