从零写一个编程语言: 实现中文计算 一 分词器,撕开解释器神话

219 阅读12分钟

任意计算一: 解释器词令

TeX\TeX
未经审视的人生不值得过

__苏格拉底 在审判后 放弃流亡,选择饮下毒酒。

我们说:

审视过往,使用中文方块字去计算。

__记实现一个mini版tree-walk解释器内核的快乐过程。

0 写在前面

真理好言值得重复。 优秀的人生和经验值借鉴。

从前以为,他人越过或达到的高峰,我再去就没有了意义。 他人得出了真理,我也没有必要去验证。

因为一个失去了原创性的产品,就像装了马粪的圣杯,一个失去贞操的有洁癖的人。(抱歉这个比喻可能不恰当,但是一时半会想不出其他,网络流行语:文化有待提高)。

实际上,优秀的真理发现 和 独特的产品发明,值得大众学习和借鉴,并且对于普通人具有无穷尽的启发性,比如运势八卦(抱歉文化好像又低了)。

但是这里我不打算继续谈论古老的运势结构,也许在将来可能继续。

我想在这里谈论一些结构,一些基于现代科学文明的启示,我们现在知道蒸汽机,电灯,自行车的结构,更复杂的如汽车是多种结构的组合,直升机,大型运输机更加进一步发展了这个逻辑。

在谈论计算机时,这里不谈论量子计算机这样前卫潮流的高大上概念,也不去回顾沿用至今的冯.诺伊曼的经典电子计算组织结构。

我们希望回顾的是,计算机是如何"思考和计算"的,或者说,人们如何让计算机具备了“思考”的能力。 鄙人认为,让一堆铁疙瘩加了油可以在物理世界开始运行跑起来,和让一个铁疙瘩通电后可以计算和思考一样,是真理好言,值得我们去重复和借鉴。

本系列将尝试在3个大的章节内,企图实现可用、快速的语言所需的最基本要素的介绍。

0.1 什么样的产出值得拥有

今天很多编程的方式让人有些头疼,好像越来越无聊,因为没有机会做新鲜事。进行这样的工作的人们的兴奋来自于有趣结果,而不是通过创造新事物得到的那种兴奋。

语言是一个巨大的话题,有成堆的概念和术语可以讨论。它在理论需要一定程度的精神上的严谨,我们可能从未有过这种精神。但是我们不打算在这里讨论太多概念。

我们已经习惯于工程方面的事情诸如DDD,MVC,MTV...,但是这里实现解释器使用了一些在其他类型的应用程序中不常见的架构方式。 鉴于所有这些,我们将让我们必须编写的代码尽可能简单明了。

所以在不到一千行干净的代码中,我们将为 Otao 构建一个完整的解释器, 它属于一种“高级”语言的一部分,使用者不必知道内部的实现细节,不必了解阴暗的角落,不过我们将要路过那里。

1 经典结构: 执行计算

在进入其中每一个黑暗和杂乱的角落之前,如果我们立即开始为解释器编写代码,而看不到我们最终会得到什么似乎很残忍. 我们先查看它的结构。

codefile.png

这里有一个示例,我们使用寄存器模拟函数的调用,以执行计算和输出结果。 我们需要模拟 逻辑与或,加减乘除,以及输出结果,我们约定 为了方便理解,我们规定如下词素:

	中文           英文	        运算符
	刷             print         PRINTI /PRINTF  
	与(按位)        LAND           &&
	或(按位)        LOR            ||
	加             PLUS            +
	减             MINUS           -
	乘             TIMES           *
	除              DIVIDE         /
            

拜托,用刷 来表示打印 print 实在难懂,太低级,太幼稚,很不科学,客人们不喜欢... 等一下,我们只是为了与“古老的” 活字印刷术关联起来。

我们不希望退回到 。 - | 丿 仄 , 这里不用刷漆,刷墙,只是把结果 "刷"到 控制台,或者文本中,似乎没有那么过分。

我们将使用这些来完成最常见的二元运算,也就是运算符两侧的子表达式是操作数。因为有两个,所以它们被称为二元运算符。

为了保持时髦,考虑到我们中有多少人整天都在使用OOP语言。如您所见,我们也制造OOP的代码用以执行。

结果非常有趣。没有你担心的那么难,可以说很简单,引人入胜。 首先在你本机保存如下 将要使机器计算的内容 testxzh.bl

        刷 149 加 151;
	刷 49 乘 511 减 22 乘 100;
	刷 149 除 51;
	刷 222222222 乘 555555555;
	刷 2 与 2;
	刷 3 或 4; 

类似的英文的如下: 保存如下内容为 testxen.bl

        print 49 + 51 + 100;
	print 49 + 51 - 101;
	print 12345679 * 99999; 
	print 3 && 2;
	print 3 || 4;

下载执行程序:

          二进制版本
		https://github.com/hahamx/otao/tree/main/builds/zhbitao
	win版本	
		https://github.com/hahamx/otao/tree/main/builds/enbitao || zhbitao
                    

执行中文代码:

	.\zhbitao.exe .\testxzh.bl

得到输出:

read code from file:.\testxzh.bl

	刷 49 加 51;
	刷 149 加 151;
	刷 49 乘 511 减 22 乘 100;
	刷 149 除 51;
	刷 222222222 乘 555555555;
	刷 2 与 2;
	刷 3 或 4;

Debug Result:

	100
	300
	22839
	2
	123456789876543210
	2
	7

我们深入一些观察这一切是如否进行的。

2 摄入扫描: 积跬步以至千里,从哪儿开始让计算机开始“思考”

从最初的 图灵的(turing)想法,输入,运算,输出。 无限的输入,无限的运算,无限的输出。 扫描对我们来说也是一个很好的起点,因为代码并不难,一个指令,几个表达式,

在我们实际扫描一些代码之前,我们需要勾勒出解释器Otao 的基本形状。一切都开始于一个类。

	import (
		"fmt"
		"os"
		"path/filepath"
		"reflect"
	)

	/*
	@param cpath: 可选代码路径,如果没有指定,将加载默认 的代码路径
	/按行 读取 代码文件,并存储在链表中
	/表示最多读 并存入chan 1000行
	*/
	func ReadCodeToString(cpath ...string) string { 

		if cpath != nil {
			code_path = cpath[0]
		} 
		fileBytes, err := os.ReadFile(code_path)
		if err != nil { 
			return ""
		} 
		return string(fileBytes)
	}
	

2.1 词素与标记

好了,我们已经有了一个获得代码的方式,安装之前的约定,需要分别按以下词素去分解代码。 我们规定如下:

	中文      英文			运算符
	刷	  print         PRINTI /PRINTF  
	与        LAND           &&
	或        LOR            ||
	加        PLUS            +
	减        MINUS           -
	乘        TIMES           *
	除        DIVIDE         /
	完	  END			 ;

为了方便,我们把它分类别存起来

var (   
		_liter_tokens = map[string]string{  
			";": "SEMI", 
		}

		_operate_tokens = map[string]string{
			"&&": "LAND", // Logical and
			"||": "LOR",  // Logical or

			"+": "PLUS",
			"-": "MINUS",
			"*": "TIMES",
			"/": "DIVIDE",

		}

		_keys_words = map[string]string{ 
			"int":      "INTEGER",
			"float":    "FLOAT",
			"string":   "STRING",
			"print":    "PRINT",
		}
		_zh_keys = map[string]string{
			"刷": "PRINT",
			"加": "PLUS",
			"减": "MINUS",
			"乘": "TIMES",
			"除": "DIVIDE",
			"与": "LAND",
			"或": "LOR",
		}
		_zh_ops = map[string]string{
			"PRINT":  "print",
			"PLUS":   "+",
			"MINUS":  "-",
			"TIMES":  "*",
			"DIVIDE": "/",
			"LAND":   "&&",
			"LOR":    "||",
		}
	)

我们将要实现一个 tree-walk的语言,然而词素只是源代码的原始子串。

然而,在将字符序列分组为词素的过程中,我们还偶然发现了一些其他有用的信息。当我们将词素与其他数据捆绑在一起时,结果是一个语句。它包括有用的东西。

然而,在将字符序列分组为词素的过程中,我们还偶然发现了一些其他有用的信息。 当我们将词素与其他数据捆绑在一起时,结果是一个语句。它包括有用的东西。

	//model
	type Node struct {
		Next *Node
		Id   string
		Data any
	}

	type Integer struct {
		// Example: 4 
		Value string
	}

	//二元运算	
	type BinOp struct {
		// Example: left + right  运算 
		Op    string
		Left  *Node  
		Right *Node  
	}

	//一元运算
	type UnaryOp struct {
		// Example: -operand 
		Op      string
		Operand any  
	}

	//刷
	type PrintStatement struct {
		// print value; 
		Value any //Expression
	}
		

现在我们定制了一些模型结构体,以使得这些代码可以组织为对应的 Token并在语句识别时,按语法 组织为语句。

2.2 分词器中的中文:词素令牌类型 Token type

关键字是语言语法形状的一部分,因此解析器通常有这样的代码, “如果下一个标记是while for  . . . ”这意味着解析器不仅想知道它有某个标识符的词素,还想知道它有一个保留字,以及它是哪个关键字。

但是篇幅所限,我们不打算实现这样的东西,按约定,该分词器可以通过比较字符串分类从原始语义中分拣,但这是缓慢的,那很难看。

从我们认识语义的角度,我们还记得哪一种语义的它代表。我们为每个关键字、运算符、标点符号和文字类型 都定义不同的类型。

有文字值的词素——数字和字符串等等。 由于扫描器必须遍历字面量中的每个字符才能正确识别它,因此它还可以将值的文本表示转换为解释器稍后将使用的实时运行时对象。

首先,我们约定一个错误处理方式,这个好像在学校中是不可谈及的话题,我们在这里简约处理,当不匹配我们的规则时,只是显示警告:

	func ErrorHander(lineno int, errinfo string) {
		fmt.Printf("WARNING:%v %v", lineno, errinfo)	
	}
            

我们分词后可以将其存入BST树之类的结构中,以便使用梯度下降类似的方式去解析Token,这里我们使用tree-walk的方式,如果对于结构有所疑问,可以参考之前的文章。

在循环的每一轮中,我们扫描一个令牌。这是分词器的真正核心。我们将从简单的开始。想象一下,如果每个词素只有一个字符长。您需要做的就是使用下一个字符并为其选择和记录类型。

	/*
	解析中文,数字,符号
	*/
	func TokenizeZhs(q string) *tlink {
		// 文本流队列入口
		var lineno int = 1
		var linkedq = makeDlink() 
		var strInt string = ""
		for n, s := range q {
			s := s
			if string(s) == " " {
				if strInt != "" {
					GenTokenLinked("int", string(strInt), linkedq, lineno)
				}
				strInt = ""
				continue
			} else if string(s) == "\t" {
				continue
			} else if string(s) == "\r" || string(s) == "\n" {
				lineno += 1
				continue
			} else if unicode.IsDigit(s) {
				strInt = strings.Join([]string{strInt, string(s)}, "")
				continue
				//结束一个语句 ;
			} else if string(s) == ";" {
				if strInt != "" {
					GenTokenLinked("int", string(strInt), linkedq, lineno)
				}
				strInt = ""
				GenTokenLinked(";", ";", linkedq, lineno)
				continue
				//处理分词中文
			} else if unicode.IsLetter(s)  {
				allLetter := string(s)
				if _zh_keys[string(allLetter)] != "" {
					tokType := _zh_keys[string(allLetter)]
					GenTokenLinked(_zh_ops[tokType], tokType, linkedq, lineno)
				} else {
					GenTokenLinked("NAME", allLetter, linkedq, lineno)
				}
				continue
			} else {
				errsInfo := fmt.Sprintf("Unterminated character %v \n", string(q[n]))
				ErrorHander(lineno, errsInfo)
			}
			logger.Printf("end  all at:%v \n", n)
		}
		// 填充结束 标志
		GenTokenLinked("END", "END", linkedq, lineno)
		return linkedq
	}

这就是把中文词素加载到我们的token令牌中的关键一步,它看起来非常简单,性能不是我们的主要关注点,正好满足我们的简单约定。

2.2.1 解读: ZH令牌,数字类型,运算符,操作符,结束符

我们如何知道一个字符是否可以称为 词令牌,以便我们完成源码到模型的过程: source -> model

    当我们遇到这样的语句:  刷 1123;	

字符流(从源码获得):

"刷"," ",1","1"," ", "", " ", "2", "3", ";")

我们需要把它变成tokens:

	  [('PRINT', 'print'),  ('INTEGER', '11'), ('PLUS', '+'), ('INTEGER', '23'), ('SEMI', ';')]

中文词素,我们按字符读取了字符串中的内容,并且将其与我们的词素进行了 确认,符合要求的则添加到我们的令牌链表

} else if unicode.IsLetter(s)  {
		allLetter := string(s)
		if _zh_keys[string(allLetter)] != "" {
			tokType := _zh_keys[string(allLetter)]
			GenTokenLinked(_zh_ops[tokType], tokType, linkedq, lineno)
		} else {
			GenTokenLinked("NAME", allLetter, linkedq, lineno)
		}
		continue

同样的处理方式,我们分辨数字类型并将其最终封装为令牌,存入链表以待处理 当一个数字类型的字符不只一个时,我们需要在下一次循环中一并添加和处理,并将指针置为空。

		if strInt != "" {
					GenTokenLinked("int", string(strInt), linkedq, lineno)
				}
				strInt = ""
	} else if unicode.IsDigit(s) {
				strInt = strings.Join([]string{strInt, string(s)}, "")
				continue
			}

扫描下一个字符并找出它的 TokenType,然后添加到 tokens 链表中。 最后完全返回。 同时,我们需要一些辅助方法,将我们识别到的 类型存入和打包到链表。

	/*
	@param: keys token类型
	@param: val token 值
	@param: t 将要存入的结构
	@param: lineno 行号记录
	// 将 keys val 存入 t dlist中, 记录lineno
	*/
	func GenTokenLinked(keys, val string, t *tlink, lineno int) bool {
		// 直接判断属于那个 token 关键字 并填充到 token队列
		if keys == "END" {
			PutinTokenLinked("END", "END", t, lineno)
			return false
		} else if _liter_tokens[keys] != "" {
			keysType := _liter_tokens[keys]
			PutinTokenLinked(keysType, val, t, lineno)
			return true
		} else if _operate_tokens[keys] != "" {
			keysType := _operate_tokens[keys]
			PutinTokenLinked(keysType, val, t, lineno)
			return true
		} else if _keys_words[keys] != "" {
			keysType := _keys_words[keys]
			PutinTokenLinked(keysType, val, t, lineno)
			return true
		} else {
			PutinTokenLinked("NAME", val, t, lineno)
			return false
		}
	}

	func PutinTokenLinked(types, val string, t *tlink, lineno int) *tlink { 

		TokenNew := &Token{Type: types, Value: val, LineNo: fmt.Sprintf("%v", lineno)} 
		t.AppendToken(TokenNew)
		return t
	}

它们获取当前词素的文本并为其创建一个新Token令牌。我们将很快使用另一个方法来处理具有价值的令牌。 如果全部约定的 词素都不能与文本匹配,则交由ErrorHandler处理

	else {
				errsInfo := fmt.Sprintf("Unterminated character %v \n", string(q[n]))
				ErrorHander(lineno, errsInfo)
			}
                            

另外,我们使用简单的 键值对 匹配来确认扫描到的字符是否是提取约定的字符。

	} else if _keys_words[keys] != "" {
		...
                    

2.2.2 最后的说明

最后,关于语句结束符 ;分号几乎每一种新语言都会刮掉的一点句法;(以及一些像 BASIC 这样的古老语言从未有过)。他们将换行符视为语句终止符,这样做是有意义的。“有意义的地方”部分是具有挑战性的部分。

然而我们希望 仍然把分号;作为显式语句终止符。

虽然大多数 语句都在它们自己的行上,但有时您需要将单个语句分布在几行中。那时混合的换行符不应被视为终止符。

大多数应该忽略换行符的明显情况很容易检测到,但也有一些意外的情况。 虽然程序员和其他人一样是时代的产物,分号是和所有大写关键字一样的传统。

只是要确保您选择了一组对您的语言的特定语法和习语有意义的规则。

结语一:

选择发布在掘金这个平台,是希望借掘金平台这个巨人看的更远。 谢谢。