谈谈 ZexVM 和 Saby - MaxXing 的暑期技术报告
文章版权所有:MaxXing。
转载须注明来源及原作者,侵权必究!
导言:
轮子哥(@vczh)曾有一言,为我们指出了他心中的程序员的三大浪漫
——操作系统、编译原理和图形学。
操作系统的构建在外行人眼中难比登天。而实际上,从零开始编写、调试乃至最终实现一个具有较高完成度的操作系统(内核)也不算是一件容易的事情。
计算机图形学同样是计算机科学的一个庞大分支。若想在其中做出一番成果,提高自身的数学水平、建模技巧,以及阅读大量论文,对于个人而言都是必不可少的。
而入门级别的编译原理——或者实际一点:小到解析爬虫取回的数据、解析 JSON,大到实现一个小型的解释器/编译器——都不算是一件过于复杂的事情。这便是一条适合初学者踏足的道路,于是我选择了这条路。
在大一下半学期的一学期时间中,我创建了两个项目:一个是致力于提供程序跨平台和运行时支持的虚拟机 ZexVM,另一个是一种自制编程语言的编译器 Saby。这两个项目将仅作为一次尝试,以便我日后深入研究编译原理这一门学科。
事实上就目前的水平而言,我是写不出任何有干货的技术报告的。不过为了例行的……不能消极怠工!(逃
那就让我们开始吧。
虚拟机、解释器、编译器
这其实是一个老生常谈的话题。
先说说虚拟机
虚拟机,顾名思义,它并不是一种实际存在的机器,它存在的目的是为了虚拟一种机器(计算机)或者是一个抽象的机器模型。这里我们谈论的虚拟机并非 QEMU 或 Virtual Box 这一类为操作系统运行提供虚拟硬件环境的软件,而是类似于 Java 虚拟机(JVM)等的为应用程序提供运行时环境的软件。这种虚拟机又被称为高级语言虚拟机(High-Level Language Virtual Machine,或者 HLLVM)。
HLLVM 往往用做编程语言和实际操作系统、硬件环境之间的桥梁。HLLVM 运行于操作系统,编程语言又运行于 HLLVM——从这我们就可以体会到虚拟机中 “虚拟” 一词的味道。
为什么要有这类软件的存在呢?根据我的理解,使用虚拟机作为一门编程语言语言的后端/运行时环境有以下优点:
- 方便在不同平台上实现。
大部分虚拟机本身的设计往往不会依赖特定硬件。如果我们想支持多种硬件平台,传统的做法是:我们针对每个平台实现一种编译器,开发者再将程序源码输入这些编译器,获得对应平台上的二进制可执行文件。但是这样对普通开发者而言费时又费力。
然而当我们拥有了虚拟机,一切变得明朗了起来:只要我们在不同的平台上实现这种虚拟机,那么在这种虚拟机上运行的编程语言就可以直接迁移到不同的硬件平台。开发者不需要考虑针对平台编译的问题,他们只需要将自己的程序编译为这种虚拟机的字节码,剩下的就交给虚拟机去做吧。
Java 的一个著名的宣传标语就阐述了这种优越性:Write Once, Run Anywhere.
- 为高级语言提供丰富的运行时支持。
这是很重要的一点,这也是目前 Java、C#、Python、Ruby 等编程语言能够如此易用的一大原因。
上述这些语言,以及目前大部分现代高级编程语言,相比于 C/C++ 这些老古董,都增加了自动内存管理的特性。举个例子,在 C/C++ 中,如果我们需要在运行时动态申请一块内存,我们可以使用 malloc 函数/new 关键字。但是当我们不再需要这块动态内存的时候,我们必须使用 free 函数/delete 关键字将其释放,否则会导致潜在的内存泄漏等问题。
而在上述语言中,我们依然可以使用 new 关键字或者其他语法来申请内存,但是我们并不需要关心如何去释放这些内存,也往往不需要考虑内存泄漏的风险。这是因为这些语言往往依赖其虚拟机/解释器提供的垃圾收集)(Garbage Collection)功能来自动的管理内存。垃圾收集器可以收集运行时的动态内存信息,自行分析何种内存空间/对象是可被释放的,从而解放程序员,提升开发效率,规避内存泄漏风险。下文我们会提及一些垃圾收集算法,以及 ZexVM 中垃圾收集器的简易实现。
除了内存管理,虚拟机还可以为编程语言提供其他一些运行时的支持。比如 Java、C# 中的反射、Ruby 中强大的元编程技术,都依赖于虚拟机保留的运行时的各类状态信息。
- 模拟一个和实际计算机结构相去甚远的抽象模型。
考虑到此篇技术报告的受众,我决定在这里多写一点。
目前主流计算机硬件系统的结构往往是哈佛结构,或者是冯·诺伊曼结构,或者是二者的结合。这些结构本质上是图灵机的硬件实现——它们考虑了诸多物理学、工程学问题,将图灵机这一数学模型变成了现实。
在目前的 IT 行业内,“函数式编程” 已经成为了一个很火的话题。越来越多的初学者将 Scala 乃至 Haskell 等作为自己的入门语言;越来越多的 JavaScript 小白以实现 “闭包” 为快乐;越来越多的主流编程语言,如 Java、C++ 等,在近几年加入了许多函数式编程特性……说了这么多,我实际上想谈谈所谓的 “函数式编程”。
函数式(Functional)的思想可以追溯到上世纪 30 年代。数学家阿隆佐·邱奇(Alonzo Church)设想了一种称为 λ 演算的规则,这种规则本质上利用了变量绑定和替换(规约),继而定义和表示一种可计算函数。λ 演算已经被证明与图灵机等价,也就是说我们设计一款完全基于 λ 演算的编程语言,它自身是图灵完备的。
关于 “正统的” λ 演算的研究实在是多而复杂,而从无类型 λ 演算扩展到带有类型系统的 λ 演算就更加复杂了,这里不再详细说明。读者若有兴趣,可以阅读《Structure and Interpretation of Computer Programs》(也就是大名鼎鼎的 “SICP”,中文译名 “计算机程序的构造和解释”)这本书以进一步了解。
似乎扯的有点远了。
现今的函数式编程语言的设计,往往离不开 λ 演算这一基础概念。但是要想在目前这种 “图灵机” 式的计算机结构上实现 λ 演算,还需要费很多功夫:λ 演算虽然与图灵机等价,但是它本身携带了许多的数学意味。函数式编程的魅力在于使用若干简单的函数,逐层构造一个复杂的运算。但是我们知道:仅就函数递归而言,它在计算机上的实际性能并不可观。因为函数调用需要参数出入栈,与简单的循环相比这无疑增加了运行开销(事实上,在函数式编程中可以运用 CPS,以及编译器/解释器/虚拟机对函数的尾递归优化来大大缩减这些开销)。递归尚且如此,更遑论其他函数式编程的概念。
所以我们可以利用虚拟机营造一个运行时环境,从而在某种程度上模拟 λ 演算的执行,为函数式编程提供方便。目前,一些函数式语言的简单实现都依赖于模拟 λ 演算的虚拟机或者解释器(也有许多特例,例如 Haskell 的 GHC 实现可以直接将 Haskell 程序编译到 Native Code,但实际上在编译过程中编译器也做了将函数式模型抽象为图灵机模型的变化)。这样做会不可避免的产生性能折损,毕竟 λ 演算和图灵机有着本质差异。不过在目前计算机性能如此强大的时代,这些损耗基本可以忽略——这其实也是函数式编程大火的原因之一。
- 缩减编译后二进制文件的体积。
这一点看起来不是那么重要。针对某种虚拟机指令集设计的字节码,往往考虑到了文件体积的问题。譬如,JVM 的字节码以一个字节为单元,Dalvik VM 的字节码以两个字节为单元……能够被这些虚拟机执行的字节码文件的体积,相比于等价的特定平台的二进制文件的体积,往往会小很多。这样一来,此种设计就能够方便编译后字节码文件的网络传输,或者是在极其有限的硬件资源中存储和运行。
说了很多东西的样子 >_<
上文提到了一些对于初学者来说比较陌生的概念,比如 “字节码” ——这就涉及虚拟机的工作原理了。
简而言之,虚拟机是一种可以从外部读入特定格式的代码(这种代码通常被设计成二进制格式,以字节为单位,方便程序读取,也就是所谓 “字节码” ),然后逐条执行这些代码,获取运行结果的程序。虚拟机会管理一些内存,供读取到的代码进行修改,或者存储运行状态。这个过程就像在实际计算机上执行程序的流程:CPU 从内存中载入指令,然后执行,并将运行结果存储在寄存器或者内存中。一些虚拟机的指令集的设计甚至与某些 CPU 指令集的设计非常相似,为的就是更方便地将一部分虚拟机的操作转化为实际 CPU 的操作,从而提高性能。
字节码从何而来?一些语言的编译器将源程序翻译为某种虚拟机的字节码而非机器语言,这样我们就可以在虚拟机上执行这种编程语言编译过后的程序了。这种编程语言可以享受上文中提到的优点,但同时也要面临一些问题。
目前使用虚拟机来实现一门编程语言的过程中,我们所面临的最大的问题就是性能问题了。我们可以想象得出,一个程序在虚拟机上间接的执行,途中经历了虚拟机的各种处理,一定会比直接翻译为机器语言后在实际机器上运行速度慢。在以前确实是这样的,JVM 最初几个版本的实现中,执行 Java 程序的速度大致比等价的 C 语言程序速度慢 10 到 20 倍。但是技术毕竟是在发展的,现代虚拟机可以运用各类技术(例如 AOT、JIT 编译等)优化性能,Oracle 公司的 HotSpot VM 执行 Java 程序的速度在一些情况下已经和等价的 C/C++ 语言程序相差无几。
再说说解释器
谈到解释器,就不得不提到编译器。解释器与编译器在结构上有很多相似之处,而它们二者之间的区别在于:将源程序(以及输入给源程序的数据)输入解释器将直接获得程序的执行结果;而将源程序输入编译器将获得编译后的源程序,我们需要运行它,并且将需要的数据输入,才可以获得执行结果。
这两种方式都可以获得执行结果,那它们之间具体有何不同?
解释器和编译器在开始的几个阶段都有相同的行为:首先读取源程序,对其进行词法分析(Lexical analysis)获得 Token 流;然后根据 Token 流进行语法分析(Parsing),得到抽象语法树(Abstract Syntax Tree,AST);再对 AST 做语义分析,得到标记过的 AST。
到这里,解释器和编译器就会分道扬镳。解释器通常会直接遍历 AST,同时按照 AST 中的程序语义做出相应的操作,实际上就相当于直接执行了源程序;编译器也会遍历 AST,但是它会生成一种中间表示(Intermediate Representation,IR)。IR 存在的意义是为了方便后续编译器对程序结构的优化,以进一步提高程序性能。最终,优化过的 IR 会被转化成目标平台的代码。
要注意的是,一般的编译器会将源程序翻译成机器语言——在这一过程中,“目标平台的代码” 指的就是机器语言的代码。但实际上有一些编译器不一定会将源程序翻译为机器语言:比如 Java 编译器会将 Java 源程序翻译为 JVM 的字节码;甚至存在将一种编程语言的源代码翻译到另一种编程语言的源代码的编译器,比如 Kotlin 的编译器可以将 Kotlin 编译为 JavaScript。
这样我们就明白了:解释器执行源程序依赖的是 AST 这种树形结构。虽然解释器可以立即将一个程序转化为执行结果,但是在性能上,它往往会比直接执行编译器编译的程序差若干数量级(只考虑编译到机器语言)。假设我们要执行一个循环,我们需要遍历若干次循环内部的 AST。而如果是编译完成的二进制,CPU 执行一个循环只需要简单的跳转,然后接着读取指令即可。另一方面,解释器通常不会保留一个程序的 AST,也就是说我需要使用解释器多次执行一个源程序,在每一次执行之前都需要经历源程序到 AST 的这一转换,这也更增加了性能开销。
解释器存在的意义,可以类比虚拟机的意义:解释器相比于编译器结构更加简单,而且可以轻而易举的移植到多个平台。解释器也可以轻松的为语言提供各类丰富的运行时支持。实际上,虚拟机和解释器的原理有很大的相似之处。虚拟机相当于其对应字节码的一个解释器——我们将字节码和一些数据输入虚拟机,虚拟机会 “解释执行” 字节码从而得到运行结果。
虚拟机和解释器在某种意义上是两个近似的概念。然而前文说过,现代的虚拟机会利用一些编译器用到的技巧来优化执行性能,这是它和解释器之间的区别。
笔者水平所限,无法写出更多更有深度的内容。
关于虚拟机和解释器的更多有深度的内容,可以参考 RednaxelaFX 大大的这篇文章。
内存管理和垃圾收集
现在越来越多的语言都带垃圾收集器了 = =
垃圾收集(Garbage Collection),GC)的概念最早出现于 Lisp 语言的解释器实现中。说起 Lisp,这其实是一门当时十分先进的编程语言:它第一个实现了解释器的概念、第一个支持递归、第一个引入 λ 演算、第一个支持动态内存管理……这么说来 Lisp 其实可以算是许多现代高级编程语言的鼻祖了。
引用计数
垃圾收集的思想实际上是对对象内存的可达性分析——如果一块分配出的内存不能以任何形式被程序访问到,那就说明这块内存是 “垃圾”、是不可达的,它可以被安全的释放。最简单的垃圾收集算法是引用计数法(Reference counting),它的大致流程是这样的:
- 在每个对象内部增加一个引用计数器,以便于记录这个对象的实例的被引用次数。
- 一个对象的引用计数器不为 0,也就是说它被程序里的某些地方引用着,那么这个对象就不该被释放;反之,如果为 0,说明这个对象无法被程序访问到,那么我们就能立即释放这个对象。
引用计数器按照如下方法进行维护:(下列几点参考了龙书相关章节的描述)
- 对象分配。新建对象时,该对象的引用计数设为 1。
- 参数传递。对象作为参数传递到某一过程的时候,引用计数加 1。
- 引用赋值。假设
u
和v
均为引用,表达式u = v
执行后,u
原先指向对象引用减 1,v
指向对象的引用加 1。 - 过程返回。一个过程再返回之前,过程内部所有局部变量指向对象的引用数都减 1。
- 可达性的传递丢失。当一个对象的引用计数变为 0 的时候,这个对象包含的所有引用指向的其他对象的引用数都减 1。
这种方法的优点在于,所有受到管控的对象可以在引用计数器清零之后立即被释放,这就保证了较低的内存占用量。它的另一个优点是实现起来非常简单。在 C++11 标准中,我们可以使用std::shared_ptr<T>
这种智能指针对指向的对象释放行为实行自动管理,而shared_ptr
的内部实现,本质上就是一种引用计数的垃圾收集算法。Python 最初几个版本的解释器实现中也使用了类似的算法。
然而这种算法实际上存在一种巨大的缺陷:它不能处理 “循环引用” 的情况。举个例子,数据结构中的双向链表大家一定都不陌生,那么现在我们来思考一个只有两个节点的双向链表,这两个相邻的节点分别为node1
和node2
。
由于双向链表的特性,node1
的next
指针将指向node2
,而node2
的prev
指针也会指向node1
。现在考虑基于引用计数的垃圾收集:node1
和node2
的引用计数均为 1,因为它们彼此互相引用。
那么问题来了:在这种 “两个对象相互引用” 的情况下,这两个对象的引用计数无论如何都不可能小于 1,这也就意味着这两个对象无论如何都不可能被垃圾收集器释放——这就是所谓的循环引用。
C++ 程序如下:
#include <iostream>
#include <memory>
struct Node {
Node(int data) : data(data), prev(nullptr), next(nullptr) {}
~Node() { std::cout << "node released" << std::endl; }
int data;
std::shared_ptr<Node> prev, next;
};
std::shared_ptr<Node> NewList() {
auto node1 = std::make_shared<Node>(100);
auto node2 = std::make_shared<Node>(200);
node1->next = node2;
node2->prev = node1;
return node1;
}
int main(int argc, const char *argv[]) {
// create a linked list using shared_ptr
auto head = NewList();
// release this linked list
head.reset();
// nothing happened...
std::cout << "done" << std::endl;
return 0;
}
我们可以看到,当一个链表节点Node
被释放的时候,Console 应当输出node released
,但是如果我们编译这个程序,执行后会发现什么也没有输出。
$ g++ test.cpp -o test -std=c++11
$ ./test
done
$
明明输出了一个done
的好吗?
在这个例子里,我们或许还能够遍历整个链表,人工释放内存。但是假如在一个庞大的工程里,各类复杂的数据结构相互引用,程序员可能根本无法避免写出一个不包含循环引用的程序。考虑到这些情况,我们就必须使用其他更加完备的垃圾收集算法。
基于跟踪的垃圾收集
上文提到垃圾收集的基本思路是对所有对象进行可达性分析,那么我们完全可以这样做:
- 为每个对象设计一种标记(通常只占用一个 bit),用来记录这个对象是否可达。
- 在垃圾收集之前先遍历全部对象,将所有这个标记设置为不可达。
- 指定一个对象为 “根结点”。
- 当满足特定条件时(通常是内存空间不足,或是程序强制调用了垃圾收集器),垃圾收集器将开始从根节点起,先将根节点和它引用的对象标记为可达;然后递归的追踪这些对象,将这些对象引用的所有对象标记为可达,再将它们引用的所有对象标记……直到所有从根节点出发能被遍历到的对象都被标记为了可达。
- 最后,垃圾收集器将释放剩余的那些不可达对象的空间。
这实际上就是大部分基于跟踪的垃圾收集(Tracing Garbage Collection)算法的基础思路。这种算法虽然不像引用计数算法那样,能够在对象不可达之后立即将对象的空间释放,但是它可以有效的解决循环引用问题。这只是一个大致的思路,实际实现中,往往不会遍历在堆区的全部对象,因为这样太浪费时间了——而这就取决于进一步的优化。
基于上述的两种思路可以衍生出许多具体的垃圾收集算法:比如 “标记-清理” 算法(Mark-and-sweep)、三色标记算法(Tri-color marking)、复制(Copying)垃圾收集、分代收集(Generational Collection)算法……等等。它们之间各有优劣,可以按实际需求来使用。
ZexVM 中垃圾收集器的实现
虽然这个实现极其极其极其……简陋,不过还是拿出来说一说吧,我还是太年轻了 >_<
现状
作为一个目前仅仅是用来练手的项目,ZexVM 使用了看起来十分 naive 的垃圾收集算法来管理内存。
顺带一说,有人也许会问这么做有什么意义?实际上技术的积累和巩固往往是在 “造轮子” 的不断实践之中体现的。就拿上面的垃圾收集算法介绍来说,我在实际完成 ZexVM 的垃圾收集之前只是大致了解这些垃圾收集算法的流程,但是你要问我具体怎么做?如何实现?需要考虑什么边界条件?在着手这个项目之前,没有任何一篇文章(至少我没搜索到中文文章)详细的告诉我具体应当如何用编程语言编写一个垃圾收集器。所有的一切只有在你这样做了之后才能内化为自身经验。导语中提到的 “轮子哥” 之所以被叫做 “轮子哥”,正是因为它乐于制造各种各样的 “轮子”,实现一些已经存在的东西(但人家水平极高啊,实现出来的东西好多都比已存在的要强)。
我能够真切地感受出,在做完这些后我才更进一步的了解到各种编程语言背后的虚拟机中垃圾收集算法的大致实现,这些感觉完全可以让我更容易的投入到一些比方说 Java 应用程序的开发中(大家都说 Java 是面向 GC 编程 = =)。可能这么说确实有点过分高估自己了,不过我还是建议各位读者,如果有兴趣的话也可以亲自实现一个垃圾收集器,然后应用到自己的软件开发中去:比如给 C/C++ 这些不带垃圾收集的语言人肉写一个通用的垃圾收集器。
又扯远了,还夹了很多私货……
为了规避循环引用问题,ZexVM 使用了一种基于跟踪的垃圾收集算法。在 ZexVM 内部,所有需要动态内存的对象,即:String
、List
和函数闭包(实际上也是List
),它们都会存储在一个由垃圾收集器管理的内存池(叫做gc-pool
)中。gc-pool
的大小会在虚拟机启动之前分配,默认值是128k
,可由用户指定,但是启动之后不可改变。
这个内存池实际上拥有类似栈的结构:当垃圾收集器需要托管一个对象时,它会把这个对象推入gc-pool
这个栈,并且给它分配一个 id,然后生成一个叫做GCObject
的数据结构存储一些必要的信息。id 和GCObject
会被存储在一个哈希表中,所有托管的对象通过 id 来识别。
如果我们需要立即释放一个对象,垃圾收集器会根据提供的 id,直接在哈希表中删除对应的GCObject
,这个对象就相当于已经不存在了,栈上的空间不会被立即释放。
那么当gc-pool
满了的时候呢?垃圾收集器在添加新对象时,如果发现内存池已经填满了,它会立即调用Reallocate
这个过程来进行垃圾收集,释放内存空间:
bool GarbageCollector::Reallocate(MemSizeT need_size) {
// reset reachable status
for (auto &&i : obj_set_) i.second.set_reachable(false);
// mark unreachable object recursively
Trace(root_id_);
temp_pool_ = std::make_unique<char[]>(pool_size_);
gc_stack_ptr_ = 0;
auto total_size = need_size;
for (auto i = obj_set_.begin(); i != obj_set_.end(); ) {
auto &gco = i->second;
// sweep unreachable object
if (!gco.reachable()) {
...
i = obj_set_.erase(i);
}
else {
total_size += gco.length();
// completely full
if (total_size > pool_size_) return false;
// copy to new pool (temp_pool_)
...
++i; // increase iterator
}
}
gc_pool_ = std::move(temp_pool_);
return true;
}
完整代码
我们可以看到,Reallocate
过程要求一个参数need_size
,这个参数指明了现在正在添加的新对象将占用的内存大小。obj_set_
就是那个存储了全部对象的哈希表,我们先遍历这个哈希表,将所有的GCObject
的可达性设置为false
。然后,调用Tracing
过程,传入根节点的 id(root_id_
):
void GarbageCollector::Trace(unsigned int id) {
auto it = obj_set_.find(id);
if (it == obj_set_.end()) return;
auto &gco = it->second;
gco.set_reachable(true);
if (gco.elem_list().empty()) return;
for (const auto &i : gco.elem_list()) {
auto it = obj_set_.find(i);
if (it != obj_set_.end() && !it->second.reachable()) Trace(i);
}
}
Tracing
实际上就是一个跟踪式垃圾收集的实现:它先根据 id 找到GCObject
,然后将它设置为可达。再根据目前这个GCObject
的所有对其他对象的引用,递归的调用Tracing
,实现整个gc-pool
内可达对象的遍历。
在标记完毕所有可达对象之后,回到Reallocate
过程。首先申请一块新的内存,用来临时存放目前可达的对象;然后归零栈指针,方便计算重新分配后的gc-pool
占用。
剩下的事情,就是遍历现在的哈希表,删除所有不可达对象了。在遇到可达对象的时候,垃圾收集器会将这些对象依次拷贝到新申请的内存空间中——这样做的目的是消除原先内存空间中可能存在的 “碎片化” 的情形。要记住之前我们提到的:如果直接释放一个对象,它所占用的gc-pool
栈空间不会被立即删除,这就会造成栈上前后两个对象都未被删除,而两空间中间的一段空间实际上已经应该被删除的情况。如果不对这种情况进行处理,栈上的连续空间也就不足以放下一个占用许多空间的对象了。
在遍历过程中,如果所有对象的大小,加上need_size
指定的大小,依然大于gc-pool
指定的大小的话,那就说明即便经过了垃圾收集,内存空间还是会被占满。也就是说垃圾收集器的内存空间已经完完全全的满了。这种情况只能报错并且强制停止执行虚拟机了。
最后,在遍历哈希表完成之后,将原来gc-pool
的空间替换为新申请的空间。大功告成!
展望一下未来?
看了上述 ZexVM 中的垃圾收集算法之后,如果你有一定的 C++ 开发经验,你很容易就能意识到这个算法还有很大的优化空间。比如Tracing
过程的递归复杂度本身就很高,遍历哈希表中所有的GCObject
这个操作(而且还遍历了三次……)也是很笨拙的做法。而且为了图方便,垃圾收集过程中直接申请了一块和gc-pool
一样大的新内存,这种做法实际上极其浪费空间资源。
目前的 ZexVM 还有很长的路要走,未来的垃圾收集思路一定不能是现在这么简单粗暴的样子。也许我们会参考一些成熟的垃圾收集算法,比如 Java 的分代 GC,然后对 ZexVM 现有的算法做出改进。总之,学无止境。
结语
事实上在写这篇文章的时候,一方面我结合了自身对这些技术的理解,另一方面,我也参考了许多其他技术性文章——这也是一个学习的过程。撰写技术报告,或者其他技术向的文章,本身就是对自己水平的一种磨练。因为技术这个东西,自己学懂是一方面,能讲给别人听又是另一方面了。要想更快的学会一个知识,最好的办法就是给别人讲一遍。
我很乐意向所有人分享自己新学得的知识,这样既有利于自己巩固已有的知识,也能在一定程度上方便他人(我这种水平怕是误人子弟)。说不定会有人读了类似的技术科普文章,开始对编译原理领域有了自己的想法,然后入门编译原理、阅读大量论文、成为技术大牛、升职加薪、出任总经理、当上 CEO、迎娶白富美、走上人生巅峰(划掉)……高中的时候正是因为在知乎上读了很多 RednaxelaFX 大大的回答,我才决定投入大量精力来学习编译原理的。总之我认为,在技术圈子里,这些文章越多越好。
但是就我个人水平而言,实现上述理想实在是心有余而力不足。我只是一个初学者。如果文章的表述方式不合您胃口,甚至是文章本身有纰漏或者重大错误,还请各位读者海涵及指正。也欢迎大家与我一同交流学习。
当然,这么长的东西,也许大家都不会看吧 QAQ
By MaxXing
EOF
blog 自带的 markdown 的代码块显示真是烂到爆炸 = =
zi ci
只是想知道作者什么时候更新一下Blog和软件……
作者都快凉了(逃,
更新这个事情,取决于作者有没有在摸鱼 = =
老哥牛逼
我很菜的