ScalaとKotlinの分割代入(っぽい機構)の比較

2021/09/12に公開

はじめに

世のプログラミング言語には「分割代入(Destructuring Assignment)」と呼ばれる機構を持つものがあります。

メジャーな所では ECMAScript/TypeScript や Python, PHP, Ruby などでしょうか。

実のところ Scala と Kotlin はこの分割代入という言語機構を持っていません。代わりに別の機構によって似たような目的を達成しています。

この記事では両者がとったアプローチを比較してそれぞれの違いについて紹介します。

使用する言語のバージョンは以下の通りです。

  • TypeScript 4.4
  • Scala 3.0.2
  • Kotlin 1.5.30

ECMAScript/TypeScript における分割代入の例

const [x, ...xs] = [1, 2, 3] 

console.log(x)  // 1
console.log(xs) // [2, 3]

ECMAScript/TypeScript ではネストした構造にも対応しています。

const [{a, x}, {b, y}] = [{a: 1, x: "A"}, {b: 2, y: "B"}]

console.log(a, x) // 1 と "A"
console.log(b, y) // 2 と "B"

もちろん分割代入なので、再代入も可能です。

let z  = 0
let zs = []
[z, ...zs] = [1, 2, 3]

console.log(z)  // 1
console.log(zs) // [2, 3]

ECMAScript/TypeScript ではforループや関数の引数で分割代入を利用することもできます。

type User = {
  id: number;
  name: string;
}
const users: User[] = [{id: 1, name: 'foo'}, {id: 2, name: 'bar'}]

for (const {id, name} of users) { // forループでの分割代入
  console.log(id, name)
}
const v = users.map(({id, name}) => `${id}:${name}`)  // アロー関数での分割代入

Scala の変数定義におけるパターンの利用

Scalaでは変数の定義時にパターンマッチのパターンを使うことができます。

val List(x, xs*) = List(1, 2, 3)

println(x)  // 1
println(xs) // List(2, 3)

当然パターンなので、ネストした構造に対しても分解することができます。

val list = List((1, "A"), (2, "B"))

val List((a, x), (b, y)) = list

println(a) // 1
println(x) // "A"
println(b) // 2
println(y) // "B"

case class も標準でパターンとして利用できるので、値の抽出も同様に行えます。値の construct と extract が同じ記述になる所にも注目ですね。

case class User(id: Int, name: String)

val user = User(1, "gakuzzzz")
val User(id, name) = user

println(id)   // 1
println(name) // "gakuzzzz"

パターンが使えるので何なら ECMAScript/TypeScript では難しかった「リストの末尾とそれ以外に分割する」みたいなものも簡単に書けます。

val a :+ b = List(1, 2, 3)

println(a) // List(1, 2)
println(b) // 3

ただしパターンに合致しない場合はコンパイルエラーにならず実行時に MatchError になってしまうので気をつけましょう。

val List(a, b) = List(1, 2, 3) // 数が合ってない

scala.MatchError: List(1, 2, 3) (of class scala.collection.immutable.$colon$colon)
  ... 38 elided

また変数の定義のみに使える構文なため、分割代入とは異なり再代入には利用できません。

var x: Int = 0
var xs: List[Int] = Nil

List(x, xs*) = List(1, 2, 3) // compile error!

Scala では for式でもパターンマッチを利用することができます。

case class User(id: Int, name: String)
val users: List[User] = List(User(1, "foo"), User(2, "bar"))

for (User(id, name) <- users) {
  println(id)
  println(name)
}

しかし ECMAScript/TypeScript や Kotlin と異なり、通常の無名関数の引数にはパターンを使うことができません。

case class User(id: Int, name: String)
val users: List[User] = List(User(1, "foo"), User(2, "bar"))

val v = users.map { User(id, name) => s"$id:$name" } // compile error!

そのかわり引数にパターンマッチが適用できるパターンマッチ無名関数という構文が別に用意されています。

case class User(id: Int, name: String)
val users: List[User] = List(User(1, "foo"), User(2, "bar"))

val v = users.map { case User(id, name) => s"$id:$name" } // これはOK!

パターンマッチなので複数のパターンで分岐する場合も簡潔に書くことができます。

val v = users.map { 
  case User(id, "foo") => s"$id:piyopiyo" 
  case User(id, "bar") => s"$id:poyopoyo" 
  case -               => s"payopayo" 
}

Kotlin の分解宣言(Destructuring Declarations)

Kotlin では 分割代入(Destructuring Assignment) の代わりに分解宣言(Destructuring Declarations) と呼ばれる機構が用意されています。

余談ですがどちらもDestructuringなのに訳語として分割と分解という異なる語を充てているのは興味深いですね。

val (a, b) = listOf(1, 2, 3)

println(a) // 1
println(b) // 2

わざわざ用語を分けたのは、Scalaと同様に、変数の宣言時のみ利用でき再代入では利用できないからと推測します。

var a: Int
var b: Int

(a, b) = List(1, 2, 3) // compile error

Kotlinの分解宣言は componentN メソッド呼び出しに置き換えられることで実現されています。

val list = listOf(1, 2, 3)
val (a, b) = list

// 上記コードは以下のようにコンパイルされる

val list = listOf(1, 2, 3)
val a = list.component1()
val b = list.component2()

data class は標準で componentN メソッドを提供するので data class の値の抽出も行えます。

data class User(val id: Int, val name: String)

val user = User(1, "gakuzzzz")
val (id, name) = user

println(id)   // 1
println(name) // "gakuzzzz"

Scala と比較すると値の construct と extract が同じ記述にはなりませんが、逆に言うと全ての data class がタプルと同じ様に扱えるという訳です。

また ECMAScript/TypeScript や Scala と異なり、ネストした構造に対して分解はできません。

val list = listOf(Pair(1, "A"), Pair(2, "B"))

val ((a, x), (b, y)) = list // compile error!

同様に ECMAScript/TypeScript や Scala では可能だった「リストの残りを受け取る」といった事もできません。

val list = listOf(1, 2, 3)

val (x, xs*)   = list // compile error! このような構文はありません
val (x, ...xs) = list // compile error! このような構文はありません

またリストを分解する際、変数よりも要素が多い場合は問題ありませんが、要素数が少なかった場合はコンパイルエラーにならず実行時エラーになるので注意が必要です。

val list = listOf(1)

val (a, b) = list

Exception in thread "main" java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
 at java.util.Collections$SingletonList.get (Collections.java:4817) 

要素が多い場合でも、リストには component5 までしか定義されていませんので 6個以上の変数に分解しようとしてもコンパイルエラーになります。

val list = listOf(1, 2, 3, 4, 5, 6, 7)

val (a, b, c, d, e, f) = list // compile error!

これらの制限も全て componentN に置き換えられるという事がわかれば理解しやすいかと思います。

また Kotlin では ECMAScript/TypeScript と同様にforループやLambda構文の引数にも分解宣言を利用することができます。

data class User(val id: Int, val name: String)
val users: List<User> = listOf(User(1, "foo"), User(2, "bar"))

for ((id, name) in users) {
  println(id)
  println(name)
}
val v = users.map { (id, name) ->  "$id:$name" }

まとめ

  • Scala も Kotlin も分割代入そのものはありませんが、変数定義時に値を分解して取り出す機構があります。
  • Scala は変数定義時にパターンマッチのパターンを使えます。
    • パターンとして成立していればOKなので非常に柔軟に値を分解することができます。
    • マッチしない場合は実行時例外になるので注意が必要です。
  • Kotlin は 変数定義時に componentN 呼び出しに置き換える分解宣言構文があります。
    • 全てのdata classなどをタプルとして同一に扱う事ができます。
    • 単純なメソッド呼び出しに置き換わる形なので柔軟性は高くありません。

Discussion