🌊

Go言語で学ぶ正規表現

2023/04/09に公開

全体の構成:

  • はじめに
  • 正規表現の基本
  • パフォーマンスを向上させるテクニック
  • 可読性を高める方法
  • まとめ

はじめに

正規表現は、テキスト処理タスクにおいて非常に強力なツールであり、パターンマッチングや検索・置換に広く利用されています。効果的な正規表現を作成する際には、パフォーマンスと可読性のバランスが重要です。パフォーマンスが低い正規表現は遅い処理を引き起こし、可読性が低い正規表現は保守が困難になります。Go言語は高いパフォーマンスとシンプルな構文を持ち、正規表現を活用するのに適した言語です。この記事では、Go言語を用いた正規表現のパフォーマンスと可読性の向上方法を紹介します。

サンプルコードはコピペして Go Playground で実行してみることをおすすめします。

正規表現の基本

正規表現(Regular Expression)は、文字列の検索、置換、抽出などの処理を行うためのパターン表現です。正規表現を使用することで、特定のルールに従った文字列を効率的に処理することができます。正規表現は主に以下の要素から構成されています。

  1. 文字クラス(Character classes):
    文字クラスは、特定の文字セットにマッチするための表現です。
  • \d:数字(0-9)
  • \D:数字以外
  • \w:単語文字(a-zA-Z0-9_)
  • \W:単語文字以外
  • \s:空白文字(スペース、タブ、改行など)
  • \S:空白文字以外
  1. 量指定子(Quantifiers):
    量指定子は、直前のパターンの繰り返し回数を指定するための記号です。
  • *:直前の文字やグループが0回以上繰り返される場合にマッチ
  • +:直前の文字やグループが1回以上繰り返される場合にマッチ
  • ?:直前の文字やグループが0回または1回繰り返される場合にマッチ
  • {n}:直前の文字やグループがn回繰り返される場合にマッチ
  • {n,}:直前の文字やグループがn回以上繰り返される場合にマッチ
  • {n,m}:直前の文字やグループがn回以上、m回以下繰り返される場合にマッチ

次のコードは、単語全体を検索する正規表現のサンプルコードです。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	text := "Learn Regular Expressions in Go Language."

	// 単語全体をマッチする正規表現パターン
	pattern := `(\w+)`

	// 正規表現をコンパイル
	re, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Error compiling regex:", err)
		return
	}

	// マッチングを行う
	matches := re.FindAllString(text, -1)

	// 結果を出力
	fmt.Println("Matches found:", ma゛tches)
}

このコードを実行(Go Playground)すると、与えられた文字列 "Learn Regular Expressions in Go Language." から単語全体が検索され、出力結果は以下のようになります。

Matches found: [Learn Regular Expressions in Go Language]

以下の箇所が少しわかりにくいかもしれないので補足します。

// マッチングを行う
matches := re.FindAllStringSubmatch(text, -1)
  1. 位置指定子(Anchors):
    位置指定子は、特定の位置でのマッチングを指定するための記号です。
  • ^:文字列の先頭
  • $:文字列の末尾
  • \b:単語の境界
  • \B:単語の境界以外
  1. グループ(Groups):
    グループは、複数の文字やパターンをまとめるために使用されます。グループには、キャプチャグループと非キャプチャグループがあります。
  • ():キャプチャグループ(マッチした部分文字列を抽出する)
  • (?:):非キャプチャグループ(マッチした部分文字列を抽出しない)

次のコードは、キャプチャグループ () を使って与えられた文字列から、カッコで囲まれた部分文字列を抽出する例です。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	text := "Today's weather is (sunny) with a chance of (rain)."

	// キャプチャグループをマッチする正規表現パターン
	pattern := `\((.*?)\)` // カッコで囲まれた部分を抽出

	// 正規表現をコンパイル
	re, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Error compiling regex:", err)
		return
	}

	// マッチングを行う
	matches := re.FindAllStringSubmatch(text, -1)

	// 結果を出力
	for i, match := range matches {
		fmt.Printf("Match %d: %s\n", i+1, match[1]) // match[1] にキャプチャグループのマッチ結果が格納される
	}
}

このコードを実行すると、与えられた文字列 "Today's weather is (sunny) with a chance of (rain)." からカッコで囲まれた部分文字列が抽出され、出力結果は以下のようになります。

Match 1: sunny
Match 2: rain
  1. その他の特殊文字やメタ文字:

正規表現には、特定の目的のために使われる特殊文字やメタ文字があります。

  • .(ドット): 任意の1文字にマッチ(改行文字を除く)
  • |(パイプ): 複数の選択肢からのマッチングを指定するための演算子 (OR演算子)
  • [...]: 文字の範囲や集合を指定するための表現
  • [^...]: 指定した文字の範囲や集合以外の文字にマッチする
  • (?i): 大文字・小文字を区別せずマッチングを行う
  • (?P<name>...): 名前付きキャプチャグループを定義する

これらの基本的な要素を組み合わせることで、さまざまなパターンの文字列にマッチする正規表現を作成できます。正規表現を効果的に使用するには、これらの基本要素を理解し、適切に組み合わせることが重要です。また、正規表現は言語や環境によって多少の違いがあるため、使用する言語や環境に応じた文法や機能を確認することも大切です。

次のサンプルコードは大文字・小文字を区別せずマッチングを行う正規表現の例です。ここでは、与えられた文字列から"hello"という単語を大文字・小文字の違いを無視して検索しています。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	text := "Hello, I am learning about regular expressions. Please say Hello to everyone."

	// 大文字・小文字を区別せずマッチングを行う正規表現パターン
	pattern := `(?i)hello`

	// 正規表現をコンパイル
	re, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Error compiling regex:", err)
		return
	}

	// マッチングを行う
	matches := re.FindAllString(text, -1)

	// 結果を出力
	fmt.Println("Matches found:", matches)
}

このコードを実行すると、与えられた文字列 "Hello, I am learning about regular expressions. Please say Hello to everyone." から "Hello" という単語が2回見つかり、出力結果は以下のようになります。

Matches found: [Hello Hello]

次に、名前付きキャプチャグループを使ったサンプルコードを示します。このコードは、名前付きキャプチャグループを使って、与えられた文字列から名前と電話番号を抽出する例です。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	text := "John: 123-456-7890, Jane: 987-654-3210, Tom: 111-222-3333"

	// 名前付きキャプチャグループを定義する正規表現パターン
	pattern := `(?P<name>[A-Za-z]+):\s+(?P<phone>\d{3}-\d{3}-\d{4})`

	// 正規表現をコンパイル
	re, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Error compiling regex:", err)
		return
	}

	// マッチングを行う
	matches := re.FindAllStringSubmatch(text, -1)

	// 結果を出力
	for _, match := range matches {
		fmt.Printf("Name: %s, Phone: %s\n", match[re.SubexpIndex("name")], match[re.SubexpIndex("phone")])
	}
}

このコードを実行すると、与えられた文字列 "John: 123-456-7890, Jane: 987-654-3210, Tom: 111-222-3333" から名前と電話番号が抽出され、出力結果は以下のようになります。

Name: John, Phone: 123-456-7890
Name: Jane, Phone: 987-654-3210
Name: Tom, Phone: 111-222-3333

パフォーマンスを向上させるテクニック

正規表現のパフォーマンスを向上させるためには、いくつかのテクニックがあります。以下に、それらのテクニックを詳しく述べます。

  1. コンパイル済み正規表現の使用:
    正規表現オブジェクトを使用する際、regexp.Compile() 関数で一度コンパイルしてからマッチングを行うことで、パフォーマンスを向上させることができます。コンパイル済みの正規表現オブジェクトは、複数回使用することができるため、繰り返しマッチングを行う場合に特に効果的です。

次のコードは、コンパイル済み正規表現の使用に焦点を当てています。この例では、複数の文字列に対して同じ正規表現を適用してマッチングを行います。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	texts := []string{
		"apple",
		"banana",
		"cherry",
		"grape",
	}

	// 単語全体をマッチする正規表現パターン
	pattern := `^(a\w+)`

	// 正規表現をコンパイル
	re, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Error compiling regex:", err)
		return
	}

	// 各テキストに対してマッチングを行う
	for _, text := range texts {
		if re.MatchString(text) {
			fmt.Printf("%s matches the pattern\n", text)
		} else {
			fmt.Printf("%s does not match the pattern\n", text)
		}
	}
}

このコードを実行すると、与えられた複数の文字列に対して、正規表現が一度コンパイルされ、その後マッチングが行われます。出力結果は以下のようになります。

apple matches the pattern
banana does not match the pattern
cherry does not match the pattern
grape does not match the pattern
  1. 非貪欲な量指定子の使用:
    デフォルトでは、正規表現の量指定子(*, +, {n,m})は貪欲な動作を行います。これは、可能な限り多くの文字にマッチしようとする動作です。しかし、この動作はパフォーマンスに悪影響を与える場合があります。そのため、非貪欲な量指定子(*?, +?, {n,m}?)を使用して、必要最低限のマッチングに抑えることで、パフォーマンスを向上させることができます。

次のコードは、非貪欲な量指定子の使用に焦点を当てています。この例では、HTMLタグ内のテキストを抽出する際に非貪欲な量指定子を使用しています。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	text := "<h1>Heading 1</h1><p>Paragraph text.</p>"

	// 非貪欲な量指定子を使用して、HTMLタグ内のテキストをマッチする正規表現パターン
	pattern := `<.*?>(.*?)<\/.*?>`

	// 正規表現をコンパイル
	re, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Error compiling regex:", err)
		return
	}

	// マッチングを行う
	matches := re.FindAllStringSubmatch(text, -1)

	// 結果を出力
	for i, match := range matches {
		fmt.Printf("Match %d: %s\n", i+1, match[1])
	}
}

このコードを実行すると、与えられた文字列 "Heading 1Paragraph text." からHTMLタグ内のテキストが抽出され、出力結果は以下のようになります。

Match 1: Heading 1
Match 2: Paragraph text.
  1. 無駄なキャプチャグループの削除:
    キャプチャグループは、マッチした部分文字列を抽出するために非常に便利ですが、パフォーマンスに影響を与える可能性があります。キャプチャグループが不要な場合は、非キャプチャグループ((?:...))を使用して、パフォーマンスを向上させることができます。

次のコードは、無駄なキャプチャグループの削除に焦点を当てています。この例では、電話番号を抽出する際に非キャプチャグループを使用しています。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	text := "My phone number is (123) 456-7890."

	// 非キャプチャグループを使用して、電話番号をマッチする正規表現パターン
	pattern := `\(?(?:\d{3})\)?[\s\-]?\d{3}[\s\-]?\d{4}`

	// 正規表現をコンパイル
	re, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Error compiling regex:", err)
		return
	}

	// マッチングを行う
	match := re.FindString(text)

	// 結果を出力
	fmt.Println("Phone number found:", match)
}

このコードを実行すると、与えられた文字列 "My phone number is (123) 456-7890." から電話番号が抽出され、出力結果は以下のようになります。

Phone number found: (123) 456-7890

この例では、非キャプチャグループ (?:...) を使用して、キャプチャグループの無駄を削除しています。こうすることで、パフォーマンスが向上し、正規表現の処理が効率化されます。

  1. アンカーを利用する:
    正規表現でアンカー(^, $)を使用することで、マッチングの開始や終了位置を制限できます。これにより、正規表現エンジンが無駄な検索を減らし、パフォーマンスを向上させることができます。

次のコードは、アンカーを利用してマッチングの開始と終了位置を制限する方法に焦点を当てています。この例では、文字列の先頭および末尾にある単語をマッチングします。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	text := "apple banana cherry grape"

	// 文字列の先頭にある単語をマッチする正規表現パターン
	startPattern := `^\w+`

	// 文字列の末尾にある単語をマッチする正規表現パターン
	endPattern := `\w+$`

	// 正規表現をコンパイル
	startRe, err := regexp.Compile(startPattern)
	if err != nil {
		fmt.Println("Error compiling start regex:", err)
		return
	}

	endRe, err := regexp.Compile(endPattern)
	if err != nil {
		fmt.Println("Error compiling end regex:", err)
		return
	}

	// マッチングを行う
	startMatch := startRe.FindString(text)
	endMatch := endRe.FindString(text)

	// 結果を出力
	fmt.Println("First word:", startMatch)
	fmt.Println("Last word:", endMatch)
}

このコードを実行すると、与えられた文字列 "apple banana cherry grape" から先頭および末尾の単語が抽出され、出力結果は以下のようになります。

First word: apple
Last word: grape

この例では、アンカー ^$ を使用して、マッチングの開始位置と終了位置を制限しています。これにより、正規表現エンジンが無駄な検索を減らし、パフォーマンスを向上させることができます。

  1. 文字クラスの使用:
    特定の文字セットにマッチさせたい場合、文字クラス([...])を使用することで、より効率的なマッチングが可能になります。また、否定文字クラス([^...])を使って、特定の文字以外にマッチさせることもできます。

次のコードは、文字クラスを使用して特定の文字の範囲にマッチする方法に焦点を当てています。この例では、文字列から数字を抽出します。

package main

import (
	"fmt"
	"regexp"
)

func main() {
	text := "My house number is 123 and my zip code is 45678."

	// 数字をマッチする正規表現パターン
	pattern := `[0-9]+`

	// 正規表現をコンパイル
	re, err := regexp.Compile(pattern)
	if err != nil {
		fmt.Println("Error compiling regex:", err)
		return
	}

	// マッチングを行う
	matches := re.FindAllString(text, -1)

	// 結果を出力
	for i, match := range matches {
		fmt.Printf("Match %d: %s\n", i+1, match)
	}
}

このコードを実行すると、与えられた文字列 "My house number is 123 and my zip code is 45678." から数字が抽出され、出力結果は以下のようになります。

Match 1: 123
Match 2: 45678

この例では、文字クラス [0-9] を使用して、数字にマッチする正規表現を作成しています。文字クラスは、特定の文字の範囲やグループにマッチすることができ、正規表現をより柔軟に扱うことができます。

可読性を高める方法

以下に、正規表現の可読性を高めるためのいくつかの方法を提案します。いままでのセクションで言及してきたことも含まれています。

  1. コメントを使用する:
    正規表現内にコメントを挿入することで、意図を明確にし、他の開発者が理解しやすくなります。Go言語では、正規表現のコメントはサポートされていませんが、他のコードと同様に適切なコメントを付けることはできます。
  1. 適切な量指定子を選択する:
    貪欲な量指定子(*, + など)を使用すると、マッチングが無駄に広がりすぎることがあります。代わりに、非貪欲な量指定子(*?, +? など)を使用することで、より具体的なマッチングを行い、意図が明確になります。

  2. 名前付きキャプチャグループを使用する:
    名前付きキャプチャグループ((?P<name>...))を使用することで、キャプチャグループに意味のある名前を付けることができ、正規表現の目的が理解しやすくなります。

  3. 文字クラスの短縮形を利用する:
    \d(数字)や \w(単語文字)などの短縮形を利用することで、正規表現を短くし、可読性を向上させることができます。

  4. 正規表現を分割し、分かりやすいパターンを使用する:
    長い正規表現は、複数の短い正規表現に分割することで、可読性を向上させることができます。分割された正規表現は、それぞれの目的が明確であるため、理解しやすくなります。

これらの方法を組み合わせ、意識することで可読性の高い正規表現を書くことができます。

まとめ

正規表現の基本から実例、可読性についての記事にしました。エンジンや方言については触れず、なるべくとっつきやすい構成にしたつもりです。
私自身、最近はあまりGoを書くことが少なくなったのですが、本記事を執筆中に Go Playground で遊びながら、改めてまた学び直してものにしていきたい言語だなと感じました。
ブラウザだけでさくっとかんたんにコードを実行できるGoは、正規表現を学ぶにも適していると思います。

Discussion