読者です 読者をやめる 読者になる 読者になる

チリペヂィア

リンクフリー。サンプルコードなどは関連記事内でライセンスについて明示されない限り商用利用なども自由に行って構いませんが、自己責任でお願いします。またこれら日記内容の著作権自体は放棄していません。引用部分については引用元の権利に従ってください。

LayoutKind.Explicitで共用体風のSerializable()+BinaryFormatter

小発見!

LayoutKind.Explicitでナンチャッテ共用体をやってもSerializable()+BinaryFormatterとか使えるんですね。

Unityだと、unsafe指定してポインタ処理が出来ないらしいです。すると、WifiとかBluetoothとかPC/スマホなど環境固有の通信ハードウェアの利用を想定すると、なんとか別の方法で可変長バイナリ形式をC#からC/C++に受け渡せるように、またC/C++から渡されたバイナリブロックを解読できるようにしとかないとなー(しかも出来れば、バイナリサイズが大きくなりすぎないように…)で、ちょっと調べてみたらこんなネタにたどり着きました(*1)。

まずLayoutKind.Explicitを指定すると、メンバごとに構造体からのバイナリ記録位置をFieldOffsetAttributeで指定しないといけなくなりますが、バイト単位で自由に設定できるため、そのまんま共用体として設定する事が出来ます。

[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
public struct uniontest {
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public uint union1;
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public byte union20;
	[System.Runtime.InteropServices.FieldOffsetAttribute(1)] public byte union21;
	[System.Runtime.InteropServices.FieldOffsetAttribute(2)] public byte union22;
	[System.Runtime.InteropServices.FieldOffsetAttribute(3)] public byte union23;
}

おお…なつかしの共用体ヨ。共用体なのでunion1とunion20〜23は同じ4バイト領域を使用します。で、これがこのままバイナリ形式にSerializeできます。

public class StructLayoutSerializeTest : MonoBehavior {

	[System.Serializable()]
	[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
	public struct uniontest {
		[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public uint union1;
		[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public byte union20;
		[System.Runtime.InteropServices.FieldOffsetAttribute(1)] public byte union21;
		[System.Runtime.InteropServices.FieldOffsetAttribute(2)] public byte union22;
		[System.Runtime.InteropServices.FieldOffsetAttribute(3)] public byte union23;
	}

	[UnityEngine.ContextMenu("DoTest1")]
	private void DoTest1() {
		
		System.Runtime.Serialization.Formatters.Binary.BinaryFormatter bf = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
		using (System.IO.MemoryStream memoryStream = new System.IO.MemoryStream()) {
			
			uniontest tester1 = new uniontest();
			
			tester1.union1 = 0xAAAAAAAA;
			memoryStream.SetLength(0);
			bf.Serialize (memoryStream, tester1);
			memoryStream.Seek(0, System.IO.SeekOrigin.Begin);
			uniontest tester2 = (uniontest)bf.Deserialize(memoryStream);
			
			tester1.union20 = 0xFF;
			tester1.union21 = 0;
			tester1.union22 = 0;
			tester1.union23 = 0x55;
			memoryStream.SetLength(0);
			bf.Serialize(memoryStream, tester1);
			memoryStream.Seek(0, System.IO.SeekOrigin.Begin);
			uniontest tester3 = (uniontest)bf.Deserialize(memoryStream);
			
			Debug.Log( System.Convert.ToString(tester2.union1,16) + " " +
				System.Convert.ToString(tester3.union1,16) );
		}
	}
}

出力

aaaaaaaa 550000ff

うむ、でございます。しかし…BinaryFormatterはC#ではインスタンス扱いになってる配列とかも、きちんと参照先をたどってシリアライズしちゃうけど、そのへん大丈夫なの?

[System.Serializable()]
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
public struct uniontest {
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public uint union1;
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public byte union20;
	[System.Runtime.InteropServices.FieldOffsetAttribute(1)] public byte union21;
	[System.Runtime.InteropServices.FieldOffsetAttribute(2)] public byte union22;
	[System.Runtime.InteropServices.FieldOffsetAttribute(3)] public byte union23;
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public uint[] union3;
}

これは実行時に例外を吐いて、シリアライズできません。直値とインスタンスでフィールドを共用すると、単に直値をバイトコピーするべきか、参照した先をSerializeするべきか不定になります。例外はGetHashCode()かなんかで吐いたようですが、参照型で仮定して処理を強行してズッコケた…ようにも見えますが、まぁムリはしないで…。

[System.Serializable()]
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
public struct uniontest {
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public uint union1;
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public byte union20;
	[System.Runtime.InteropServices.FieldOffsetAttribute(1)] public byte union21;
	[System.Runtime.InteropServices.FieldOffsetAttribute(2)] public byte union22;
	[System.Runtime.InteropServices.FieldOffsetAttribute(3)] public byte union23;
	[System.Runtime.InteropServices.FieldOffsetAttribute(4)] public uint[] union3;
}

おとなしく、インスタンスの部分と直値の部分で共用しないようにするのがセオリーかと思います。これはもちろん、シリアライズできます。

もう一点、個人的な課題としては、インスタンス型に続いて別の型を並べるケースで、共用しないときのオフセット量をどうしたら良いものか。

[System.Serializable()]
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
public struct uniontest {
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public uint[] field1;
	[System.Runtime.InteropServices.FieldOffsetAttribute(4とするべきか8とするべきか、はたまた…)] public uint field2;
}

インスタンス型のサイズ(参照先ではなく参照ハンドルを記録するのに必要なサイズ)を静的に取得する方法がいまいち不明でした。どうもFieldOffsetAttributeには静的に値を指定しないとダメっぽいのです。System.Runtime.InteropServices.Marshal.SizeOf()でuint[]のインスタンス型サイズを実行時計測すると、Win32で普通に4が返ってきたので、インスタンス記録サイズは単にIntPtr.Sizeあたりと一致すんじゃないのかなーと思っていますが、あんまりにも状況証拠しかないので微妙です(*2)。

そのへん考えると基本的には、参照型は共用体末尾メンバのみ、または従来どおり、直値が共用体になってる部分だけでstructを定義してしまって、他の部分は外側か内側かにLayoutKind.Sequentialでビッチリ並べちゃうのが良さそうです。こんなふうに。

[System.Serializable()]
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
public struct uniontest {
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public uint union1;
	[System.Runtime.InteropServices.FieldOffsetAttribute(0)] public byte union20;
	[System.Runtime.InteropServices.FieldOffsetAttribute(1)] public byte union21;
	[System.Runtime.InteropServices.FieldOffsetAttribute(2)] public byte union22;
	[System.Runtime.InteropServices.FieldOffsetAttribute(3)] public byte union23;
	[System.Runtime.InteropServices.FieldOffsetAttribute(4)] public uint[] union3; // 末尾なら気にならない。
}

[System.Serializable()]
public class someserializableclass {
	public int hoge;
}

[System.Serializable()]
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
public struct serializetest { // LayoutKind.SequentialならFieldOffsetAttributeしなくとも単に順番にピッタリくっつくだけ。
	public uniontest field1;
	public int[] field2;
	public someserializableclass field3;
}

まとめるとunion相当はほぼ再現可能で、独自のファイル形式や通信データ形式のバイナリフォーマットはC/C++的に設計する事が可能なようです。あとはこれを環境依存の通信APIに流してしまえば…って、しかしこのMemoryStreamをバイト列にしてまたMarshal系のメモリ領域にマップ処理して〜とまた面倒ですが、さすがにあまり贅沢は言っていられませんね。おっと、このへんの話を見る限り、Dll扱いにしてしまえば.Netさんによきにはからってもらえそうです。
http://answers.unity3d.com/questions/132083/returning-a-byte-array-from-objc-to-c-script-on-io.html

*1:一応IntPtrでもポインタ演算は出来るんですけど、一度ToIntメソッドで整数化した後、オフセット計算してその整数値からIntPtr構造体をまたnewする、といった面倒な処理をするのが、たかがアドレスのオフセットにそんな…という気がしないでもありません。

*2:例えばどこかのマイナーなC#コンパイラデバッグバージョンコンパイルでは、デバッグ情報としてインスタンスの記録に12バイトを使用するようになってます、とか言われても、そんな仕様はオカシイ!と言い張るほどの材料がないですし