アクセサを使った変数の綺麗なやりとり 〜UnityによるHCI研究 その3〜

この記事では,オブジェクト指向におけるクラスのメンバ変数の扱いについて触れた後,メンバ変数をうまく外部とやりとりするための関数であるアクセサを紹介します.その後,C#でのアクセサについて見ていきます.

オブジェクト指向自体に関する情報については,ネットである程度集めることができると思うので,ここでは深く解説しません.参考程度にIT用語辞典のページを貼っておきます.

オブジェクト指向における変数

さて,オブジェクト指向においては,基本的に変数はクラスの内部状態を表すものとしてクラスに格納されます(「メンバ変数」).メンバ変数はクラスの内部状態を表すので,外部から気軽に書き換わると大変です.例えば,学生クラスを以下のように定義したとします.

// 学生
public class Student : MonoBehaviour
{
    // 名前(フルネーム)
    public string Name;
    // 年齢
    public int Age;
}

このとき,手癖でアクセス修飾子をpublicにする方は注意が必要です.例えば,以下のようなことが起こります.

// ボス
public class Professor : MonoBehaviour
{
    // 見ている学生
    public Student MyStudent;
    // よりシンプルな名称
    public string SimpleName = "千";

    void Awake()
    {
        Debug.Log(MyStudent.Name); // 荻野千尋

        // 贅沢な名だね
        MyStudent.Name = SimpleName;

        Debug.Log(MyStudent.Name); // 千
    }
}

このように,外部から変更してほしくないメンバ変数も,誰かの手によって変更を受ける可能性があります.今回は名前Nameでしたが,年齢Ageも同様です.したがって,特別な理由がない限り,メンバ変数はprivateで宣言すべきです.
Unityに限って言えば,publicなメンバ変数はインスペクタで全て公開されてしまうので,みだりにpublicで宣言しすぎると,インスペクタが煩雑になるという問題もあります.

// 学生(メンバ変数が秘匿されている)
public class Student : MonoBehaviour
{
    // 名前(フルネーム)
    private string m_name;
    // 年齢
    private int m_age;
}

ただし,このままではprivateにしたメンバ変数に誰もアクセスすることができません.というわけで,メンバ変数にアクセスするためのメンバ関数を実装する必要があります.このメンバ関数のことをアクセサと呼びます.

アクセサによるメンバ変数のやりとり

アクセサは大きく分けて2種類があり,メンバ変数に数値を代入するSetterとメンバ変数の値を取り出すGetterがあります.言語やチームの取り決めによって命名規則が定まることがあり,その場合は元の変数の頭にSetまたはGetを付けるのが一般的です.
まずは例として,何も考えずにStudentクラスのメンバ変数にそれぞれアクセサを追加してみます.

// 学生(アクセサ付き)
public class Student : MonoBehaviour
{
    // 名前(フルネーム)
    private string m_name;
    // 名前のSetter
    public void SetName(string name)
    {
        m_name = name;
    }
    // 名前のGetter
    public string GetName()
    {
        return m_name;
    }

    // 年齢
    private int m_age;
    // 年齢のSetter
    public void SetAge(int age)
    {
        m_age = age;
    }
    // 年齢のGetter
    public int GetAge()
    {
        return m_age;
    }
}

上記は,最も単純なSetterとGetterを表したものです.読者の中には,ただ記述が長くなっただけじゃないかと思う方もいるかもしれませんが,アクセサを使うことで処理の流れが明確になります.例えば,以下のような工夫が考えられます.

Setter

Studentクラスのメンバ変数m_nameとm_ageは,いずれも外部からの変更を受けたくありません.しかし,何かの間違いで初期値を修正しなくてはいけない可能性も考えられます.このような場合,Setterは最初から書かないでおくか,以下のようにSetterが呼び出されたことを警告する機能があると良いでしょう.

public void SetName(string name)
{
    Debug.LogWarning($"Student.SetName: name has been changed! {m_name} -> {name}.");
    m_name = name;
}
public void SetAge(int age)
{
    Debug.LogWarning($"Student.SetAge: age has been changed! {m_age} -> {age}.");
    m_age = age;
}

また,名前はさておき,年齢は0以上の整数である必要があるはずです.以下の例では,負の数が代入されようとする場合に警告して処理を中断します.

public void SetAge(int age)
{
    // 負の数が代入されようとする場合は中断する
    if (age < 0)
    {
        Debug.LogError($"Student.SetAge: age must be natural number! age = {age}.");
        return;
    }

    Debug.LogWarning($"Student.SetAge: age has been changed! {m_age} -> {age}.");
    m_age = age;
}

このように,定義域が決まっている数値の場合は,このように代入される値に修正を加えるのが定石です.定義域外の数値が代入されようとしている場合は,年齢の例のように処理を中断したり,上限・下限の値を代入したりすると良いでしょう.角度のように周期性のある数値の場合は,周期の値を足し引きして定義域内に収めると良いでしょう.

Getter

Getterは,値を参照する時に使うアクセサですが,値を取り出す際に毎回返り値が変わる場合に特に効果があります.ここでは,現在の日付を文字列で取得する例をご紹介します.

// 日付時刻を返す
public string GetNowTime()
{
	var time = DateTime.Now;
	return $"{time.Year}_{time.Month}_{time.Date}_{time.Hour}_{time.Minute}_{time.Second}";
}

あるメンバ変数が複数の意味を持ちうる時にもGetterを使うと良いことが多いです.ここでは,年齢を示すm_ageというメンバ変数を使って,成人しているかどうかを判断するGetter,IsAdault()を定義してみます.なお,Bool型のGetterはGetの代わりにIsやHasを使うこともあります.

// 成人しているかどうか
// 整数型m_ageが変化した場合,IsAdault()の返り値も自動で変化する
public bool IsAdault()
{
    return 18 <= m_age;
}

このように,メンバ変数を秘匿し,専用のアクセサによってのみアクセスするようにすることを「カプセル化」と呼びます.カプセル化されていると,メンバ変数が代入・参照された場合に別の処理を呼び出したり,処理を中断したりできます.

C#におけるアクセサ(プロパティ)

ここまでで,メンバ変数はカプセル化され,アクセサによって外部とやりとりすることの重要性を説明しました.ただし,このままではメンバ変数ごとにアクセサ(Setter,Getter)を実装する必要があり,あまり綺麗でも効率的でもないと言われても仕方ありません.C#では,内部的には関数として,外部的には変数として振る舞うプロパティという仕組みによって,この問題が解消されています.

// 学生(プロパティ付き)
public class Student : MonoBehaviour
{
    // 名前(フルネーム)
    private string m_name;
    // 名前のプロパティ
    public string Name
    {
        set
        {
            m_name = value;
        }
        get
        {
            return m_name;
        }
    }

    // 年齢
    private int m_age;
    // 年齢のプロパティ
    public int Age
    {
        set
        {
            m_age = value;
        }
        get
        {
            return m_age;
        }
    }
}

Setterの引数がvalueという予約語によって表現される点に注意します.
先述したSetterとGetterの工夫も適用すると,こんな風になります.

// 学生(プロパティ付き)
public class Student : MonoBehaviour
{
    // 名前(フルネーム)
    private string m_name;
    // 名前のプロパティ
    public string Name
    {
        set
        {
            Debug.LogWarning($"Student.set_Name: name has been changed! {m_name} -> {value}.");
            m_name = value;
        }
        get
        {
            return m_name;
        }
    }

    // 年齢
    private int m_age;
    // 年齢のプロパティ
    public int Age
    {
        set
        {
            // 負の数が代入されようとする場合は中断する
            if (value < 0)
            {
                Debug.LogError($"Student.set_Age: value must be natural number! value = {value}.");
                return;
            }
        
            Debug.LogWarning($"Student.set_Age: age has been changed! {m_age} -> {value}.");
            m_age = value;
        }
        get
        {
            return m_age;
        }
    }
    // 成人しているかどうか
    public bool IsAdault
    {
        get
        {
            return 18 <= m_age;
        }
    }
    // ちなみに,1行で処理が完結するGetterはこういう書き方もできる
    // public bool IsAdault => (18 <= m_age);
}

プロパティを外部から呼び出す場合は,変数を使うようなニュアンスで書けます.「内部的には関数,外部的には変数」とはこういうことです.

// ボス
public class Professor : MonoBehaviour
{
    // 見ている学生
    public Student MyStudent;
    // よりシンプルな名称
    public string SimpleName = "千";

    void Awake()
    {
        Debug.Log(MyStudent.Name); // 荻野千尋
        Debug.Log(MyStudent.Age); // 10
    
        // 贅沢な名だね
        MyStudent.Name = "千"; // Student.set_Name: name has been changed! 荻野千尋 -> 千.
        MyStudent.Age = -9; // Student.set_Age: age must be natural number! value = -9.

        // 成人か判定する
        Debug.Log($"Professor.Awake: {MyStudent.Name} is adault? {MyStudent.IsAdault}.");
        // Professor.Awake: 千 is adault? False.
    }
}

Professorクラスの書き方は冒頭とほぼ変わりませんが,MyStudentのメンバ変数(のように見えるプロパティ)を書き換えようとすると,警告を出してくれたり,処理を中断してくれたりするようになりました.

自動実装プロパティ

プロパティは,カプセル化する変数を省略することもできます.

public string Name
{
    get; set;
}

こうすると本末転倒のように思えますが,こういった書き方は,代入と参照でアクセス修飾子を分けたい変数を定義するときによく使います.例えば,以下のような書き方をします.このように書くと,プロパティNameはクラス内でのみ代入(書き換え)可能で,外部からは参照のみできます.

public string Name
{
    get; private set;
}

サンプルコード

Unityを用いたHCI研究でよくある処理を,プロパティで実装してみます.

アバターの切り替え

VRアバターの外見自体が実験条件になっている場合や,実験参加者の性別に応じてアバターの性別を切り替えなければならない場合はよくあります.今回は,前回のGUIも併用してアバターの切り替えを実装してみます.

using System;
using System.Collections.Generic;
using UnityEngine;

// アバターの外見を管理するクラス
public class AvatarVisualManager : MonoBehaviour
{
    // 切り替えるアバターのリスト
    [SerializeField]
    private List<GameObject> m_avatars;
    // 現在のアバター
    private GameObject m_currentAvatar;
    // 現在のアバターのプロパティ
    public GameObject CurrentAvatar
    {
        get
        {
            // もしnullなら,m_avatar[0]を代入して返す
            if (m_currentAvatar is null)
            {
                m_currentAvatar = m_avatars[0];
            }
            return m_currentAvatar;
        }
        set
        {
            // m_avatarsにvalueが含まれていない場合は,中断する
            if (!m_avatars.Contains(value))
            {
                Debug.LogError($"AvatarVisualManager.set_CurrentAvatar: m_avatars does not contain the GameObject {value.name}!");
                return;
            }

            // m_avatarsのうち,valueだけを有効化する
            for (int i=0; i<m_avatars.Count; i++)
            {
                m_avatars[i].SetActive(m_avatars[i] == value);
            }

            // m_currentAvatarを更新する
            m_currentAvatar = value;
        }
    }
    // 現在のアバターの順番
    // 内部に変数があるわけではなく,m_avatarsとCurrentAvatarを用いて処理が記述されている点に注意
    // GameObjectはやや扱いづらいため,エイリアスのような形で整数型でもアバターを表現できるようにしておいた
    public int CurrentAvatarIndex
    {
        get
        {
            return m_avatars.IndexOf(CurrentAvatar);
        }
        set
        {
            // もしvalueがm_avatarsの範囲を超えるとIndexOutOfRangeExceptionが起こるので,予め対策する
            // valueが最大値を超えたら最小値にし,最小値を下回ったら最大値にする
            value = (m_avatars.Count <= value) ? 0 : value;
            value = (value < 0) ? m_avatars.Count - 1 : value;

            CurrentAvatar = m_avatars[value];
        }
    }

    void Update()
    {
        // 「<」キーまたは「>」キーの押下でアバターを変更する
        // CurrentAvatarIndexを実装したため,シンプルな記述で処理を表現できる
        if (Input.GetKeyDown(KeyCode.LeftArrow))
        {
            CurrentAvatarIndex--;
        }
        if (Input.GetKeyDown(KeyCode.RightArrow))
        {
            CurrentAvatarIndex++;
        }
    }

    #region GUI(ここから下は書かなくても良いが,その2を読んだ方はぜひ参考にして欲しい)
    // GUI固有のID
    private int m_windowId = 0;
    // GUIのウィンドウの位置・大きさ
    private Rect m_windowRect = new Rect(0, 0, 200, 100);
    void OnGUI()
    {
        m_windowRect = GUI.Window(m_windowId, m_windowRect, (id) =>
        {
            // 現在のアバターの名称を表示する
            GUILayout.Label($"Current Avatar: {CurrentAvatar.name}");

            // ボタンの押下でアバターを変更する
            GUILayout.BeginHorizontal();
            if (GUILayout.Button("<"))
            {
                CurrentAvatarIndex--;
            }
            if (GUILayout.Button(">"))
            {
                CurrentAvatarIndex++;
            }
            GUILayout.EndHorizontal();
        }, name);
    }
    #endregion
}

今回は,Unity-chanのアバターを使用して実装してみました.m_avatarsの中に,Unity-chanとBoxUnity-chanのゲームオブジェクトをアタッチしました.左右の矢印キーを押すと,下のようにアバターの外見を切り替えることができます.

Unity-chan
BoxUnity-chan(Unity-chanアセットに同梱)

このサンプルコードは別にアバターの外見に関わらず,あらゆるGameObjectに応用できることに注意してください.

注意点

アクセサはこのように便利な一方で,いくつか気をつけなければいけないポイントがあります.

コードの複雑化

元のメンバ変数に処理を付け足しているので,使いすぎるとコードが複雑化します.実装を続けているとアクセサの中に色々な機能を増やしがちですが,それがメンバ変数にアクセスする際に常に必要な機能なのか,逐一考える必要があります.特に,研究用のコードは設計やレビューをほとんどしないので,コードが煩雑になりがちです.

ミスによる無限ループのリスク

アクセサを実装する際,カプセル化する変数名とアクセサの名称は元来ほぼ同じで,統合開発環境の自動補完機能を使っているとアクセサの中にアクセサを書いてしまうことがあります(筆者がそうです).このままPlayモードに入ってしまうと,アクセサの呼び出しの瞬間にUnityが無限ループを引き起こし,クラッシュします.特に警告無く落ちるので,原因の究明がしづらく,解決に時間がかかる場合が多いです.小一時間格闘した後にタイポが見つかると,さらに小一時間落ち込むことになるので,気をつけましょう.

この記事が気に入ったらサポートをしてみませんか?