起因
最近在看 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)完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄漏问题。
这里有几个点:
- 「资源」。这里的资源主要是指操作系统中有限的东西如内存、网络套接字等等。
- 是它所表达的约定。所有资源,应该在构造函数中获取和初始化,并在析构函数中释放。
这里贴一段 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 WeakRef
和 FinalizationRegistry
WeakRef
其实很好理解,它直接提供了弱引用的渠道,避免了滥用 WeakSet
和 WeakMap
。FinalizationRegistry
通过官方文档来看,其实它不是一个 Finalizer
的实现,而是类似 Java 的 Cleaner
。ES 规范不对注册的 Finalizer
提供任何调用保证。FinalizationRegistry
不应该用于任何实际的析构逻辑,而是仅仅用作记录 Log 的作用,为内存调优提供了 Javascript API。当然,
FinalizationRegistry
作为手动调用 dispose()
方法的兜底,用作防御性编程,也是可以考虑的。但是我个人并不建议这么做,我还是觉得这是一个增加复杂度的东西。
感谢观看。
参考资料
- wikipedia
Loading Comments...