6
3

宣言順の不正や相互参照を検出するアナライザー

Last updated at Posted at 2024-07-30

C# の静的フィールドの初期化子は問題があってもコンパイルエラーを起こさないので、今までは「気を付ける」で対応していたんですが、うっかり相互参照して静的フィールドの初期化は順序が変わってしまい、そしてエラーの原因が分かり辛く、、、なんで? 何故動かない! となりました。

なので Roslyn アナライザーを作ってエラーとして検出できるようにしました。

1)宣言順序の診断

以下のように宣言順序が間違っている場合にエラーを出します。

static int BEFORE = AFTER;  // 310 ではなく 0 になってしまう
                    ~~~~~
static int AFTER = 310;     // 宣言より先に参照されている
           ~~~~~

2)相互参照の検出

相互参照と言っても A.Value = B.Value B.Value = A.Value という単純な話だけではありません。

以下の例のように自身のメンバーを参照しているクラスのメンバーを参照する、という形でクロス参照を行っていると初期化で問題を起こす可能性があります。

この問題が厄介なのはクラスへのアクセス順によって問題が起きたり起きなかったりする点です。その為すべてのケースでエラーとして検出します。

public class A {
    public readonly static int OUT = B.OUT;  // A を参照している B のメンバーを参照している
                               ~~~
    public readonly static int INT = 310;
}

public class B {
    public readonly static int OUT = 620;
    public readonly static int INT = A.INT;  // B を参照している A のメンバーを参照している
                               ~~~
}

初期化順序が変わる例

この例では、先に A にアクセスした場合の初期化の順序は、

A.OUT = B.OUT;      // B にアクセスしたので B の静的フィールドの初期化が始まる
    B.OUT = 620;
    B.INT = A.INT;  // A.INT の初期化は済んでいないので 0 が代入される
A.INT = 310;        // B の初期化が終わってから A.INT の初期化が行われる

となり B.INT が適切に初期化されません。

ですが、クラスへのアクセス順序が変わるだけで問題が起きなくなります。

B.OUT = 620;
B.INT = A.INT;      // A の初期化が始まる
    A.OUT = B.OUT;  // 👈 B.OUT は初期化済みなので適切な値が代入される
    A.INT = 310;

サンプルコード

実行環境: https://dotnetfiddle.net/

class A {
    public static int INT = B.OUT;
    public static int OUT = 310;
}

class B {
    public static int OUT = 620;
    public static int INT = A.OUT;  // will be '0' not '310'
}

public static class Test
{
    public static void Main()
    {
        if (true)  // 切り替え
        {
            System.Console.WriteLine(A.INT);  // 620
            System.Console.WriteLine(A.OUT);  // 310
            System.Console.WriteLine(B.INT);  // 0   👈👈👈
            System.Console.WriteLine(B.OUT);  // 620
        }
        else
        {
            System.Console.WriteLine(B.INT);  // 310  👈 correct!!
            System.Console.WriteLine(B.OUT);  // 620
            System.Console.WriteLine(A.INT);  // 620
            System.Console.WriteLine(A.OUT);  // 310
        }
    }
}

この例であれば A の INT と OUT の宣言順を変えれば問題を解決できますが、基本的にクロス参照をすること自体に問題があります。コンパイルエラーは出ないですがバグといって良いでしょう。クラスへのアクセス順が固定のテストでは露見しない爆弾を抱えることになりますから。

が! まぁやっちゃうこともあるでしょう。私の場合は大体こんな感じでした。

// 🤔 BaseClass.DefaultName がなー
Utils.Method(Utils.DefaultNumber, BaseClass.DefaultName);

// 👇 Utils.DefaultName を定義して BaseClass.DefaultName を参照させて気持ちよくなろう!
Utils.Method(Utils.DefaultNumber, Utils.DefaultName);

// 😭 結果クロス参照になり正しく初期化されず、何故か動かない状態に!

3)partial 型のファイル間参照

複数ファイルからなる型の初期化順は C# の言語仕様として定義されていないとのことで、別の .cs ファイルで宣言されている partial 型の静的フィールドへのアクセスもエラーとして検出します。

こちらもクロス参照と同様に初期化順が不安定になるデザイン上のエラーです。ソースジェネレーターの生成したファイル内で宣言されているフィールドを生成元で参照している場合などは、静的コンストラクターを使った初期化に切り替えたほうが良いでしょう。

4)型引数 TSelf

C# 11.0 ではインターフェイスで静的メンバーを宣言することが可能になりました。

言語機能の拡張が必要となった主な要因である Generic Math では TSelf という実装するクラス自身をさす型引数を取ります。

public interface INumber<TSelf> where TSelf : INumber<TSelf>

この TSelf に対する型引数制約は、実際には実装する型に限定するモノにはなっていません。(C# 的にそういう制約を課すことが出来ません)

その為、以下の例のように INumber<TSelf> を実装した別の型を指定できてしまいます。

public class GenericMath : INumber<GenericMath> { }

public class OtherClass : INumber<GenericMath> { }   // 実装する型以外を TSelf に指定している
                                  ~~~~~~~~~~~

アナライザーは型引数が TSelf という名前の場合、自身を型指定しないと警告を発します。インターフェイスだけではなくジェネリック型の基底クラスが TSelf を取る場合も同様です。

class Base<TSelf> { }             // 型引数制約が無くても TSelf という名前の場合は診断対象になる

class Derived : Base<object> { }  // Derived ではありませんか?
                     ~~~~~~

5)注釈/下線表示

Visual Studio 上で [DescriptionAttribute("注意書き等のメッセージ")] というアトリビュートが付いている型やメンバーにアンダーラインを引いてメッセージを表示することが出来ます。

スクリーンショットではそんなでもないですが Visual Studio 上ではかなり邪魔で目立ちます。なので、扱いに注意して欲しい型やメンバーに Obsolete アトリビュートを付けてて注意書きをしている、という場合の代替手段として利用できます。

アトリビュートを付けると行頭/文頭/シンボル/行末にアンダーラインが表示され #pragma warning... で各要素の表示/非表示をコントロールできるようになります。

--

独立した Roslyn アナライザーとして作るのは非常に面倒なのでついでに入れちゃお、で入っている機能です。

アナライザーへの依存を避けるためにアトリビュートを System.ComponentModel から流用している関係で [Description(...)] だと下線が表示されません。[System.ComponentModel.Description(...)] もダメです。必ず [DescriptionAttribute(...)] である必要があります。

※ 型ではなく文字列が一致しているかどうかを見ています。なので System.ComponentModel 以外の DescriptionAttribute 型でもアンダーラインを引けます。そもそもアトリビュートが存在しない状態でも [DescriptionAttribute] と書けば下線が引かれます。(コンパイルは通りませんが)

インストール/ダウンロード

dotnet add package SatorImaging.StaticMemberAnalyzer  #--version 1.4.0

👇 またはコチラ。

Unity プロジェクトへのインストール

Directory.Build.props

お手軽なセットアップ方法ですが Visual Studio 上でこのアナライザーがエラーを出していても Unity のコンパイルは通ってしまうという問題があります。

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <PropertyGroup>
        <RestoreProjectStyle>PackageReference</RestoreProjectStyle>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="SatorImaging.StaticMemberAnalyzer" Version="1.4.0">
            <PrivateAssets>all</PrivateAssets>
            <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
        </PackageReference>
    </ItemGroup>
</Project>

Directory.Build.props については以下を参照のこと。

アセットとしてインポート

アナライザーとして設定済みの .meta 付きのアセット一式が以下よりダウンロード可能です。コチラを導入するとエラーが含まれている場合に Unity のコンパイルが通らなくなります。

ダウンロードして Assets フォルダー内の良きところに配置してください。

.asmdef ファイルがあるフォルダー以下に配置するとアナライザーの対象がそのアセンブリーのみに限定されてしまうので、UPM パッケージを含めた全ての C# スクリプトを対象に診断を行いたい場合は Assets 直下に配置します。

参考)静的コンストラクターの実行順

静的フィールド変数初期化子が静的コンストラクターのクラスに存在する場合、それらは、クラス宣言に出現するテキストの順序で実行されます。 初期化子は、静的コンストラクターの実行直前に実行されます。

const vs static readonly

Effective C# には const より static readonly を! と書いてあります。

配布されている .dll 内の const を参照してコンパイルすると、その値が「焼きこまれる」のでちょっと不便だよねって話です。

public float Value = ExternalDll.PublicConstantValue;   //10.1f
public float Other = ExternalDll.PublicStaticReadonly;

// 👇 コンパイルするとこうなる

public float Value = 10.1f;  // 定数としてコンパイル結果に焼きこまれる(.dll への参照は残らない)
public float Other = ExternalDll.PublicStaticReadonly;  // 参照が残るので毎回 .dll から値を取得する
                                                        // .dll の値を更新すれば反映される状態

C# 公式ソースで定数になっているものを見てみる

const ローカル変数

良い機会なのでメソッドのローカル変数を const にした際の挙動を見てみます。

(クラスメンバーにする場合はしっかりとした名付けをして変更も避けなきゃいけないのが面倒で、、、)

実行環境: https://sharplab.io/

using System;

public class C
{
    public const string KClassString = "KClassString";
    public const int KClassInt = 310;

    public void M()
    {
        const string KMethodString = "KMethodString";
        const int KMethodInt = 620;

        var val = 10 + KClassInt + KMethodInt;
        Console.WriteLine(KClassString + KMethodString + "A" + val);
        Console.WriteLine(KClassString);
        Console.WriteLine(KMethodString);
    }
}

結果

フィールドとして定数が残るか残らないかの違いしかありません。参照している部分は全て定数に置き換えられます。

C#(逆コンパイル結果)
public class C
{
    public const string KClassString = "KClassString";

    public const int KClassInt = 310;

    public void M()
    {
        Console.WriteLine(string.Concat("KClassStringKMethodStringA", 940.ToString()));
        Console.WriteLine("KClassString");
        Console.WriteLine("KMethodString");
    }
}

おわりに

なんとなく車輪の再発明っぽいアナライザーですが、軽くネットを探した感じ同様のものは見つからず。

不正な宣言順の検出はマジ便利です。私の場合は昔書いたスクリプトに1か所だけ宣言順の間違いがありました。今まで人力チェックしていたのは一体何だったのか! て感じです。

以上です。お疲れ様でした。

6
3
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
6
3