はい、承知いたしました。C#における参照渡し(ref, out, in, ref return, ref local など)について、基本から応用、そしてC# 7以降の新しい機能まで含め、初心者向けに約5000語の詳細な解説記事を記述します。
【C#】参照渡し入門:基本から使い方まで分かりやすく解説
C#でプログラミングをしていると、メソッドに値を渡す際に「値渡し」や「参照渡し」という言葉を耳にするでしょう。特に「参照渡し」は、メソッド内で引数の値を変更したい場合や、メソッドから複数の値を返したい場合に非常に強力な手段となります。しかし、その仕組みや使い分けは初心者にとって少し分かりにくいかもしれません。
この記事では、C#における参照渡しについて、その基本的な概念から、値渡しとの違い、そしてref、out、inといったキーワードの使い方、さらにC# 7以降で導入されたref returnやref localといった高度な機能まで、図解(の説明)や豊富なコード例を交えて徹底的に解説します。この記事を読めば、参照渡しを自信を持って使いこなせるようになるでしょう。
さあ、C#の参照渡しの世界へ踏み込みましょう!
1. はじめに:なぜ参照渡しを学ぶのか?
プログラミングにおいて、メソッド(関数)は特定の処理をまとめたブロックであり、外部からデータを受け取ったり、処理結果を返したりします。このデータの受け渡し方には、大きく分けて「値渡し」と「参照渡し」の二種類があります。
多くの入門書やチュートリアルでは、まず「値渡し」を中心に解説されます。これは、値渡しの方が直感的で分かりやすいためです。しかし、ある程度複雑な処理を記述したり、パフォーマンスを意識したりするようになると、「参照渡し」の知識が不可欠になります。
例えば、
- メソッド内で、呼び出し元の変数そのものの値を変更したい場合
- メソッドから複数の値を同時に返したい場合
- 大きなデータをメソッドに渡す際に、不要なコピーによる性能劣化を防ぎたい場合
こういったケースでは、参照渡しが非常に有効、あるいは唯一の解決策となることがあります。
この記事では、これらのニーズに応えるために、以下の内容を段階的に学んでいきます。
- プログラムにおける「値」と「参照」の概念
- 「値渡し」の仕組みと限界
- 「参照渡し」の仕組み(
ref、outキーワード) refとoutの使い分けと具体的なコード例- C# 7以降で追加された参照関連の高度な機能(
in、refreturn、reflocal) - 参照渡しを使う上での注意点やデメリット
これらの知識を身につけることで、より効率的で柔軟なコードを書くことができるようになります。
2. プログラムにおける「値」と「参照」
参照渡しを理解するためには、まずプログラムがメモリ上でどのようにデータを扱っているのか、特に「値」と「参照」という二つの概念を区別して理解する必要があります。
コンピュータのメモリは、データを格納するためのたくさんの小さな箱(番地付きのロケーション)が集まったものだと想像してください。変数は、このメモリ上の特定の場所を指し示す名前のようなものです。
値 (Value):
変数そのものに、実際のデータ(数値、文字、真偽値など)が直接格納されている状態を指します。
例:int x = 10; この場合、変数 x が割り当てられたメモリ上の場所に、直接 10 という数値が格納されます。
参照 (Reference):
変数そのものには、実際のデータは格納されていません。代わりに、実際のデータが格納されている「別の場所」のアドレス(番地)が格納されています。このアドレスのことを「参照」と呼びます。参照は、実際のデータが格納されているオブジェクトを「指し示す」役割を果たします。
例:MyClass obj = new MyClass(); この場合、変数 obj が割り当てられたメモリ上の場所には、MyClass オブジェクトそのものではなく、そのオブジェクトがメモリ上のどこに存在するかを示すアドレス(参照)が格納されます。MyClass オブジェクトの実体は、メモリの別の場所に作成されます。
イメージ図(説明):
- 値型変数:
変数名 (例: x)
+-------+
| 10 | <- 値そのものが格納
+-------+ - 参照型変数:
変数名 (例: obj) 実体 (例: MyClassオブジェクト)
+-------+ +-----------------+
| アドレスA | ---------------------> | オブジェクトデータ |
+-------+ +-----------------+
↑ ↑
参照(実体のアドレス) 実体が格納されている場所 (アドレスA)
が格納されている
C#には、大きく分けて「値型」と「参照型」という二種類の型が存在します。
- 値型 (Value Types):
int,float,bool,char,structなど。これらの変数は、特別な指定がない限り、変数自体に値が直接格納されます。主にスタック領域に割り当てられます。(構造体も値型です。) - 参照型 (Reference Types):
class,interface,delegate,string, 配列 (int[]など) など。これらの変数は、変数自体にはオブジェクトの実体ではなく、オブジェクトが格納されている場所への参照(アドレス)が格納されます。オブジェクトの実体は主にヒープ領域に割り当てられます。(stringは参照型ですが、少し特殊な振る舞いをします。後述。)
この「値型」と「参照型」の違いが、メソッドに引数を渡す際の「値渡し」と「参照渡し」のデフォルトの挙動に影響を与えます。
3. 値渡し (Pass by Value) の仕組み
C#において、特別なキーワード(ref, out, in)を付けずにメソッドに引数を渡す場合、それは「値渡し」になります。
値渡しの仕組みは非常にシンプルです。メソッドが呼び出されるとき、引数として渡された変数に格納されている「値」がコピーされ、そのコピーがメソッド内の仮引数に渡されます。
-
値型を値渡しした場合:
変数に格納されている値そのものがコピーされます。メソッド内で仮引数の値を変更しても、それはコピーに対する変更なので、呼び出し元の変数の値は一切変わりません。 -
参照型を値渡しした場合:
変数に格納されている参照(アドレス)がコピーされます。つまり、メソッド内の仮引数には、呼び出し元の変数と同じ「オブジェクトを指し示す参照」が入ります。この参照を使ってメソッド内でオブジェクトの中身を変更した場合、呼び出し元の変数も同じオブジェクトを指しているので、その変更は反映されます。しかし、メソッド内で仮引数に新しいオブジェクトの参照を代入して参照自体を付け替えた場合、それはあくまでコピーされた参照の付け替えなので、呼び出し元の変数が指すオブジェクトは変わりません。
この参照型を値渡しする場合の挙動は、「参照の値渡し」と呼ばれることもあり、初心者にとって混同しやすい点です。ここではまず、最も基本的な「値型を値渡しする場合」から見ていきましょう。
3.1. 値型を値渡しする例
int は値型です。以下のコードを見てください。
“`csharp
using System;
public class ValuePassExample
{
public static void Main(string[] args)
{
int number = 10;
Console.WriteLine($”メソッド呼び出し前: {number}”); // 出力: 10
ModifyValue(number); // 値渡しでnumberを渡す
Console.WriteLine($"メソッド呼び出し後: {number}"); // 出力: 10 (変わらない!)
}
// int型の値を引数に取るメソッド (値渡し)
static void ModifyValue(int value)
{
Console.WriteLine($"メソッド内 (開始): {value}"); // 出力: 10
value = 20; // 仮引数valueの値を変更
Console.WriteLine($"メソッド内 (終了): {value}"); // 出力: 20
}
}
“`
解説:
Mainメソッドでint number = 10;と宣言します。メモリ上のnumberという場所には10が格納されます。ModifyValue(number);を呼び出します。ModifyValueメソッドが実行されます。引数numberの値10がコピーされ、ModifyValueの仮引数valueに渡されます。メモリ上では、valueという別の場所に10が格納されます。ModifyValue内でvalue = 20;が実行されます。これはModifyValueの仮引数valueの値を20に変更しているだけであり、Mainメソッドのnumberとは全く別のメモリ上の場所で行われています。ModifyValueの実行が終了し、Mainメソッドに戻ります。Mainメソッドでnumberの値を確認すると、やはり10のままです。
イメージ図(説明):
“`
メソッド呼び出し前:
Mainスコープ
+——-+
|number |
+——-+
| 10 |
+——-+
ModifyValue呼び出し時(値渡し):
Mainスコープ ModifyValueスコープ
+——-+ +——-+
|number | | value | <- numberの「値」10がコピーされる
+——-+ +——-+
| 10 | | 10 |
+——-+ +——-+
ModifyValue内で value = 20; とした場合:
Mainスコープ ModifyValueスコープ
+——-+ +——-+
|number | | value | <- valueの「値」が20に変更される
+——-+ +——-+
| 10 | | 20 |
+——-+ +——-+
ModifyValue終了後:
Mainスコープ ModifyValueスコープ (破棄される)
+——-+
|number |
+——-+
| 10 | <- 変化なし
+——-+
“`
このように、値型を値渡しする場合、メソッド内で引数の値を変更しても、呼び出し元の変数には影響しません。これは、メソッドが引数の「コピー」に対して操作を行っているためです。
3.2. 参照型を値渡しする例(参照の値渡し)
次に、参照型を値渡しする場合の挙動を見てみましょう。int[] は配列であり、参照型です。
“`csharp
using System;
public class ReferencePassExample
{
public static void Main(string[] args)
{
int[] numbers = { 1, 2, 3 }; // 配列は参照型
Console.WriteLine(“メソッド呼び出し前:”);
PrintArray(numbers); // 出力: 1 2 3
ModifyArrayContent(numbers); // 参照型を値渡し
Console.WriteLine("メソッド呼び出し後 (中身の変更):");
PrintArray(numbers); // 出力: 10 2 3 (中身は変わった!)
ReplaceArray(numbers); // 参照型を値渡し
Console.WriteLine("メソッド呼び出し後 (参照の付け替え):");
PrintArray(numbers); // 出力: 10 2 3 (参照は変わらなかった!)
}
static void PrintArray(int[] arr)
{
Console.WriteLine(string.Join(" ", arr));
}
// 配列の中身を変更するメソッド (参照型を値渡し)
static void ModifyArrayContent(int[] arr)
{
Console.WriteLine("ModifyArrayContent メソッド内 (開始):");
PrintArray(arr); // 1 2 3
arr[0] = 10; // 配列の要素を変更
Console.WriteLine("ModifyArrayContent メソッド内 (終了):");
PrintArray(arr); // 10 2 3
// arr は呼び出し元の numbers と同じ配列オブジェクトを指している
}
// 配列の参照を付け替えるメソッド (参照型を値渡し)
static void ReplaceArray(int[] arr)
{
Console.WriteLine("ReplaceArray メソッド内 (開始):");
PrintArray(arr); // 10 2 3 (ModifyArrayContentで変更された値)
// 仮引数arrに新しい配列の参照を代入
arr = new int[] { 99, 98, 97 };
Console.WriteLine("ReplaceArray メソッド内 (終了):");
PrintArray(arr); // 99 98 97 (このメソッド内では新しい配列を指す)
// arr は新しい配列を指すが、これは「コピーされた参照」の付け替え
}
}
“`
解説:
Mainメソッドでint[] numbers = { 1, 2, 3 };と宣言します。メモリ上では、numbersという変数に、{ 1, 2, 3 }という配列オブジェクトが格納されている場所への「参照」(アドレス)が格納されます。配列オブジェクトの実体は別の場所に作られます。ModifyArrayContent(numbers);を呼び出します。引数numbersに格納されている「参照(アドレス)」がコピーされ、ModifyArrayContentの仮引数arrに渡されます。これで、numbersとarrは同じ配列オブジェクトを指すようになります。ModifyArrayContent内でarr[0] = 10;が実行されます。これはarrが指す配列オブジェクトの0番目の要素の値を10に変更しています。numbersも同じオブジェクトを指しているので、この変更はMainメソッドからも見えます。-
ModifyArrayContentの実行後、Mainに戻りnumbersの中身を確認すると、{ 10, 2, 3 }となっています。 -
次に
ReplaceArray(numbers);を呼び出します。再び、引数numbersに格納されている「参照(アドレス)」がコピーされ、ReplaceArrayの仮引数arrに渡されます。numbersとarrはこの時点でも同じ配列オブジェクト{ 10, 2, 3 }を指しています。 ReplaceArray内でarr = new int[] { 99, 98, 97 };が実行されます。これは、仮引数arrに新しい配列オブジェクト{ 99, 98, 97 }の参照を代入しています。ここで重要なのは、変更されているのは「arrが指す参照」だけであり、numbersに格納されている参照はそのままということです。これは、引数として渡されたのが「参照そのもののコピー」だからです。ReplaceArrayの実行後、Mainに戻りnumbersの中身を確認すると、{ 10, 2, 3 }のままです。ReplaceArray内で行われた「参照の付け替え」は、呼び出し元には影響しませんでした。
イメージ図(説明):
“`
メソッド呼び出し前 (numbers):
Mainスコープ
+——-+ +———–+
|numbers| ——> | {1, 2, 3} |
+——-+ +———–+
↑
参照が格納
ModifyArrayContent呼び出し時(参照型を値渡し):
Mainスコープ ModifyArrayContentスコープ
+——-+ +——-+ +———–+
|numbers| ——> | arr | ——> | {1, 2, 3} |
+——-+ +——-+ +———–+
↑ ↑
参照が格納 numbersの参照がコピーされて格納
ModifyArrayContent内で arr[0] = 10; とした場合:
Mainスコープ ModifyArrayContentスコープ
+——-+ +——-+ +———–+
|numbers| ——> | arr | ——> | {10, 2, 3}| <- 指しているオブジェクトの中身が変わる
+——-+ +——-+ +———–+
↑ ↑
参照が格納 参照が格納 (同じオブジェクトを指す)
ReplaceArray呼び出し時(参照型を値渡し):
Mainスコープ ReplaceArrayスコープ
+——-+ +——-+ +———–+
|numbers| ——> | arr | ——> | {10, 2, 3}| <- この時点では同じオブジェクトを指す
+——-+ +——-+ +———–+
↑ ↑
参照が格納 numbersの参照がコピーされて格納
ReplaceArray内で arr = new int[] { … }; とした場合:
Mainスコープ ReplaceArrayスコープ
+——-+ +——-+ +———–+ +————-+
|numbers| ——> | arr | –X– | {10, 2, 3}| | {99, 98, 97}| <- 新しいオブジェクトが作成
+——-+ +——-+ | +————-+
↑ ↑ +——————–>
参照が格納 参照が格納 (arrは新しいオブジェクトを指す)
(numbersは元のオブジェクトを指したまま)
“`
これが「参照の値渡し」です。参照型の場合、メソッドに渡されるのはオブジェクトそのものではなく、「オブジェクトへの参照」のコピーです。このコピーされた参照を使ってオブジェクトの中身を変更することはできますが、コピーされた参照自体を付け替えても、呼び出し元の変数が持つ参照には影響しません。
3.3. 文字列 (string) の特殊な挙動
string 型は参照型ですが、値型のように振る舞うことで知られています。これは、string オブジェクトが不変 (immutable) であるためです。一度作成された string オブジェクトの内容は変更できません。
string 変数に対して文字列操作(連結など)を行うと、元の string オブジェクトが変更されるのではなく、新しい string オブジェクトが生成され、その新しいオブジェクトへの参照が変数に代入されます。
この特性のため、string をメソッドに値渡しした場合、メソッド内で文字列を変更(つまり新しい文字列を代入)しても、呼び出し元の変数には影響しません。これはまるで値型を値渡ししたかのように見えます。
“`csharp
using System;
public class StringPassExample
{
public static void Main(string[] args)
{
string message = “Hello”;
Console.WriteLine($”メソッド呼び出し前: {message}”); // 出力: Hello
ModifyString(message); // stringを値渡し
Console.WriteLine($"メソッド呼び出し後: {message}"); // 出力: Hello (変わらない!)
}
// stringを引数に取るメソッド (値渡し)
static void ModifyString(string text)
{
Console.WriteLine($"メソッド内 (開始): {text}"); // 出力: Hello
// 文字列を変更 (実際には新しいstringオブジェクトが作成され、textにその参照が代入される)
text = text + ", World!";
Console.WriteLine($"メソッド内 (終了): {text}"); // 出力: Hello, World!
}
}
“`
解説:
Mainメソッドでstring message = "Hello";と宣言します。messageに"Hello"オブジェクトへの参照が格納されます。ModifyString(message);を呼び出します。messageに格納されている参照がコピーされ、ModifyStringの仮引数textに渡されます。messageとtextは最初は同じ"Hello"オブジェクトを指しています。ModifyString内でtext = text + ", World!";が実行されます。"Hello" + ", World!"の結果として、新しいstringオブジェクト"Hello, World!"が作成されます。そして、この新しいオブジェクトへの参照がtextに代入されます。textは新しいオブジェクトを指すようになりますが、messageは元の"Hello"オブジェクトを指したままです。ModifyStringの実行後、Mainに戻りmessageの値を確認すると、やはり"Hello"のままです。
このように、string は参照型でありながら、不変性のため値型のような振る舞いをします。メソッド内で文字列を変更したい場合は、その変更後の新しい文字列をメソッドの戻り値として返すのが一般的です。
4. 参照渡し (Pass by Reference) の仕組み (ref キーワード)
さて、値渡しでは、メソッド内で引数の値を(値型の場合は全く、参照型の場合は参照そのものを)変更しても、呼び出し元には影響しないことが分かりました。しかし、「メソッド内で、呼び出し元の変数そのものの値を変更したい」という明確な目的がある場合、参照渡しを使います。
C#で参照渡しを実現するには、引数の前に ref キーワードを付けます。ref は “reference” の略です。
ref キーワードを使って引数を渡すと、メソッドには引数の「参照(変数がメモリ上のどこにあるか)」が渡されます。これにより、メソッド内での仮引数への操作が、呼び出し元の変数そのものに対する操作として行われることになります。
4.1. ref キーワードの使い方
ref キーワードを使うには、以下の二つの場所で指定が必要です。
- メソッドの定義時(シグネチャ): 引数の型の前に
refを付けます。
csharp
static void ModifyValueByRef(ref int value)
{
// ... 処理 ...
} - メソッドの呼び出し時: 引数の変数名の前に
refを付けます。
csharp
int number = 10;
ModifyValueByRef(ref number); // ここでもrefが必要
4.2. 値型を ref で参照渡しする例
先ほどの値型(int)の例を、ref を使って参照渡しに書き換えてみましょう。
“`csharp
using System;
public class RefPassExample
{
public static void Main(string[] args)
{
int number = 10;
Console.WriteLine($”メソッド呼び出し前: {number}”); // 出力: 10
ModifyValueByRef(ref number); // refを付けて参照渡しで呼び出す
Console.WriteLine($"メソッド呼び出し後: {number}"); // 出力: 20 (変わった!)
}
// int型の参照を引数に取るメソッド (参照渡し by ref)
static void ModifyValueByRef(ref int value)
{
Console.WriteLine($"メソッド内 (開始): {value}"); // 出力: 10
value = 20; // 仮引数valueの値(=呼び出し元のnumberの値)を変更
Console.WriteLine($"メソッド内 (終了): {value}"); // 出力: 20
}
}
“`
解説:
Mainメソッドでint number = 10;と宣言します。ModifyValueByRef(ref number);を呼び出します。ここでref numberとすることで、numberという変数がメモリ上のどこにあるか、その「参照」がModifyValueByRefメソッドに渡されます。ModifyValueByRefメソッドが実行されます。仮引数valueは、Mainメソッドのnumber変数が格納されている同じメモリ上の場所を指すようになります。valueへのアクセスは、そのままnumberへのアクセスとなります。ModifyValueByRef内でvalue = 20;が実行されます。これはvalueが指している場所(つまりnumberの場所)の値を20に変更します。ModifyValueByRefの実行が終了し、Mainメソッドに戻ります。Mainメソッドでnumberの値を確認すると、20に変更されていることが分かります。
イメージ図(説明):
“`
メソッド呼び出し前:
Mainスコープ
+——-+
|number |
+——-+
| 10 |
+——-+
↑
変数の場所(アドレス)
ModifyValueByRef呼び出し時(参照渡し by ref):
Mainスコープ ModifyValueByRefスコープ
+——-+ +——-+
|number | <—- | value | <- numberの「参照(場所)」が渡される
+——-+ +——-+
| 10 | | | (value自身は値を格納せず、numberの場所を指す)
+——-+ +——-+
↑ ↑
同じ場所を指す
ModifyValueByRef内で value = 20; とした場合:
Mainスコープ ModifyValueByRefスコープ
+——-+ +——-+
|number | <—- | value | <- valueが指す場所(numberの場所)の値が20に変更される
+——-+ +——-+
| 20 | | |
+——-+ +——-+
↑ ↑
同じ場所を指す
ModifyValueByRef終了後:
Mainスコープ ModifyValueByRefスコープ (破棄される)
+——-+
|number |
+——-+
| 20 | <- 変更が反映されている
+——-+
“`
ref を使うことで、メソッドは呼び出し元の変数そのものにアクセスし、その値を直接変更できることが分かります。
4.3. ref パラメータの注意点:初期化
ref パラメータとして渡す変数は、メソッドを呼び出す前に必ず初期化されている必要があります。これは、ref が「既存の変数の参照」を渡すためのものだからです。存在しない(初期化されていない)変数の参照を渡すことはできません。
csharp
// 例: コンパイルエラーになるコード
int uninitializedNumber; // 初期化されていない
ModifyValueByRef(ref uninitializedNumber); // エラー! uninitializedNumberは初期化されていない
これはコンパイラによってチェックされるため、間違って初期化されていない変数を ref で渡そうとすると、コンパイル時にエラーが発生します。
4.4. 参照型を ref で参照渡しする例
次に、参照型(配列)を ref で参照渡しする場合を見てみましょう。これにより、メソッド内で参照そのものを付け替える操作(例:新しい配列オブジェクトを代入)が、呼び出し元の変数にも影響を与えるようになります。
“`csharp
using System;
public class RefReferencePassExample
{
public static void Main(string[] args)
{
int[] numbers = { 1, 2, 3 }; // 配列は参照型
Console.WriteLine(“メソッド呼び出し前 (numbersが指す配列):”);
PrintArray(numbers); // 出力: 1 2 3
// refを付けて参照型を参照渡し
ReplaceArrayByRef(ref numbers);
Console.WriteLine("メソッド呼び出し後 (numbersが指す配列):");
// ここでは新しい配列 {99, 98, 97} が出力されるはず
PrintArray(numbers); // 出力: 99 98 97 (参照が付け替わった!)
}
static void PrintArray(int[] arr)
{
if (arr == null)
{
Console.WriteLine("null");
return;
}
Console.WriteLine(string.Join(" ", arr));
}
// 配列の参照を付け替えるメソッド (参照型を参照渡し by ref)
static void ReplaceArrayByRef(ref int[] arr)
{
Console.WriteLine("ReplaceArrayByRef メソッド内 (開始):");
PrintArray(arr); // 1 2 3
// 仮引数arrに新しい配列の参照を代入
// これはarrが指す呼び出し元の変数(numbers)に新しい参照を代入することになる
arr = new int[] { 99, 98, 97 };
Console.WriteLine("ReplaceArrayByRef メソッド内 (終了):");
PrintArray(arr); // 99 98 97
}
}
“`
解説:
Mainメソッドでint[] numbers = { 1, 2, 3 };と宣言します。numbersには最初の配列オブジェクト{ 1, 2, 3 }への参照が格納されます。ReplaceArrayByRef(ref numbers);を呼び出します。ref numbersとすることで、numbers変数そのもの(つまりnumbersが格納されているメモリ上の場所)への参照がReplaceArrayByRefの仮引数arrに渡されます。arrはnumbersと同じメモリ上の場所を指すようになります。ReplaceArrayByRef内でarr = new int[] { 99, 98, 97 };が実行されます。これは、新しい配列オブジェクト{ 99, 98, 97 }を作成し、その参照をarrが指すメモリ上の場所、すなわちnumbers変数が格納されている場所に代入します。これにより、numbersに格納されていた参照が新しい配列への参照に置き換わります。ReplaceArrayByRefの実行後、Mainメソッドに戻りnumbersの値(指しているオブジェクト)を確認すると、新しい配列{ 99, 98, 97 }に変わっていることが分かります。
このように、参照型を ref で渡すことで、メソッド内でその参照自体を付け替える操作(別のオブジェクトへの参照を代入するなど)が、呼び出し元の変数に反映されるようになります。これは、通常の参照型を値渡し(参照の値渡し)した場合とは異なる重要な点です。
5. 出力パラメータ (Output Parameters) の仕組み (out キーワード)
ref キーワードと似ているのが out キーワードです。out も引数を参照渡しするためのものですが、目的と要件が少し異なります。
out キーワードは、主にメソッドから複数の値を返したい場合に使われます。メソッドの戻り値は一つしか指定できませんが、out パラメータを使えば、戻り値とは別に、メソッド内で計算・取得した値を呼び出し元の変数に「出力」できます。
5.1. out キーワードの使い方
out キーワードも、ref と同様に以下の二つの場所で指定が必要です。
- メソッドの定義時(シグネチャ): 引数の型の前に
outを付けます。
csharp
static void CalculateAndOutput(int input, out int result1, out int result2)
{
// ... 処理 ...
} - メソッドの呼び出し時: 引数の変数名の前に
outを付けます。
csharp
int num = 10;
int res1, res2; // 呼び出し元ではout変数は初期化されていなくてもよい (C# 7以降)
CalculateAndOutput(num, out res1, out res2); // ここでもoutが必要
5.2. out パラメータの注意点:初期化と代入
out パラメータは、ref パラメータと以下の二点で異なります。
- 呼び出し元の変数の初期化:
outパラメータとして渡す変数は、メソッドを呼び出す前に初期化されている必要はありません。outは、メソッドがその変数に新しい値を「出力する」ことを意図しているためです。 - メソッド内での代入:
outパラメータは、メソッドが正常に終了するまでに必ず値が代入されている(初期化されている)必要があります。これは、コンパイラによって厳密にチェックされます。メソッドが呼び出し元変数に新しい値を書き込むことを保証するためです。
5.3. out パラメータを使った例
int.TryParse メソッドは、文字列を整数に変換する際によく使われるメソッドです。このメソッドは、変換に成功したかどうかを bool 型の戻り値で返し、変換後の整数値を out パラメータで返します。これは out パラメータの典型的な使用例です。
“`csharp
using System;
public class OutPassExample
{
public static void Main(string[] args)
{
string strNumber = “123”;
int resultNumber; // C# 7以降: out変数呼び出し前に初期化不要
// int.TryParseは戻り値(bool)とoutパラメータ(int)を持つ
bool success = int.TryParse(strNumber, out resultNumber);
if (success)
{
Console.WriteLine($"変換成功: {resultNumber}"); // 出力: 変換成功: 123
}
else
{
Console.WriteLine("変換失敗");
}
string strInvalid = "abc";
int resultInvalid;
bool successInvalid = int.TryParse(strInvalid, out resultInvalid);
if (successInvalid)
{
Console.WriteLine($"変換成功: {resultInvalid}");
}
else
{
Console.WriteLine($"変換失敗: {resultInvalid}"); // 出力: 変換失敗: 0 (out変数にはデフォルト値が入る)
// 失敗した場合でもout変数は初期化される(多くの場合デフォルト値)
}
// C# 7以降のout変数宣言の簡略化
string strAnother = "456";
if (int.TryParse(strAnother, out int anotherResult)) // 呼び出し時にout変数を宣言
{
Console.WriteLine($"新しい方法で変換成功: {anotherResult}"); // 出力: 新しい方法で変換成功: 456
}
// 独自のoutパラメータを持つメソッドの例
int area, perimeter;
CalculateRectangle(10, 5, out area, out perimeter);
Console.WriteLine($"長方形の面積: {area}, 周囲長: {perimeter}"); // 出力: 長方形の面積: 50, 周囲長: 30
}
// 複数の値をoutパラメータで返すメソッド
static void CalculateRectangle(int width, int height, out int area, out int perimeter)
{
// outパラメータはメソッド内で必ず代入する必要がある
area = width * height;
perimeter = 2 * (width + height);
// もしareaまたはperimeterに代入し忘れるとコンパイルエラー
}
}
“`
解説:
int.TryParseの呼び出しでは、最初の引数strNumberは値渡しで渡されます。- 2番目の引数
out resultNumberはoutパラメータとして渡されます。resultNumberは呼び出し前に初期化されていませんが、これはoutなので問題ありません。 int.TryParseメソッド内で、文字列の解析が行われます。成功した場合、解析された整数値がresultNumberが指すメモリ上の場所(つまりMainメソッドのresultNumber変数の場所)に代入されます。int.TryParseは解析の成否をboolで返します。- 失敗した場合 (
strInvalidの例)、outパラメータresultInvalidにはint型のデフォルト値である0が代入されます(これはint.TryParseの実装によるもので、outパラメータは必ずメソッド内で何らかの値が代入されるという要件を満たすためです)。
独自の CalculateRectangle メソッドの例:
- このメソッドは、引数
widthとheightを受け取り、計算結果である面積と周囲長をout areaとout perimeterという二つのoutパラメータで返します。 - メソッド内で
area = width * height;およびperimeter = 2 * (width + height);という代入が行われており、これによりareaとperimeterがメソッドの終了までに初期化されている(値が代入されている)というoutの要件を満たしています。 - 呼び出し元では、
int area, perimeter;と変数を宣言し(初期化は不要)、CalculateRectangle(10, 5, out area, out perimeter);と呼び出します。メソッド実行後、これらの変数に計算結果が格納されます。
5.4. ref と out の違いのまとめ
| 特徴 | ref パラメータ |
out パラメータ |
|---|---|---|
| 目的 | 呼び出し元の変数の値をメソッド内で変更 | メソッドから複数の値を「出力」する |
| 呼び出し前の初期化 | 必須 | 不要 (C# 7以降は宣言も同時に可能) |
| メソッド内での代入 | 必須ではない (読み取りも書き込みも可能) | メソッド終了までに必須 (書き込みが主目的) |
| 利用シーン | スワップ処理、既存の変数の更新 | 複数の戻り値、TryParseのようなパターン |
これらの違いを理解することが、ref と out を適切に使い分ける鍵となります。
6. 参照渡しの応用例と使い分け
6.1. 値型での ref の応用:変数のスワップ
二つの変数の値を入れ替える(スワップする)処理は、ref の典型的な応用例です。値渡しでは変数のコピーが渡されるため、メソッド内で値を入れ替えても元の変数には影響しません。しかし、ref を使えば、呼び出し元の変数そのものにアクセスして値を入れ替えることができます。
“`csharp
using System;
public class SwapExample
{
public static void Main(string[] args)
{
int a = 5;
int b = 10;
Console.WriteLine($"スワップ前: a = {a}, b = {b}"); // 出力: a = 5, b = 10
// refを使って参照渡しで呼び出す
Swap(ref a, ref b);
Console.WriteLine($"スワップ後: a = {a}, b = {b}"); // 出力: a = 10, b = 5 (入れ替わった!)
}
// 二つのint変数の値を参照渡しで入れ替えるメソッド
static void Swap(ref int x, ref int y)
{
int temp = x; // xが指す場所の値(つまり呼び出し元のaの値)をtempに一時保存
x = y; // yが指す場所の値(つまり呼び出し元のbの値)をxが指す場所(aの場所)に代入
y = temp; // tempの値(元のaの値)をyが指す場所(bの場所)に代入
Console.WriteLine($"メソッド内でのスワップ完了: x = {x}, y = {y}"); // 出力: x = 10, y = 5
}
}
“`
この例では、Swap メソッドに a と b の参照を渡すことで、メソッド内で直接 a と b の値を操作し、入れ替えを実現しています。
6.2. 構造体 (Struct) と参照渡し:パフォーマンス
構造体 (struct) は値型です。大きな構造体をメソッドに値渡しする場合、構造体全体のデータがコピーされるため、パフォーマンスのオーバーヘッドが発生する可能性があります。
このような場合、ref を使って構造体を参照渡しすることで、コピーのコストを削減できます。これにより、メソッドは構造体の実体への参照を受け取り、直接その場でデータを操作できます。
ただし、ref で渡された構造体はメソッド内で変更可能になるため、意図しない副作用に注意が必要です。後述する C# 7以降の in キーワードは、構造体を効率的に渡したいが、メソッド内での変更は避けたいという場合に有用です。
“`csharp
using System;
using System.Diagnostics; // パフォーマンス計測用
// 大きな構造体を模倣
public struct LargeStruct
{
public long Value1;
public long Value2;
// … 他にもたくさんのフィールドがあると想定 …
public long Value100;
public LargeStruct(long value)
{
Value1 = value;
Value2 = value;
// ...
Value100 = value;
}
}
public class StructPassExample
{
public static void Main(string[] args)
{
var largeObject = new LargeStruct(10);
Console.WriteLine("値渡し vs 参照渡し (構造体)");
// 値渡しによる呼び出し
Stopwatch swValue = Stopwatch.StartNew();
for (int i = 0; i < 1000000; i++)
{
ProcessStructByValue(largeObject);
}
swValue.Stop();
Console.WriteLine($"値渡しでの処理時間: {swValue.ElapsedMilliseconds} ms");
// 参照渡し (ref) による呼び出し
Stopwatch swRef = Stopwatch.StartNew();
for (int i = 0; i < 1000000; i++)
{
ProcessStructByRef(ref largeObject);
}
swRef.Stop();
Console.WriteLine($"参照渡し (ref) での処理時間: {swRef.ElapsedMilliseconds} ms");
// 注意: この簡易計測は環境に依存します
// out/in も同様にパフォーマンス向上に貢献する可能性があります
}
// 構造体を値渡しで処理するメソッド
static void ProcessStructByValue(LargeStruct data)
{
// dataはlargeObjectのコピー
// ここでdataを変更してもlargeObjectには影響しない
data.Value1 = 99; // コピーに対する変更
}
// 構造体を参照渡し (ref) で処理するメソッド
static void ProcessStructByRef(ref LargeStruct data)
{
// dataはlargeObjectの参照
// ここでdataを変更するとlargeObjectも変更される
data.Value1 = 99; // 元のオブジェクトに対する変更
}
}
“`
この例では、大きな構造体を100万回メソッドに渡す処理時間を比較しています(厳密なベンチマークには不向きですが、概念を理解するのに役立ちます)。多くの場合、特に構造体が大きいほど、ref や in (後述) を使った参照渡しの方が値渡しのコピーコストを避けて高速になる傾向があります。
ProcessStructByRef の中で data.Value1 = 99; のように構造体のメンバーを変更すると、呼び出し元の largeObject のメンバーも変更されます。これを避けつつ効率的に渡したい場合は、C# 7以降の in キーワードがより適しています。
6.3. 参照型での ref の応用:参照の付け替え
前述の例でも示しましたが、参照型を ref で渡すことで、メソッド内でその参照自体を付け替える(別のオブジェクトを指すようにする)操作が呼び出し元に反映されます。これは、例えばオブジェクトの初期化や入れ替えを行うユーティリティメソッドを作成する際に役立ちます。
“`csharp
using System;
public class MyClass { public string Name { get; set; } }
public class RefReferenceSwapExample
{
public static void Main(string[] args)
{
MyClass objA = new MyClass { Name = “A” };
MyClass objB = new MyClass { Name = “B” };
Console.WriteLine($"スワップ前: objA.Name = {objA.Name}, objB.Name = {objB.Name}"); // 出力: A, B
// refを使って参照型を参照渡し
SwapReferences(ref objA, ref objB);
Console.WriteLine($"スワップ後: objA.Name = {objA.Name}, objB.Name = {objB.Name}"); // 出力: B, A (参照が入れ替わった!)
// 通常の参照型値渡しでは参照の付け替えは反映されない
MyClass objC = new MyClass { Name = "C" };
MyClass objD = new MyClass { Name = "D" };
Console.WriteLine($"\n通常の参照型値渡しでの付け替え前: objC.Name = {objC.Name}, objD.Name = {objD.Name}"); // 出力: C, D
TrySwapReferencesByValue(objC, objD);
Console.WriteLine($"通常の参照型値渡しでの付け替え後: objC.Name = {objC.Name}, objD.Name = {objD.Name}"); // 出力: C, D (変わらない!)
}
// 二つの参照型変数の「参照」を参照渡しで入れ替えるメソッド
static void SwapReferences(ref MyClass x, ref MyClass y)
{
MyClass temp = x; // xが指す参照(呼び出し元のobjAの参照)を一時保存
x = y; // yが指す参照(呼び出し元のobjBの参照)をxが指す場所(objAの場所)に代入
y = temp; // tempの参照(元のobjAの参照)をyが指す場所(objBの場所)に代入
Console.WriteLine($"メソッド内での参照スワップ完了: x.Name = {x.Name}, y.Name = {y.Name}"); // 出力: B, A
}
// 参照型を値渡しで渡した場合の参照付け替え (反映されない)
static void TrySwapReferencesByValue(MyClass x, MyClass y)
{
// x, y は objC, objD の参照のコピー
// このメソッド内で x=y; y=temp; としても、それはコピーされた参照の付け替え
// 呼び出し元の objC, objD が持つ参照はそのまま
MyClass temp = x;
x = y;
y = temp;
Console.WriteLine($"TrySwapReferencesByValue メソッド内での参照スワップ完了: x.Name = {x.Name}, y.Name = {y.Name}"); // 出力: D, C
}
}
“`
SwapReferences メソッドでは、ref MyClass x, ref MyClass y とすることで、呼び出し元の objA および objB という変数そのものへの参照が渡されます。そのため、x = y; や y = temp; といった代入は、そのまま objA および objB に格納されている参照を書き換えることになります。
一方、TrySwapReferencesByValue メソッドでは、MyClass x, MyClass y と値渡しで渡されるため、x と y は objC と objD が持つ参照の「コピー」を受け取ります。メソッド内での x = y; といった代入は、あくまでこのコピーされた参照を付け替えるだけであり、呼び出し元の objC, objD が持つ参照には影響しません。
7. C# 7以降の参照関連機能
C# 7.0以降では、参照渡しをさらに柔軟かつ効率的に行うための新しい機能がいくつか追加されました。これらを理解することで、より高度なテクニックが使えるようになります。
inキーワード(読み取り専用参照渡し)refreturn(参照戻り値)reflocal(参照ローカル変数)
7.1. in キーワード:読み取り専用参照渡し (C# 7.2+)
in キーワードは、ref と似ていますが、パラメータがメソッド内で変更されないことを保証する「読み取り専用」の参照渡しです。主に大きな構造体を効率的に渡す際に、値渡しによるコピーのコストを削減しつつ、メソッドによる意図しない変更を防ぎたい場合に使用します。
in パラメータとして渡された変数は、メソッド内で値を変更しようとするとコンパイルエラーになります。
“`csharp
using System;
// 大きな構造体を模倣
public struct LargeStruct
{
public long Value;
// … 他にもたくさんのフィールドがあると想定 …
public LargeStruct(long value)
{
Value = value;
}
}
public class InPassExample
{
public static void Main(string[] args)
{
var largeObject = new LargeStruct(100);
// inを付けて読み取り専用参照渡し
ProcessStructByIn(in largeObject);
Console.WriteLine($"処理後: {largeObject.Value}"); // 出力: 100 (inで渡したので変更されない)
// LargeStruct modifiedObject = largeObject;
// ModifyStructValue(in modifiedObject); // エラー!inで渡されたものを変更するメソッドには渡せない(デフォルトでは)
// ProcessStructByIn(in new LargeStruct(200)); // OK. 一時的な変数もinで渡せる
}
// 構造体をinで読み取り専用参照渡しするメソッド
static void ProcessStructByIn(in LargeStruct data)
{
// dataはlargeObjectの参照だが、読み取り専用
Console.WriteLine($"ProcessStructByIn メソッド内: {data.Value}"); // 出力: 100
// data.Value = 99; // コンパイルエラー! inパラメータは変更できない
}
// inパラメータを通常のrefやoutパラメータとして渡すこともできない (デフォルトでは)
// static void ModifyStructValue(ref LargeStruct data) { data.Value = 50; } // ProcessStructByIn(in largeObject) から呼び出すとエラー
}
“`
解説:
ProcessStructByIn(in largeObject);のように、呼び出し元でもinキーワードが必要です。- メソッド内では、
dataを通じて構造体の値を読み取ることはできますが、変更しようとするとコンパイルエラーになります。 inパラメータは、値渡しのように振る舞うが、実体は参照渡しでありコピーコストがない、と考えると分かりやすいでしょう。パフォーマンスが重要な場面で、大きな構造体をメソッドに渡す際に有効です。inパラメータはデフォルトでは変更できないため、それをrefやoutパラメータとして別のメソッドに渡すこともできません。
7.2. ref return:参照戻り値 (C# 7.0+)
従来のメソッドの戻り値は、値そのものか、参照型のオブジェクトへの参照でした。ref return は、メソッドが変数そのものへの参照を返すことができる機能です。これにより、メソッドの呼び出し側で、返された参照を通じて元の変数を直接操作できます。
ref return は、配列の要素や構造体のフィールドなど、特定のメモリ上の場所への参照を返す場合に特に有用です。
“`csharp
using System;
public class RefReturnExample
{
private int[] numbers = { 10, 20, 30, 40, 50 };
// 配列の特定の要素への参照を返すメソッド
public ref int GetElementByIndex(int index)
{
if (index < 0 || index >= numbers.Length)
{
throw new IndexOutOfRangeException();
}
return ref numbers[index]; // 配列要素への参照を返す
}
public static void Main(string[] args)
{
RefReturnExample example = new RefReturnExample();
Console.WriteLine($"元の配列: {string.Join(" ", example.numbers)}"); // 出力: 10 20 30 40 50
// メソッドから参照を受け取る (ref local変数で受け取る)
ref int element = ref example.GetElementByIndex(2); // numbers[2] (値は30) への参照を受け取る
Console.WriteLine($"elementが指す値: {element}"); // 出力: 30
// 受け取った参照を通じて、元の配列の要素の値を変更
element = 99;
Console.WriteLine($"変更後、elementが指す値: {element}"); // 出力: 99
Console.WriteLine($"変更後、元の配列: {string.Join(" ", example.numbers)}"); // 出力: 10 20 99 40 50 (元の配列も変更された!)
// 別の要素への参照を取得して変更
ref int firstElement = ref example.GetElementByIndex(0);
firstElement = 5;
Console.WriteLine($"さらに変更後、元の配列: {string.Join(" ", example.numbers)}"); // 出力: 5 20 99 40 50
// ref returnは、参照を返すため、リテラルやプロパティの値など、
// メモリ上の固定された変数でないものは返せない
// public ref int GetValue() { return ref 123; } // コンパイルエラー
// public ref string GetName() { return ref SomeProperty; } // プロパティは通常変数ではないのでエラー
}
}
“`
解説:
GetElementByIndexメソッドは、戻り値の型にrefを付け (ref int)、return ref numbers[index];のように、refを付けて値を返しています。Mainメソッドでは、このrefを返すメソッドの呼び出し結果を、ref int element = ref example.GetElementByIndex(2);のように、refを付けたローカル変数 (ref local) で受け取ります。elementは、example.numbers[2]という配列の要素そのものへの参照となります。したがって、element = 99;のようにelementの値を変更すると、それはexample.numbers[2]の値の変更に直結します。
ref return は、特に大きなデータ構造の一部に効率的にアクセスし、直接変更したい場合に強力です。ただし、メソッドから返される参照が、メソッドのスコープ外でも有効なメモリ領域を指しているか(ローカル変数など、メソッド終了後に破棄される場所への参照を返さないか)など、安全性の考慮が必要です。C#コンパイラは、安全でない ref return を防ぐためのチェックを行います。
7.3. ref local:参照ローカル変数 (C# 7.0+)
ref local 変数は、通常のローカル変数とは異なり、値そのものを格納するのではなく、別の変数への参照を格納します。これにより、ref local 変数を通じて、それが参照している元の変数を操作できます。
これは、特に ref return で受け取った参照をローカル変数に保持したい場合に役立ちます。
“`csharp
using System;
public class RefLocalExample
{
public static void Main(string[] args)
{
int a = 10;
int b = 20;
// aへの参照を保持するref local変数を作成
ref int refToA = ref a;
Console.WriteLine($"refToA が指す値: {refToA}"); // 出力: 10
// refToA を通じて a の値を変更
refToA = 100;
Console.WriteLine($"refToA を変更後: refToA = {refToA}, a = {a}"); // 出力: refToA = 100, a = 100 (aも変わった!)
// bへの参照を保持するようにrefToAを再割り当て (ref reassignment)
refToA = ref b; // C# 7.3以降: ref localの再割り当てが可能に
Console.WriteLine($"refToA を b に再割り当て後: refToA = {refToA}, a = {a}, b = {b}"); // 出力: refToA = 20, a = 100, b = 20 (refToA は b を指す)
// refToA を通じて b の値を変更
refToA = 200;
Console.WriteLine($"refToA を再度変更後: refToA = {refToA}, a = {a}, b = {b}"); // 出力: refToA = 200, a = 100, b = 200 (bが200になった!)
// 値そのものをコピーして格納する通常のローカル変数との違い
int copyOfB = b; // bの値(200)がコピーされる
copyOfB = 300;
Console.WriteLine($"copyOfB を変更後: copyOfB = {copyOfB}, b = {b}"); // 出力: copyOfB = 300, b = 200 (bは変わらない)
}
}
“`
解説:
ref int refToA = ref a;のように、reflocal変数の宣言時にrefキーワードを使用し、初期化時にはrefを付けて他の変数への参照を代入します。refToAに値を代入 (refToA = 100;) すると、これはrefToAが参照している元の変数aの値を変更します。- C# 7.3以降では、
ref local変数が参照する対象をrefToA = ref b;のように変更(再割り当て)できるようになりました。これにより、refToAはaを参照する代わりにbを参照するようになります。その後のrefToAへの代入はbに影響します。
ref localは、主に ref returnと組み合わせて使用されることで、メソッドから返された参照を効果的に利用できるようになります。
8. 参照渡しを使う上での注意点とデメリット
参照渡しは強力な機能ですが、使用には注意が必要です。不適切に使用すると、コードの可読性を損なったり、予期せぬバグを引き起こしたりする可能性があります。
- 可読性の低下と副作用: メソッドのシグネチャに
refやoutが付いていると、呼び出し元の変数がメソッド内で変更される可能性があることが分かります。しかし、引数が多い場合やメソッドの処理が複雑な場合、どの引数がどのように変更されるのかを把握するのが難しくなり、コードの可読性が低下する可能性があります。これは「副作用」と呼ばれ、コードの理解やデバッグを難しくする要因となります。 - 意図しない変更:
refで渡された変数はメソッド内で自由に読み書きできてしまうため、誤って本来変更すべきでない変数の値を書き換えてしまうリスクがあります。inキーワードはこのリスクを軽減しますが、refを使う場合は特に注意が必要です。 - スレッドセーフティの考慮: 複数のスレッドから同じ変数を参照渡しでメソッドに渡して同時に操作した場合、競合状態 (race condition) が発生し、予期しない結果になる可能性があります。並行処理を行う場合は、ロック機構などを使って参照渡しの変数へのアクセスを同期させる必要があります。
- パフォーマンス: 大きな構造体を渡す場合は
refやinがパフォーマンス上有利なことが多いですが、小さな値型の場合は値渡しのコピーコストは非常に小さいため、参照渡しを使うメリットは少なく、むしろオーバーヘッドが発生する可能性もあります。また、参照渡しを使うことで、コンパイラが特定の最適化を行えなくなる場合もあります。パフォーマンスのために参照渡しを検討する場合は、実際にプロファイリングを行って効果を確認することが推奨されます。 - API設計: 公開APIで
refやoutを多用すると、そのAPIを利用する側のコードが煩雑になったり、誤用を招きやすくなったりする可能性があります。API設計においては、戻り値や新しいクラス/構造体、あるいはタプル(C# 7以降)を使って複数の値を返す方が、多くの場合より分かりやすく、メンテナンスしやすいコードになります。
参照渡しは、明確な目的(呼び出し元の変更、複数値の出力、大きな構造体の効率的な受け渡し)がある場合にのみ、そのメリットとデメリットを考慮して慎重に使うべきです。
9. まとめ
この記事では、C#における値渡しと参照渡しについて、それぞれの仕組み、ref と out キーワードの使い方、そしてC# 7以降のin, ref return, ref localといった新しい機能まで、詳しく解説しました。
- 値渡し: 引数の値のコピーを渡す。メソッド内で引数を変更しても呼び出し元には影響しない(参照型の場合は中身の変更は影響するが、参照自体の付け替えは影響しない)。C#のデフォルトの挙動。
- 参照渡し (
ref): 引数の変数の場所(参照)を渡す。メソッド内で引数を変更すると、呼び出し元の変数そのものが変更される。呼び出し元の変数は事前に初期化が必要。 - 出力パラメータ (
out): メソッドから複数の値を返すために使う参照渡し。メソッド内で引数に必ず値を代入する必要がある。呼び出し元の変数は事前に初期化が不要(C# 7以降は呼び出し時に宣言も可能)。 - 読み取り専用参照渡し (
in): 大きな構造体などを効率的に渡すための参照渡し。メソッド内で引数の値を変更することはできない。C# 7.2以降。 - 参照戻り値 (
refreturn): メソッドが変数そのものへの参照を返す機能。返された参照を通じて元の変数を直接操作できる。C# 7.0以降。 - 参照ローカル変数 (
reflocal): 別の変数への参照を格納するローカル変数。refreturnで受け取った参照を保持する際などに使う。C# 7.0以降(再割り当てはC# 7.3以降)。
参照渡しは、変数の値や参照を直接操作できる強力な機能であり、特定の状況下ではパフォーマンス向上にも寄与します。しかし、コードの可読性やメンテナンス性を考慮し、その利用は慎重に行うべきです。特に新しいC#の機能は、より高度なシナリオに対応するためのものですが、適切な理解なしに使用するとかえってコードが複雑になることもあります。
まずは基本的な値渡しとref/outをしっかり理解し、その上で必要に応じてin, ref return, ref localといった機能に挑戦していくのが良いでしょう。
この記事が、C#の参照渡しを理解し、適切に使いこなすための一助となれば幸いです。学習はここで終わりではありません。実際にコードを書いて試したり、他の人のコードを読んだりすることで、理解はさらに深まります。頑張ってください!