📚

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

2021/02/27に公開

2021/02/27に公開したものを使用ライブラリのバージョンアップに追従する形で更新した

はじめに

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.3.0
	github.com/ikawaha/kagome-dict/ipa v1.0.4
	github.com/ikawaha/kagome/v2 v2.7.0
)

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

	// prologインタプリタを作成
	i := prolog.New(nil, nil)

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

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

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

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

こうして追加した述語をもとに問い合わせをすることができる。問い合わせは i.Query() で行い解集合を得ることができる。標準的なPrologからの逸脱になるが、クエリには database/sql のように、プレースホルダー ? を含めることができ、追加の引数として文字列(アトムとして)や整数、浮動小数点数を渡すことができる

	// 与えられた文を形態素解析して名詞だけ取り出す
	sols, err := i.Query("analyze(?, normal, Tokens), nouns(Tokens, Nouns).", "すもももももももものうち")
	if err != nil {
		panic(err)
	}

解集合から結果を取り出すにはまず問い合わせに用いた変数と同名のフィールドを含む構造体を定義する。フィールドの型は何でも受けることができる term.Interface でもよいが、リストが返ってくることが確かならスライスで受けることもでき、またアトムや整数、浮動小数点数が返ってくることが確かならそれぞれ stringintfloat でも受けることができる

	// Prologの変数と同名のフィールドを持つ構造体で受ける
	var s struct {
		Tokens []term.Interface
		Nouns  []string
	}

次に解集合をループしながらそれぞれの解をスキャンして結果を先程の構造体に取り出す。これは github.com/jmoiron/sqlxに倣っている

	for sols.Next() {
		if err := sols.Scan(&s); err != nil {
			panic(err)
		}
		fmt.Printf("Tokens: %+v\n", s.Tokens)
		fmt.Printf("Nouns: %s\n", s.Nouns)
	}

さて、Prologインタプリタに追加する述語の書き方だが、引数としてアリティ分の項 term.Interface と継続 k func(*term.Env) *nondet.Promise 、変数の状態を記録した環境 env *term.Env を取り、プロミス *nondet.Promise を返す関数として定義する。ここでは input, mode, tokens の3引数の述語を追加するので3つの項 term.Interface を取り、その後継続 k と環境 env を取る。継続 k は「このあとに続く処理」を表す関数で、バックトラッキングによって複数回「このあとに続く処理」が実行されうるので何度でも呼べる関数の形で渡されている

// analyze/3の定義
func Analyze(input, mode, tokens term.Interface, k func(*term.Env) *nondet.Promise, env *term.Env) *nondet.Promise {

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

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

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

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

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

package main

import (
	"errors"
	"fmt"

	"github.com/ichiban/prolog"
	"github.com/ichiban/prolog/engine"
	"github.com/ichiban/prolog/nondet"
	"github.com/ichiban/prolog/term"

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

func main() {
	// prologインタプリタを作成
	i := prolog.New(nil, nil)

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

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

	// 与えられた文を形態素解析して名詞だけ取り出す
	sols, err := i.Query("analyze(?, normal, Tokens), nouns(Tokens, Nouns).", "すもももももももものうち")
	if err != nil {
		panic(err)
	}

	// Prologの変数と同名のフィールドを持つ構造体で受ける
	var s struct {
		Tokens []term.Interface
		Nouns  []string
	}
	for sols.Next() {
		if err := sols.Scan(&s); err != nil {
			panic(err)
		}
		fmt.Printf("Tokens: %+v\n", s.Tokens)
		fmt.Printf("Nouns: %s\n", s.Nouns)
	}
}

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

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

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

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

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

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

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

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

おわりに

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

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

Discussion