Scala 3 — Book

Функциональная обработка ошибок

Language

Функциональное программирование похоже на написание ряда алгебраических уравнений, и поскольку алгебра не имеет null значений или исключений, они не используются и в ФП. Что поднимает интересный вопрос: как быть в ситуациях, в которых вы обычно используете null значение или исключение программируя в ООП стиле?

Решение Scala заключается в использовании конструкций, основанных на классах типа Option/Some/None. Этот урок представляет собой введение в использование такого подхода.

Примечание:

  • классы Some и None являются подклассами Option
  • вместо того чтобы многократно повторять “Option/Some/None”, следующий текст обычно просто ссылается на “Option” или на “классы Option

Первый пример

Хотя этот первый пример не имеет дело с null значениями, это хороший способ познакомиться с классами Option.

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

def makeInt(s: String): Int =
  try {
    Integer.parseInt(s.trim)
  } catch {
    case e: Exception => 0
  }
def makeInt(s: String): Int =
  try
    Integer.parseInt(s.trim)
  catch
    case e: Exception => 0

Если преобразование работает, метод возвращает правильное значение Int, но в случае сбоя метод возвращает 0. Для некоторых целей это может быть хорошо, но не совсем точно. Например, метод мог получить "0", но мог также получить "foo", "bar" или бесконечное количество других строк, которые выдадут исключение. Это реальная проблема: как определить, когда метод действительно получил "0", а когда получил что-то еще? При таком подходе нет способа узнать правильный ответ наверняка.

Использование Option/Some/None

Распространенным решением этой проблемы в Scala является использование классов, известных как Option, Some и None. Классы Some и None являются подклассами Option, поэтому решение работает следующим образом:

  • объявляется, что makeInt возвращает тип Option
  • если makeInt получает строку, которую он может преобразовать в Int, ответ помещается внутрь Some
  • если makeInt получает строку, которую не может преобразовать, то возвращает None

Вот доработанная версия makeInt:

def makeInt(s: String): Option[Int] =
  try {
    Some(Integer.parseInt(s.trim))
  } catch {
    case e: Exception => None
  }
def makeInt(s: String): Option[Int] =
  try
    Some(Integer.parseInt(s.trim))
  catch
    case e: Exception => None

Этот код можно прочитать следующим образом: “Когда данная строка преобразуется в целое число, верните значение Int, заключенное в Some, например Some(1). Когда строка не может быть преобразована в целое число и генерируется исключение, метод возвращает значение None.”

Эти примеры показывают, как работает makeInt:

val a = makeInt("1")     // Some(1)
val b = makeInt("one")   // None

Как показано, строка "1" приводится к Some(1), а строка "one" - к None. В этом суть альтернативного подхода к обработке ошибок. Данная техника используется для того, чтобы методы могли возвращать значения вместо исключений. В других ситуациях значения Option также используются для замены null значений.

Примечание:

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

Потребитель makeInt

Теперь представим, что мы являемся потребителем метода makeInt. Известно, что он возвращает подкласс Option[Int], поэтому возникает вопрос: как работать с такими возвращаемыми типами?

Есть два распространенных ответа, в зависимости от потребностей:

  • использование match выражений
  • использование for выражений

Использование match выражений

Одним из возможных решений является использование выражения match:

makeInt(x) match {
  case Some(i) => println(i)
  case None => println("That didn’t work.")
}
makeInt(x) match
  case Some(i) => println(i)
  case None => println("That didn’t work.")

В этом примере, если x можно преобразовать в Int, вычисляется первый вариант в правой части предложения case; если x не может быть преобразован в Int, вычисляется второй вариант в правой части предложения case.

Использование for выражений

Другим распространенным решением является использование выражения for, то есть комбинации for/yield. Например, представим, что необходимо преобразовать три строки в целочисленные значения, а затем сложить их. Решение задачи с использованием выражения for:

val y = for {
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
} yield {
  a + b + c
}
val y = for
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
yield
  a + b + c

После выполнения этого выражения y может принять одно из двух значений:

  • если все три строки конвертируются в значения Int, y будет равно Some[Int], т.е. целым числом, обернутым внутри Some
  • если какая-либо из трех строк не может быть преобразована в Int, y равен None

Это можно проверить на примере:

val stringA = "1"
val stringB = "2"
val stringC = "3"

val y = for {
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
} yield {
  a + b + c
}
val stringA = "1"
val stringB = "2"
val stringC = "3"

val y = for 
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
yield
  a + b + c

С этими демонстрационными данными переменная y примет значение Some(6).

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

y: Option[Int] = None

Восприятие Option, как контейнера

Для лучшего восприятия Option, его можно представить как контейнер:

  • Some представляет собой контейнер с одним элементом
  • None не является контейнером, в нем ничего нет

Если предпочтительнее думать об Option как о ящике, то None подобен пустому ящику. Что-то в нём могло быть, но нет.

Использование Option для замены null

Возвращаясь к значениям null, место, где null значение может незаметно проникнуть в код, — класс, подобный этому:

class Address(
  var street1: String,
  var street2: String,
  var city: String,
  var state: String,
  var zip: String
)

Хотя каждый адрес имеет значение street1, значение street2 не является обязательным. В результате полю street2 можно присвоить значение null:

val santa = new Address(
  "1 Main Street",
  null,               // <-- О! Значение null!
  "North Pole",
  "Alaska",
  "99705"
)
val santa = Address(
  "1 Main Street",
  null,               // <-- О! Значение null!
  "North Pole",
  "Alaska",
  "99705"
)

Исторически сложилось так, что в этой ситуации разработчики использовали пустые строки и значения null, оба варианта это “костыль” для решения основной проблемы: street2 - необязательное поле. В Scala и других современных языках правильное решение состоит в том, чтобы заранее объявить, что street2 является необязательным:

class Address(
  var street1: String,
  var street2: Option[String],   // необязательное значение
  var city: String, 
  var state: String, 
  var zip: String
)

Теперь можно написать более точный код:

val santa = new Address(
  "1 Main Street",
  None,           // 'street2' не имеет значения
  "North Pole",
  "Alaska",
  "99705"
)
val santa = Address(
  "1 Main Street",
  None,           // 'street2' не имеет значения
  "North Pole",
  "Alaska",
  "99705"
)

или так:

val santa = new Address(
  "123 Main Street",
  Some("Apt. 2B"),
  "Talkeetna",
  "Alaska",
  "99676"
)
val santa = Address(
  "123 Main Street",
  Some("Apt. 2B"),
  "Talkeetna",
  "Alaska",
  "99676"
)

Option — не единственное решение

В этом разделе основное внимание уделялось Option классам, но у Scala есть несколько других альтернатив.

Например, три класса, известные как Try/Success/Failure, работают также, но (а) эти классы в основном используются, когда код может генерировать исключения, и (б) когда желательно использовать класс Failure, потому что он дает доступ к сообщению об исключении. Например, классы Try обычно используются при написании методов, которые взаимодействуют с файлами, базами данных или интернет-службами, поскольку эти функции могут легко создавать исключения.

Краткое ревью

Этот раздел был довольно большим, поэтому давайте подведем краткое ревью:

  • функциональные программисты не используют null значения
  • основной заменой null значениям является использование классов Option
  • функциональные методы не выдают исключений; вместо этого они возвращают такие значения, как Option, Try или Either
  • распространенными способами работы со значениями Option являются выражения match и for
  • Option можно рассматривать как контейнеры с одним элементом (Some) и без элементов (None)
  • Option также можно использовать для необязательных параметров конструктора или метода

Contributors to this page: