値型の参照渡し
最後に、参照渡しの活用場面について説明します。
C#には、値渡し・参照渡しと、値型・参照型という区別があって、組み合わせると以下の4つが考えられます。
- 値型の値渡し
- 参照型の値渡し
- 値型の参照渡し
- 参照型の参照渡し
正直、参照型の参照渡しを使いたい場面は、出力引数くらいでしょう。
通常の参照引数(ref
引数)や参照戻り値は、ほぼ値型に対して使うものです。
ここでは、どうして値型の場合は参照渡しが必要になるかについて説明して行きましょう。
値型の部分書き換えに関する問題
前述の通り、値渡しをすると、値のコピーが発生します。 結果として、値の書き換えは変数ごとに独立になります。
例えば、以下のようなコードを書いたとしましょう。
2つの変数p
とq
がありますが、それぞれ別コピーになっていて、片方の書き換えは他方に影響しません。
using System;
struct Point
{
public int X;
public int Y;
public override string ToString() => $"({X}, {Y})";
}
class Program
{
static void Main()
{
var p = new Point { X = 1, Y = 2 };
// p のコピーが作られる
var q = p;
// コピー側の書き換えなので、p には影響なし
q.X = 3;
Console.WriteLine(p); // 1, 2
Console.WriteLine(q); // 3, 2
// 同じく、p を書き換えても q に影響なし
p.Y = 4;
Console.WriteLine(p); // 1, 4
Console.WriteLine(q); // 3, 2
}
}
以下の図のような状態になっているわけです。
この例はローカル変数への代入に関するものですが、同様の「コピー」は、引数や戻り値でも起こります。 ここで注意が必要なのはプロパティとインデクサーです。 プロパティやインデクサーは、フィールドや配列に対する読み書きに似た呼び出し方になりますが、 実際には関数呼び出しになっています。 値を直接読み書きしているように見えて、実際には引数・戻り値越しの読み書きになります。 そのため、値型のプロパティやインデクサーには注意が必要です。
例えば、フィールドや配列を直接読み書きするのであれば、以下のような書き方ができます。
class RawData
{
// フィールドを直接公開
public Point P;
// 配列を公開
public Point[] Items { get; } = new Point[3];
}
class Program
{
static void Main()
{
var raw = new RawData();
raw.P.X = 1; // フィールドは直接書き換え可能
raw.Items[0].X = 1; // 配列の要素の直接書き換え可能
}
}
これが、プロパティやインデクサーを介すると、以下のように書き換えが面倒になります。
class CapsuledData
{
// プロパティで公開
public Point P { get; set; }
// インデクサーで公開
public Point this[int i]
{
get { return _items[i]; }
set { _items[i] = value; }
}
private Point[] _items = new Point[3];
}
class Program
{
static void Main()
{
#if false
var cap = new CapsuledData();
cap.P.X = 1; // プロパティの戻り値(コピー品)の書き換えはコンパイル エラーに
cap[0].X = 1; // インデクサーの戻り値も同様、コンパイル エラーに
#else
// こんな書き方が必須になる
var cap = new CapsuledData();
var p = cap.P; // 一旦ローカル変数に全体をコピー
p.X = 1; // ローカル変数を部分書き換え
cap.P = p; // 全体を渡しなおし
var q = cap[0];
q.X = 1;
cap[0] = q;
#endif
}
}
この例を見ての通り、部分書き換えができなくなります。 一旦コピーして、ローカル変数に対して部分書き換えをして、その結果を全体を渡しなおす必要があります。
補足: 「構造体は書き換え不能に作れ」ガイドライン
プロパティやインデクサーを通して部分書き換えできないというのが意外と罠になるので、
構造体は最初から部分書き換え不能に作る方がいいというガイドラインもあるくらいです。
このガイドライン通りにPoint
構造体を作るなら、以下のようになります。
struct Point
{
public readonly int X;
public readonly int Y;
public Point(int x, int y) { X = x; Y = y; }
public override string ToString() => $"({X}, {Y})";
}
ただし、この方針は、パフォーマンス的には不利になることが多いです。
X
, Y
のどちらかだけを書き換えたい場合でも、X
, Y
両方のコピーが発生するためです。
特に、構造体のサイズが大きくなると、コピーの負担が結構深刻になってきます。
参照渡しの活用
補足で説明したような部分書き換えできない型を作る実装方法は、バグを減らす意味では有効です。 しかしその一方で、パフォーマンス的には不利になります。
先ほどの例のPoint
構造体(int
型2つでせいぜい8バイト)くらいならいいんですが、
全体のコピーのコストが問題になる場合もあります。
別項の「値型の性能」で少し触れていますが、
構造体のサイズによってはパフォーマンスに数倍の差が出たりします。
このコピーのコストが許容できない場面で、参照戻り値が役立つことがあります。 例えば先ほどの例を以下のような書き換えてみましょう。 値渡しの時と違って、構造体の部分書き換えができるようになります。
class RefData
{
// 参照戻り値のプロパティで公開
public ref Point P => ref _p;
private Point _p;
// 参照戻り値のインデクサーで公開
public ref Point this[int i] => ref _items[i];
private Point[] _items = new Point[3];
}
class Program
{
static void Main()
{
var raw = new RefData();
raw.P.X = 1; // プロパティ越しに、参照先のフィールドを書き換え可能
raw[0].X = 1; // インデクサー越しに、参照先の配列を書き換え可能
}
}
プロパティ/インデクサーのsetアクセサーを介する場合と比べると自由度は減ります(set時に値の検証などの処理が挟めない)。 しかし、フィールドや配列を直接公開するよりは自由な処理が書けます(少なくともget時の処理は挟める)。 例えば以下のような利用例が考えられるでしょう。getアクセサーに少しだけ処理が挟まっています。
/// <summary>
/// 循環バッファー。
/// </summary>
/// <typeparam name="T">要素の型。</typeparam>
class CircularBuffer<T>
{
private int _startIndex;
private T[] _data;
/// <summary>
/// 容量を指定して初期化。
/// </summary>
/// <param name="capacity">容量。</param>
public CircularBuffer(int capacity)
{
_startIndex = 0;
_data = new T[capacity];
}
/// <summary>
/// 値を追加。
/// 容量を超えた分は古いものから削除。
/// </summary>
/// <param name="item">新しい値。</param>
public void Push(T item)
{
_data[_startIndex] = item;
_startIndex++;
if (_startIndex >= _data.Length) _startIndex = 0;
}
/// <summary>
/// 先頭要素。
/// </summary>
public ref T Head => ref _data[_startIndex];
/// <summary>
/// 先頭から <paramref name="index"/> 先の要素。
/// </summary>
/// <param name="index">先頭からの位置。</param>
/// <returns></returns>
public ref T this[int index] => ref _data[(_startIndex + index) % _data.Length];
}
補足: 配列のインデクサー
本節で挙げた例で、配列のインデクサーはユーザー定義のインデクサーと挙動が違うことにお気づきでしょうか。 実は、配列のインデクサーは参照を返しています。
C# 6までは参照戻り値のための構文がなく、ユーザー定義のインデクサーでは参照を返す手段はありませんでした。 しかし、配列は特別扱いを受けていて、インデクサーが参照になっています。 例えば、以下のようなコードを書くと、配列の方だけ正常にコンパイルできます。
var array = new[]
{
new Point(),
new Point(),
};
// 配列のインデクサーは要素への参照になってる
// 値型の要素の書き換え可能
array[0].X = 1; // OK
var list = new List<Point>
{
new Point(),
new Point(),
};
// これまで、ユーザー定義のインデクサーは参照返せなかった
// 当然、C# 6以前からあるクラスのインデクサーは値型の要素の書き換え不能
list[0].X = 1; // コンパイル エラー