フューチャー技術ブログ

GoPlus自由研究

フューチャー夏休みの自由研究連載の2回目です。

TIG DXユニットの伊藤真彦です。

夏休みの自由研究として、GoPlusの調査を行いました。

この記事ではGoPlusの紹介、および簡単な内部構造の調査を行います。

目次

  • はじめに
  • GoPlusとは
  • GoPlusの記述例
  • GoPlusのインストール
  • GoPlusをGoにインポート
  • GoPlusのハックを研究
  • GoPlusのこれから

はじめに

私は現在Goを書いていますが、前職ではRubyでのWeb開発がメインでした。

Rubyは型定義を書く必要がありません、端的に言うと書きやすさに重きを置いています。

Wikipediaにも

開発者のまつもとゆきひろは、「Rubyの言語仕様策定において最も重視しているのはストレスなくプログラミングを楽しむことである (enjoy programming)」と述べている。

と書いてあることからも、その設計思想が伺えます。

一方Goはコンパイル言語としての高速実行と書きやすさの絶妙なバランスを攻めている印象です。

C#を書いた経験もあるので、型があるのが自然という感覚も理解できますが、Rubyに慣れ親しんだ身からすると型定義や配列の取り扱いが少々面倒に感じます。
そもそも大抵の言語はRubyよりお堅いのではないでしょうか。
(どちらの言語が優れている、という主張は勿論ありません。)

そんなRuby愛を引きずっている私にとって衝撃的なニュースがありました。
GoPlusの存在です。

GoPlusとは

Goのスーパーセットにあたるライブラリです。

  • 静的型付言語である
  • Goと完全な互換性を持ってる
  • スクリプト言語のようなスタイルで、データサイエンスにおいてGoより高い可読性を実現している

と、READMEに書いてあります。
Cに対するC++のようなノリで命名されているようですね。

GoPlusの記述例

HelloWorld

hello.gop
println("Hello, world!")

拡張子は.gopです。
この1行だけで動きます、つまりpackageの定義やmain関数の記述が不要です。
素晴らしいですね。

配列、マップの取り扱い

maplit.gop
l := [1, 2, 3]
m := {"a": 1, "b": 2}
println(l)
println(m)

# [1 2 3]、map[a:1 b:2]が出力される

特にスライス(可変長の配列型)の宣言の感じがRuby時代を思い出します。
同じことをGoでやると、このような感じでしょうか

maplit.go
package main

import "fmt"

func main() {
l := []int{1, 2, 3}
m := map[string]int{"a": 1, "b": 2}
fmt.Println(l)
fmt.Println(m)
}

余談ですが、サンプルコードのprintln関数もGoPlusで新たに定義されています。
本家GoのPrint系の関数より柔軟で、型を意識せずに使えるように拡張されています。

他にも多倍長整数型・多倍長浮動小数点数型のサポート、配列操作の拡張、エラーハンドリングの簡易的な記法の実装など便利な機能が多数用意されています。

GoPlusのインストール

GoPlusを実際に使用する方法を紹介します。
go getコマンドでGOPATH配下にライブラリをインポートします

go get github.com/qiniu/goplus
go get github.com/qiniu/goplus@v0.6.50 #バージョン指定する場合

GOPATH配下にgoplusディレクトリが生成されるので移動します。
go installコマンドでパッケージをインストールします。

cd $GOPATH/src/github.com/qiniu/goplus
go install -v ./...

インストールするとgopコマンドが利用できるようになります。
※執筆時点ではα版であるため、代わりに4種類のコマンドがインストールされます。

  • qrun: Similar to gop run
  • qfmt: Similar to gop fmt
  • qexp: Similar to gop export
  • qgo: Similar to gop go

試しにqrunコマンドを使用します。
qrungop run、つまりGoのgo runに相当するファイル実行コマンドです。
拡張子がgopのファイルを実行できます。
実際にprintln("Hello, world!")1行だけが書かれたファイルを実行できました。

GoPlusをGoにインポート

GoPlusの拡張機能をGoのライブラリとしてインポートするような使い方も可能です。

main.go
package main

import (
"fmt"

"github.com/qiniu/goplus/tutorial/14-Using-goplus-in-Go/foo"
)

func main() {
rmap := foo.ReverseMap(map[string]int{"Hi": 1, "Hello": 2})
fmt.Println(rmap)
}

GoPlusのハックを研究

仕事で使うかはともかくGoPlusは面白いライブラリだと感じました。
今回はGoPlusがどのような手法でGoを柔軟な言語に拡張しているのか調査します。
ようやく自由研究の始まりです。

qrunコマンドでファイルが実行できる仕組み

go runに相当するqrunコマンドを見てみましょう。
コマンドの実装はこちらです。

いたって普通のGoのスクリプトです。
つまり、go installコマンドでインストールしたGoの実装が各種コマンドの正体です。

qrunのmain関数内部で、拡張子gopのファイルを解析、実行することで、gopファイル本体にはmain関数や諸々のおまじないが不要になっていたんですね。

GoPlusは既に5年に渡り開発が行われています(initial commitが2015年でした)。
そのため複雑な実装が組みあがっていますが、頑張って要点だけでも解析してみます。

qrun.go
func main() {
flag.Parse()
if flag.NArg() < 1 {
fmt.Fprintf(os.Stderr, "Usage: qrun [-asm -quiet -debug -prof] <gopSrcDir|gopSrcFile>\n")
flag.PrintDefaults()
return
}

まずは標準ライブラリのflagを用いて、コマンドライン引数を格納しています。
つまりflag.Arg(0)で引数で渡したファイル名.gopが取得できます。
試しに何もファイル名を指定せずqrunコマンドのみを実行するとflag.NArg() < 1trueになり、実装の通りのエラーメッセージが出力されます。

qrun.go
target, _ := filepath.Abs(flag.Arg(0))
isDir, err := IsDir(target)
if err != nil {
log.Fatalln("input arg check failed:", err)
}
pkgs, err := parser.ParseGopFiles(fset, target, isDir, 0)
if err != nil {
log.Fatalln("ParseGopFiles failed:", err)
}

ファイル名にパスを組み合わせたtargetparser.ParseGopFiles()に渡されます。
ここでgopファイルとしての解析が始まるようです。

qrun.go
cl.CallBuiltinOp = exec.CallBuiltinOp

b := exec.NewBuilder(nil)
_, err = cl.NewPackage(b.Interface(), pkgs["main"], fset, cl.PkgActClMain)
if err != nil {
log.Fatalln("cl.NewPackage failed:", err)
}
code := b.Resolve()
if *flagAsm {
code.Dump(os.Stdout)
return
}
ctx := exec.NewContext(code)
ctx.Exec(0, code.Len())
if *flagProf {
exec.ProfileReport()
}

解析した結果の実行はNewBuilderNewPackageなどの関数内部で行われています。
全てを解析しようとすると終わりが見えません、5年分の重みを感じます。

構文解析の仕組み

GoPlusスクリプトの構文解析はcl.NewPackage()で行われるようです。

compile.go
func NewPackage(out exec.Builder, pkg *ast.Package, fset *token.FileSet, act PkgAct) (p *Package, err error) {
if pkg == nil {
return nil, ErrNotFound
}
if CallBuiltinOp == nil {
log.Panicln("NewPackage failed: variable CallBuiltinOp is uninitialized")
}
p = &Package{}
ctxPkg := newPkgCtx(out, pkg, fset)
ctx := newGblBlockCtx(ctxPkg)
for _, f := range pkg.Files {
loadFile(ctx, f)
}
switch act {
case PkgActClAll:
for _, sym := range ctx.syms {
if f, ok := sym.(*funcDecl); ok && f.fi != nil {
ctxPkg.use(f)
}
}
if pkg.Name != "main" {
break
}
fallthrough
case PkgActClMain:
if pkg.Name != "main" {
return nil, ErrNotAMainPackage
}
entry, err := ctx.findFunc("main")
if err != nil {
if err == ErrNotFound {
err = ErrMainFuncNotFound
}
return p, err
}
if entry.ctx.noExecCtx {
ctx.file = entry.ctx.file
compileBlockStmtWithout(ctx, entry.body)
ctx.checkLabels()
} else {
out.CallFunc(entry.Get(), 0)
ctxPkg.use(entry)
}
out.Return(-1)
}
ctxPkg.resolveFuncs()
p.syms = ctx.syms
return
}

上記のように複雑な分岐を経由しますが、各経路での前処理を終えたらcompileStmt()で実際に構文解析が行われます。
その先頭のcompileExprStmt()内部のcompileExpr()までトークンを解析して送り込みます。

expr.go
func compileExpr(ctx *blockCtx, expr ast.Expr) func() {
switch v := expr.(type) {
case *ast.Ident:
return compileIdent(ctx, v.Name)
case *ast.BasicLit:
return compileBasicLit(ctx, v)
case *ast.CallExpr:
return compileCallExpr(ctx, v)
case *ast.BinaryExpr:
return compileBinaryExpr(ctx, v)
case *ast.UnaryExpr:
return compileUnaryExpr(ctx, v)
case *ast.SelectorExpr:
return compileSelectorExpr(ctx, v)
case *ast.ErrWrapExpr:
return compileErrWrapExpr(ctx, v)
case *ast.IndexExpr:
return compileIndexExpr(ctx, v)
case *ast.SliceExpr:
return compileSliceExpr(ctx, v)
case *ast.CompositeLit:
return compileCompositeLit(ctx, v)
case *ast.SliceLit:
return compileSliceLit(ctx, v)
case *ast.FuncLit:
return compileFuncLit(ctx, v)
case *ast.ParenExpr:
return compileExpr(ctx, v.X)
case *ast.ListComprehensionExpr:
return compileListComprehensionExpr(ctx, v)
case *ast.MapComprehensionExpr:
return compileMapComprehensionExpr(ctx, v)
case *ast.ArrayType:
return compileArrayType(ctx, v)
case *ast.Ellipsis:
return compileEllipsis(ctx, v)
case *ast.KeyValueExpr:
panic("compileExpr: ast.KeyValueExpr unexpected")
default:
log.Panicln("compileExpr failed: unknown -", reflect.TypeOf(v))
return nil
}
}

こちらのswitch文まで辿り着いたら、解析結果に応じたコンパイルが走ります。
興味があればqrunから構文解析までの処理の流れを詳しく探索してみてください、私はそろそろ限界です。

試しに解析結果が*ast.SliceExprだった場合の動き、compileSliceExpr()を見てみます。

expr.go
func compileSliceExpr(ctx *blockCtx, v *ast.SliceExpr) func() { // x[i:j:k]
var kind reflect.Kind
exprX := compileExpr(ctx, v.X)
x := ctx.infer.Get(-1)
typ := x.(iValue).Type()
if kind = typ.Kind(); kind == reflect.Ptr {
typ = typ.Elem()
if kind = typ.Kind(); kind != reflect.Array {
logPanic(ctx, v, `cannot slice a (type *%v)`, typ)
}
typ = reflect.SliceOf(typ.Elem())
ctx.infer.Ret(1, &goValue{typ})
}
return func() {
exprX()
i, j, k := exec.SliceDefaultIndex, exec.SliceDefaultIndex, exec.SliceDefaultIndex
if v.Low != nil {
i = compileIdx(ctx, v.Low, exec.SliceConstIndexLast, kind)
}
if v.High != nil {
j = compileIdx(ctx, v.High, exec.SliceConstIndexLast, kind)
}
if v.Max != nil {
k = compileIdx(ctx, v.Max, exec.SliceConstIndexLast, kind)
}
if v.Slice3 {
ctx.out.Slice3(i, j, k)
} else {
ctx.out.Slice(i, j)
}
}
}

独自の実装が山盛りですね…再帰的にcompileExpr()が呼び出されるところも迷宮ポイントを高めています。
キリがないので今回はこの辺りで調査を終了したいと思います。

GoPlusのこれから

GoPlusは執筆時点では月間90件に近いプルリクエストがマージされ、絶賛開発中の状態です。

α版を乗り越えgopコマンドが動き出す日は来るのか、乞うご期待です。

追記

記事の公開前日に確認したところ、バージョン0.7.1よりgopコマンドが採用されていました!
おめでとうGoPlus。

私は偉いので記事公開の1か月前に概ね書き終わっていたのですが、その間にもGoPlusは大きく進化していました。
追記執筆時点での最新の実装(v0.7.4)ではREPL(対話型インタプリタ)の実行機能が追加されたようです。

さらにはvscode用プラグインの開発もスタートしました。

今後の成長がますます楽しみになりました。