Kotlin не е Java

Kotlin е много хубав език за програмиране, който е много лесен за научаване от екипи, които вече имат опит в работата с Java и добавят много интересни функции, които могат да направят разработката на вашия проект по-лесна и по-безопасна. Наистина, по мое лично мнение, Kotlin се чувства като сладко място между Java и Scala, където Java, особено ако вашият екип е заседнал в по-стари версии, е проста, многословна и с важна липса на функции, присъстващи в множество други съвременни езици.

Scala е напълно функционален език с лудо количество функции, но с по-сложна крива на обучение и понякога проблеми със съвместимостта с Java. JetBrains коригира тези два основни проблема със Scala, като предоставя по-опростен, наложителен, напълно съвместим с Java език, но все пак с много предимства за ежедневна разработка.

Това вероятно е причината да обмислите да дадете шанс на Kotlin в следващия си проект на работа. Ако вашият екип работи с Java или никога не е опитвал Kotlin, следващият нов проект може да бъде добра възможност за целия екип да научи и да играе с нещо ново и да изпита предимствата и недостатъците на това решение.

Има обаче една уловка: Kotlin не е Java. Това може да звучи доста очевидно, но е важно да се отбележи. Ако вашият екип започне да разработва код на Kotlin (или Scala), както беше в Java, ще получите кодова база, която няма да се възползва от инструментите на езика.

Ето защо е важно, по време на определянето на обхвата на проекта, вашият екип да може да посвети няколко часа или дни, за да проучи Kotlin, неговите функции, интересни библиотеки, модели и т.н., за да можете да решите какъв е вашият код как ще изглежда и какви стандарти за качество ще прилага вашият екип.

Въпреки това, по време на тези разговори, вие също трябва да вземете под внимание, че вероятно проектът ще бъде обхванат за определено време или че вашият екип може да няма опит с Kotlin. Това означава, че стандартите, които вашият екип задава, трябва да балансират качеството, потреблението на време и кривата на обучение, за да може проектът да успее.

По време на останалата част от статията ще опиша насоките, които считам за добра идея да обмислите добавянето към стандартите за кодиране на следващия си проект в Kotlin или поне да ги вземете под внимание. Обърнете внимание, че някои от тези указания не са специфични за Kotlin и могат да бъдат приложени към всеки компилиран език.

Оставете компилатора да докаже вашия код

Това не е насока или специфичен кодов стандарт, а вместо това, по мое мнение, един от най-важните принципи, които трябва да ръководят вашето вземане на решения.

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

Kotlin има множество семантики, които могат да ви помогнат да постигнете това, включително система от типове, проверки за нищожност, експериментални договори, запечатани класове и т.н. Ще обсъдим по-конкретно как можем да се възползваме от тези инструменти в следващите параграфи.

Тип системни доказателства

Преди да продължим, вероятно трябва да отделим няколко минути, за да обсъдим важността на типовете. Когато ме помолят да взема вече съществуващ JavaScript код, който не съм написал сам, имам лошо време да разбера как работи всичко, защото за всяка отделна променлива или поле, което е дефинирано в проекта, стойността му може да бъде буквално всичко, тъй като няма начин да се ограничи какви стойности могат да се поберат в променлива.

Типовете в даден език, изразени в концепции на теорията на множествата, ни позволяват да ограничим домейна на възможните стойности, които могат да бъдат съхранени в променлива, параметър и т.н., и потенциално също така да изразяваме свойства на правните стойности в този домейн. И това е изключително мощен инструмент, който може да ни помогне да докажем (поне неофициално) някои свойства на нашия код, които могат да ни помогнат да предотвратим неочаквани грешки при неуспех на компилацията.

Нека да напишем пример, който вероятно е твърде сложен, за да се използва в реална производствена среда или поне не е обичайно да се вижда в реалния код в Kotlin. Да предположим, че имаме функция, която умножава първия елемент от списъка по сумата на останалите (опашката) елементи на списъка. Първият подход за извършване на това би бил нещо като:

fun multiplyTail(list: List<Int>): Int =
  list.first() * list.drop(1).sum()

Тази функция приема като параметър списък от цели числа и връща едно цяло число. С други думи, неговият домейн е целият възможен списък от цели числа с произволна дължина, а неговият кодомейн е всяко цяло число. Така че тази функция основно ще картографира всеки един възможен списък от цели числа към стойност от тип Int… с изключение на един конкретен случай. Ако е даден празен списък, тогава функцията ще хвърли изключение вместо да върне обикновено цяло число: нашата функция не е дефинирана за случай на празен списък.

За да предотвратим домейна на функцията да бъде по-широк от домейна, в който е дефинирана, и да предотвратим хвърлянето на изключения във функцията, можем да използваме типове, за да докажем, че предаваният списък винаги има поне един елемент. Може да изглежда нещо подобно:

@JvmInline
value class NonEmptyList<T> private constructor(
    private val list: List<T>
): List<T> by list {
    companion object {
        fun <T> of(firstElement: T, vararg tail: T): NonEmptyList<T> = 
            NonEmptyList(listOf(firstElement) + tail)
            
        fun <T> fromList(other: List<T>): List<T> {
            require(other.isNotEmpty(), { "List cannot be empty" })
            return NonEmptyList(other)
        }
    }
}

fun multiplyTail(list: NonEmptyList<Int>): Int =
  list.first() * list.drop(1).sum()

fun main() {
    println("Result: ${multiplyTail(NonEmptyList.of(1, 2, 3))}") // This works
    println("Result: ${multiplyTail(NonEmptyList.of())}") // This doesn't compile
    println("Result: ${multiplyTail(NonEmptyList.fromList(listOf()))}") // This compiles, but fails in runtime.
}

В този пример създадохме тип, NonEmptyList<T>, който не може да бъде конструиран, освен ако не е изпълнено ограничението за наличие на непразен списък (е, може би може, ако започнете да играете с хакерско отражение, но не и ако стриктно спазваме правила за видимост на езика). Правейки това, доказваме, отново, неофициално, че наличието на екземпляр NonEmptyList предполага, че вътрешният списък не е празен. В света на Scala това може да се нарече „рафиниран“ тип. Въпреки че това не е често срещан модел в Kotlin, по-късно в тази статия ще видим, че те все още са доста полезни при някои обстоятелства.

Друг пример за доказателства, които можем да направим с типове, е като използваме типа Nothing. Този тип представлява „долния тип“ в Kotlin и е населен, което означава, че домейнът му е празен. Това, което означава от гледна точка на доказателствата, е, че единственият начин да има стойност от тип Nothing в определен момент от кода, предвид факта, че домейнът на типа е празен, е защото кодът е недостижим или, в контекста на функция, връщаща този тип, която не може нормално да върне, освен чрез хвърляне на изключение. Например, разгледайте тези три примера:

fun thisFunctionCannotBeEverCalled(value: Nothing) {
    
}

fun infiniteLoop(): Nothing {
    while (true) {}
}

fun raiseListEmptyException(): Nothing {
    throw IllegalArgumentException("List cannot be empty")
}

Използвайки типа Nothing, ние доказваме различни твърдения:

  • В първия пример доказваме, че тъй като няма стойност, която да отговаря на входните параметри, тази функция не може да бъде извикана.
  • Във втория и третия пример доказваме, че функцията никога няма да се върне нормално. Това е особено полезно, за да уведомим компилатора, че някои от нашите разклонения на кода са недостъпни, така че да може да компилира нашия код въз основа на този инвариант. Например, в Java, ако искате да излезете от CLI приложение с нетърпение, ще направите нещо като:
public static void main(String[] args) {
  String firstArg;
  if (args.length == 0) {
    System.err.println("Invalid args count");
    System.exit(1);
    return;
  } else {
    firstArg = args[0];
  }
 
  System.out.println(firstArg);
}

Ясно е, че операторът return на if guard е ненужен, защото след извикването на exit(1) процесът винаги ще спре изпълнението си и това извикване никога няма да се върне. Ако обаче не го напишем, програмата няма да успее да се компилира, тъй като Java не може да гарантира, че променливата, firstArg, не е била инициализирана преди първото й използване; не може да заключи, че първият клон на if никога няма да се върне. В Kotlin функцията exitProcess се дефинира чрез връщане на Nothing като резултат поради тази причина. Ето как изглежда това:

fun main(args: Array<String>) {
  val firstArg: String
  if (args.isEmpty()) {
    println("Invalid args count")
    exitProcess(1)
  } else {
    firstArg = args.first()
  }
 
  println(firstArg);
}

Обратно, в този случай кодът се компилира без никакви проблеми, защото благодарение на типа Nothing, върнат от функцията exitProcess, Kotlin може да заключи, че първият клон на if никога няма да се върне и следователно, ако кодът достигне последния println , е така, защото кодът премина през else клона на условието.

Забранете използването на!! Оператор от Ден 1

Това вероятно е една от най-важните конкретни насоки, които да обмислите да приложите към Kotlin, и наистина „Detekt има някои правила, активни по подразбиране за улавяне на това“.

Една от най-често срещаните грешки по време на изпълнение, които откриваме в Java, е добре познатата NullPointerException и се получава от факта, че системата от типове третира като валидна стойността „null“ като част от домейна на всеки непримитивен тип. Това е от самите основи, нещо, което се нуждае от преосмисляне: система от типове, която позволява да има стойност, която означава „липса на стойност“ в домейна на тип, е проблематична, защото винаги ще има един случай, който трябва да третирате специално за всяка една не-примитивна стойност във вашия код.

Става още по-лошо, когато този специален случай е прозрачен за разработчика и компилатора, където всяка променлива или параметър може имплицитно да съдържа тази стойност, без да има ефективни методи за разграничаване на променливи, които могат да съдържат нулеви стойности от тези, които не могат (Има начини, като използването на анотацията @NotNull, но не е често да я видите в реален код и през по-голямата част от времето ще генерира предупреждения по време на компилиране, а не твърди грешки.

Обратно, Kotlin обработва този конкретен случай, като позволява да се посочи маркер (?), който разграничава тип, който може да съдържа nullable стойност от тип, който не може. Това решение е подобно, но не абсолютно същото, на тези езици, като Rust или Scala, което представлява липсата на стойност с „тип обвивка“, която има два възможни варианта: Един, при който стойността присъства и осигурява достъп до това стойност; и друг, когато просто няма стойност. Така или иначе, тези подходи ви позволяват да се справите със ситуации, при които е доказано, че стойността винаги присъства по различен начин от тези, при които стойността може да не е там. В последния случай това ще принуди програмата винаги да обработва нулевия случай, като по този начин ще предотврати всяко NullPointerException.

Въпреки това все още има две основни възможни ситуации, в които Kotlin все още може да повдигне NullPointerException:

Първият е при извикване на Java код с нулеви стойности или използване на обекти, върнати от Java код: В тези случаи Kotlin не може да бъде сигурен дали обектите трябва да бъдат нулеви при извикване на Java код или върнатите стойности от Java код могат да бъдат нулеви, освен ако не са изрично маркирани с анотациите @Nullable или @NotNull. В тези случаи зависи от разработчика да наложи или не правилата винаги да проверяват невалидността на върнатите стойности за предотвратяване на по-нататъшни грешки в кода на Kotlin.

Вторият е при използване на оператора !!: Този оператор позволява достъп до стойности на типове, маркирани като nullable, директно без допълнителни проверки. Следователно отваря възможността NullPointerExceptions да бъдат повдигнати. И това е причината да не се използва. Злоупотребата с този оператор няма да доведе до липса на полза от нашия код Kotlin; можеше да е написано на Java, по отношение на проблемите с невалидността.

Когато сме в ситуация, в която намираме оператора !! за полезен, това обикновено е, когато искаме да получим достъп до евентуално нулева стойност, за която „знаем“, че никога няма да бъде нулева. Това се случва, когато ние, като разработчици, не можем да намерим начин да докажем на компилатора, че стойността никога няма да бъде нула. И това твърдение може да е грешно поради грешка, която не създава грешка при компилиране и се скрива. Може би това не е проблем днес, но може да се превърне в проблем в бъдеще - когато кодът стане по-голям, има логически промени или собствеността на проекта се прехвърли към хора без контекст за кода и т.н.

Като цяло, основното решение, необходимо за предотвратяване на използването на този оператор, е да преработим нашия код, за да намерим начини да докажем това ограничение на компилатора. Тук имаме някои идеи за това как да коригираме някои сценарии, които според мен са често срещани, когато разработчиците се опитват да използват оператора !!:

  • Валидации на входове: ако вашата система получи набор от входове, които могат да бъдат нулеви, например тези, представени от data class, където всички негови полета са маркирани като нулеви, и системата ви валидира ненулевите данни на всички тези полета, уверете се, че разпространявате резултатите от това валидиране извън вашите функции за валидиране. Можете да направите това, като дефинирате нов клас, който има същите полета като оригиналния data class, но с всички полета, маркирани като ненулеви. Правейки това, вие ще кажете на компилатора и останалата част от системата, че сте проверили правилно, че тези стойности не са нулеви, и можете да използвате това ограничение в останалата част от системата.
    По-общо, добра практика за по-добро позволяване на компилатора да докаже нашия код е да представи тази стойност с по-точен тип, който доказва това ограничение. Разбира се, това твърдение трябва да се приема внимателно, тъй като опитите да се представи резултатът от всяко валидиране, включително неща като размери на списъци, цели числа и т.н., могат да бъдат толкова многословни, че наистина не си заслужават. Пример за това валидиране може да бъде нещо подобно:
data class CreateUserRequest(
  val name: String?,
  val email: String?
)

data class ValidatedCreateUserRequest(
  val name: String,
  val email: String
)

fun validateCreateUserInput(input: CreateUserRequest): ValidatedCreateUserRequest {
  if (input.name == null) {
    throw BadRequestException("Name cannot be null")
  }

  if (input.email == null) {
    throw BadRequestException("Email cannot be null")
  }

  return ValidatedCreateUserRequest(name, email)
}
  • Едно поле в моя модел винаги няма да бъде null само ако друго поле в моя модел също не е null: В този случай добра препоръка ще бъде тези две полета да се обвият в друг модел, който сам по себе си може да бъде nullable, така че ограничението за nullable от тези полета са свързани помежду си. Например:
// Instead of this

data class Person(
  val name: String,
  val tshirtType: TShirtType?,
  val tshirtColor: Color?
)

// We can rearrange our model like this
data class PersonTShirt(
  val tshirtType: TShirtType,
  val tshirtColor: Color
)

data class Person(
  val name: String,
  val tshirt: PersonTShirt?
)

Направете невалидните състояния непредставими

Този принцип ви насочва да мислите във вашия модел на данни по такъв начин, че неговите типове да могат да представят възможно най-точно (и до разумна точка) домейна на всички възможни стойности, които са законни според вашите бизнес правила, така че ние може да предотврати допълнителни проверки или неочаквани условия в средата на нашата бизнес логика. Kotlin разполага с различни инструменти, които можем да използваме за това, като например маркера за нищожност, който обсъждахме по-рано, изброявания, запечатани класове и т.н. Можем също да приложим някои други техники, като дефинирането на прецизирани типове.

Като пример, да предположим, че нашето приложение моделира служители на различни позиции в една компания и всяка различна позиция има някои свързани атрибути:

data class Employee(
  val name: String?,
  val email: String?,
  val position: String,
  val directReports: List<Employee>?
)

В този пример можем да дефинираме, че единствените валидни позиции са SALES_REP и SALES_MANAGER и само последната позиция може да има отчети.

С тези данни има някои подобрения, които можем да направим в нашия модел, за да направим някои невалидни стойности непредставими:

  • Първо, в този случай можем да предположим, че името и имейлът са задължителни, защото може да има само служител с тези атрибути. Тогава можем да премахнем маркера за нищожност.
  • Второ, позицията е много свободно дефинирана. Позицията може да се дефинира по-точно с изброяване или запечатан клас, за да се ограничи броят на възможните стойности.
  • Трето, тъй като само мениджър продажби може да е докладвал, тогава можем да пренаредим нашия модел, за да включим полето directReports в запечатан клас, който зависи от позицията, така че можем да имаме достъп до тази стойност само когато сме съпоставили позицията, което ще позволяват ни да премахнем и маркера за нищожност.

Прилагайки тези предложения, нашият окончателен модел ще изглежда така:

sealed class EmployeePosition {
  object SalesRep : EmployeePosition()
  data class SalesManager(val reports: List<Employee>): EmployeePosition
}

data class Employee(
  val name: String?,
  val email: String?,
  val position: EmployeePosition
)

Изискани видове

В нашия пример по-горе сме дефинирали имейла на служител като низ. Но това има някои проблеми:

  • Първо, това е много неясно определение. Тъй като в безкрайния домейн на всички възможни низове с всякаква дължина, повечето не са валидни имейли. Това означава, че в случай на грешка, можем да имаме невалидни имейли, съхранени в това поле, или предадени между функции, които се нуждаят от валиден имейл, за да вършат правилно работата си.
  • Второ, в ситуация, в която функция приема множество параметри на низ, е доста лесно да се допусне грешка и например да се разменят два параметъра на низ и да се стигне до неправилно извикване на функция.

Един модел, който можем да приложим тук, е да използваме нещо, което е доста често срещано в света на Scala, което са „рафинирани типове“. Тези типове са просто „обвивки“ или може би просто маркери, в зависимост от това как го прилагаме, около други типове, което ограничава домейна на възможните стойности на оригиналния тип. Например, можем да имаме прецизиран тип, който ограничава Int да има положителна стойност или низ, ограничен от регулярен израз. Това ни позволява да бъдем по-прецизни с нашите типове. Можем да разглеждаме нашия тип NonEmptyList от един от предишните ни примери като прецизиран тип: списък, който удовлетворява, че съдържа поне един елемент.

В Kotlin не знам нито една установена библиотека за автоматизиране на дефиницията на прецизирани типове, но истината е, че те така или иначе са доста лесни за дефиниране. Например в Kotlin можем да дефинираме прецизиран тип, който представлява имейл чрез нещо подобно:

@JvmInline
value class Email private constructor(val value: String) {
    companion object {
        private val EMAIL_PATTERN = // ...
        
        fun of(value: String): Email {
            if (EMAIL_PATTERN.matchEntire(value) == null) {
                throw IllegalArgumentException("Value $value is not a valid email because it doesn't match pattern ${EMAIL_PATTERN}")
            }
            return Email(value)
        }
    }
}

Обърнете внимание, че използваме „вграден клас на стойност“, за да намалим въздействието върху производителността от създаването на обвивка около нашата стойност.

Разбира се, това, което правят тези усъвършенствани типове, е намаляване на размера на домейна на даден тип чрез, в случая на примера, използване на регулярни изрази за съпоставяне на валидни имейли. Но това не означава, че имейлите са валидни: това просто означава, че намаляваме списъка с възможни невалидни имейли, за да можем да представим данните си по-точно, но това няма нищо общо с допълнителна проверка, която може да направи, за да гарантира, че имейлът съществува или че използва регистрирани домейни и т.н.

Разумни граници

Добре, идеята да направим невалидните състояния непредставими звучи добре, но трябва да има ограничение. В зависимост от вашия случай на използване, може да бъде изключително трудно да се постигне перфектно съответствие между домейна на вашите данни и вашите бизнес правила. Например, може би не се интересувате от прецизиране на всяко едно число във вашето приложение, за да докажете, че са положителни, отрицателни или между две числа (особено последното, което може да бъде трудно, тъй като Kotlin не поддържа нещо като const generics ). Може би в тези ситуации просто предпочитате да се върнете към класическото предишно валидиране на параметри в началото на функция и да я извикате за един ден.

Мисленето за това как да моделирате данните си, за да съответстват на тази насока или дори да се занимавате с много сложен модел на данни само поради това, също може да бъде проблематично поради всички допълнителни разходи за поддържане на този код и разработване на повече върху него. Винаги трябва да има баланс между качество на кода, поддръжка и скорост на разработка.

Променливостта е коренът на всички злини… Най-вече

Променливостта е нещо, с което обикновено трябва да се работи внимателно. Наличието на твърде голяма променливост прави вашия код по-сложен за следване, по-труден за поддръжка и по-труден за паралелизиране. Освен ако не изграждате следващото поколение машини за бази данни и вместо това изграждате нещо по-подобно на уеб сървър или API, вероятно за вас е по-лесно да запазите променливостта до минимум.

Това включва не само състоянието на класовете и приложенията като цяло, но и на функционално ниво. Особено на функционално ниво, разработването на алгоритми и процедури, които използват неизменни структури от данни, обикновено помага да се получи по-ясен код. Има изключения, разбира се, като например ако работите с кръгови структури от данни като графики, или просто имате алгоритъм, който не можете да обобщите в по-абстрактни операции, и просто предпочитате да използвате променливост за кодирането му.

Ето защо е добра практика, докато работите с колекции, да се опитате да намалите или елиминирате използването на fors във вашите алгоритми и да се опитате да използвате по-абстрактните методи, които стандартната библиотека на Kotlin е дефинирала за работа с колекции. Бих препоръчал да отделите секунда, за да прочетете официалната документация на Kotlin и да опресните колко много неща можете да правите с API за колекции на Kotlin.

Обработка на грешки

В Java най-често срещаният начин за обработка на грешки е само с изключения: просто задействате изключения, когато имате нужда, и от вида, от който се нуждаете, оставяте повикващите да се справят с тях и това е почти всичко. В Котлин съм изпитал, че това е малко по-трудно от това. Но да започнем отначало.

Изключенията на Java бяха „хубави“ преди Java 8: Ако имате персонализиран метод, който предизвиква персонализирани изключения, добавяте тези изключения към клаузата „хвърляния“ в подписа на метода, така че можете да информирате извикващия тази функция, че може да се провали много специфичен набор от проблеми и ви принуждава да се справите с тях (проверени изключения). Това не само ви предоставя документация, вписана в метода на подписа за начините, по които дадена функция може да се провали, но също така ви принуждава да обработвате всеки случай или да делегирате рекурсивно на вашия повикващ, така че той да може да ги обработва.

Проблемът обаче идва с въвеждането на ламбда функциите в Java 8. И тук започвате да усещате, че е трудно да комбинирате изключения с тях, защото функциите, които дефинират хвърлени изключения, са много трудни за композиране заедно. И може да се окажете в ситуация като тази:

public String processElement(String value) throws SomethingWentBadException {
 // ...
}

public List<String> processList(List<String> input) {
  return input.stream()
           .map(this::processFilter) // This fails to compile!
           .collect(Collectors.toList());
}

Това е проблем! Тъй като филтърната функция не очаква предадената Lambda да хвърли нещо, но в нашия случай го прави и не успява да се компилира. Така че или правите това:

public List<String> processList(List<String> input) {
  return input.stream().filter((e) -> {
    try {
      return processFilter(e);
    } catch (SomethingWentBadException ex) {
      logger.error("Error!", ex);
    }
  }).collect(Collectors.toList());
}

... което е ужасно не само от гледна точка на стила на кода, но и поради факта, че потискате изключенията, за да бъдат хвърлени според очакванията. Или използвате класическия @SneakyThrows на Lombok, който също е самоуверен, защото ще ви попречи да обработите това изключение, ако имате нужда от него в друга част от вашия код. Той също така ще скрие изключението от сигнатурата на метода. Последната опция може също да бъде да дефинирате своя собствена функция за карта, която поддържа хвърляне на изключения.

В Kotlin това не се случва, защото компилаторът не налага добавянето на изключения към сигнатурата на метода и всички изключения работят така, сякаш са RuntimeExceptions. Така че кодът по-горе ще се компилира правилно в Kotlin.

Това обаче има и някои недостатъци и вероятно сте забелязали какви биха могли да бъдат те: Ако няма проверени изключения, тогава не знаем какво и как даден метод може да се провали, което прави обработката на грешки по-неясна и по-малко ясна, отколкото преди бъди с Java.

Друг интересен подход би бил да се използва типът Either от добре известната Библиотека със стрелки. Тази библиотека, наред с много други неща, е дефинирала типа Either<L, R>, чиято дефиниция може да бъде опростена, както следва:

sealed class Either<out L, out R> {
  data class Left<L>(val value: L): Either<L, Nothing>
  data class Right<R>(val value: R): Either<Nothing, R>
}

По принцип типът Either ни позволява да съхраняваме или стойност от тип L, или стойност от тип R. В случай на обработка на грешки можем да използваме това, за да съхраним стойност отдясно, когато всичко е наред, и стойност отляво ако се случи нещо лошо. Това ще ни позволи да съхраним причината за грешката там (Съхраняването на „дясната“ стойност в дясно и грешката в ляво е просто конвенция, но е удобно да я следвате, защото Arrow API има някои полезни инструменти, базирани на това подреждане). Начинът, по който съхраняваме грешките от тази лява страна, зависи от разработчика и те могат да бъдат всякакви изключения, вложени в тип Either или запечатани класове, които перфектно описват всички възможни грешки, които функцията може да предизвика, така че да могат да бъдат съпоставени по образец от извикващите .

Използването на типа Either означава, че можем да възстановим видимостта на грешките, които сме загубили с премахването на проверените изключения в Kotlin и сме принудени да обработваме грешките отново, но в по-съставим стил.

Този подход обаче не е изключен от недостатъци и има няколко, които трябва да се вземат под внимание:

  • Той е по-подробен от нормалните изключения и понякога е по-труден за научаване. Ако вашият екип някога е работил с това преди, това може да е нещо, което може да затрудни кривата на обучение.
  • Извикването на Java код и извикването от Java код или от код, който използва изключение за обработка на грешки, става по-трудно: Всеки път, когато трябва да използвате код, който може да хвърля изключения, вероятно трябва да опаковате тези извиквания във функции, които могат да обработват изключения, които се случват вътрешно и ги съпоставете с правилен Either екземпляр, така че да можете да разпространявате тази грешка чрез вашето Either-базирано приложение за обработка на изключения. Това добавя допълнителна стъпка всеки път, когато искате да извикате тези видове API от вашето приложение.
  • Така или иначе ще трябва да се справите с изключения: Това не е непременно недостатък, а просто нещо, което трябва да имате предвид. Всички изключения не могат просто да бъдат заменени с обработка на грешки в стил Either, основно защото има изключения, които се задействат чрез стандартната библиотека на Kotlin, които не можете да промените. Но това също така работи обработката на грешки в Rust, например: За грешки, причинени от I/O грешки, системни грешки и т.н., се използва резултат (еквивалент на Either в Rust) за разпространение на грешката. Но ако се случи фатална грешка, като опит за достъп до индекс извън границите на списък, се създава паника, което би било еквивалентно на нашите изключения. При този начин на обработка на грешки, фаталните грешки все още хвърлят изключения, но други грешки, които могат да бъдат възстановени, като мрежови грешки, грешки при валидиране и т.н., могат да бъдат разпространени с Either монадата.

Така че изборът между един или друг механизъм зависи от опита на вашия екип, управляващ този стил на кодиране, доколко зависи от кода на Java, зависимостите на вашата библиотека, как планирате да извършвате докладване за грешки и обработка на грешки и т.н.

И накрая, след като обяснихме как бихме направили обработка на грешки с помощта на Arrow в Kotlin, можем да завършим нашия пример:

fun processElement(value: String): Either<SomethingWentWrongException, String> {
    // ...
}

fun processList(input: List<String>): Either<SomethingWentWrongException, List<String>> {
    return input.traverse(::processElement)
}

Корутини или не корутини?

Това също е много интересна тема, върху която да мислите, когато стартирате нов проект, и решение, което трябва да се вземе възможно най-скоро в даден проект поради „проблема с червените и сините функции“. Ако стартирате нов уеб сървър, може би смятате, че е добра идея да използвате съпрограмми. Но има някои точки, които трябва да се вземат предвид:

  • Зависите ли от библиотеки, които силно зависят от локалните променливи на нишката? Ако случаят е такъв, трябва да отделите няколко минути, за да проучите колко усилия може да ви отнеме, за да създадете Елементите на контекста на Coroutine, които ще ви трябват, за да направите тази библиотека съвместима с Coroutines.
  • Зависите ли силно от зависимости, които използват блокиране на I/O? Ако случаят е такъв, все още виждам ползи от използването на Coroutines, защото те имат обширен API, който все още може да ви помогне да паралелизирате заявки надолу по веригата, да създавате таймери, отменими заявки и т.н. Но е наложително да се вземе предвид, че нишките на coroutine трябва никога да не бъде блокиран от дълготрайни процеси, включително повиквания, които развиват скъпи CPU изчисления или блокиране на I/O повиквания. И това е проблем, защото няма точен линтер, който може да предупреди разработчика, че блокира повикванията от сърутинна нишка. Това е нещо, което трябва да имате предвид, тъй като е друг източник на възможни грешки, които лесно могат да нарушат ефективността на вашето приложение.

Но какво ще стане, ако разработвате, например, краткотрайна AWS Lambda функция или CLI приложение? Има ли смисъл да има корутини? Е, за мен това имаше смисъл, защото в различни части на приложението ми трябваше да правя паралелни заявки към моите услуги надолу по веригата. Coroutines ми позволяват да създам приложението по-генерално и просто, така че да мога да ги използвам, когато е необходимо. Ако смятате, че това може никога да не е вашият случай, вероятно няма смисъл да тръгвате по този път.

Заключение

Това е само един поглед към някои аспекти, от които се нуждаех, за да взема решение в последния си проект в Kotlin, и той взема предвид времето, опита на екипа и качеството на кода. Не е лесно да се направи и зависи от вашите ресурси и зависимостта на този проект с други вече съществуващи наследени Java проекти и други фактори. Може да искате да отидете в друга посока. Независимо какви стандарти изберете за следващия си проект, просто не забравяйте, че Kotlin не е Java.