Goのスクリプト言語としてPrologを使う
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
でもよいが、リストが返ってくることが確かならスライスで受けることもでき、またアトムや整数、浮動小数点数が返ってくることが確かならそれぞれ string
、 int
、 float
でも受けることができる
// 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
のいずれかの型である。入力用の引数(この例では input
と mode
)が実際にどの型かを判定するには v, ok := env.Resolve(引数).(期待する型)
のイディオムが使える
// 最初の引数 input が(変数かもしれないので、それをたどっていった先で)アトム(文字列みたいなもの)であるか確認
i, ok := env.Resolve(input).(term.Atom)
if !ok {
return nondet.Error(errors.New("not an atom"))
}
出力用の引数には単一化によって値を書き出すことができる。term.Integer
や term.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