今回は Go 言語の標準ライブラリ strings と regexp を取り上げます。regexp は「正規表現 (regular expression)」を扱うパッケージです。正規表現は「文字列のパターンを示した式」のことで、昔は一部のエディタやツール [*1] で利用されていましたが、今では多くのプログラミング言語で正規表現を使うことができるようになりました。
たとえば、テキストファイルから文字列を探す場合、Windows のコマンド find では、"abcd"とか "golang" などの文字列を検索することはできますが、3 桁以上の数字を見つけるといったことはできません。このような場合でも、正規表現を使うと簡単に検索パターンを指定することができます。
また、Go 言語は正規表現だけではなく、文字列を操作するときに便利なパッケージ strings が用意されています。最初に、strings を使った文字列操作から説明します。
それでは、文字列の検索を行う関数から説明しましょう。表 1 を見てください。
操作 | 機能 |
---|---|
func Contains(s string, sub string) bool | s 中に sub が存在すれば真を返す。 |
func Count(s string, sub string) int | s 中に出現する sub の回数を返す。 |
func Index(s string, sub string) int | s 中に sub が最初に出現する位置を返す。見つからない場合は -1 を返す。 |
func Lastindex(s string, sub string) int | s 中に sub が最後に出現する位置を返す。見つからない場合は -1 を返す。 |
部分文字列だけではなく、文字列から文字やバイトを検索する関数も用意されています。
文字列の端から余分な文字を取り除くには表 2 のメソッドを使います。
名前 | 機能 |
---|---|
func Trim(s string, cutset string) string | 先頭と末尾から cutset に含まれる文字を取り除く |
func TrimSpace(s string) string | 先頭と末尾から空白文字を取り除く |
func TrimLeft(s string, cutset string) string | 先頭から cutset に含まれる文字を取り除く |
func TrimRight(s string, cutset string) string | 末尾から cutset に含まれる文字を取り除く |
このほかに、以下の関数が用意されています。
文字列の分割は関数 Fields や Split を、文字列の結合は関数 Join を使います (表 3)。
名前 | 機能 |
---|---|
func Fields(s string) []string | s を連続した空白文字で区切り、スライスに格納して返す |
func FieldsFunc(s string, p func(rune) bool) []string | s を述語 p を満たす文字で区切り、スライスに格納して返す |
func Split(s, sep string) []string | s を sep で区切り、スライスに格納して返す |
func SplitN(s, sep string, n int) []string | s を n 個の部分文字列に切り分ける |
func Join(a []string, sep string) string | スライスに格納されている文字列を sep で連結して返す |
文字列を空白文字で分割する場合は Fields が便利です。関数で区切り文字を指定する場合は FiledsFunc を使います。述語 p を満たす連続した文字で文字列 s を区切ります。
Split は文字列 sep で文字列 s を分割します。sep に空文字列を指定すると、文字 (unicode) ごとに区切ります。s の中に sep が見つからない場合、s を格納したスライスを返します。SplitN は切り分ける部分文字列の個数を指定します。n > 0 の場合、最大で n 個の部分文字列を返します。0 を指定すると nil (ゼロ値) を返します。-1 を指定すると Split と同じ動作になります。
文字列の置換は関数 Replace と ReplaceAll を使います (表 4)。
名前 | 機能 |
---|---|
func Replace(s, old, new string, n int) string | old を new に置き換える (最大 n 回) |
func ReplaceAll(s, old, new string) string | old をすべて new に置き換える |
Replace で n に負の値を指定した場合、ReplaceAll と同じ動作になります。old に空文字列を指定すると、文字列 s の先頭と各文字の後ろに new が挿入されます。英大文字を英小文字に置き換える関数 ToLower や、その逆を行う関数 ToUpper もあります。
このほかにもパッケージ strings には便利な関数が用意されています。詳細は Go 言語のマニュアル「パッケージ strings」をお読みください。
Go 言語で使用できる正規表現の基本的な構文は、他のプログラミング言語 (たとえば Perl や Python など) とあまり変わらないので、説明は割愛させていただきます。正規表現については、以下に示す拙作のページや Go 言語のマニュアルをお読みください。
Go 言語で正規表現で利用する場合はパッケージ regexp をインポートしてください。正規表現を使って入力データとマッチングを行う関数を以下に示します。
func Match(pattern string, b []byte) (matched bool, err error) func MatchReader(pattern string, r io.RuneReader) (matched bool, err error) func MatchString(pattern string, s string) (matched bool, err error)
Match はバイトデータ、MatchReader は io.RuneReader、MatchString は文字列と引数 pattern を照合し、入力データに pattern が含まれていれば真を返します。なお、これらの関数は pattern とのマッチングを判定するだけで、もっと複雑なことをしたい場合は正規表現 pattern を関数 Compile でコンパイルしてください。
func Compile(expr string) (*Regexp, error) func MustCompile(str string) *Regexp
Compile は正規表現 pattern をコンパイルして Regexp へのポインタとエラーを返します。本稿では Regexp を Regex Object と呼ぶことにします。MustCompile はエラーが発生した場合はパニックします。
そして、Regex Object 用のメソッドを使って入力データと照合します。なお、本稿では文字列との照合を中心に説明することにします。
func (re *Regexp) MatchString(s string) bool func (re *Regexp) FindString(s string) string func (re *Regexp) FindStringIndex(s string) (loc []int) func (re *Regexp) FindAllString(s string, n int) []string func (re *Regexp) FindAllStringIndex(s string, n int) [][]int
MatchString は Regex Object が文字列 s とマッチングすれば真を返します。FindString はマッチングした一番左端の文字列を返します。マッチングしない場合は空文字列 "" を返します。FindStringIndex は、マッチングした文字列の始点と終点を格納したスライスを返します。つまり、s[loc[0]:loc[1]] がマッチングした文字列になります。マッチングしない場合は nil を返します。All が付いたメソッドは、引数 n でマッチングの回数を指定します。n < 0 の場合、マッチングするすべての文字列 (または位置) をスライスに格納して返します。
簡単な使用例を示しましょう。
リスト : 正規表現の使用例 (re01.go) package main import ( "fmt" "regexp" ) func main() { re, _ := regexp.Compile(`\d+`) s := "abcd01234ABCD56789efgh" fmt.Println(re.MatchString(s)) fmt.Println(re.FindString(s)) fmt.Println(re.FindStringIndex(s)) fmt.Println(re.FindAllString(s, -1)) fmt.Println(re.FindAllStringIndex(s, -1)) }
$ go run re01.go true 01234 [4 9] [01234 56789] [[4 9] [13 18]]
Go 言語の文字列 "..." はエスケープ記号 (バックスラッシュまたは円記号) が有効なため、正規表現のメタ文字として使うにはエスケープ記号を二重に書かなければいけません。たとえば、\d は \\d と書く必要があります。これでは面倒なので、エスケープ記号が無効になるヒアドキュメント `...` で正規表現 \d+ を指定します。
regexp.Compile で \d+ をコンパイルします。MatchString で文字列 s を \d+ で検索すると、'0123' とマッチングするので true を返します。FindStringIndex は [4 9] を返します。All メソッドを使うと 01234 と 56789 の 2 つにマッチングします。
正規表現はカッコ () を使って複数の文字をグループにまとめることができます。たとえば、ab* は ab の繰り返しではなく、b の繰り返しになります。ab の繰り返しを実現するには ( ) を使って、正規表現をひとつのグループにまとめます。
(ab)+ => ab abab ababab abababab など (ab)*c => c abc ababc abababc ababababc など
なお、グループのカッコは一致した部分文字列を覚えておくためにも使われます。これを「部分マッチ」とか「キャプチャグループ」といいます。Go 言語の場合、名前に Submatch が付いているメソッドで部分マッチを取り出すことができます。
1. func (re *Regexp) FindStringSubmatch(s string) []string 2. func (re *Regexp) FindStringSubmatchIndex(s string) []int 3. func (re *Regexp) FindAllStringSubmatch(s string, n int) [][]string 4. func (re *Regexp) FindAllStringSubmatchIndex(s string, n int) [][]int
1 のメソッドは文字列のスライスを返します。0 番目の要素が正規表現とマッチングした文字列、以降の n 番目の要素が n 番目のグループと部分マッチした文字列となります。2 のメソッドは 2 つの要素でマッチングした文字列の始点と終点を表します。0, 1 番目の要素が正規表現とマッチングした文字列の位置、それ以降の 2 * n, 2 * n + 1 番目の要素が n 番目のグループに部分マッチした文字列の位置を表します。
3 のメソッドは []string を格納したスライスを返します。各要素の構成は 1 のメソッドと同じです。4 のメソッドは []int を格納したスライスを返します。各要素の構成は 2 のメソッドと同じです。
簡単な使用例を示しましょう。
リスト : 部分マッチの使用例 (re02.go) package main import ( "fmt" "regexp" ) func main() { re, _ := regexp.Compile(`(\d+)/(\d+)/(\d+)`) s := "123/456/789 147/258/369" fmt.Println(re.FindStringSubmatch(s)) fmt.Println(re.FindStringSubmatchIndex(s)) fmt.Println(re.FindAllStringSubmatch(s, -1)) fmt.Println(re.FindAllStringSubmatchIndex(s, -1)) }
$ go run re02.go [123/456/789 123 456 789] [0 11 0 3 4 7 8 11] [[123/456/789 123 456 789] [147/258/369 147 258 369]] [[0 11 0 3 4 7 8 11] [12 23 12 15 16 19 20 23]]
スラッシュで区切られた 3 つの数値を取り出します。`(\d+)/(\d+)/(\d+)` のように数値を表す正規表現 \d+ をグループにすると、部分マッチで各数値を取り出すことができます。
正規表現を使うと、指定した文字列をファイルから検索する grep のようなツールを簡単に作成することができます。次のリストを見てください。
リスト : 文字列の検索 (grep.go) package main import ( "fmt" "bufio" "os" "regexp" ) func main() { re := regexp.MustCompile(os.Args[1]) s := bufio.NewScanner(os.Stdin) n := 1 for s.Scan() { l := s.Text() if re.MatchString(l) { fmt.Printf("%6d: %s\n", n, l) } n++ } }
パッケージ os の Args から 1 番目のコマンドライン引数を取り出し、MustCompile に渡してコンパイルします。次に、標準入力 (os.Stdin) から 1 行ずつ読み込んで変数 l にセットします。あとは、MatchString で re と l を照合して、マッチングした場合は行番号と文字列を表示します。
それでは実行してみましょう。
$ go run grep.go main < grep.go 1: package main 10: func main() { $ go run grep.go "\d+" < grep.go 11: re := regexp.MustCompile(os.Args[1]) 13: n := 1 17: fmt.Printf("%6d: %s\n", n, l) $ go run grep.go [A-Z][a-z]+ < grep.go 11: re := regexp.MustCompile(os.Args[1]) 12: s := bufio.NewScanner(os.Stdin) 14: for s.Scan() { 15: l := s.Text() 16: if re.MatchString(l) { 17: fmt.Printf("%6d: %s\n", n, l)
次は文字列の置換を行うメソッドを説明しましょう。
1. func (re *Regexp) ReplaceAllString(src, repl string) string 2. func (re *Regexp) ReplaceAllLiteralString(src, repl string) string 3. func (re *Regexp) ReplaceAllStringFunc(src string, repl func(string) string) string
1 は文字列 src と正規表現と照合し、マッチングした部分を文字列 repl に置き換えた新しい文字列を返します。repl の中では $ + 数字 で部分マッチを取り込むことができます。2 は $ + 数字 で部分マッチを取り込むことはできません。マッチングした文字列は repl そのものに置換されます。ReplaceAllStringFunc はマッチングした文字列を関数 repl に渡して呼び出し、repl が返す文字列に置き換えます。どのメソッドもマッチングした文字列はすべて置換されることに注意してください。
簡単な例を示しましょう。
リスト : 文字列の置換 (re03.go) package main import ( "fmt" "strings" "regexp" ) func main() { r1, _ := regexp.Compile(`(\d+)/(\d+)/(\d+)`) s1 := "123/456/789 147/258/369" fmt.Println(r1.ReplaceAllString(s1, "$0 $1 $2 $3")) fmt.Println(r1.ReplaceAllLiteralString(s1, "$0 $1 $2 $3")) r2, _ := regexp.Compile(`b\w+`) s2 := "hello, world, foo, bar, baz" fmt.Println(r2.ReplaceAllStringFunc(s2, strings.ToUpper)) }
$ go run re03.go 123/456/789 123 456 789 147/258/369 147 258 369 $0 $1 $2 $3 $0 $1 $2 $3 hello, world, foo, BAR, BAZ
正規表現 `(\d+)/(\d+)/(\d+)` とマッチングした部分文字列は、全体が $0、1 番目のグループが $1、2 番目が $2、3 番目が $3 で取り出すことができます。Literal が付くメソッドは、引数に与えられた文字列のまま置換します。部分マッチした値には置き換わりません。
正規表現 \w は英数字とアンダースコア '_' にマッチングします。したがって、文字列 s2 と b\w+ は英単語 bar と baz にマッチングします。関数 strings.ToUpper は英小文字を英大文字に変えた文字列を返すので、s2 は "hello, world, foo, BAR, BAZ" に置換されます。
文字列を分解するメソッド Split はパッケージ strings にもありますが、regexp.Split は単語の区切りを正規表現で指定します。
func (re *Regexp) Split(s string, n int) []string
Split は切り分ける部分文字列の個数を引数 n で指定します。正規表現にメタ文字が含まれていない場合、strings.SplitN と同じ動作になります。
簡単な例を示します。
リスト : 文字列の分解 (re04.go) package main import ( "fmt" "regexp" ) func main() { re, _ := regexp.Compile(`[abcd]+`) s := "123ab456cd789" fmt.Println(re.Split(s, -1)) fmt.Println(re.Split(s, 2)) }
$ go run re04.go [123 456 789] [123 456cd789]
上の例では、文字 a, b, c, d が連続している箇所で文字列を区切ります。したがって、最初の例は 123, 456, 789 の 3 つに分割されます。2 番目の例では、個数の指定が 2 なので、123 と 456cd789 の 2 つに分割されます。
ReplaceAllString を使うと文字列の置換ツールも簡単に作成することができます。次のリストを見てください。
リスト : 文字列置換ツール (gres.go) package main import ( "fmt" "bufio" "os" "regexp" ) func main() { re := regexp.MustCompile(os.Args[1]) rl := os.Args[2] s := bufio.NewScanner(os.Stdin) for s.Scan() { fmt.Println(re.ReplaceAllString(s.Text(), rl)) } }
コマンドライン引数 os.Args[1] から正規表現を取り出して MustCompile でコンパイルします。置換文字列は os.Args[2] から取り出して変数 rl にセットします。そして、標準入力から 1 行ずつ読み込み、メソッド ReplaceAllString で文字列を置換するだけです。
簡単な実行例を示しましょう。
$ go run gres.go '(\d+)/(\d+)/(\d+)' '$1-$2-$3' 123/456/789 123-456-789 1/2/3 4/5/6 7/8/9 1-2-3 4-5-6 7-8-9 <- CTRL-D を入力
このように、3 つの数値の区切り文字 '/' を '-' に変更することも簡単にできます。
ところで、Go 言語の正規表現は拡張記法 (?...) を使うことができます。? の後ろに続く文字で機能が決まります。基本的な機能を以下に示します。
(?:...) (?P<name>...)
(?:...) は正規表現をグループ化しますが、一致した文字列は記憶しません。したがって、そのグループで部分マッチした文字列を取り出すことはできません。(?P<name>...) はグループに名前 name を付けます。カッコ内の正規表現に一致した文字列は、$name で参照することができます。
簡単な例を示しましょう。
$ go run gres.go '(\d+)/(?:\d+)/(\d+)' '$1 $2' 123/456/789 123 789 12/34/56 7/8/9 12 56 7 9 <- CTRL-D を入力 $ go run gres.go '(?P<fst>\d+)/(?P<snd>\d+)' '$snd $fst' 123/456 456 123 12/34 56/78 98/76 34 12 78 56 76 98 <- CTRL-D を入力
今度は、クロスリファレンスを作成するプログラムを作ってみましょう。クロスリファレンスとは、プログラムで使用された変数や関数の名前と、それが現れる行番号をすべて書き出した一覧表のことです。今回作成するプログラムは変数名や関数名ではなく、正規表現と一致する文字列をキーワードとし、それが現れる行番号を出力することにします。
キーワードは文字コード順に整列して出力した方が見やすいので、出現したキーワードと行番号を覚えておいて、ファイルを読み終わってから結果をまとめで出力することにします。この場合、キーワードの探索処理によってプログラムの実行時間が大きく左右されます。
コンピュータの世界では、昔からデータを高速に探索するアルゴリズムが研究されています。基本的なところでは「二分探索木」や「ハッシュ法」があります。キーワードをリストに格納して線形探索すると時間がかかるので、このプログラムでは Go 言語の連想配列 (マップ, map) を使うことにします。
ただし、マップのキーを for ループで取り出すとき、文字コード順に取り出されるわけではありません。したがって、データを文字コード順に表示したい場合は、データをソートしないといけません。Go 言語にはスライスをソートするための関数が標準ライブラリ sort に用意されています。
func Float64s(a []float64) func Ints(a []int) func Strings(a []string) func Slice(slice interface{}, less func(i, j int) bool)
Float64s は float64 のスライスを、Ints は int のスライスを、Strings は string のスライスを昇順にソートします。Slice は渡された関数 less を使ってスライスをソートします。これらのソートはスライスを破壊的に修正することに注意してください。
簡単な例を示しましょう。
リスト : スライスのソート (testsort.go) package main import ( "fmt" "sort" "strings" ) func main() { a := []int {5, 6, 4, 7, 3, 8, 2, 9, 1, 0} b := []float64 {0.5, 0.6, 0.4, 0.7, 0.3, 0.8, 0.2, 0.9, 0.0} c := []string {"foo", "baz", "bar", "oops", "FOO", "BAZ", "BAR", "OOPS"} sort.Ints(a) sort.Float64s(b) sort.Strings(c) fmt.Println(a) fmt.Println(b) fmt.Println(c) sort.Slice(a, func(i, j int) bool { return a[i] > a[j] }) fmt.Println(a) sort.Slice(c, func(i, j int) bool { return strings.ToLower(c[i]) < strings.ToLower(c[j]) }) fmt.Println(c) }
$ go run testsort.go [0 1 2 3 4 5 6 7 8 9] [0 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9] [BAR BAZ FOO OOPS bar baz foo oops] [9 8 7 6 5 4 3 2 1 0] [BAR bar BAZ baz FOO foo OOPS oops]
Strings はスライスに格納されている文字列を文字コード順に並べます。このとき、英大小文字は区別されます。英大小文字を区別せずにソートする場合、最後の例のように Slice に比較関数を渡します。このとき、strings.ToLower を使って英小文字に変換してから比較します。
ただし、ToLower は新しい文字列を生成するので、この方法でソートすると実行速度は遅くなります。strings には英大小文字を区別せずに比較する関数 EqualFold がありますが、等値を判定するだけのでソートには使えません。もしかしたら、M.Hiroi の探し方が悪いだけで、ほかに関数があるのかもしれません。
それでは、クロスリファレンスのプログラムを示します。
リスト : クロスリファレンスの作成 package main import ( "fmt" "bufio" "os" "regexp" "sort" ) func main() { re := regexp.MustCompile(os.Args[1]) s := bufio.NewScanner(os.Stdin) n := 1 m := make(map[string][]int) for s.Scan() { l := s.Text() for _, key := range re.FindAllString(l, -1) { xs, ok := m[key] if ok { m[key] = append(xs, n) } else { m[key] = []int{n} } } n++ } ks := make([]string, 0, len(m)) for k, _ := range m { ks = append(ks, k) } sort.Strings(ks) for _, k := range ks { fmt.Printf("%s\n", k) for _, n := range m[k] { fmt.Printf("%6d", n) } fmt.Println("") } }
MustCompile でコマンドライン引数 Args[1] に渡された正規表現をコンパイルし、NewScanner で標準入力 Stdin からスキャナ s を生成します。変数 n は行番号を表し、変数 m には空のマップをセットします。次に、ファイルから 1 行読み込み、FindAllString でキーワードを切り出します。そこから for ループでキーワード key を一つずつ取り出し、マップ m に key があるかチェックします。key があれば append で行番号を追加し、なければ n を格納したスライスを m[key] にセットします。
for ループのあとはキーワードと行番号の表示処理になります。まず、マップ m からキーを取り出してスライス ks にセットし、sort.Strings でソートします。次の for ループで ks からキーワード k を取り出して Printf で表します。それから、2 番目の for ループで map[k] から行番号を取り出して Printf で表示します。
これでプログラムは完成です。それでは実行してみましょう。図 1 に示すファイル test.dat で、\w+ をキーワードにしたクロスリファレンスを作成します。実行結果を図 2 に示します。
abc def ghi jkl def ghi jkl mno ghi jkl mno pqr jkl mno pqr stu mno pqr stu vwx 図 1 : test.dat の内容
$ go run cref.go '\w+' < test.dat abc 1 def 1 2 ghi 1 2 3 jkl 1 2 3 4 mno 2 3 4 5 pqr 3 4 5 stu 4 5 vwx 5 図 2 : cref.go の実行結果
正規表現で表せるパターンであれば、そのクロスリファレンスを cref.go で作成することができます。このように、正規表現を使うと文字列を処理するプログラムを簡単に作ることができます。