勇者.cpp開発日記 #1: asmdef配下ではcsc.rspが効かない
最近はコアロジックとUnity側の二層構成でコード品質の底上げをしていて、これはその途中で見つかった穴の話。C#で書いているゲームプロジェクトで、Unity非依存のコアライブラリ側(core/)はnullable注釈コンテキストが有効なのに、Unity側(game/)は無効になっていた。?もnull!も全部ただの飾りとして通っていて、リポジトリ内で検査の強さが2段階になっていた。
有効化しようとして最初に踏んだのがタイトルの落とし穴。ここと、有効化後に出た129件の警告をどう畳んだかを書く。
症状: CS8632が482件
nullable注釈コンテキストが無効なアセンブリでstring?のような注釈を書くと、コンパイラはCS8632を出す。
warning CS8632: '#nullable' 注釈コンテキスト内以外では、
Null 許容参照型の注釈を使用しないでください。
これが482件。つまり482箇所で「nullかもしれない/絶対にnullでない」を宣言していたのに、コンパイラはその宣言を1つも検査していなかった。書いた側は守っているつもりでいる、という一番まずい状態になっていた。
落とし穴: Assets/csc.rspはasmdef配下に効かない
Unityでコンパイラオプションを渡す方法はcsc.rspで、Assets/csc.rspを置けばプロジェクト全体に効く——と思っていたが、これはasmdefが無いアセンブリ(Assembly-CSharp)にしか効かない。asmdefを切ったアセンブリには適用されない。
csc.rspはasmdefファイルと同じ階層に置く必要がある。

なのでasmdef 3本それぞれの隣に置いた。中身は1行だけ。
-nullable:enable
asmdefを新設したらcsc.rspも併せて置く、を規約として書いておかないと、そのアセンブリだけ検査が緩んだまま増えていく。ここが暗黙のままだったのが482件の出どころだった。
Unityを起動せずに同じ検査を回す
このプロジェクトはコンパイル検査をCLIで回している(tools/roslyn-check.sh)。Unityが吐いたcsprojを雛形にしてビルドする作りなので、こちら側にも<Nullable>enable</Nullable>を注入しないと、エディタとCLIで検査の強さがずれる。
# nullable 注釈コンテキストを有効にする
# (Unity 側は asmdef と同じ階層の csc.rsp が同じ役割を担う)
text = re.sub(r"</LangVersion>",
"</LangVersion>\n <Nullable>enable</Nullable>",
text, count=1)
ついでに、それまで検査対象から漏れていたPlayModeテストのアセンブリも足した。
for name in HeroCppGame HeroCppGame.Editor HeroCppGame.PlayModeTests; do
# Unity が吐いた csproj を雛形にして、Compile Include をワイルドカードへ差し替える
...
done
dotnet build HeroCppGame.Editor.csproj -v minimal -nologo
dotnet build HeroCppGame.PlayModeTests.csproj -v minimal -nologo
つまずき: 有効化した瞬間に129件
CS8632の482件は消えたが、代わりに本物のnull警告が129件出た。内訳はCS8618(non-nullableフィールドが未初期化)、CS8625(non-nullableにnullリテラル)、CS8600/8603/8604(null許容の変換・戻り値・引数)。
ここで!を機械的に付けて黙らせると、有効化する前と同じ「宣言が飾り」の状態に戻る。なので先に書き分けの規約を決めた。
- 必須参照(配線・組み立てで必ず入る)は
null!で宣言する。Awake/Startで1回だけ検証して、駄目ならLogError+enabled = false。以後は疑わない - 任意参照は
?で宣言して?.で触る - 同じフィールドを片方で
!・片方で== nullと扱わない
3つ目が地味に効いた。実際に2つのファイルで、同じフィールドを「Updateでは疑わない・他のメソッドでは== nullで守る」と割れて扱っていた。読む側は「このフィールドはnullになりうるのか」を判断できない。Awakeで必ず作られる側に揃えたので、到達しうる挙動は1つも変わっていない。
規約に沿って寄せると、こうなる。
// 必須参照: 組み立てで必ず入る。初期値は今までと同じ null で IL は不変
[SerializeField] private Transform panelRoot = null!;
// 任意参照: 読む側が null を確かめている
private Session? _session;
void Update()
{
// ここだけ _session! と書いていたのを ?. へ揃えた
_session?.Tick();
}
null!は「初期値としてnullを入れるが、non-nullableとして扱う」という宣言なので、生成されるILは変わらない。注釈を、コードが既にしている判断に合わせるだけの作業になる。
畳み方: エディタ2本 → 残り全部
129件を一度に潰そうとすると、規約が揺れたときに全部やり直しになる。まずエディタ系の2ファイルだけを規約に揃えて、そこでCS8618/8625/8600/8603/8604を0件にした。この時点で全体は66件まで落ちた。
このとき出てきた判断の型がそのまま残りに使えた。
- 読む側が全部nullを確かめている参照 →
? - いったんnullに戻す参照(前回値のキャッシュなど) →
? - 早期returnで自分でnullに戻すシングルトン →
?(実際にnullになりうる) - フォルダ行でnullが入る仕様のフィールド →
? - 組み立てで必ず入るUI参照 →
null!
残り66件も同じ型で寄せて0件になった。特殊だったのは2つ。
遅延生成があるプロパティは、フィールドを?にして公開プロパティ側で!を付けた。全滅時にLogErrorを出して既定値へ落とす経路を持っているので、呼ぶ側は疑わなくていい。
private static TMP_FontAsset? _body;
public static TMP_FontAsset Body
{
get { EnsureCreated(); return _body!; }
}
「無ければLogErrorで知らせて呼び出し側は疑わない」という契約のメソッドは、戻り値に!を付けて意図を明示した。呼び出しが70箇所あるので、ここを?にすると70箇所にnullチェックが増える。契約として「呼び出し側は疑わない」を選んでいるなら、それを型に書くべきだった。
潰さなかったもの
「Startで検証したのにNow => host != null ? ... : 0も残っている」という重複ガードが2箇所あった。冗長に見えるが、潰すと制御フローが変わる。enabled = falseにしたあとでも外部APIから呼ばれうる経路があり、そこで0を返さなくなってしまう。
nullable対応は「警告を0にする」が目的化しやすいが、警告0のために挙動を変えたら本末転倒なので、これは触らずに残した。
検査結果
tools/roslyn-check.sh: 0エラー / null系警告0件(残るのは既存のCS0618が1件)core/側:dotnet testが560件緑
制御フローの書き換えはしていないので、テストは全部そのまま通っている。
学び
CS8632が482件出ていたのに誰も気づいていなかったのは、規約がコンパイラの中にしか無かったから。リポジトリ内に「nullableは有効です」「必須参照はnull!、任意参照は?」と書いた文章が1行も無かった。今回はAGENTS.md(AI向けの開発規約ファイル)に、有効化の場所と書き分けを明記した。
- `core/` と同じく **nullable 注釈コンテキストは有効**。有効化は asmdef と
同じ階層の `csc.rsp`(`-nullable:enable`)なので、**asmdef を新設したら
csc.rsp も併せて置く**(無いとそのアセンブリだけ検査が緩む)
- null の書き分け: **必須参照**(配線・組み立てで必ず入る)は `null!` で宣言し、
`Awake`/`Start` で 1 回だけ検証して `LogError` + `enabled = false`、以後は疑わない。
**任意参照**は `?` で宣言して `?.` で触る。
同じフィールドを片方で `!`・片方で `== null` と扱わない
ビルド設定は「一度通れば誰も見ない」ので、設定の意図を文章で残しておかないと、次にasmdefを切った人が同じ穴を開ける。設定ファイル1行の変更でも、それが何を保証しているのかは別に書く価値がある。