Java で String オブジェクトをバイト列表現したときの長さをヒープにやさしい方法で求める

何気ない str.getBytes(Charset.forName("UTF-8")).length が、JVM を傷つけた」とならないように、String オブジェクトを任意の文字エンコーディングでエンコードしたときのバイト数を、文字列の長さに依存しない (すなわち定数オーダーの) 空間計算量で算出する方法を探ってみます。

String のバイト数を空間効率よく算出したい

Java で文字列を扱っていて「この文字列を UTF-8 で表現したときのバイト数を把握したい!」という状況に遭遇した場合、特に深く考えずに str.getBytes(Charset.forName("UTF-8")).length として求めることがよくあるかと思います。

このやり方は単純かつその目的が明確という利点があり、 文字列の長さが常に比較的短め であることが保証されている状況下であれば適切な方法と考えられます。一方で、 文字列の長さが不定である、もしくは明らかに長大な文字列を扱っている 場合には必ずしも適切な方法とは言えません。1 それというのも、 String#getBytes(Charset) は実際に文字列をエンコードした結果を収めた byte 配列を生成することから、JVM のヒープのことを考えると (ほんの一時的とは言え) それなりの大きさの byte 配列が生成されうるため、です。2

String オブジェクトもしくは CharSequence オブジェクトで表現された文字列に対して、ある文字エンコーディングにおけるバイト数を実際にエンコーディングすることなく把握する 簡単な 方法は残念ながら Java の標準クラスライブラリでは提供されていません。しかしながら、外部のライブラリを利用したり実装を工夫すれば、文字列の長さに依存しない空間計算量でバイト数の算出は実現可能です。

以降、具体的な実現方法について説明します。

UTF-8 エンコーディングの場合

まずは UTF-8 でエンコーディングした場合のバイト数を算出する方法です。

こちらは Stackoverflow の質問 Calculating length in UTF-8 of Java String without actually encoding it における回答にあるように、自前実装で頑張る方法Guava の Utf8.encodedLength() を利用する方法などが紹介されています。

特に後者はそのコメントにもあるとおり、時間効率・空間効率ともに String#getBytes(Charset) を利用する方法よりもよいとのことなので、(Guava 使用禁止のプロジェクトとかでない限りは) 原則としてこちらを使うのがよいでしょう。

任意の文字エンコーディングの場合

世の中的に、最近構築されるシステムでは積極的に MS932 などの UTF-8 以外の文字エンコーディングが採用されることはないかと思うので、基本的には上述した方法でみな満足かと思います。しかしながら、どうしても UTF-8 以外の特定の文字エンコーディングを扱わなければいけない場合は、別の方法を考える必要があります。ここでは、 CharsetEncoder#encode(CharBuffer, ByteBuffer, boolean) を利用したバイト数の算出方法をご紹介します。

CharsetEncoder#encode(CharBuffer, ByteBuffer, boolean) は、第一引数で指定された文字列 (の CharBuffer 表現) をエンコードし、エンコード結果のバイト列を第二引数のバッファ (をあらわす ByteBuffer オブジェクト) に出力するメソッドです。このメソッドの第二引数に指定するバッファを 8 バイトなど固定長にし、エンコード結果をすべて出力し尽くすまで、

  1. CharsetEncoder#encode() を呼び出し、
  2. ByteBuffer.position() で出力されたバイト数を求め、
  3. ByteBuffer.rewind() でバッファを巻き戻す (≒ バッファクリア)

これらの処理を繰り返すことで、空間計算量的に定数オーダーながら文字エンコーディング結果のバイト数を算出できるようになるわけです。

具体的な実装は以下のとおりです (JUnit のテストコードを含む git リポジトリは こちら )。

import java.nio.*;
import java.nio.charset.*;

public class StringByteCounter {
    public static int countBytes(String string, Charset charset) throws CharacterCodingException {
        if (string.isEmpty()) {
            return 0;
        }

        ByteBuffer buffer = ByteBuffer.allocate(8);
        CharBuffer in = CharBuffer.wrap(string);

        CharsetEncoder encoder = charset.newEncoder()
                .onMalformedInput(CodingErrorAction.REPLACE)
                .onUnmappableCharacter(CodingErrorAction.REPLACE)
                .reset();

        int bytesEncoded = 0;
        while (in.length() > 0) {
            CoderResult cr = encoder.encode(in, buffer, true);

            if (buffer.position() == 0) {
                throw new CharacterCodingException();
            }
            bytesEncoded += buffer.position();
            buffer.rewind();

            if (cr.isUnderflow()) {
                break;
            }
            if (cr.isError()) {
                cr.throwException();
            }
        }

        while (true) {
            CoderResult cr = encoder.flush(buffer);
            bytesEncoded += buffer.position();

            if (cr.isUnderflow()) {
                break;
            }
            if (cr.isError()) {
                cr.throwException();
            }

            buffer.rewind();
        }

        return bytesEncoded;
    }
}

このやり方の明らかな欠点は、CharsetEncoder#encode() を何度も呼び出さなければならない点です。当該メソッドの呼び出しに時間がかかるような文字エンコーディングが存在した場合、速度的なパフォーマンスに多大な影響を与える可能性があります (なお、ByteBuffer.allocate() で生成している固定長のバッファを大きくとれば、CharsetEncoder#encode() の呼び出し回数を減らすことができるでしょう)。

まとめ

  • UTF-8 エンコーディングしたときのバイト数を知りたければ、Guava の Utf8.encodedLength() を使うのが最適だよ
  • 文字列の長さが短ければ String#getBytes(Charset) で byte 配列を生成してその長さを取るのが楽でいいよ
  • 長めの文字列に対して、速度的なパフォーマンスを対象に犠牲にしてでも定数オーダーの空間計算量で求めたいのであれば、 CharsetEncoder#encode(CharBuffer, ByteBuffer, boolean) を活用する方法があるよ

  1. そもそもそんな長い文字列を String オブジェクトで扱うこと自体避けるべきではありますが、ここではいかんともしがたい理由により扱わざるを得ないケースを考えます 

  2. より厳密には、エンコーディング結果を書き出す先の ByteBuffer が保持する byte 配列と、そこから必要な長さに切り出された byte 配列の 2 つの byte 配列が生成されることになります 

Tags:

Categories:

Updated: