Подтвердить что ты не робот

Закрытие в Java - Захваченное значение - почему этот неожиданный результат?

Я думаю, что встретил эту классическую ситуацию в JavaScript.

Обычно программист ожидает, что этот код ниже будет напечатан "Питер", "Пол", "Мэри".

Но это не так. Может ли кто-нибудь объяснить, почему это работает на Java?

Этот код Java 8 компилируется и печатает 3 раза "Мэри".

Я предполагаю, что это вопрос того, как он реализован в глубине души но... разве это не означает неправильную реализацию?

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

public class Test008 {

    public static void main(String[] args) {
        String[] names = { "Peter", "Paul", "Mary" };
        List<Runnable> runners = new ArrayList<>();

        int[] ind = {0};
        for (int i = 0; i < names.length; i++){ 
            ind[0] = i;
            runners.add(() -> System.out.println(names[ind[0]]));
        }

        for (int k=0; k<runners.size(); k++){
            runners.get(k).run();
        }

    }

}

Напротив, если я использую расширенный цикл for (при добавлении Runnables), будут зафиксированы правильные (т.е. все разные) значения.

for (String name : names){
    runners.add(() -> System.out.println(name));
}

Наконец, если я использую цикл classic для цикла (при добавлении Runnables), тогда я получаю ошибку компиляции (что имеет смысл, поскольку переменная i не является окончательной или фактически окончательной).

for (int i = 0; i < names.length; i++){ 
    runners.add(() -> System.out.println(names[i]));
}

EDIT:

Моя точка зрения: почему не значение names[ind[0]] уловлено (значение, которое оно имеет в момент добавления Runnables)? Не важно, когда я выполняю лямбда-выражения, не так ли? Я имею в виду, ОК, в версии с расширенным циклом, я также запускаю Runnables позже, но правильные/различные значения были записаны ранее (при добавлении Runnables).

Другими словами, почему Java не всегда имеет эту по значению/моментальной копии семантику (если можно так выразиться) при захвате значений? Разве это не было бы чище и иметь больше смысла?

4b9b3361

Ответ 1

Другими словами, почему Java не всегда имеет это значение с помощью семантики value/snapshot (если я могу сказать это так) при захвате значений? Разве это не было бы чище и имело бы смысл?

Java lambdas имеют семантику захвата по значению.

Когда вы выполните:

runners.add(() -> System.out.println(names[ind[0]]));

здесь лямбда фиксирует два значения: ind и names. Оба эти значения являются объектными ссылками, но ссылка на объект - это значение, подобное "3". Когда ссылка захваченного объекта ссылается на изменяемый объект, это то, где вещи могут запутаться, потому что их состояние во время захвата лямбда и их состояние во время вызова лямбда могут быть разными. В частности, массив, к которому относится ind, изменяет таким образом, что является причиной вашей проблемы.

То, что лямбда не захватывает, - это значение выражения ind[0]. Вместо этого он захватывает ссылку ind, и, когда вызывается лямбда, выполняется разыменование ind[0]. Lambdas закрывает значения из их лексически охватывающей области; ind - значение в лексически охватывающей области, но ind[0] - нет. Мы фиксируем ind и используем его еще во время оценки.

Вы как-то ожидаете здесь, что лямбда сделает полный снимок всех объектов во всей куче, которые достижимы через захваченные ссылки, но это не так, как это работает - и это не будет иметь большого смысла.

Сводка: Lambdas захватывает все захваченные аргументы - включая ссылки на объекты - по значению. Но ссылка на объект и объект, к которому он относится, - это не одно и то же. Если вы фиксируете ссылку на изменяемый объект, состояние объекта может быть изменено к моменту вызова лямбда.

Ответ 2

Я бы сказал, что код делает именно то, что вы ему говорите.

Вы создаете "Runnable", которые печатают имя в позиции от ind[0]. Но это выражение оценивается во втором цикле for-loop. И в этот момент ind[0]=2. Таким образом, выражение печатает "Mary".

Ответ 3

Когда вы создаете выражение лямбда, вы не выполняете его. Он выполняется только во втором цикле for, когда вы вызываете метод run для каждого Runnable.

Когда выполняется второй цикл, ind[0] содержит 2, поэтому "Mary" печатается при выполнении всех методов run.

EDIT:

Улучшенный для цикла ведет себя по-другому, потому что в этом фрагменте выражение лямбда содержит ссылку на экземпляр String, а String неизменен. Если вы измените String на StringBuilder, вы можете создать пример с расширенным циклом, который также печатает окончательное значение экземпляров, на которые ссылаются:

StringBuilder[] names = { new StringBuilder().append ("Peter"), new StringBuilder().append ("Paul"), new StringBuilder().append ("Mary") };
List<Runnable> runners = new ArrayList<>();

for (StringBuilder name : names){
  runners.add(() -> System.out.println(name));
  name.setLength (0);
  name.append ("Mary");
}

for (int k=0; k<runners.size(); k++){
  runners.get(k).run();
}

Выход:

Mary
Mary
Mary

Ответ 4

он работает отлично.. при вызове метода println ind[0] есть 2, поскольку вы используете общую переменную и увеличиваете ее значение перед вызовом функции. поэтому он всегда будет печатать Mary. Вы можете сделать следующее, чтобы проверить

    for (int i = 0; i < names.length; i++) {
        final int j = i;
        runners.add(() -> System.out.println(names[j]));
    }

это напечатает все имена по желанию

или объявить ind локально

    for (int i = 0; i < names.length; i++) {
        final int[] ind = { 0 };
        ind[0] = i;
        runners.add(() -> System.out.println(names[ind[0]]));
    }

Ответ 5

Другой способ объяснить это поведение - посмотреть, что заменяет лямбда-выражение:

runners.add(() -> System.out.println(names[ind[0]]));

- синтаксический сахар для

runers.add(new Runnable() {
    final String[] names_inner = names;
    final int[] ind_inner = ind;
    public void run() {
        System.out.println(names_inner[ind_inner[0]]));
    }
});

которая превращается в

// yes, this is inside the main method body
class Test008$1 implements Runnable {
    final String[] names_inner;
    final int[] ind_inner;

    private Test008$1(String[] n, int[] i) {
        names_inner = n; ind_inner = i;
    }

    public void run() {
        System.out.println(names_inner[ind_inner[0]]));
    }
}
runers.add(new Test008$1(names,ind));

(Имена сгенерированного материала на самом деле не имеют значения. Знак доллара - это просто символ, который часто используется в именах сгенерированных методов/классов в Java. Поэтому он зарезервирован. )

Два внутренних поля добавляются, так что код внутри Runnable может видеть переменные вне его; если names и ind были определены как final во внешнем коде (основной метод), внутренние поля не нужны. Это так, что переменные могут передаваться по значению, как объясняет Брайан Гетц в своем ответе.

Массивы подобны объектам, в том случае, если вы передадите их методу, который их модифицирует, они также будут изменены в исходном местоположении; остальное - это просто основы ООП. Таким образом, это на самом деле правильное поведение.

Ответ 6

Хорошо, перед лямбдой у Java было более строгое правило для захвата локальных/переменных параметров в анонимных классах. То есть допускается захват только переменных final. Я считаю, что это предотвращение путаницы concurrency - конечные переменные не будут меняться. Поэтому, если вы передаете некоторые экземпляры, созданные таким образом, в очередь задач и выполняете их другим потоком, по крайней мере вы знаете, что два потока имеют одно и то же значение. И более глубокая причина объясняется Почему только конечные переменные доступны в анонимном классе?

Теперь, с lambdas и Java 8, вы можете захватывать переменные, которые являются "фактически окончательными", которые являются переменными, которые не объявлены final, но считаются окончательными компилятором в соответствии с тем, как он читается и записывается. В принципе, если вы добавите последнее ключевое слово в объявление эффективно конечной переменной, компилятор не будет жаловаться на это.