LoginSignup
3
0

自作言語にimportを導入する【Pangaea】

Posted at

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内部実装

続いて実装の紹介です。処理のおおまかな流れは以下の通りです。

  • モジュールの種類を識別
  • (ソースファイルの場合)読み込み、パース、評価
  • (標準モジュールの場合)オブジェクトを名前解決

モジュールの種類を識別

まずは、モジュールの種類を識別し読み込む方法を決めます、名前解決は以下の順序で行われます。

  • . はじまりの場合ソースファイル
  • 標準モジュール(組み込み)
  • 標準モジュール(ネイティブ)
di/import.go
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した結果ではなく 評価後の名前空間をオブジェクト化している のがポイントです。

di/import.go
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で手に入れたい「モジュール」も、「ソースファイルを評価した後の名前空間」とみなすことができるためです。

foo.pangaea
a := 1
b := 2
a + b
bar.pangaea
foo := import("./foo")

foo.p # {a: 1, b: 2}

標準モジュールのimport

続いて、標準モジュールのimportです。標準モジュールはホスト言語(Go)で実装されている場合とネイティブ(Pangaea)で実装されている場合があるため、それぞれの場所を順に探索しています。

di/import.go
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自体をシングルバイナリで配布可能にしています。環境構築でトラブって入門者の心が折れるのは避けたいので... :innocent:

di/import.go
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.Fileio.Reader を実装しているので、ソースファイルや文字列の場合と区別なくevalに渡せます。 io.Reader 最高。愛してる

invite!

続いて invite! の実装です。処理はほとんど同じですが、スコープを切らずに直接カレントスコープを渡しています。

di/import.go
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
main.pangaea
import("./foo/foo1") # `$(pwd)/foo/foo1.pangaea`
/foo/foo1.pangaea
# 同じディレクトリのfoo2をimportしたい
import("./foo2") # `$(pwd)/foo2.pangaea` になってしまう!?
/foo/foo2.pangaea
foo2 := 1

直感的には、現在評価中のソースファイルを起点として相対パスを解決してほしいところです。
そこで、Openするファイルを ${評価中のソースファイルの絶対パス}/${importで指定した相対パス} にしています。
評価中のソースファイルのパスについては、ファイルOpen時に定数 _PANGAEA_SOURCE_PATH に暗黙的に代入しています4

おわりに

以上、自作言語にimport関数を導入した紹介でした。ライブラリを作ることができるようになったので、いよいよツールとして役立つ機能が実装できそうです。

  1. といいつつ、JavaScript, Elixir, Io等からも大きな影響を受けています。

  2. Pangaeaでは、破壊的変更を伴う関数に慣例として ! を付けています(Rubyリスペクト)。

  3. 余談ですが、Praat Scriptという言語はこのパス解決を採用しているため、ディレクトリを掘るとincludeができなくなってしまいます(昔思いっきりハマった

  4. 完全に処理系内部に隠蔽することもできますが、ユーザーが参照してメタプログラミングに使えるかもしれないので公開しました。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0