このページの最終更新日 2020/10/25

[C#] 文字列操作と正規表現 - 導入編

 プログラムで文字列を解析する場合、プログラムを始めたばかりの頃は正規表現がわからず、文字列をいじくり回すメソッドを使ってなんとかしようとしがちなものではないでしょうか。例えば、文字列の中に特定の文字が何文字目に含まれていて、その場所から何文字先には何の文字があるかを判定するなどのやり方です。
 このやり方でも問題は解決するのですが、プログラムの記述が一気に複雑になり、バグも出やすくなり、のちのち修正しようと思ったときにもまずコードの理解に時間を取られることが多くなります。 このような文字列操作が複雑化するようなコードに対して、正規表現を使用すると、コードがすっきりして見た目にも分かり易いコードになることが多いものです。
 もちろん正規表現を覚えて使いこなせるようになるまでにはそれなりの学習時間が必要ですし、具体的なサンプルコードもたくさん見て書いていく必要もあるでしょう。
 そこでこのページでは、実際の業務で使用したプログラムのコードの中から、正規表現を使っている箇所を教育用に作り替えて学習用のサンプルを提示していくことにします。

1. 文字列の中から特定の範囲内に存在する文字列を抽出する

 例えば、A,B,Cにはそれぞれ整数値が与えられており、それが「A=123,B=456,C=789」のような文字列として表現されているとします。
 この文字列からBの値は何であるのか、プログラムを書いて取得することを考えてみます。
 具体的にC#で正規表現を使わずにこの値を取得するとき、stringクラスのIndexOfやSubstringメソッドを使って次のようなコードを書くことが考えられるでしょう。5行目のConsole.WriteLine(valB)で、「456」と出力されることを確認してみて下さい。

string textData = "A=123,B=456,C=789";
int startValB = textData.IndexOf("B=")+"B=".Length;
int endValB = textData.IndexOf(",", startValB);
string valB = textData.Substring(startValB, endValB - startValB);
Console.WriteLine(valB);

 IndexOfメソッドを使用することで「B=」という文字が全体の何文字目に登場するかが解かり、さらに「B=」の次のカンマの文字のインデックスも取得します。Substringを使って「B=」とその次の「,」の文字の間にある数字を取得することが出来ています。これはこれで正しいコードではあります。しかし、このようなコードは直観的に解かりにくく感じることがあります。

 ここで正規表現を使ってBの値を取得することを考えてみます。コードは次のようになります。

string textData = "A=123,B=456,C=789";
var rgxValB = new System.Text.RegularExpressions.Regex(@"B=(?<valB>\d+)");
System.Text.RegularExpressions.Match mtValB = rgxValB.Match(textData);
Console.WriteLine(mtValB.Groups["valB"]);
 正規表現を知らなければ「"B=(?<valB>\d+)"」が何を表しているのか良く分からないかもしれません。\d+ というのは数字の連続した並びを表しています。ここでは、\d+は「456」の部分を表しています。
 ?<valB>は()内の文字列をグループ化してグループ名をつけてるという操作を行っています。mtValB.Groups["valB"] のようにグループ名を指定してグループ化した文字列を抽出することが出来ます。
いかがでしょうか。少し慣れないと難しく感じるかもしれませんが、IndexOfやSubstringで文字列を引っ掻き回すと一体どういう文字列を扱っているのかが分かり辛いものです。
 ここで示しているコードでは「string textData = "A=123,B=456,C=789"」のようにtextData変数の文字列を明示していますが、実際のプログラムのコードを読んでいると、このtextData変数にどのような値が入力されるのか自体、直ぐに解らないような場合も多いものです。
 正規表現を使うことで、どのような文字列を考えているのか、ということのヒントになることも多いものです。



2. 複数の条件が組み合わさる場合

 ある文字列が何か特定の文字列で始まっているかを判定したい場合があったとします。ある文字列が"A1"という文字で始まっているか否か、という単一の条件なら簡単なのですが、 "A1"もしくは"B2"もしくは"C3"のいずれかで始まっているか否か、のように複数の条件を判定したい場合どうすれば良いでしょうか。 StartWithを使う場合はこうでしょうか。

using System.Text.RegularExpressions; //以下、この宣言は省略して書く。
string inputData = "C32gla090"; //判定する文字列
bool isMatchByStartWith = (inputData.StartsWith("A1") || inputData.StartsWith("B2") || inputData.StartsWith("C3"));
Console.WriteLine(isMatchByStartWith ? "一致しています" :"一致していません"); //出力結果:一致しています

 正規表現を使った場合は、次のように短く書けるでしょう。^は文字列の開始位置であることを表し、| は or条件になります。

string inputData = "C32gla090"; //判定する文字列
bool isMatchByRegex = Regex.IsMatch(inputData, "^(A1|B2|C3)");
Console.WriteLine(isMatchByRegex ? "一致しています" :"一致していません"); //出力結果:一致しています


3. 連続した半角空白文字で文字列を分割する

 正規表現はSplitやReplaceと共に使いこなせると、文字列操作が楽になる場合が多いです。 例えば、連続した半角空白文字で文字列を区切りたい場合などがあります。正規表現を使わないのであれば、2つ以上の半角空白文字を1つの半角空白文字に一旦置き換えて、1つの半角空白文字で改めてSplitする方法などが考えられます。

string inputData = "abcd      defg   hijk";
const string HALF_WIDTH_BLANK = " ";
while (inputData.Contains(HALF_WIDTH_BLANK + HALF_WIDTH_BLANK))
{
    inputData = inputData.Replace(HALF_WIDTH_BLANK + HALF_WIDTH_BLANK, HALF_WIDTH_BLANK);
}
string[] inputDataArray = inputData.Split(HALF_WIDTH_BLANK[0]);
inputDataArray.ToList().ForEach(m => Console.WriteLine(m));  //出力結果:abcd
                                //    :defg
                                //    :hijk

 正規表現の場合は、文字の繰り返しは「+」で表現出来るため、これを使ってSplitをします。

string inputData = "abcd      defg   hijk";
const string HALF_WIDTH_BLANK = " ";
string[] inputDataArray = Regex.Split(inputData, HALF_WIDTH_BLANK + "+");
inputDataArray.ToList().ForEach(m => Console.WriteLine(m));  //出力結果:abcd
                                //    :defg
                                //    :hijk

4. マッチする箇所が複数ある場合

次のようなテキストファイル(ファイル名「result.txt」)があり、

XX1001A
XX1002B
XX10003A
XX10004B
XX10k005A

 このテキストファイルの中から、「XX」という文字列で始まり「A」という文字で終わる行を探すものとします。また、「XX」と「A」の間は整数値であるとし、この条件に合致する整数値の一覧を作成します。さらに「result.txt」ファイルの改行コードはCRLF(\r\n)であるとします。
 まずは正規表現を使わないで、テキストファイルを1行ずつ読み込んで、それぞれの行が条件に一致しているかを調べるコードを例示します。テキストファイルの最後の行は「XX」と「A」の間に数字だけでなく「k」というアルファベットが入っており条件を満たしません。以下のコードでは「int.TryParse」メソッドによって「XX10k005A」という行はfalseとなり、「idList」リストの中に入らないようにしています。

string filePath = @"C:-----\result.txt";
var idList = new List<int>();
using (var sr = new IO.StreamReader(filePath, Encoding.UTF8))
{
    while (!sr.EndOfStream)
    {
        string oneline = sr.ReadLine();

        if (oneline.StartsWith("XX") && oneline.EndsWith("A"))
        {
            string content = oneline.Substring(2, oneline.Length - 3);

            int idval = -1;
            if (int.TryParse(content, out idval))
            {
                idList.Add(idval);
            }
        }
    }
}
idList.ForEach(m => Console.Write(m + ",")); //出力結果:1001,10003,

 正規表現を使う場合を下に書いてみます。C#では Regex.Matchクラスで複数マッチする箇所が見つかった場合は、NextMatchメソッドで順次マッチする箇所を取り出していくことが出来ます。
 また、^や$の記号を改行コードと見なせる「RegexOptions.Multiline」オプションを指定することが出来るのですが、改行コードがCRLF(\r\n)の場合はマッチしないようであるため「\r\n」とそのまま記述しています。

 string filePath = @"C:-----\result.txt";
var idList = new List<int>();

string allText;
using (var sr = new IO.StreamReader(filePath, Encoding.UTF8))
{
    allText = sr.ReadToEnd();
}

Match idMatch = Regex.Match(allText, @"(^|\r\n)XX(?<idVal>\d+)A($|\r\n)");
while (idMatch.Success)
{
    idList.Add(int.Parse(idMatch.Groups["idVal"].Value));
    idMatch = idMatch.NextMatch();
}
idList.ForEach(m => Console.Write(m + ","));

 上の例だと正規表現を使った方が少し複雑になってしまったかもしれません。けれども条件がもっと複雑になってきた場合はどうでしょうか。 例えば「AA89966BB33556CC」のように、AAで始まり連続した数値の並びがあり、間にBBがあって更に数値の並びがありCCで終わる。しかも数値の部分がちゃんと数値である保障がなく「AA89966BB33x556CC」のような変なものは排除しなければならない。このように条件が複雑化してきた場合、文字列をひっかき回す方法では難しくなってくるものです。