web-dev-qa-db-ja.com

Javaに真の多次元配列がないのはなぜですか?

TL; DRバージョンは、バックグラウンドが不要な場合、次の特定の質問です。

質問

Javaに真の多次元配列が実装されていないのはなぜですか?確かな技術的理由がありますか?ここで何が欠けていますか?

バックグラウンド

Javaには、構文レベルで多次元配列があり、宣言することができます。

int[][] arr = new int[10][10];

しかし、これは本当に人が期待したものではないようです。 JVMに100intsを格納するのに十分な大きさのRAMの連続ブロックを割り当てるのではなく、intsの配列の配列として出力されます。レイヤーはRAMの連続したブロックですが、全体としてはそうではありません。したがって、arr[i][j]へのアクセスはかなり遅く、JVMは

  1. int[]に保存されているarr[i]を見つけます。
  2. これにインデックスを付けて、arr[i][j]に保存されているintを見つけます。

これには、あるレイヤーから次のレイヤーに移動するためのオブジェクトのクエリが含まれます。

なぜJavaこれを行うのか

1つのレベルでは、1つの固定ブロックにすべてが割り当てられている場合でも、単純なスケールと追加のルックアップに最適化できない理由を理解するのは難しくありません。問題は、arr[3]がそれ自体の参照であり、変更できることです。したがって、配列は固定サイズですが、簡単に書くことができます

arr[3] = new int[11];

そして、この層が成長したので、スケールアンドアドはねじ込みます。実行時に、すべてが以前と同じサイズであるかどうかを知る必要があります。さらに、もちろん、これはRAM(置き換えられるものよりも大きいため、必要になります)の別の場所に割り当てられるため、適切な場所にさえありません。スケールアンドアド。

何が問題か

これは理想的ではないように思えますが、2つの理由があります。

一つには、それは遅いです。 1次元または多次元配列の内容を合計するためにこれらのメソッドを使用して実行したテストでは、約2倍の長さ多次元の場合(ランダムなint値で満たされたそれぞれint[1000000]int[100][100][100]は、ウォームキャッシュで1000000回実行されます)。

public static long sumSingle(int[] arr) {
    long total = 0;
    for (int i=0; i<arr.length; i++)
        total+=arr[i];
    return total;
}

public static long sumMulti(int[][][] arr) {
    long total = 0;
    for (int i=0; i<arr.length; i++)
        for (int j=0; j<arr[0].length; j++)
            for (int k=0; k<arr[0][0].length; k++)
                total+=arr[i][j][k];
    return total;
}   

第二に、それは遅いので、それによってあいまいなコーディングを奨励します。多次元配列で自然に行われるパフォーマンスクリティカルな何かに遭遇した場合、それが不自然で読みにくくなったとしても、それをフラット配列として書くインセンティブがあります。あいまいな選択、つまりあいまいなコードまたは遅いコードが残ります。

それについて何ができるか

基本的な問題は簡単に修正できるように思えます。前に見たように、最適化できない唯一の理由は、構造が変更される可能性があることです。しかし、Javaには、参照を変更できないようにするメカニズムがすでにあります。それらをfinalとして宣言してください。

今、それを宣言するだけです

final int[][] arr = new int[10][10];

ここでarrであるのはfinalだけなので、十分ではありません。arr[3]はまだ変更されておらず、変更される可能性があるため、構造が変更される可能性があります。しかし、final値が格納されている最下層を除いて、全体がintになるように宣言する方法があれば、完全に不変の構造になります。すべてを1つのブロックとして割り当て、scale-and-addでインデックスを付けることができます。

構文的にどのように見えるかはわかりません(私は言語デザイナーではありません)。多分

final int[final][] arr = new int[10][10];

確かにそれは少し奇妙に見えますが。これは、次のことを意味します。最上位層のfinal; final次のレイヤー;最下層のfinalではありません(そうでない場合、int値自体は不変になります)。

全体のファイナリティにより、JITコンパイラはこれを最適化して、1次元配列のパフォーマンスを向上させることができます。これにより、多次元配列の速度低下を回避するために、そのようにコーディングする誘惑がなくなります。

(C#がこのようなことをするという噂を聞きますが、CLRの実装が非常に悪いため、持つ価値がないという別の噂も聞きます...おそらくそれらは単なる噂です...)

質問

では、なぜJavaに真の多次元配列の実装がないのですか?確かな技術的な理由はありますか?ここに何が欠けていますか?

更新

奇妙な補足:現在の合計にintではなくlongを使用すると、タイミングの違いはわずか数パーセントに減少します。なぜintとこんなに小さな違いがあり、longとこんなに大きな違いがあるのでしょうか?

ベンチマークコード

誰かがこれらの結果を再現しようとする場合に備えて、ベンチマークに使用したコード:

public class Multidimensional {

    public static long sumSingle(final int[] arr) {
        long total = 0;
        for (int i=0; i<arr.length; i++)
            total+=arr[i];
        return total;
    }

    public static long sumMulti(final int[][][] arr) {
        long total = 0;
        for (int i=0; i<arr.length; i++)
            for (int j=0; j<arr[0].length; j++)
                for (int k=0; k<arr[0][0].length; k++)
                    total+=arr[i][j][k];
        return total;
    }   

    public static void main(String[] args) {
        final int iterations = 1000000;

        Random r = new Random();
        int[] arr = new int[1000000];
        for (int i=0; i<arr.length; i++)
            arr[i]=r.nextInt();
        long total = 0;
        System.out.println(sumSingle(arr));
        long time = System.nanoTime();
        for (int i=0; i<iterations; i++)
            total = sumSingle(arr);
        time = System.nanoTime()-time;
        System.out.printf("Took %d ms for single dimension\n", time/1000000, total);

        int[][][] arrMulti = new int[100][100][100];
        for (int i=0; i<arrMulti.length; i++)
            for (int j=0; j<arrMulti[i].length; j++)
                for (int k=0; k<arrMulti[i][j].length; k++)
                    arrMulti[i][j][k]=r.nextInt();
        System.out.println(sumMulti(arrMulti));
        time = System.nanoTime();
        for (int i=0; i<iterations; i++)
            total = sumMulti(arrMulti);
        time = System.nanoTime()-time;
        System.out.printf("Took %d ms for multi dimension\n", time/1000000, total);
    }

}
35

しかし、これは本当に人が期待したものではないようです。

どうして?

T[]の形式が「T型の配列」を意味すると考えてください。int[]が「int型の配列」を意味すると予想されるのと同じように、int[][]は「int型の配列型の配列」を意味すると予想されます。理由は少なくありません。 int[]Tよりintとして使用するため。

そのため、任意のタイプの配列を持つことができることを考えると、配列の宣言と初期化で[]が使用される方法(さらに言えば、{},)から、特別なルールの禁止なしになります。配列の配列、私たちは「無料で」この種の使用を取得します。

ここで、ギザギザの配列で実行できることが、他の方法では実行できないことも考慮してください。

  1. さまざまな内部配列のサイズが異なる「ギザギザ」配列を作成できます。
  2. データの適切なマッピングがある場合、またはおそらく遅延構築を可能にするために、外側の配列内にnull配列を含めることができます。
  3. 配列内で意図的にエイリアスを作成できます。 lookup[1]lookup[5]と同じ配列です。 (これにより、一部のデータセットで大幅な節約が可能になります。たとえば、プロパティのリーフ配列を一致するパターンの範囲で繰り返すことができるため、多くのUnicodeプロパティを少量のメモリ内の1,112,064コードポイントのフルセットにマッピングできます)。
  4. 一部のヒープ実装は、メモリ内の1つの大きなオブジェクトよりも多くの小さなオブジェクトをより適切に処理できます。

確かに、このような多次元配列が役立つ場合があります。

現在、機能のデフォルトの状態は指定されておらず、実装されていません。誰かが機能を指定して実装することを決定する必要があるか、そうでなければそれは存在しません。

上に示したように、誰かが特別な禁止配列配列機能を導入することを決定しない限り、配列配列のような多次元配列が存在するためです。配列の配列は上記の理由で役立つので、それは奇妙な決断です。

逆に、配列のランクが1より大きく、単一のインデックスではなく一連のインデックスで使用されるような多次元配列は、すでに定義されているものから自然には従いません。誰かがする必要があります:

  1. 宣言の仕様を決定し、初期化と使用が機能します。
  2. それを文書化します。
  3. これを行うための実際のコードを記述します。
  4. これを行うにはコードをテストします。
  5. バグ、エッジケース、実際にはバグではないバグのレポート、バグの修正によって引き起こされた下位互換性の問題を処理します。

また、ユーザーはこの新機能を学ぶ必要があります。

だから、それはそれだけの価値がなければなりません。それを価値あるものにするいくつかのことは次のとおりです。

  1. 同じことをする方法がなかった場合。
  2. 同じことをする方法が奇妙であるか、よく知られていない場合。
  3. 人々は同じような文脈からそれを期待するでしょう。
  4. ユーザー自身が同様の機能を提供することはできません。

ただし、この場合:

  1. しかし〜がある。
  2. 配列内でストライドを使用することは、CおよびC++プログラマーにはすでに知られており、Javaはその構文に基づいて構築されているため、同じ手法を直接適用できます。
  3. Javaの構文はC++に基づいており、C++も同様に、多次元配列を配列の配列として直接サポートしているだけです。 (静的に割り当てられている場合を除いて、これはJava配列がオブジェクトである場合)に類似しているものではありません).
  4. 配列とストライドサイズの詳細をラップし、一連のインデックスを介してアクセスできるようにするクラスを簡単に作成できます。

本当に、問題は「なぜJavaは真の多次元配列を持たないのか」ではなく、「なぜそれが必要なのか」ではありません。

もちろん、多次元配列を支持するためにあなたが述べた点は有効であり、いくつかの言語はその理由でそれらを持っていますが、それでも、機能を議論するのではなく、議論するのは負担です。

(C#がこのようなことをするという噂を聞きますが、CLRの実装が非常に悪いため、持つ価値がないという別の噂も聞きます...おそらくそれらは単なる噂です...)

多くの噂のように、ここには真実の要素がありますが、それは完全な真実ではありません。

.NET配列は、実際に複数のランクを持つことができます。これは、Javaよりも柔軟性がある唯一の方法ではありません。各ランクには、ゼロ以外の下限を設定することもできます。そのため、たとえば、-3から42までの配列、または1つのランクが-2から5まで、別のランクが57から100までの2次元配列などを作成できます。

C#は、組み込み構文からこれらすべてに完全にアクセスできるわけではありませんが(ゼロ以外の下限の場合はArray.CreateInstance()を呼び出す必要があります)、2つの構文int[,]を使用できます-3次元配列の場合はintint[,,]などの次元配列。

現在、ゼロ以外の下限の処理に関連する余分な作業はパフォーマンスの負担を追加しますが、これらのケースは比較的まれです。そのため、下限が0の単一ランク配列は、よりパフォーマンスの高い実装を持つ特殊なケースとして扱われます。実際、それらは内部的には別の種類の構造です。

.NETでは、下限がゼロの多次元配列は、より高いランクを処理できる高速のケースではなく、下限が偶然にゼロになる(つまり、低速のケースの例として)多次元配列として扱われます。 1よりも。

もちろん、.NET couldは、ゼロベースの多次元配列の高速パスの場合がありますが、Javaそれらが適用されない理由はすべて- andすでに1つの特別なケースがあり、特別なケースが吸うという事実と、2つの特別なケースがあり、それらはさらに吸うことになります(実際には、一方のタイプの値をもう一方のタイプの変数に変換します)。

上記の1つも、Javaは、あなたが話しているような多次元配列を持つことができなかったことを明確に示しています。それは十分に賢明な決定でしたが、その決定も作られたのも賢明でした。

19
Jon Hanna

これはジェームズ・ゴズリングへの質問であるべきだと思います。 Javaの初期設計は約OOPであり、速度ではなく単純でした。

多次元配列がどのように機能するかをよりよく理解している場合、それを実現するにはいくつかの方法があります。

  1. JDK拡張提案 を送信します。
  2. Java Community Process を通じて新しいJSRを開発します。
  3. 新しい提案 Project

[〜#〜] upd [〜#〜]。もちろん、Java配列設計の問題に疑問を投げかけるのはあなたが最初ではありません。
たとえば、プロジェクト スマトラ および パナマtrue多次元配列の恩恵を受けます。

"Arrays 2.0" は、JVM Language Summit2012でのこのテーマに関するJohnRoseの講演です。

15
apangin

私には、あなたが自分で質問に答えたように見えます。

...それが不自然で読みにくくなったとしても、それをフラット配列として書くインセンティブ。

したがって、読みやすいフラット配列として記述してください。のような些細なヘルパーで

_double get(int row, int col) {
    return data[rowLength * row + col];
}
_

そして、同様のセッターと、おそらく_+=_と同等のものであれば、2D配列で作業しているように見せかけることができます。それは本当に大したことではありません。配列表記を使用することはできず、すべてが冗長で醜いになります。しかし、それはJavaの方法のようです。BigIntegerまたはBigDecimalとまったく同じです。Map、これは非常によく似たケースです。

さて、問題はこれらすべての機能がどれほど重要であるかです。 x += BigDecimal.valueOf("123456.654321") + 10;、_spouse["Paul"] = "Mary";_を記述したり、定型文なしで2D配列を使用したりできれば、もっと多くの人が幸せになるでしょうか。これはすべて素晴らしいことであり、配列スライスなど、さらに先に進むことができます。 ただし、実際の問題はありません。他の多くの場合と同様に、冗長性と非効率性のどちらかを選択する必要があります。 IMHO、この機能に費やす労力は他の場所でより適切に費やすことができます。あなたの2Dアレイは新しい最高のものです...

Javaには実際には2Dプリミティブ配列がありません...

それは主に構文上の砂糖であり、その根底にあるのはオブジェクトの配列です。

_double[][] a = new double[1][1];
Object[] b = a;
_

配列が具体化されているため、現在の実装ではほとんどサポートが必要ありません。あなたの実装はワームの缶を開きます:

  • 現在、8つのプリミティブ型があります。つまり、9つの配列型がありますが、2D配列は10番目になりますか? 3Dはどうですか?
  • 配列には、1つの特別なオブジェクトヘッダータイプがあります。 2Dアレイには別のアレイが必要になる場合があります。
  • _Java.lang.reflect.Array_はどうですか? 2Dアレイ用にクローンを作成しますか?
  • 他の多くの機能が適応されているでしょう。シリアル化。

そして何が

_??? x = {new int[1], new int[2]};
_

?古いスタイルの2D _int[][]_?相互運用性についてはどうですか?

私はそれはすべて実行可能だと思いますが、Javaには欠けているより単純でより重要なものがいくつかあります。常に2D配列が必要な人もいますが、多くの場合、どの配列をいつ使用したかをほとんど思い出せません。

10
maaartinus

あなたが主張するパフォーマンス上の利点を再現することはできません。具体的には、テストプログラム:

public abstract class Benchmark {

    final String name;

    public Benchmark(String name) {
        this.name = name;
    }

    abstract int run(int iterations) throws Throwable;

    private BigDecimal time() {
        try {
            int nextI = 1;
            int i;
            long duration;
            do {
                i = nextI;
                long start = System.nanoTime();
                run(i);
                duration = System.nanoTime() - start;
                nextI = (i << 1) | 1;
            } while (duration < 1000000000 && nextI > 0);
            return new BigDecimal((duration) * 1000 / i).movePointLeft(3);
        } catch (Throwable e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public String toString() {
        return name + "\t" + time() + " ns";
    }

    public static void main(String[] args) throws Exception {
        final int[] flat = new int[100*100*100];
        final int[][][] multi = new int[100][100][100];

        Random chaos = new Random();
        for (int i = 0; i < flat.length; i++) {
            flat[i] = chaos.nextInt();
        }
        for (int i=0; i<multi.length; i++)
            for (int j=0; j<multi[0].length; j++)
                for (int k=0; k<multi[0][0].length; k++)
                    multi[i][j][k] = chaos.nextInt();

        Benchmark[] marks = {
            new Benchmark("flat") {
                @Override
                int run(int iterations) throws Throwable {
                    long total = 0;
                    for (int j = 0; j < iterations; j++)
                        for (int i = 0; i < flat.length; i++)
                            total += flat[i];
                    return (int) total;
                }
            },
            new Benchmark("multi") {
                @Override
                int run(int iterations) throws Throwable {
                    long total = 0;
                    for (int iter = 0; iter < iterations; iter++)
                        for (int i=0; i<multi.length; i++)
                            for (int j=0; j<multi[0].length; j++)
                                for (int k=0; k<multi[0][0].length; k++)
                                    total+=multi[i][j][k];
                    return (int) total;
                }
            },
            new Benchmark("multi (idiomatic)") {
                @Override
                int run(int iterations) throws Throwable {
                    long total = 0;
                    for (int iter = 0; iter < iterations; iter++)
                        for (int[][] a : multi)
                            for (int[] b : a)
                                for (int c : b)
                                    total += c;
                    return (int) total;
                }
            }

        };

        for (Benchmark mark : marks) {
            System.out.println(mark);
        }
    }
}

私のワークステーションで実行します

Java version "1.8.0_05"
Java(TM) SE Runtime Environment (build 1.8.0_05-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.5-b02, mixed mode)

プリント

flat              264360.217 ns
multi             270303.246 ns
multi (idiomatic) 266607.334 ns

つまり、提供した1次元コードと多次元コードの間にわずか3%の違いが見られます。トラバーサルに慣用的なJava(具体的には拡張forループ)を使用すると、この差は1%に低下します(おそらく、ループが逆参照する同じ配列オブジェクトに対して境界チェックが実行されるため、ジャストインが有効になります)境界チェックをより完全に回避するための時間コンパイラ)。

したがって、パフォーマンスは、言語の複雑さを増すには不十分な理由のように思われます。具体的には、真の多次元配列をサポートするために、Javaプログラミング言語は、配列の配列と多次元配列を区別する必要があります。同様に、プログラマーはそれらを区別し、それらを認識する必要があります。違い:API設計者は、配列の配列を使用するか、多次元配列を使用するかを考える必要があります。コンパイラ、クラスファイル形式、クラスファイルベリファイア、インタプリタ、およびジャストインタイムコンパイラを拡張する必要があります。これは特に困難です。 、異なる次元数の多次元配列は互換性のないメモリレイアウトを持つため(境界チェックを有効にするには次元のサイズを格納する必要があるため)、したがって、相互のサブタイプにすることはできません。その結果、クラスJavaのメソッド。 util.Arraysは、配列を操作する他のすべての多態性アルゴリズムと同様に、次元数ごとに複製する必要がある可能性があります。

結論として、Javaを拡張して多次元配列をサポートすると、ほとんどのプログラムでパフォーマンスの向上はごくわずかになりますが、型システム、コンパイラ、およびランタイム環境に重要な拡張が必要になります。したがって、それらの導入はJavaプログラミング言語、具体的には 単純 であるという設計目標とのオッズ。

9
meriton

この質問の大部分はパフォーマンスに関するものなので、適切なJMHベースのベンチマークを提供させてください。また、いくつかの点を変更して、例をシンプルにし、パフォーマンスエッジをより際立たせました。

私の場合、1D配列と2D配列を比較し、非常に短い内部次元を使用します。これはキャッシュの最悪のケースです。

longintの両方のアキュムレータを試してみましたが、違いは見られませんでした。 intでバージョンを送信します。

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(X*Y)
@Warmup(iterations = 30, time = 100, timeUnit=MILLISECONDS)
@Measurement(iterations = 5, time = 1000, timeUnit=MILLISECONDS)
@State(Scope.Thread)
@Threads(1)
@Fork(1)
public class Measure
{
  static final int X = 100_000, Y = 10;
  private final int[] single = new int[X*Y];
  private final int[][] multi = new int[X][Y];

  @Setup public void setup() {
    final ThreadLocalRandom rnd = ThreadLocalRandom.current();
    for (int i=0; i<single.length; i++) single[i] = rnd.nextInt();
    for (int i=0; i<multi.length; i++)
      for (int j=0; j<multi[0].length; j++)
          multi[i][j] = rnd.nextInt();
  }

  @Benchmark public long sumSingle() { return sumSingle(single); }

  @Benchmark public long sumMulti() { return sumMulti(multi); }

  public static long sumSingle(int[] arr) {
    int total = 0;
    for (int i=0; i<arr.length; i++)
      total+=arr[i];
    return total;
  }

  public static long sumMulti(int[][] arr) {
    int total = 0;
    for (int i=0; i<arr.length; i++)
      for (int j=0; j<arr[0].length; j++)
          total+=arr[i][j];
    return total;
  }
}

パフォーマンスの違いは大きいは測定した値よりも大きくなります。

Benchmark                Mode  Samples  Score  Score error  Units
o.s.Measure.sumMulti     avgt        5  1,356        0,121  ns/op
o.s.Measure.sumSingle    avgt        5  0,421        0,018  ns/op

それは3つ以上の要素です。 (タイミングが報告されることに注意してください配列要素ごと。)

また、ウォームアップが含まれていないことにも注意してください。最初の100ミリ秒は残りのミリ秒と同じくらい高速です。どうやらこれは非常に単純なタスクであるため、インタプリタはそれを最適化するために必要なすべてをすでに実行しています。

更新

sumMultiの内部ループを

      for (int j=0; j<arr[i].length; j++)
          total+=arr[i][j];

(注arr[i].length)は、maaartinusによって予測されたように、大幅な高速化をもたらしました。 arr[0].lengthを使用すると、インデックス範囲チェックを削除できなくなります。結果は次のとおりです。

Benchmark                Mode  Samples  Score   Error  Units
o.s.Measure.sumMulti     avgt        5  0,992 ± 0,066  ns/op
o.s.Measure.sumSingle    avgt        5  0,424 ± 0,046  ns/op
3
Marko Topolnik

真の多次元配列の高速実装が必要な場合は、次のようなカスタム実装を作成できます。しかし、あなたは正しいです...それは配列表記ほど鮮明ではありません。ただし、きちんとした実装は非常に友好的かもしれません。

public class MyArray{
    private int rows = 0;
    private int cols = 0;
    String[] backingArray = null;
    public MyArray(int rows, int cols){
        this.rows = rows;
        this.cols = cols;
        backingArray  = new String[rows*cols];
    }
    public String get(int row, int col){
        return backingArray[row*cols + col];
    }
    ... setters and other stuff
}

なぜデフォルトの実装ではないのですか?

Java=の設計者はおそらく、通常のC配列構文のデフォルト表記がどのように動作するかを決定する必要がありました。配列の配列または真の多次元を実装できる単一の配列表記がありました配列。

初期のJava設計者はJavaの安全性に本当に関心を持っていたと思います。平均的なプログラマー(または悪い日に良いプログラマー)何かを台無しにしないでください。真の多次元配列を使用すると、ユーザーが役に立たない場所にブロックを割り当てることで、メモリの大きなチャンクを無駄にしやすくなります。

また、Javaの組み込みシステムのルーツから、真の多次元オブジェクトに必要なメモリの大きなチャンクよりも、割り当てるメモリの断片を見つける可能性が高いことがわかったでしょう。

もちろん、反対に、多次元配列が本当に意味をなす場所は苦しんでいます。そして、あなたはあなたの仕事を成し遂げるためにライブラリと厄介な見た目のコードを使わざるを得ません。

なぜまだ言語に含まれていないのですか?

今日でも、真の多次元配列は、メモリの浪費/誤用の可能性の観点からリスクがあります。

1
Teddy