同じトレイに並んだ要素の一部を透明な枠で囲い、その中の要素を操作するイラスト

実装・エラー解決

JavaのsubListで元のListも変わる理由|切り出した範囲だけを並べ替える方法

この記事でわかること

subListで先頭2件を切り出し、表示用に並べ替えたら、処理待ちの元のリストまで順序が変わりました。同じ範囲を独立したリストへ移し、表示順と処理順を分ける方法をJUnitで確かめます。

新人SE
新人SE
確認画面の2件だけを注文番号順にしたら、後で発送する順番も変わりました。subListで別の変数にしたはずです。
ポンコツSE
ポンコツSE
変数は別でも、subListが見ているのは元のリストの一部分です。表示用に順序を変えるなら、先に独立したリストを作ります。

注文の発送キューは受付順で、O-003、O-001、O-002が待っています。管理画面の確認欄には、その先頭2件を注文番号順に表示したいとします。先頭2件の選び方はそのまま、表示の順番だけ変えるのが仕様です。

この例で扱うのは、nullを含まない、桁をそろえた文字列の注文番号です。受付順はリストの並びに保持されています。

実行サンプル

元の発送キューが変わるコードと修正版、9件のJUnit、pom.xml、READMEを収録。JDK 17とMavenで、展開したディレクトリから mvn clean test を実行できます。

表示用のsubListを並べ替えると、元の発送キューも並び替わる

次は問題が起きるBrokenDispatchPreview.javaの全文です。pendingには、変更可能なArrayListを渡します。pending.subList(0, 2)で、先頭のO-003とO-001を指定します。

package example.dispatch;

import java.util.Comparator;
import java.util.List;

final class BrokenDispatchPreview {
    static List<String> sortedCodes(List<String> pending, int from, int to) {
        List<String> preview = pending.subList(from, to);
        preview.sort(Comparator.naturalOrder());
        return preview;
    }
}

preview.sortの後、表示は予定どおり[O-001, O-003]になります。表示だけ見れば成功です。ただし元のpendingを読むと、[O-001, O-003, O-002]です。元の先頭がO-003からO-001に変わり、受付順を使う次の発送処理にも影響します。

subListは、元の一部分を別のListとして扱うビュー

subList(from, to)が返すListは、元のListの範囲を参照します。今回のArrayListでは、ビュー側で要素をsetすると元の対応位置に反映され、元の要素をsetするとビューからも変わって見えます。sortがビューの要素の並びを変えると、元の範囲の並びも変わります。Java 17のList.subList。

元の範囲を意図的に削除したいなら、この性質を使えます。pending.subList(0, 2).clear()を実行すると、ArrayListの先頭2件が消え、[O-002]だけが残ります。先頭2件を元の処理待ちから消す仕様ならこの動きでよく、表示だけを変える仕様ならコピーします。

ビューを持ったまま、ビュー経由以外で元のListの要素数などを変えると、そのビューの後の動作はList.subListの契約では定まりません。後続処理に範囲だけ渡し、元のListが別の場所で編集されるなら、使う範囲をコピーしてから渡します。

表示用にコピーし、そのコピーだけを並べ替える

次は修正版DispatchPreview.javaの全文です。new ArrayList<>(pending.subList(from, to))で、その時点の範囲の要素を新しいリストへ移します。その後に並べ替えるのは、新しいリストだけです。

package example.dispatch;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

final class DispatchPreview {
    static List<String> sortedCodes(List<String> pending, int from, int to) {
        List<String> preview = new ArrayList<>(pending.subList(from, to));
        preview.sort(Comparator.naturalOrder());
        return preview;
    }
}
先頭2件を並べ替えた結果表示用の順元の発送キュー
ビューを並べ替える[O-001, O-003][O-001, O-003, O-002]
コピーを並べ替える[O-001, O-003][O-003, O-001, O-002]

独立するのは、リストの順番と、どの要素を持つかです。この例の要素は変更できないStringなので、リストを分ければ表示順を変える目的を満たせます。要素が変更できるOrderで、表示用の処理が注文の中身まで変更する場合は、同じオブジェクトを参照しているかも確認します。

終了位置は含まない。sizeは指定できる

subList(0, 2)の範囲は添字0と1です。要素数3のリストではsubList(0, 3)が全件、subList(3, 3)が空です。終了位置3を範囲の境目として指定できることと、get(3)で要素を取得できることは別です。このサンプルのArrayListでは、subList(0, 4)なら範囲外でIndexOutOfBoundsException、subList(2, 1)なら前後が逆でIllegalArgumentExceptionになります。ArrayList.subListの仕様。

開始・終了が正しくても、元のリストを編集してよいかは別の条件です。取得する添字やページ範囲が原因のときは、範囲外の診断を先に確認できます。

関連記事JavaでIndexOutOfBoundsExceptionになる原因|Listと配列の確認ポイント 添字やページ範囲が原因で例外になる場合に、どの入力と境界を確認するかを整理できます。 記事を読む

表示結果だけでなく、元の処理順もテストする

配布ZIPを展開し、pom.xmlがある場所でmvn clean testを実行します。DispatchPreviewTest.javaの9件で、表示の順と元の順、ビューでの要素置換・範囲削除、範囲の境界を確認できます。

DispatchPreviewTest.javaの全文を見る
package example.dispatch;

import java.util.ArrayList;
import java.util.List;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class DispatchPreviewTest {
    private static final List<String> PROCESS_ORDER = List.of("O-003", "O-001", "O-002");

    @Test
    void sortingTheViewAlsoReordersTheProcessingQueue() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);

        List<String> preview = BrokenDispatchPreview.sortedCodes(pending, 0, 2);

        assertEquals(List.of("O-001", "O-003"), preview);
        assertEquals(List.of("O-001", "O-003", "O-002"), pending);
    }

    @Test
    void sortingTheCopyKeepsTheProcessingQueueInOrder() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);

        List<String> preview = DispatchPreview.sortedCodes(pending, 0, 2);

        assertEquals(List.of("O-001", "O-003"), preview);
        assertEquals(PROCESS_ORDER, pending);
    }

    @Test
    void previewAndQueueCanBeEditedIndependently() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);
        List<String> preview = DispatchPreview.sortedCodes(pending, 0, 2);

        preview.set(0, "O-009");
        assertEquals(PROCESS_ORDER, pending);

        pending.clear();
        assertEquals(List.of("O-009", "O-003"), preview);
    }

    @Test
    void settingAnElementThroughEitherViewIsReflectedInTheOther() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);
        List<String> view = pending.subList(0, 2);

        view.set(0, "O-008");
        assertEquals("O-008", pending.get(0));

        pending.set(1, "O-009");
        assertEquals(List.of("O-008", "O-009"), view);
    }

    @Test
    void clearingTheViewRemovesTheSelectedRangeFromTheQueue() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);

        pending.subList(0, 2).clear();

        assertEquals(List.of("O-002"), pending);
    }

    @Test
    void fromEqualToToIsAnEmptyRangeEvenAtSize() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);

        assertEquals(List.of(), DispatchPreview.sortedCodes(pending, 3, 3));
    }

    @Test
    void sizeIsAValidExclusiveEnd() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);

        assertEquals(List.of("O-001", "O-002", "O-003"),
                DispatchPreview.sortedCodes(pending, 0, pending.size()));
    }

    @Test
    void endPastSizeIsRejected() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);

        assertThrows(IndexOutOfBoundsException.class,
                () -> DispatchPreview.sortedCodes(pending, 0, 4));
    }

    @Test
    void reversedRangeIsRejected() {
        List<String> pending = new ArrayList<>(PROCESS_ORDER);

        assertThrows(IllegalArgumentException.class,
                () -> DispatchPreview.sortedCodes(pending, 2, 1));
    }
}
pom.xmlの全文を見る
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>java-sublist-view-copy</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.3</version>
      </plugin>
    </plugins>
  </build>
</project>

通常は9件が成功します。修正版からnew ArrayList<>(...)を外すと、元の受付順が残るテストと、元・表示を別々に編集できるテストの2件が失敗します。表示用の結果は同じなので、assertEqualsを表示のリストだけに書くと今回の不具合を見逃します。

レビューでは、並べ替えが影響してよいリストを確認する

レビューでは「先頭2件の選び方はそのままでよいです。ここでsortすると、発送処理が使う受付順も変わります。表示用のコピーだけを並べ替え、元が[O-003, O-001, O-002]のままか確かめられますか?」と伝えます。

  • 範囲の終了位置が、最後に含める要素の一つ先になっている。
  • ビューを変更したとき、元の順序や要素が変わってよいか決めている。
  • 元のListを残す仕様なら、表示結果と元の状態を両方テストした。
  • 要素の中身も変更する場合は、要素の参照が共有されるか確認した。

Listの操作を基礎から復習するなら

Listの追加、取得、走査を自分のコードで書くところから復習したい場合は、『スッキリわかるJava入門 第5版』が候補です。基礎を一通り動かしてから今回の例に戻ると、どの変数を通してリストの中身を読んでいるかを整理しやすくなります。

書籍「スッキリわかるJava入門 第5版」の表紙
商品画像:Amazon.co.jp
PR Java初学者向け

スッキリわかるJava入門 第5版

中山 清喬 (著), 国本 大悟 (著), 株式会社フレアリンク (監修)

変数・条件分岐から、クラスの基本まで学ぶ一冊。

豊富な図とサンプルで、変数、条件分岐、クラス、例外処理まで順番に学べます。Javaの最初の教科書を探している人向けです。

  • Javaを基礎から体系的に学びたい
  • Java 25 LTS対応の入門書を選びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事JavaでListをコピーしても元データが変わる理由|浅いコピーと深いコピー リストを分けた後も、可変な要素の中身が共有される場合を確認できます。 記事を読む 関連記事Java初心者におすすめの最初の1冊|Spring Bootに進むまでの学び方 Listを使う前に、変数やメソッドの読み方から学び直したいとき、最初の1冊を選べます。 記事を読む

まとめ

subListは元のListの一部分を扱うため、ビューの変更は元の範囲にも影響します。表示順だけ変えたいなら、範囲をnew ArrayList<>(...)でコピーしてから並べ替えます。今回の受付順のように、後続処理が使う元の値もテストに残します。

-実装・エラー解決
-, ,