こんにちは。開発のY.Mです。

C#で属性(Attrinute)を記述する場合、属性のパラメータには「定数=コンパイル時に決定される値」しか指定する事ができません。
本記事ではその制約を回避して「変数=コンパイル時に決定されない値」を属性のパラメータに指定する方法を紹介します。

※本記事は C# の言語仕様を概ね理解されている方向けです。
 サンプルコードではリフレクションや拡張メソッド、デリゲート、ラムダ式など説明なしで使用しています。

※裏技的な要素が強いので、参考にされる場合は適用先の環境で副作用が生じないかよく検証してからにしてください。

 

なぜ属性のパラメータに変数を指定したいのか

定数には const のバージョニング問題というものがあり、それを回避するため定数として扱う値を const ではなく get-only プロパティなど定数以外に置き換えて対応する事があります。

public static class Constants
{
    // 定数
    public const string CONST1 = "const value 1";

    // get-only プロパティ
    public static string CONST2 => "const value 2";
}

しかしそうすると定数として扱いたい値が変数扱いになってしまうため、属性のパラメータに使用できなくなってしまいます。

[DisplayName(Constants.CONST1)]  // OK
[DisplayName(Constants.CONST2)]  // NG

他にはリソースを用いて多言語対応をしている場合、DisplayName 属性のパラメータにリソースを指定して言語ごとに表示名を自動的に切り替えよう、なんて事を思い付いたとしてもリソースは変数扱いであるため定数のみの制限に阻まれ思い通りにいきません。

public class User
{
    [DisplayName(Properties.Resource.UserName)] // NG 
    public string Name { get; set; } = "";

    [DisplayName(Properties.Resource.UserEmail)] // NG
    public string Email { get; set; } = "";

    [DisplayName(Properties.Resource.UserAge)] // NG
    public int Age { get; set; } = 0;
}

属性の意味を考えればパラメータが定数に限定されている事は理解できるのですが、不便に感じる場面は少なくありません。

では、具体的に変数を指定する方法を挙げていきましょう。

 

方法その1 – 列挙型(enum)を使う

列挙型(enum)はコンパイル時に値が決定されるため定数と同じ扱いとなり、属性のパラメータに使用できます。
新たにカスタム属性を作る場合に限られますが、属性のパラメータに列挙型を指定し、その値を別の変数の値に変換してやります。

例として、DateTime 型のプロパティに書式を指定するカスタム属性 DateTimeFormat (DateTimeFormatAttribute クラス)を追加するケースで説明しましょう。

// DateTimeFormat 属性の使用イメージ
public class DateTimeRange
{
    [DateTimeFormat("yyyy/MM/dd")]
    public DateTime Start { get; set; }

    [DateTimeFormat("yyyy/MM/dd HH:mm")]
    public DateTime End { get; set; }
}

属性のパラメータに指定する DateTime 型の書式は get-only プロパティで宣言します。

※以降「定数」の記述は「get-only プロパティで宣言した変数」を指すものとします。

public static class Constants
{
    // 日付時刻型の書式
    public static class DateTimeFormats
    {
        public static string DATE => "yyyy/MM/dd";
        public static string DATE_TIME => "yyyy/MM/dd HH:mm";
        public static string DATE_TIME_SEC => "yyyy/MM/dd HH:mm:ss";
    }
}

このままでは属性のパラメータに使用できませんので、定数の宣言に対応する列挙型と、列挙型から定数に変換するマッピングディクショナリ、マッピングメソッドを追加します。

public static class Enums
{
    // 日付時刻型の書式の定数宣言に対応する列挙型
    public enum DateTimeFormats
    {
        DATE,
        DATE_TIME,
        DATE_TIME_SEC,
    }

    // 列挙型から定数に変換するマッピングディクショナリ
    private static Dictionary<DateTimeFormats, string> MapDateTimeFormats => new(){
        { DateTimeFormats.DATE         , Constants.DateTimeFormats.DATE },
        { DateTimeFormats.DATE_TIME    , Constants.DateTimeFormats.DATE_TIME },
        { DateTimeFormats.DATE_TIME_SEC, Constants.DateTimeFormats.DATE_TIME_SEC },
    };

    // マッピングメソッド(拡張メソッド)
    public static string ToConstant(this DateTimeFormats item) => MapDateTimeFormats[item];
}

カスタム属性 DateTimeFormatAttribute クラスには、文字列の他に列挙型をパラメータとするコンストラクタを宣言します。

 [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class DateTimeFormatAttribute : Attribute
{
    public string Value { get; init; }

    // 文字列をパラメータとするコンストラクタ
    public DateTimeFormatAttribute(string value)
    {
        Value = value;
    }

    // 列挙型をパラメータとするコンストラクタ
    public DateTimeFormatAttribute(Enums.DateTimeFormats value)
    {
        Value = value.ToConstant(); // 列挙型を変数の値に変換
    }
}

列挙型をパラメータとするコンストラクタの処理ではマッピングメソッド ToConstant で列挙型の値から定数の値を取得し、Value プロパティの値にセットしています。
こうする事で DateTimeFormat 属性のパラメータは文字列と列挙型両対応になり、列挙型指定時はあたかも const で宣言した定数を指定しているかのように記述できます。

public class DateTimeRange
{
    [DateTimeFormat(Enums.DateTimeFormats.DATE)]
    public DateTime Start { get; set; }

    [DateTimeFormat(Enums.DateTimeFormats.DATE_TIME)]
    public DateTime End { get; set; }
}

属性のパラメータ値を取得する際は、既に変換済みの値がセットされていますので単純に Value の値を取得するコードを書くだけです。

var dateRangeStartFormat = typeof(DateTimeRange).GetProperty(nameof(DateTimeRange.Start))?
    .GetCustomAttribute<DateTimeFormatAttribute>()?.Value;

var dateRangeEndFormat = typeof(DateTimeRange).GetProperty(nameof(DateTimeRange.End))?
    .GetCustomAttribute<DateTimeFormatAttribute>()?.Value;

変換用のコードを追加するのが少々面倒ですが、生成AIに例題を示してコードを生成させる事で省力化できると思います。
試しに上記のコードを元に別の定数を指定し、Microsoft Copilot にコード生成をさせたら望み通りのコードを一発で生成したので驚きました。

 

方法その2 – リフレクションを使う

カスタム属性を作成する場合は列挙型を使えば概ね対応できそうなのですが、DisplayName 属性など既存の属性の場合はどうすればよいでしょうか。
こちらについてはパラメータに変数名を指定し、リフレクションで変数名からその値を取得する方法を用います。

先程「なぜ属性のパラメータに変数を指定したいのか」で例に出した DisplayName 属性での表示名多言語化を実現してみましょう。

public class User
{
    [DisplayName(Properties.Resource.UserName)]  // なんとかしたい
    public string Name { get; set; } = "";

    [DisplayName(Properties.Resource.UserEmail)] // なんとかしたい
    public string Email { get; set; } = "";

    [DisplayName(Properties.Resource.UserAge)] // なんとかしたい
    public int Age { get; set; } = 0;
}

まずはリソースを値とする定数(get-only プロパティ)を宣言します。

public static class Constants
{
    public static class DisplayNames
    {
        public static string USER_NAME => Properties.Resource.UserName;
        public static string USER_EMAIL => Properties.Resource.UserEmail;
        public static string USER_AGE => Properties.Resource.UserAge;
    }
}

DisplayName 属性のパラメータに先程宣言した定数の名前を指定します。
名前の指定に文字列リテラルを使用するとリファクタリングが困難になるので nameof 式を用いることをおすすめします。

public class User
{
    [DisplayName($"{nameof(Constants.DisplayNames)}.{nameof(Constants.DisplayNames.USER_NAME)}")]
    public string Name { get; set; } = "";

    [DisplayName($"{nameof(Constants.DisplayNames)}.{nameof(Constants.DisplayNames.USER_EMAIL)}")]
    public string Email { get; set; } = "";

    [DisplayName($"{nameof(Constants.DisplayNames)}.{nameof(Constants.DisplayNames.USER_AGE)}")]
    public int Age { get; set; } = 0;
}

属性のパラメータに指定した定数の名前からその値を取得する拡張メソッドを追加します。
下記サンプルコードの ToConstant 拡張メソッドでは Constants クラスのプロパティ、もしくは Constants クラス内で宣言されている内部クラスのプロパティから値を取得するようにしています。プロパティが見付からなかった場合は属性のパラメータに指定した値(定数の名前)を返します。

public static class ConstantsHelper
{
    public static object? ToConstant<T>(this T attr, Func<T, string> func)
        where T : Attribute
    {
        var value = func(attr);
        if (string.IsNullOrEmpty(value)) { return value; }
        var level = value.Split(".").ToList();
        if (level.Count < 1) { return value; }

        var propName = level[^1];
        level.RemoveAt(level.Count - 1);
        level.Insert(0, typeof(Constants).FullName!);
        var typ = Type.GetType(string.Join("+", level));

        return typ?.GetProperty(propName)?.GetValue(typ) ?? value;
    }
}

属性のパラメータ値からリソースの値を取得するコードは下記のとおりです。

var userNameDisplayName = (string?)typeof(User).GetProperty(nameof(User.Name))?
    .GetCustomAttribute<DisplayNameAttribute>()?
    .ToConstant(x => x.DisplayName);

var emailDisplayName = (string?)typeof(User).GetProperty(nameof(User.Email))?
    .GetCustomAttribute<DisplayNameAttribute>()?
    .ToConstant(x => x.DisplayName);

var ageDisplayName = (string?)typeof(User).GetProperty(nameof(User.Age))?
    .GetCustomAttribute<DisplayNameAttribute>()?
    .ToConstant(x => x.DisplayName);

少々面倒ではありますが目的は達することができました。
ただし、この方法が使えるのは属性のパラメータが string 型である場合に限られます。その他の型で実現したい場合は方法1のように列挙型とカスタム属性を用いる方が良いでしょう。

 

以上、属性のパラメータに変数を指定する方法をふたつばかり紹介しました。
この記事を読まれた方が抱える問題を解決するヒントになれば幸いです。