- C#
- 技術 💻
C#文字列操作一覧|Substring・Split・Replace・比較
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期連続の黒字決算。