⚠️

Rider で ContractAnnotation が動かなくなっちゃった!?

2024/04/20に公開

はじめに

C# なコードを書く際に JetBrains Rider にお世話になっている人も多いことかと思います。
(Windows な方は Visual Studio + ReSharper という構成も多いかな?)

現在、Rider は 2024.1 [1] というバージョンがリリースされており、筆者もリリース直後にアップデートしたのですが、ある日「あれ?なんか前は出てなかった警告が出てる…?」というコトに気付きました。
具体的には NRT: Nullable Reference Types / null 許容参照型 を有効にしたプロジェクトで、null チェックを行った以降のコードで Nullable 型を参照した際に「null になる可能性あるよ」という警告が表示されるようになっていたのです。
本稿は、これの原因を調査・対応した際の備忘録となります。

原因調査

そもそものコード

そもそものコードは以下のようなものでした。

using System;
using JetBrains.Annotations;

public class Example
{
    private readonly string? _value;

    public Example(string value)
    {
        _value = value;
    }

    public void Foo()
    {
        Assert.NotNull(_value);
        Console.WriteLine(_value.Contains("hoge"));
        //                ^^^^^^ ここで Dereference of a possibly null reference 警告が出る
    }
}

public static class Assert
{
    [ContractAnnotation("value:null => halt")]
    public static void NotNull(object? value, string message = "")
    {
        if (value == null)
        {
            throw new ArgumentNullException(message);
        }
    }
}

このコードの要点としては Assert.NotNull() メソッドを JetBrains が用意している Contract Annotations / 契約アノテーションを用いてマークしているところにあります。

Contract Annotations

Contract Annotations は、ざっくり言うと「引数が非 null のときは結果がこうなるよ」とか「結果が返ってこないときは引数が null と見做せるよ」みたいなことを IDE に教えるための属性で、 [ContractAnnotation(string)] の第一引数に独自の文法を用いて宣言した引数と結果の関係を渡します。
本来、この属性が付いたメソッドを通過した以降のコードは、宣言に基づいてメソッド引数に渡した変数やフィールドやプロパティが解釈され、null / 非 null の判定などが自動で行われることになります。
上述のサンプルコードで言うと、以下の部分が該当しています。

[ContractAnnotation("value:null => halt")]
public static void NotNull(object? value, string message = "")
{
    if (value == null)
    {
        throw new ArgumentNullException(message);
    }
}

このコードで ContractAnnotationAttribute の第一引数に渡している契約は "value:null => halt" なので、「value 引数が null な場合、halt する」と訳せます。
ここで言う halt は「例外が throw されて処理が停止する」と言い換えられるので、結果として「value 引数が null な場合、以降のコードは実行されない [2]」と言い換えることができます。
で、これは逆説的に「例外が throw されなければ value 引数は非 null」と言い換えられるので、結果として「NotNull メソッド以降のコードに於いて、NotNull メソッドの第一引数に渡した値は非 null である」ことをアナライザに伝えることができるわけです。

よって、サンプルコードで言うと以下のコードは本来警告が出ないはずだったんですが…。

public void Foo()
{
    Assert.NotNull(_value);
    // ↑ Assert.NotNull を無事に通過している!
    //                ↓ ってことは _value は非 null!!
    Console.WriteLine(_value.Contains("hoge"));
}

調査したこと

契約構文のミス?

まずは、ContractAnnotationAttribute に渡している契約の構文が間違ってるのかな?と思い、他にこの属性を使っているコードを調べてみました。

んで、ヒットしたのが UnityEngine.Assertions.Assert.IsNotNull() メソッドでした。
UnityEngine.Assertions.Assert.IsNotNull

うーむ、一言一句違わず一緒ですね。

Rider のバージョン?

最近 2024.1 に上げたばかりだったので、なんかバグがある?と思い、直前まで使っていた 2023.3.4 を Archive からダウンロードして確認してみましたが、特に変わらず。
そもそも、どのバージョンまで警告が出ていなかったのかもハッキリしていないので、これ以上のダウングレード調査は早々に切り上げました。
(仮にバージョンの問題なんだとすると、今後修正が入るまで上げられないことになっちゃうので、それも嫌だなぁという思いもあり。)

設定がなんらかの理由で上書きされた?

該当しそうなのは、以下のスクショにある Editor > Code Style > C# の Null Checking とかでしょうか?

Null Checking

これも、前のバージョンのソレを覚えていないのと、仮に覚えていたとしても設定を上書きできそうな気配がないので見切りをつけました。

Unity プロジェクトだとアカン?

今回の問題が発覚したプロジェクトは Unity プロジェクトだったので、標準的な .NET のプロジェクトとはそれなりに異なる構成だったりします。
この影響もあるんじゃないか?と思い、素の .NET Solution を作成して検証してみました。

が………駄目っ……!(画像略)

他の属性はどうだ?

Rider は JetBrains.Annotations な属性以外に、.NET 標準の属性もサポートしています。
具体的には

  • [NotNullWhen(bool)]: 戻り値が属性引数の真偽値と一致する場合、属性が付けられている引数が非 null
  • [NotNullIfNotNull(string)]: 属性引数のパラメータ名の値が非 null な場合、属性が付けられている引数が非 null
  • [DoesNotReturn]: 属性が付けられたメソッドは戻らない = 例外を throw する
  • [DoesNotReturnIf(bool)]: 属性が付けられた引数の値が属性引数の真偽値と一致する場合は戻らない(例外 throw)
    などが挙げられます。

色々実験コード書いて試してみたところ、これらの属性は正しく読んでくれることがわかりました!
.NET 標準属性ならワンチャンいけるのでは…!

対応方針を考える

もともとやりたかったことは「メソッドが戻り値を返す(halt しない)なら、引数は非 null である」ことをアナライザに教えることでした。
理想は1つのメソッドに変数などを渡して、戻り値をチェックしたりせず、引数に value != null 的な式を渡さずに Assertion できることです。

これを実現するのは、少なくとも .NET 標準の属性だけでは難しそうです。
が、[NotNull...] 系と [DoesNotReturn...] 系の属性を組み合わせればいけそうな気がしないでもないです。

対応結果

結論としては [NotNullWhen(bool)][DoesNotReturnIf(bool)] を組み合わせることで、今回やりたかったことをどうにかクリアできました。

コードはこんな感じ。

using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;

public class Example
{
    private readonly string? _value;

    public Example(string value)
    {
        _value = value;
    }

    public void Foo()
    {
        // 1メソッドで完結させるのは妥協した
        Debug.Assert(Assertion.NotNull(_value));
        Console.WriteLine(_value.Contains("hoge"));
    }
}

public static class Debug
{
    [Conditional("DEBUG")] // これを付けておくと、リリースビルドとかでメソッド呼び出しも含めてメソッドが消えてくれる。
    public static void Assert([DoesNotReturnIf(false)] bool condition)
    {
        if (condition)
        {
            return;
        }
        throw new InvalidOperationException();
    }
}

public static class Assertion
{
    public static bool NotNull([NotNullWhen(true)] object? value)
    {
        return value != null;
    }
}

よくわかる(かもしれない)解説

まず、理想としていた「1メソッド」「式渡しなし」「if 文不使用」のうち、「1メソッド」は諦めました。[3]

ミソは「[NotNullWhen(bool)] を利用した Assertion の結果から引数の null / 非 null を判定」と「[DoesNotReturnIf(bool)] を利用した引数の真偽値による halt 有無の判定」を組み合わせた点にあります。

Debug.Assert(Assertion.NotNull(_value));

このコードの意味するところを「Debug.Assert 以降の行が実行されるかどうか」で場合分けして考えると以下のような感じになります。

  • Debug.Assert 以降の行が実行されない場合
    • _value が null であろうがなかろうが、実行されることはないので警告は不要
  • Debug.Assert 以降の行が実行される場合
    • 実行されると言うことは [DoesNotReturnIf(false)] 的に condition 引数の値は true である
    • condition 引数が true と言うことは Assertion.NotNull(_value) の結果が true である
    • Assertion.NotNull(_value) の結果が true であると言うことは [NotNullWhen(true)] 的に value 引数の値は非 null である
    • よって value 引数に渡している _value の値は非 null である

つまり、アナライザ的には「Console.WriteLine(_value.Contains("hoge")); に到達できている時点で _value は非 null 確定なんだから、警告は出さなくて良いよね」と解釈してくれるわけですね。

めでたしめでたし。

(おまけ)Unity の場合

めでたく対応が完了したわけですが、上記のコードをそのまま Unity プロジェクトに持ち込むと、ちょっと困ったことになります。
具体的には Debug と言う型名が UnityEngine.Debug と衝突するため、完全修飾するなり using でエイリアスを張るなりする必要が出てきます。[4]
いやはや、それは面倒臭いなぁ…。

どっこいところが、Unity には UnityEngine.Debug.Assert(bool condition) [5] と言うメソッドが存在しています。

UnityEngine.Debug.Assert(bool condition)

デコンパイルした UnityEngine.Debug.Assert
/// <summary>
///   <para>Assert a condition and logs an error message to the Unity console on failure.</para>
/// </summary>
/// <param name="condition">Condition you expect to be true.</param>
/// <param name="context">Object to which the message applies.</param>
/// <param name="message">String or object to be converted to string representation for display.</param>
[Conditional("UNITY_ASSERTIONS")]
public static void Assert(bool condition)
{
    if (condition)
        return;
    Debug.unityLogger.Log(LogType.Assert, (object) "Assertion failed");
}

完成品のサンプルコードにそっくりですね!
まぁ、サンプルコードの方は例外を throw しているのに対して、Unity の方は Logger に LogType.Assert で書き込んでいるだけなので、微妙に挙動は異なっていますが。

「じゃあコイツを使えば良いじゃん!」って話なんですが、このメソッドは標準状態では condition 引数に [DoesNotReturnIf(false)] 属性が付けられていません。
標準の UnityEngine.Debug.Assert

External Annotations 概要

さて困った、と色々調査していたところ、そもそもデコンパイルした結果の UnityEngine.Debug.Assert(bool) メソッドに付いている属性と Rider 上で表示されるメソッド属性(↑ のスクショ)に差異があることに気付きました。
どうやら、DLL のデコンパイル結果に外から属性を追加付与している誰かが居るっぽいです。

で、深く調べてみたところ Rider の仕組みの一つに External Annotations / 外部アノテーション と言うものがあることが分かりました。

External Annotations はざっくり言えば「外から属性(アノテーション)を追加する」ための仕組みで、XML ファイルに「追加先の型やメンバや引数の情報」と「追加する属性の情報」を定義することで発動するものになります。
今回のケースだと UnityEngine.Debug.Assert(bool condition) (と他3つのオーバーロード)の condition 引数に対して [DoesNotReturnIf(false)] を追加できればゴール、と言うわけですね。

External Annotations 詳細

今回対応したい「UnityEngine.Debug.Assert(bool condition) (と他3つのオーバーロード)の condition 引数に対して [DoesNotReturnIf(false)] を追加」を実現するためには以下のような XML ファイルを配置すれば OK です。

<assembly name="UnityEngine.CoreModule">
    <member name="M:UnityEngine.Debug.Assert(System.Boolean)">
        <parameter name="condition">
            <attribute ctor="M:System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute.#ctor(System.Boolean)">
                <argument>false</argument>
            </attribute>
        </parameter>
    </member>
    <member name="M:UnityEngine.Debug.Assert(System.Boolean,System.Object)">
        <parameter name="condition">
            <attribute ctor="M:System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute.#ctor(System.Boolean)">
                <argument>false</argument>
            </attribute>
        </parameter>
    </member>
    <member name="M:UnityEngine.Debug.Assert(System.Boolean,UnityEngine.Object)">
        <parameter name="condition">
            <attribute ctor="M:System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute.#ctor(System.Boolean)">
                <argument>false</argument>
            </attribute>
        </parameter>
    </member>
    <member name="M:UnityEngine.Debug.Assert(System.Boolean,System.String)">
        <parameter name="condition">
            <attribute ctor="M:System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute.#ctor(System.Boolean)">
                <argument>false</argument>
            </attribute>
        </parameter>
    </member>
</assembly>

このファイルの詳細な解説は省きますが、なんとなく書いてあることは理解できるのではないでしょうか?

で、この XML ファイルを配置するわけですが、配置先に以下のような制約があります。

  1. .sln やら .csproj やらが生成されるディレクトリのサブディレクトリとして ExternalAnnotations/ を掘る
  2. 更に 1. のディレクトリの下に Assembly 名のディレクトリを掘る
    • 今回の場合だと UnityEngine.DebugUnityEngine.CoreModule.dll に含まれるので ExternalAnnotations/UnityEngine.CoreModule/ となる
  3. その下に任意の名前で XML ファイルを配置 [6]

まとめると以下のような構造になっていれば OK です。

Assets/
ExternalAnnotations/
  UnityEngine.CoreModule/
    Annotations.xml
Packages/
AwesomeSolution.sln
AwesomeProject.csproj
...

反映時は Solution リロード

一点注意すべき点として、Rider はこの XML ファイルを動的に読み込んでくれないので、Solution をリロードする必要があります。
XML ファイルを配置したり書き換えたりした際には Rider を再起動しちゃうのが手っ取り早くてオススメです。

無事に反映されました

Solution リロードで無事に反映されたことを確認できました。

まとめ

カッとなって書き始めたら結構な長文記事になってしまいましたが、いかがでしたでしょうか?

そのうち Rider 側が修正されたり、そもそも私の環境依存の問題だったりする可能性も否めませんが、External Annotations とかは結構使いどころのある機能かな?とも思うので機会があれば色々試してみたいです。

この記事が役に立った!と言う方は ↓ にある ♥ マークを押してもらえると筆者が泣いて喜びますw

脚注
  1. 本稿執筆時点で 2024.1.1 ↩︎

  2. 厳密に言えば、「value 引数が null な場合、このメソッドの呼び出し以降、例外がキャッチされるまでの間のコードは実行されない」ですかね? ↩︎

  3. 「この方法で行けるよ!」とかあったらコメント欄とかで教えてください。 ↩︎

  4. まぁ、型名変えろやって話なんですが、おまけの話をしたかったので敢えて Debug にしていましたw ↩︎

  5. 他にも第二引数に string, object, UnityEngine.Object を受け取るオーバーロードがあります。 ↩︎

  6. 公式ドキュメントには ExternalAnnotations/UnityEngine.CoreModule.ExternalAnnotations.xml でも良いよ的なことが書かれていたんですが、私の環境ではこれだとうまく発動してくれませんでした。 ↩︎

Discussion