Gaming Life

一日24時間、ゲームは10時間

C/C++から始めた人の、C#のref/out/in修飾子の用途

概要

ref: 引数を参照で渡し、値の書き換えと、関数内で参照先の書き換えの両方を許可したい時に使う

out: 関数の引数に計算結果を返したい時に使う

in: 引数を参照で渡し、値の書き換えのみ許可したい時に使う

前提知識:C#における値型と参照型の違いについて

C/C++からプログラムを始めた私にとって、C#の最初のつまづきポイントがここだった。C#プログラミングにおいて、値型と参照型を意識することは非常に重要である。

値型と参照型の違いは単純で、「その型の値を直接保持するか否か」である。以下に簡単な例を示す。

// C#ではstruct(構造体)は値型である
public struct T
{
    public int a, b, c;
    public T(int _a, int _b, int _c) { a = _a; b = _b; c = _c; }
    public override string ToString() { return $"{a}, {b}, {c}"; }
}

// C#ではclassは参照型である
public class U
{
    public int a, b, c;
    public U(int _a, int _b, int _c) { a = _a; b = _b; c = _c; }
    public override string ToString() { return $"{a}, {b}, {c}"; }
}

// 値型の変数を=で代入した時の挙動を見る
T a1 = new T(1, 1, 1);
T a2 = a1;
T a3 = a2;
a1.a = 2;
Console.WriteLine($"a1: {a1}");
Console.WriteLine($"a2: {a2}");
Console.WriteLine($"a3: {a3}");

Console.WriteLine("=======================");

// 参照型の変数を=で代入した時の挙動を見る
U b1 = new U(1, 1, 1);
U b2 = b1;
U b3 = b2;
b1.a = 2;
Console.WriteLine($"b1: {b1}");
Console.WriteLine($"b2: {b2}");
Console.WriteLine($"b3: {b3}");

出力結果

a1: 2, 1, 1
a2: 1, 1, 1
a3: 1, 1, 1
=======================
b1: 2, 1, 1
b2: 2, 1, 1
b3: 2, 1, 1

値型の変数は、値そのもの(今回の例でいうと int a, b, cの3つのint型の値)を保持する。そのため、 = を使って代入した時、値すべてをコピーして代入先に格納するという挙動になる。

一方参照型の変数は、値の実体がどこにあるかを示す参照情報だけを持ち、実体は別の場所(ヒープ)に用意する。そのため、 = を使って代入すると参照情報だけをコピーするので、上記コードでの変数 b1, b2, b3 は同じ値の実体を指していることになる。

値型と参照型について、詳しくは以下のWebサイトを見てほしい。

ufcpp.net

techblog.kayac.com

関数の引数は値渡し

C#で引数付きの関数を呼び出す時、関数には値そのものが渡される。これを一般に値渡しと呼ぶ。

では、値型と参照型で「値渡し」した時の挙動がどう変わってくるか見てみる。

void ModifyValueStruct(T t)
{
    t.a = 10; // 値型引数の値を関数内で書き換える
}

void ModifyValueClass(U u)
{
    u.a = 10; // 参照型引数の値を関数内で書き換える
}

T t = new T(1, 1, 1);
U u = new U(1, 1, 1);
ModifyValueStruct(t);
ModifyValueClass(u);
Console.WriteLine($"T t: {t}");
Console.WriteLine($"U u: {u}");

出力結果

T t: 1, 1, 1
U u: 10, 1, 1

値型の変数を関数に値渡しすると、「値そのもの」がコピーされ関数に引き渡される。つまり、関数呼び出し側の変数 t と、関数内の変数 t は別モノ。関数内でいくら値型引数の値を書き換えても、関数呼び出し元には、その変更は伝わらない。

一方参照型の変数を関数に値渡しすると、「値の参照情報」だけがコピーされ、関数に引き渡される。つまり、関数呼び出し側の変数 u と 関数内の変数 u は同じ値を指している。したがって、関数内で参照型引数の値を書き換えると、関数呼び出し元の変数が指す値も書き換わる。

ref/out/inを使った参照渡し

ref/out/in修飾子は、「関数の引数を参照渡し」したい時に使われる。

ref/out/inを使いたいときは、関数宣言側と、引数引数の型名の前にrefなどの修飾子をつければよい。

void RefFunc(ref int i) { /* ... */ } // 型名の前にref修飾子をつける

// ...

int num;
RefFunc(ref num); // 呼び出すときにもref修飾子をつける(注:inはつけなくてもよい)

ref/out/in修飾子はすべて「引数の参照渡し」を実現するためのキーワードだが、それぞれ細かく挙動が変わってくる。以下にその違いをまとめた表を載せた。

キーワード 引数として渡す前に初期化が必要か メソッド内で参照先の値の書き換えが可能か 呼び出し時の修飾子付与
ref 必須 可能 必須
out 不要 必須 必須
in 必須 不可 任意

値型を参照渡しする例

void Func(T t)
{
    t.a = 2; // 値渡しされた値型を関数内で書き換えても呼び出し元には伝わらない
}
void Func2(T t)
{
    t = new T(2, 2, 2); // 値渡しされた値型のインスタンス差し替えは呼び出し元に伝わらない
}
void RefFunc(ref T t)
{
    t.a = 2; // ref引数の値を書き換えると呼び出し元にも伝わる
}
void RefFunc2(ref T t)
{
    t = new T(2, 2, 2); // 引数が指すインスタンスを書き換えることも出来る
}

void OutFunc(out T t)
{
    t = new T(3, 3, 3); // out引数は関数内で必ず初期化しなければならない
}

void InFunc(in T t)
{
    // t.a = 2; // in引数の値を書き換えることはできない
}

T t;

t = new T(1, 1, 1);
Func(t);
Console.WriteLine($"Func() -> {t}");

t = new T(1, 1, 1);
Func2(t);
Console.WriteLine($"Func2() -> {t}");

t = new T(1, 1, 1);
RefFunc(ref t);
Console.WriteLine($"RefFunc() -> {t}");

t = new T(1, 1, 1);
RefFunc2(ref t);
Console.WriteLine($"RefFunc2() -> {t}");

t = new T(1, 1, 1);
OutFunc(out t);
Console.WriteLine($"OutFunc() -> {t}");

t = new T(1, 1, 1);
InFunc(in t);
Console.WriteLine($"InFunc() -> {t}");

出力結果

Func() -> 1, 1, 1
Func2() -> 1, 1, 1
RefFunc() -> 2, 1, 1
RefFunc2() -> 2, 2, 2
OutFunc() -> 3, 3, 3
InFunc() -> 1, 1, 1

参照型を参照渡しする例

void Func(U u)
{
    u.a = 2; // 値渡しされた参照型引数の値を書き換えると、呼び出し元に伝わる
}
void Func2(U u)
{
    u = new U(2, 2, 2); // 値渡しされた参照型引数の指す先を変えても、呼び出し元には伝わらない
}
void RefFunc(ref U u)
{
    u.a = 2; // 値を書き換えると呼び出し元にも伝わる
}
void RefFunc2(ref U u)
{
    u = new U(2, 2, 2); // 引数が指すインスタンスを書き換えることも出来る
}

void OutFunc(out U u)
{
    u = new U(3, 3, 3); // ouu引数は関数内で必ず初期化しなければならない
}

void InFunc(in U u)
{
    // u.a = 2; // in引数の値を書き換えることはできない
}

U u;

u = new U(1, 1, 1);
Func(u);
Console.WriteLine($"Func() -> {u}");

u = new U(1, 1, 1);
Func2(u);
Console.WriteLine($"Func2() -> {u}");

u = new U(1, 1, 1);
RefFunc(ref u);
Console.WriteLine($"RefFunc() -> {u}");

u = new U(1, 1, 1);
RefFunc2(ref u);
Console.WriteLine($"RefFunc2() -> {u}");

u = new U(1, 1, 1);
OutFunc(out u);
Console.WriteLine($"OutFunc() -> {u}");

u = new U(1, 1, 1);
InFunc(in u);
Console.WriteLine($"InFunc() -> {u}");

出力結果

Func() -> 2, 1, 1
Func2() -> 1, 1, 1
RefFunc() -> 2, 1, 1
RefFunc2() -> 2, 2, 2
OutFunc() -> 3, 3, 3
InFunc() -> 1, 1, 1

ref/out/inの使い道

refは、既に値が入っている変数を書き換える時に使う。

気をつけたいのは、参照型の引数を書き換えたいからという理由でrefをつける必要はないこと。参照型の値渡しで値の書き換えは可能なので、その用途でrefをつける必要はない。

参照型引数にrefをつけるのは、「引数が指す先を変えたい(新しいインスタンスを指すようにしたい)」ときのみ。.NET APIでは、 Array.Resize() で参照型引数にref修飾子を使っている。

outは、関数の返り値を複数持たせたいような時に使うことが多い印象。C++では「非constポインタ渡し」で実現する処理。ただしrefとは異なり、関数内で新たに初期化されるので、まだ宣言しかしていない変数を渡すべき。

ちなみに、下のように変数宣言をoutによる参照渡しと同時にこなすこともできる(outを使うときはこの書き方が安全だと思う)。

bool isValid = Calclate(out int num);

inは、inを使わければならない特別なプログラムがあるわけではない。

inの用途はズバリ「値型引数のコピーコスト削減」。

値型を値渡しする時、その値のコピーが作られる。この値が4Byteのint程度ならコピーにかかるコストは無視できるほど小さい(だろう)。しかし、もし巨大な値型(struct)を値渡ししたら巨大なコピーが作られてしまい、その分コピーに必要な時間もメモリも増えてしまう。こういう時にinは使われる。C++でいうところの「&を使ったconst参照渡し」に相当すると考えて良い。

が、コピーに無視できないほどのコストがかかる巨大な値型(struct)を作るべきではないということは忘れてはならない(そうしないとならない特別な事情って何かあるんでしょうか。知ってる方がいたら教えていください)。

まとめ

C/C++からプログラムを始めた人にとって、C#の値型と参照型の違いは必ず抑えておきたい。それを理解した上で、ref/out/inを使いたい。