C# 6 で導入された文字列補間(Interpolated String, $"...")は、単に string.Format の糖衣構文ではなく、C# 10 でコンパイラが最適化する補間ハンドラー機構・C# 11 でraw string との連携など、現代の C# では最重要の文字列生成 API です。カジュアルに使える反面、CultureInfo 依存・構造化ログでの誤用・SQL インジェクションなど知っておくべき落とし穴も多く存在します。
本記事では書式指定子と幅指定・$@/@$・C# 10 の DefaultInterpolatedStringHandler 内部実装・C# 11 の raw string 連携・FormattableString による CultureInfo 制御・SQL / HTML 安全性・ILogger での誤用防止・パフォーマンス最適化・カスタム補間ハンドラーまで体系的に解説します。
基本構文と式の埋め込み
// 変数の埋め込み
string name = "Alice";
int age = 30;
string msg = $"Hello, {name}! You are {age} years old.";
// 計算式
int a = 3, b = 5;
Console.WriteLine($"{a} + {b} = {a + b}"); // 3 + 5 = 8
// メソッド呼び出し
Console.WriteLine($"Now: {DateTime.Now:HH:mm:ss}");
// 三項演算子(括弧で囲むか :?: を避ける記法がよい)
bool premium = true;
Console.WriteLine($"{(premium ? "プレミアム" : "通常")} ユーザー");
// プロパティ・インデクサ
var list = new[] { "A", "B", "C" };
Console.WriteLine($"1番目: {list[0]}, 件数: {list.Length}");
// null 合体演算子も OK
string? input = null;
Console.WriteLine($"入力: {input ?? "(未入力)"}");
// 中かっこ自体を出力するには {{ と }}
Console.WriteLine($"{{{name}}}"); // {Alice}
書式指定子(:)と幅指定(,)
decimal price = 1234.5m;
DateTime today = new(2026, 4, 16);
// 書式指定子は {式:フォーマット}
Console.WriteLine($"{price:C}"); // ¥1,234.50(通貨)
Console.WriteLine($"{price:C0}"); // ¥1,234(小数なし)
Console.WriteLine($"{price:N2}"); // 1,234.50(3桁区切り + 小数2桁)
Console.WriteLine($"{price:F2}"); // 1234.50(固定小数点)
Console.WriteLine($"{0.75:P1}"); // 75.0%(パーセント)
Console.WriteLine($"{255:X}"); // FF(16進大文字)
Console.WriteLine($"{255:X4}"); // 00FF
Console.WriteLine($"{255:D5}"); // 00255(整数を0埋め)
Console.WriteLine($"{12345:###,###}"); // 12,345(カスタム書式)
// 日時
Console.WriteLine($"{today:yyyy-MM-dd}"); // 2026-04-16
Console.WriteLine($"{today:yyyy年M月d日}"); // 2026年4月16日(日本語リテラル)
Console.WriteLine($"{today:o}"); // 2026-04-16T00:00:00.0000000(ISO 8601)
// TimeSpan
TimeSpan ts = TimeSpan.FromMinutes(75);
Console.WriteLine($"{ts:hh\\:mm}"); // 01:15(C# 文字列内で \\: と書くと実値は \: でコロンをエスケープ)
// {式,幅:書式} の形で幅を指定(正: 右寄せ、負: 左寄せ)
Console.WriteLine($"|{"Name",-10}|{"Price",8:C}|");
// 出力: |Name | ¥1,234|
// 表形式の出力
var items = new[]
{
("Apple", 150m),
("Banana", 80m),
("Cherry", 320m),
};
foreach (var (name, price) in items)
Console.WriteLine($"{name,-10}{price,8:C}");
// Apple ¥ 150
// Banana ¥ 80
// Cherry ¥ 320
// 幅指定もコロン書式と併用
Console.WriteLine($"{12345.678,15:N2}"); // 右詰め幅15で " 12,345.68"
| 書式指定子 | 用途 | 例 |
|---|---|---|
C |
通貨(現在カルチャ) | {1234:C} → ¥1,234 |
N |
数値 + 3桁区切り | {1234:N2} → 1,234.00 |
F |
固定小数点 | {1.5:F3} → 1.500 |
P |
パーセント | {0.75:P} → 75.00% |
X / x |
16進数(大/小) | {255:X4} → 00FF |
D |
整数0埋め | {5:D3} → 005 |
E |
指数表記 | {12345:E2} → 1.23E+004 |
G |
汎用(既定) | {12345:G} → 12345 |
yyyy-MM-dd |
日付 | {now:yyyy-MM-dd} |
verbatim と raw string との組み合わせ
string user = "Alice";
// $@"..." と @$"..." はどちらも同じ(C# 8+ で両順序OK)
string path1 = $@"C:\Users\{user}\Documents";
string path2 = @$"C:\Users\{user}\Documents";
// 両方とも: C:\Users\Alice\Documents
// verbatim なので \ はエスケープ不要、複数行リテラルもOK
string sql = $@"
SELECT *
FROM Users
WHERE Name = '{user}'
AND IsActive = 1";
// ⚠ この書き方は SQL インジェクションの温床 → 後述の FormattableString を参照
// 中かっこの扱いは通常の補間と同じ
Console.WriteLine($@"{{ {user} }}"); // { Alice }
// 改行: verbatim なので実際の改行がそのまま文字列に入る
Console.WriteLine($@"Hello,
World");
string user = "Alice";
string city = "Tokyo";
// C# 11: """..."""(raw string literal)
// 補間する場合は先頭の $ を付ける。$ の数 = 補間識別子の {} の数
string json = $$"""
{
"name": "{{user}}",
"city": "{{city}}",
"tags": ["vip", "active"]
}
""";
// $$ なので {{ }} が補間プレースホルダ、{ } はそのままリテラル
// → JSON のような「{} を多用する」構文でエスケープが劇的に減る
// インデント: 閉じ """ のインデントが基準になり、各行から差し引かれる
string html = $"""
<div class="user">
<span>{user}</span>
</div>
""";
// 出力 (先頭のインデント4スペースは削除):
// <div class="user">
// <span>Alice</span>
// </div>
// 複数文字の補間識別子が必要なケース({{ } を文字として含む場合)
string example = $$$"""
{{name}} は文字として残り、{{{value}}} だけ補間される
""";
C# 11 の
$$"""...""" は「{{」「}}」を補間識別子に変更できるため、JSON({ } が構造)や LaTeX、正規表現の {n,m} などで威力を発揮します。$ の数を増やせば「何文字連続の中括弧を補間として扱うか」を自由に調整できるため、どんな文字列もエスケープなしで表現できるようになります。CultureInfo の制御 — ロケール依存を避ける
文字列補間は現在のカルチャでフォーマットされるため、サーバーのロケールによって小数点が「.」と「,」に変わるなどの落とし穴があります。API レスポンス・設定ファイル・DB に保存する文字列では必ずカルチャを明示してください。
using System.Globalization;
double price = 1234.5;
// ① 通常の $"..." は現在カルチャ(CurrentCulture)でフォーマット
string localized = $"{price:N2}";
// ja-JP: "1,234.50"
// de-DE: "1.234,50" ← ドイツでは小数点が "," に
// en-US: "1,234.50"
// ② FormattableString として受けると後でカルチャを指定できる
FormattableString raw = $"{price:N2}";
string invariant = raw.ToString(CultureInfo.InvariantCulture);
// どこで実行しても "1,234.50"
// ③ より簡潔に: FormattableString.Invariant()
string iv = FormattableString.Invariant($"price = {price}, time = {DateTime.UtcNow:o}");
// ④ 明示的にカルチャを指定
string de = string.Create(CultureInfo.GetCultureInfo("de-DE"), $"{price:N2}");
// → "1.234,50"
// 実務での使い分け:
// - ユーザー表示 → $"{value:N2}"(現在カルチャ)
// - JSON・API・ログ・DB → FormattableString.Invariant(...)
// - 特定カルチャ固定 → string.Create(culture, ...)
ドイツのサーバー(de-DE)で
$"Price: {1234.5:N2}" を書くと「Price: 1.234,50」となり、これを JSON で返すとdecimal.Parse("1.234,50") は米国ロケールのクライアントでは失敗します。永続化・API・ログの文字列は常に InvariantCulture、ユーザー表示だけが CurrentCulture というルールを徹底してください。サーバーのロケールを UTC + InvariantCulture に固定する設定も有効です。内部実装 — C# 10 の DefaultInterpolatedStringHandler
C# 10 から文字列補間はコンパイラによってDefaultInterpolatedStringHandler を使った効率的な IL に変換されます。以前は string.Format(format, args) 相当のコードが生成されていましたが、現在は「StringBuilder 相当のバッファ + TryFormat」によるボクシングなし・中間文字列生成なしの実装になっています。
// 書いたコード
int x = 42;
string name = "Alice";
string msg = $"Hello {name}, value={x}";
// C# 10+ ではおおよそ以下のような IL が生成される
var handler = new DefaultInterpolatedStringHandler(
literalLength: 14, // "Hello " と ", value=" の合計長
formattedCount: 2); // name と x
handler.AppendLiteral("Hello ");
handler.AppendFormatted(name);
handler.AppendLiteral(", value=");
handler.AppendFormatted(x);
string msg2 = handler.ToStringAndClear();
// 効果:
// - string.Format の "object[] に args を詰め込む" 部分が不要
// - 値型のボクシングなし
// - 中間的な string が作られない
// - 多くのケースで string.Format より 2〜3倍高速
// 固定長バッファに対して補間を「その場で」書き込む API
Span<char> buffer = stackalloc char[256];
int x = 42;
bool success = buffer.TryWrite($"x = {x}", out int charsWritten);
// → スタック上のバッファに書き込み、ヒープ確保ゼロ
// string を最終的に取得する場合
string result = string.Create(CultureInfo.InvariantCulture,
stackalloc char[256], $"x = {x:N0}");
// DefaultInterpolatedStringHandler を公開メソッドで直接受けることもできる
public void LogValue(
[InterpolatedStringHandlerArgument("")]
ref DefaultInterpolatedStringHandler handler)
{
Console.WriteLine(handler.ToStringAndClear());
}
カスタム補間ハンドラー(C# 10+)
using System.Runtime.CompilerServices;
// ILogger の Information が無効なとき、
// 通常の $"..." は文字列を作ってから捨てる(コスト発生)
// カスタムハンドラーを使えば「必要なときだけ」構築できる
[InterpolatedStringHandler]
public ref struct LogInfoHandler
{
private DefaultInterpolatedStringHandler _inner;
private readonly bool _enabled;
public LogInfoHandler(int literalLength, int formattedCount,
ILogger logger, out bool isEnabled)
{
_enabled = logger.IsEnabled(LogLevel.Information);
isEnabled = _enabled; // ★ ここがポイント
_inner = _enabled
? new DefaultInterpolatedStringHandler(literalLength, formattedCount)
: default;
}
public void AppendLiteral(string s)
{
if (_enabled) _inner.AppendLiteral(s);
}
public void AppendFormatted<T>(T value)
{
if (_enabled) _inner.AppendFormatted(value);
}
public string ToStringAndClear() => _enabled ? _inner.ToStringAndClear() : "";
}
public static class LoggerExt
{
public static void LogInfo(this ILogger logger,
[InterpolatedStringHandlerArgument(nameof(logger))]
ref LogInfoHandler handler)
{
// isEnabled が false なら handler は default(何も処理されない)
// true のときだけ文字列が構築される
logger.LogInformation(handler.ToStringAndClear());
}
}
// 使用側: 普通の $"..." と同じ見た目で書ける
logger.LogInfo($"Request {id} processed in {elapsed.TotalMs}ms");
// → Information が無効なら文字列生成コストが完全にスキップされる
ILogger.LogInformation($"...") は C# 10+ で「ログレベルが無効なら文字列を構築しない」カスタムハンドラーが組み込まれています。とはいえ構造化ログの観点からは $”…” を直接 LogInformation に渡すべきではない(後述)点に注意してください。ILogger との組み合わせ(重要)
int userId = 42;
string orderId = "ORD-001";
// NG: 文字列補間でログを書くと、プロパティが構造化されない
logger.LogInformation($"User {userId} placed order {orderId}");
// → Seq / Elasticsearch で見ると "User 42 placed order ORD-001" の1文字列
// → {UserId: 42} でフィルタできない
// OK: メッセージテンプレート(ただの文字列)+ 引数
logger.LogInformation("User {UserId} placed order {OrderId}", userId, orderId);
// → Properties: { "UserId": 42, "OrderId": "ORD-001" }
// → UserId = 42 で検索・集計できる
// コードアナライザー CA2254 で NG パターンを検出できる
// <WarningsAsErrors>CA2254</WarningsAsErrors> でビルド時エラー化推奨
// 補間を使ってよい場面:
// - UI 表示用の文字列
// - 例外メッセージ(throw new InvalidOperationException($"Value {x} invalid"))
// - デバッグ用の Console.WriteLine
// - SQL 以外の構造を持たないテキスト生成
SQL / HTML 安全性 — FormattableString の活用
using Microsoft.EntityFrameworkCore;
string userInput = "'; DROP TABLE Users; --";
// NG: 文字列補間で SQL を組み立てる → SQLインジェクション
string sqlBad = $"SELECT * FROM Users WHERE Name = '{userInput}'";
dbContext.Users.FromSqlRaw(sqlBad); // 危険!
// OK: FromSqlInterpolated は FormattableString を受け取り、
// パラメータ化クエリに変換する
var users = dbContext.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Name = {userInput}")
.ToList();
// 実行される SQL: SELECT * FROM Users WHERE Name = @p0
// → @p0 に userInput がバインドされるので SQL インジェクション耐性あり
// Dapper も同様に FormattableString を安全に扱うオーバーロードを提供する
// Razor では @() 内で補間文字列を書いても自動 HTML エスケープされる
@{
string userName = "<script>alert(1)</script>";
}
<p>@($"ようこそ {userName} さん")</p>
// → <p>ようこそ <script>alert(1)</script> さん</p>
// 安全にエスケープされる
// 手動でエスケープが必要な場面(HTML を直接組み立てるとき)
using System.Net;
string safe = WebUtility.HtmlEncode(userName);
string html = $"<p>Hello {safe}</p>";
// JavaScript への埋め込みは JS エスケープを使う
string jsString = JsonEncodedText.Encode(userName);
string jsCode = $"var user = \"{jsString}\";";
パフォーマンスと最適化
| 用途 | 書き方 | 速度 | アロケーション |
|---|---|---|---|
| 2〜3個の値 | $"{a} {b}" |
高速 | 最小 |
| 多数の値 + ループ | StringBuilder |
最速 | 最小 |
| 固定バッファ | string.Create(ci, Span, $"...") |
最速 | ほぼゼロ |
| 高頻度ログ | [LoggerMessage] ソースジェネレーター |
最速 | ボクシングなし |
| string.Format | string.Format("{0}", a) |
遅め | object[] ボクシング |
// NG: ループ内で補間(ヒープ確保が件数分発生)
string result = "";
foreach (var item in items)
result += $"{item.Name}: {item.Price:C}
";
// OK: StringBuilder(.NET 6+ は AppendInterpolatedStringHandler 最適化あり)
var sb = new StringBuilder();
foreach (var item in items)
sb.Append($"{item.Name}: {item.Price:C}
");
string result2 = sb.ToString();
// OK: string.Join + Select(簡潔)
string result3 = string.Join("
",
items.Select(item => $"{item.Name}: {item.Price:C}"));
よくある落とし穴
string? nullStr = null;
int? nullInt = null;
// null は空文字列として出力される(例外にはならない)
Console.WriteLine($"value = {nullStr}"); // "value = "
Console.WriteLine($"value = {nullInt}"); // "value = "
// 「null」と表示したい場合は明示
Console.WriteLine($"value = {nullStr ?? "(null)"}");
Console.WriteLine($"value = {nullInt?.ToString() ?? "(null)"}");
// null チェックなしのプロパティアクセスは NRE
User? user = null;
Console.WriteLine($"name = {user.Name}"); // NullReferenceException!
Console.WriteLine($"name = {user?.Name ?? "(unknown)"}"); // OK
// NG: 補間の中に複雑な式を書くと可読性が崩壊
Console.WriteLine($"{(user.IsAdmin ? (user.SuperAdmin ? "SA" : "A") : (user.IsActive ? "U" : "G"))}");
// OK: 事前に変数化
string role = user switch
{
{ IsAdmin: true, SuperAdmin: true } => "SA",
{ IsAdmin: true } => "A",
{ IsActive: true } => "U",
_ => "G",
};
Console.WriteLine($"{role}");
int value = 255;
// NG: : の後は書式指定子として解釈される(時刻ではない)
Console.WriteLine($"{value:D2}"); // "255"(D2 は最小2桁 = 実際の桁優先)
// 期待: 16進表示 → X4 を使う
Console.WriteLine($"{value:X4}"); // "00FF"
// 時刻と混同しがち
DateTime time = DateTime.Now;
Console.WriteLine($"{time:HH:mm:ss}"); // OK: 18:30:45
Console.WriteLine($"{time:hh:mm:ss tt}"); // 12時間表記 + AM/PM
// 5922 の記事でも扱ったが、最重要なので再掲
var userId = 42;
// NG: 文字列補間をログ API に渡す
logger.LogInformation($"User {userId} logged in");
// OK: メッセージテンプレート
logger.LogInformation("User {UserId} logged in", userId);
// 例外メッセージなどの「構造化不要なテキスト」には $"..." OK
throw new ArgumentException($"Value {userId} is invalid");
よくある質問
$"..."(文字列補間)を使ってください。可読性が高く、C# 10+ では DefaultInterpolatedStringHandler により string.Format より高速です。string.Format を使うのは「フォーマット文字列を変数で保持して後で適用する」場合(テンプレートの動的生成・リソースファイル)だけです。Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture; を設定すると全スレッドで Invariant が既定になります。ただしユーザー向け表示部分(Web UI)では CurrentCulture が必要なので、「API / バックエンドは Invariant、フロントエンドは CurrentCulture」という使い分けを意識してください。サーバーアプリでは UseInvariantCulture=true を .csproj に設定することも選択肢です。$"..." が簡潔です。raw string は主に「JSON・SQL・HTML など複雑な構造をエスケープなしで書きたい場面」で威力を発揮します。1行の補間でまで $"""...""" を使うと逆に冗長になります。「エスケープが多い長めのリテラル」→ raw string、「短い1行補間」→ 従来の $"..." という使い分けが現実的です。DefaultInterpolatedStringHandler により object[] の確保とボクシングが排除されるためです。ただしループ内で数千回文字列連結するならば依然として StringBuilder が最速で、さらに高頻度の場合は string.Create + Span<char> で stackalloc バッファに直接書き込むパターンが採用されます。通常の業務コードは文字列補間で十分です。{date:o} または {date:yyyy-MM-ddTHH:mm:ssZ})、ユーザー表示には {date:yyyy-MM-dd} など簡潔な形式を使い分けます。:o(ラウンドトリップ)は解析も容易で時刻情報を完全に保持するため永続化に最適です。DateTime.Now を使うとローカル時刻になるため、サーバーでは DateTime.UtcNow + :o の組み合わせが標準です。まとめ
| 項目 | ベストプラクティス |
|---|---|
| 基本 | $"{式:書式}"。中かっこ自体は {{ / }} |
| 書式指定子 | :C/:N2/:X4/:yyyy-MM-dd |
| 幅指定 | {式,幅:書式}(負値で左寄せ) |
| verbatim | $@"..." または @$"..." |
| raw string | $$"""..."""(C# 11+、JSON・SQL に最適) |
| CultureInfo 制御 | 永続化・API は FormattableString.Invariant() |
| 構造化ログ | $"..." を LogInformation に渡さない |
| SQL | FromSqlInterpolated($"...")(EF Core)で SQL インジェクション防止 |
| HTML | Razor @() は自動エスケープ。直書きは WebUtility.HtmlEncode |
| C# 10 最適化 | DefaultInterpolatedStringHandler で string.Format より高速 |
| ループ内連結 | StringBuilder or string.Join |
| null 値 | {val ?? "(null)"} で明示表示 |
関連する文字列機能は以下を参照してください。文字列操作完全ガイドで Substring・Split・Trim 等、ログ出力完全ガイドで構造化ログと [LoggerMessage]、DateTime 完全ガイドで日時書式の詳細、値型と参照型完全ガイドで string 不変性の仕組みを解説しています。

