Hulo 编程语言开发 —— 包管理与模块解析

106 阅读9分钟

书接上回,在《Hulo 编程语言开发 —— 从源代码到 AST 的魔法转换》一文中,我们介绍了Hulo编程语言的抽象语法树。今天,让我们深入探讨编译流程中的第二个关键环节——模块管理。

现代化的编程语言通常都支持第三方模块的分发和依赖管理。在解析 import 语句时,需要判断模块来源的不同情况:标准库、相对路径、绝对路径、第三方依赖等。为此,模块管理成为了一个独立的组件层。

在传统的解释性语言中,模块管理往往与解释器紧密绑定。但是基于Hulo需要转译成目标语言的特点,转换过程中的符号信息也需要在转译器中使用,因此,模块管理被单独提取出来,作为一个独立的服务层,为解释器和转译器分别提供模块信息和符号解析服务。这种设计使得模块管理逻辑更加清晰,也便于后续的维护和扩展。

路径解析

为了避免复杂度和歧义,Hulo的模块解析采用了简化的路径解析策略:

支持的导入方式

  • 相对路径导入: 使用 ./../ 前缀,如 import "./utils"import "../common"
  • 标准库导入: 直接使用模块名,如 import "os"import "math"
  • 第三方依赖导入: 使用完整的包名,如 import "owner/package"

不支持的导入方式

  • 绝对路径导入: 不支持以 / 开头的绝对路径
  • 隐式相对路径: 不支持不带 ./../ 的相对路径

这种简化的设计带来了以下好处:

  1. 降低复杂度: 避免了路径解析的歧义问题
  2. 提高性能: 减少了路径解析的计算开销
  3. 增强安全性: 避免了绝对路径可能带来的安全风险
  4. 简化实现: 对于非相对路径的模块名,只需要区分是标准库还是第三方依赖即可

模块解析

对于支持的三种导入方式,在解析成功后都会存储一份文件所对应的绝对路径。这样做的好处是便于全局管理文件,能够准确追踪哪些文件已经解析过了,哪些还没有解析。如果不进行这一层路径标准化转换,那么可能出现不同路径书写方式指向同一个文件,导致重复解析的情况发生。

解析过程

模块解析包含两个主要步骤:

  1. 文件解析: 将文件读取出来,然后将字符串转换成AST的过程
  2. 符号提取: 解析完成后还会进行一次AST扫描,提取出类声明、函数声明、Pub导出等信息

符号表管理

这些提取出的信息由符号表(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 的格式存储这个源代码。

安装流程详解

  1. 解析包名: 将 owner/repo@version 格式的包名解析为 owner、repo、version 三个部分
  2. 获取版本信息: 如果未指定版本,通过 GitHub API 获取最新版本号
  3. 构建下载 URL: 根据 GitHub Releases 的规则构建下载链接 下载源码: 下载 ZIP 格式的源码包,支持重试机制
  4. 解压到本地: 将源码解压到 HULO_MODULES/owner/repo/version/ 目录
  5. 更新依赖配置: 在 hulo.pkg.yaml 中记录新安装的依赖

使用示例

# 安装指定版本的包
hlpm install hulo-lang/stdlib@v1.0.0

# 安装最新版本
hlpm install hulo-lang/stdlib

# 批量安装多个包
hlpm install hulo-lang/stdlib@v1.0.0 hulo-lang/utils@v0.5.0

卸载

卸载就是安装的逆过程,其实就是找到本地缓存,然后删除那个文件夹即可。包管理工具会:

  1. 从依赖配置中移除: 从 hulo.pkg.yaml 中删除对应的依赖记录
  2. 清理本地文件: 可选择是否删除本地缓存的源码文件
  3. 更新锁文件: 更新 hulo.pkg.lock.yaml 文件

使用示例

# 卸载包(保留源码文件)
hlpm uninstall hulo-lang/stdlib

# 卸载包并删除源码文件
hlpm uninstall hulo-lang/stdlib --remove-files

列出项目的依赖

项目的依赖关系就是要列出项目的所有的直接依赖和间接依赖。这也很简单,只需要读取包文件(在 Hulo 中是 hulo.pkg.yaml),里面会记录项目安装的所有的依赖以及版本号,然后根据这些数据确认依赖的存储位置,再读取它的包文件,依次递归为止。

依赖树构建

包管理工具会构建完整的依赖树,包括:

  1. 直接依赖: 项目直接声明的依赖
  2. 间接依赖: 直接依赖的子依赖
  3. 版本信息: 每个依赖的具体版本号
  4. 依赖层级: 以树形结构展示依赖关系

使用示例

# 列出所有依赖
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),用于存储下载的包源码。这个缓存机制可以:

  1. 提高安装速度: 避免重复下载相同的包
  2. 支持离线使用: 已缓存的包可以在网络不可用时使用
  3. 节省带宽: 减少重复的网络传输

缓存清理

# 清理未使用的缓存
hlpm cache clean

# 查看缓存信息
hlpm cache info

符号混淆

这一部分和传统的语言不太一样,是Hulo增加的功能,如果不感兴趣可以直接跳过,不会影响前后连贯性。

背景

在Hulo中,支持现代化的模块导入语法,如:

  • import { date as d } from "time" - 具名导入并重命名
  • import * from "time" - 全量导入
  • import "time" as t - 模块别名导入

但是作为目标语言的批处理脚本对别名的支持和对模块的支持却很鸡肋,因此Hulo需要在编译时就解决这个导入问题。

解决方案

解决的方式说白了就是改名,将类名、函数名等符号重命名为特定的格式。Hulo采用 _[模块ID]_[符号类型]_[自增计数器] 这样的命名规则:

  • 模块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个

这种符号混淆机制的优势:

  1. 避免命名冲突: 确保不同模块的符号不会冲突
  2. 简化目标代码: 生成的目标语言代码更加简洁
  3. 保持语义: 在编译时解决别名问题,运行时无需额外处理
  4. 可预测性: 混淆后的名称具有规律性,便于调试和追踪