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


Дізнайтесь більше про нові кар'єрні можливості в EchoUA. Цікаві проекти, ринкова оплата, гарний колектив. Надсилайте резюме та приєднуйтеся до нас.

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

У цій частині ми розглянемо п’ять концепцій:

  • чисті функції;
  • побічні ефекти;
  • порядок;
  • незмінність;
  • конкурентність.

Що таке функціональне програмування і чому ми повинні його використовувати?

Прим. перекл. Радимо подивитися наше керівництво по функціональному програмуванню з прикладами на 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 listOfDrivers;
public Car (final List listOfDrivers) { this.listOfDrivers = listOfDrivers; } public List getListOfDrivers (){ return listOfDrivers; }}

У цьому випадку хтось може використати метод getListOfDrivers (), щоб отримати посилання на наш внутрішній список і змінити його, що робить наш клас змінюваним.

Щоб зробити його незмінним, ми повинні передати глибоку копію списку, щоб новий список міг бути безпечно змінений тим, що викликає. Глибока копія означає, що ми копіюємо всі залежні дані рекурсивно. Наприклад, якщо список був списком об’єктів “Водій” замість простих рядків, нам також довелося б копіювати кожного з об’єктів “Водій”. Інакше ми створимо новий список з посиланнями на початкові об’єкти, які можуть бути змінені. У нашому класі список складається з незмінних рядків, тому ми зробимо глибоку копію таким чином:

Java

public final class Car { private final List listOfDrivers; public Car (final List listOfDrivers) { this.listOfDrivers = deepCopy (listOfDrivers); } public List getListOfDrivers (){
return deepCopy (listOfDrivers); } private List deepCopy (List oldList) { List newList = new ArrayList<>(); for (String driver: oldList) { newList.add (driver); } return newList; }}

Тепер наш клас дійсно незмінний.

У Kotlin ми можемо просто оголосити список незмінним у визначенні класу, а потім безпечно використати його (якщо Ви, звичайно, не викликаєте його з Javа).

Kotlin

class Car (val listOfDrivers: List)

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

Як ми зазначали вище, чисті функції дозволяють нам спростити конкурентність. Якщо об’єкт незмінний, то його дуже просто використати в чистих функціях, оскільки Ви не можете його змінити і тим самим викликати побічні ефекти.

Давайте подивимося на приклад. Припустимо, що ми додали метод 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 2. Thread 1 хоче зробити деякі обчислення на основі кількості водіїв. Він викликає метод getNoOfDrivers () у Java або звертається до властивості noOfDrivers у Kotlin. Тим часом Thread 2 змінює змінну noOfDrivers. Thread 1 не знає про цю зміну і продовжує свої розрахунки. Ці обчислення будуть неправильними, оскільки стан світу був змінений без Thread 2 і Thread 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 хоче змінити клас, тоді він створить власну копію. Ніяких блокувань не знадобиться.

Незмінність гарантує, що дані, які не мають бути змінені, не будуть змінені.

Переклад статті “Functional Programming for Android Developers – Part 1”

Київ, Харків, Одеса, Дніпро, Запоріжжя, Кривий Ріг, Вінниця, Херсон, Черкаси, Житомир, Хмельницький, Чернівці, Рівне, Івано-Франківськ, Кременчук, Тернопіль, Луцьк, Ужгород, Кам'янець-Подільський, Стрий - за статистикою саме з цих міст програмісти найбільше переїжджають працювати до Львова. А Ви розглядаєте relocate?


Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *