您必须在 JS 中手动“破坏”对象。创建销毁函数在 JS 中很常见。在其他语言中,这可能被称为 free、release、dispose、close 等。根据我的经验,虽然它往往是 destroy ,这将解开内部引用、事件并可能将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,它返回一个索引来跟踪侦听器,这样您就可以添加原型函数,至少在我看来,这些函数在性能和内存方面应该会更好。不过,您仍然必须跟踪返回的标识符并传递您的对象以确保在调用时将侦听器绑定到它。
destroy 函数会增加一些麻烦。首先是我必须调用它并释放引用:
df.destroy();
v1.destroy();
v2.destroy();
df = v1 = v2 = null;
这是一个小烦恼,因为它的代码有点多,但这不是真正的问题。当我将这些引用传递给许多对象时。在这种情况下,您究竟什么时候调用破坏?您不能简单地将这些交给其他对象。您最终将通过程序流或其他方式获得破坏链和手动实现跟踪。你不能开枪就忘记。
此类问题的一个示例是,如果我决定 View 在 df 被销毁时也会调用 destroy 。如果 v2 仍在销毁 df 将破坏它,因此不能简单地将销毁转发给 df。相反,当 v1 使用 df 使用它时,它需要告诉 df 它已被使用,这将引发一些计数器或类似于 df。 df 的销毁函数会比计数器减少,并且只有在它为 0 时才真正销毁。这种事情增加了很多复杂性,并增加了很多可能出错的地方,其中最明显的是销毁某些东西,而在某处仍有参考将被使用和循环引用(此时它不再是管理计数器的情况,而是引用对象的映射)。当您考虑在 JS 中实现自己的引用计数器、MM 等时,它可能是有缺陷的。
如果 WeakSet 是可迭代的,则可以使用:
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,两者都可以在难以置信的距离上实现)。