VB.NET(.NET)の正規表現で、連続する空白やタブなどの「個数」を区切りとして文字列をまとめたいのに、Regex.Matches の結果が“区切りの空白列そのもの”になって困ることがあります。これは正規表現の書き方と Matches の仕様が噛み合っていないのが原因です。この記事では原因をほどきつつ、要件に合う取り出し方を3パターン+実務向けの堅牢案までまとめます。
Regex.Matches が「空白そのもの」を返す理由(仕様どおり)
まず結論から言うと、次のパターンは「区切り(連続空白)を見つける」ための正規表現です。
(?<=\S)\s{LenSpace,}(?=\S)
(?<=...)(肯定の後読み)と (?=...)(肯定の先読み)は、どちらもマッチの前後条件をチェックするだけで、実際にマッチして返ってくる文字列を“置き換える”機能ではありません。上の式で実際にマッチしているのは真ん中の \s{LenSpace,}(連続する空白・タブ等)なので、Regex.Matches が返す Match.Value が「空白列」になるのは自然です。
言い換えると、「区切りを探す正規表現」を Matches に渡したら区切りが返る、ただそれだけです。欲しいのが「区切り間の要素(トークン)」なら、正規表現そのものを“要素にマッチする形”へ変えるか、Splitで分割する方向に切り替えるのが近道になります。
要件の再確認:LenSpace 以上は区切り、未満は同一要素として保持
今回の要件は次の考え方です。
- LenSpace 以上の連続
\s(空白やタブ等)が出たら、そこは区切り - LenSpace 未満の
\sは、同じ塊(同一要素)に含める
例として LenSpace = 2 の場合は、空白が1個なら同一要素、空白が2個以上なら区切り、という意味になります。
| 連続する \s の個数 | LenSpace=2 の扱い | 要素としてどうなるか |
|---|---|---|
| 0 | 通常文字列 | そのまま同一要素 |
| 1 | 区切りではない | 同一要素内の空白として保持 |
| 2以上 | 区切り | 要素を分割する境界 |
この条件で、たとえば以下のように「2個以上の空白(またはタブ等)の並び」で要素が切り替わり、要素内では1個の空白が残るのが期待値です。
{aA Aa, aAA BBa, aSS UU POPO SSa, aGABa, aZZZZa}
解決策A:区切り(LenSpace 以上の空白)の“手前まで”をマッチして要素を抜き出す
採用回答の考え方として分かりやすいのがこの方法です。ポイントは、区切りをマッチさせるのではなく、要素そのものをマッチさせるように正規表現を作り替えることです。
Imports System.Text.RegularExpressions
Dim regMatch As MatchCollection =
Regex.Matches(sttTest,
"\S.*?(?=\s{" & LenSpace & ",}|\s*$)")
このパターンの読み方は次のとおりです。
\S:要素は非空白文字から始まる.*?:そこから先を最短一致で進む(= 要素を伸ばす)(?=\s{LenSpace,}|\s*$):ただし、次がLenSpace以上の空白か末尾の手前で止まる
つまり「非空白から始まり、区切り(または末尾)の直前まで」が、1要素として取り出せます。Matches が返すのはこの要素なので、Match.Value は空白ではなく期待したテキストになります。
動作サンプル(LenSpace=2)
以下のような入力を想定します(見やすさのため、区切りは複数空白、要素内は単一空白にしています)。
aA Aa aAA BBa aSS UU POPO SSa aGABa aZZZZa
上記に対して解決策Aのパターンを使うと、概念的には次のように取れます。
| 抽出順 | Match.Value(要素) | 区切りの判定 |
|---|---|---|
| 1 | aA Aa | 次が連続 \s(2以上) |
| 2 | aAA BBa | 次が連続 \s(2以上) |
| 3 | aSS UU POPO SSa | 次が連続 \s(2以上) |
| 4 | aGABa | 次が連続 \s(2以上) |
| 5 | aZZZZa | 末尾 |
解決策Aの注意点
- 改行を含む可能性がある入力では、
.が改行にマッチしないため、要素が途中で切れることがあります。複数行をひとつの文字列として扱うなら、RegexOptions.Singlelineを使うか、.*?の代わりに[\s\S]*?のような「改行も含む任意文字」に置き換えます。 - 要素の先頭が必ず非空白である前提なので、入力が全部空白のときはマッチが0件になります(要件的には自然な挙動です)。
解決策B:要件をそのまま正規表現に落として「要素そのもの」をマッチさせる
「LenSpace 未満の空白は同一要素に含める」という要件を、正規表現で素直に表現するならこちらです。要素は「非空白の塊」+「(LenSpace-1個までの空白+非空白の塊)の繰り返し」として書けます。
Dim patt = "\S+(?:\s{1," & (LenSpace - 1) & "}\S+)*"
Dim regMatch = Regex.Matches(sttTest, patt)
LenSpace = 2 のとき、この式は次のように解釈できます。
\S+:非空白の連続(要素の最初の単語)(?:\s{1,1}\S+)*:空白1個+非空白の連続、を必要なだけ繰り返す(要素内で単語が増える)
結果として、空白が1個の場所は同一要素に取り込み、空白が2個以上の場所で要素が途切れます。今回の要件にかなり忠実で、後から見返しても「何をしたいのか」が読み取りやすいのが利点です。
LenSpace の下限に注意(LenSpace=1 のとき)
実務では LenSpace が外部入力(設定値・UI値)になることがあります。もし LenSpace が 1 の場合、(LenSpace - 1) が 0 になり、\s{1,0} のような不正な量指定子になって例外が起きます。
安全にするなら、例えば次のように分岐させるのが堅いです。
Dim patt As String
If LenSpace <= 1 Then
' 1文字以上の空白で区切る=通常の単語抽出に近い
patt = "\S+"
Else
patt = "\S+(?:\s{1," & (LenSpace - 1) & "}\S+)*"
End If
Dim regMatch = Regex.Matches(sttTest, patt)
解決策C:Regex.Split で分割する(最短で読みやすい)
「区切りが LenSpace 以上の空白列」と定義できているなら、分割(Split)で解くのが最もシンプルです。分割後にトリムして空要素を落とせば、実務ではこれで十分なケースも多いです。
Imports System.Text.RegularExpressions
Imports System.Linq
Dim parts = Regex.Split(sttTest, "\s{" & LenSpace & ",}") _
.Select(Function(x) x.Trim()) _
.Where(Function(x) x <> "") _
.ToArray()
この方法のポイントは次のとおりです。
Regex.Splitは「区切りの正規表現」を渡す(今回の要件に自然)- 先頭や末尾に区切りがあると空文字が混ざるので、
Trimと空要素除外で仕上げる
「区切りは欲しいが、戻り値としては要素が欲しい」なら、Split は目的に直結しています。さらに、抽出した要素をそのまま配列で扱えるため、後工程が書きやすいのもメリットです。
3つの解決策の選び方(おすすめ早見表)
どれが正解というより、入力の性質と、後工程で何をしたいかで選ぶのがコツです。
| 観点 | 解決策A(区切り直前までをMatches) | 解決策B(要件に忠実なMatches) | 解決策C(Regex.Split) |
|---|---|---|---|
| 読みやすさ | 中(先読みの意図を理解すると明快) | 高(「未満なら結合」が直に表現される) | 高(区切りで分割するだけ) |
| 要件追従 | 高(区切り基準に沿う) | 最高(未満の扱いがそのまま) | 高(区切りで分割+整形) |
| 末尾の空白 | 先読みで吸わない(扱いやすい) | そもそもマッチ外 | 空要素が出やすい(Trim/除外が必要) |
| パフォーマンス | 一般に良好(最短一致の連続なので入力による) | 良好(構造が素直) | 良好(分割+LINQのコストはある) |
| おすすめ用途 | 要素を順に走査し、必要ならMatch情報も使いたい | 要件を明確に表現し、保守性を上げたい | 最短で配列が欲しい、後工程がSplit中心 |
よくある落とし穴:「以上」「以下」「未満」を1文字ズラすミス
この手の要件で一番起きやすいのが、量指定子の境界を取り違えるミスです。たとえば次のようなパターンは、一見それっぽく見えますが、要件によってはズレます。
[^\s]+(?:\s{1,LenSpace}[^\s]+)*
この式は「LenSpace 以下の空白なら結合」になっています。今回の要件は「LenSpace 未満なら結合」なので、正しくは LenSpace-1 を使う必要があります。
| 要件の日本語 | 量指定子の上限 | 例(LenSpace=2) |
|---|---|---|
| LenSpace 未満を結合 | LenSpace-1 | \s{1,1} |
| LenSpace 以下を結合 | LenSpace | \s{1,2} |
ここを1つでも間違えると、「2個の空白でも結合されてしまう」「分割されるはずが分割されない」といった、見た目では気づきにくいバグになります。正規表現を固定文字列で書くよりも、LenSpace を変えたテストケースを用意して動作確認するのが安全です。
\s は何を含む? タブ・改行・全角スペースの扱いを明確にする
\s は単なる半角スペースだけではなく、タブや改行なども含む「空白類」にマッチします。今回の要件が「非表示文字(\s)の連続数」を区切りにする、という前提なら \s は便利です。一方で、入力によっては「改行を区切りにしたくない」「全角スペースは別扱いにしたい」といった要望が出ることもあります。
その場合は、どの文字を“空白”とみなすかを明示したパターンに置き換えるのが実務的です。
| 扱いたい“空白” | おすすめの正規表現 | 補足 |
|---|---|---|
| 半角スペースのみ | {LenSpace,} | タブは区切り判定に含めない |
| 半角スペース+タブのみ | [ \t]{LenSpace,} | 改行を含めない(ログ等で有効) |
| すべての空白類(既定) | \s{LenSpace,} | 入力が多様ならまずこれが無難 |
| 全角スペースも含めたい | (?:\s|\u3000){LenSpace,} | 環境によって全角スペース混入がある場合 |
また、重要な注意点として、タブは「見た目の幅」ではなく「1文字」としてカウントされます。たとえば「タブ1個+スペース1個」は、\s 連続2文字と見なされるので、LenSpace=2 の区切り条件に引っかかります。要件が“見た目の列揃え”に依存するなら、正規表現よりも表示幅を計算する別処理が必要になります。
実務で強い:正規表現を使わない(空白ラン長で区切る)堅牢実装
正規表現は便利ですが、入力が巨大だったり、条件が増えたりすると、可読性やデバッグ性で苦しくなることがあります。そこで「連続する空白(\s)の長さで区切る」だけに集中した、シンプルなパーサ(状態機械)実装も紹介します。
この方法は次の強みがあります。
- LenSpace の意味がコード上で直感的
- 改行や全角スペース等の扱いを
Char.IsWhiteSpaceなどで調整しやすい - 想定外入力のときに挙動を追いやすい
Imports System.Text
Public Function SplitByWhitespaceRun(input As String, lenSpace As Integer) As String()
If input Is Nothing Then Return Array.Empty(Of String)()
If lenSpace <= 1 Then
' 1文字以上の空白で区切る(= Trimした単語列に近い)
Return input.Split(New Char() {" "c, ControlChars.Tab, ControlChars.Cr, ControlChars.Lf},
StringSplitOptions.RemoveEmptyEntries)
End If
Dim result As New List(Of String)()
Dim sb As New StringBuilder()
Dim i As Integer = 0
While i < input.Length
Dim ch = input(i)
If Char.IsWhiteSpace(ch) Then
' 空白ランの長さを数える
Dim j As Integer = i
While j < input.Length AndAlso Char.IsWhiteSpace(input(j))
j += 1
End While
Dim runLen As Integer = j - i
If runLen >= lenSpace Then
' 区切り:今までの要素を確定
Dim token = sb.ToString().Trim()
If token.Length > 0 Then result.Add(token)
sb.Clear()
Else
' 未満:要素内の空白として保持(そのまま追加)
sb.Append(input.Substring(i, runLen))
End If
i = j
Else
sb.Append(ch)
i += 1
End If
End While
' 最後の要素を確定
Dim lastToken = sb.ToString().Trim()
If lastToken.Length > 0 Then result.Add(lastToken)
Return result.ToArray()
End Function
「正規表現を捨てる」と聞くと大げさですが、要件が“空白ラン長で区切る”だけなら、このループはかなり強力です。後から「区切りはスペースだけ」「タブは区切りに含めない」などの仕様が出ても、Char.IsWhiteSpace の部分を置き換えるだけで対応しやすいのが魅力です。
検証用のテストコード(期待する配列になるか)
正規表現の境界条件は、目視で合っているように見えても、入力の揺れで壊れがちです。最小限でいいので、実際の入力に近いケースをテストすると安全です。
Dim sttTest As String =
"aA Aa aAA BBa aSS UU POPO SSa" & vbTab & vbTab & "aGABa aZZZZa"
Dim LenSpace As Integer = 2
' A
Dim mcA = Regex.Matches(sttTest, "\S.*?(?=\s{" & LenSpace & ",}|\s*$)")
Dim partsA = mcA.Cast(Of Match)().Select(Function(m) m.Value).ToArray()
' B
Dim pattB = "\S+(?:\s{1," & (LenSpace - 1) & "}\S+)*"
Dim mcB = Regex.Matches(sttTest, pattB)
Dim partsB = mcB.Cast(Of Match)().Select(Function(m) m.Value).ToArray()
' C
Dim partsC = Regex.Split(sttTest, "\s{" & LenSpace & ",}") _
.Select(Function(x) x.Trim()) _
.Where(Function(x) x <> "") _
.ToArray()
Console.WriteLine(String.Join(" | ", partsA))
Console.WriteLine(String.Join(" | ", partsB))
Console.WriteLine(String.Join(" | ", partsC))
ここで出力がすべて同じになれば、3案は少なくとも今回の要件では同等に使えます。あとは「読みやすさ」「後工程の都合」「入力の種類(改行あり/なし)」で選ぶと失敗が減ります。
まとめ:Matches は“区切り”ではなく“要素”にマッチさせるのが正攻法
(?<=\S)\s{LenSpace,}(?=\S) が空白を返すのは、正規表現が「区切りの空白列」をマッチしているからで、挙動としては正しいです。欲しいのが区切り間の要素なら、発想を切り替えて次のいずれかに寄せるのが最短ルートです。
- 区切り直前までを最短一致で抜く(解決策A)
- LenSpace 未満の空白を要素内に含める規則をそのまま書く(解決策B)
- 区切り定義が明確なら Split で分割して整形する(解決策C)
特に「未満/以下」の境界は1文字ズレるだけで結果が激変するため、LenSpace=2 だけでなく、3や4などでもテストしておくと安心です。要件が固まっていて保守性を重視するなら解決策B、短く書いて配列が欲しいなら解決策C、Matchの走査や柔軟性を取りたいなら解決策A、と覚えておくと使い分けが楽になります。

コメント