C#の文字列操作は、string型のメソッド(Substring・Split・Replace・IndexOf・Trim など)と、範囲演算子 [..]、比較方法を指定する StringComparison の3つを押さえると、ほとんどの処理が書けます。 stringは変更できない型(イミュータブル)なので、どのメソッドも元の文字列は変えず、新しい文字列を返します。この1点を知っているだけで、「Replaceしたのに変わらない」というつまずきの多くが解けます。

この記事では、文字列操作を「やりたいこと」から引ける逆引き表にまとめ、すべてに実行結果のコメントを付けました。さらに、==・Equals・Compare・StringComparison の違いを全角半角や大文字小文字の例で比べた表、Substringの範囲外で出る例外と安全な書き方、Splitの空要素の扱い、ループ連結とStringBuilderの速度差まで扱います。掲載した結果は、.NET SDK 8.0.131(ランタイム 8.0.31、Linux、カルチャ未設定=インバリアント)で実行して確かめたものです。

C#文字列操作の早見表

s = "Hello, World" のときの結果です。

やりたいこと 書き方 結果
先頭からn文字 s[..5] / s.Substring(0, 5) "Hello"
途中から最後まで s[7..] / s.Substring(7) "World"
末尾のn文字 s[^5..] "World"
区切り文字で分割 "a,b,c".Split(',') ["a", "b", "c"]
空要素を除き、前後の空白も取る Split(',', RemoveEmptyEntries | TrimEntries) ["a", "b", "c"]
置換 s.Replace("World", "C#") "Hello, C#"
位置を調べる s.IndexOf('o') 4(無ければ -1)
含むか調べる s.Contains("World") True
大文字小文字を無視して含むか s.Contains("world", StringComparison.OrdinalIgnoreCase) True
配列をつなぐ string.Join(", ", array) "りんご, みかん, ぶどう"
前後の空白を取る " abc ".Trim() "abc"
0埋め "42".PadLeft(5, '0') "00042"
大文字・小文字にする s.ToUpperInvariant() / s.ToLowerInvariant() "HELLO, WORLD" / "hello, world"
空かどうか string.IsNullOrWhiteSpace(s) False
同じか(大文字小文字を区別) a == b 中身を比べる
同じか(大文字小文字を無視) string.Equals(a, b, StringComparison.OrdinalIgnoreCase) nullでも例外にならない
たくさん連結する StringBuilder / string.Join ループの += より大幅に速い

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C#文字列操作一覧|Substring・Split・Replace・比較」

動かすための準備

この記事のコードは、dotnet new console で作ったコンソールアプリの Program.cs に貼って dotnet run で動かせます。最初に次の using と、結果を表示する補助関数を置いてください(.NET 6以降のトップレベルステートメントの形です)。

using System.Diagnostics;
using System.Globalization;
using System.Text;

// 結果を見やすく表示する補助関数
void P(string label, object? v) =>
    Console.WriteLine($"{label} => {(v is string s ? "\"" + s + "\"" : v is null ? "null" : v)}");
void A(string label, IEnumerable<string> v) =>
    Console.WriteLine($"{label} => [{string.Join(", ", v.Select(x => "\"" + x + "\""))}]");

string s = "Hello, World";

以降のコードブロックでは、右側のコメント // => に実行結果を書いています。

目的別の逆引き一覧(実行結果つき)

切り出す(Substring・範囲演算子)

s.Substring(7)        // => "World"   7文字目(0始まり)から最後まで
s.Substring(0, 5)     // => "Hello"   0文字目から5文字
s[..5]                // => "Hello"   先頭から5文字(5は含まない)
s[7..]                // => "World"   7文字目から最後まで
s[^5..]               // => "World"   末尾から5文字
s[7..^2]              // => "Wor"     7文字目から、末尾2文字の手前まで
s[0]                  // => H         1文字(char型)
s[^1]                 // => d         最後の1文字
s.Remove(5)           // => "Hello"   5文字目以降を削除
s.Remove(5, 1)        // => "Hello World"   5文字目から1文字削除
s.Insert(5, "!")      // => "Hello!, World" 5文字目に挿入

Substringの第2引数は「終わりの位置」ではなく「文字数」です。一方、範囲演算子 [開始..終了] は「終了の位置(その位置は含まない)」を書きます。

書き方 第2の値の意味 "Hello, World" で "World" を取る
Substring(開始, 文字数) 取り出す文字数 s.Substring(7, 5)
[開始..終了] 終了位置(含まない) s[7..12]
[^n..] 末尾から数えた位置 s[^5..]

範囲演算子は C# 8.0(.NET Core 3.0以降)で使えます。^1 が最後の文字、^0 は文字列の長さと同じ位置を表します。

分割する(Split)

"a,b,c".Split(',')                    // => ["a", "b", "c"]
"key=a,b,c".Split(',', 2)             // => ["key=a", "b,c"]   最大2つに分ける
"key=a=b".Split('=', 2)               // => ["key", "a=b"]     最初の = だけで分ける
"a,b;c".Split(new[] { ',', ';' })     // => ["a", "b", "c"]    複数の区切り文字
"a::b::c".Split("::")                 // => ["a", "b", "c"]    文字列で区切る
"a b\tc\nd".Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
                                      // => ["a", "b", "c", "d"]  空白類で分ける
"1行目\r\n2行目\n3行目".Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)
                                      // => ["1行目", "2行目", "3行目"]  改行で分ける

key=value のような形式は、Split('=', 2) と個数を指定すると、値の側に = が入っていても崩れません。空要素と空白の扱いは、後半の「Splitの空要素除去とTrimEntries」で詳しく見ます。

置換する(Replace)

s.Replace("World", "C#")                                    // => "Hello, C#"
s.Replace('l', 'L')                                         // => "HeLLo, WorLd"  すべて置き換わる
s.Replace("hello", "Hi")                                    // => "Hello, World"  大文字小文字が違うと変わらない
s.Replace("hello", "Hi", StringComparison.OrdinalIgnoreCase)// => "Hi, World"
s.Replace(",", "")                                          // => "Hello World"   削除は空文字に置換
"a\r\nb\nc".ReplaceLineEndings("|")                         // => "a|b|c"         改行をそろえる(.NET 6以降)

Replaceは一致した箇所をすべて置き換えます。 最初の1か所だけを置き換えたいときは、IndexOfで位置を調べて範囲演算子で組み立てます(パターンで置き換えるなら Regex.Replace が向いています)。

戻り値を受け取らないと、元の変数は変わりません。

var t = "abc";
t.Replace("a", "X");      // 戻り値を受け取っていない
P("t", t);                // => "abc"
t = t.Replace("a", "X");  // こう書くと "Xbc" になる

検索する(IndexOf・Contains・StartsWith)

s.IndexOf("o", StringComparison.Ordinal)     // => 4    最初に見つかった位置
s.IndexOf('o', 5)                            // => 8    5文字目以降で探す
s.LastIndexOf('o')                           // => 8    後ろから探す
s.IndexOf("xyz", StringComparison.Ordinal)   // => -1   見つからないと -1
s.IndexOfAny(new[] { ',', ' ' })             // => 5    どれかが最初に出る位置
s.Contains("World")                          // => True
s.Contains("world")                          // => False
s.Contains("world", StringComparison.OrdinalIgnoreCase)   // => True
s.StartsWith("Hello", StringComparison.Ordinal)           // => True
s.EndsWith("world", StringComparison.OrdinalIgnoreCase)   // => True
s.Count(c => c == 'l')                       // => 3    文字の出現回数(LINQ)

IndexOfは見つからないと -1 を返します。 この値をそのままSubstringに渡すと例外になるので、>= 0 を確かめてから使います(例は「範囲外の例外と安全な書き方」に載せます)。

結合する(+・補間・Join・Concat)

"ab" + "cd"                                           // => "abcd"
$"{s.Length}文字"                                      // => "12文字"       文字列補間
string.Concat("a", "b", "c")                          // => "abc"
string.Join(", ", new[] { "りんご", "みかん", "ぶどう" }) // => "りんご, みかん, ぶどう"
string.Join("-", new[] { 1, 2, 3 })                   // => "1-2-3"        数値の配列もつなげる
new string('-', 10)                                   // => "----------"   同じ文字を繰り返す
string.Concat(Enumerable.Repeat("ab", 3))             // => "ababab"       文字列を繰り返す

区切り文字を入れてつなぐときは string.Join が便利です。最後の要素のあとに区切りが付かないので、末尾のカンマを削る処理が要りません。

トリム・埋める(Trim・PadLeft)

"  abc  ".Trim()                 // => "abc"
"  abc  ".TrimStart()            // => "abc  "
"  abc  ".TrimEnd()              // => "  abc"
"/path/".Trim('/')               // => "path"    指定した文字を前後から取る
"line\r\n".TrimEnd('\r', '\n')   // => "line"    行末の改行を取る
" abc ".Trim()                 // => "abc"     全角スペースも取れる
"000123".TrimStart('0')          // => "123"
"42".PadLeft(5, '0')             // => "00042"   左を埋めて5桁に
"42".PadRight(5, '*')            // => "42***"
"12345".PadLeft(3, '0')          // => "12345"   指定より長いときはそのまま
42.ToString("D5")                // => "00042"   数値なら書式指定でも0埋めできる

引数なしの Trim() は、半角スペースだけでなくタブ・改行・全角スペースなど、Unicodeで空白とされる文字を取り除きます。

大文字・小文字を変える

s.ToUpperInvariant()                                   // => "HELLO, WORLD"
s.ToLowerInvariant()                                   // => "hello, world"
char.ToUpperInvariant("apple"[0]) + "apple"[1..]       // => "Apple"        先頭だけ大文字
CultureInfo.InvariantCulture.TextInfo.ToTitleCase("hello world")  // => "Hello World"

ToUpper()/ToLower() は実行している環境のカルチャ(言語と地域の設定)に従って変換します。識別子やキーのように環境によらず同じ結果がほしい場合は ToUpperInvariant()/ToLowerInvariant() を使います。理由は比較の節で実例を示します。

判定する(空・数字・長さ)

string? nul = null;
string.IsNullOrEmpty(nul)          // => True
string.IsNullOrEmpty("")           // => True
string.IsNullOrEmpty(" ")          // => False   空白1文字は「空ではない」
string.IsNullOrWhiteSpace(" ")     // => True
string.IsNullOrWhiteSpace(" ")    // => True    全角スペースも空白扱い
"あいう".Length                    // => 3
"123".All(char.IsDigit)            // => True
"123".All(char.IsDigit)         // => True    全角数字も「数字」と判定される
"123".All(char.IsAsciiDigit)    // => False   半角の0〜9だけ(.NET 7以降)
int.TryParse("12a", out _)         // => False
int.TryParse("12", out var n)    // => False(n は 0)
判定 null "" " " " "(全角空白)
s == "" False True False False
string.IsNullOrEmpty(s) True True False False
string.IsNullOrWhiteSpace(s) True True True True

入力欄の未入力チェックには IsNullOrWhiteSpace が合います。また、char.IsDigit は全角数字も True になるので、「半角数字だけ」を確かめたいときは char.IsAsciiDigit か int.TryParse を使います。

文字数・逆順・正規化

new string("abc".Reverse().ToArray())                 // => "cba"
"😀".Length                                           // => 2    絵文字は char 2つぶん
new StringInfo("😀👨‍👩‍👧か").LengthInTextElements      // => 3    見た目の文字数
"アイウA1".Normalize(NormalizationForm.FormKC)        // => "アイウA1"  半角カナ→全角、全角英数→半角
Encoding.UTF8.GetByteCount("あいう")                  // => 9    UTF-8でのバイト数

Length は「charの個数(UTF-16のコード単位数)」です。絵文字のように2つのcharで1文字を表すものは2と数えられます。見た目の文字数が必要なときは StringInfo を使います。

文字列比較:==・Equals・Compare・StringComparison

「同じかどうか」を調べる方法の比較表

string a = "abc", b = "ABC";
string c2 = new string(new[] { 'a', 'b', 'c' });   // 中身は "abc" だが別のインスタンス
object o1 = a, o2 = c2;
string? nul = null;

a == c2                                              // => True   中身を比べる
o1 == o2                                             // => False  object型同士は参照の比較になる
a.Equals(c2)                                         // => True
o1.Equals(o2)                                        // => True   Equalsは中身を比べる
a == b                                               // => False  大文字小文字は区別される
a.Equals(b)                                          // => False
a.Equals(b, StringComparison.OrdinalIgnoreCase)      // => True
string.Equals(a, b, StringComparison.OrdinalIgnoreCase)  // => True
string.Equals(null, null)                            // => True
nul == "x"                                           // => False  nullでも例外にならない
nul.Equals("x")                                      // => NullReferenceException
書き方 比べ方 大文字小文字 左辺がnull 向く場面
a == b 序数(文字コードそのまま) 区別する 例外にならない ふつうの一致判定
a.Equals(b) 序数 区別する NullReferenceException aがnullでないと分かっているとき
a.Equals(b, StringComparison.X) 指定どおり 指定どおり NullReferenceException 比較方法を明示したいとき
string.Equals(a, b, StringComparison.X) 指定どおり 指定どおり 例外にならない 大文字小文字を無視する比較のおすすめ
string.Compare(a, b, StringComparison.X) 指定どおり 指定どおり 例外にならない 並び順(大小)を知りたいとき
a.CompareTo(b) 現在のカルチャ 区別する NullReferenceException 並び順。比較方法を選べない

C#の == は、string型同士なら中身を比べます(Javaのように参照の比較にはなりません)。ただし、変数の型が object のときは参照の比較になるので、上の例のように同じ中身でも False になります。object型で受け取った値は、stringにキャストするか Equals で比べます。

Compareは大小を返す(一致判定には Equals)

string.Compare(a, b, StringComparison.Ordinal)            // => 32   'a'(97) - 'A'(65)
string.Compare(a, b, StringComparison.OrdinalIgnoreCase)  // => 0    同じ
string.CompareOrdinal(a, b)                               // => 32
string.Compare(a, b)                                      // => -1   カルチャの規則では小文字が先
string.Compare(a, b, StringComparison.InvariantCulture)   // => -1
a.CompareTo(b)                                            // => -1

Compareの戻り値は「負:aが前、0:同じ、正:aが後」です。同じ "abc" と "ABC" でも、Ordinalでは正(32)、カルチャの比較では負(-1)と、向きが逆になりました。 Ordinalは文字コードの大小(大文字が先)、カルチャの比較は言語としての並び(小文字が先)で比べるためです。

並べ替えでも同じ違いが出ます。

string[] words = { "banana", "Apple", "cherry", "apple", "Banana" };
Array.Sort(w1, StringComparer.Ordinal);            // => ["Apple", "Banana", "apple", "banana", "cherry"]
Array.Sort(w2, StringComparer.OrdinalIgnoreCase);  // => ["Apple", "apple", "banana", "Banana", "cherry"]
Array.Sort(w3, StringComparer.InvariantCulture);   // => ["apple", "Apple", "banana", "Banana", "cherry"]
Array.Sort(w4);                                    // => ["apple", "Apple", "banana", "Banana", "cherry"]

(w1〜w4 は words を Clone() した配列です。)Ordinalは大文字がすべて先に並び、カルチャの比較は辞書に近い順になります。

StringComparisonの選び方

値 比べ方 選ぶ場面
Ordinal 文字コードそのまま。速く、環境によらず同じ結果 ID、キー、ファイルパス、タグ名などプログラム内部の値
OrdinalIgnoreCase 上と同じで、大文字小文字だけ無視 拡張子、ヘッダー名、設定キーなどの一致判定
CurrentCulture 実行環境の言語の規則 利用者に見せる一覧の並べ替え
CurrentCultureIgnoreCase 同上で大文字小文字を無視 利用者向けの検索
InvariantCulture 特定の言語によらない言語的な規則 環境によらず同じ順で保存・表示したい並べ替え
InvariantCultureIgnoreCase 同上で大文字小文字を無視 同上

Microsoft Learnの「.NETでの文字列比較に関するベストプラクティス」のページ(今回開いて確認)では、カルチャに依存しない比較の安全な既定値として Ordinal または OrdinalIgnoreCase を使うこと、比較方法を明示しないオーバーロードに頼らないこと、一致の判定に Compare(...) == 0 ではなく Equals を使うことがすすめられています。同じページによると、引数を省略したときの比べ方はメソッドごとに違います。

メソッド 省略時の比べ方
==、Equals、Contains Ordinal
Compare、CompareTo CurrentCulture
StartsWith、EndsWith、IndexOf、LastIndexOf 引数がcharなら Ordinal、stringなら CurrentCulture
ToUpper、ToLower CurrentCulture
Array.Sort、OrderBy(比較子なし) CurrentCulture

同じ「探す」でも、ContainsはOrdinal、IndexOf(string)はカルチャ依存と分かれている点がつまずきやすいところです。実際に差が出る例を動かしました。

"abc".IndexOf("\0")                               // => 0    カルチャの比較ではNUL文字が無視され、先頭で一致
"abc".IndexOf("\0", StringComparison.Ordinal)     // => -1   Ordinalなら「無い」
"a\r\n".EndsWith("\n")                            // => True

トルコ語のカルチャでは、I の小文字が点のない ı になります。カルチャを明示して確かめました。

var tr = CultureInfo.GetCultureInfo("tr-TR");
"FILE".ToLower(tr)                                             // => "fıle"
"file".ToUpper(tr)                                             // => "FİLE"
"FILE".ToLowerInvariant()                                      // => "file"
string.Compare("file", "FILE", tr, CompareOptions.IgnoreCase) == 0   // => False
string.Equals("file", "FILE", StringComparison.OrdinalIgnoreCase)    // => True

ToLower() してから == で比べる書き方は、実行環境のカルチャによって結果が変わることがあります。大文字小文字を無視したいときは、変換せずに StringComparison.OrdinalIgnoreCase を渡すと、環境によらず同じ結果になり、変換用の文字列も作られません。

辞書やLINQにも同じ指定ができます。

var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase) { ["Apple"] = 1 };
dict["APPLE"]                                                  // => 1
string.Join(",", words.Distinct(StringComparer.OrdinalIgnoreCase))   // => "banana,Apple,cherry"

全角半角・ひらがなカタカナで結果が変わる例

日本語のデータでは、全角と半角、ひらがなとカタカナの違いが比較に影響します。7組の文字列を、8通りの方法で比べました(○=同じと判定、×=違うと判定)。

比べる2つ == OrdinalIgnoreCase InvariantCulture InvariantCultureIgnoreCase IgnoreWidth IgnoreWidth+IgnoreCase IgnoreWidth+IgnoreKanaType NFKC正規化後に ==
abc と ABC × ○ × ○ × ○ × ×
abc と abc(全角) × × × × ○ ○ ○ ○
ABC と abc(全角小文字) × × × × × ○ × ×
アイウ(半角カナ)と アイウ × × × × ○ × ○ ○
あいう と アイウ × × × × ○ × ○ ×
が と か+濁点(結合文字) × × ○ ○ ○ ○ ○ ○
123(全角)と 123 × × × × ○ ○ ○ ○

IgnoreWidthの列は、次のように CompareOptions を指定した結果です。

var ja = CultureInfo.GetCultureInfo("ja-JP");
bool same = string.Compare("abc", "abc", ja, CompareOptions.IgnoreWidth) == 0;   // => True
bool nfkc = "アイウ".Normalize(NormalizationForm.FormKC) == "アイウ";                  // => True

この表から分かることは次のとおりです。

  • OrdinalIgnoreCase が無視するのは大文字小文字だけで、全角と半角は別の文字として扱われます。
  • 全角半角を同じとみなすには CompareOptions.IgnoreWidth、ひらがなとカタカナを同じとみなすには CompareOptions.IgnoreKanaType を指定します。
  • 見た目が同じ「が」でも、1文字の「が」と「か+濁点」の2文字では == が False になります。外部から来たデータは Normalize() でそろえてから比べると一致します。
  • 「あいう」と「アイウ」は、IgnoreWidthだけを指定した列でも○になりました。これは今回の環境(Linux、ICU)での結果です。OSやICUのバージョンで違いが出る可能性があるので、ひらがなとカタカナを同じとみなしたいときは IgnoreKanaType を明示するのが確実です。Windowsでの結果は未実行です。

検索用のキーを作るなら、保存する前に Normalize(NormalizationForm.FormKC) で全角英数と半角カナをそろえておく方法が扱いやすくなります。

Substringの範囲外で出る例外と安全な書き方

どんなときに例外になるか

"abc".Substring(5)
// => ArgumentOutOfRangeException: startIndex cannot be larger than length of string. (Parameter 'startIndex')
"abc".Substring(1, 5)
// => ArgumentOutOfRangeException: Index and length must refer to a location within the string. (Parameter 'length')
"abc".Substring(3)
// => ""   長さと同じ位置は例外にならず、空文字が返る
"abc".Substring(-1)
// => ArgumentOutOfRangeException: startIndex ('-1') must be a non-negative value. (Parameter 'startIndex') Actual value was -1.
"abc"[..5]
// => ArgumentOutOfRangeException: Index and length must refer to a location within the string. (Parameter 'length')
"abc"[5]
// => IndexOutOfRangeException: Index was outside the bounds of the array.
"abc".Remove(5)
// => ArgumentOutOfRangeException: startIndex cannot be larger than length of string. (Parameter 'startIndex')
"abc".Insert(9, "x")
// => ArgumentOutOfRangeException: startIndex ('9') must be less than or equal to '3'. (Parameter 'startIndex') Actual value was 9.
"abc".Replace("", "x")
// => ArgumentException: The value cannot be an empty string. (Parameter 'oldValue')
nul.Substring(1)
// => NullReferenceException: Object reference not set to an instance of an object.
例外 出る場面 確認すること
ArgumentOutOfRangeException 開始位置や文字数が文字列の長さを超えた、負の値を渡した 文字列の長さ、IndexOfの戻り値が -1 でないか
IndexOutOfRangeException s[5] のように1文字を取る位置が範囲外 添字が Length 未満か
NullReferenceException nullの変数でメソッドを呼んだ ?. や IsNullOrEmpty で先に確かめる
ArgumentException Replaceの検索文字列が空 検索文字列が空でないか

範囲演算子 [..5] も、文字列が5文字より短いと Substring と同じ例外になります。 「先頭10文字だけ表示したい」のように、文字列のほうが短い可能性があるときは、長さを確かめてから切り出します。

短くても例外にならない Left・Right・Mid

// 先頭 n 文字(短ければ全体を返す)
string Left(string? v, int n) =>
    string.IsNullOrEmpty(v) ? "" : v.Length <= n ? v : v[..n];

// 末尾 n 文字(短ければ全体を返す)
string Right(string? v, int n) =>
    string.IsNullOrEmpty(v) ? "" : v.Length <= n ? v : v[^n..];

// start 文字目から n 文字(はみ出すぶんは切り詰める)
string Mid(string? v, int start, int n)
{
    if (string.IsNullOrEmpty(v) || start >= v.Length || start < 0 || n <= 0) return "";
    return v.Substring(start, Math.Min(n, v.Length - start));
}

P("Left(\"abc\", 5)", Left("abc", 5));              // => "abc"
P("Left(\"abcdefg\", 5)", Left("abcdefg", 5));      // => "abcde"
P("Left(null, 5)", Left(null, 5));                  // => ""
P("Right(\"abcdefg\", 3)", Right("abcdefg", 3));    // => "efg"
P("Right(\"ab\", 3)", Right("ab", 3));              // => "ab"
P("Mid(\"abcdefg\", 2, 3)", Mid("abcdefg", 2, 3));  // => "cde"
P("Mid(\"abcdefg\", 5, 10)", Mid("abcdefg", 5, 10));// => "fg"
P("Mid(\"abcdefg\", 9, 2)", Mid("abcdefg", 9, 2));  // => ""

1行で書くなら Math.Min を使う形もあります。

"abc"[..Math.Min(5, "abc".Length)]    // => "abc"

IndexOfと組み合わせるときは -1 を確かめる

string mail = "taro@example.com";
int at = mail.IndexOf('@');
P("@の前", at >= 0 ? mail[..at] : mail);          // => "taro"
P("@の後", at >= 0 ? mail[(at + 1)..] : "");      // => "example.com"

// @ が無い文字列で、確かめずに切り出すと
string none = "taro.example.com";
int at2 = none.IndexOf('@');                      // -1
none.Substring(0, at2);
// => ArgumentOutOfRangeException: length ('-1') must be a non-negative value. (Parameter 'length') Actual value was -1.

string path = "/var/log/app/error.log";
P("最後の/より後", path[(path.LastIndexOf('/') + 1)..]);   // => "error.log"
P("拡張子", Path.GetExtension(path));                      // => ".log"

最後の例は、LastIndexOf が -1 を返しても +1 で 0 になり、全体が返る書き方です。ファイル名や拡張子は Path.GetFileName/Path.GetExtension に任せると、区切り文字の違いも吸収できます。

絵文字を含む文字列を位置で切ると、2つのcharのうち片方だけが残ることがあります。

"ab😀cd"[..3]   // 3文字取れるが、3つ目は絵文字の前半(U+D83D)だけになる

絵文字が入りうる文字列を見た目の文字数で切りたいときは、StringInfo の SubstringByTextElements を使います。

Splitの空要素除去とTrimEntries

区切り文字が連続していたり、前後に空白があったりするデータでは、Splitのオプションで結果が変わります。"a,,b, c ," を分けた結果です。

string src = "a,,b, c ,";
src.Split(',')                                          // => ["a", "", "b", " c ", ""]
src.Split(',', StringSplitOptions.RemoveEmptyEntries)   // => ["a", "b", " c "]
src.Split(',', StringSplitOptions.TrimEntries)          // => ["a", "", "b", "c", ""]
src.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                                                        // => ["a", "b", "c"]
オプション 空の要素 各要素の前後の空白 使える版
None(既定) 残る 残る すべて
RemoveEmptyEntries 除く 残る すべて
TrimEntries 残る 取る .NET 5以降
両方を | で指定 除く 取る .NET 5以降

両方を指定したときは、トリムしたあとで空になった要素も除かれます。 空白だけの要素がある "a, ,b" で比べると違いが分かります。

"a, ,b".Split(',', StringSplitOptions.RemoveEmptyEntries)
// => ["a", " ", "b"]   空白1文字の要素が残る
"a, ,b".Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
// => ["a", "b"]

TrimEntries は .NET 5 で追加されたオプションです。.NET Framework では使えないため、.Split(',').Select(x => x.Trim()).Where(x => x != "") のようにLINQで同じ処理を書きます(.NET Frameworkでの動作は今回は未実行です)。

もう1つ知っておくとよいのが、空文字列を分割したときの結果です。

"".Split(',').Length                                         // => 1   空文字1つの配列 [""]
"".Split(',', StringSplitOptions.RemoveEmptyEntries).Length  // => 0

空文字列をSplitすると、要素0個ではなく「空文字1つ」の配列になります。 件数で分岐する処理では、RemoveEmptyEntries を付けるか、先に IsNullOrEmpty で確かめます。

なお、値の中にカンマや改行を含むCSV("ノート,A4" のように引用符で囲まれたもの)は、Splitでは正しく分けられません。CSVには専用の読み取り処理を使います。

ループ連結とStringBuilderの速度差

stringは変更できないので、+= は毎回「これまでの内容をすべてコピーした新しい文字列」を作ります。回数が増えると、コピーする量が雪だるま式に増えていきます。

foreach (int n in new[] { 10_000, 50_000, 100_000 })
{
    var sw = Stopwatch.StartNew();
    string r = "";
    for (int i = 0; i < n; i++) r += "item" + i + ",";          // (1) ループで +=
    sw.Stop(); long t1 = sw.ElapsedMilliseconds;

    sw.Restart();
    var sb = new StringBuilder();
    for (int i = 0; i < n; i++) sb.Append("item").Append(i).Append(',');   // (2) StringBuilder
    string r2 = sb.ToString();
    sw.Stop(); double t2 = sw.Elapsed.TotalMilliseconds;

    sw.Restart();
    string r3 = string.Join(",", Enumerable.Range(0, n).Select(i => "item" + i)) + ",";  // (3) Join
    sw.Stop(); double t3 = sw.Elapsed.TotalMilliseconds;

    Console.WriteLine($"n={n,7}: += {t1,6}ms / StringBuilder {t2,6:F2}ms / string.Join {t3,6:F2}ms / 同じ結果={r == r2 && r == r3} 長さ={r.Length}");
}

実行結果(.NET 8.0.31、dotnet run -c Release):

n=  10000: +=    618ms / StringBuilder   0.28ms / string.Join   2.53ms / 同じ結果=True 長さ=88890
n=  50000: +=   5604ms / StringBuilder   7.92ms / string.Join   6.43ms / 同じ結果=True 長さ=488890
n= 100000: +=  34189ms / StringBuilder   5.20ms / string.Join  21.64ms / 同じ結果=True 長さ=988890
回数 ループの += StringBuilder string.Join
10,000回 618ms 0.28ms 2.53ms
50,000回 5,604ms 7.92ms 6.43ms
100,000回 34,189ms 5.20ms 21.64ms

10万回の連結で、+= は約34秒、StringBuilderは約5ミリ秒でした。 回数が10倍になると += の時間は約55倍に増えており、回数に比例する以上に遅くなっています。数値は1回の計測値で、メモリの少ない検証用コンテナでの結果です。マシンによって絶対値は変わりますが、桁の違いは同じ傾向になります。

場面 選ぶもの
2〜数個を1行でつなぐ + や文字列補間 $"..."(コンパイラがまとめて連結する)
ループの中で繰り返し足す StringBuilder
配列やリストを区切り文字でつなぐ string.Join
回数が少ない(数十回まで)ループ += でも差は小さい

StringBuilderの基本形は次のとおりです。

var sb = new StringBuilder();
sb.Append("ID: ").Append(42).AppendLine();      // Appendはつなげて書ける
sb.AppendLine("名前: サンプル");
sb.Insert(0, "---\n");
string result = sb.ToString();                  // 最後に1回だけ string にする

よくあるつまずきと直し方

症状 原因 直し方
Replace や Trim を呼んだのに変わらない stringは変更できず、新しい文字列が返る s = s.Replace(...) と戻り値を受け取る
Substringで ArgumentOutOfRangeException 文字列が想定より短い、IndexOfが -1 長さを確かめる、Math.Min を使う、>= 0 を確かめる
Substring(2, 5) で思った範囲にならない 第2引数は終了位置ではなく文字数 範囲演算子 s[2..5] か、文字数を計算する
大文字小文字が違うと一致しない == や Contains は区別する StringComparison.OrdinalIgnoreCase を渡す
全角と半角が一致しない Ordinal系は別の文字として扱う Normalize(FormKC) でそろえる、CompareOptions.IgnoreWidth
Splitの結果に空文字が混ざる 区切り文字が連続、末尾に区切りがある RemoveEmptyEntries と TrimEntries
object型の変数を == で比べると False 参照の比較になる Equals、または string にキャスト
ループの連結が遅い += が毎回コピーを作る StringBuilder か string.Join
環境によって並び順が変わる Sortの既定がカルチャ依存 StringComparer.Ordinal などを明示

よくある質問

Q. == と Equals はどちらを使えばよいですか? A. string型同士の一致判定なら、どちらも中身を序数で比べるので結果は同じです。== は左辺がnullでも例外にならないので手軽です。大文字小文字を無視したいときは string.Equals(a, b, StringComparison.OrdinalIgnoreCase) を使います。

Q. Substringと範囲演算子 [..] はどちらがよいですか? A. 結果は同じなので、読みやすいほうで構いません。「何文字目から何文字目の手前まで」を書くなら範囲演算子、「何文字ぶん」を書くならSubstringが自然です。どちらも範囲外では例外になるので、長さの確認は同じように必要です。

Q. 大文字小文字を無視して比べるのに、ToLower() してから比べても構いませんか? A. 動きますが、実行環境のカルチャで結果が変わる場合があり(トルコ語の I と ı など)、変換用の文字列も作られます。StringComparison.OrdinalIgnoreCase を渡す書き方なら、環境によらず同じ結果になります。

Q. StringBuilderはいつから使うべきですか? A. ループの中で連結する回数が増えそうなときです。今回の計測では、1万回で += が618ms、StringBuilderが0.28msでした。1行の中で数個をつなぐだけなら、+ や文字列補間で十分です。

まとめ

  • stringは変更できない型。Replace・Trim・Substringなどは新しい文字列を返すので、戻り値を受け取る
  • 切り出しは Substring(開始, 文字数)か範囲演算子 [開始..終了]。どちらも範囲外は ArgumentOutOfRangeException になるので、長さと IndexOf の -1 を確かめる
  • Splitは RemoveEmptyEntries | TrimEntries で空要素と前後の空白をまとめて整理できる(.NET 5以降)
  • 一致判定は ==、大文字小文字を無視するなら string.Equals(a, b, StringComparison.OrdinalIgnoreCase)、並び順は Compare
  • 引数を省略したときの比べ方はメソッドごとに違うので、StringComparison を明示するとそろう
  • 全角半角やひらがなカタカナは Ordinal 系では別の文字。Normalize(FormKC) や CompareOptions.IgnoreWidth でそろえる
  • ループでの連結は StringBuilder か string.Join。10万回で約34秒と約5ミリ秒の差があった

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「C#文字列操作一覧|Substring・Split・Replace・比較」

株式会社RJCでは、C#の文字列操作や比較の考え方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?