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

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

введите описание изображения здесь

4b9b3361

Ответ 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, но даже это фактически не гарантируется текущей спецификацией. Его важно, что это упущение скоро исправится...