文字列

【C#】Substring・Split・Regexでおさえる文字列切り出しの基本操作と実用例

C#で文字列を切り出すならSubstringSplitの併用が基本で、開始位置か区切り文字のどちらを主軸にするかで選ぶと効率的です。

前後関係が複雑な場合はRegexで抽出パターンを定義すると保守性が上がり、コードの意図も明確になります。

文字列切り出しの考え方

C#で文字列を扱う際、特定の部分だけを取り出す操作は非常に頻繁に行われます。

文字列切り出しの基本的な考え方は、大きく分けて「特定位置での抽出」「区切り文字での分割」「パターンマッチによる抽出」の3つに分類できます。

ここではそれぞれの特徴や使いどころについて詳しく解説いたします。

特定位置での抽出

特定位置での抽出は、文字列の中の「何文字目から何文字分」という位置情報を指定して部分文字列を取り出す方法です。

C#では主にSubstringメソッドを使って実現します。

例えば、文字列の先頭から5文字だけを取り出したい場合や、10文字目から3文字だけを切り出したい場合に使います。

位置は0から始まるインデックスで指定し、開始位置と長さを明示的に指定するため、切り出したい範囲が明確なときに便利です。

ただし、開始位置や長さが文字列の範囲外になると例外が発生するため、事前に文字列の長さを確認するなどの対策が必要です。

また、マルチバイト文字や絵文字などの特殊文字を扱う場合は、単純なインデックス指定が意図した結果にならないこともあるため注意が必要です。

区切り文字での分割

区切り文字での分割は、文字列の中に含まれる特定の文字や文字列を境にして複数の部分文字列に分割する方法です。

C#ではString.Splitメソッドが代表的です。

例えば、CSV形式の文字列をカンマで分割したり、文章をスペースや句読点で区切って単語ごとに分割したりする場合に使います。

複数の区切り文字を指定できるため、複雑な分割も柔軟に対応可能です。

Splitは分割した結果を配列で返すため、分割後の各要素に対してループ処理を行うことが多いです。

また、空文字列を結果から除外するオプションもあり、不要な空要素を簡単に取り除けます。

区切り文字が連続している場合や、区切り文字が文字列の先頭や末尾にある場合の挙動にも注意が必要です。

パターンマッチによる抽出

パターンマッチによる抽出は、正規表現(Regex)を使って文字列の中から特定のパターンにマッチする部分を抽出する方法です。

System.Text.RegularExpressions.Regexクラスを利用します。

この方法は、単純な位置指定や区切り文字だけでは対応できない複雑な条件で文字列を切り出したい場合に非常に有効です。

例えば、数字だけを抽出したり、特定の形式の日付やメールアドレスを取り出したりすることができます。

正規表現はパターンを柔軟に定義できる反面、パターンの作成が難しい場合もあります。

名前付きキャプチャグループを使うことで、抽出したい部分を明確に指定でき、コードの可読性も向上します。

また、正規表現は処理コストが高くなることがあるため、単純な切り出しにはSubstringSplitを優先し、複雑なパターン抽出が必要な場合に使うのが望ましいです。

Substringの基本

メソッドシグネチャと引数

Substringメソッドは、文字列の一部分を切り出すためのメソッドで、主に2つのオーバーロードがあります。

1つ目は開始位置だけを指定する方法です。

この場合、開始位置から文字列の末尾までを切り出します。

public string Substring(int startIndex);

2つ目は開始位置と切り出す長さを指定する方法です。

public string Substring(int startIndex, int length);
  • startIndexは切り出しを開始する文字のインデックス(0から始まる)です
  • lengthは切り出す文字数を指定します

例えば、"HelloWorld"の3文字目(インデックス2)から4文字を切り出す場合は、Substring(2, 4)となり、結果は"lloW"です。

0-basedインデックスの扱い

Substringのインデックスは0から始まるため、文字列の最初の文字はインデックス0です。

これは配列のインデックスと同じ考え方で、プログラミングに慣れている方には直感的です。

例えば、文字列"Example"の最初の文字'E'はインデックス0、2番目の文字'a'はインデックス1となります。

このため、開始位置を指定する際は0から数え始めることを忘れないようにしてください。

文字列長と例外対策

Substringを使う際に注意したいのが、startIndexlengthが文字列の範囲外になると例外が発生することです。

具体的には、ArgumentOutOfRangeExceptionがスローされます。

例えば、文字列の長さが10文字の場合、startIndexは0から9まで指定可能ですが、10以上を指定すると例外になります。

また、startIndex + lengthが文字列の長さを超える場合も例外が発生します。

IndexOutOfRangeExceptionの防止

例外を防ぐためには、Substringを呼び出す前に文字列の長さをチェックすることが重要です。

以下のように条件を確認してから切り出すと安全です。

string text = "HelloWorld";
int startIndex = 5;
int length = 3;
if (startIndex >= 0 && startIndex < text.Length && startIndex + length <= text.Length)
{
    string result = text.Substring(startIndex, length);
    Console.WriteLine(result); // 出力: Wor
}
else
{
    Console.WriteLine("指定した範囲が文字列の長さを超えています。");
}

このように条件を満たさない場合は処理をスキップしたり、例外処理を行うことで安全に文字列を切り出せます。

マルチバイト文字への注意

Substringは文字列のインデックスを文字単位で扱いますが、Unicodeのマルチバイト文字やサロゲートペア(絵文字など)を含む場合、1文字が複数のコードユニットで表現されることがあります。

例えば、絵文字は1文字に見えても内部的には2つのcharで構成されていることが多いです。

そのため、Substringで単純にインデックスを指定すると、絵文字の途中で切れてしまい、意図しない結果になることがあります。

string emojiText = "Hello👋World";
string part = emojiText.Substring(5, 2);
Console.WriteLine(part);

このコードは"👋W"のように絵文字の一部だけを切り出す可能性があり、文字化けや不正な文字列になることがあります。

この問題を回避するには、System.Text.StringInfoクラスを使ってテキスト要素(グラフェムクラスタ)単位で扱う方法があります。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string emojiText = "Hello👋World";
        StringInfo stringInfo = new StringInfo(emojiText);
        // 5番目のテキスト要素から2つ分を取得
        string result = stringInfo.SubstringByTextElements(5, 2);
        Console.WriteLine(result); // 出力: 👋W
    }
}

このようにStringInfoを使うと、絵文字や複数コードユニットで構成される文字も正しく切り出せます。

マルチバイト文字を含む文字列を扱う場合は、Substringの単純な使用に注意してください。

Splitの基本

区切り文字の単一指定

Splitメソッドは、文字列を指定した区切り文字で分割し、部分文字列の配列を返します。

最も基本的な使い方は、単一の区切り文字を指定する方法です。

例えば、カンマ,を区切り文字として文字列を分割する場合は以下のように記述します。

using System;
class Program
{
    static void Main()
    {
        string sentence = "Apple,Banana,Cherry";
        string[] fruits = sentence.Split(',');
        foreach (string fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
Apple
Banana
Cherry

このコードでは、sentenceの中のカンマを境に3つの単語に分割し、それぞれを配列fruitsに格納しています。

Splitは区切り文字が見つかるたびに文字列を分割し、結果を配列で返します。

複数区切り文字の利用

複数の区切り文字を指定して分割することも可能です。

Splitメソッドの引数に文字の配列を渡すことで、どれか1つでも該当する区切り文字があれば分割されます。

例えば、スペース、カンマ、セミコロンを区切り文字として使う場合は以下のようにします。

using System;
class Program
{
    static void Main()
    {
        string sentence = "Apple, Banana; Cherry Orange";
        char[] delimiters = { ' ', ',', ';' };
        string[] fruits = sentence.Split(delimiters);
        foreach (string fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
Apple

Banana

Cherry
Orange

この例では、スペース、カンマ、セミコロンのいずれかで文字列を分割し、単語ごとに分けています。

複数の区切り文字を指定することで、より柔軟な分割が可能です。

StringSplitOptionsの活用

Splitメソッドには、分割結果の配列に空文字列が含まれるかどうかを制御するためのオプションStringSplitOptionsがあります。

主に2つの値が使われます。

RemoveEmptyEntriesで空要素除去

区切り文字が連続している場合や、文字列の先頭・末尾に区切り文字があると、空の文字列が分割結果に含まれることがあります。

RemoveEmptyEntriesを指定すると、これらの空文字列を除外できます。

using System;
class Program
{
    static void Main()
    {
        string sentence = "Apple,,Banana,,Cherry,";
        char delimiter = ',';
        string[] fruits = sentence.Split(new char[] { delimiter }, StringSplitOptions.RemoveEmptyEntries);
        foreach (string fruit in fruits)
        {
            Console.WriteLine(fruit);
        }
    }
}
Apple
Banana
Cherry

このコードでは、カンマが連続している部分や末尾のカンマによって生じる空文字列が結果から除かれています。

空の要素を取り除きたい場合に便利です。

Noneで空要素保持

StringSplitOptions.Noneを指定すると、空文字列も分割結果に含まれます。

これはデフォルトの動作で、空の要素も必要な場合に使います。

using System;
class Program
{
    static void Main()
    {
        string sentence = "Apple,,Banana,,Cherry,";
        char delimiter = ',';
        string[] fruits = sentence.Split(new char[] { delimiter }, StringSplitOptions.None);
        foreach (string fruit in fruits)
        {
            Console.WriteLine($"'{fruit}'");
        }
    }
}
'Apple'
''
'Banana'
''
'Cherry'
''

空文字列が''として出力されているのがわかります。

空の要素も含めて処理したい場合はこちらを使います。

カンマ区切りCSVの分割テクニック

CSV(カンマ区切り値)形式の文字列を分割する際、単純にSplit(',')を使うと、カンマがデータの一部として含まれている場合に正しく分割できません。

例えば、値がダブルクォーテーションで囲まれている場合です。

string csv = "John,Doe,\"New York, NY\",30";
string[] parts = csv.Split(',');

この場合、"New York, NY"の中のカンマで分割されてしまい、意図しない結果になります。

この問題を解決するには、正規表現を使うか、CSVパーサーを利用するのが一般的です。

簡単な正規表現を使った例を示します。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string csv = "John,Doe,\"New York, NY\",30";
        string pattern = @",(?=(?:[^""]*""[^""]*"")*[^""]*$)";
        string[] parts = Regex.Split(csv, pattern);
        foreach (string part in parts)
        {
            Console.WriteLine(part.Trim('\"'));
        }
    }
}
John
Doe
New York, NY
30

この正規表現は、ダブルクォーテーションで囲まれていないカンマのみで分割するため、カンマを含むフィールドを正しく扱えます。

より複雑なCSV処理が必要な場合は、Microsoft.VisualBasic.FileIO.TextFieldParserや外部ライブラリの利用を検討してください。

単純な分割では対応できないケースが多いため注意が必要です。

Regexによる抽出

正規表現パターンの基礎

正規表現(Regex)は、文字列のパターンを表現し、そのパターンにマッチする部分を検索・抽出する強力なツールです。

C#ではSystem.Text.RegularExpressions名前空間のRegexクラスを使います。

基本的な正規表現パターンの例をいくつか挙げます。

  • \d:数字(0-9)にマッチ
  • \w:英数字およびアンダースコアにマッチ
  • .:任意の1文字にマッチ(改行を除く)
  • +:直前のパターンが1回以上繰り返される
  • *:直前のパターンが0回以上繰り返される
  • ?:直前のパターンが0回または1回
  • []:文字クラス。中のいずれか1文字にマッチ(例:[abc]はaかbかc)
  • ^:文字列の先頭にマッチ
  • $:文字列の末尾にマッチ

例えば、数字の連続を抽出したい場合は\d+というパターンを使います。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string input = "Order123 and Order456";
        string pattern = @"\d+";
        MatchCollection matches = Regex.Matches(input, pattern);
        foreach (Match match in matches)
        {
            Console.WriteLine(match.Value);
        }
    }
}
123
456

このように、正規表現は複雑な文字列の中から特定のパターンを簡単に抽出できます。

MatchとMatchCollectionの使い分け

Regexクラスのメソッドには、MatchMatchesがあります。

  • Matchは最初にマッチした1つの結果を返します。マッチがなければMatch.Successfalseになります
  • Matchesはマッチしたすべての結果をMatchCollectionとして返します

単一のマッチを取得したい場合はMatchを使い、複数のマッチを取得したい場合はMatchesを使います。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string input = "abc123def456ghi789";
        string pattern = @"\d+";
        // 最初のマッチだけ取得
        Match firstMatch = Regex.Match(input, pattern);
        if (firstMatch.Success)
        {
            Console.WriteLine("最初の数字: " + firstMatch.Value);
        }
        // すべてのマッチを取得
        MatchCollection allMatches = Regex.Matches(input, pattern);
        Console.WriteLine("すべての数字:");
        foreach (Match match in allMatches)
        {
            Console.WriteLine(match.Value);
        }
    }
}
最初の数字: 123
すべての数字:
123
456
789

キャプチャグループで複数値取得

正規表現では、丸括弧()を使ってパターンの一部をグループ化し、マッチした部分をキャプチャできます。

これにより、複数の値を同時に抽出可能です。

例えば、日付文字列"2023-06-15"から年、月、日を分けて取得する場合は以下のようにします。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string date = "2023-06-15";
        string pattern = @"(\d{4})-(\d{2})-(\d{2})";
        Match match = Regex.Match(date, pattern);
        if (match.Success)
        {
            Console.WriteLine("年: " + match.Groups[1].Value);
            Console.WriteLine("月: " + match.Groups[2].Value);
            Console.WriteLine("日: " + match.Groups[3].Value);
        }
    }
}
年: 2023
月: 06
日: 15

名前付きグループの利点

キャプチャグループには名前を付けることもできます。

名前付きグループは、(?<名前>パターン)の形式で定義し、Groups["名前"]でアクセスします。

これにより、コードの可読性が向上します。

先ほどの例を名前付きグループで書き換えます。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string date = "2023-06-15";
        string pattern = @"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})";
        Match match = Regex.Match(date, pattern);
        if (match.Success)
        {
            Console.WriteLine("年: " + match.Groups["year"].Value);
            Console.WriteLine("月: " + match.Groups["month"].Value);
            Console.WriteLine("日: " + match.Groups["day"].Value);
        }
    }
}
年: 2023
月: 06
日: 15

名前付きグループを使うことで、インデックス番号を覚える必要がなくなり、メンテナンス性が高まります。

ルックアヘッド・ルックビハインドの応用

ルックアヘッド(Lookahead)とルックビハインド(Lookbehind)は、特定のパターンの前後にある文字列を条件にマッチさせる高度な正規表現テクニックです。

これらはマッチの対象には含めず、条件だけをチェックします。

  • ポジティブ・ルックアヘッド:(?=パターン)

直後に指定パターンが続く場合にマッチ

  • ネガティブ・ルックアヘッド:(?!パターン)

直後に指定パターンが続かない場合にマッチ

  • ポジティブ・ルックビハインド:(?<=パターン)

直前に指定パターンがある場合にマッチ

  • ネガティブ・ルックビハインド:(?<!パターン)

直前に指定パターンがない場合にマッチ

例えば、数字の後に「円」が続く場合だけ数字を抽出したいときはポジティブ・ルックアヘッドを使います。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string input = "価格は1000円、送料は500円、割引は100円です。";
        string pattern = @"\d+(?=円)";
        MatchCollection matches = Regex.Matches(input, pattern);
        foreach (Match match in matches)
        {
            Console.WriteLine(match.Value);
        }
    }
}
1000
500
100

この例では、数字の後に「円」が続く場合のみマッチし、「円」自体は結果に含まれません。

ルックビハインドの例として、数字の前に「価格は」がある場合だけ抽出するパターンです。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string input = "価格は1000円、送料は500円、価格は2000円です。";
        string pattern = @"(?<=価格は)\d+";
        MatchCollection matches = Regex.Matches(input, pattern);
        foreach (Match match in matches)
        {
            Console.WriteLine(match.Value);
        }
    }
}
1000
2000

ルックアヘッド・ルックビハインドを活用すると、より複雑な条件で文字列を抽出できるため、正規表現の表現力が大幅に向上します。

パフォーマンス比較

SubstringとSpanの違い

Substringは文字列の一部を切り出して新しい文字列オブジェクトを生成します。

つまり、元の文字列から指定範囲の文字をコピーして新しいstringを作成するため、メモリ割り当て(アロケーション)が発生します。

大量にSubstringを使うとガベージコレクションの負荷が増える可能性があります。

一方、Span<T>(特にReadOnlySpan<char>)は、文字列の一部を参照する構造体であり、新しい文字列を生成せずに元の文字列のメモリを直接参照します。

これにより、コピーやアロケーションを避けて高速かつ低メモリで部分文字列を扱えます。

以下はSubstringReadOnlySpan<char>の違いを示す例です。

using System;
class Program
{
    static void Main()
    {
        string text = "Hello, World!";
        // Substringは新しい文字列を生成
        string sub1 = text.Substring(7, 5);
        Console.WriteLine(sub1); // 出力: World
        // ReadOnlySpan<char>は文字列の一部を参照
        ReadOnlySpan<char> span = text.AsSpan(7, 5);
        Console.WriteLine(span.ToString()); // 出力: World
    }
}
World
World

ReadOnlySpan<char>はスタック上に存在し、GCの対象外なので、短命な部分文字列の操作に適しています。

ただし、Spanはヒープに格納された文字列の寿命に依存するため、長期間保持する用途には向きません。

SplitとRegexのメモリアロケーション

Splitメソッドは区切り文字で文字列を分割し、分割された部分文字列の配列を返します。

Splitは比較的高速でシンプルな処理ですが、分割結果の配列と各部分文字列の新規生成にメモリ割り当てが発生します。

一方、Regexはパターンマッチングを行うため、内部で状態管理やキャプチャグループの処理を行い、Splitよりも処理コストが高くなりがちです。

さらに、Regexのマッチ結果はMatchオブジェクトとして生成されるため、より多くのメモリ割り当てが発生します。

例えば、単純な区切り文字で分割するだけならSplitのほうがパフォーマンスが良いことが多いですが、複雑なパターンで分割や抽出を行う場合はRegexが必要です。

BenchmarkDotNetでの測定例

BenchmarkDotNetはC#のパフォーマンス測定に特化したライブラリで、簡単にメソッドの実行速度やメモリ使用量を比較できます。

以下はSubstringReadOnlySpan<char>のパフォーマンスを比較する簡単なベンチマーク例です。

using System;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class SubstringVsSpanBenchmark
{
    private readonly string text = "The quick brown fox jumps over the lazy dog";
    [Benchmark]
    public string SubstringTest()
    {
        return text.Substring(10, 5);
    }
    [Benchmark]
    public string SpanTest()
    {
        ReadOnlySpan<char> span = text.AsSpan(10, 5);
        return span.ToString();
    }
}
class Program
{
    static void Main()
    {
        var summary = BenchmarkRunner.Run<SubstringVsSpanBenchmark>();
    }
}

このベンチマークを実行すると、Substringは新しい文字列を生成するためメモリ割り当てが発生し、Spanは参照のみなので割り当てが少なく高速であることがわかります。

BenchmarkDotNetは詳細な統計情報やGCコレクションの回数も表示してくれるため、文字列操作のパフォーマンス最適化に役立ちます。

実際のプロジェクトでどの手法が適しているか判断する際に活用してください。

Null安全と例外回避

Null合体演算子でのガード

C#では文字列操作を行う際に、対象の文字列がnullであるとNullReferenceExceptionが発生するリスクがあります。

これを防ぐために、Null合体演算子??やNull条件演算子?.を活用して安全に処理を行うことが重要です。

例えば、文字列のSubstringを呼び出す前に、対象文字列がnullでないかをチェックする代わりに、Null条件演算子を使うと簡潔に書けます。

using System;
class Program
{
    static void Main()
    {
        string? input = null;
        // Null条件演算子でnullチェックしつつSubstringを呼び出す
        string? result = input?.Substring(0, 3);
        Console.WriteLine(result ?? "入力がnullのため処理できません");
    }
}
入力がnullのため処理できません

この例では、inputnullの場合、Substringは呼び出されず、resultnullになります。

さらにNull合体演算子??で代替メッセージを表示しています。

また、SplitRegexの対象文字列にも同様のガードを入れることで、例外を未然に防げます。

string? text = null;
string[] parts = text?.Split(',') ?? Array.Empty<string>();
Console.WriteLine($"分割結果の要素数: {parts.Length}");

このように、Null合体演算子を使うと、nullの場合に空の配列を返すなどの安全な処理が可能です。

try -catchを減らす設計

例外処理はプログラムの安定性を高めますが、過剰に使うとパフォーマンス低下やコードの可読性低下を招きます。

特に文字列操作で発生しやすいArgumentOutOfRangeExceptionNullReferenceExceptionは、事前チェックで防ぐことが望ましいです。

例えば、Substringの開始位置や長さが文字列の範囲内かを事前に確認してから呼び出す設計にすると、例外を発生させずに済みます。

using System;
class Program
{
    static void Main()
    {
        string text = "HelloWorld";
        int startIndex = 5;
        int length = 10;
        if (IsValidSubstringRange(text, startIndex, length))
        {
            string result = text.Substring(startIndex, length);
            Console.WriteLine(result);
        }
        else
        {
            Console.WriteLine("指定した範囲が文字列の長さを超えています。");
        }
    }
    static bool IsValidSubstringRange(string s, int start, int len)
    {
        return start >= 0 && len >= 0 && start + len <= s.Length;
    }
}
指定した範囲が文字列の長さを超えています。

このように、例外を発生させる前に条件をチェックすることで、try -catchブロックを減らし、処理の効率化とコードの明確化が可能です。

また、try -catchは例外が発生したときの最終手段として使い、通常のフロー制御には使わないことがベストプラクティスです。

文字列操作においても、入力値の検証やNullチェックを徹底して例外を未然に防ぐ設計を心がけましょう。

典型的なユースケース集

ファイルパスから拡張子を抽出

ファイルパスから拡張子を取得する処理はよく使われます。

拡張子は通常、ファイル名の最後のドット.以降の部分です。

SubstringLastIndexOfを組み合わせて抽出できます。

using System;
class Program
{
    static void Main()
    {
        string filePath = @"C:\Users\user\Documents\report.pdf";
        int lastDotIndex = filePath.LastIndexOf('.');
        if (lastDotIndex >= 0 && lastDotIndex < filePath.Length - 1)
        {
            string extension = filePath.Substring(lastDotIndex + 1);
            Console.WriteLine("拡張子: " + extension);
        }
        else
        {
            Console.WriteLine("拡張子が見つかりません。");
        }
    }
}
拡張子: pdf

LastIndexOfで最後のドットの位置を取得し、その次の位置から文字列の末尾までをSubstringで切り出しています。

拡張子が存在しない場合は適切にメッセージを表示します。

URLクエリパラメータの解析

URLのクエリパラメータは?以降にkey=value形式で複数並び、&で区切られています。

Splitを使ってパラメータごとに分割し、さらに=でキーと値を分けることができます。

using System;
class Program
{
    static void Main()
    {
        string url = "https://example.com/search?q=C%23+substring&lang=ja&page=2";
        int queryStart = url.IndexOf('?');
        if (queryStart >= 0 && queryStart < url.Length - 1)
        {
            string queryString = url.Substring(queryStart + 1);
            string[] parameters = queryString.Split('&');
            foreach (string param in parameters)
            {
                string[] keyValue = param.Split('=');
                string key = keyValue.Length > 0 ? keyValue[0] : "";
                string value = keyValue.Length > 1 ? keyValue[1] : "";
                Console.WriteLine($"キー: {key}, 値: {value}");
            }
        }
        else
        {
            Console.WriteLine("クエリパラメータがありません。");
        }
    }
}
キー: q, 値: C%23+substring
キー: lang, 値: ja
キー: page, 値: 2

この例では、IndexOf?の位置を特定し、Substringでクエリ部分を切り出しています。

Split('&')でパラメータごとに分割し、さらにSplit('=')でキーと値を分けています。

ログ行から日時とメッセージを分離

ログファイルの1行から日時とメッセージを切り出すケースも多いです。

日時が固定フォーマットで先頭にある場合、SubstringSplitで分離できます。

using System;
class Program
{
    static void Main()
    {
        string logLine = "2023-06-15 14:30:45 INFO: 処理が正常に完了しました。";
        // 日時は先頭19文字("yyyy-MM-dd HH:mm:ss")
        if (logLine.Length >= 20)
        {
            string dateTime = logLine.Substring(0, 19);
            string message = logLine.Substring(20);
            Console.WriteLine("日時: " + dateTime);
            Console.WriteLine("メッセージ: " + message);
        }
        else
        {
            Console.WriteLine("ログ行の形式が不正です。");
        }
    }
}
日時: 2023-06-15 14:30:45
メッセージ: INFO: 処理が正常に完了しました。

日時部分の長さが固定なので、Substringで先頭19文字を切り出し、残りをメッセージとして取得しています。

固定長データの切り出し

固定長フォーマットのデータを扱う場合、各フィールドの開始位置と長さが決まっているため、Substringで切り出すのが基本です。

using System;
class Program
{
    static void Main()
    {
        // 固定長データ例: "12345John    030"
        // 0-4: ID(5文字), 5-12: 名前(8文字), 13-15: 年齢(3文字)
        string record = "12345John    030";
        string id = record.Substring(0, 5).Trim();
        string name = record.Substring(5, 8).Trim();
        string ageStr = record.Substring(13, 3).Trim();
        Console.WriteLine($"ID: {id}");
        Console.WriteLine($"名前: {name}");
        Console.WriteLine($"年齢: {ageStr}");
    }
}
ID: 12345
名前: John
年齢: 030

Trimを使って余分な空白を除去しています。

固定長データは位置と長さが決まっているため、Substringで正確に切り出せます。

データのフォーマットが変わらない限り、非常に安定した方法です。

罠とアンチパターン

魔法の数値をハードコード

文字列の切り出しでよくある罠の一つが、開始位置や長さなどの数値をコード内に直接書き込む「魔法の数値(マジックナンバー)」の使用です。

例えば、Substring(5, 3)のように意味がわかりにくい数値をそのまま使うと、コードの可読性や保守性が著しく低下します。

string text = "HelloWorld";
string part = text.Substring(5, 3); // 何を切り出しているのか不明

このコードだけでは、なぜ53を指定しているのかがわかりません。

将来、仕様変更があった場合に修正箇所を探すのも困難です。

対策としては、意味のある定数や変数に名前を付けて使うことが重要です。

const int StartIndexForName = 5;
const int NameLength = 3;
string part = text.Substring(StartIndexForName, NameLength);

こうすることで、コードの意図が明確になり、変更も容易になります。

Cultureによる違いを無視

文字列操作は文化(カルチャ)によって挙動が異なる場合があります。

特に大文字・小文字変換や文字の比較、正規表現のマッチングなどで影響を受けやすいです。

例えば、トルコ語のiは大文字にするとİ(ドット付きI)になるため、単純にToUpper()ToLower()を使うと意図しない結果になることがあります。

string lower = "i";
string upper = lower.ToUpper(new System.Globalization.CultureInfo("tr-TR"));
Console.WriteLine(upper); // 出力: İ

このような文化依存の違いを無視すると、文字列の切り出しや比較でバグが発生します。

特にユーザー入力や多言語対応のアプリケーションでは注意が必要です。

対策としては、文化に依存しない比較や変換を行う場合はCultureInfo.InvariantCultureを使う、または明示的にカルチャを指定することが推奨されます。

string upperInvariant = lower.ToUpper(System.Globalization.CultureInfo.InvariantCulture);
Console.WriteLine(upperInvariant); // 出力: I

正規表現の過剰使用

正規表現は強力ですが、すべての文字列切り出しに使うのはアンチパターンです。

単純な区切り文字での分割や固定長の切り出しに正規表現を使うと、コードが複雑になり、パフォーマンスも低下します。

例えば、カンマ区切りの文字列を分割するだけならSplit(',')で十分です。

正規表現を使うと以下のように冗長になります。

string text = "apple,banana,cherry";
string pattern = ",";
string[] parts = Regex.Split(text, pattern);

この場合、Splitのほうがシンプルで高速です。

また、複雑な正規表現はメンテナンスが難しく、誤ったパターンを書くと意図しないマッチやパフォーマンス問題を引き起こします。

正規表現は、複雑なパターンマッチや抽出が必要な場合に限定して使い、単純な切り出しにはSubstringSplitを優先することが望ましいです。

モダンC#での代替手法

Range演算子とIndex型

C# 8.0以降で導入されたRange演算子..Index型を使うと、文字列の切り出しがより直感的かつ簡潔に書けます。

Indexは文字列の位置を表し、^を使うことで末尾からのインデックス指定が可能です。

例えば、文字列の先頭から5文字を取得する場合は以下のように書けます。

using System;
class Program
{
    static void Main()
    {
        string text = "HelloWorld";
        // 先頭から5文字を取得
        string firstFive = text[..5];
        Console.WriteLine(firstFive); // 出力: Hello
        // 末尾から5文字を取得
        string lastFive = text[^5..];
        Console.WriteLine(lastFive); // 出力: World
        // 3文字目から7文字目までを取得(インデックスは0-based)
        string middle = text[2..7];
        Console.WriteLine(middle); // 出力: lloWo
    }
}
Hello
World
lloWo

text[..5]text.Substring(0, 5)と同等ですが、より読みやすく書けます。

text[^5..]は末尾から5文字を切り出す便利な書き方です。

RangeIndexを使うことで、コードの可読性と保守性が向上します。

ReadOnlySpan<char>でコピー削減

ReadOnlySpan<char>は文字列の一部をコピーせずに参照できる構造体で、パフォーマンスを重視した文字列操作に適しています。

Substringのように新しい文字列を生成しないため、メモリ割り当てを抑えられます。

using System;
class Program
{
    static void Main()
    {
        string text = "HelloWorld";
        // ReadOnlySpan<char>で部分文字列を参照
        ReadOnlySpan<char> span = text.AsSpan(5, 5);
        Console.WriteLine(span.ToString()); // 出力: World
        // Range演算子と組み合わせて使うことも可能
        ReadOnlySpan<char> spanRange = text.AsSpan(2..7);
        Console.WriteLine(spanRange.ToString()); // 出力: lloWo
    }
}
World
lloWo

ReadOnlySpan<char>はスタック上に存在し、GCの負荷を減らせるため、短期間の部分文字列操作に最適です。

ただし、Spanはヒープ上の文字列の寿命に依存するため、長期間保持する用途には向きません。

文字列切り出しと国際化

Unicodeサロゲートペア

Unicodeでは、基本多言語面(BMP)に収まらない文字、特に絵文字や一部の特殊文字は「サロゲートペア」と呼ばれる2つの16ビットコードユニットcharで表現されます。

C#のstringはUTF-16エンコーディングで内部的に管理されているため、1文字が2つのcharで構成されることがあります。

このため、Substringやインデックス指定で単純にchar単位で切り出すと、サロゲートペアの片方だけを切り出してしまい、不正な文字列や文字化けが発生するリスクがあります。

例えば、以下のコードを見てください。

using System;
class Program
{
    static void Main()
    {
        string text = "Hello👋World"; // 👋はサロゲートペアで表現される絵文字
        Console.WriteLine("元の文字列: " + text);
        // サロゲートペアの途中で切り出す例
        string part = text.Substring(5, 1);
        Console.WriteLine("切り出した部分: " + part);
    }
}
元の文字列: Hello👋World
切り出した部分: �

この例では、絵文字「👋」が2つのcharで構成されているため、Substring(5, 1)で片方だけを切り出し、不正な文字(�)が表示されています。

サロゲートペアを正しく扱うには、System.Globalization.StringInfoクラスを使い、テキスト要素(グラフェムクラスタ)単位で文字列を操作する方法があります。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string text = "Hello👋World";
        StringInfo stringInfo = new StringInfo(text);
        // 6番目のテキスト要素(絵文字)を取得
        string grapheme = stringInfo.SubstringByTextElements(5, 1);
        Console.WriteLine("正しく切り出した部分: " + grapheme);
    }
}
正しく切り出した部分: 👋

このように、StringInfoを使うとサロゲートペアを含む文字も正しく切り出せます。

国際化対応のアプリケーションでは、サロゲートペアの扱いに注意が必要です。

NormalizationFormの考慮

Unicode文字は複数の表現方法が存在し、同じ見た目の文字でも内部的には異なるコードポイントの組み合わせで表されることがあります。

これを「正規化(Normalization)」と呼び、主に以下の4つの正規化形式があります。

  • NormalizationForm.FormC(NFC):合成済み文字を使う形式(推奨されることが多い)
  • NormalizationForm.FormD(NFD):分解済み文字を使う形式
  • NormalizationForm.FormKC(NFKC):互換合成形式
  • NormalizationForm.FormKD(NFKD):互換分解形式

例えば、アクセント付きの文字「é」は、単一の合成文字(U+00E9)としても、e(U+0065)とアクセント記号(U+0301)の組み合わせとしても表現できます。

この違いを無視して文字列の切り出しや比較を行うと、意図しない結果になることがあります。

using System;
using System.Text;
class Program
{
    static void Main()
    {
        string composed = "é"; // U+00E9
        string decomposed = "e\u0301"; // U+0065 + U+0301
        Console.WriteLine(composed == decomposed); // False
        // 正規化して比較
        bool equalNormalized = composed.Normalize(NormalizationForm.FormC) == decomposed.Normalize(NormalizationForm.FormC);
        Console.WriteLine(equalNormalized); // True
    }
}
False
True

切り出しの際も、正規化されていない文字列を部分的に切り出すと、分解文字の途中で切れてしまい、文字化けや不正な文字列になる可能性があります。

そのため、国際化対応の文字列操作では、入力文字列を適切な正規化形式に変換してから処理を行うことが推奨されます。

string normalizedText = input.Normalize(NormalizationForm.FormC);

これにより、文字列の一貫性が保たれ、切り出しや比較の結果が安定します。

Unicodeの複雑さを理解し、サロゲートペアや正規化を考慮した文字列切り出しを行うことが、国際化対応の品質向上につながります。

安全性とセキュリティ

入力バリデーションの基本

文字列切り出しを行う際、外部からの入力を直接処理すると、予期しない文字列や不正なデータによってアプリケーションが異常動作したり、セキュリティ上の問題が発生したりするリスクがあります。

これを防ぐために、入力バリデーションは必須です。

入力バリデーションの基本は、処理対象の文字列が想定される形式や長さ、文字種を満たしているかを事前にチェックすることです。

例えば、切り出しの開始位置や長さが文字列の範囲内にあるか、特定の文字セットのみを許可するかなどを検証します。

using System;
class Program
{
    static void Main()
    {
        string? input = Console.ReadLine();
        if (string.IsNullOrEmpty(input))
        {
            Console.WriteLine("入力が空です。");
            return;
        }
        if (input.Length < 5)
        {
            Console.WriteLine("入力が短すぎます。");
            return;
        }
        // 例: 英数字のみ許可
        foreach (char c in input)
        {
            if (!char.IsLetterOrDigit(c))
            {
                Console.WriteLine("英数字以外の文字が含まれています。");
                return;
            }
        }
        string result = input.Substring(0, 5);
        Console.WriteLine("切り出した文字列: " + result);
    }
}

このように、入力の妥当性を検証してから切り出し処理を行うことで、例外の発生や不正なデータ処理を防げます。

特にWebアプリケーションや外部APIからの入力では、バリデーションを徹底することがセキュリティ上重要です。

Regex DoSの回避

正規表現は強力ですが、複雑なパターンや悪意のある入力に対しては「正規表現によるサービス拒否攻撃(Regex Denial of Service、ReDoS)」のリスクがあります。

ReDoSは、特定の入力に対して正規表現のマッチング処理が極端に遅くなり、システムのリソースを枯渇させる攻撃手法です。

ReDoSを回避するためのポイントは以下の通りです。

  • 複雑な正規表現を避ける

特にネストした繰り返し(例:(a+)+)やバックトラッキングが多発するパターンは避けます。

  • 入力長の制限を設ける

正規表現を適用する前に、入力文字列の長さを制限し、過度に長い文字列を処理しない。

  • 正規表現のパフォーマンスを検証する

複雑なパターンは事前にベンチマークやテストを行い、処理時間が許容範囲内か確認します。

  • タイムアウトを設定する

.NETのRegexクラスでは、Regex.MatchRegex.MatchesのオーバーロードでTimeSpanによるタイムアウトを指定できるため、処理が長時間かかる場合は例外を発生させて処理を中断できます。

using System;
using System.Text.RegularExpressions;
class Program
{
    static void Main()
    {
        string pattern = @"^(a+)+$"; // 悪意のあるパターン例
        string input = new string('a', 10000) + "b";
        try
        {
            // タイムアウトを1秒に設定
            Regex regex = new Regex(pattern, RegexOptions.None, TimeSpan.FromSeconds(1));
            bool isMatch = regex.IsMatch(input);
            Console.WriteLine("マッチ結果: " + isMatch);
        }
        catch (RegexMatchTimeoutException)
        {
            Console.WriteLine("正規表現のマッチングがタイムアウトしました。");
        }
    }
}
正規表現のマッチングがタイムアウトしました。

この例では、意図的にReDoSを誘発しやすいパターンに対してタイムアウトを設定し、長時間の処理を防いでいます。

正規表現を使う際は、ReDoSのリスクを理解し、適切な対策を講じることが安全な文字列処理のポイントです。

まとめ

この記事では、C#での文字列切り出しの基本から応用までを解説しました。

SubstringSplitRegexの使い方や注意点、モダンC#の新機能であるRange演算子やReadOnlySpan<char>の活用法、国際化対応のためのUnicodeサロゲートペアや正規化の考慮、さらに安全性とセキュリティ面での入力バリデーションや正規表現のDoS対策まで幅広く理解できます。

これにより、効率的かつ安全な文字列操作が実現できます。

関連記事

Back to top button
目次へ