Kotlin/JS/Java: ラムダから外の変数を参照するときの注意点

Kotlin の無名関数 (ラムダ) は、外側のスコープにある変数を参照することができる。

うっかりして、ループ内で変更している変数を参照してしまったので、忘れないようにメモ。

今日から明後日までの日付を出力しようとして、次のように書いたとする。

val l = mutableListOf<() -> Unit>()
var date = LocalDate.now()
for (i in 0..2) {
    l.add { println("$i $date") }
    date = date.plusDays(1)
}

for (f in l) f()

実行してみると、

0 2018-10-19
1 2018-10-19
2 2018-10-19

変数 datevar で宣言されていて、ループを抜けた時点では 3 日後の日付に変わってしまっているため、その後で関数を実行すると、必ず 3 日後の日付が出力されるようになってしまった。

これを修正するには、より狭いスコープで別の変数を用意する。

val l = mutableListOf<() -> Unit>()
var date = LocalDate.now()
for (i in 0..2) {
    val d = date
    l.add { println("$i $d") }
    date = date.plusDays(1)
}

for (f in l) f()

実行結果は、期待どおり。

0 2018-10-16
1 2018-10-17
2 2018-10-18

この例では val を使ったけれど、重要なのは可変・不変 (再代入の可否) ではなくスコープなので、var を使ったとしても同じ出力が得られる。

ループで使っている変数 i の方は修正前でも意図どおりの値が表示されていたけれど、これは for の繰り返しに使う変数のスコープが「ループの内側」固定になるため。

JavaScript の場合

そういえば、JavaScript でも同じだった。

var i, j;
var a = new Array();
var date = new Date(Date.now());
for (i = 0; i < 3; i++) {
  a.push(function() {
    console.log(i + ' ' + date.toLocaleDateString());
  });
  date = new Date(date.getTime() + 24 * 60 * 60 * 1000);
}

for (j = 0; j < a.length; j++) {
  var f = a[j];
  f();
}

これの出力結果は、

3 2018/10/19
3 2018/10/19
3 2018/10/19

JavaScript の場合、元々はブロックスコープがなく、関数スコープだけだったので、ループ内に狭いスコープを作るためだけに無名関数を作って呼び出していたりした。

var i, j;
var a = new Array();
var date = new Date(Date.now());
for (i = 0; i < 3; i++) {
  (function() {
    var n = i;
    var d = date;
    a.push(function() {
      console.log(n + ' ' + d.toLocaleDateString());
    });
  })();
  date = new Date(date.getTime() + 24 * 60 * 60 * 1000);
}

for (j = 0; j < a.length; j++) {
  var f = a[j];
  f();
}

結果はこう。

0 2018/10/16
1 2018/10/17
2 2018/10/18

ES2015 では、ブロックスコープで変数を宣言できるようになった。var の代わりに、不変 (再代入不可) なら const、可変 (再代入可) なら let を使う。for ループの変数に let を付けると、スコープはループの内側になる。

ついでに、同じく ES2015 で導入されたアロー関数も使うと、さらに短くなる。

const a = new Array();
let date = new Date(Date.now());
for (let i = 0; i < 3; i++) {
  let d = date;
  a.push(() => {
    console.log(i + ' ' + d.toLocaleDateString());
  });
  date = new Date(date.getTime() + 24 * 60 * 60 * 1000);
}

for (let i = 0; i < a.length; i++) {
  const f = a[i];
  f();
}

Java の場合

Java にはラムダがあるけれど、仕組みの都合上、クロージャは作れない。

昔、「Javaクロージャが入るらしいぞ」と聞いたけれど、数年するうちに「クロージャは議論がまとまらなくて大変らしい」という噂が聞こえてきて、最終的に Java 7 に入ったのは、メソッドひとつだけの (インタフェースや抽象クラスに対する実装の) クラスを簡単にかけるものとしてのラムダ式だった。

final な変数ならキャプチャできるけれど、これはつまり、Java のラムダでは仕組み上、変数自体を記憶できないため、代わりに値を保存しているからだ。

var l = new ArrayList<Runnable>();
var date = LocalDate.now();
for (var i = 0; i < 3; i++) {
    final var n = i;
    final var d = date;
    l.add(() -> {
        System.out.println(n + " " + d);
    });
    date = date.plusDays(1);
}

for (var r : l) {
    r.run();
}

これで結果は正しく出る。

0 2018-10-16
1 2018-10-17
2 2018-10-18

ラムダ導入以前の Java 6 時代だともっと長くなる。JSR-310 もなかったから、こんな感じかな。

List<Runnable> l = new ArrayList<Runnable>();
Date date = new Date();
for (int i = 0; i < 3; i++) {
    final int n = i;
    final Date d = date;
    l.add(new Runnable() {
        @Override
        public void run() {
            System.out.printf("%d %tF%n", n, d);
        }
    });
    date = new Date(date.getTime() + TimeUnit.DAYS.toMillis(1));
}

for (Runnable r : l) {
    r.run();
}

結果はこう。

0 2018-10-16
1 2018-10-17
2 2018-10-18