値型と参照型

データ型の分類

データ型でも説明した通り、C#には様々なデータ型が用意されています。
データ型には組み込み型ユーザー定義型という分類の仕方もありますが、挙動の違いによる分類方法もあります。
それが値型参照型です。

値型のデータ型

値型は組み込み型のうち、string型とobject型以外のデータ型が該当します。
(bool型、int型、double型など)
その他、列挙型構造体型も該当します。

プリミティブ型

値型のうち、bool型やint型、double型などの最も基本的な型をプリミティブ型といいます。
decimal型は内部的には構造体となっており、プリミティブ型ではありません。

参照型のデータ型

参照型は組み込み型のうち、string型とobject型が該当します。
その他、配列型クラス型インターフェイス型デリゲート型といったデータ型も該当します。

値型と参照型の違い

メモリへの保存方法

変数はメモリ上に実際のデータを保存するわけですが、値型と参照型とではメモリへの保存方法が異なります。

値型は、変数が用意する「入れ物」に直接データを保存するイメージです。
例えばint型変数の使用を宣言した時、メモリ上に4バイトの保存領域が確保されます。
その変数に値を代入すると、確保された領域に実際のデータが保存されます。

参照型は、変数が用意する「入れ物」には実際のデータは保存しません。
変数の「入れ物」には、実際のデータを保存する「別の保存領域」の場所を示すアドレスが保存されます。
アドレスとは「メモリ上の住所」のことです。
保存データへのアクセスは、このアドレスを元にして行います。

■値型と参照型のメモリ上のイメージ1
値型と参照型のメモリ上のイメージ1

アドレスは自動的に決定され、プログラマが意識することはありません。

値型はイメージしやすいでしょう。
参照型は値の操作の前にワンクッションある、というイメージです。

値型と参照型の違いが現れるのは代入時です。


//値型
int val1 = 123;
int val2 = val1;

val1 = 456;

Console.WriteLine(val1); //456
Console.WriteLine(val2); //123

//参照型
short[] ref1 = new short[] { 1, 2, 3 };
short[] ref2 = ref1;

ref1[0] = 4;

Console.WriteLine(ref1[0]); //4
Console.WriteLine(ref2[0]); //4
456
123
4
4

値型の変数val2に変数val1を代入後、変数val1を書き換えてみます。
これは変数val2の値には何も影響しません。
変数val2の保存領域には変数val1の「実際の値」をそのままコピーしたものが保存されるからです。

今度は参照型の変数ref2に変数ref1を代入後、変数ref1の値を書き換えます。
すると変数ref2の値も書き変わってしまいます。

参照型変数ref1に入っているのは「実際の値」ではなくアドレスです。
代入操作で行われるのは「変数の入れ物に入っているデータのコピー」です。
つまり参照型変数の代入は「アドレスのコピー」であり、「実際の値」が保存されている「別の保存領域」にはノータッチです。
つまり、変数ref1もref2も中身のアドレスが同じになり、同じ「別の保存領域」を読み書きするように指定されることになります。

■値型と参照型のメモリ上のイメージ2
値型と参照型のメモリ上のイメージ2

読み書きされる場所が同じなのだから、一方の変数を通じて値を書き換えると、もう一方の変数が読み取る値にも影響がでる、ということです。
配列の丸ごとコピー?で説明したように、配列変数同士の代入でコピーができないのはこれが理由です。

C言語やC++でのポインタとほぼ同じイメージです。
C#の参照型はポインタよりも扱いやすい反面、自由度が低くなったものと言えます。

参照型のメリット

参照型がわざわざこんな面倒な処理をしているのには理由があります。
最大の特徴は、巨大な値を扱う場合のデータの受け渡しが高速にできることです。

値型の代入では常に「実際の値」のコピー処理が発生します。
int型で4バイト、decimal型でも16バイトなので大したサイズではありません。
(構造体は巨大なデータを作れますが、推奨されません)

しかし、例えば参照型であるstring型は長大な文字列が使用可能です。
(最大2GB分らしい)
流石に2GBもの文字列を扱うことはほぼありませんが、int型などに比べれば大きなデータを扱います。
この大きな「実際の値」を代入の度にコピーしていてはメモリをたくさん消費しますし、パフォーマンス的にもよくありません。

そこで、「実際の値」は別の場所に保存しておき、変数の「入れ物」にはデータの場所を示すアドレスを保存します。
代入操作時にはこのアドレスをコピーします。
アドレスはint型などと同じく小さなデータですので、コピーは高速ですしメモリ使用量も抑えられます。
(アドレスのサイズは32bitの場合で4バイト、64bitの場合で8バイト)
こうすることでパフォーマンス向上につながります。

代入だけでなく、メソッドに引数としてデータを渡した際にもコピー処理が行われます。
値型を引数に指定した場合は「実際の値」をコピーしたものがメソッドに渡されます。
参照型を引数にした場合はアドレスが渡されます。

参照型のデメリット

デメリットというほどのものではありませんが、参照型は「実際の値」へのアクセスにワンクッションあるので、値型の場合よりも読み書きが若干遅くなります。
また、「参照の参照」といった間接参照が発生する可能性があります。
(参照が示す先が「実際の値」ではなく、さらに別の参照になっている状態)
これも速度的にはデメリットです。
とはいえ体感できるような差が出ることはないので、そこまで気にする必要はありません。

また、「値型と参照型」という概念を知らないとバグのあるコードになってしまう危険性があるのもデメリットと言えるかもしれません。
C言語のポインタなどは変数の宣言時に「これはポインタ変数である」ということが明示されますが、C#の変数は値型なのか参照型なのかは見た目ではわかりません。
例えば構造体とクラスは機能的にも似ているため、今使っているものがどちらなのかを意識する必要があります。

object型と値型

「object型も参照型」と説明しましたが、データ型の項でも説明した通り、object型は何でも代入可能なデータ型です。
object型に値型(int型など)の値を代入した場合は値型のように振舞います


//「123」は値型
object val1 = 123;
object val2 = val1;

val1 = 456;

Console.WriteLine(val2);

//配列は参照型
object ref1 = new int[] { 123 };
object ref2 = ref1;

//ref1をint型配列にキャスト
//その上で添字演算子で0番目の要素にアクセス
((int[])ref1)[0] = 456;

Console.WriteLine(((int[])ref2)[0]);
123
456

同じ「object型」でも、代入するデータ型によって挙動が異なっていることが分かります。

これはボックス化という仕組みによるのですが、説明のためにはメモリの構造などの知識が必要になります。
内部的な動作を知らなくとも上記のルールさえ知っておけば大丈夫です。

object型に配列を代入した場合、各要素へのアクセスがややこしいので注意してください。

まず、object型変数を目的のデータ型の配列にキャストします。
(例ではint[]型)
次に、キャスト演算子も含めてその配列変数をすべて丸括弧で囲い、その後ろに添字演算子を書いて各要素にアクセスします。

丸括弧を書かずに「(int[])ref1[0]」とすると、「(int[])ref1」の処理よりも「ref1[0]」のほうが優先されてしまいます。
演算子には優先順位があって、キャスト演算子よりも添字演算子のほうが優先度が高いのです。
たとえ中身が配列であってもobject型変数に対してそのまま添字演算子は使用できませんので、エラーとなります。


object ref1 = new int[] { 123 };

((int[])ref1)[0] = 456;

//複数行に分けると↓

int[] ref1Int = (int[])ref1;
ref1Int[0] = 456;

//これはエラー
(int[])ref1[0] = 456;

(int[])    ref1    [0] = 456;

//↑は↓の意味になる

(int[])    (ref1    [0]) = 456;

//なので優先順位を変える↓

((int[])    ref1)    [0] = 456;

string型と参照型

string型も参照型ですが、string型変数をメソッドの引数に渡し、メソッド内で書き換えても呼び出し元に影響しません。


static void Main(string[] args)
{
    string str = "abc";

    FuncStr(str);

    Console.WriteLine(str);

    Console.ReadLine();
}

//引数を書き換えるメソッド
static void FuncStr(string s)
{
    s = "def";
}
abc

上記のコードは今までの説明とは反するような動作に思えるかもしれません。
しかしこれは正常な動作です。

このFuncStrメソッド内の処理は「"文字列abc"が保存されているメモリ領域に"文字列def"を上書きではない」からです。
正しくは「引数sに保存されているアドレスを、"文字列def"が保存されているメモリ領域のアドレスで上書き」という処理です。

■string型を引数に取るメソッドの動作イメージ
string型を引数に取るメソッドの動作イメージ

上記コードと同じことをint型配列で行うと動作がよくわかります。


static void Main(string[] args)
{
    int[] nums = new int[] { 123 };

    FuncArr1(nums);
    foreach(var n in nums)
        Console.WriteLine(n);

    FuncArr2(nums);
    foreach (var n in nums)
        Console.WriteLine(n);

    Console.ReadLine();
}

static void FuncArr1(int[] a)
{
    a[0] = 465;
}

static void FuncArr2(int[] a)
{
    a = new int[] { 789 };
}
456
456

配列の要素を書き換えると呼び出し元に影響しますが、配列自体を別の配列に置き換えると呼び出し元に影響しません。
文字列自体を別の文字列に置き換えても呼び出し元に影響しないのと同じです。

ちなみに文字列リテラルは書き換えることはできないので、以下のようなコードは書けません。


static void FuncStr(string s)
{
    //エラー
    //文字列リテラル自体の書き換えは不可
    s[0] = 'z';
}

string型をメソッド内で書き換えるには引数の参照渡し(ref、out)を使用します。

参照型とnull

参照型の変数にはnullが代入可能です。
これはメモリ上の何処も指していない状態を意味します。

nullが代入されている状態の変数はそのままでは使うことはできません。


int[] nums = null;

//エラー
int count = nums.Length;

上記のコードは、本来ならば配列numsの要素数が取得できますが、配列numsの中身はnullです。
このような場合、要素数0を返すとかそういった動作にはならず、エラー(例外)になります。
配列型やstring型は値がnullになることがあるので、必要に応じて使用する前にnullチェックを行います。

値型でもnull許容値型という特殊なデータ型を使用することでnullを代入可能ではあります。

参照型変数のキャスト

データ型の変換はキャスト演算子で行いますが、参照型変数の場合はas演算子を使用して変換することもできます。


//object型は参照型
object objArr = new int[] { 1, 2, 3 };

//配列型は参照型
int[] intArr;

//キャスト演算子
intArr = (int[])objArr;

//as演算子による変換
intArr = objArr as int[];

foreach (var n in intArr)
    Console.Write("{0}, ", n);
1, 2, 3,

どちらを使用しても同じ結果を得ることができます。
両者には以下のような違いがあります。

as演算子が使用できるのは参照型だけ

as演算子は値型のデータ型変換には使用できません。


//値型ではas演算子は使用できない
//以下はエラー
int intNum = 5;
short shortNum = intNum as short;

変換に失敗した時の挙動が異なる

as演算子は、目的のデータ型に変換できなかった時の挙動がキャスト演算子と異なります。

キャスト演算子の場合は例外が発生します。
例外の発生は、何も処理をしなければその場でプログラムが停止します。


object objArr = new int[] { 1, 2, 3 };
string str;

//ここでプログラム停止
str = (string)objArr;

例外発生時にプログラムを止めずに処理を続けるには例えば以下のようにします。
とりあえず変換失敗時はstring型変数にnullを代入する処理にしています。
(try~catchはまだ説明していないので参考程度にみてください)


object objArr = new int[] { 1, 2, 3 };
string str;

try
{
    str = (string)objArr;
}
catch(Exception e)
{
    str = null;
}

as演算子の場合は変換に失敗するとnullが返ります。


object objArr = new int[] { 1, 2, 3 };
string str;

//nullが代入される
str = objArr as string;

if(str == null)
{
    //変換失敗
}
else
{
    //変換成功
}

変換成功か失敗かはnullと比較すれば良いので、例外発生時のように特殊なコードは必要ありません。

as演算子はユーザー定義のデータ変換はできない

C#では自作クラスのデータ型を別のデータ型に変換する際の変換ルールをユーザーが定義できます。
キャスト演算子は変換ルールを自分で定義できますが、as演算子は定義できません。
クラスの型変換ルールについてはキャスト演算子のオーバーロードで改めて説明します。

is演算子

as演算子に似ているものにis演算子があります。
これは目的のデータ型への変換ができるか(互換性があるか)を判定する演算子です。


object objArr = new int[] { 1, 2, 3 };
string str;
int[] intArr;

//is演算子
if(objArr is string)
    str = (string)objArr;
else
    str = string.Empty;

//is演算子
if (objArr is int[])
    intArr = (int[])objArr;
else
    intArr = new int[] { 0 };

Console.WriteLine(str);
foreach (var n in intArr)
    Console.Write("{0}, ", n);

1, 2, 3,

is演算子は目的のデータ型に変換可能な場合は真(true)を返しますので、if文などを使用して処理を分けることができます。

is演算子で真が返ってきた場合は変換可能なのですから、キャストにはas演算子ではなく通常のキャスト演算子を使用します。
実はas演算子は内部的には「変換の可否判定 + 変換」の処理のセットとなっています。
つまり上記のコードでキャストにas演算子を使用すると「変換の可否判定」が二回行われることになり、無駄な処理となるためです。

参照型のコピー

参照型変数は上記の通り、そのまま別の変数に代入しただけでは参照先のコピーとなります。
そのままでは不都合な場合は参照先のデータも含めてコピーする必要があります。

参照型のコピーについてはシャローコピーとディープコピーで改めて説明します。

null条件演算子

参照型に対してはnull条件演算子という演算子が使用できます。
詳しくはnull条件演算子で改めて説明します。