C#のメソッド:ref・out・paramsとオーバーロードを.NET 10で検証

C#メソッドの定義・値渡しと参照渡しから、ref・out・in、C# 13のparams ReadOnlySpan、省略可能引数、ローカル関数、オーバーロードの落とし穴まで。2021年の例の誤りとparamsの割り当ても実測。

2026年11月改訂: この記事は2021年に韓国語で書いたメソッドの講座を、日本語向けに書き直したものです。例を .NET 10.0.14(C# 14)でコンパイルし直したところ、オーバーロード説明用の断片は double 版の戻り値の型が int になっていてコンパイルできず(error CS0266)、完成版の Add(544.2, 63.2) は元記事の 607.4 ではなく 607.4000000000001 と表示されました。params の例は出力が載っていなかったので、実際の結果(sum=188、sum=39)を補っています。さらに2021年以降に入った params ReadOnlySpan<T>(C# 13)、ローカル関数、オーバーロードの落とし穴と、params の割り当ての実測を足しました。

C#のメソッドを学び始めた人や、ref/out/params は知っているけれど in、ローカル関数、params ReadOnlySpan<int> がいつ入って何が違うのか知りたい人向けの記事です。先に結論を書くと、C#の引数は既定では値がコピーされて渡り、呼び出し側の変数を書き換えるには ref/out が要ります。可変長引数は params ReadOnlySpan<T> で受ければ配列の割り当てが消え、オーバーロードは思ったより頻繁に「意図と違うメソッド」を選びます。

メソッドの定義と戻り値

メソッドは名前を付けたコードのまとまりです。形は次のとおりです。

[アクセス修飾子] [static] 戻り値の型 メソッド名(引数リスト) { 本体 }

元の記事はアクセス修飾子として private、public、protected の3つだけを挙げていましたが、C#には internal、protected internal、private protected を含めて6種類あり、何も書かなければクラスのメンバーは private です。また「引数が1つ以上ある」とも書いていましたが、引数のないメソッド(static int Next())ももちろん書けます。

元の割り算の例はそのまま 4 を出しました。整数どうしの割り算なので小数点以下は切り捨てられ、0で割ると例外になります。1行のメソッドは C# 6 から => で書けます(式形式のメンバー)。

Console.WriteLine(Division(40, 10));
Console.WriteLine(Division(7, 2));
try { Console.WriteLine(Division(1, 0)); }
catch (DivideByZeroException e) { Console.WriteLine(e.GetType().Name); }

static int Division(int a, int b) => a / b;
4
3
DivideByZeroException

return はメソッドの途中にも書け、その時点でメソッドを抜けます。値を複数返したいときは、後で出てくる out よりタプルで返す方が読みやすくなります。

(int min, int max) = MinMax([4, 9, 1, 0, 21, 12]);
Console.WriteLine($"min={min} max={max}");   // min=0 max=21

値渡しと参照渡し

元の Swap の例2つは、.NET 10 でも同じ結果でした。ref なしで渡すと値がコピーされるので外の変数は変わらず、ref を付けると入れ替わります。

Swap before: a=40, b=10
Swap after: a=40, b=10      ← Swap(int a, int b)
Swap before: a=40, b=10
Swap after: a=10, b=40      ← Swap(ref int a, ref int b)

元の記事は ref を「変数のメモリアドレスを渡す」と説明していました。たとえとしては合っていますが、C#でもっと紛らわしいのは参照型(配列やクラス)を値渡しする場合です。配列を渡すと「配列を指す参照」がコピーされます。そのため要素の変更は外からも見えますが、引数に新しい配列を代入しても外には影響しません。

int[] data = [1, 2, 3];
Mutate(data);        Console.WriteLine(string.Join(" ", data));
Replace(data);       Console.WriteLine(string.Join(" ", data));
ReplaceRef(ref data); Console.WriteLine(string.Join(" ", data));

static void Mutate(int[] a) => a[0] = 100;           // 同じ配列の要素を変更
static void Replace(int[] a) => a = [7, 8, 9];        // 引数だけが変わる
static void ReplaceRef(ref int[] a) => a = [7, 8, 9]; // 呼び出し側の変数が変わる
100 2 3
100 2 3
7 8 9

Array.Resize(ref arr, n) が ref を取るのは、まさにこの3つ目の理由です(配列 の記事を参照)。

ref、out、in

元の ref で足し込む例(a=140、a=340、a=640)と out の例(a=100)はそのまま動きました。2つの違いはコンパイラーが強制する規則に表れます。わざと規則を破ってみました。

int a;
Add(ref a, 1);                                   // 初期化せずに ref で渡す
static void Add(ref int x, int y) { x += y; }
static void NoAssign(out int x) { }              // out に代入しない
Program.cs(2,9): error CS0165: 未割り当てのローカル変数 'a' が使用されました
Program.cs(4,13): error CS0177: out パラメーター 'x' はコントロールが現在のメソッドを抜ける前に割り当てられる必要があります

つまり ref は「読み書きする」引数、out は「書くだけの結果」引数です。out を一番よく見るのは Try パターンで、C# 7 からは呼び出しと同時に変数を宣言(out var)したり捨てたり(out _)できます。

if (int.TryParse("123", out var n)) Console.WriteLine($"parsed {n}");
if (!int.TryParse("12a", out _)) Console.WriteLine("not a number");
parsed 123
not a number

C# 7.2 で入った in は読み取り専用の参照です。大きな構造体をコピーせずに渡しつつ、メソッドが書き換えられないようにします。呼び出し側では in を省略でき、メソッド内で代入するとコンパイルエラーです。

static void Read(in int v) { v = 3; }
error CS8331: 変数 に割り当てることができません。'v' は読み取り専用変数であるため、ref 割り当ての右辺として使用することはできません。

(このメッセージは日本語リソースの訳が崩れていますが、.NET SDK 10.0.403 が実際に出した文面のままです。)int のような小さな値に in を付けても得はありません。数十バイト以上の struct を頻繁に渡すときだけ検討します。

params と C# 13 の params コレクション(実測)

元の params の例には出力がありませんでした。.NET 10 で実行した結果です。

sum=188
sum=39

params int[] は呼び出しのたびに引数を入れる配列を新しく作ります。C# 13 からは params の後ろに配列以外にも ReadOnlySpan<T> や List<T> などのコレクション型を書けます。Span 版は引数をスタックに置くので割り当てが起きません。

Console.WriteLine(Total());             // 0
Console.WriteLine(Total(1, 2, 3));      // 6
Console.WriteLine(Total([1, 2, 3, 4])); // 10(コレクション式もそのまま渡せる)
Console.WriteLine(TotalList(1, 2, 3));  // 3

static int Total(params ReadOnlySpan<int> xs) { int s = 0; foreach (var x in xs) s += x; return s; }
static int TotalList(params List<int> xs) => xs.Count;

差を測りました。int を4つ足すメソッドを3通り([MethodImpl(NoInlining)])用意し、それぞれ1000万回呼びます。Release ビルド、時間は Stopwatch、割り当ては GC.GetAllocatedBytesForCurrentThread()、ウォームアップ2回のあと3回計測です。

シグネチャ 1回目 2回目 3回目 割り当て
Total(params int[] xs) 63.8 ms 64.1 ms 64.7 ms 400.1 MB(1回40 B)
Total(params ReadOnlySpan<int> xs) 31.4 ms 31.5 ms 31.3 ms 0 B
Total4(int a, int b, int c, int d) 10.6 ms 10.6 ms 10.6 ms 0 B
1000万回の呼び出し時間の棒グラフ:params int[] 64.2ms、params ReadOnlySpan<int> 31.4ms、int 引数4つの固定版 10.6ms。Span 版は割り当てゼロで、配列版の約半分の時間だった。
4つの int を合計する呼び出しを1000万回。ウォームアップ後3回の平均。.NET 10.0.14、Release ビルド、Apple Silicon Mac。割り当てが出たのは params int[] だけで、1回40バイト、合計400MB。

params int[] は1回の呼び出しごとに40バイト(配列ヘッダー+int 4つ)を割り当て、1000万回で400MBになりました。Span 版は割り当てゼロで時間も半分です。引数の数が決まっているなら固定引数が最速でした。普通のアプリなら1000万回で0.03秒の差なので気にするほどではありませんが、ロギングや文字列組み立てのように毎秒大量に呼ばれるAPIなら Span 版のオーバーロードを用意する価値があります。.NET 10 のランタイムをリフレクションで確かめると、string.Join、string.Format、string.Concat、Path.Combine、Task.WhenAll、Console.WriteLine(string, …) にはすでに params ReadOnlySpan<T> 版があり、再コンパイルするだけでそちらが選ばれます。実際、string.Join(",", a, b, c) の割り当ては1回32バイト(結果の文字列だけ)で、同じ値を new string[] { a, b, c } で渡すと80バイトでした。

オーバーロード:2021年の例の誤りと実務の落とし穴

元のオーバーロードの断片は static int Add(double a, double b) となっていて、コンパイルできませんでした。

Program.cs(18,20): error CS0266: 型 'double' を 'int' に暗黙的に変換できません。明示的な変換が存在します (cast が不足していないかどうかを確認してください)

戻り値の型を double に直した完成版の出力です(表示文字列だけ英語にしています)。

Add(int, int)
60
Add(double, double)
607.4000000000001
Add(int, int, int)
20

元の記事では 607.4 でした。.NET Core 3.0 から double の既定の文字列化が「読み戻したときに同じ値になる最短の表現」に変わったため、544.2 + 63.2 の2進数の誤差がそのまま見えるようになったのです。表示用なら ToString("0.##")、金額のように10進の正確さが要るなら decimal(544.2m + 63.2m → 607.4)を使います。

オーバーロードが複数あるとき、コンパイラーがどれを選ぶかを確かめました(int、long、double、string? の4つの Show)。

Show(1);      // int 1
Show(1L);     // long 1
Show(1.0f);   // double 1      ← float 版がなければ double へ
Show('A');    // int 65        ← char は string ではなく int へ!
Show(null);   // string null

char が int に暗黙変換される点は見落としやすい罠です。ほかに実際に出たエラーをまとめます。

コード 結果
Show(string?) と Show(int[]?) があるときの Show(null) error CS0121 呼び出しが不適切(あいまい)
Show2(int, long) と Show2(long, int) があるときの Show2(1, 2) error CS0121 呼び出しが不適切(あいまい)
戻り値の型だけが違う int F(int) / long F(int) error CS0111 同じパラメーターの型のメンバーが既にある
G(ref int) / G(out int) error CS0663 ref と out だけの違いではオーバーロード不可
Print(int a, int b = 0) と Print(params int[] a) があるときの Print(5) 省略可能引数の方が選ばれる(params の展開より優先)
Sum(params int[]) と Sum(params ReadOnlySpan<int>) があるときの Sum(1, 2) / Sum(arr) Span 版 / 配列版

そして .NET 6 以降のテンプレートで一番戸惑う罠です。トップレベルステートメントの Program.cs でメソッドをオーバーロードするとコンパイルできません。

static int F(int a) => a;
static int F(long a) => (int)a;
Program.cs(2,12): error CS0128: 'F' という名前のローカル変数または関数はこのスコープで既に定義されています

トップレベルステートメントの下の static メソッドは、実はコンパイラーが作る Main の中のローカル関数で、ローカル関数はオーバーロードできないからです。オーバーロードが要るなら static class の中に置きます。

省略可能引数・名前付き引数とローカル関数

オーバーロードを増やす代わりに既定値を持たせる方法もあります(C# 4)。呼び出し時に引数名を書けば順番も入れ替えられます。

Console.WriteLine(Greet("Kim"));
Console.WriteLine(Greet("Kim", greeting: "Hi"));
Console.WriteLine(Greet(greeting: "Yo", name: "Lee"));

static string Greet(string name, string greeting = "Hello") => $"{greeting}, {name}";
Hello, Kim
Hi, Kim
Yo, Lee

既定値のある引数は後ろに置く必要があり、Bad(int a = 1, int b) は error CS1737: 省略可能なパラメーターはすべての必須パラメーターの後で指定する必要があります になります。また既定値は呼び出し側にコンパイルされて埋め込まれるので、ライブラリ側で既定値を変えても、再コンパイルしていない呼び出し側は古い値を使い続けます。

メソッドの中だけで使う補助関数はローカル関数(C# 7)として隠せます。static を付けると(C# 8)外側の変数をキャプチャできなくなり、意図しないクロージャの割り当てを防げます。キャプチャのコストは ラムダ式 の記事で実測しています。

Console.WriteLine(Fib(30));   // 832040

static long Fib(int n)
{
    return Go(n);
    static long Go(int k) => k < 2 ? k : Go(k - 1) + Go(k - 2);
}

どれを使うか

判断フロー。出発点:引数を渡す。最初の質問:Try パターンのように、メソッドが追加の結果を返す必要があるか。はいなら out(呼び出し側は初期化不要)。いいえなら、呼び出し側の変数をメソッドが書き換える必要があるか。はいなら ref(呼び出し側で初期化が必要)。いいえなら、引数の数が可変か。はいなら C# 13 の params ReadOnlySpan<T>。いいえなら、読むだけの大きな構造体か。はいなら in、そうでなければ既定の値渡し。
引数の渡し方の選び方。左から順に確認する。ほとんどの引数は最後の「値渡し」に落ち着く。
状況 おすすめ
結果が1つ 戻り値。1行なら => の式形式
結果が複数 タプルで返す (int Min, int Max)
成否+結果 Try パターン bool TryX(…, out T value)
呼び出し側の変数を書き換える ref(まれ。戻り値で済むならその方がよい)
大きな struct を読むだけ in
引数の数が可変 params ReadOnlySpan<T>(C# 13)。互換性が要るなら params T[] 版も
機能は同じで引数が一部違う オーバーロードより先に省略可能・名前付き引数を検討
メソッド内だけの補助関数 static ローカル関数
トップレベルでオーバーロード 不可(CS0128)→ static class へ移す

メソッドを値として渡す方法は Func・Actionデリゲート と ラムダ式 に続きます。

確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14(net10.0 の既定値)、macOS(Apple Silicon Mac)。

よくある質問

ref と out の違いは?

どちらも呼び出し側の変数そのものを渡しますが、ref は渡す前に値が入っている必要があり(なければ error CS0165)、out は初期化不要の代わりにメソッドが必ず代入しなければなりません(しなければ error CS0177)。int.TryParse(s, out var n) のような「成否+結果」を返す Try パターンで out を使います。

params int[] ではなく params ReadOnlySpan<int> にすべき?

C# 13 から書けて、呼び出し回数の多い経路では効果があります。int を4つ渡す呼び出し1000万回で、params int[] は1回40バイト(計400MB)を割り当てて64.2ms、params ReadOnlySpan<int> は割り当てゼロで31.4msでした(.NET 10、Release、Apple Silicon Mac)。両方のオーバーロードがあると、Sum(1, 2) は Span 版、配列変数を渡すと配列版が選ばれました。

トップレベルステートメントでオーバーロードできないのはなぜ?

Program.cs のトップレベルステートメントの下に書いた static メソッドは、実際には Main の中のローカル関数なので、同じ名前を2回宣言できません。error CS0128(同じ名前のローカル変数または関数が既に定義されています)になります。オーバーロードしたいなら static class の中に置きます。

  • #C#
  • #メソッド
  • #params
  • #オーバーロード
  • #.NET

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑