0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C# 12】プロパティの扱い方を徹底整理

Posted at

はじめに

C#のプロパティについて、これまで何となく使いつつも、もっと色々な使い方ができるような雰囲気は感じていたため、一度整理してみました。

前提

はじめに、用語の定義を明確にします。

Person.cs
public class Person
{
    private string _name; // ←フィールド

    public void Walk() { } // ←メソッド
}

本記事では、オブジェクトが持つ状態のことを「フィールド」と表現します。
属性やプロパティ、メンバ変数などと呼ばれるあれです。

また、オブジェクトが持つ振る舞いのことを「メソッド」と表現します。
関数などと呼ばれるあれです。

本記事で扱うプロパティは?

Person.cs
public class Person
{
    private string _name;
    
    public string Name // ←プロパティ
    {
        get { return _name; }
        set { _name = value; }
    }

    public void Walk() { }
}

フィールドともメソッドとも似た、変数名の直後にブロック(またはラムダ式)を記述するのが特徴的なメンバを「プロパティ」と表現します。

プロパティについて

基礎

プロパティは、本質的にはメソッドです。(なのでパスカルケース)

役割としてフィールドに対するアクセス制御を担っています。
つまり、いわゆるgetterとsetterを提供することです。

C#のプロパティでは、これらをgetやsetといった「アクセサ」を使って定義します。(initというアクセサもありますが後ほど触れます)

Person.cs
public class Person
{
    private string _name;
    
    public string Name
    {
        get { return _name; }  // ←getアクセサ
        set { _name = value; } // ←setアクセサ
    }
}

これにより、本来getterとsetter2つのメソッドとして定義されるメンバを、1つのプロパティという形で簡潔に記述することができます。1

ちなみに、setアクセサではvalueキーワードを使うことで、setterの引数に相当する値を暗黙的に参照できます。

■プロパティの呼び出し方
単にプロパティ名を指定すればOKです。

getアクセサとsetアクセサ、どちらが呼ばれているか分からないじゃんと思うかもしれませんが、問題ありません。

呼び出し側
Person p = new Person();
string value = p.Name; // ←getアクセサが呼び出される
p.Name = "hoge";       // ←setアクセサが呼び出される

そのプロパティに対する読み取り処理に対してはgetアクセサが、書き込み処理に対してはsetアクセサが呼び出されるよう、内部的に自動で振り分けてくれています。

これにより、あたかも「フィールドを直接読み書きしている」感があり、直感的に理解しやすいコードを実現できます。

構文

■基本的な記法
先ほども触れたように、プロパティはgetterやsetterを提供する、本質的にはメソッドそのものです。

よって、記法もメソッドのように、実行したい処理をブロックで定義します。

Person.cs
public class Person
{
    private string _name;
    
    public string Name
    {
        // ブロック
    }
}

ただし、このブロック内にはアクセサのみ定義できます。

Person.cs
public class Person
{
    private string _name;
    
    public string Name
    {
        get { // ブロック } // アクセサ
        set { // ブロック } // アクセサ
    }
}

また、アクセサの中身もプロパティ同様、実行したい処理をブロックで定義します。

■ラムダ式を利用した記法
基本的な記法はここまで書いた通りですが、ブロック内の処理が単一の場合、ラムダ式を用いて簡潔に記述できます。

アクセサ内部をラムダ式で記述
public class Person
{
    private string _name;
    
    public string Name
    {
        get => _name;         // get { return _name; }と同一
        set => _name = value; // set { _name = value; }と同一
    }
}

また、プロパティ自体も、ブロック内の処理がgetアクセサのみの場合に限り、ラムダ式を利用できます。

プロパティ内部をラムダ式で記述
public class Person
{
    private string _name;

    public string Name => _name;

    // 以下と同一
    // public string Name
    // {
    //     get => _name;
    // }
}

■自動実装プロパティ
これまで登場したサンプルコードでは、getアクセサ内では単純に値を返すだけ、setアクセサ内では単純に値を設定するだけでした。

このように、純粋なgetter/setterを用意する場合は、以下のように自動実装プロパティを利用できます。

自動実装プロパティで記述
public class Person
{
    public string Name
    {
        get;
        set;
    }

    // 以下と同一
    // private string _name;
    
    // public string Name
    // {
    //     get => _name;         または get { return _name; }
    //     set => _name = value; または set { _name = value; }
    // }
}

自動実装プロパティは、プロパティのブロック内にアクセサ名を記述するだけといったシンプルな記法です。
これにより、アクセサ内のブロック(またはラムダ式)や、フィールドすらも省略することができます。2

■改行の有無
ここまで挙げた、基本的な記法をベースに、必要に応じてラムダ式や自動実装プロパティを利用するというのが、プロパティにおける構文の全てです。

その上で、構造に誤りがなければ、改行は無くすこともできます。

改行を調整して記述
public class Person
{
    private string _name;
    private int _age;

    public string Name { get { return _name; } set { _name = value; } }
    public int Age { get => _age; set => _age = value; }
    public string Mail { get; set; }
}

上記プロパティはいずれも問題なく動作します。

と言っても、改行の有無に限らず、上記NameやAgeのようにプロパティを定義することは正直ほとんどないです。
理由は、Mailのように自動実装プロパティを使えばよいからです。(コード量が大幅に削減できる)

よって、純粋なプロパティを用意したい場合は、積極的に自動実装プロパティを利用するのがよいと考えています。

では、どのような場合に自動実装でないプロパティを使うのか?
こちらは、この後触れるプロパティの用途である2つ目や3つ目の使い方をする時となります。

用途

はじめに少し脱線しますが、プロパティのブロック内では自由に処理を実装することができます。

▲setアクセサがsetterの役割を果たさない
public class Person
{
    private string _name;
    
    public string Name
    {
        get => _name;
        set => Console.WriteLine("Hello C#");
    }
}

Person p = new Person();
p.Name = "hoge";
Debug.WriteLine(p.Name);
実行結果("hoge"の設定を試みたはずが意味なし)
Hello C#
(空行)

上記では、setアクセサがフィールドに対して値の設定を行っていませんが、コード自体は問題なく動きます。

とはいえ、このような名が体を表さない使い方をすることは、(プロパティの文脈に限らず)まずないと思います。

そこで、現実的にプロパティはどのような用途で使われるのか、以下に3つ取り上げます。

①アクセス制御

1つ目は、アクセス制御です。
こちらがプロパティの最大の用途かと思います。(記事を占める割り合いも大きいです)

アクセサの有無でアクセス制御を設定
public class Person
{
    public string Name { get; set; } // 読み書き可
    public string Mail { get; }      // 読み取り専用
}

Person p = new Person();
p.Name = "hoge";
p.Mail = "abc@xxx"; // ←コンパイルエラー

これは、開発者がどのアクセサを用意するかによって定めることができます。
例えば、上記Mailプロパティは、setアクセサを用意しないことで読み取り専用としています。

ここで名称を整理すると、getおよびsetアクセサを用意したプロパティを「Read-Writeプロパティ」、getアクセサのみ用意したプロパティを「Read-onlyプロパティ」と呼びます。

■readonlyキーワード
読み取り専用と聞くと、アクセサ以外にもreadonlyキーワードをイメージすることもあると思うので、こちらも触れておきます。

大きな違いとして、readonlyキーワードはフィールドにのみ設定できます。

readonlyフィールドとRead-onlyプロパティ
public class Person
{
    public readonly string name; // readonlyフィールド
    
    public string Mail { get; } // Read-onlyプロパティ
}

上記nameフィールドとMailプロパティは、実装方法は違えど、どちらも同じ読み取り専用を実現しています。

一方で、カプセル化の原則に則るとフィールドは公開しないことが望ましいです。

このことから、対象のフィールドがクラス内部に対して用意している場合はprivateにしたreadonlyフィールドを、クラス外部に対して用意している場合はgetアクセサによるRead-onlyプロパティを利用するという使い分けになります。

■initアクセサ
アクセサにはgetとsetの他にもう一つ、initアクセサというものが用意されています。
setアクセサ同様、設定に関するアクセサですが、値を設定できるタイミングに制限があります。

initアクセサの利用
public class Person
{   
    public string Name { get; init; }
}

initアクセサでは、インスタンス化時において、オブジェクト初期化子(※この後詳しく)による値の設定を許可することができます。
ちなみに、setアクセサ同様、valueキーワードを利用できます。

用語を整理すると、getおよびinitアクセサを用意したプロパティを「Init-onlyプロパティ」と呼びます。

■初期化
ところで、C#にはフィールドを初期化する方法が2種類あります。

  1. オブジェクト初期化子
  2. コンストラクタ
オブジェクト初期化子による初期化
public class Person
{
    public string Name { get; set; }
    public string Mail { get; set; }
}

Person p = new Person // オブジェクト初期化子
{
    Name = "hoge",
    Mail = "abc@xxx",
};
コンストラクタによる初期化
public class Person
{
    public string Name { get; set; }
    public string Mail { get; set; }
    
    public Person(string name, string mail) // コンストラクタ
    {
        Name = name;
        Mail = mail;
    }
}

Person p = new Person("hoge", "abc@xxx");

上記オブジェクト初期化子の例では、デフォルトコンストラクタを使用してインスタンス化し、その後プロパティに値を設定するという動きをします。

オブジェクト初期化子とコンストラクタの併用
public class Person
{
    public string Name { get; set; }
    public string Mail { get; set; }

    public Person(string name) // コンストラクタ
    {
        Name = name;
    }
}

Person p = new Person("hoge") // オブジェクト初期化子
{
    Mail = "abc@xxx",
};

このように、自前で用意したコンストラクタを使用してインスタンス化し、併せてオブジェクト初期化子により値を設定ということもできます。

話をアクセス制御に戻すと、これらの初期化方法には、値を設定できるプロパティの種類に違いがあります。

1つ目のオブジェクト初期化子による初期化では、Read-Writeプロパティはもちろん、Init-onlyプロパティにも値を設定することができます。

オブジェクト初期化子によるRead-Write, Init-onlyプロパティの初期化
public class Person
{
    public string Name { get; set; } // Read-Writeプロパティ ○
    public int Age { get; init; }    // Init-onlyプロパティ  ○
    public string Mail { get; }      // Read-onlyプロパティ  ×
}

Person p = new Person
{
    Name = "hoge",
    Age = 26,
    Mail = "abc@xxx", // ←コンパイルエラー
};

2つ目のコンストラクタによる初期化では、全てのプロパティに値を設定することができます。

コンストラクタによるRead-Write, Init-only, Read-onlyプロパティの初期化
public class Person
{
    public string Name { get; set; } // Read-Writeプロパティ ○
    public int Age { get; init; }    // Init-onlyプロパティ  ○
    public string Mail { get; }      // Read-onlyプロパティ  ○
    
    public Person(string name, int age, string mail)
    {
        Name = name;
        Age = age;
        Mail = mail;
    }
}

Person p = new Person("hoge", 26, "abc@xxx");

■アクセス制御まとめ
ここまで、get,set,initの各アクセサや、オブジェクト初期化子とコンストラクタで値を設定できるプロパティの違いを見てきましたが、これらを実際にどのように使い分けてアクセス制御を実現するのかまとめていきます。

これまでの話から、各アクセサで設定できるアクセス制御は下表の通り整理できます。

読み取り 宣言時に書き込み コンストラクタで書き込み オブジェクト初期化子で書き込み インスタンス化後に書き込み
Read-Writeプロパティ
Init-onlyプロパティ
Read-onlyプロパティ
readonlyフィールド
定数

ちなみに、宣言時に書き込みとは、いわゆる変数の初期化のことを指しています。

宣言時に書き込み
public class Person
{
    public const int ADULT_AGE = 18; // 定数
    
    public string Name { get; } = "hoge"; // Read-onlyプロパティ

    // 以下と同一
    // private string _name = "hoge";
    
    // public string Name
    // {
    //     get => _name;
    // }
}

これまで触れてこなかったですが、自動実装プロパティの場合、上記の通り記述することでフィールドの初期値として設定できます。

先ほど整理した一覧から、
⭐インスタンス化後に値の書き込みを許可するもの ⇒ Read-Writeプロパティ
⭐インスタンス化時のみ値の書き込みを許可、かつオブジェクト初期化子で値を設定するもの ⇒ Init-onlyプロパティ
⭐インスタンス化時のみ値の書き込みを許可、かつコンストラクタで値を設定するもの ⇒ Read-onlyプロパティ
のように整理できることが分かります。

ただこれだけだと、以下の疑問が残るため、こちら触れておきます。

❓コンストラクタでInit-onlyプロパティを初期化することはないのか
→基本的にはないと考えています。理由は以下の通りです。

▲コンストラクタでInit-onlyプロパティを初期化
public class Rectangle
{
    public double Width { get; init; } // Init-onlyプロパティ
    public double Height { get; }

    public Rectangle(double width, double height)
    {
        Width = width;
        Height = height;
    }

    public void CalcArea() { Debug.Write(Width * Height); }
}

例えば、上記のような、幅と高さを保持して面積を出力する長方形クラスがあるとします。

この場合、長方形クラスの呼び出し側は、以下のように不正な扱い方ができてしまいます。

▲不正な呼び出し
Rectangle r = new Rectangle(3, 2)
{
    Width = 50
};
r.CalcArea(); // 100

幅3高さ2のオブジェクトをインスタンス化したはずが、Widthには50が設定されてしまっています。(その結果、面積が100)

また、今回はありませんが、明示的にコンストラクタを用意している場合、受け取った値を使って検証や何かしらの処理を実行することも少なくないと思います。
しかし、先ほどのようにオブジェクト初期化子を使って値を上書きされると、それらが意味をなさなくなってしまいます。

このことから、コンストラクタで初期化するプロパティはRead-onlyプロパティにすることで、「インスタンス化時のみ値を設定できる」という特性を保ちつつ、先ほどのような不正な使い方を防ぐことができます。

②検証ロジックの実装

プロパティが持つ2つ目の用途は、検証ロジックを実装することです。

検証ロジックを実装
public class Person
{
    private string _mail;

    public string Mail {
        get => _mail;
        set
        {
            if (!value.Contains("@"))
            {
                throw new ArgumentException("メールアドレスが不正です。");
            }
            _mail = value;
        }
    }
}

具体的には、setまたはinitアクセサ内で、フィールドに値を設定する前にその値が正当かを検証するというものです。

補足として、上記例だとMailプロパティがstring型なので、valueキーワードもstring型として扱われます。

③値の加工

最後の用途は、値を加工することです。

フィールドに対して値を加工
public class Person
{
    private string _name;

    public string Name {
        get => _name;
        // 先頭文字だけ大文字に加工
        set => _name = char.ToUpper(value.Trim()[0]) + value.Trim().Substring(1).ToLower();
    }
}
他プロパティに対して値を加工
public class Temperature
{
    public double Celsius { get; set; } // 摂氏

    public double Fahrenheit // 華氏
    {
        get => (Celsius * 9.0 / 5.0) + 32.0;
        set => Celsius = (value - 32.0) * 5.0 / 9.0;
    }
}

具体的には、フィールドや他のプロパティを対象に、取得・設定時問わず値を加工するというものです。

以上の通り、プロパティの3つの用途をまとめました。

  1. アクセス制御
  2. 検証ロジックの実装
  3. 値の加工

これらを駆使して、意図したタイミングでのみ値を設定できるようにし、値自体も意図したもののみを扱うように制御する、というのがプロパティで実現したいことだと考えられます。

発展

オブジェクト初期化子 vs コンストラクタ

上でアクセス制御について触れた際に、インスタンス化時のみ値の書き込みを許可したいプロパティについては、以下の通り整理しました。
・オブジェクト初期化子で値を設定するもの ⇒ Init-onlyプロパティ
・コンストラクタで値を設定するもの ⇒ Read-onlyプロパティ

では、これらのうちどちらを使うのがよいか、どのように判断するのか簡単に触れておきます。

■オブジェクト初期化子

オブジェクト初期化子による初期化
public class Person
{
    public string Name { get; init; } = "noname";
    public int Age { get; init; } = 0;
    public string Mail { get; init; } = "";
}

Person p1 = new Person { Name = "hoge" }; // Nameだけ初期化
Person p2 = new Person { Name = "fuga", Age = 40, Mail = "xyz@xxx" }; // 全て初期化

オブジェクト初期化子では、上記の通り、どのプロパティを初期化するかクラスの使用者側で決められる特徴があります。

この特徴から、初期値だけ用意しといて、インスタンス化時に必要に応じて値を上書きしてほしいようなプロパティに対して、オブジェクト初期化子を重宝する印象です。

■コンストラクタ
一方でコンストラクタでは、インスタンス化に必須な値の組み合わせをクラスの作成者側が制御できます。

コンストラクタによる初期化
public class User
{
    public int UserId { get; }
    public string Mail { get; }
    public string Password { get; }
    public string UserName { get; init; }
    public string Address { get; init; }

    public User(int userId, string password)
    {
        UserId = userId;
        Password = password;
    }
    public User(string mail, string password)
    {
        Mail = mail;
        Password = password;
    }
}

上記例では、「ユーザID+パスワード」の組み合わせか、「メールアドレス+パスワード」の組み合わせを必須としています。

また、他にもコンストラクタが有用なケースとしては、プロパティの初期化の順番を厳格に制御したい場合や、使用者から受け取った複数の値の組み合わせで検証や加工を行うような場合、オブジェクト初期化子では実現できないためコンストラクタが重宝されます。

required修飾子

オブジェクト初期化子による初期化を想定したプロパティのうち、一部は値の設定を必須としたい場合、required修飾子を使うことで実現できます。

required修飾子を付与したプロパティの初期化
public class Person
{
    public string Name { get; init; } = "noname";
    public int Age { get; init; } = 0;
    public required string Mail { get; init; } // 必須プロパティ
}

Person p1 = new Person { Name = "hoge", Age = 26, Mail = "abc@xxx" };
Person p2 = new Person { Name = "fuga", Age = 40, }; // Mailを初期化していないのでコンパイルエラー

required修飾子はsetまたはinitアクセサを持つプロパティに付与することができます。
これにより、クラス使用者に対して、インスタンス化時に必ずオブジェクト初期化子で初期化をすることを強制できます。

アクセサに対するアクセス修飾子

get,set,initの各アクセサに対して、アクセス修飾子を付与できます。

アクセサに対してアクセス修飾子を付与
public class Person
{
    public string Name { get; private set; } // 取得:パブリック、設定:同一クラスのみ
    public int Age { internal get; init; }   // 取得:同一アセンブリのみ、設定:パブリック
    public string Mail { get; init; }        // 取得:パブリック、設定:パブリック
}

クラスやメソッドに付与するような、基本的なアクセス修飾子を使用できます。
何も設定されていない場合、デフォルトではプロパティ自体のアクセス修飾子が適用されます。

アクセサそのものにより、フィールドに対する取得・設定の可否を制御しつつ、アクセス修飾子によりその範囲を制御しているイメージです。

fieldキーワード

※こちらは C#14から実装

上でも触れたように、自動実装プロパティを使うとフィールドを省略できます。
一方で、検証ロジックや値の加工処理を実装する必要がある場合、どうしても自前でフィールドを用意する必要がある点が手間となっていました。

従来の方法で検証ロジックを持ったプロパティを実装する場合
public class Person
{
    private string _mail;

    public string Mail {
        get => _mail;
        set
        {
            if (!value.Contains("@"))
            {
                throw new ArgumentException("メールアドレスが不正です。");
            }
            _mail = value;
        }
    }
}

C#14からは、fieldキーワードを使うことでこの手間が解消されています。

fieldキーワードを使用して検証ロジックを持ったプロパティを実装する場合
public class Person
{
    public string Mail {
        get;
        set
        {
            if (!value.Contains("@"))
            {
                throw new ArgumentException("メールアドレスが不正です。");
            }
            field = value;
        }
    }
}

フィールドは省略され、アクセサ内の処理も自動実装プロパティ同様不要な場合は簡略化できます。
フィールドを指定する必要がある箇所は、fieldキーワードに代替されます。

何かしらロジックを埋め込む場合でも、自動実装プロパティのような開発体験を得られるのは、とても魅力的だと感じました。

歴史

本記事で触れた技術がいつから実装されているのか、気になったので簡単にまとめました。

機能 言語バージョン リリース日
・フィールド
・プロパティ
・コンストラクタ
C# 1.0 2002年1月
・自動実装プロパティ
・オブジェクト初期化子
C# 3.0(.NET 3.5) 2007年11月
・initアクセサ C# 9(.NET 5) 2020年11月
・required修飾子 C# 11(.NET 7) 2022年11月
・fieldキーワード C# 14(.NET 10)

おわりに

以上の通り、C#でプロパティを扱う時の構文や用途などをまとめてみました。

個人的には、理解が浅かった部分がすっきり整理されたので満足です。
また、これまでは言語機能そのものに着目することがあまりなかったので、その点もとても勉強になりました。

fieldキーワードについては元々存在を知らなかったのですが、本記事を書くにつれて、「こんな機能あったらいいな」→調べてみる→ドンピシャに当てはまるのが存在、という流れで知ることになり驚きました。めちゃくちゃテンション上がりました。

本記事をまとめた結果、今までは利用者100%のスタンスでC#と向き合っていましたが、わずかでも作成者のスタンスを持つことができた点が大きな成果だと感じます。
今後も、より深く言語に対する理解を深めたいと感じました。

動作環境

  • Windows 11
  • C# 12.0
  • .NET 8.0
  • Visual Studio 2022
  1. 実際は、コンパイラがILコードに変換する際に、getter/setterの役割を果たすメソッドを自動生成するようですが、開発者は意識しなくて済むというわけです。

  2. こちらも、コンパイラがgetter/setterの役割を果たすメソッドに加え、privateなフィールドを自動生成します。この時生成されるフィールドを「バッキングフィールド」と呼びます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?