Почему лямбда должна захватывать охватывающий экземпляр при ссылке на конечное поле String?
Это основан на этом вопросе. Рассмотрим этот пример, когда метод возвращает Consumer
на основе выражения лямбда:
public class TestClass {
public static void main(String[] args) {
MyClass m = new MyClass();
Consumer<String> fn = m.getConsumer();
System.out.println("Just to put a breakpoint");
}
}
class MyClass {
final String foo = "foo";
public Consumer<String> getConsumer() {
return bar -> System.out.println(bar + foo);
}
}
Как мы знаем, это не очень хорошая практика, чтобы ссылаться на текущее состояние внутри лямбда при выполнении функционального программирования, одна из причин заключается в том, что лямбда захватит охватывающий экземпляр, который не будет собираться мусором, пока сама лямбда не окажется вне сфера.
Однако в этом конкретном сценарии, связанном с строками final
, кажется, что компилятор мог просто вставить строку константы (final
) foo
(из пула констант) в возвращаемой лямбда, вместо того, чтобы вставлять весь MyClass
экземпляр, как показано ниже, при отладке (размещение прерывания на System.out.println
). Это связано с тем, как lambdas скомпилированы в специальный invokedynamic
байт-код?
![введите описание изображения здесь]()
Ответы
Ответ 1
Если лямбда захватывала foo
вместо this
, вы могли бы в некоторых случаях получить другой результат. Рассмотрим следующий пример:
public class TestClass {
public static void main(String[] args) {
MyClass m = new MyClass();
m.consumer.accept("bar2");
}
}
class MyClass {
final String foo;
final Consumer<String> consumer;
public MyClass() {
consumer = getConsumer();
// first call to illustrate the value that would have been captured
consumer.accept("bar1");
foo = "foo";
}
public Consumer<String> getConsumer() {
return bar -> System.out.println(bar + foo);
}
}
Вывод:
bar1null
bar2foo
Если foo
было записано лямбдой, оно будет записано как null
, а второй вызов будет печатать bar2null
. Однако, поскольку экземпляр MyClass
захвачен, он печатает правильное значение.
Конечно, это уродливый код и немного надуманный, но в более сложном, реальном коде, такая проблема может несколько легко произойти.
Обратите внимание, что единственная истинная уродливая вещь - это то, что мы вынуждаем читателя к назначаемому foo
в конструкторе через пользователя. Ожидается, что создание самого потребителя не будет читать foo
в то время, поэтому законно его построить до назначения foo
- если вы не используете его немедленно.
Однако компилятор не позволит вам инициализировать тот же consumer
в конструкторе перед назначением foo
- возможно, для лучшего: -)
Ответ 2
В вашем коде bar + foo
действительно сокращается для bar + this.foo
; мы так привыкли к сокращению, что мы забываем, что мы неявно выбираем члена экземпляра. Таким образом, ваша лямбда захватывает this
, а не this.foo
.
Если ваш вопрос: "Может ли эта функция реализована по-другому", ответ "возможно, да"; мы могли бы сделать спецификацию/реализацию лямбда-захвата произвольно более сложной в целях обеспечения пошаговой эффективности для множества особых случаев, в том числе и этого.
Изменение спецификации так, чтобы мы захватили this.foo
вместо this
, не сильно изменили бы способ работы; это все равно будет захватывающей лямбдой, что гораздо более дорогостоящее, чем дополнительное разыменование поля. Поэтому я не вижу в этом реального повышения производительности.
Ответ 3
Вы правы, это технически могло бы сделать так, потому что поле, о котором идет речь, final
, но это не так.
Однако, если проблема заключается в том, что возвращаемая лямбда сохраняет ссылку на экземпляр MyClass, вы можете легко исправить ее самостоятельно:
public Consumer<String> getConsumer() {
String f = this.foo;
return bar -> System.out.println(bar + f);
}
Обратите внимание, что если поле не было final
, тогда ваш исходный код будет использовать фактическое значение во время выполнения лямбда, а код, указанный здесь, будет использовать значение со времени getConsumer()
.
Ответ 4
Обратите внимание, что для любого обычного доступа Java к переменной, являющейся константой времени компиляции, имеет место постоянное значение, поэтому, в отличие от некоторых заявленных лиц, оно невосприимчиво к проблемам с порядком инициализации.
Мы можем продемонстрировать это в следующем примере:
abstract class Base {
Base() {
// bad coding style don't do this in real code
printValues();
}
void printValues() {
System.out.println("var1 read: "+getVar1());
System.out.println("var2 read: "+getVar2());
System.out.println("var1 via lambda: "+supplier1().get());
System.out.println("var2 via lambda: "+supplier2().get());
}
abstract String getVar1();
abstract String getVar2();
abstract Supplier<String> supplier1();
abstract Supplier<String> supplier2();
}
public class ConstantInitialization extends Base {
final String realConstant = "a constant";
final String justFinalVar; { justFinalVar = "a final value"; }
ConstantInitialization() {
System.out.println("after initialization:");
printValues();
}
@Override String getVar1() {
return realConstant;
}
@Override String getVar2() {
return justFinalVar;
}
@Override Supplier<String> supplier1() {
return () -> realConstant;
}
@Override Supplier<String> supplier2() {
return () -> justFinalVar;
}
public static void main(String[] args) {
new ConstantInitialization();
}
}
Он печатает:
var1 read: a constant
var2 read: null
var1 via lambda: a constant
var2 via lambda: null
after initialization:
var1 read: a constant
var2 read: a final value
var1 via lambda: a constant
var2 via lambda: a final value
Итак, как вы можете видеть, тот факт, что запись в поле realConstant
еще не произошла, когда выполняется супер-конструктор, не отображается неинициализированное значение для истинной константы времени компиляции, даже при обращении к ней через лямбда-выражения. Технически, потому что поле фактически не читается.
Кроме того, неприятные рефлексивные хаки не влияют на обычный доступ Java к константам времени компиляции по той же причине. Единственный способ прочитать такое модифицированное значение обратно - через Reflection:
public class TestCapture {
static class MyClass {
final String foo = "foo";
private Consumer<String> getFn() {
//final String localFoo = foo;
return bar -> System.out.println("lambda: " + bar + foo);
}
}
public static void main(String[] args) throws ReflectiveOperationException {
final MyClass obj = new MyClass();
Consumer<String> fn = obj.getFn();
// change the final field obj.foo
Field foo=obj.getClass().getDeclaredFields()[0];
foo.setAccessible(true);
foo.set(obj, "bar");
// prove that our lambda expression doesn't read the modified foo
fn.accept("");
// show that it captured obj
Field capturedThis=fn.getClass().getDeclaredFields()[0];
capturedThis.setAccessible(true);
System.out.println("captured obj: "+(obj==capturedThis.get(fn)));
// and obj.foo contains "bar" when actually read
System.out.println("via Reflection: "+foo.get(capturedThis.get(fn)));
// but no ordinary Java access will actually read it
System.out.println("ordinary field access: "+obj.foo);
}
}
Он печатает:
lambda: foo
captured obj: true
via Reflection: bar
ordinary field access: foo
который показывает нам две вещи:
- Отражение также не влияет на константы времени компиляции
- Окружающий объект был захвачен, несмотря на то, что он не будет использоваться
Id будет рад найти такое объяснение, как "любой доступ к полю экземпляра требует, чтобы лямбда-выражение захватывало экземпляр этого поля (даже если поле фактически не читается)", но, к сожалению, я не мог найти никакого заявления относительно захват значений или this
в текущей спецификации языка Java, что немного пугает:
Мы привыкли к тому, что не доступ к полям экземпляра в выражении лямбда будет создавать экземпляр, который не имеет ссылки на this
, но даже это фактически не гарантируется текущей спецификацией. Его важно, что это упущение скоро исправится...