meryngii.neta

今日も新たな"ネタ"を求めて。

Java向けにC++クラスをラップする方法

JNI (Java Native Interface)ネタは、この記事で一応最後です。
前々回は、Javaから渡された配列や文字列を簡単に受け取る方法について述べました。配列の受け渡しに関しては、最悪だと呼び出し毎にネイティブヒープへのコピーが発生する可能性があるので、ネイティブ側とVM側のどちらにデータを配置するのが正しいのかを考慮して設計する必要があります。
前回は、C++をJNI経由で呼び出す際の例外処理について述べました。STLに例外が必要となるわけですし、Android等でも基本的には-fexceptionsを有効にしてビルドした方がいいと思います。
さて、ここまでの知識だと、JavaからC言語インターフェイス経由で関数を呼び出すということは出来ますが、C++のクラスをJavaから管理するとなるともう少し工夫が必要です。今回は、オブジェクト指向なやり方でC++のクラスを利用するにはどうすればいいのかについて述べていきます。

ポインタなんて所詮整数だ

Javaには、CやC++のポインタを認識する仕組みはありません。*1Java仮想マシン上の"綺麗な世界"には、ポインタなどという穢らわしきモノは存在しないわけです。
しかし、結局のところ、ポインタ≒アドレスなんて所詮整数なので、Javaのクラスに整数メンバを用意しておけばアドレスを保存しておくことができます。そいつをJNI経由でC++側から読み出せば、C++側でインスタンスを特定できるという話です。今回の記事の根幹はここだけです。
この辺りの話題は、以下の記事を参考にしています。
http://thebreakfastpost.com/2012/01/21/wrapping-a-c-library-with-jni-introduction/
例として、以下の様なテストクラスをC++によって実装してみることにします。

public class WrapperTest
{
    private long mHandle = 0; // <=== こっそりアドレスを保存する

    public WrapperTest() {
        init();
    }
    
    private native void init();   
    public native void put(int x);
    public native void show();
    public native void destroy();
}

ほとんどネイティブ関数となっていますが、mHandleなる整数メンバを入れていることに注目です。*2こいつをC++から読み書きすることで、Javaオブジェクトにぶら下がったC++インスタンスのアドレスを特定します。
ちなみに、Android標準ライブラリの多くが裏でネイティブを呼び出す形になっているのですが、使われているテクニックはやはり同じのようです。

ラッパークラス用テンプレート

さて、ここからはC++での実装に入っていきます。理屈は極めて単純なものの、毎回新しいラッパークラスを作る度に整数<->ポインタ間キャストについて考えるのは面倒でしょう。というわけで、ラッパークラスを作るための補助テンプレートについて紹介します。参考記事よりはもう少し賢い実装になっていると思います。
ほぼ同様のコードはGitHubにも上げてあります: https://github.com/meryngii/jni_util

template <typename T>
class Wrapper
{
public:
    explicit Wrapper(JNIEnv* env, jobject objj)
        : env_(env), objj_(objj) { }

    Wrapper(const Wrapper&) = default;
    Wrapper& operator = (const Wrapper&) = default;
    
    T& get() const {
        T* ptr = getPointer();
        if (!ptr) {
            throw std::runtime_error("Uninitialized native object was used!");
        }
        
        return *ptr;
    }
    
    template <typename... Args>
    void create(Args&&... args) const {
        destroy(); // delete if an object exists
        setPointer(new T(std::forward(args)...));
    }
    
    void destroy() const {
        delete getPointer();
        setPointer(nullptr);
    }
    
    // Pointer Operations
    T* getPointer() const {
        return reinterpret_cast<T*>(env_->GetLongField(objj_, getFieldID(objj_)));
    }
    void setPointer(T* ptr) const { 
        env_->SetLongField(objj_, getFieldID(objj_), reinterpret_cast<jlong>(ptr));
    }
    
private:
    jfieldID getFieldID(jobject objj) const {
        if (!classj_) {
            classj_ = env_->GetObjectClass(objj_);
            checkException(env_);
        }
        
        if (!fieldID_) {
            fieldID_ = env_->GetFieldID(classj_, "mHandle", "J");
            checkException(env_);
        }
        
        return fieldID_;
    }

private:
    JNIEnv* env_;
    jobject objj_;
    static jclass classj_;
    static jfieldID fieldID_;
};

ポイントとなる関数はgetFieldID, set/getPointerの3つで、後はオマケです。Javaのメンバ変数を読み書きするにはjfieldIDが必要となるので、ネイティブ側でstatic変数にキャッシュしておきます。
jclassやjfieldIDといった値は、実行時に一度手に入れば(確か)プロセス終了時まで有効なので、必ずキャッシュしましょう。ちなみに、jclassやjobjectはクラスやオブジェクトが同じでも異なるポインタ値となる可能性があるので、env->IsSameObjectといった関数がわざわざ定義されています。jfieldIDやjmethodIDは一意性が保たれているのでキーとして使うことも可能ですが、個人的にあまり危なそうなことはオススメしません。
上のWrapperテンプレートを使えば、後はラッパークラスを作るのは簡単です。C++で普通にクラスを作って、それにJNIのインターフェイスを加えます。

class WrapperTest
{
public:
    void put(int x) { data_.push_back(x); }
    
    void show() {
        for (int x : data_)
            std::cout << x << " ";

        std::cout << std::endl;
    }
    
private:
    std::vector<int> data_;
};

extern "C"
{

JNIEXPORT void JNICALL Java_WrapperTest_init
  (JNIEnv* env, jobject thisj)
{
    convertException(env, [=] () {
        Wrapper<WrapperTest>(env, thisj).create();
    });
}

JNIEXPORT void JNICALL Java_WrapperTest_put
  (JNIEnv* env, jobject thisj, jint x)
{
    convertException(env, [=] () {
        auto& self = Wrapper<WrapperTest>(env, thisj).get();
        self.put(x);
    });
}

// ...

}

ネイティブオブジェクトの破棄

基本的な話としては以上ですが、まだオブジェクトの破棄について言及していませんでした。
JavaのオブジェクトはGCによって不定期に開放されます。finalizeメソッドをオーバーライドすることによって、GCが走った時にリソースの最終処理を行うことが可能になっているので、普通に考えればC++のオブジェクトもその際に開放するのが自然な発想でしょう。ところが、finalizeメソッドはいつ呼び出されるか予測が出来ず、しかもアプリケーションの終了タイミングによっては呼び出されない可能性があります。
C++プログラマは「デストラクタは必ず呼び出される」という考えでクラスを設計するので、finalizeにネイティブリソースの破棄を一任するのは良い考えとはいえないでしょう。結局のところ、少々面倒ではありますが素直に破棄用メソッドを作って、Java側のクラス利用者がfinallyなりで責任を持って呼び出すという手法が一番マシと考えられます。上のクラスにはdestroyメソッドを定義してあります。

extern "C"
JNIEXPORT void JNICALL Java_WrapperTest_destroy
  (JNIEnv* env, jobject thisj, jstring strj)
{
    convertException(env, [=] () {
       Wrapper<WrapperTest>(env, thisj).destroy();
    });
}

finalizeは当てになりませんが、一応保険として最後に破棄するようにしておくことは悪くないと思います。そのためにもdestroyは何度呼び出してもOKな作りにしておく必要があるでしょう。

@Override
protected void finalize() throws Throwable
{
    destroy();
    super.finalize();
}

おわりに

最後に、JNIを使う際の注意点について簡単にまとめておきます。

  • Java Native Interface を使用する上でのベスト・プラクティスをじっくり読む。
  • (上にも書いてあるが)JNIの返り値は全力でグローバルにキャッシュする。
  • C++の例外が漏れ出さないように気を付ける。逆に、Java側の例外が発生したまま継続しない。
  • finalizeが呼び出されない可能性に気を付ける。
  • UnsatistiedLinkErrorで泣かない。
    • extern "C"を付けたか?
    • メソッド名・クラス名は正しいか?
    • javahを試したか?
    • System.loadLibraryを忘れていないか?
  • ぶっちゃけJNIは生産性が低いので、出来ることなら使わないほうがいい。(今日のオチ)

お粗末さまでした。

*1:C#にはunsafeなる魔術があるらしいですね…。

*2:Javaのlong型は64bitです。可搬性を考慮して、ポインタを保持する型は大きめに取ったほうがいいと思います。2038年問題を教訓に…。