Goのスクリプト言語としてPrologを使う

6 min read読了の目安(約5800字

はじめに

Goのスクリプト言語としてPrologを使えるようにする ichiban/prolog を作ったので実際にGoのプログラムに埋め込む例を示す

埋め込みの例(kagome)

KagomeはGoのみで書かれた形態素解析器で、かんたんに日本語文を形態素に分割するものだが、これをPrologにする例を通して ichiban/prolog の使い方を説明する

この例での go.mod は以下のとおり

module github.com/ichiban/kagomelog

go 1.15

require (
	github.com/ichiban/prolog v0.1.0
	github.com/ikawaha/kagome-dict/ipa v1.0.2
	github.com/ikawaha/kagome/v2 v2.4.4
)

まずPrologのインタプリタを作成する必要があり、これには prolog.NewEngine() を呼び出す。引数にはインタプリタにとっての標準入力、標準出力に相当する io.Reader, io.Writer をそれぞれ渡せるが、今回は入出力をしないので nil を渡している

	// prologエンジンを作成
	e, err := prolog.NewEngine(nil, nil)
	if err != nil {
		panic(err)
	}

このインタプリタは(現時点で)114の組み込み述語がすでに登録されているが、埋め込み先プログラム特有の述語を追加することができる。追加する述語のアリティ毎に登録用のメソッドがあり、ここではアリティ3の述語のため、e.Register3() を使う。この述語はGoで書かれた特定のシグネチャを持つ関数だが、その書き方については後述する

	// 形態素解析の述語 analyze/3 を登録
	e.Register3("analyze", Analyze)

当然だが、Prologのコードで述語を定義することもできる。そのためには e.Exec() を使う

	// 名詞だけを取りだす述語 nouns/2 を定義
	if err := e.Exec(`
nouns([token(_, Noun, ['名詞'|_])|Tokens], [Noun|Nouns]) :- nouns(Tokens, Nouns), !.
nouns([_|Tokens], Nouns) :- nouns(Tokens, Nouns).
nouns([], []).
`); err != nil {
		panic(err)
	}

こうして追加した述語をもとに問い合わせをすることができる。問い合わせは e.Query() で行うが、その第2引数はコールバック関数で、その引数はクエリ中の自由変数のスライスである。もし解が存在するならそれらの自由変数を値で束縛してコールバック関数が呼び出される。解は複数存在する可能性があるので、コールバック関数も複数回呼び出される。これを止めるにはコールバック関数の戻り値として true を返す。ここではひとつの解で十分なので単純に return true としている

	// 与えられた文を形態素解析して名詞だけ取り出す
	if _, err := e.Query("analyze('すもももももももものうち', normal, Tokens), nouns(Tokens, Nouns).", func(vars []*prolog.Variable) bool {
		for _, v := range vars {
			fmt.Printf("%s\n", e.Describe(v))
		}
		return true
	}); err != nil {
		panic(err)
	}

さて、Prologインタプリタに追加する述語の書き方だが、引数としてアリティ分の prolog.Term と継続 k func() (bool, error) を取り、 (bool, error) を返す関数として定義する。ここでは input, mode, tokens の3引数の述語を追加するので3つの prolog.Term を取り、その後継続 k を取る。継続 k は「このあとに続く処理」を表す関数で、バックトラッキングによって複数回「このあとに続く処理」が実行されうるので何度でも呼べる関数の形で渡されている

// analyze/3の定義
func Analyze(input, mode, tokens prolog.Term, k func() (bool, error)) (bool, error) {

prolog.Term はProlog上でのデータ構造を表すインタフェースで、実際には *prolog.Variable, prolog.Float, prolog.Integer, prolog.Atom, *prolog.Compound, *prolog.Stream のいずれかの型である。入力用の引数(この例では inputmode)が実際にどの型かを判定するには v, ok := prolog.Resolve(引数).(期待する型) のイディオムが使える

	// 最初の引数 input が(変数かもしれないので、それをたどっていった先で)アトム(文字列みたいなもの)であるか確認
	i, ok := prolog.Resolve(input).(prolog.Atom)
	if !ok {
		return false, errors.New("not an atom")
	}

出力用の引数には単一化によって値を書き出すことができる。prolog.Floatprolog.Integer, prolog.Atom, *prolog.Compound で出力したい prolog.Term を組み立てて出力用の引数と prolog.Unify() することで出力できる

	// トークンを表す複合項の入ったスライスをリストに変換して出力用の引数と単一化
	// 継続 k はそのまま渡した先で処理してもらう
	return prolog.Unify(tokens, prolog.List(ret...), k)

全体としては以下のようになる

package main

import (
	"errors"
	"fmt"

	"github.com/sirupsen/logrus"

	"github.com/ichiban/prolog"
	"github.com/ikawaha/kagome-dict/ipa"
	"github.com/ikawaha/kagome/v2/tokenizer"
)

func main() {
	logrus.SetLevel(logrus.WarnLevel) // ちょっとログがうるさいので黙らせる

	// prologエンジンを作成
	e, err := prolog.NewEngine(nil, nil)
	if err != nil {
		panic(err)
	}

	// 形態素解析の述語 analyze/3 を登録
	e.Register3("analyze", Analyze)

	// 名詞だけを取りだす述語 nouns/2 を定義
	if err := e.Exec(`
nouns([token(_, Noun, ['名詞'|_])|Tokens], [Noun|Nouns]) :- nouns(Tokens, Nouns), !.
nouns([_|Tokens], Nouns) :- nouns(Tokens, Nouns).
nouns([], []).
`); err != nil {
		panic(err)
	}

	// 与えられた文を形態素解析して名詞だけ取り出す
	if _, err := e.Query("analyze('すもももももももものうち', normal, Tokens), nouns(Tokens, Nouns).", func(vars []*prolog.Variable) bool {
		for _, v := range vars {
			fmt.Printf("%s\n", e.Describe(v))
		}
		return true
	}); err != nil {
		panic(err)
	}
}

// analyze/3の定義
func Analyze(input, mode, tokens prolog.Term, k func() (bool, error)) (bool, error) {
	// 最初の引数 input が(変数かもしれないので、それをたどっていった先で)アトム(文字列みたいなもの)であるか確認
	i, ok := prolog.Resolve(input).(prolog.Atom)
	if !ok {
		return false, errors.New("not an atom")
	}

	// 形態素解析のモードを与えられたアトムから特定
	var tm tokenizer.TokenizeMode
	m, ok := prolog.Resolve(mode).(prolog.Atom)
	if !ok {
		return false, errors.New("not an atom")
	}
	switch m {
	case "normal":
		tm = tokenizer.Normal
	case "search":
		tm = tokenizer.Search
	case "extended":
		tm = tokenizer.Extended
	default:
		return false, errors.New("unknown mode")
	}

	// 形態素解析器を準備
	t, err := tokenizer.New(ipa.Dict(), tokenizer.OmitBosEos())
	if err != nil {
		return false, err
	}

	// 形態素解析の結果得られたトークンを token(ID, Surface, [Feature, ...]) の形式の複合項に変換
	var ret []prolog.Term
	for _, t := range t.Analyze(string(i), tm) {
		features := t.Features()
		fs := make([]prolog.Term, len(features))
		for i, f := range features {
			fs[i] = prolog.Atom(f)
		}

		ret = append(ret, &prolog.Compound{
			Functor: "token",
			Args: []prolog.Term{
				prolog.Integer(t.ID),
				prolog.Atom(t.Surface),
				prolog.List(fs...),
			},
		})
	}

	// トークンを表す複合項の入ったスライスをリストに変換して出力用の引数と単一化
	// 継続 k はそのまま渡した先で処理してもらう
	return prolog.Unify(tokens, prolog.List(ret...), k)
}

出力結果は以下のようになる。「すもももももももものうち」が形態素解析され、そのうち名詞だけがフィルターされている

$ go run main.go 
Tokens = [token(36163, 'すもも', ['名詞', '一般', *, *, *, *, 'すもも', 'スモモ', 'スモモ']), token(73244, 'も', ['助詞', '係助詞', *, *, *, *, 'も', 'モ', 'モ']), token(74988, 'もも', ['名詞', '一般', *, *, *, *, 'もも', 'モモ', 'モモ']), token(73244, 'も', ['助詞', '係助詞', *, *, *, *, 'も', 'モ', 'モ']), token(74988, 'もも', ['名詞', '一般', *, *, *, *, 'もも', 'モモ', 'モモ']), token(55829, 'の', ['助詞', '連体化', *, *, *, *, 'の', 'ノ', 'ノ']), token(8027, 'うち', ['名詞', '非自立', '副詞可能', *, *, *, 'うち', 'ウチ', 'ウチ'])]
Nouns = ['すもも', 'もも', 'もも', 'うち']

おわりに

Goのスクリプト言語としてPrologを使えるようにする ichiban/prolog を紹介し、実際にKagomeを使ったGoのプログラムにPrologを埋め込む例を示した。

ichiban/prolog はまだ荒削りでドキュメントやテストも十分ではないが、現状でもほぼISO PrologでREPLも書けるので試してみてほしい。