本文へスキップ

勇者.cpp開発日記 #1: asmdef配下ではcsc.rspが効かない

6 分で読めます

最近はコアロジックと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.rspasmdefファイルと同じ階層に置く必要がある。

Assets/csc.rspが効く範囲と、asmdefごとに隣へ置いたcsc.rsp

なので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行の変更でも、それが何を保証しているのかは別に書く価値がある。