C#
.NET
Roslyn
C#7.1

Pattern-Matching with Generics は必要なのか?

はじめに

パターンマッチングの補完機能として C# 7.1 で追加された pattern-matching with generics

始めは「型パラメーターの変数もマッチングできた方が自然だよね」と思いましたが、そもそもこのジェネリックでのパターンマッチング、どこで使うのでしょうか?
ユースケースは様々で正解はないと思いますが、個人的にはコードの複雑度を上げるだけと考えるのでお勧めしません。

この記事では機能追加のあらましとお勧めしない理由を記します。

なぜ機能は追加されたのか?

csharp-7.1/generics-pattern-match.md には次のように書かれています。

Motivation

Cases where pattern-matching should "obviously" be permitted currently fail to compile. See, for example, https://github.com/dotnet/roslyn/issues/16195.

「"明らかに" 許可されるべきケース」とありますが、その根拠はここにはありません。
続けて例示されている dotnet/roslyn#16195 を読んでみましょう。

Roslyn is recommending pattern matching for the following code with IDE0019. Applying the code fix to the following code results in the compilation error above.

var keepalive = packet as KeepalivePacket;
if (keepalive != null)
{
    // Do stuff with keepalive
}

上記のコードパターンの際に、Roslyn はパターンマッチングの使用を推奨(IDE0019)するようになっていますが、実際に適用すると C# 7.0 ではコンパイルエラーになります。これが機能追加の理由のようです。

IDE0019適用後
if (packet is KeepalivePacket keepalive) {  // compile error
    // Do stuff with keepalive
}

ところで issue は以下のように続いています。

Does IDE0019 need to be updated to not apply when the variable/parameter is generic?

このようなケースでの Roslyn によるパターンマッチングの推奨を止める事も一案として問いかけています。
この対応でも良かったと思うのですが、IDE0019 の件はこれ以降では触れられていません。なんでや。

この機能の pull-request dotnet/roslyn#18784 に下記がコメントされています。

@jaredpar Please look at this and determine if it is suitable for 15.3. A number of customers have complained, and the fix is small.

ジェネリック対応は、多くの顧客が望んだ結果なんですね(ニッコリ

原因は何なのか?

コンパイラは type pattern の検証時、オペランドとの間に変換が無い場合に ERR_PatternWrongType としていました。しかしオペランドと type pattern の何れかががオープン型1の場合には変換が存在しないので、このケースに当たってしまうという訳です。

この動作はプログラムのバグではなく仕様漏れに相当するので、仕様に追記する形で修正されました。
csharplang/proposals/csharp-7.1/generics-pattern-match.md > Detailed design

Detailed design

We change the paragraph in the pattern-matching specification (the proposed addition is shown in bold):

Certain combinations of static type of the left-hand-side and the given type are considered incompatible and result in compile-time error. A value of static type E is said to be pattern compatible with the type T if there exists an identity conversion, an implicit reference conversion, a boxing conversion, an explicit reference conversion, or an unboxing conversion from E to T, or if either E or T is an open type. It is a compile-time error if an expression of type E is not pattern compatible with the type in a type pattern that it is matched with.

オリジナルのパターンマッチングの仕様(proposed)はこちらになります。
csharplang/proposals/patterns.md > Type Pattern

具体的な修正コードに興味がある方は pull-request dotnet/roslyn#18784 を参照してみて下さい。

回避策は無いのか?

Workarounds, if any
Cast the expression being matched to object.

やや冗長な記述になりますが、dotnet/roslyn#18784 にあるように一旦 object にキャストする事で回避できます。

// ここでは Packet にキャスト
if ((Packet)packet is KeepalivePacket keepalive) {
    // Do stuff with keepalive
}

この「一旦 object にキャスト」はジェネリックで良く見るパターンですね。

string ToString<T>(T value) {
    if(value is string) {
        //return (string)value;    // compile error
        return (string)(object)value;
    }
    ...
}

これが仕様でしたら、この issue も「仕様です☆」で返して良かったと思います。

非ジェネリックでは駄目なのか?

実は dotnet/roslyn#16195 の例、そもそもジェネリックを使わずともベースクラスや共通インターフェースで引数を受ける事ができ、シンプルです。

dotnet/roslyn#16195
public class Packet {}
public class KeepalivePacket : Packet {}

public void Send<T>(T packet)
    where T : Packet
{
    if (packet is KeepalivePacket keepalive)
    {
        // Do stuff with keepalive
    }

    switch (packet)
    {
        case KeepalivePacket keepalivePacket:
            // Do stuff with keepalivePacket
            break;
    }
}
:arrow_down:
非ジェネリック版
public class Packet {}
public class KeepalivePacket : Packet {}

public void Send(Packet packet)
{
    if (packet is KeepalivePacket keepalive)
    {
        // Do stuff with keepalive
    }

    switch (packet)
    {
        case KeepalivePacket keepalivePacket:
            // Do stuff with keepalivePacket
            break;
    }
}

ジェネリックを使う理由の一つに boxing を回避する事が挙げられますので、その為にジェネリックを採用したいという動機もあるかも知れません。
一方で、パターンマッチングは isinst 命令で行われている為、比較対象のオブジェクト参照を必要とします。それゆえ、型引数に値型を渡したとしても必ず boxing が生じてしまいます。

ジェネリックの型パラメーターを使用した例
public void Display<T>(T value) {
    switch (value) {
        case int n:
            break;
    }
}
:arrow_down:
IL
.method public hidebysig instance void  Display<T>(!!T 'value') cil managed
{
  // コード サイズ       41 (0x29)
  .maxstack  2
  .locals init (!!T V_0,
           int32 V_1,
           object V_2)
  IL_0000:  ldarg.1
  IL_0001:  stloc.0
  IL_0002:  ldloc.0
  IL_0003:  box        !!T
  IL_0008:  brfalse.s  IL_0028
  IL_000a:  ldloc.0
  IL_000b:  box        !!T
  IL_0010:  stloc.2
  IL_0011:  ldloc.2
  IL_0012:  isinst     [System.Runtime]System.Int32
  IL_0017:  ldnull
  IL_0018:  cgt.un
  IL_001a:  dup
  IL_001b:  brtrue.s   IL_0020
  IL_001d:  ldc.i4.0
  IL_001e:  br.s       IL_0026
  IL_0020:  ldloc.2
  IL_0021:  unbox.any  [System.Runtime]System.Int32
  IL_0026:  stloc.1
  IL_0027:  pop
  IL_0028:  ret
} // end of method MyClass::Display

ちなみに、インターフェースを使用した例(非ジェネリック)では、引数が既に参照型のため、boxing は発生していません(int への変換のため unboxing は発生していますけど)。

インターフェースを使用した例
public void Display(IFormattable value) {
    switch (value) {
        case int n:
            break;
    }
}
:arrow_down:
IL
.method public hidebysig instance void  Display(class [System.Runtime]System.IFormattable 'value') cil managed
{
  // コード サイズ       31 (0x1f)
  .maxstack  2
  .locals init (class [System.Runtime]System.IFormattable V_0,
           int32 V_1,
           object V_2)
  IL_0000:  ldarg.1
  IL_0001:  stloc.0
  IL_0002:  ldloc.0
  IL_0003:  brfalse.s  IL_001e
  IL_0005:  ldloc.0
  IL_0006:  stloc.2
  IL_0007:  ldloc.2
  IL_0008:  isinst     [System.Runtime]System.Int32
  IL_000d:  ldnull
  IL_000e:  cgt.un
  IL_0010:  dup
  IL_0011:  brtrue.s   IL_0016
  IL_0013:  ldc.i4.0
  IL_0014:  br.s       IL_001c
  IL_0016:  ldloc.2
  IL_0017:  unbox.any  [System.Runtime]System.Int32
  IL_001c:  stloc.1
  IL_001d:  pop
  IL_001e:  ret
} // end of method MyClass::Display

実引数が値型の場合には、後者の非ジェネリック版の方が速かったりします(参照型の場合は同程度)。
簡単なパフォーマンス比較も行いましたので参考にしてみて下さい。
pattern-matching におけるジェネリックと非ジェネリックのパフォーマンス比較 - GitHubGist

ジェネリック対応は不要なのか?

上記のようにジェネリック無しで書ける事が殆どだと思いますが、例えば dotnet/roslyn#16993 のようなケースでは pattern-matching with generics を用いても良いかも知れません。

namespace NS1
{
    public interface ITest<T> {}

    internal class Test1 : ITest<int> { }
    internal sealed class Test2 : ITest<int> { }

    internal class C<T>
    {
        public void F(ITest<T> x)
        {
            if (x is Test1 z) { }
            if (x is Test2 y) { }
        }
    }
}

(中略)
Actual Behavior:
The if (x is Test2 y) { } line results in the error:
CS8121 An expression of type ITest<T> cannot be handled by a pattern of type Test2

Test1Test2 の違いは sealed 修飾子の有無だけです。これが命運を分けます。

簡単に説明しますと、ITest<T>Test1 との間には参照変換があります2が、ITest<T>Test2 には変換が存在しません。その為 C# 7.0 では if (x is Test2 y) { } はコンパイルエラーとなってしまいます。

これは判り難い!

pattern-matching with generics があれば、オペランド又は type pattern がオープン型であれば変換が無くても if (x is Test2 y) { } は期待通りに実行できます。

……でも、

F() の引数を ITest<int> にしたり、または非ジェネリックな ITest インターフェースを用意すれば良いと思うのですけどね。
(そもそも例にあるクラス設計について、実用的にこのようになる事ってあるのでしょうか?)

総括

ジェネリックでパターンマッチングを行うと、

  • 型引数が値型の場合に boxing が発生し、パフォーマンスに影響が出る。
  • コードの複雑度が増す(非ジェネリックの方が理解し易く、コードも短い)。

以上から、pattern-matching with generics の使用は個人的にはお勧めしません。代わりに、ジェネリックを利用しない方法を考慮してみて下さい。どうしても他にスマートな解決手段が見つからない場合に仕方なく採る選択肢、という消極的な位置づけが良いと思います。

もし他に、もっと pattern-matching with generics の有用なケースをご存知の方がいらっしゃいましたら、コメントでお知らせ頂けますと幸いです。

See also

Related Posts


  1. 大雑把に言えば、型パラメーターを包含している型。e.g. IEnumerable<T>。オープン型以外は全てクローズ型。型引数が与えられた IEnumerable<int> はクローズ型。Types | Microsoft Docs > Open and closed types 

  2. 「From any interface_type S to any class_type T, provided T is not sealed or provided T implements S.」Conversions | Microsoft Docs > Explicit reference conversions