Закрытие в 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 не всегда имеет эту по значению/моментальной копии семантику (если можно так выразиться) при захвате значений? Разве это не было бы чище и иметь больше смысла?
Ответы
Ответ 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
, но считаются окончательными компилятором в соответствии с тем, как он читается и записывается. В принципе, если вы добавите последнее ключевое слово в объявление эффективно конечной переменной, компилятор не будет жаловаться на это.