syghの新フラグメント置き場

プログラミングTipsやコード断片の保管場所です。お絵描きもときどき載せます。

Android Javaのアサーション

昨年2018年は転職したこともあって、一番よく使った言語はJavaでした。とはいっても、ほとんどはAndroidJavaであり、サーバーサイドやデスクトップで使われている正式なJavaとはいささか性質が異なります。

JavaC/C++C#と違ってプリプロセッサをサポートしないのですが、そのせいで一番困るもののうちのひとつはアサーション(表明)です。アサーションは「契約による設計」(design by contract) を実現・実践するために有効なプログラミング手法であり、呼び出し元が保証すべき事前条件や、呼び出し先が保証すべき事後条件をコード中に記述し、条件が満たされなかった場合はプログラムを続行せずに中断します。一方、リリースビルド(本番環境)でアサーションが無効になっているときは、引数として渡した式(表明したい条件)が評価されずに実行コストゼロとなることが好ましく、そのためC/C++C#ではコンパイルオプションに応じて有効/無効が切り替わるプリプロセッサマクロやConditional属性付きメソッドによって、ゼロオーバーヘッドのアサーションを実現しています。逆に言うと、リリースビルドではアサーションの条件式は評価されないため、必ず実行されないと困るような式(システムに対して副作用のある式)は記述しないようにします。

#include <cassert>
// NDEBUG シンボルが定義されていない場合、assert マクロが有効になる。
// アサーションが有効な場合は assert マクロが実行される。無効な場合は引数の評価ごと無視される。
// 条件が満たされず、アサーションが失敗したとき、abort 関数が実行される。
static void test(const int ary[], size_t length) {
    assert(ary != nullptr);
    for (size_t i = 0; i < length; ++i) {
        printf("%d\n", ary[i]);
    }
}

C/C++標準のassert()マクロは、表明が失敗 (assertion failure) するとabort()関数でプログラムを強制終了しますが、C# (.NET) のDebug.Assert()メソッドは表明が失敗した場合に無視してプログラムを続行することもできます。MSVCのCRTマクロ、_ASSERT()_ASSERTE()に近い挙動です。

// DEBUG シンボルが定義されている場合、Assert メソッドが有効になる。
// アサーションが有効な場合は Assert メソッドが実行される。無効な場合は引数の評価ごと無視される。
// 条件が満たされず、アサーションが失敗したとき、エラーメッセージが表示される。
static void Test(int[] ary)
{
    System.Diagnostics.Debug.Assert(ary != null);
    foreach (int x in ary)
    {
        System.Console.WriteLine(x);
    }
}    

Javaにはバージョン1.4で assert ステートメントが追加されたのですが、Javaアサーションコンパイルオプションではなく実行時オプションで有効/無効を切り替えます。JITコンパイラによる最適化がうまく機能すれば、無効時にはゼロコストとなることが期待できます。

// アサーションが有効な場合は assert ステートメントが実行される。無効な場合は引数の評価ごと無視される。
// 条件が満たされず、アサーションが失敗したとき、java.lang.AssertionError がスローされる。
static void test(int[] ary) {
    assert ary != null;
    for (final int x : ary) {
        System.out.printf("%d\n", x);
    }
}

上記の例に限れば、仮にリリースビルドでnullが渡されたとしても、途中でjava.lang.NullPointerExceptionがスローされることでプログラムは中断されますが、nullを有効値として許可しないメソッドであるということをコード上で明らかにするためにもアサーションは有用です。Java 7以降であれば、java.util.Objects.requireNonNull()を併用するとなお良いです。ちなみにJSR 305の@Nonnullアノテーション (javax.annotation.Nonnull) はメソッドインターフェイスのドキュメント化に役立ちますが、すべての処理系で使えるわけではないのが残念です。

Android Javaの場合

しかし、Android 4.xまでの仮想マシン Dalvik では、アサーションの有効/無効を切り替えるシステム プロパティの debug.assert は信頼性がなく(無視されることがある)、さらにAndroid 5.0以降の仮想マシン ART (Android Runtime) では未実装とのこと。
Android StudioJava言語組み込みのassert文を使おうとすると、以下のような静的チェックツール (lint) による警告が出ます。

Assertions are not checked at runtime. There are ways to request that they be used by Dalvik (`adb shell setprop debug.assert 1`), but note that this is not implemented in ART (the newer runtime), and even in Dalvik the property is ignored in many places and can not be relied upon.
Instead, perform conditional checking inside `if (BuildConfig.DEBUG) { }` blocks. That constant is a static final boolean which is true in debug builds and false in release builds, and the Java compiler completely removes all code inside the if-body from the app.
For example, you can replace `assert speed > 0` with `if (BuildConfig.DEBUG && !(speed > 0)) { throw new AssertionError() }`.
(Note: This lint check does not flag assertions purely asserting nullness or non-nullness; these are typically more intended for tools usage than runtime checks.)
https://code.google.com/p/android/issues/detail?id=65183

つまり現在のAndroidでは、Java言語組み込みのassert文はまともに使用できません。
代替として、上記 lint では java.lang.AssertionError を明示的にスローするコードを書くことが提案されていますが、正直そんなまどろっこしいことやってられません*1

Android開発環境が自動生成するクラスBuildConfig*2のboolean定数フィールドBuildConfig.DEBUGを使って分岐すれば、確かにコンパイラ最適化によりリリースビルドではゼロコストとなることが期待できるのですが、分岐&例外スローを逐一書くのがダルいので、自分はJUnitライクのアサーションユーティリティクラスと静的メソッドを作って対処しています。個人的には分岐やメソッド呼び出しのオーバーヘッドよりも、分岐&例外スローを逐一書くことでノイズとメンテナンスコストが増大することのほうが懸念だからです。なお、表明したい式を直接記述してbooleanで受け取るのではなく、ラムダ式で表現して遅延評価することにより、リリースビルドでの実行時コストを削減する方法もありますが、ラムダ式を使わない場合と比べてやはりコード上のノイズが増えます。

import java.util.function.*;

public class MyAssert {
    public static final boolean DEBUG = true; // BuildConfig.DEBUG で初期化する方式でも OK。

    public static void assertTrue(boolean condition) {
        if (DEBUG && !condition) {
            throw new AssertionError();
        }
    }

    public static void assertTrue(BooleanSupplier expression) {
        if (DEBUG && !expression.getAsBoolean()) {
            throw new AssertionError();
        }
    }
}

// 利用例。
MyAssert.assertTrue(ary != null);
MyAssert.assertTrue(() -> ary != null);

AndroidJavaは本物のJavaではなくJavaモドキでしかない、としばしば揶揄されますが、こういった非互換な仕様や動作には実際かなりウンザリさせられます。Oracleがブチ切れて訴訟を起こしたくなる気持ちも理解できます。自分にとってC/C++の一番嫌いなところは、処理系依存の仕様や未定義動作があまりに多すぎることですが、本来のJavaC#はそういった曖昧な仕様が極力排除されていることが最大の魅力のうちのひとつとなっています。仮にもJavaを名乗るのであれば、そういった根本的な設計思想を曲げるようなことをすべきではありません。これは技術者の倫理 (engineering ethics) に関わる問題です。

*1:ちなみにこの警告文中に記載されている例示コードですが、文末のセミコロンが欠けており、コンパイルが通りません。

*2:ちなみにBuildConfigクラスはアプリモジュールのパッケージ(名前空間)になるので、ライブラリモジュールからは直接参照することができません。