TL; DR
- import=「evalした後の名前空間をオブジェクト化」
- 相対パスの解決は呼び出し元ファイルを起点とするのが使いやすそう
はじめに
2020年から「Pangaea」というプログラミング言語を自作しています。コンセプトは「P言語(Perl, Python, PHP, Ruby)の2次創作」1で、特にワンライナー特化のスクリプト言語です。
言語の詳細については過去の記事をご覧ください。
先日、3年越しにimport機能を実装できたので、本記事ではこの設計と内部動作について紹介したいと思います。 やっと共通ライブラリで実装を使いまわせる...
importの設計
式指向
import
設計にあたり、まずはこれを文にするか式(関数)にするか決める必要があります。
import "http"
res := http.Client.get("http://example.com")
http := import("http")
res := http.Client.get("http://example.com")
Pangaeaでは、式(関数)にすることにしました。これは、Pangaeaが「ワンライナーの書きやすさ」を最重要視しているためです。
importが関数であれば、以下のようにメソッドチェーンでモジュールを使えます。
import("http").Client.get("http://example.com")
さらに、実装的にもeval関数を使いまわせるので比較的簡単に実装できそうです。
invite!
とはいえ、 hoge := import("hoge")
と書くのはやはり冗長です。そもそも、ワンライナーで文字数を省略したいときにモジュール名を毎回書くのも面倒です。
そこで、モジュールのオブジェクトをprefix無しで呼び出せる(=今の名前空間に直接展開する) invite!
も用意しました2。
# 直接オブジェクトを展開
invite!("http")
# prefixいらず
res := Client.get("http://example.com")
標準モジュール
import
では別ファイルと標準モジュールどちらも呼び出せます。
# 標準モジュールimport
http := import("http")
# .をつけるとファイルimport (同一ディレクトリの `hoge.pangaea`)
hoge := import("./hoge")
import内部実装
続いて実装の紹介です。処理のおおまかな流れは以下の通りです。
- モジュールの種類を識別
- (ソースファイルの場合)読み込み、パース、評価
- (標準モジュールの場合)オブジェクトを名前解決
モジュールの種類を識別
まずは、モジュールの種類を識別し読み込む方法を決めます、名前解決は以下の順序で行われます。
-
.
はじまりの場合ソースファイル - 標準モジュール(組み込み)
- 標準モジュール(ネイティブ)
func importModule(env *object.Env, importPath string) object.PanObject {
// 相対パスの場合
if strings.HasPrefix(importPath, ".") {
return importRelative(env, importPath)
}
// スコープを切って標準モジュールを評価
newEnv := object.NewEnclosedEnv(env.Global())
return injectStandardModule(newEnv, importPath)
}
ソースファイルのimport
仕組みとしてはシンプルで、パスにあるファイルを読み込んでからevalしています。この際、evalした結果ではなく 評価後の名前空間をオブジェクト化している のがポイントです。
func importRelative(env *object.Env, importPath string) object.PanObject {
f, importPath, errObj := readSourceFile(env, importPath)
if errObj != nil {
return errObj
}
// 独立したスコープを切る(※カレントスコープの内側に作ると、import先で呼び出し元の変数が参照できてしまいバグの元)
newEnv := object.NewEnclosedEnv(env.Global())
result := eval(parser.NewReader(f, importPath), newEnv)
if result.Type() == object.ErrType {
return result
}
// 名前空間をオブジェクト化 (`{var: value}` の形式)
return newEnv.Items()
}
この機構は、評価した後の名前空間を返すメソッド Str#evalEnv
を使いまわしています。
# 普通のeval
"a := 1; b := 2; a + b".eval.p # 3
# evalEnvは評価後の名前空間を返す
"a := 1; b := 2; a + b".evalEnv.p # {a: 1, b: 2}
importで手に入れたい「モジュール」も、「ソースファイルを評価した後の名前空間」とみなすことができるためです。
a := 1
b := 2
a + b
foo := import("./foo")
foo.p # {a: 1, b: 2}
標準モジュールのimport
続いて、標準モジュールのimportです。標準モジュールはホスト言語(Go)で実装されている場合とネイティブ(Pangaea)で実装されている場合があるため、それぞれの場所を順に探索しています。
func injectStandardModule(env *object.Env, importPath string) object.PanObject {
// ホスト言語(Go)で実装された組み込みモジュール
// モジュールマップから直接取得
if m, ok := modules.Modules[importPath]; ok {
modules.InjectTo(env, m())
return env.Items()
}
// 無ければネイティブ(pangaea)モジュールを検索
return injectNativeStandardModule(env, importPath)
}
ネイティブモジュールのソースファイルはembedで埋め込んでいます。こうすることでPangaea自体をシングルバイナリで配布可能にしています。環境構築でトラブって入門者の心が折れるのは避けたいので...
func injectNativeStandardModule(env *object.Env, importPath string) object.PanObject {
filePath := fmt.Sprintf("modules/%s.pangaea", importPath)
// embed.FSからオープン
fp, err := native.FS.Open(filePath)
if err != nil {
return object.NewFileNotFoundErr(fmt.Sprintf("failed to read native module %q: %s", importPath, err))
}
defer fp.Close()
// 以下ソースファイル読み込みと同様
result := eval(parser.NewReader(fp, importPath), env)
if result.Type() == object.ErrType {
return result
}
return env.Items()
}
FS.File
も io.Reader
を実装しているので、ソースファイルや文字列の場合と区別なくevalに渡せます。 io.Reader
最高。愛してる
invite!
続いて invite!
の実装です。処理はほとんど同じですが、スコープを切らずに直接カレントスコープを渡しています。
func inviteModule(env *object.Env, importPath string) object.PanObject {
if strings.HasPrefix(importPath, ".") {
return inviteRelative(env, importPath)
}
// カレントスコープに直接代入
m := injectStandardModule(env, importPath)
if m.Type() == object.ErrType {
return m
}
return object.BuiltInNil
}
はまったところ
相対パスimport
Goの os.Open
では、プロセスを実行した場所を起点として相対パスが評価されます。そのため、そのままでは入れ子のimportが正しく評価されません3。
- main.pangaea (pwdもここ)
- /foo
- foo1.pangaea
- foo2.pangaea
import("./foo/foo1") # `$(pwd)/foo/foo1.pangaea`
# 同じディレクトリのfoo2をimportしたい
import("./foo2") # `$(pwd)/foo2.pangaea` になってしまう!?
foo2 := 1
直感的には、現在評価中のソースファイルを起点として相対パスを解決してほしいところです。
そこで、Openするファイルを ${評価中のソースファイルの絶対パス}/${importで指定した相対パス}
にしています。
評価中のソースファイルのパスについては、ファイルOpen時に定数 _PANGAEA_SOURCE_PATH
に暗黙的に代入しています4。
おわりに
以上、自作言語にimport関数を導入した紹介でした。ライブラリを作ることができるようになったので、いよいよツールとして役立つ機能が実装できそうです。