Не используйте return в Scala

в 10:25, , рубрики: GOTO, return, scala, Программирование, функциональное программирование

Сегодня я бы хотел представить вашему вниманию перевод небольшой статьи Роберта Норриса, возможно, знакомого вам под никнеймом tpolecat. Этот человек достаточно хорошо известен в Scala-сообществе как автор бибилиотеки doobie и участник проекта cats.

В своей публикации Роберт рассказывает о том, что использование return может негативно повлиять на семантику вашего кода, а также проливает свет на пару интересных особенностей реализации return в Scala. Оригинал статьи вы можете найти в блоге автора по ссылке.

Итак, каждый раз, когда на Coursera запускают курс Мартина, у нас на #scala появляются люди, вопрошающие, почему за return с них снимают очки стиля. Поэтому, вот вам ценный совет:

Ключевое слово return не является «необязательным» или «подразумевающимся» по контексту — оно меняет смысл вашей программы, и вам никогда не следует его использовать.

Взглянем на этот небольшой пример:

// Сложим в методе два инта и затем используем его,
// чтобы просуммировать список.
def add(n: Int, m: Int): Int = n + m
def sum(ns: Int*): Int = ns.foldLeft(0)(add)

scala> sum(33, 42, 99)
res0: Int = 174

// То же самое, но при помощи return.
def addR(n:Int, m:Int): Int = return n + m
def sumR(ns: Int*): Int = ns.foldLeft(0)(addR)

scala> sumR(33, 42, 99)
res1: Int = 174

Пока что все в порядке. Между sum и sumR нет очевидной разницы, что может навести вас на мысль о том, что return является просто необязательным ключевым словом. Но давайте слегка отрефакторим оба метода, вручную заинлайнив add и addR:

// Заинлайнили add.
def sum(ns: Int*): Int = ns.foldLeft(0)((n, m) => n + m)

scala> sum(33, 42, 99)
res2: Int = 174 // Вполне норм.

// Заинлайнили addR.
def sumR(ns: Int*): Int = ns.foldLeft(0)((n, m) => return n + m)

scala> sumR(33, 42, 99)
res3: Int = 33 // Хм...

Какого...?!

Если кратко, то:

Когда поток управления доходит до выражения return, текущее вычисление прекращается и происходит немедленный возврат из того метода, в теле которого находится return.

В нашем втором примере оператор return не возвращает значение из анонимной функции — он возвращает значение из метода, внутри которого находится. Еще пример:

def foo: Int = {
  val sumR: List[Int] => Int = _.foldLeft(0)((n, m) => return n + m)
  sumR(List(1,2,3)) + sumR(List(4,5,6))
}

scala> foo
res4: Int = 1

Нелокальный возврат

Когда функциональный объект, содержащий вызов return, выполняется нелокально, прекращение вычисления и возврат результата из него происходит путём возбуждения исключения NonLocalReturnControl[A]. Эта деталь реализации легко и без особых церемоний просачивается наружу:

def lazily(s: => String): String =
  try s catch { case t: Throwable => t.toString }

def foo: String = lazily("foo")
def bar: String = lazily(return "bar")

scala> foo
res5: String = foo

scala> bar
res6: String = scala.runtime.NonLocalReturnControl

Если кто-нибудь мне сейчас возразит, что перехватывать Throwable — дурной тон, я могу ему ответить, что дурной тон — использовать исключения для управления потоком исполнения. Глупость под названием breakable из стандартной библиотеки устроена аналогичным образом и, подобно return, не должна никогда использоваться.

Ещё пример. Что, если оператор return оказывается замкнут в лямбда-выражение, которое остаётся живым даже после того, как его родной метод отработал? Возрадуйтесь, в вашем распоряжении бомба замедленного действия, которая рванет при первой же попытке использования.

scala> def foo: () => Int = () => return () => 1
foo: () => Int

scala> val x = foo
x: () => Int = <function0>

scala> x()
scala.runtime.NonLocalReturnControl

Дополнительным бонусом прилагается тот факт, что NonLocalReturnControl наследуется от NoStackTrace, поэтому у вас не будет никаких улик относительно того, где эта бомба была изготовлена. Классная штука.

Какой тип у return?

В конструкции return a возвращаемое выражение a должно соответствовать по типу результату метода, в котором находится return, однако выражение return a и само по себе имеет тип. Исходя из его смысла «прекратить дальнейшие вычисления», вы, должно быть, догадались какой тип оно имеет. Если нет, вот вам чутка просвещения:

def x: Int = { val a: Int = return 2; 1 } // результат 2

Видите, анализатор типов не ругается, так что можем предположить, что тип выражения return a всегда совпадает с типом a. Давайте теперь проверим эту теорию, попробовав написать что-то, что не должно работать:

def x: Int = { val a: String = return 2; 1 }

Хм, тоже не ругается. Что вообще происходит? Каким бы ни был тип у return 2, он должен быть приводимым к Int и String одновременно. А так как оба эти класса являются final, а Int — еще и AnyVal, вы знаете, к чему всё идёт.

def x: Int = { val a: Nothing = return 2; 1 }

Именно так, к Nothing. А всякий раз, сталкиваясь с Nothing, вам было бы благоразумнее развернуться и пойти другой дорогой. Так как Nothing — необитаем (не существует ни одного значения этого типа), то и результат return не имеет никакой нормального представления в программе. Любое выражение, имеющее тип Nothing, при попытке его вычислить обязано либо войти в бесконечный цикл, либо завершить виртуальную машину, либо (методом исключения) передать управление куда-либо еще, что мы и можем тут наблюдать.

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

Return нарушает ссылочную прозрачность

Это, как бы, очевидно. Но вдруг вы не совсем в курсе, что эти слова означают. Так вот, если у меня есть такой код:

def foo(n:Int): Int = {
  if (n < 100) n else return 100
}

то, будь он ссылочно прозрачным, я был бы вправе переписать его без изменения смысла вот так:

def foo(n: Int): Int = {
  val a = return 100
  if (n < 100) n else a
}

Конечно, он не будет работать: выполнение return порождает побочный эффект.

Но что, если мне это действительно нужно?

Не нужно. Если вы окажетесь в ситуации, когда вам, по вашему мнению, нужно досрочно покинуть метод, на самом деле вам нужно переделать структуру кода. Например, вот это

// Складываем числа из списка до тех пор,
// пока их сумма меньше ста.
def max100(ns: List[Int]): Int =
  ns.foldLeft(0) { (n, m) =>
    if (n + m > 100)
      return 100
    else
      n + m
  }

может быть переписано с использованием простой хвостовой рекурсии:

def max100(ns: List[Int]): Int = {
  def go(ns: List[Int], a: Int): Int =
    if (a >= 100) 100
    else ns match {
      case n :: ns => go(ns, n + a)
      case Nil     => a
    }
  go(ns, 0)
}

Это преобразование возможно всегда. Даже полное устранение оператора return из языка не увеличит количество программ, которые невозможно написать на Scala, и на единицу. Вам может потребоваться немного усилий над собой, чтобы принять бесполезность return, но в результате вы поймёте, что куда проще писать код без внезапных возвратов, чем ломать голову в попытках предсказать побочные эффекты, вызванные нелокальными переходами потока управления.

От переводчика:
Большое спасибо Бортниковой Евгении за вычитку. Отдельная благодарность firegurafiku за уточнения в переводе. Спасибо Владу Ледовских, за пару дельных советов которые сделали перевод немного точнее.

Автор: Павел Попов

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js