C++のassertの挙動をC#で実現する方法

289 Views

March 28, 25

スライド概要

2025/03/06に開催されたイベント、「ぷちTechCon for Unity」 で発表したスライドです。
イベント概要:https://dena.connpass.com/event/339748/

C/C++の伝統的な関数風マクロによるassertは、実はUnity(C#)のassertよりも優れている点があります。
今回の発表では、その優れている点の紹介と、それらをUnityで実現するためのちょっとしたテクニックを紹介します。

profile-image

DeNA が社会の技術向上に貢献するため、業務で得た知見を積極的に外部に発信する、DeNA 公式のアカウントです。DeNA エンジニアの登壇資料をお届けします。

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

(ダウンロード不可)

関連スライド

各ページのテキスト
1.

ぷちTechCon for Unity C/C++のassertの挙動を C#で実現する方法 © DeNA Co., Ltd. 1

2.

自己紹介 小松 拓馬 | 株式会社ディー・エヌ・エー ゲームサービス事業本部開発運営統括部 第一技術部テクノロジー推進第四グループ 2018年にDeNA中途入社。 泥臭いバグ調査を好む中年男性。 普段は横断部門でテックリードをしている。 © DeNA Co., Ltd. 22

3.

assertとは なんぞや © DeNA Co., Ltd. 3

4.

assertとは ● assert(アサート)は「表明する」「断言する」「主張する」などの意味を持 つ動詞 ● プログラミングでは、コードの実行時に想定通りの状態であることを検証す るための関数名やステートメントに使われることが多い ○ 名詞のassertion(アサーション)はその機能や概念に使われることが多い © DeNA Co., Ltd. 4

5.

assertとは ● 一般的なassert ○ 例えば、assert(x > 0) のように書くと、xが0以下のときにエラーや例外 を発生させたり、プログラムを中断したりする ○ assertはバグを早期に発見したり、間違ったデータが処理されたりする のを防ぐために利用する ○ assertは開発時のみ有効化し、製品では無効化(ストリップ)すること が多い © DeNA Co., Ltd. 5

6.

assertとは ● 一般的なassert ○ 例えば、assert(x > 0) のように書くと、xが0以下のときにエラーや例外 を発生させたり、プログラムを中断したりする ○ assertはバグを早期に発見したり、間違ったデータが処理されたりする のを防ぐために利用する ○ assertは開発時のみ有効化し、製品では無効化(ストリップ)すること が多い →製品で発生しうるエラー処理にassertを使うのはダメ絶対! © DeNA Co., Ltd. 6

7.

assertとは ● Unityで利用する主なアサーション ○ UnityEngine.Debug.Assertメソッド ■ 条件がfalseならエラーになるシンプルなAPI ○ UnityEngine.Assertions.Assertクラス(Unity 5.1以降) ■ Nullチェックなどの用途に応じた様々なメソッドが提供される ○ NUnit.Framework.Assertクラス ■ Unity Test Framework(Test Runner)で利用する ■ ※ゲームコードでは利用しない © DeNA Co., Ltd. 7

8.

C++のassertの特徴 © DeNA Co., Ltd. 8

9.

C++のassertの特徴 ● よく使われる「ログ出力付きassert」をUnityとUnreal Engineで比較してみる ○ Unity / C# ■ ○ UnityEngine.Assertions.Assert.IsTrue(bool condition, string message) Unreal Engine / C++ ■ checkf(expr, format, …) ※各ご家庭で実装しているassertもだいたい同じような実装になっているはず © DeNA Co., Ltd. 9

10.

assertの比較 Unity / C# // a == bでなかったらaとbの内容をログに出力する Assert.IsTrue(a == b, $"a = {a}, b = {b}"); © DeNA Co., Ltd. 10

11.

assertの比較 Unity / C# // a == bでなかったらaとbの内容をログに出力する Assert.IsTrue(a == b, $"a = {a}, b = {b}"); © DeNA Co., Ltd. Unreal Engine / C++ // a == bでなかったらaとbの内容をログに出力する checkf(a == b, TEXT("a = %d, b = %d"), a, b); 11

12.
[beta]
assertの比較(出力される情報の違い)
Unity / C#

Unreal Engine / C++

// a == bでなかったらaとbの内容をログに出力する
Assert.IsTrue(a == b, $"a = {a}, b = {b}");

// a == bでなかったらaとbの内容をログに出力する
checkf(a == b, TEXT("a = %d, b = %d"), a, b);

—----------------------------------------------[出力]
AssertionException:
a = 0, b = 1
Assertion failure. Value was False
Expected: True
…
クラス名.メソッド名 () (at ファイル名:行番号)

—----------------------------------------------[出力]
Assertion failed: a == b
[File:ファイル名] [Line: 行番号]
a = 0, b = 1

© DeNA Co., Ltd.

12

13.
[beta]
assertの比較(出力される情報の違い)
Unity / C#

Unreal Engine / C++

// a == bでなかったらaとbの内容をログに出力する
Assert.IsTrue(a == b, $"a = {a}, b = {b}");

// a == bでなかったらaとbの内容をログに出力する
checkf(a == b, TEXT("a = %d, b = %d"), a, b);

—----------------------------------------------[出力]
AssertionException:
a = 0, b = 1
Assertion failure. Value was False
Expected: True
…
クラス名.メソッド名 () (at ファイル名:行番号)

—----------------------------------------------[出力]
Assertion failed: a == b
[File:ファイル名] [Line: 行番号]
a = 0, b = 1

青:共通の情報
赤:一方にはない情報

© DeNA Co., Ltd.

13

14.
[beta]
assertの比較(出力される情報の違い)
Unity / C#

Unreal Engine / C++

// a == bでなかったらaとbの内容をログに出力する
Assert.IsTrue(a == b, $"a = {a}, b = {b}");

// a == bでなかったらaとbの内容をログに出力する
checkf(a == b, TEXT("a = %d, b = %d"), a, b);

—----------------------------------------------[出力]
AssertionException:
a = 0, b = 1
Assertion failure. Value was False
Expected: True
…
クラス名.メソッド名 () (at ファイル名:行番号)

—----------------------------------------------[出力]
Assertion failed: a == b
[File:ファイル名] [Line: 行番号]
a = 0, b = 1

どのクラスのどのメソッドから呼び出した
のかわかるので、ちょっと嬉しい。

© DeNA Co., Ltd.

14

15.
[beta]
assertの比較(出力される情報の違い)
Unity / C#

Unreal Engine / C++

// a == bでなかったらaとbの内容をログに出力する
Assert.IsTrue(a == b, $"a = {a}, b = {b}");

// a == bでなかったらaとbの内容をログに出力する
checkf(a == b, TEXT("a = %d, b = %d"), a, b);

—----------------------------------------------[出力]
AssertionException:
a = 0, b = 1
Assertion failure. Value was False
Expected: True
…
クラス名.メソッド名 () (at ファイル名:行番号)

—----------------------------------------------[出力]
Assertion failed: a == b
[File:ファイル名] [Line: 行番号]
a = 0, b = 1

© DeNA Co., Ltd.

どのような式を書いたのかがわかるので

めちゃくちゃ嬉しい!

15

16.

assertの比較(処理の流れの違い) Unity / C# // a == bでなかったらaとbの内容をログに出力する Assert.IsTrue(a == b, $"a = {a}, b = {b}"); © DeNA Co., Ltd. Unreal Engine / C++ // a == bでなかったらaとbの内容をログに出力する checkf(a == b, TEXT("a = %d, b = %d"), a, b); 16

17.

assertの比較(処理の流れの違い) Unity / C# // a == bでなかったらaとbの内容をログに出力する Assert.IsTrue(a == b, $"a = {a}, b = {b}"); 展開 © DeNA Co., Ltd. Unreal Engine / C++ // a == bでなかったらaとbの内容をログに出力する checkf(a == b, TEXT("a = %d, b = %d"), a, b); 展開 17

18.
[beta]
assertの比較(処理の流れの違い)
Unity / C#

Unreal Engine / C++

// 引数は事前に評価されてIsTrueメソッドに渡される
bool condition = a == b;
var s = string.Format("a = {0}, b = {1}", a, b);

// 一般的なC++のassertはマクロ風関数で実装されており、
// コンパイル時に以下のような(C#風疑似)コードに展開される
if (!(a == b))
{
string s = string.Format(
"a = {0}, b = {1}", a, b);
Assert.Fail(“a == b”, s, “ファイル名”, 行番号);
}

Assert.IsTrue(condition, s);

© DeNA Co., Ltd.

18

19.
[beta]
assertの比較(処理の流れの違い)
Unity / C#

Unreal Engine / C++

// 引数は事前に評価されてIsTrueメソッドに渡される
bool condition = a == b;
var s = string.Format("a = {0}, b = {1}", a, b);

// 一般的なC++のassertはマクロ風関数で実装されており、
// コンパイル時に以下のような(C#風疑似)コードに展開される
if (!(a == b))
{
string s = string.Format(
"a = {0}, b = {1}", a, b);
Assert.Fail(“a == b”, s, “ファイル名”, 行番号);
}

Assert.IsTrue(condition, s);

条件に依らず常に文字列処理が走る
=GCが発生するため、開発時のみとはいえ
高頻度に呼び出される場合は気を使う必要
がある(めんどい…)

© DeNA Co., Ltd.

19

20.
[beta]
assertの比較(処理の流れの違い)
Unity / C#

Unreal Engine / C++

// 引数は事前に評価されてIsTrueメソッドに渡される
bool condition = a == b;
var s = string.Format("a = {0}, b = {1}", a, b);

// 一般的なC++のassertはマクロ風関数で実装されており、
// コンパイル時に以下のような(C#風疑似)コードに展開される
if (!(a == b))
{
string s = string.Format(
"a = {0}, b = {1}", a, b);
Assert.Fail(“a == b”, s, “ファイル名”, 行番号);
}

Assert.IsTrue(condition, s);

一方、C++ではエラー発生時のみ文字列処
理が行われるため、高頻度に呼び出されて
も気を使う必要はない(最 of 高!)

© DeNA Co., Ltd.

20

21.

assertの比較(ストリップルールの違い) Unity / C# Unreal Engine / C++ // UNITY_ASSERTIONSが定義されていない 場合は // ストリップされる // UE_BUILD_SHIPPINGが定義されている 場合は // ストリップされる※ // ※イメージ // ※イメージ #if UNITY_ASSERTIONS #if UE_BUILD_SHIPPING Assert.IsTrue(condition, message); // コードが削除される // (※厳密にはコンパイラにヒントを与える処理が残る) #else // コードが削除される #endif #else checkf(a == b, TEXT("a = %d, b = %d"), a, b); #endif // ※実際のストリップルールはもっと複雑です © DeNA Co., Ltd. 21

22.

C++のassertの特徴(まとめ) ● 引数に渡した条件式をそのまま文字列としてログに出力する ● 文字列処理はアサーションが失敗したときのみ実行される ● なにかが#defineされた場合にストリップされる © DeNA Co., Ltd. 22

23.

C/C++のassertの挙動を C#で実現する方法 本編 © DeNA Co., Ltd. 23

24.

引数に渡した条件式をそのまま文字列としてログに出力する ● C#10で追加されたCallerArgumentExpressionAttributeを使い、引数に渡した 式をそのまま文字列として別の引数で受け取ることで実現可能 © DeNA Co., Ltd. 24

25.

引数に渡した条件式をそのまま文字列としてログに出力する ● C#10で追加されたCallerArgumentExpressionAttributeを使い、引数に渡した 式をそのまま文字列として別の引数で受け取ることで実現可能 しかし、Unityでは通常C#9までしか使えないため、まずはUnityでC#10を使 える状態にする必要がある © DeNA Co., Ltd. 25

26.

UnityでC#10を使えるようにする ● Unityが公式にサポートしているC#のバージョンは9だが、Unity 2022.3の時点 でC#コンパイラ自体はC#10(2022.3.12からC#11)に対応している(非公式) © DeNA Co., Ltd. 26

27.

UnityでC#10を使えるようにする ● 最も簡単な方法 ○ ProjectSettings→Player→Additional Compiler Arguments に-langVersion:10を追加する ※asmdef単位で設定したいときはcsc.rspに同オプションを追加する © DeNA Co., Ltd. 27

28.
[beta]
UnityでC#10を使えるようにする
●

csprojのLangVersionを強引に上げる(※必須ではない)

using System.Text.RegularExpressions;
using UnityEditor;
public class CSharpVersionUpper : AssetPostprocessor
{
private static readonly Regex s_langVersionPattern =
new(@"<LangVersion>(.*?)</LangVersion>", RegexOptions.Compiled);

}
© DeNA Co., Ltd.

private static string OnGeneratedCSProject(string path, string content)
{
const string replacement = "<LangVersion>10</LangVersion>";
return s_langVersionPattern.Replace(content, replacement);
}

28

29.

引数に渡した条件式をそのまま文字列としてログに出力する ● CallerArgumentExpressionAttributeはUnityでは定義されていないので、独自 に定義する // C#10なのでファイルスコープ名前空間が使える namespace System.Runtime.CompilerServices; [AttributeUsage(AttributeTargets.Parameter)] public sealed class CallerArgumentExpressionAttribute : Attribute { public CallerArgumentExpressionAttribute(string parameterName) { ParameterName = parameterName; } } © DeNA Co., Ltd. public string ParameterName { get; } 29

30.

文字列処理はアサーションが失敗したときのみ実行される ● C#10で追加された文字列補間ハンドラー(interpolated string handler)を 用いて、文字列補間を処理するかどうかの条件を与えることで実現できる © DeNA Co., Ltd. 30

31.

文字列処理はアサーションが失敗したときのみ実行される ● 文字列補間ハンドラーに必要な属性はUnityでは定義されていないので、独自 に定義する namespace System.Runtime.CompilerServices; [AttributeUsage( AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] public sealed class InterpolatedStringHandlerAttribute : Attribute { } © DeNA Co., Ltd. 31

32.

文字列処理はアサーションが失敗したときのみ実行される [AttributeUsage(AttributeTargets.Parameter)] public sealed class InterpolatedStringHandlerArgumentAttribute : Attribute { public InterpolatedStringHandlerArgumentAttribute(string argument) { Arguments = new[] { argument }; } public InterpolatedStringHandlerArgumentAttribute(params string[] arguments) { Arguments = arguments; } } public string[] Arguments { get; } © DeNA Co., Ltd. 32

33.

なにかが#defineされた場合にストリップされる ● ConditionalAttributeだけでは実現できないが、 #ifと絶対に定義されないシンボルを用いることで簡単に実現できる // RELEASE_BUILDが定義されていたらメソッドをストリップする #if RELEASE_BUILD [Conditional(“NEVER_DEFINED_SYMBOL”)] #endif public void DebugOnlyMethod() { } © DeNA Co., Ltd. 33

34.

あとは実装するだけ ● UnityEngine.Assertions.Assert.IsTrue互換のMyAssert.IsTrueを実装してみる © DeNA Co., Ltd. 34

35.
[beta]
// MyAssert.IsTrue用の文字列補間ハンドラー
[InterpolatedStringHandler]
public readonly ref struct MyAssertIsTrueInterpolatedStringHandler
{
private readonly StringBuilder? _builder;
public MyAssertIsTrueInterpolatedStringHandler(
int literalLength, int formattedCount, bool condition, out bool shouldAppend)
{
if (!condition) // コンストラクタに IsTrueの第一引数が渡ってくるので、
{
_builder = new StringBuilder(literalLength);
shouldAppend = true; // conditionがfalseだったら文字列処理を実行する
}
else
{
_builder = default;
shouldAppend = false; // conditionがtrueだったら文字列処理をスキップする
}
}

}

public void AppendLiteral(string s) => _builder!.Append(s);
public void AppendFormatted<T>(T t) => _builder!.Append(t);
internal string GetFormattedText() => _builder!.ToString();

© DeNA Co., Ltd.

35

36.
[beta]
public class MyAssert
{
// RELEASE_BUILDが定義されているか、 UNITY_ASSERTIONSが定義されていない場合にストリップされる
#if RELEASE_BUILD || !UNITY_ASSERTIONS
[System.Diagnostics.Conditional("NEVER_DEFINED_SYMBOL")]
#endif
public static void IsTrue(
// TIPS: falseの場合は処理が戻らないことをコンパイラに伝える
[DoesNotReturnIf(false)] bool condition,
// MyAssertIsTrueInterpolatedStringHandlerのコンストラクタに conditionを渡す指示
[InterpolatedStringHandlerArgument("condition")]
ref MyAssertIsTrueInterpolatedStringHandler message,

{

}

}

© DeNA Co., Ltd.

// conditionに渡された式を取得する指示
[CallerArgumentExpression("condition")]
string? conditionExpr = null)
if (!condition)
{
// 文字列補間の結果を取得
var formattedMessage = message.GetFormattedText();
// ※以下略
}

36

37.

意図した挙動になっているか確認 C# var a = 1; var b = 2; MyAssert.IsTrue(a == b, $"a = {a}, b = {b}"); © DeNA Co., Ltd. 37

38.
[beta]
意図した挙動になっているか確認
C#

C#→IL→C#

var a = 1;
var b = 2;

var a = 1;
var b = 2;

MyAssert.IsTrue(a == b, $"a = {a}, b = {b}");

var condition1 = a == b;
var condition2 = condition1;

コンパイルしてデコンパイル

bool shouldAppend;
var message =
new MyAssertIsTrueInterpolatedStringHandler(
10, 2, condition1, out shouldAppend);
if (shouldAppend) // = !(a == b)
{
message.AppendLiteral("a = ");
message.AppendFormatted(a);
message.AppendLiteral(", b = ");
message.AppendFormatted(b);
}
MyAssert.IsTrue(
condition2, ref message, "a == b");

© DeNA Co., Ltd.

38

39.
[beta]
意図した挙動になっているか確認
C#→IL→C#
●

条件式が文字列としてIsTrueに
渡されている

var a = 1;
var b = 2;
var condition1 = a == b;
var condition2 = condition1;
bool shouldAppend;
var message =
new MyAssertIsTrueInterpolatedStringHandler(
10, 2, condition1, out shouldAppend);
if (shouldAppend) // = !(a == b)
{
message.AppendLiteral("a = ");
message.AppendFormatted(a);
message.AppendLiteral(", b = ");
message.AppendFormatted(b);
}
MyAssert.IsTrue(
condition2, ref message, "a == b");

© DeNA Co., Ltd.

39

40.
[beta]
意図した挙動になっているか確認
C#→IL→C#
●

a == bのときは文字列処理が
スキップされている

var a = 1;
var b = 2;
var condition1 = a == b;
var condition2 = condition1;
bool shouldAppend;
var message =
new MyAssertIsTrueInterpolatedStringHandler(
10, 2, condition1, out shouldAppend);
if (shouldAppend) // = !(a == b)
{
message.AppendLiteral("a = ");
message.AppendFormatted(a);
message.AppendLiteral(", b = ");
message.AppendFormatted(b);
}
MyAssert.IsTrue(
condition2, ref message, "a == b");

© DeNA Co., Ltd.

40

41.

意図した挙動になっているか確認 C# C#→IL→C# var a = 1; var b = 2; MyAssert.IsTrue(a == b, $"a = {a}, b = {b}"); RELEASE_BUILDを定義してから コンパイルしてデコンパイル © DeNA Co., Ltd. 41

42.

意図した挙動になっているか確認 C# C#→IL→C# var a = 1; var b = 2; MyAssert.IsTrue(a == b, $"a = {a}, b = {b}"); RELEASE_BUILDを定義してから コンパイルしてデコンパイル © DeNA Co., Ltd. 無 42

43.

でぎだーっ!! ※まとめは時間の都合で省略 © DeNA Co., Ltd. 43

44.

© DeNA Co., Ltd. 44