C#で属性を利用して処理に制約の説明を追加する

属性とは

C#に属性(Attribute)という機能があり、これを付ける事でクラスやメンバーに情報を追加することができます。

.NET で使用されている有名なものでは、デバッグ時だけコンパイルされる"Conditionat"属性や、廃止予定を予告するための"Obsolete"属性が有名です。

これらは付与するとIDEや言語と連携して何らかの機能が提供されたりします(例えばメッセージが表示されたり、実装が追加されたり etc...)

また属性は自作することもできます。自作の属性は付与しても特に何が起きるわけではないので純粋な情報追加という意味になります。

もちろん外部ツールでアセンブリからメタ情報を読み取ってCI時にチェックするという使用方法もあります。最終的にかなり手間がかかるので面倒なため限られた人しかやっていません。

後から特別な方法で付与した属性の値は取り出すことができるのでこれを利用してenumを拡張するのような使い方をするのが一番よくある利用のされかたかと思います。

自作の属性でプロパティやメソッドに説明を付与する

で、アイデアレベルになりますが、の属性を使ってメソッドや属性に説明を追加したいと思います。

コメントだと自由記述過ぎるのため、特定の属性が付与されていることで制約を表すことに使えないかなと。

ImplementAttribute:インターフェースの実装を明示する

C#は言語仕様上インターフェースを具象クラスに実装した場合、メソッドにoverrideキーワードをつけません。

"abstract" や "virtual" メソッドは実装した場合 "overide" キーワード指定が必須で後からコードを見たときに区別が一瞬つかないときがあります。またAPIコメントがインターフェースと実装でコピペされているのも結構無駄な感じなのでImplementAttributeを作成してoverrideキーワードの代わりにしたいと思います。

属性の宣言は以下の通りです。付与して説明するだけなのでコンストラクターで引数を指定するだけで値の保存などはしません。

/// <summary>
/// インターフェースを実装していることを表します。
/// 付与するだけで特にシステム上の意味はありません。
/// </summary>
[AttributeUsage(AttributeTargets.Method | 
                AttributeTargets.Property, 
                Inherited = false, AllowMultiple = false)]
public sealed class ImplementAttribute : Attribute
{
    public ImplementAttribute(string name) { }
    public ImplementAttribute(Type type) { }
}

上記属性の使用方法です。

// Sample.cs

using System;

public class Sample : IDisposable
{
    // インターフェースを実装したメソッドに以下のように付与する
    [Implement(nameof(IDisposable.Dispose))]
    public void Dispose()
    {
        // hoge
    }
}

後から見たときにインターフェースを実装していることを属性で明示できたような気がします。

ReqireWithNoCheckAttribute:指定が必須だけどチェックしない

パラメーターとして渡した値が渡した先でチェックされるのかされないのかを属性によって明示します。

チェックしないけど必須パラメーター(パフォーマンス上の都合など)や、プロパティに何か指定しないと後の処理が動かなくなるなどを属性で明示します。

// ReqireAttribute.cs

/// <summary>
/// 値の設定がインスタンス生成時や以降の処理実行時に必須かどうかを表します。
/// (未設定の場合、以降の操作で未チェックの例外が発生する可能性を表す)
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, Inherited = false, AllowMultiple = true)]
public sealed class ReqireAttribute : Attribute
{
    /// <summary>
    /// 設定が必須のパラメーターの旨を明示してオブジェクトを作成します。
    /// </summary>
    public ReqireAttribute() { }

    /// <summary>
    /// 必須パラメーターがメソッド内でチェックされるかどうかを指定してオブジェクトを作成します。
    /// true  : メソッド内でチェックあり
    /// false : パラメーターは渡した先でチェックされない。未検査でパラメーターの仕様で落ちる可能性がある。
    /// </summary>
    public ReqireAttribute(bool inspection) { }
}

上記属性の使用方法です。

// Sample.cs

public class Sample
{
    // 引数がチェックされないで使用される旨を属性で明示する
    public void Execute([Reqire(inspection: false)] Around around)
    {
        // aroundオブジェクトにnullチェックをしないでいきなりアクセスする
        around.Foo();
    }

    // 有意の値をプロパティに設定する必要がある旨を表す。
    [Reqire]
    public string Message { get; set; } = "";

    public void Foo()
    {
        // このメソッド内でMessageプロパティを検査せずに使用する
        if (this.Message == "Foo")
        {
            // hoge
        }
    }
}

呼び出すときにパラメータやプロパティの内容に注意が必要な旨を属性で表明した気になれます。

これも付与して説明するだけでシステム上何か特別な作用があるわけではないのですが、他人のコードにこれがついてると多少ありがたい時があります。

SideEffectAttribute:呼び出すと副作用が発生する

プロパティに値を設定すると連動してほかのプロパティが変化する(クソ設計臭がすごいですが…)や、呼び出すとオブジェクトの状態が変化するメソッドなどに付与して副作用が発生することを属性を使って明示します。

// SideEffectAttribute.cs

using System;

/// <summary>
/// 呼び出しに副作用があることを明示します。
/// 付与するだけで特にシステム上の意味はありません。
/// </summary>
[AttributeUsage(AttributeTargets.Method | 
                AttributeTargets.Property | 
                AttributeTargets.Parameter, 
                Inherited = false, AllowMultiple = true)]
public sealed class SideEffectAttribute : Attribute
{
    public string[] Names { get; private set; }

    /// <summary>
    /// 副作用のある対象を列挙してオブジェクトを新規作成します。
    /// </summary>
    /// <param name="names"></param>
    public SideEffectAttribute(params string[] names) => this.Names = names;
}

上記属性の使用方法です。

// Sample.cs

public class Sample
{
    public int Count { get; private set; }

    // 呼び出すとCountプロパティに影響があることを指定する
    [SideEffect(nameof(Count))]
    public void Execute()
    {
        this.Count++;
    }
}

呼び出すと関係ない個所に影響があることを属性の引数で説明文でも良いですし、変数名でも良いので指定して属性を付与します。

副作用が無いのが一番いいですが理想通りに実装できるケースの方が少ないので他のドキュメントで説明するよりは属性で示した方が後で幸せになれると思います。

DoNotChangeAttribute:値を変えてはいけない

リリース後にリテラルやEnumなどを変更してはいけないことを明示します。

既定値のあるSerializeAttributeやDataContract、SerializeFieldが付与されているプロパティの名称の変更の禁止は当然ですが、定数リテラルとかEnumを数字として扱っている場合、変更されると境界面や境界外で影響が発生するものに付与し変更を禁止する旨を明示します。

/// <summary>
/// フィールド、プロパティ、パラメータの設定値が変更禁止な事を表します。
/// 特にリリースした後に変更してはいけない定数などに付与します。
/// 目印として付与するだけでシステム上の意味は特にありません。
/// </summary>
[AttributeUsage(AttributeTargets.Field | 
                           AttributeTargets.Property | AttributeTargets.Parameter, Inherited = false, AllowMultiple = true)]
public sealed class DoNotChangeAttribute : Attribute
{
    /// <summary>
    /// 既定の初期値でオブジェクトを生成します。
    /// </summary>
    public DoNotChangeAttribute()
    {
    }

    /// <summary>
    /// 禁止の理由や対象を説明する文字列を指定してオブジェクトを生成します。
    /// </summary>
    public DoNotChangeAttribute(string message)
    {
    }
}

上記属性の使用方法です。

// Sample.cs

using System;
using System.Runtime.Serialization;
using UnityEngine;

[DoNotChange("並び順及び名称変更NG")]
[DataContract]
public enum Hoge
{
    [EnumMember] AAA,
    [EnumMember] BBB,
    [EnumMember] CCC,
}

public class Sample
{
    [DoNotChange("名称変更禁止")]
    [SerializeField]
    public GameObject Sprite;
}

名前を指定してない "DataMember" のプロパティの名前を他人が変更してデシリアライズに失敗とか最高にあるあるなので付けておいた方が身を守れます。

あとはSerializeFieldの名前をリリース後に変えてしまって参照が外れたまま気が付かないとか割とよくありそうです。

Enumをintにキャストしてるちょっとアレなコードの中ほどに定義を一個追加してシステムがおかしくなるとかも稀によくあるのでコードに書いておいた方がいいです。

実装を直した方がいいと思いますがそういう訳にもいかない場合、属性を付与して防衛しておくのもアリかなと。。

最後に

ConstAttribte とかやり始めるとキリがないのに実効性が無いものもたくさん出てくると思いますが個人的にあって幸せになれそうな4つを紹介しました。

属性は実装に一切影なく宣言に追加できるのでこういった自己説明的な属性があってもいいのではないかなと思います。

やりすぎると属性だらけになってしまう(というかそうなったら設計直した方がいいですが)のでご利用は計画的に。

また、頑張ればCI時にインスペクションで指摘が出せる可能性もあるのでよかったら使ってみてください。

以上です。