书接上回,在《 Hulo 编程语言开发 —— 从源代码到 AST 的魔法转换》一文中,我们介绍了Hulo 编程语言的抽象语法树。今天,让我们深入探讨编译流程中的第二个关键环节——模块管理。
现代化的编程语言通常都支持第三方模块的分发和依赖管理。在解析 import
语句时,需要判断模块来源的不同情况:标准库、相对路径、绝对路径、第三方依赖等。为此,模块管理成为了一个独立的组件层。
在传统的解释性语言中,模块管理往往与解释器紧密绑定。但是基于 Hulo 需要转译成目标语言的特点,转换过程中的符号信息也需要在转译器中使用,因此,模块管理被单独提取出来,作为一个独立的服务层,为解释器和转译器分别提供模块信息和符号解析服务。这种设计使得模块管理逻辑更加清晰,也便于后续的维护和扩展。
为了避免复杂度和歧义,Hulo 的模块解析采用了简化的路径解析策略:
支持的导入方式
./
或 ../
前缀,如 import "./utils"
或 import "../common"
import "os"
或 import "math"
import "owner/package"
不支持的导入方式
/
开头的绝对路径./
或 ../
的相对路径这种简化的设计带来了以下好处:
对于支持的三种导入方式,在解析成功后都会存储一份文件所对应的绝对路径。这样做的好处是便于全局管理文件,能够准确追踪哪些文件已经解析过了,哪些还没有解析。如果不进行这一层路径标准化转换,那么可能出现不同路径书写方式指向同一个文件,导致重复解析的情况发生。
模块解析包含两个主要步骤:
这些提取出的信息由符号表(Symbol Table)管理,每个模块都有一份独立的符号表。符号表存储了模块中所有可访问的符号信息,包括:
这边的解析就是将文件读取出来,然后将字符串转换成 AST 的过程。不过,解析完后还会进行一次 AST 的扫描,会提取出类声明、函数声明、Pub 导出等信息。这些信息往往由符号表(Symbol Table)管理,每个模块各一份独立的符号表。
例如,我们有一个包含多个声明的模块文件 user.hl
:
// user.hl
pub class User {
name: str
age: num
pub fn greet() -> str {
return "Hello, " + $this.name
}
}
pub fn create_user(name: str, age: num) -> User {
return User{name: $name, age: $age}
}
const MAX_AGE = 120
解析后,这个模块的符号表可能是这样的:
SymbolTable {
"User": {
Type: "class",
Exported: true,
Fields: [
{ Name: "name", Type: "str", Exported: false },
{ Name: "age", Type: "num", Exported: false }
],
Methods: [
{ Name: "greet", ReturnType: "str", Exported: true }
]
},
"create_user": {
Type: "function",
Exported: true,
Params: [
{ Name: "name", Type: "str" },
{ Name: "age", Type: "num" }
],
ReturnType: "User"
},
"MAX_AGE": {
Type: "constant",
Exported: false,
Value: 120
}
}
这种抽象使得模块间的依赖分析和符号解析变得更加高效和可靠。当其他模块导入这个文件时,只需要查看符号表就能快速了解可用的公共接口,而不需要重新解析整个 AST 。
如果说上面的 import 原理较为抽象,那么下面的包管理工具应该算是亲切的。几乎现代化编程开发,都离不开包管理工具。而它呢,简单的说就是下载依赖、移出依赖、列出项目的依赖这几个功能。
这样说的原因呢,是因为 Hulo 在实现 import 的时候已经将模块系统内置到编译器当中,而不采用分离的方式,让包管理工具去解决依赖,而是让编译器直接解决。这样做的好处是,性能更好,处理起来更方便。所以,包管理工具对于 Hulo 来说更像是一个软件包管理工具,只是提供安装和卸载的功能。
由于 Hulo 采用 Go 语言编写的缘故,很大一部分也采用了 Go Module 的思想,就是"去中心化"的仓库,没有像 maven 、npm 那样的中央仓库,完全是采用 GitHub Releases 进行存储和分发依赖。这样做的好处呢,就是不需要花钱维护中央仓库,同时充分利用了 GitHub 的生态系统。
在 Hulo 中第三方依赖是以 owner/repo@version
的方式识别的,也就是拥有者和其所对应的仓库,以及指定的版本(可以不填写,默认拉取最新版本)。包管理工具在安装的时候执行的流程会从 GitHub Releases 下载包,其实就是通过 GitHub API 请求的过程。
这些 URL 路径遵循一定的规则,可以根据 owner 、repo 、version 拼接。如果不存在 version 也没关系,可以调用 GitHub 开放的 API 请求项目的最新版本。当下载下来后,它就会在本地创建缓存,按照文件路径关系 owner/repo/version
的格式存储这个源代码。
安装流程详解
owner/repo@version
格式的包名解析为 owner 、repo 、version 三个部分HULO_MODULES/owner/repo/version/
目录hulo.pkg.yaml
中记录新安装的依赖使用示例
# 安装指定版本的包
hlpm install hulo-lang/[email protected]
# 安装最新版本
hlpm install hulo-lang/stdlib
# 批量安装多个包
hlpm install hulo-lang/[email protected] hulo-lang/[email protected]
卸载就是安装的逆过程,其实就是找到本地缓存,然后删除那个文件夹即可。包管理工具会:
hulo.pkg.yaml
中删除对应的依赖记录hulo.pkg.lock.yaml
文件使用示例
# 卸载包(保留源码文件)
hlpm uninstall hulo-lang/stdlib
# 卸载包并删除源码文件
hlpm uninstall hulo-lang/stdlib --remove-files
项目的依赖关系就是要列出项目的所有的直接依赖和间接依赖。这也很简单,只需要读取包文件(在 Hulo 中是 hulo.pkg.yaml
),里面会记录项目安装的所有的依赖以及版本号,然后根据这些数据确认依赖的存储位置,再读取它的包文件,依次递归为止。
包管理工具会构建完整的依赖树,包括:
使用示例
# 列出所有依赖
hlpm list
# 显示依赖树结构
hlpm list --verbose
Hulo 使用 hulo.pkg.yaml
作为包配置文件,类似于 npm 的 package.json
,包含以下信息:
name: "my-project"
version: "0.1.0"
description: "A Hulo package"
author: "Your Name"
license: "MIT"
repository: "https://github.com/owner/repo"
dependencies:
hulo-lang/stdlib: "v1.0.0"
hulo-lang/utils: "v0.5.0"
main: "main.hl"
Hulo 包管理工具会在本地维护一个缓存目录(HULO_MODULES
),用于存储下载的包源码。这个缓存机制可以:
缓存清理
# 清理未使用的缓存
hlpm cache clean
# 查看缓存信息
hlpm cache info
这一部分和传统的语言不太一样,是 Hulo 增加的功能,如果不感兴趣可以直接跳过,不会影响前后连贯性。
在 Hulo 中,支持现代化的模块导入语法,如:
import { date as d } from "time"
- 具名导入并重命名import * from "time"
- 全量导入import "time" as t
- 模块别名导入但是作为目标语言的批处理脚本对别名的支持和对模块的支持却很鸡肋,因此 Hulo 需要在编译时就解决这个导入问题。
解决的方式说白了就是改名,将类名、函数名等符号重命名为特定的格式。Hulo 采用 _[模块 ID]_[符号类型]_[自增计数器]
这样的命名规则:
0
: 函数 (Function)1
: 类 (Class)2
: 常量 (Constant)3
: 变量 (Variable)例如,_0_1_0
代表这个符号出现在解析的第 0 个模块中,类型 1 表示它是一个 Class 符号,0 代表它是这个模块中第一个被解析出来的 Class 。
如果后面还有 Person 类,那么它将被命名为 _0_1_1
。
假设有以下导入:
import { User as MyUser, Person } from "./models"
import { date as d, time as t } from "time"
import * from "utils"
经过符号混淆后:
// 原始符号 -> 混淆后的符号
User -> _0_1_0 // 第 0 个模块,类型 1(Class),第 0 个
Person -> _0_1_1 // 第 0 个模块,类型 1(Class),第 1 个
date -> _1_0_0 // 第 1 个模块,类型 0(Function),第 0 个
time -> _1_0_1 // 第 1 个模块,类型 0(Function),第 1 个
这种符号混淆机制的优势: