Javascript 中的析构

Javascript 中的析构

Tags
Javascript
Code Complete2
Published
Nov 21, 2023
 

起因

最近在看 Code Complete 2,里面介绍了一些传统静态语言的析构。
联想到 Javascript,好像并没有析构的概念,于是 Google 了一下,发现了这个帖子。
 
 
里面大家的理解还是有一些差异的,围绕在 GC 和析构相关,有很大的争议。
结合自己之前学习 C# 和使用 three.js 时候的经验,决定把这些问题整理出来,于是有了这篇文章。

RAII

讲析构就不能不提 RAII,RAII 是 Resource acquisition is initialization 的缩写,直接翻译就是「资源获取即初始化」。
 
RAII 是一个 programming idiom。
programming idiom 表示了一组编程行为或规则,用于更快速的和其他程序员达成共识。
 
RAII 要求,资源的有效期与持有资源的对象的生命期(life time)严格绑定,即由对象的构造函数完成资源的分配(获取),同时由析构函数(finalizer)完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄漏问题。
 
这里有几个点:
  1. 「资源」。这里的资源主要是指操作系统中有限的东西如内存、网络套接字等等。
  1. 是它所表达的约定。所有资源,应该在构造函数中获取和初始化,并在析构函数中释放。
 
这里贴一段 C++ 代码,我不懂 C++,只是方便大家理解。
基本的意思,就是在构造函数里面初始化成员变量,分配内存,然后在 ~ 开头的这个析构函数里面释放他们,析构函数会由语言内部的机制来调用,无需手动调用。
#include <iostream> using namespace std; class person { public: person(const std::string name = "", int age = 0) : name_(name), age_(age) { std::cout << "Init a person!" << std::endl; } ~person() { std::cout << "Destory a person!" << std::endl; } const std::string& getname() const { return this->name_; } int getage() const { return this->age_; } private: const std::string name_; int age_; }; int main() { person p; return 0; } 编译并运行: g++ person.cpp -o person ./person 运行结果: Init a person! Destory a person!
 
所以,V2ex 帖子争论的要点是,他们对资源的理解不一致。
对于现代常用的 GC 语言,是可以做到内存的自动「析构(释放、回收)」,但是不能做到其他资源自动「析构(释放、回收)」。
GC 语言可以做到内存的自动释放,但是如果代码使用了非托管的资源,如窗口、文件和网络连接 (副作用),GC 就无能为力了。
这里的非托管的资源是 C# 的概念,表示的是不由 VM 托管的资源,比 GC 的范围要大一点。
 

RAII 和 GC

早期的 GC 语言的设计,如 Java 和 C#,都参照 C++,提供了析构函数。
 
下面是 C# 的析构语法。
 
C# 提供了一个和 class 同名的函数,不包含类型、返回值、可见性声明,由 ~ 为前缀。
析构函数由 GC 调用,在 GC 回收对象之后,会尝试调用析构函数来确保 RAII。
class Car { ~Car() // finalizer { // cleanup statements... } } class Wang : System.IDisposable { public void Dispose() { } ~Wang() { } }
 
一切设计的很美好,但是实际使用中大家发现,事情没有那么简单。
由于 GC 是复杂的,而析构函数是由 GC 调用的,这样就会导致析构和程序的执行顺序割裂,用前端的话说,由于析构的执行是异步的,且不可预测的,会出现大量的问题。
比如,滥用析构函数,导致的问题。比如和 DI (依赖注入) 冲突,导致的问题。比如析构函数报错,等等。这些最终会导致应用程序复杂度提高和可靠性大幅度降低。
 
最终,人们意识到,对于 GC 语言的最佳实践,不是 RAII,而是通过在代码中手动调用,来清理非托管资源。
 
Java 在 Java 9 废弃了析构函数,转而提供 Cleaner。
C# 在 .NET core 的规范里面,也不再承诺在应用程序退出前,自动调用所有析构函数。应该由程序代码手动调用。
 

怎么手动调用

C# 提供了 System.IDisposable 接口和 System.IAsyncDisposable 接口。
IDisposable 接口要求实现 Dispose() 函数来执行手动清理。
IAsyncDisposable 接口要求实现 DisposeAsync() 函数来执行异步的手动清理。
两个接口只能实现一个。
 
对于 Javascript,也应该遵守这个约定,three.js 的清理一样是使用了 dispose() 方法。
 
dispose() 方法可以确保稳定的析构,在调用 dispose() 方法之后,我们可以毫无顾忌的继续实例化新的实例,而不必担心内存泄露和 Heap Overflow。
 
遗忘 dispose() 调用的问题其实比 Finalizer 更好发现,复杂度更低。
 

关于新的 ES API WeakRefFinalizationRegistry

WeakRef 其实很好理解,它直接提供了弱引用的渠道,避免了滥用 WeakSetWeakMap
 
FinalizationRegistry 通过官方文档来看,其实它不是一个 Finalizer 的实现,而是类似 Java 的 Cleaner 。ES 规范不对注册的 Finalizer 提供任何调用保证。
 
FinalizationRegistry 不应该用于任何实际的析构逻辑,而是仅仅用作记录 Log 的作用,为内存调优提供了 Javascript API。
当然,FinalizationRegistry 作为手动调用 dispose() 方法的兜底,用作防御性编程,也是可以考虑的。
 
但是我个人并不建议这么做,我还是觉得这是一个增加复杂度的东西。
 
 
 
感谢观看。
 
 
参考资料
  1. https://zhuanlan.zhihu.com/p/34660259
  1. https://www.ithome.com.tw/voice/129618
  1. https://www.lagagain.com/post/這些那些你可能不知道我不知道的web技術細節29/
  1. wikipedia

Loading Comments...