НСПК / 24.12.24 / перетяжка / 2W5zFK76vmn
НСПК / 24.12.24 / перетяжка / 2W5zFK76vmn
НСПК / 24.12.24 / перетяжка / 2W5zFK76vmn

Функциональное программирование для Android-разработчика. Часть первая

Аватар Дмитрий Юрченко
Отредактировано

Первая часть серии, в который мы описываем основы функционального программирования, его концепции и методы, которые будут полезны для Android-разработки.

11К открытий11К показов
Функциональное программирование для Android-разработчика. Часть первая

В этой серии статей мы изучим основы функционального программирования, его концепции и методы, которые будут полезны для Android-разработки.

В этой части мы рассмотрим пять концепций:

Что такое функциональное программирование и почему мы должны его использовать?

Прим. перев. Советуем посмотреть наше руководство по функциональному программированию c примерами на JavaScript. Также у нас на сайте есть цикл статей о функциональном C#.

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

Основы ФП, которые стоит выделить:

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

Чистые функции

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

Рассмотрим простую функцию, которая складывает два числа. Она читает одно число из файла, а другое принимает в качестве параметра:

Java

			int add(int x) {
    int y = readNumFromFile();
    return x + y;
}
		

Kotlin

			fun add(x: Int): Int {
    val y: Int = readNumFromFile()
    return x + y
}
		

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

Java

			int add(int x, int y) {
    return x + y;
}
		

Kotlin

			fun add(x: Int, y: Int): Int {
    return x + y
}
		

Теперь выходные значения функции зависят только от входных. Для заданных x и y функция всегда будет возвращать один и тот же результат. Теперь эта функция чистая. Математические функции работают так же: их выходные значения зависят только от входных. Поэтому функциональное программирование гораздо ближе к математике, чем к привычному стилю программирования.

Побочные эффекты

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

Java

			int add(int x, int y) {
    int result = x + y;
    writeResultToFile(result);
    return result;
}
		

Kotlin

			fun add(x: Int, y: Int): Int {
    val result = x + y
    writeResultToFile(result)
    return result
}
		

Функция записывает результат вычисления в файл, т.е. меняет состояние «внешнего мира». Такую функцию уже не называют чистой, теперь у неё есть побочный эффект.

Любая функция, которая изменяет переменную, удаляет что-то, записывает в файл или в БД, имеет побочный эффект и не используется в ФП.

Предположим, что вы пишете фрагмент кода, который зависит от кэша. Теперь результат выполнения вашего кода зависит от того, что находилось в кэше и валидны ли данные в нём. Вы не сможете понять, что делает программа, если не понимаете все возможные состояния кэша. Добавим сюда другие вещи, от которых зависит приложение: сеть, база данных, файлы, ввод пользователя. Становится очень сложно узнать, что именно происходит. Значит ли это, что в ФП не используются базы данных, кэши и т.д.? Конечно же, нет.

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

Порядок

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

Предположим, у нас есть функция, которая вызывает 3 чистых функции:

Java

			void doThings() {
    doThing1();
    doThing2();
    doThing3();
}
		

Kotlin

			fun doThings() {
    doThing1()
    doThing2()
    doThing3()
}
		

Мы знаем, что эти функции не зависят друг от друга и что они ничего не изменят в системе. Это делает порядок, в котором они выполняются, полностью взаимозаменяемым. Обратите внимание, что если бы doThing2() была результатом doThing1(), то функции должны были бы выполняться по порядку, но doThing3() могла бы быть выполнена перед doThing1().

Что нам это даёт? Конкурентность, вот что! Мы можем запустить эти функции на 3 отдельных ядрах процессора, ни о чём не беспокоясь.

В основном, компиляторы функциональных языков, таких как Haskell, могут проанализировать ваш код и сказать, является ли он параллельным. Теоретически эти компиляторы могут распараллелить ваш код автоматически (но на практике пока такое не реализовано).

Неизменяемость

Идея неизменяемости заключается в том, что созданное значение никогда не может быть изменено.

Предположим, что у нас есть класс Car:

Java

			public final class Car {
    private String name;

    public Car(final String name) {
        this.name = name;
    }

    public void setName(final String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}
		

Kotlin

			class Car(var name: String?)
		

Поскольку у класса есть метод в Java и он является изменяемым в Kotlin, я могу изменить имя машины после того, как я его создал:

Java

			Car car = new Car("BMW");
car.setName("Audi");
		

Kotlin

			val car = Car("BMW")
car.name = "Audi"
		

Этот класс не является неизменяемым. Его можно изменить после создания. Давайте сделаем его неизменяемым. Чтобы сделать это на Java, мы должны:

  • создать переменную final;
  • удалить метод;
  • создать класс final так, чтобы другой класс не смог расширить и изменить его.

Java

			public final class Car {
    private final String name;

    public Car(final String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}
		

В Kotlin нам просто нужно сделать название класса неизменяемым.

Kotlin

			class Car(val name: String)
		

Теперь, если кому-то нужно создать новый автомобиль, им нужно инициализировать новый объект. Никто не сможет изменить наш автомобиль, когда он будет создан. Этот класс теперь неизменяемый.

Но что насчет метода getName() в Java? В нем строки по умолчанию неизменяемы. Даже если кто-то получил ссылку на нашу строку и попытается ее изменить, то они получат копию этой строки, а исходная строка осталась бы неизменной. Но что относительно вещей, которые не являются неизменяемыми? Возможно, список? Давайте модифицируем класс Car, чтобы иметь список людей, которые им управляют.

Java

			public final class Car {
    private final List<String> listOfDrivers;

    public Car(final List<String> listOfDrivers) {
        this.listOfDrivers = listOfDrivers;
    }

    public List<String> getListOfDrivers() {
        return listOfDrivers;
    }
}
		

В этом случае кто-то может использовать метод getListOfDrivers(), чтобы получить ссылку на наш внутренний список и изменить его, что делает наш класс изменяемым.

Чтобы сделать его неизменяемым, мы должны передать глубокую копию списка, чтобы новый список мог быть безопасно изменен вызывающим. Глубокая копия означает, что мы копируем все зависимые данные рекурсивно. Например, если список был списком объектов «Водитель» вместо простых строк, нам также пришлось бы копировать каждый из объектов «Водитель». В противном случае мы создадим новый список со ссылками на исходные объекты, которые могут быть изменены. В нашем классе список состоит из неизменяемых строк, поэтому мы сделаем глубокую копию следующим образом:

Java

			public final class Car {
    private final List<String> listOfDrivers;

    public Car(final List<String> listOfDrivers) {
        this.listOfDrivers = deepCopy(listOfDrivers);
    }

    public List<String> getListOfDrivers() {
        return deepCopy(listOfDrivers);
    }

    private List<String> deepCopy(List<String> oldList) {
        List<String> newList = new ArrayList<>();
        for (String driver : oldList) {
            newList.add(driver);
        }
        return newList;
    }
}
		

Теперь наш класс действительно неизменяем.

В Kotlin мы можем просто объявить список неизменяемым в определении класса, а затем безопасно использовать его (если вы, конечно, не вызываете его из Javа).

Kotlin

			class Car(val listOfDrivers: List<String>)
		

Конкурентность

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

Давайте посмотрим на пример. Предположим, что мы добавили метод getNoOfDrivers() в наш класс Car в Java. Мы делаем его изменяемым как в Kotlin, так и в Java, позволяя пользователю изменять количество переменных водителей следующим образом:

Java

			public class Car {
    private int noOfDrivers;

    public Car(final int noOfDrivers) {
        this.noOfDrivers = noOfDrivers;
    }

    public int getNoOfDrivers() {
        return noOfDrivers;
    }

    public void setNoOfDrivers(final int noOfDrivers) {
        this.noOfDrivers = noOfDrivers;
    }
}
		

Kotlin

			class Car(var noOfDrivers: Int)
		

Разделим экземпляр класса Car через 2 потока: Thread_1 и Thread_2Thread_1 хочет сделать некоторые вычисления на основе количества водителей. Он вызывает метод getNoOfDrivers() в Java или обращается к свойству noOfDrivers в Kotlin. Тем временем Thread_2 изменяет переменную noOfDrivers. Thread_1 не знает об этом изменении и продолжает свои расчеты. Эти вычисления будут неправильными, поскольку состояние мира было изменено без Thread_2 и Thread_1.

Следующая диаграмма иллюстрирует эту проблему:

Функциональное программирование для Android-разработчика. Часть первая 1

Эта проблема называется Read-Modify-Write. Традиционный способ решить ее — использовать блокировки и мьютексы, чтобы только один поток мог работать с данными. В нашем случае Thread_1 будет удерживать блокировку до тех пор, пока не завершит расчет.

Этот тип управления ресурсами трудно сделать безопасным, и он приводит к ошибкам конкурентности, которые трудно анализировать.

Как это исправить? Давайте сделаем класс CAR снова неизменяемым:

Java

			public final class Car {
    private final int noOfDrivers;

    public Car(final int noOfDrivers) {
        this.noOfDrivers = noOfDrivers;
    }

    public int getNoOfDrivers() {
        return noOfDrivers;
    }
}
		

Kotlin

			class Car(val noOfDrivers: Int)
		

Теперь Thread_1 может выполнять вычисления без проблем, так как гарантировано, что Thread_2 не сможет изменить класс Car. Если Thread_2 хочет изменить класс, тогда он создаст собственную копию. Никаких блокировок не потребуется.

Функциональное программирование для Android-разработчика. Часть первая 2

Неизменяемость гарантирует, что данные, которые не должны быть изменены, не будут изменены.

Следите за новыми постами
Следите за новыми постами по любимым темам
11К открытий11К показов