すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

【C#】え、Generic Interfaceでメソッド引数を設定すれば構造体のBoxingを回避できるの?

どうも、最近GC Allocにおびえているすぎしーです。
今日はUnityじゃなくてC#がメインの話題です。

概要

今回はGeneric Interfaceと値型のBoxing回避についてお話します!
Boxingは余計なGC Allocが発生してしまいますが、回避が難しいパターンも存在します。

ただ、一見変わった方法でそのBoxingを回避する方法があったので紹介したいと思います!

Constraints on type parameters について

要するにGenericで指定される型に制約を付けられるC#の機能のことです。

docs.microsoft.com

C#では、where句を使うことでGenericの型に制約を付けることができます。

例:structで制約を付けた場合

例えば、以下のようにwhere T : structとつけると、Tの指定をstruct(構造体)のみに制限させることができます。

public class GenericData<T> where T : struct
{
    T Value { get; }
}

以下のようなコードで確認することができます。

// エラーなし
GenericData<StructSample> structData;

public struct StructSample
{
}
// エラー: The type 'ClassSample' must be a non-nullable value type in order to use it as parameter 'T' in the generic type or method 'GenericData<T>'
GenericData<ClassSample> classData;

public class ClassSample
{
}
// エラー: The type 'IInterfaceSample' must be a non-nullable value type in order to use it as parameter 'T' in the generic type or method 'GenericData<T>'
GenericData<IInterfaceSample> interfaceData

public interface IInterfaceSample
{
}

interfaceを継承した構造体のboxing

C#の構造体はinterfaceを継承することができます。

// ILoggerを継承した構造体
public struct Data : ILogger
{
    public int value;

    public void Log()
    {
        Debug.Log("I am `Data`");
    }
}

public interface ILogger
{
    void Log();
}

さて、このinterfaceを継承した構造体ですが意図しないBoxingがあっさり発生します。。。

var data = new Data { value = 0, };

// ILogger型の変数に構造体Dataを渡す
ILogger logger = data;

intefaceを引数としたメソッドに渡した場合のBoxing

先述したBoxingですが、引数の型がinterfaceのメソッドに引数として渡しても発生します。

var data = new Data { value = 0 };

Func(data);  // 引数として渡す

// 引数の型がinterfaceのメソッド
public void Func(ILogger logger)
{
    // 何もしない
}

なぜBoxingが発生したのか

そもそも「Boxingは参照型の変数に値型のデータを渡すことで発生」します。

var data = new Data { value = 0 };

// 参照型変数のloggerに値型を渡すためBoxingが発生
ILogger logger = data

interfaceの変数もclass, delegate同様参照型として扱われます。

docs.microsoft.com

interfaceを継承している構造体であって、参照型にするにはBoxingを行って参照型に変換してやる必要があります。

※「なぜBoxingが必要なのか」を後述しています。

Generic Interfaceによる構造体のBoxingの回避

さて、いよいよ本題です!

先述の「intefaceを引数としたメソッドに渡した場合のBoxing」に限っては回避方法があります!
それはFunc(ILogger logger)メソッドを以下のように書き換えるだけです。

public void Func<T>(T logger) where T : ILogger
{
    // 何もしない
}

ポイントは where T : ILogger の部分で"Generic Interface"と呼ばれるものです。

Generic Interfaceとは

Generic InterfaceはGenericの制約にinterfaceを指定したもののことです。

docs.microsoft.com

上記参考ページでも以下のような記述があり、Generic Interfaceを使うことでBoxingの回避が可能なことがわかります。

The preference for generic classes is to use generic interfaces, such as IComparable<T> rather than IComparable, in order to avoid boxing and unboxing operations on value types.

なぜBoxingが回避されたのか

Generic型の引数は渡されたデータの型になります。

where T : ILoggerとinterfaceの制約が付いたとしてもFunc<T>(T logger)Tはあくまで引数の型、つまり構造体Dataになります。

つまり、「引数Tが値型となり、参照型への変換がそもそも不要のため、Boxingも発生しなかった」ということになります。

逆にFunc(ILogger logger)の場合は引数は参照型で固定のため、値型を渡してしまうとBoxingが発生してしまったということだったんですね。

Generic InterfaceでのBoxing回避はメソッド限定 

この回避方法はメソッドのみ限定で可能です。
後述していますが「メソッドのコールスタック」と「メソッドの引数」は基本的にスタック領域に置かれます。

Generic型に値型を指定した場合、Tは参照型に変換されることなくそのままスタック領域の格納され、 メソッドからはその領域のデータにアクセスされます。
メソッドもその引数もスタック領域に置かれているため、実現できた回避方法と言えそうです。

余談: OpCodes.Constrained Field

さらに細かく話すとGeneric Interfaceの場合、以下の特殊なオペコードが使われるようになりスタック領域のデータが参照されるようです。
気になる方はどうぞ~。

docs.microsoft.com stackoverflow.com

余談:なぜBoxingが必要なのか

Boxingは値型のデータを参照型として扱うために必要な機能と言えます(意図しないBoxingの場合は別ですが)。

参照型(objectなど)と値型(struct)の大きな違いとして、その「データを格納する領域」に違いがあります。

データの格納場所
値型(Value Type) スタック領域(一時領域)、ヒープ領域(永続領域)の両方
参照型(Reference Type) 基本的にヒープ領域(永続領域)のみ
関数のコールタック(余談) スタック領域(一時領域)

スタック領域は一時的、ヒープ領域は永続的にデータを保持します。
そして、Boxingは「一時的なスタック領域にある値型」を「ヒープ領域に移して参照型にする」ために行います。

コードとイメージは以下のような感じです。

// ヒープ領域に確保
var loggerHolder = new LoggerHolder();

// ローカル変数のためスタック領域
var data = new Data() { a = 1f, b = 2f, c = 3f, d = 4f, e = 5f };

// dataのBoxingを行い、ヒープ領域確保、データをコピーして永続化
loggerHolder.logger = data

f:id:tsgcpp:20200822142943j:plain

参照型はいつ参照しても期待するデータにアクセスできないといけません。
interfaceの変数は参照型のため、構造体が対象のinterfaceを継承していたとしても必ずヒープ領域に持っていって参照型にしておく必要があります。

これがBoxingが発生する主理由の1つと言えます。 (他にもスタック領域は急に領域を確保できないからヒープ領域を使うなど有りますが、細かくは割愛しますw)。

参考: Boxing and Unboxing

雑感

1つのGC Allocの回避を紹介するのに、ずいぶんと長文になってしまいました。。。

記事を1週間以上投稿しなかったのは、夏バテが原因だった気がします(言い訳)。

まあ、使命感でやってるわけではないので気長にやっていきます。

皆さんのC#の知識向上につながれば幸いですー。 それでは~。