M.Hiroi's Home Page

Go Language Programming

お気楽 Go 言語プログラミング入門

[ PrevPage | Golang | NextPage ]

パッケージ strings と regexp

今回は Go 言語の標準ライブラリ strings と regexp を取り上げます。regexp は「正規表現 (regular expression)」を扱うパッケージです。正規表現は「文字列のパターンを示した式」のことで、昔は一部のエディタやツール [*1] で利用されていましたが、今では多くのプログラミング言語で正規表現を使うことができるようになりました。

たとえば、テキストファイルから文字列を探す場合、Windows のコマンド find では、"abcd"とか "golang" などの文字列を検索することはできますが、3 桁以上の数字を見つけるといったことはできません。このような場合でも、正規表現を使うと簡単に検索パターンを指定することができます。

また、Go 言語は正規表現だけではなく、文字列を操作するときに便利なパッケージ strings が用意されています。最初に、strings を使った文字列操作から説明します。

-- note --------
[*1] 有名なところでは grep, sed, awk などがあります。

●文字列の検索

それでは、文字列の検索を行う関数から説明しましょう。表 1 を見てください。

表 1 : 文字列の検索
操作機能
func Contains(s string, sub string) bools 中に sub が存在すれば真を返す。
func Count(s string, sub string) ints 中に出現する sub の回数を返す。
func Index(s string, sub string) ints 中に sub が最初に出現する位置を返す。見つからない場合は -1 を返す。
func Lastindex(s string, sub string) ints 中に sub が最後に出現する位置を返す。見つからない場合は -1 を返す。

部分文字列だけではなく、文字列から文字やバイトを検索する関数も用意されています。

●文字の除去

文字列の端から余分な文字を取り除くには表 2 のメソッドを使います。

表 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)。

表 3 : 文字列の分解と結合
名前機能
func Fields(s string) []strings を連続した空白文字で区切り、スライスに格納して返す
func FieldsFunc(s string, p func(rune) bool) []strings を述語 p を満たす文字で区切り、スライスに格納して返す
func Split(s, sep string) []strings を sep で区切り、スライスに格納して返す
func SplitN(s, sep string, n int) []strings を 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)。

表 4 : 文字列の置換
名前機能
func Replace(s, old, new string, n int) stringold を new に置き換える (最大 n 回)
func ReplaceAll(s, old, new string) stringold をすべて 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 で作成することができます。このように、正規表現を使うと文字列を処理するプログラムを簡単に作ることができます。


Copyright (C) 2022 Makoto Hiroi
All rights reserved.

[ PrevPage | Golang | NextPage ]