🕌

就活のときに質問される技術を深ぼってみるVol1(値型、参照型)

に公開

この記事は GCC Advent Calendar 2024 の5日目 の記事です!(遅刻)
初めましての人は初めまして、そうでない方はお久しぶりです。
今回は新卒でゲーム業界をメインに就活をしていた時のことを思い出し、1次面接で聞かれる基礎的な技術を深堀りしていこうと思います。
Vol1となる今回は、値型と参照型についてです。

はじめに

サンプルコードはC#で記述します。
あくまで個人の意見なので参考程度に留めておいてください。
対象の読者はゲーム業界の就職を視野に入れている学生です。

就職用の結論

面接では、以下の要素が説明できていれば問題ないと思います。
・値型は変数にデータが入る
・参照型は変数にアドレスが入る
・値型は引数で渡したときにコピーされる
・参照型は引数で渡したときにアドレスを渡す
今回は、上記の説明より少し詳しくなれるように説明していきます。

値型と参照型

値型と参照型の違いを初級、中級、上級に分けて解説していきます。

初級 データの扱い方の違い

まず一番わかりやすい違いとして、データの扱いに差があります。
以下のコードの場合、変数aの中と変数bの中の5は別のデータとして扱っている(コピー)ため、aを書き換えてもbは更新されない、変数cの中と変数dの中の値は同じアドレスの値を共有しているためどちらかを書き換えるともう片方も書き変わります。

// 値型
int a = 5;
int b = a;
a = 10; // <- bの中身は変わらない

// 参照型
char[] c = "あいうえお";
char[] d = c;
c = "かきくけこ"; // <- dも書き換わる

なぜこのような挙動になるなるのか

プログラム上では変数にデータを入れたときにメモリが確保されます。また、確保されたメモリにはアドレスが割り振られます。しかし、値型の変数にはデータをそのまま入れるため、C#ではそもそもアドレスの情報をもっておらずコピーを作ることしかできません。[1]
一方で参照型は、データを格納するメモリを確保し、その後データを格納しているアドレスを格納するメモリを確保し(int相当)、変数に入れます。なので、参照型の場合はアドレスがコピーされることにより内部の値が共有される仕組みになっています。[2]

中級 生存期間の違い

二つ目は生存期間の違いです。ここでは初級で曖昧にしたスタックとヒープに関して解説します。
以下のコードでは、メモリの確保のパターンを列挙しています。

Hoge hoge = new();
hoge.Fuga(): // <- メソッド内の変数はここのメソッドが終われば開放
~~~~~~~~~~~
hoge = null;// <-ここまでaとbは確保される

public class Hoge
{
    int a;
    char[] b;

    public void Fuga()
    {
        int c;
        char[] d;
        a = 5;
        b = "あいうえお";
        c = 5;
        d = "あいうえお";
        Piyo(c, d);
    }

    // cはコピーされる(別のデータ)、dは同じアドレスを指す(同じデータ)
    public void Piyo(int c, string d)
    {
        // Do Something
    }
}

確保のパターンを表にするとこうなります。

値型 参照型
クラス内 ヒープ ヒープ[3]
メソッド内 データを確保したスタック内 スタック内

生存期間は(値型×メソッド)<(参照型×メソッド)<(値型×クラス)≦(参照型×クラス)

基本的には値型、参照型に限らずメソッド内でのみ確保する変数は生存期間が短いです。
参照型のオブジェクトは、メモリを確保するときにデータはヒープ領域という場所に格納されます。ヒープ領域は、明示的に開放するかC#の場合は参照がなくなった場合にメモリから消去されます。なので、クラス内に変数を実装する=ヒープ領域にデータが乗るということなので生存期間が長くなる傾向にあります。

上級 コンパイル時の型サイズ

個人的にこれが一番大事だと思っています。
まず前提として、プログラムの仕様上メソッドを実行する場合に確保されるスタックのサイズは固定である必要があります。
なので理論上、型の定義はすべて値型(固定サイズ)で実装しできるだけスタックに詰め込み、値の参照渡しで引き回せば高速になります。(stackallocやUnityのDOTSはこっちの思想)[4]
しかし、ランダムな長さの配列(文字列)や具象クラスを入れる抽象クラスの型などのコンパイル時に型のサイズが決まらないものを実装しないといけない場合があります。
そこで、参照型で実装を行いスタックに乗せるサイズをアドレスの長さで固定することができるというのが参照型の本当の特徴です。以下サンプルコード

// エントリポイント
public void Main()
{
    // 値型(固定サイズ)なのでスタックに確保
    var data = new MainData();
    Main2(ref data);
}

public void Main2(ref MainData data)
{
    // float[]はコンパイル時にサイズが確定していないが、
    // 参照型のおかげでスタックからアドレスを介してアクセスできる
    data.array = new float[Random.Range(0,100)];
    data.array[0] = 1.0f;
    // Parentはコンパイル時にChildかParentのどちらが入るかわからない(確保サイズ不明)
    Parent instance = array.Length > 50 ? new Child() : new Parent();

    // Do Something
}

public struct MainData
{
    // 大量のintなどのデータ
    int a;
    int b;
    int c;

    float[] array;
    Parent instance;
}

// value1のみ持っている
public class Parent
{
    public int value1;
}

// value1とvalue2を持っているためParentとメモリの確保サイズが違う
public class Child : Parent
{
    public int value2;
}

まとめ

就活する分には詳しく知らなくても正直なんとかなる

脚注
  1. 実際はrefやunsafeの利用でアドレスを取得すること自体は可能です。 ↩︎

  2. 参照型は参照の値渡しを行っているため、refと同一の挙動(値の参照渡し)ではありません。 ↩︎

  3. 新たに別のアドレスでデータが確保されるため、データはメモリに連続的に並んでいません。 ↩︎

  4. 実際はWindowsだとスタックメモリは1MBが標準なのでそこまで詰めれません。 ↩︎

Discussion