アクセス修飾子を意識するために

この記事は、FUN Advent Calendar 2023 Part2の3日目の記事です。


はじめに

前回はPart1ではみ🦊さんがVRチャットのお話をされていました。Part2ではyukidqma(8)さんがポーカーで優勝した話をされていました。

そして今回ゲームの話を期待していた方には申し訳ないですが、プログラミングの話です。

アクセス修飾子の使い方とその意義を思い出すためにこの記事を書きました。僕は最初のうちはそんなに意識していませんでしたが、意識したほうが様々な点で良いコードになるので、まだアクセス修飾子を気にして使っていない人に読んでほしいです。

今回初めて記事を書くので拙い文章かもしれませんが、許してください。

使用言語

今回はC#を使います。

対象読者

アクセス修飾子というキーワードをあまり聞いたことがない人
カプセル化というキーワードをあまり聞いたことがない人
プロパティ、ゲッター、セッターというキーワードをあまり聞いたことがない人
よくわからずアクセス修飾子を付けている人

自己紹介

未来大の学部2年生です。普段はUnityでゲームを制作しています。一緒にゲームを作ってくれる人を探しています。
Twitter(X):https://twitter.com/shirurona

アクセス修飾子とは

概要

クラスやメンバーのアクセスに制限をかけるもの。

アクセス修飾子の種類

今回はinternal、protected internal、private protectedについては取り上げません。

基本は以下の3つです。

  • public : すべてに公開

  • protected : 同じクラスとその派生クラスからアクセスできる

  • private : 同じクラス内のコードからのみアクセスできる

アクセス修飾子を使うメリット

なぜわざわざ使える範囲を狭めて使いづらくする必要があるのか

必要なものだけを公開し、それ以外のものを隠すことを情報隠蔽といいます。情報隠蔽を徹底すると、そのクラスのことをすべて知らなくても使えるようになる。という利点があります。

クラスやメンバ変数、メソッドを他人や未来の自分が使用するときに、使ってもいいものとだめなものを明示することができる。そうすると、未来の自分が、過去に作ったクラスやメンバ変数、メソッドを使うときに、使ってはいけないものを使わずに済みます。

チームで開発していたとき、自分が知らないうちに変数の状態が変更されてしまっていたという事態を未然に防ぐことができます。そして、そのミスの影響範囲を狭めることができます。

また、元の値ではなく、加工された値を使ってほしいときなどに使えます。裏を返せば、元の値を直接使ってほしくないときです。

まとめると、アクセス修飾子は変数やメンバの使用範囲を狭めて使いづらくしているのではなく、使わなくていい変数やメンバを隠して、気にしなくてもいいようにしてくれています。

アクセス修飾子はオブジェクト指向言語における、「カプセル化」を達成するために必須です。

アクセス修飾子を気にしなくても動けば良い?

アクセス修飾子は未来の自分や他人が見たときにどこに何があって何を使ったらいいのか、どう使ったらいいのかを分かりやすくするために気にする必要があるので、自分が書いたコードをすべて完璧に記憶して、自分しか見ることがないと言いきれるか、そのソフトウェアが一切修正されず、見返されることがなければ、アクセス修飾子を気にする必要はないと思います。

アクセス修飾子を気にしない人はどのような考えでコードを書いているのか、過去の自分の思っていたことから推測すると下のようになります。

  1. そもそもコード量が少ない

  2. クラスの分割のし方が分からない

  3. 何をクラスにしたらいいのかわからない

  4. 全て同じクラスで書いているから気にする必要がない

  5. publicにした方が他のクラスからも使えて良い

  6. 関数で処理を分けてるし、スコープを気にしているから問題ない

  7. 自分が分かればそれでいい

だいたいこんな感じだと思います(異論は認めます)。

1.コード量が少ないことによるもの
1がこれにあてはまります。
今後もコード量が少なくて済むことが確定していればまだアクセス修飾子を気にする必要がないと思うかもしれません。しかし、開発中にもっと製品をよくするためのアイデアが浮かぶことはよくあることです。このとき、コードの量は増えます。コード量が増えても大丈夫なように、今のうちからアクセス修飾子を気にする必要があります。

2.クラスの分け方が分からない
2と3がこれにあてはまります。
これは自分で試して正しく使いこなすことは難しい問題です。なので、クラスの分け方の指標を知る必要があります。これについては後で説明します。

3.クラスが適切に分けられていない
4と5と6がこれにあてはまります。
そのように考えていると、コード量が増えるにつれてコード全体の見通しが悪くなっていきます。どんどん気にする変数や関数が多くなってきて、忘れた頃に使ってバグが起きることがあります。クラスを有効に使えていないことに気付いていないパターンです。

4.自分が分かればそれでいい
今日や明日のあなたはわかっていても、課題や試験が忙しくて開発をおやすみにしていたあなたがそのコードを見てわかる保証はありません。そして、あなたが他の人と協力してコードを書くことになったときに困ります。

まとめると、クラスが適切に分けられていないか、他人に見られることがないと思っているからうまく使えていないと思います。

クラスの分割はアクセス修飾子を使ううえでとても重要です。もし分け方が悪いと、他のクラスで必要になって必要以上に他のクラスから依存させてしまい、関心事が増えてしまいます。分けなさすぎると一つのクラスで使われる変数が多くなってしまい、結局全部の役割を把握するのに苦労します。

そこで、クラスを分けるときは関連する要素をまとめることでカプセル化します。クラスは単なるメンバのまとまりではなく、責任を持たせてカプセル化しなければなりません。この考え方はオブジェクト指向の原則の一つであるSOLID原則の単一責任の法則にあります。そのためにはクラスに良い名前をつけると良いのですが、記事が長くなりそうなので調べてみてください。

アクセス修飾子を使うのは、このコードを読む人にそのメンバの意図を伝えるためだと思います。例えば、誰かにコメントを残すのと似ています。気を付けて使ってはいけないものは使わなければいいですが、気を付けるものが多くなると大変ですし、いちいちコメントして覚えてもらうより実際に使えなかったほうが分かりやすいです。だからといってコメントは必要ないということではありませんが。

メンバにはいくつかの情報があります。まずは名前で大体その変数が何者なのかを知ることができます。それから、キーワード、属性によってどんな機能を付けられているのかを知ることができます。定数ならconstで、どのように使ったらいいのか分かります。アクセス修飾子もその一つで、その変数にアクセスできる範囲が分かることで、クラスの中での役割が分かり、どのように使ったらいいのかが分かります。

アクセス修飾子を使ってみる


今回は何かサービスのアカウント操作を例に説明していきます。
今回は通常のアカウントクラスとそれを継承して作った管理者アカウントクラスを用意します。

アカウントはアカウント名とユーザーID、パスワードを持っています。通常アカウントでは自身のアカウント名とパスワードの変更をすることはできませんが、管理者アカウントでは自身のアカウント名とパスワードを自由に変更することができることとします。(通常アカウントでパスワード変更できないのはまずくない?という疑問は見逃してください)

まず、アカウントクラスを考えます。アカウント情報の漏洩を防ぐために、最初はメンバーのアクセス修飾子をprivateにして考えます。

コンストラクタはインスタンス生成時に外のクラスから呼べるようにしたいので基本的にpublicにしておきます。

また、ユーザーIDはRandomUserId()関数で生成したものをセットします。(ユニークじゃないのは見逃してください)

ToString()関数はアカウント名とユーザーIDを返す関数です。

class Account
    {
        private string accountName;
        private string password;
        private int userId;

        public Account(string accountName, string password)
        {
            this.accountName = accountName;
            this.password = password;
            userId = RandomUserId();
        }

        private int RandomUserId()
        {
            Random random = new Random();
            return random.Next(100);
        }

		private override string ToString()
        {
           	return $"{AccountName}({userId})";
        }
    }

しかしこのままでは、継承して作る管理者アカウントでアカウント名とパスワードを変更できるようになっていません。

class AdminAccount : Account
    {
        public AdminAccount(string accountName, string password) : base(accountName, password)
        {
        }

        public void ChangeAccountName(string newAccountName)
        {
            // 「Account.accountNameはアクセスできない保護レベルになっています」というエラーが出てくれる
            accountName = newAccountName;
        }
        public void ChangePassword(string newPassword)
        {
            // 「Account.passwordはアクセスできない保護レベルになっています」というエラーが出てくれる
            password = newPassword;
        }
    }

実際にクラスを作ると基底クラスのメンバーにアクセスできないエラーが出てくれます。アカウント名とパスワードは派生クラスで書きかえるためにprotected修飾子で公開します。

ここで基底クラスのアカウント名とパスワードをpublicにしてしまうと、Accountクラスのメンバがインスタンス経由でどこからでも変更できるようになってしまいます。すると「通常アカウントでは自身のアカウント名とパスワードの変更をすることはできない」という仕様を満たさなくなってしまいます。

しかしこのとき、このままprotectedにするのではなく、値の取得と変更に分けて公開する範囲を考える必要があります。ここでプロパティ、ゲッター、セッターというものが出てきます。通常のメンバ変数では値の取得と変更のアクセス制限は同時に変わってしまいます。

class Foo{
    public int a;
    public int B { get; set; }
    public Foo() { }
}

class Program
{
    static void Main(string[] args)
    {
        Foo foo = new Foo();
        foo.a = 1;
        Console.WriteLine(foo.a);//1と表示
        foo.B = 2;
        Console.WriteLine(foo.B);//2と表示
    }
}

aはメンバ変数でBはプロパティですが、どちらも同じ挙動をします。Javaでは関数としてゲッターの関数とセッターの関数を作り、それにアクセスすることで値の取得と値の変更をするようにしています。

なぜわざわざ取得と変更で別々のアクセス制限をかけるのか

取得と変更は別の動作だからです。値を取得するときは派生クラスで加工した値だけを取り出してほしかったり、値を外部から勝手に変更されたくなかったりすることは、
protected { set; private get; }(ただし、派生クラスに加工した値を取得する関数がpublicで公開されている)
で表現することで解決します。ローカル変数が取得も変更も自由にできるのは、他のクラスからアクセスされることがないからです。そのかわりにスコープの範囲内でしか使えないので、カプセル化が守られることになります。つまり、値の変更の影響が限定されているということです。なので、コードを読む範囲がスコープ内に限られて読みやすさが保たれます。

話をもどして、プロパティを使用してコードを書きなおしました。

class Account
    {
        public string AccountName { get; protected set; }
        public string Password { get; protected set; }
        private int userId;

        public Account(string accountName, string password)
        {
            AccountName = accountName;
            Password = password;
            userId = RandomUserId();
        }

        private int RandomUserId()
        {
            Random random = new Random();
            return random.Next(100);
        }

		public override string ToString()
        {
           	return $"{AccountName}({userId})";
        }
    }

自身のアカウント名とパスワードを取得は自由にできますが、変更は派生クラスからでしかできないようにsetをprotectedにしました。これで派生クラスからは変更できますが、そとからは変更できず、取得はできるということにしました。

すると、管理者アカウントクラスではエラーがなくなるので、外からアクセスできるように関数をpublicにしました。

また、ToString()関数はアカウント情報を表示する際に外から使ってほしいのでpublicにしました。

class AdminAccount : Account
    {
        public AdminAccount(string accountName, string password) : base(accountName, password)
        {
        }

        public void ChangeAccountName(string newAccountName)
        {
            AccountName = newAccountName;
        }
        public void ChangePassword(string newPassword)
        {
            Password = newPassword;
        }
    }

これでアカウントと管理者アカウントが完成しました。せっかくアカウントを作ったので、このアカウントでログインできる認証機能を追加します。

class Account
    {
        private static List<Account> allAccounts = new List<Account>();
        private static List<Account> loginAccounts = new List<Account>();
        public string AccountName { get; protected set; }
        public string Password { get; protected set; }
        private int userId;

        public Account(string accountName, string password)
        {
            AccountName = accountName;
            Password = password;
            userId = RandomUserId();
        }

        private int RandomUserId()
        {
            Random random = new Random();
            return random.Next(100);
        }

        public override string ToString()
        {
            return $"{AccountName}({userId})";
        }

        public bool Signin()
        {
            if (allAccounts.Contains(this))
            {
                Console.WriteLine("Account Already Exists!");
                return false;
            }

            allAccounts.Add(this);
            Console.WriteLine("Signin Success!");
            return true;
        }

        public static bool Login(Account account)
        {
            Account? allFind = allAccounts.Find(x => x.AccountName == account.AccountName && x.Password == account.Password);
            if (allFind == null)
            {
                Console.WriteLine("Account Doesn't Exists!");
                return false;
            }
            Account? loginFind = loginAccounts.Find(x => x.AccountName == account.AccountName && x.Password == account.Password);
            if (loginFind != null)
            {
                Console.WriteLine("Account Already Login!");
                return false;
            }

            loginAccounts.Add(account);
            Console.WriteLine("Login Success!");
            return true;
        }

        public static bool Logout(Account account)
        {
            Account? removeAccount = loginAccounts.Find(x => x.AccountName == account.AccountName && x.Password == account.Password);
            if (removeAccount == null) {
                Console.WriteLine("Account Doesn't Find!");
                return false;
            }
            loginAccounts.Remove(removeAccount);
            Console.WriteLine("Logout Success!");
            return true;
        }

        public static void ShowLoginAccounts()
        {
            foreach (Account account in loginAccounts)
            {
                Console.WriteLine(account);
            }
        }
    }

allAccountsは登録されたアカウントを保持するリストです。
LoginAccountsはログイン中のアカウントを保持するリストです。

Main関数を書いて、実際にプログラムを動かしてみます。

class Program
{
    static void Main(string[] args)
    {
        Account account = new Account("shirurona", "12345");
        AdminAccount admin = new AdminAccount("admin", "admin2023");
        account.Signin();// Signin Success!
        admin.Signin();// Signin Success!
        Account.Login(account);// Login Success!
        admin.ChangeAccountName("hogeadmin");
        Account.ShowLoginAccounts();
        // shirurona(59)

        Account.Login(new Account("hogeadmin", "admin2023"));// Login Success!
        Account.ShowLoginAccounts();
        // shirurona(59)
        // hogeadmin(45)
        Account.Logout(account);// Logout Success!
        Account.Logout(admin);// Logout Success!
        Account.ShowLoginAccounts();
        //
    }
}

こんな感じで、アカウントの管理がたぶんできたと思います。既存のアカウントにログインするとき、別のインスタンスを作るとContainsを使えないので結局アカウント名とパスワードが同じものを探しに行っていて、少しコードが長くなってしまったのでもう少し工夫したいですが。

おわりに

アクセス修飾子はpublicなど、結局つけることになります。アクセス修飾子を意識しても作業量は増えません。むしろ気にする変数が減って開発が楽になります。なので使いこなせて損はありません。

今回、僕の経験や主観からアクセス修飾子について書きましたが、足りない部分がたくさんあります。記事のうちに出てきたキーワード「カプセル化」、「単一責任の原則」そして「オブジェクト指向」について自分から本やインターネットで調べてみることが大切だと思います。

最後まで読んでいただき、ありがとうございました。次回はPart1はバッシュくんのFUN-BS、Nコン決勝に進みましたレポです。Part2はTADA Teruki (多田 瑛貴)くんの函館市電LTの話をベースに、技術イベントに関する話です。

あとがき

アカウント管理者っていう名前なのにできることが自分のアカウント名とパスワードを変えるだけという例をもう少し改善したかったです。
あとは、他のクラスを気にしなくてもよくなるメリットがより感じられる例にできたら良かったかもしれません。
次回はもっと良い記事を書けるように頑張りたいと思います。

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