Пропустити до головного контенту

Extension Oriented Design

· 3 хвилин для перегляду
Вадим Ярощук

З появою різних підходів до написання коду, люди по різному розділяли код: спочатку на функції, потім на об'єкти та зрешті на модулі та окремі проєкти. Розберім підхід, що не створює нову парадигму програмування, але створює новий підхід до написання коду: Extension oriented design (Дизайн, що орієнтований на розширення).

Але для чого нам розширення?

Насправді причин є декілька, тож розберім їх усі.

Відсутність доступу до класу

Перша, і сама очевидна причина, чому ми потребуємо функції-розширення – це відсутність доступу до фактичного класу. Одним із видів рішення, зазвичай, було і є спадкування класу. І це прекрасно працює до моменту, коли наш клас не фінальний або наш клас це не примітив. У нас з'являється величезна проблема, яку можна вирішити за допомогою створення статичних функцій в умовних Helper'ax чи деінде.

І тут ми плинно підходимо до того, для чого були створені extension-функції – для статичного розширення створених класів, але «після крапки», а не за допомогою аргументу функції, що допомагає в пошуках потрібної нам функції.

Тобто, замість наступного:

public class ListUtils {
public static int maxOf(List<Int> list) {...}
}

public class Main {
public static main(String[] args) {
List<Int> list = ...;
System.out.println(ListUtils.maxOf(list));
}
}

Ми маємо варіант, що куди краще:

fun List<Int>.max(): Int {...}

fun main() {
val list = listOf(1, 3, 9)
println(list.max())
}

Що має наступні переваги:

  • простіше шукати функції, які потрібні для конкретного типу даних (немає захаращування простору імен іншими непотрібними нам функціями)
  • не потрібно мати доступу до фактичного класу
  • можна використовувати з примітивами

Але для чого ще можна їх використовувати?

Обхід обмежень

Деякі речі, наприклад inline функції не можуть бути переназначені або ініціалізовані спадкоємцями (тобто ми не можемо використовувати їх як абстрактних членів), бо inline-функції final by definition. Тому зазвичай, наприклад для reified, використовують наступний лайфхак:

interface Serializer {
fun <T> encode(kClass: KClass<T>, value: T): String
}

inline fun <reified T> Serializer.encode(value: T): String {
return encode(T::class, value)
}

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

Розділення власного коду

Хоча одною з переваг функцій-розширень є саме непотрібність фактичного доступу до класу, функції-розширення також використовуються для розділення вашого коду на основні та допоміжні функції, наприклад:

class Storage(...) {
fun getStringOrNull(key: String): String {...}
}

fun Storage.getString(key: String): String =
getStringOrNull() ?: throw NullPointerException("$key is null")
fun Storage.getStringOrDefault(key: String, defaultVal: String) =
getStringOrNull() ?: defaultVal

Тобто за аналогією з попереднім прикладом, ми виносимо функції, що не є базовою логікою (без яких функціональність класу не змінюється) окремо після нашого класу (це такий собі шаблон написання коду, що утворився в ком'юніті, як це також, наприклад, з factory-функціями, які ми виносимо перед класом).

Можете переглянути приклади з стандартної бібліотеки: kotlin.Result, чи з kotlinx.coroutines [1] [2] або з ktor [1] [2]. Погортайте власноруч ці репозиторії чи будь-які інші, й скоріш за все знайдете цей підхід.

Висновок

Ми використовуємо функції розширення з багатьох причин: технічні обмеження (такі як: недоступність фактичного класу або неможливість використання деякої функціональності типу inline-функцій), для більш ефективного пошуку потрібних нам функцій та для розділення нашого коду для більшого розуміння.

Займатись оверінжинирінгом не варто, але й ігнорувати подібні підходу також!