macroのカジュアル多用は危険 ⚠️
背景
過去c++やc#、objective-cでmacroがカジュアルに多用してあるプロジェクトを触ってきて、
非常にミスが発生しやすく、ミスに気づきにくく、メンテも地獄だと感じた.
ただきちんと言語化しないと伝わらないなと感じたので、なぜmacroをカジュアルに多用していくと危険なのかを言語化してみる試み.
macro嫌だな、このコード辛いなと思ったときに、誰かのコーディングの考える材料になれば幸い
結論
長いので先に自分の結論.
macro利用はコンパイルチェックをスキップするので、非常に強力な一方、危険な劇薬.
あくまで最後の砦として、極限まで使わないで済む方法を考えよう.
下記ステップでの思考が、macroのリスクが減ってよいなと思っている.
- 他の代替手段ではダメか? (利用有無)
- 使用するmacro数は最小限にできないか? (数/種類)
- 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
}
-
#define PLATFORM_ANDROID
でcompile -
#define PLATFORM_IOS
でcompile -
#define PLATFORM_EDITOR
でcompile -
nop
でcompile
上のようなプラットフォームなどの排他的なフラグであれば、4パターンのビルドをするだけですむ.
ただこれが一般的なフラグであったとすると、各フラグがtrue/falseで 2^3 = 8
パターンのビルドに増える.
ここで問題なのは、これら作られたビルドに対して、実行内容がことなるのでそれぞれに検証が必要となるという点.
フラグが増えるほどにビルドパターンは指数関数的に増加する.
(これが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チェックにどれほど助けられているのか、普段は自覚しないと思う.
人間はあまりに不完全. 最近も3件、macro内のミスで失敗してるケースを目撃した.
デメリット4. Editor機能が効かなくなる
現代の大抵のEditorは、構文解析が終わったコンパイル済みの状態からコードハイライトをつけたり、importをoptimizeしたり、refactorコマンドを実行したりする.
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: バグ発見が遅れる
デメリット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のところで書いたが、
- macroフラグが増える
- ビルドパターンが指数関数的に増える
- デバック/テストパターンが指数関数的に増える
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