Почему лямбда должна захватывать охватывающий экземпляр при ссылке на конечное поле 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, но даже это фактически не гарантируется текущей спецификацией. Его важно, что это упущение скоро исправится...