Статьи

Ленивые вычисления в Java с ленивым типом

Я выбрал ленивого человека для тяжелой работы.
Потому что ленивый человек найдет простой способ сделать это. — Билл Гейтс

Говорят, что Java является строгим языком, что означает, что выражения оцениваются с нетерпением, как только они объявлены, в противовес ленивым языкам, в которых выражения оцениваются только тогда, когда необходимо их значение. Разница важна, потому что некоторые значения выражения могут никогда не понадобиться, в зависимости от некоторых условий.

Ленивая оценка экономит вычислительные ресурсы, избегая ненужных вычислений. Лень позволяет манипулировать данными, которые невозможно использовать в вычислениях, такими как бесконечные списки, или конечными данными, которые переполняют доступную память, такими как огромные файлы. Тогда важно уметь использовать ленивые вычисления даже в строгих по своей природе языках, таких как Java. На таких языках нам нужно научиться реализовывать ленивые типы, что мы и сделаем в этой статье.

В предыдущей статье я объяснил, что значит лень . Я говорил о том, где может быть реализована лень (на уровне языка или на уровне типа) и когда в конечном итоге произойдет оценка. Мы видели, что вызов по имени приводит к тому, что оценка происходит каждый раз, когда требуется значение, тогда как вызов по необходимости состоит из оценки значения по первой необходимости и сохранения его на случай, если оно понадобится позже. Мы также видели, что лень позволяет экономить ресурсы обработки в случае, если значение в конечном итоге не понадобится. Но это еще не все.

Абстрагирование вычислительных контекстов

Лень — это не только шаблон для экономии ресурсов. Это вычислительный контекст, который может быть применен к любому выражению. В этом смысле нельзя противопоставлять строгости (тот факт, что оценка выполняется сразу после определения выражения). Это должно быть против выражений вне контекста. Выражения могут быть использованы вне контекста или в контексте. Конечно, можно утверждать, что все выражения определены в контексте программы. Итак, допустим, что некоторые выражения могут быть определены на дополнительном уровне контекста. А поскольку все выражения определены в контексте программы (насколько нам известно), мы забудем об этом «верхнем» контексте и рассмотрим только дополнительный уровень контекста.

Лень — это особый контекст. Когда выражение помещается в этот контекст, это просто означает, что оно может быть еще не оценено. И, кроме того, включение его в этот контекст мешает оценке до тех пор, пока мы не хотим, чтобы это произошло. Обратите внимание, что мы могли бы, однако, поместить в контекст лени выражение, которое уже оценено. Это, конечно, ничего не изменит в отношении оценки, но может быть полезно объединить выражения, как мы скоро увидим.

Есть много других возможных вычислительных контекстов. Мы могли бы манипулировать выражениями, для оценки которых требуется много времени. Эти выражения могут быть помещены в другой контекст, в котором оценка начнется немедленно, но позволяет нам манипулировать ими до завершения оценки. Или мы могли бы поместить выражения в определенный контекст, позволяющий нам прозрачно иметь дело с ошибочными условиями. В таком контексте выражение будет оцениваться либо по его результату, либо по ошибке. Но как насчет оценки, которая займет много времени и может привести к ошибке? Ну, мы могли бы определить конкретный контекст для этого, но мы могли бы лучше составить два предыдущих контекста. Составление контекстов разных типов — интересная проблема. Но на данный момент мы рассмотрим только создание нескольких экземпляров одного и того же контекста, то есть сочинение лени. Но прежде чем смотреть на это, мы должны сначала реализовать лень типа .

Простой подход к типу лень

Программисты всегда знали, как реализовать минимальное количество лени там, где это было нужно. Самым простым способом реализации ленивого свойства, вероятно, является следующий:

private String message;

public String getMessage() {
    if (message == null) {
        message = constructMessage();
    }
    return message;
}

В этом примере свойство message Метод constructMessageif К сожалению, мы не можем использовать эту технику для аргументов метода, потому что, как только мы будем использовать свойство message

 public String message;

public String getMessage() {
    if (message == null) {
        message = constructMessage();
    }
    return message;
}

private String constructMessage() {
    System.out.println("Evaluating message");
    return "Message";
}

public void doNothingWith(String string) {
    // do nothing
}

public void testLaziness() {
    doNothingWith(getMessage());
}

В этом примере на консоли будет выведено Evaluating message Можем ли мы сделать лучше?

Использование существующего типа

Да мы можем. Мы всегда могли, но теперь намного проще, когда у нас есть лямбды и ссылки на методы. До Java 8 мы могли бы написать:

 public void doNothingWith(Supplier<String> string) {
    // do nothing
}

public void testLaziness() {
    doNothingWith((new Supplier<String>() {
        @Override
        public String get() {
            return getMessage();
        }
    }));
}

Конечно, поскольку интерфейс Supplier

 public interface Supplier<T> {

    T get();

}

В Java 8 мы можем использовать стандартный интерфейс java.util.function.Supplier

 public void testLaziness() {
    doNothingWith(() -> getMessage());
}

Или, лучше, мы можем заменить лямбду ссылкой на метод:

 public void testLaziness() {
    doNothingWith(this::getMessage);
}

Обратите внимание, что эти примеры не эквивалентны. Использование анонимного класса приведет к созданию экземпляра этого класса. Напротив, использование лямбды не приведет к созданию объекта, а только к методу. И если используется ссылка на метод, никакой метод даже не будет создан, так как указанный метод будет просто вызываться. Это имеет определенное значение с точки зрения эффективности, но не только. Основное последствие, с точки зрения программиста, заключается в том, что ссылка this

Составление Ленивых Типов

Лень, которую мы получаем с помощью Supplier Представьте, что у нас есть две ленивые строки, которые мы хотим объединить:

 private String constructMessage(
        Supplier<String> greetings, Supplier<String> name) {
    return greetings.get() + name.get();
}

Какая польза от лени, если мы вынуждены оценивать выражения для их составления? Если мы точно знаем, что нам понадобится результат композиции, имеет смысл оценить выражения перед их составлением. Но мы можем захотеть составить два ленивых выражения и получить ленивый результат. Это означает, что мы хотим составить значения в контексте, не вынимая их из контекста. Для этого нам просто нужно обернуть композицию в новый экземпляр ленивого контекста, что означает « Supplier

 private Supplier<String> constructMessage(
        Supplier<String> greetings, Supplier<String> name) {
    return () -> greetings.get() + name.get();
}

Конечно, мы должны изменить тип возвращаемого значения. Вместо того, чтобы возвращать результат составления значений, мы возвращаем программу, которая при выполнении (имеется в виду, когда вызывается Supplier.get() Вот что такое лень: маленькие программы, упаковывающие выражения, которые будут оцениваться при выполнении этих программ.

Разработка продвинутого ленивого типа

Это хорошее начало, так как оно уже более мощное, чем простая лень, но мы можем сделать намного лучше. Интерфейс Supplierget Сам по себе он не позволяет составлять ленивые значения. А что если метод get А что, если мы хотим применить функцию к этому ленивому значению, не вызывая оценки? Мы можем справиться со всеми этими проблемами внешне, но поскольку Java является объектно-ориентированным языком, мы должны делегировать эту работу самому ленивому типу. Мы можем начать с создания нашего собственного интерфейса:

 @FunctionalInterface
public interface Lazy<A> {

    A get();

}

Мы можем захотеть использовать наш интерфейс там, где ожидается Supplier Чтобы сделать это возможным, все, что нам нужно сделать, это расширить Supplier

 @FunctionalInterface
public interface Lazy<A> extends Supplier<A> { }

Лениво применяя функции

В качестве первой функции мы хотим разрешить преобразование еще не оцененного значения, лениво применяя функцию к результату оценки. Мы сделаем это, создав метод map

 default <B> Lazy<B> map(Function<A, B> f) {
    return () -> f.apply(get());
}

Возможно, нам придется иметь дело с возможностью исключения в методе get У нас есть несколько возможностей:

  • Отбрось исключение. Нам не нужно много делать для этого, поскольку это произойдет автоматически, если мы ничего не сделаем. Однако следует отметить, что исключением может быть только RuntimeException Поэтому, если оценка выдает проверенное исключение, мы должны либо обработать его на месте, либо обернуть его непроверенным до повторного выброса. Бросать исключения — это то, чего мы хотим избежать в функциональном программировании.

  • Вернуть специальный тип, указывающий, что произошла ошибка. Это может быть Optional<B> Основным недостатком является то, что Optional Это только указывает на то, что значение могло присутствовать, хотя это не так, но не может сказать, почему. В следующей статье мы увидим, как решить эту проблему.

  • Вернуть значение по умолчанию. Конечно, мы не знаем, какое значение вернуть, поэтому оно должно быть предоставлено вызывающей стороной.

Реализация случая, возвращающего значение по умолчанию, проста:

 default <B> Lazy<B> map(Function<A, B> f, B defaultValue) {
    return () -> {
        try {
            return f.apply(get());
        } catch (Exception e) {
            return defaultValue;
        }
    };
}

Это можно использовать как в следующем примере:

 static Lazy<String> name1 = () -> {
    System.out.println("Evaluating name1");
    return "Bob";
};

static Lazy<String> name2 = () -> {
    System.out.println("Evaluating name2");
    throw new RuntimeException();
};

static Function<String, String> constructMessage =
        name -> String.format("Hello, %s!", name);

public static void main(String... args) {
    String defaultValue = "Sorry, but I don't talk to anonymous people.";
    System.out.println(name1.map(constructMessage, defaultValue).get());
    System.out.println("----");
    System.out.println(name2.map(constructMessage, defaultValue).get());
}

И вот результат, отображаемый на консоли:

 Evaluating name1
Hello, Bob!
----
Evaluating name2
Sorry, but I don't talk to anonymous people.

Но, конечно, нам иногда нужно будет использовать лениво вычисленное значение по умолчанию, что довольно просто:

 default <B> Lazy<B> map(Function<A, B> f, Lazy<B> defaultValue) {
    return () -> {
        try {
            return f.apply(get());
        } catch (Exception e) {
            return defaultValue.get();
        }
    };
}

Возвращение Optional Вот возможная реализация:

 default <B> Lazy<Optional<B>> mapOption(Function<A, B> f) {
    return () -> {
        try {
            return Optional.of(f.apply(get()));
        } catch (Exception e) {
            return Optional.empty();
        }
    };
}

Это даст тот же результат, если мы немного изменим нашу примерную программу:

 public static void main(String... args) {
    String defaultValue = "Sorry, but I don't talk to anonymous people.";
    System.out.println(name1
            .mapOption(constructMessage)
            .get()
            .orElse(defaultValue));
    System.out.println("----");
    System.out.println(name2
            .mapOption(constructMessage)
            .get()
            .orElse(defaultValue));
}

Сопоставление функции, возвращающей ленивый тип

Иногда вместо Function<A, B>Lazy<B> Если мы передадим такую ​​функцию методу mapLazy<Lazy<B>> Какова бы ни была полезность быть LazyLazy<Lazy<B>>Lazy<B> Это также очень распространенный вариант использования, который обычно называется flattenjoin Вот как мы можем это реализовать:

 static <A> Lazy<A> flatten(Lazy<Lazy<A>> lla) {
    return lla.get();
}

Это хорошо, но это может быть реализовано только как статический метод. Поскольку необходимость чаще всего возникает при отображении функции, предпочтительно создать специальную версию mapflatMap

 default <B> Lazy<B> flatMap(Function<A, Lazy<B>> f) {
    return () -> f.apply(get()).get();
}

С помощью такой функции мы можем составить столько ленивых значений, сколько нам нужно, ничего не вычисляя:

 static Lazy<String> lazyGreetings = () -> {
    System.out.println("Evaluating greetings");
    return "Hello";
};

static Lazy<String> lazyFirstName = () -> {
    System.out.println("Evaluating first name");
    return "Jane";
};

static Lazy<String> lazyLastName = () -> {
    System.out.println("Evaluating last name");
    return "Doe";
};

public static void main(String... args) {
    Lazy<String> message = lazyGreetings
        .flatMap(greetings -> lazyFirstName
            .flatMap(firstName -> lazyLastName
                .map(lastName -> String.format(
                        "%s, %s %s!", greetings, firstName, lastName))));
    System.out.println("Message has been composed but nothing has been evaluated yet");
    System.out.print(message.get());
}

Результат:

 Message has been composed but nothing has been evaluated yet
Evaluating greetings
Evaluating first name
Evaluating last name
Hello, Jane Doe!

Это показывает, что ничего не оценивается до getreturn

Помещение значения в контекст

Как я уже сказал, нам иногда нужно помещать уже вычисленное выражение в ленивый контекст. Это ничего не меняет в отношении оценки, но это необходимо для составления оцененных выражений с не оцененными. Это делается статическим методом, который обычно называется unitof Java использует другое соглашение для этого, вызывая этот метод, поэтому мы определим такой метод в нашем интерфейсе:

 static <A> Lazy<A> of(A a) {
    return () -> a;
}

«Ленивые типы в Java»

Абстрагирование поведения

Реализованный нами тип LazyOptionalCompletableFutureStreamjava.util.List Наличие метода flatMapLazy<A>ALazyмонадой (при условии, что эти методы удовлетворяют некоторым дополнительным условиям). И все монады могут быть составлены из монад того же типа и с функциями, так же, как мы сделали это здесь.

Использование монад позволяет абстрагировать конкретное поведение (в данном случае лень) от того, как это поведение может быть составлено с другими элементами, предоставляя контекст, в котором могут происходить обычные вычисления, хотя эти обычные вычисления обычно не применяются. Это то, что мы видели, когда применяли Function<A, B>AA

Некоторые другие монады могут показаться очень похожими на нашу Lazy Например, Future<A>FutureCompletableFuture Большая разница с Lazy<A>LazyFuture Кроме того, все другие характеристики, такие как способ их составления с использованием mapflatMap

Лучший способ справиться с эффектами

В нашем примере мы вызывали метод get Другими словами, мы извлекли значение из его контекста, чтобы применить к нему эффект. Это отменяет эффект лени. Конечно, иногда это в конечном итоге необходимо, но мы должны стараться как можно больше откладывать это. Функциональные программисты-фундаменталисты используют для этого очень сложные методы, но для начала мы можем использовать гораздо более простые.

Преобразование, которое мы реализовали выше, состоит из введения необработанной функции в контекст лени, а не извлечения значения для передачи его функции. Мы можем сделать то же самое с эффектами. Вместо вызова getLazy Для этого мы будем использовать метод, который мы будем вызывать для forEach Это позволяет нам абстрагироваться от принципа, сходного для большинства контекстов, под одним именем.

Реализация очень проста:

 default void forEach(Consumer<A> c) {
    c.accept(get());
}

Используя этот метод, наш последний пример можно переписать так:

 public static void main(String... args) {
    Lazy<String> message = lazyGreetings
        .flatMap(greetings -> lazyFirstName
            .flatMap(firstName -> lazyLastName
                .map(lastName -> String.format(
                        "%s, %s %s!", greetings, firstName, lastName))));
    System.out.println("Message has been composed but nothing has been evaluated yet");
    message.forEach(System.out::println);
}

Обратите внимание, что наш метод forEachConsumerкаждому значению в контексте. Тот факт, что для Lazy Важно четко видеть, что это та же концепция, что и для forEachStream Это (помимо всего прочего) то, что OptionalifPresentforEachStream

Запоминание результата оценки

Наш интерфейс допускает отложенную оценку, но не позволяет повторно использовать значение после его оценки. Это означает, что если мы хотим использовать значение дважды, не пересчитывая его, мы должны где-то его сохранить. Было бы более практично, чтобы Lazy

Но интерфейс не может хранить значения, поэтому мы должны выбрать другой дизайн. Вместо создания интерфейса, расширяющего SupplierSupplier Этот класс будет хранить как Supplier<A>A

 public class Lazy<A> {

    private final Supplier<A> sValue;

    private A value;

    private Lazy(Supplier<A> value) {
        this.sValue = value;
    }

    public A get() {
        // Note that the following code is not thread safe. Thread safety
        // is not implemented here to keep the code simple, but can be
        // added easily.
        if (value == null) {
            value = sValue.get();
        }
        return value;
    }

    public <B> Lazy<B> map(Function<A, B> f) {
        return new Lazy<>(() -> f.apply(this.get()));
    }

    public <B> Lazy<B> map(Function<A, B> f, B defaultValue) {
        return new Lazy<>(() -> {
            try {
                return f.apply(this.get());
            } catch (Exception e) {
                return defaultValue;
            }
        });
    }

    public <B> Lazy<Optional<B>> mapOption(Function<A, B> f) {
        return new Lazy<>(() -> {
            try {
                return Optional.of(f.apply(this.get()));
            } catch (Exception e) {
                return Optional.empty();
            }
        });
    }

    public <B> Lazy<B> flatMap(Function<A, Lazy<B>> f) {
        return new Lazy<>(() -> f.apply(get()).get());
    }

    public void forEach(Consumer<A> c) {
        c.accept(get());
    }

    public static <A> Lazy<A> of(Supplier<A> a) {
        return new Lazy<>(a);
    }

    public static <A> Lazy<A> of(A a) {
        return new Lazy<>(() -> a);
    }

}

Обратите внимание на наличие двух статических фабричных методов, чтобы разрешить создание Lazy<A>Supplier<A>A Запуск следующего теста показывает преимущества запоминания:

 static Lazy<String> name1 = Lazy.of(() -> {
    System.out.println("Evaluating name1");
    return "Bob";
});

static Lazy<String> name2 = Lazy.of(() -> {
    System.out.println("Evaluating name2");
    throw new RuntimeException();
});

static Function<String, String> constructMessage =
        name -> String.format("Hello, %s!", name);

public static void main(String... args) {
    String defaultValue = "Sorry, but I don't talk to anonymous people.";
    name1.map(constructMessage, defaultValue).forEach(System.out::println);
    System.out.println("----");
    name2.map(constructMessage, defaultValue).forEach(System.out::println);
    System.out.println("----");
    name1.mapOption(constructMessage).forEach(System.out::println);
    System.out.println("----");
    name2.mapOption(constructMessage).forEach(System.out::println);
}

Результат показывает, что успешная оценка происходит только один раз:

 Evaluating name1
Hello, Bob!
----
Evaluating name2
Sorry, but I don't talk to anonymous people.
----
Optional[Hello, Bob!]
----
Evaluating name2
Optional.empty

Здесь результат неудачной оценки не запоминается. Это дает нам возможность повторить попытку следующего вызова, что может или не может быть полезным в зависимости от причины сбоя. Если мы хотим, чтобы ошибка запомнилась, мы можем просто сохранить Optional

Резюме

Мы узнали, как эффективно реализовать лень типа:

  • Использование Supplier
  • Составление ленивых типов, чтобы получить ленивый результат
  • Сопоставление функции, возвращающей вычисленное значение, с отложенным типом
  • Сопоставление функции, возвращающей ленивое значение ленивому типу
  • Применение эффекта к ленивым типам
  • Памятные ленивые типы

Изучая эти методы, мы обнаружили, что лень является вычислительным контекстом . Мы научились

  • Поместите выражение в контекст
  • Объединить значения в контексте
  • Применить эффект к значениям в контексте

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