Kotlin

[Kotlin][Bug] 配列の copyOfRange・slice・sliceArray の挙動について

(2018-09-13追記)

本件は Kotlin 1.3-M2 で ArrayIndexOutOfBoundsException をスローするように修正されました。以下は修正前の情報です。
Kotlin 1.2.61 では修正されていないので引き続き注意して下さい。
(Issue

1.2.x: publish a note per section 3.1
1.3: change the behavior as proposed

とあるので 1.2.x は注記だけして実装は修正しないのだと思うんですが、これって何の文書の section 3.1 なんですかね?)

概要

各種配列の copyOfRange(fromIndex: Int, toIndex: Int) と、それを内部的に使用している slice(indices: IntRange)sliceArray(indices: IntRange) に、インデックスを超える値を指定した場合の挙動がややバグっているので注意。
引数が IntRange でない方の slice(indices: Iterable<Int>)、sliceArray(indices: Collection<Int>) は問題ない。
List の slice(indices: IntRange)、slice(indices: Iterable<Int>) も問題ない。
Kotlin 1.0.7/1.1.3/1.2-M1 で確認。

Issue には既に登録されていた: KT-19489 Array.copyOfRange returns value violating declared type when bounds are out of range

サンプル

// 元配列のインデックスを超える範囲を指定しても ArrayIndexOutOfBoundsException は発生しない
val array: Array<Int> = arrayOf(1, 2, 3).copyOfRange(0, 6)

// non-null な型に null が格納されている
println(array.toList()) // => [1, 2, 3, null, null, null]

// nullable な型ではアクセスできる
println((array as Array<Int?>)[4]) // => null

// non-null な型のまま参照するとエラー
println(array[4]) // => NullPointerException が発生

類似メソッドの挙動はこんな感じ。

arrayOf(1, 2, 3).slice(0..5) // => [1, 2, 3, null, null, null]
arrayOf(1, 2, 3).slice((0..5).toList()) // ArrayIndexOutOfBoundsException が発生

arrayOf(1, 2, 3).sliceArray(0..5) // =>  [1, 2, 3, null, null, null]
arrayOf(1, 2, 3).sliceArray((0..5).toList()) // ArrayIndexOutOfBoundsException が発生

listOf(1, 2, 3).slice(0..5) // IndexOutOfBoundsException が発生

なお、Array<T> でなく IntArray や BooleanArray の場合、null ではなく 0 や false が埋められるため参照時にも例外が発生しない
意図的な指定でない場合、表面上エラーを吐かないまま間違った値を生成し続ける可能性があるので、ある意味こちらの方が深刻な問題を生じる可能性もある。

原因

Array<T>.copyOfRange は内部的には java.util.Arrays.copyOfRange を呼んでいるだけなのだが、その java.util.Arrays.copyOfRange の仕様はこうなっている。

範囲の最後のインデックス(to)は、from以上でなければならず、original.lengthより大きくてもかまいません。
その場合、インデックスがoriginal.length - from以上のコピーのすべての要素にnullが配置されます。 1

ところが、Array<T>.copyOfArray はこれを Array<T?> ではなく Array<T> で返してしまっている。
要するに nullable なプラットフォーム型を (ちゃんと仕様を確認しないで) non-null として返してしまっているのが直接の原因である。

もっとも、slice/sliceArray ではそれ以前の問題として同名の関数間で振る舞いが異なってしまっているので、本来的には ArrayIndexOutOfBoundsException をスローする方向に統一されるべきだろう。

問題点

意図的に範囲外の値を指定することはあまりないと思うのだが、バグで意図せず指定してしまった場合、即座には例外が発生せず、実際に値を参照するまで例外の発生が遅延してしまう。
また、発生する例外が NullPointerException というのも原因からは適切ではない。
いずれも、バグを作りこんでしまったときに原因の特定を困難にしてしまう可能性がある。

対処A: ArrayIndexOutOfBoundsException がスローされるようにする

基本的には類似メソッドへの置き換えで対処できるので、新規コードの実装時には暫定的にそちらを使っておく方が無難か。
動いているコードに手を入れる必要があるかは微妙。

// 部分配列の取得
// NG
array.copyOfRange(from, toExclusive)
array.sliceArray(from unitl toExclusive)
// OK
array.sliceArray((from until toExclusive).toList())

//部分リストの取得
// NG
array.slice(from..toInclusive)
// OK
array.slice((from..toInclusive).toList())
array.toList().slice(from..toInclusive)

事情を知らないと無駄な変換をしているようにしか見えないと思うので、別メソッドに切り出してコメントを書くなりしておいた方がいいかも知れない。

// KT-19489 対策を兼ねて Ruby スタイルの slice を定義
operator fun <T> Array<T>.get(range: IntRange) = sliceArray(range.toList())

// 使い方
val array = arrayOf(0, 1, 2, 3, 4)
array[1..3] // => [1, 2, 3]

もしくは、個人的にはそもそも特別な事情がない限りは配列でなくリストを使う方が好ましいと思っているので、API 含めてリストにできる部分はリストに書き換えてしまって解決するのもよいと思う。

なお、slice/sliceArray の引数を .. 演算子 (Int.rangeTo) で生成した場合は終端を含むが、copyOfRange は含まないので、修正の際は off-by-one エラーを出さないよう注意。
copyOfRange(from, to) と等価なのは sliceArray(from..to) ではなく sliceArray(from until to) になる。

対処B: NullPointerException がスローされないようにする

Java と同様の挙動 (配列の範囲を超える部分に null を埋める) が必要な場合は、型を null 許容型に変換すれば済む。

// 配列は不変なので Array<Int> を Array<Int?> に格納するには out を指定する必要がある
val copyOfRange: Array<out Int?> = array.copyOfRange(from, toExclusive)
val sliceArray: Array<out Int?> = array.sliceArray(from..toInclusive)

// リストは共変なので List<Int> はそのまま List<Int?> に格納できる
val slice: List<Int?> = array.slice(from..toInclusive)

ただ、今後これらの関数が ArrayIndexOutOfBoundsException をスローするように修正される可能性もあるので、Array<T>.copyOfRange ではなく java.util.Arrays.copyOfRange を直接使用する方が確実かも知れない。

val copyOfRange: Array<Int?> = java.util.Arrays.copyOfRange(array, from, toExclusive)

  1. この辺り、Java のドキュメントには詳細に仕様が記載されているのに対して、Kotlin のドキュメントはそもそも境界やら異常系やらについての記述が一切なく、「Returns new array which is a copy of range of original array」の一言で済ませてしまっているのはどうなのか…。