🦔

macroのカジュアル多用は危険 ⚠️

2021/12/20に公開

背景

過去c++やc#、objective-cでmacroがカジュアルに多用してあるプロジェクトを触ってきて、
非常にミスが発生しやすく、ミスに気づきにくく、メンテも地獄だと感じた.

ただきちんと言語化しないと伝わらないなと感じたので、なぜmacroをカジュアルに多用していくと危険なのかを言語化してみる試み.
macro嫌だな、このコード辛いなと思ったときに、誰かのコーディングの考える材料になれば幸い

結論

長いので先に自分の結論.

macro利用はコンパイルチェックをスキップするので、非常に強力な一方、危険な劇薬.
あくまで最後の砦として、極限まで使わないで済む方法を考えよう.

下記ステップでの思考が、macroのリスクが減ってよいなと思っている.

  1. 他の代替手段ではダメか? (利用有無)
  2. 使用するmacro数は最小限にできないか? (数/種類)
  3. macroの適用範囲は局所に絞れないか? (範囲)

理由

macroをカジュアルに多用していくとなぜプロジェクトの安全にミスなく変更できなくなっていくのか、
理由を言語化してみる.

デメリット1. ビルドパターンが増える

例えば、 #PLATFORM_ANDROID, #PLATFORM_IOS, #PLATFORM_EDITOR といったマクロがあるとする.

class PlatformSpecific {
    #if PLATFORM_ANDROID
    public void Execute() {
      //
    }
    #elif PLATFORM_IOS
    public void Execute() {
      //
    }
    #elif PLATFORM_EDITOR
    public void Execute() {
      //
    }
    #endif
}
  1. #define PLATFORM_ANDROID でcompile
  2. #define PLATFORM_IOS でcompile
  3. #define PLATFORM_EDITOR でcompile
  4. nop でcompile

上のようなプラットフォームなどの排他的なフラグであれば、4パターンのビルドをするだけですむ.
ただこれが一般的なフラグであったとすると、各フラグがtrue/falseで 2^3 = 8 パターンのビルドに増える.

build_pattern

ここで問題なのは、これら作られたビルドに対して、実行内容がことなるのでそれぞれに検証が必要となるという点.
フラグが増えるほどにビルドパターンは指数関数的に増加する.
(これがmacroのフラグの種類は最小限に減らした方が良い理由)

これら全てのパターンに対して、定常的にデバックしていくのは極めて困難.
そもそも全てのパターンのビルドを作成すること自体もコストが高く困難になる.
フラグの組み合わせによるコンパイルミスの可能性も容易に増える.

デメリット2. テスト困難

前述の処理を前提に考えるのであれば、UnitTestを完璧にしようとすると、そのmacroのパターンごとにテストが必要になる.

class PlatformSpecificTest {
    #if PLATFORM_ANDROID
    [Test]
    public void ExecuteTest() {
      //
    }
    #elif PLATFORM_IOS
    [Test]
    public void ExecuteTest() {
      //
    }
    #elif PLATFORM_EDITOR
    [Test]
    public void ExecuteTest() {
      //
    }
    #endif
}

だが現実的にはここまでやっているような丁寧なプロジェクトに私は出会ったことがない.
またcompile前提なツールが多いのでカバレッジ測定なんかも正常に機能しないケースが多い.

結果としてUnitTestなり、手動Debugなりで全く検証さえないコードパスがリリースに含まれるリスクが生じてしまう。時にはこれが致命的な結果を産むこともある.

デメリット3. コンパイルチェックが効かなくなる

いっけん問題がなさそうに見えるコード.
(正常な時に見ればすぐわかるかもしれないが、常に注意力が正常だとは限らない)
Unityでswitch platformしたらAndroidでエラーが..なんてことはザラにある..

#if PLATFORM_ANDROID
Debug.Log("Platform Android")
#endif

Compile check

compileチェックにどれほど助けられているのか、普段は自覚しないと思う.
人間はあまりに不完全. 最近も3件、macro内のミスで失敗してるケースを目撃した.

デメリット4. Editor機能が効かなくなる

現代の大抵のEditorは、構文解析が終わったコンパイル済みの状態からコードハイライトをつけたり、importをoptimizeしたり、refactorコマンドを実行したりする.

missing highlight

macroでdisableされてしまったコードパスはEditor機能から認知できなくなるケースが多い.
(↑Rider)

コード記述、lintチェック、コーディングルールのチェック、warning、Edtior機能(FindUsage, OptimizeImport, Refactor...)など全てコンパイルされていることに依存するので、これら機能が無効になる.
Editor補助なしでは、人間はあまりに脆弱ですぐにミスをする...
Importがミスってたり、無意味なコードを入れっぱなしにしたり、構文ミスしたり、最近でもそんなケースに遭遇した.

デメリット5. 変更に弱く、暗黙的なバグを許容する

たとえば、ログを出力するのに #DEBUG で覆ってたりする

public void Run() {
  #if DEBUG
  MyLogger.Debug("Hello");
  #endif
}

ここリファクタリングでMyLoggerを置換するとすると、Ediorの解釈によっては#define DEBUG な状態でないと、参照箇所を発見できず、古いコードを残してしまう可能性がある.
コンパイルチェックが失われたmacroコードは、変更時のリスクが極めて高くなる.
消し忘れや処理の記述ミスが増え、デバックタイミングは遅れ、メンテナンスの更新、リファクタリングなどすべてに影響が出る.

デメリット6: バグ発見が遅れる

late bug

デメリット1-5の結果、ビルドするまで気づかない、プレイするまで気づかないなど、エラーが終盤に先送りされる割合が増える.
基本的に、バグは早期に発見修正できるほどコストが安く、イテレーション速度が上がる.
macroを多用すると開発の信頼性、イテレーションスパン、エンバグリスクも増大する.

理想

理想はmacroがゼロな状態.
だがコーディング要件や制約によってはそうはいかないケースが多々ある.
(どうしてもcode stripが必要で特定macroは使う必要があるなど)
自身のプロジェクトの枠組みの中で3つを考えてmacroの不用意なリスクを最小化すると良いと思う.

1. 他の代替手段ではダメか? (利用有無)

これらはケースバイケースなので、具体例で語っていくのには前提が必要で難しいが、
考え方のヒントになるようなケースを2つ挙げてみる.

例1.

例えば、下記のようなclassがあるとする.
(全文をmacroで囲ったようなクラス)

#if UNITY_EDITOR
class Hoge
{}
#endif

これであれば、Unityの仕組みで、Editor/フォルダ以下に移動する or Editor用のasmdef管理にcodeを移動することでmacroを利用せずにすむ.

例2.

例えば、Unityではplatform依存コードをRuntimePlatformで逃がせるケースもある.
プロジェクトで想定する状況や前提によるが、本当にmacroである必要があるのか、macroを使う場合と代替手段を使う場合でメリットとデメリットを比較して、どちらが優れているのかを考える必要がある.

public class ClassForEditor { public void Execute(); }
public class ClassForAndroid { public void Execute(); }
public class ClassForIOS { public void Execute(); }

public void Run() {
   if (Application.platform == RuntimePlatform.Android) {
     new ClassForAndroid().Execute();
   } else if ...
}

以上、同じようにmacro分岐部分を共通のconfigなどに逃せないか? 依存注入することでmacroを使わずに処理できないか? など場合によって、代替手段がないかを考えよう

2. 使用するmacro数は最小限にできないか? (数/種類)

理由1のところで書いたが、

  1. macroフラグが増える
  2. ビルドパターンが指数関数的に増える
  3. デバック/テストパターンが指数関数的に増える

macroの数/種類をカジュアルに増やしまくると、網羅しきれずに破綻する.
別の代替方法はないか、無駄にフラグを増やしてないか、本当にmacroでやるべき処理なのかを熟考する

3. macroの適用範囲は局所に絞れないか? (範囲)

例1.

例として適切かはわからないけど、
DebugLogをmacroがonの場合に出したいなんていう処理ならば、共通の処理に統合することができる.

public class Before
{
    public void Run()
    {
#if DEBUG
        Debug.Log("DEBUG1");
#endif

        // ...

#if DEBUG
        Debug.Log("DEBUG2");
#endif
    }
}
public class After
{
    public void Run()
    {
        DebugLog("Debug1");
        // ...
        DebugLog("Debug2");
    }

    [Conditional("DEBUG")]
    private void DebugLog(string message)
    {
        Debug.Log(message);
    }
}

こうしてしまえば、考慮する場所はDebugLog関数のみになる.
コード全体でmacroを考慮しなきゃいけない数が減ると、ヒューマンエラーが減る.

例2.

例えば、Platformごとに全く違う処理をやるケースで、そのコーディング量が多岐にわたるケース.

  • PluginBase.asmdef: AnyPlatform
    • interface INativeRunner{}
  • PluginAndroid.asmdef: Android
    • class AndroidRunner: INativeRunner{}
  • PluginIOS.amsdef: iOS
    • class IOSRunner: INativeRunner{}
  • PluginEditor.asmdef: Editor
    • class EditorRunner: INaiveRunner{}

みたいな感じでプラットフォーム処理をinterfaceでファイル分けしておけば、
利用元で1行macroで分岐するだけで、広い範囲のcodeをmacroで囲んで読みづらい検証しづらいなんてことも少なくなる.

#if UNITY_EDITOR
var runner = new EditorRunner();
#elif UNITY_IOS
var runner = new IOSRunner();
#elif UNITY_ANDROID
var runner = new AndroidRunner();
#endif

終わりに

macroのカジュアル多用結構やりがちで、そのくせリスクがでかいので、なんか参考になれば...!
世の中から不要なmacroは減らしていきましょ!

Discussion