Сегодня я бы хотел представить вашему вниманию перевод небольшой статьи Роберта Норриса, возможно, знакомого вам под никнеймом 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 за уточнения в переводе. Спасибо Владу Ледовских, за пару дельных советов которые сделали перевод немного точнее.

Поделиться с друзьями
-->

Комментарии (10)


  1. RuddyRudeman
    18.06.2017 23:46
    +6

    Когда они пришли за goto, я молчал — ведь я не использовал goto…


  1. senia
    18.06.2017 23:52
    +3

    Это все-таки об идиоматичном коде.
    Если на scala адаптируется какой-то алгоритм с while + break, то есть всего 2 варианта:

    1. завернуть while в метод и делать return внутри
    2. избавиться от while в пользу хвостовой рекурсии

    Второй вариант не всегда оправдан в случае «академического» алгоритма.


  1. Bonart
    19.06.2017 00:05
    +3

    Нехилый косяк дизайна однако.
    В шарпе return работает в рамках самой внутренней лямбды, как в скале умнейшие головы умудрились сделать такую бомбу — понять решительно невозможно.
    Автор, пожалуй, слишком жестко подходит к использованию исключений (для breakable они в принципе нормально), но return через exception воистину за пределами добра и зла.


    1. senia
      19.06.2017 00:45
      +4

      Это компромисс для плавного перехода с java. Если не ошибаюсь негативные его последствия были описаны еще в первой версии Programming in Scala.
      Нужен он чтоб подобное работало:

      for (i <- 0 to (array.length - 1)) {
        if (...) return array(i)
      }
      

      За пределами добра и зла это было когда в этом исключении был не отключен стектрейс. Теперь это не слишком дорого.

      Не думайте, что на эти грабли наступают многие. Чтобы воспользоваться return в лямбде в scala для возвращения результата из этой лямбды надо обладать неординарным воображением.


      1. Bonart
        19.06.2017 22:09
        +2

        Это компромисс для плавного перехода с java

        Так в том и странность определения хода мысли автора фичи — получается return в скале работает одинаково не нативно что для скалы, что для явы.
        Дальнейшее немного предсказуемо — раз уж последствия были замечены сразу и описаны в книге, то в ногу себе никто не стреляет.


      1. bm13kk
        20.06.2017 15:48

        отличный пример.


        То есть получается наоптимизированность скалы на таком простом и частом примере. Программист,
        а) либо использует фунциональный стиль первый_в_списке(фильтр(массив)) — и необоснованно проходит весь массив
        б) либо использует блочный стиль ретурн — и делает оверхед из-за скрытого использования исключения.


        1. senia
          20.06.2017 18:53
          +3

          Либо удосужится прочитать документацию и узнать о find/collectFirst и ленивых коллекциях.


        1. senia
          20.06.2017 19:22
          +1

          Чтоб не быть голословным: https://scalafiddle.io/sf/1PJR8IG/0

          А return для тех, кто документацию не прочитал.
          Выше я описал единственную адекватную причину использовать return после прочтения инструкции.


      1. shishmakov
        22.06.2017 11:43
        -1

        В Java 8 вы можете без проблем использовать return внутри лямбд. Он работает именно внутри неё.
        Не понимаю почему в Scala "не смогли", скорее "не захотели" так как для них только правило 1ин return и обязательно в конце.


        1. senia
          22.06.2017 12:05
          +1

          Вы мне отвечаете?

          Зачем в java return понятно — там есть множество синтаксических конструкций, не являющихся выражениями. В первую очередь это if и try.

          В этом отношении в scala return не нужен.

          И тем более он не нужен внутри лямбды: лямбда должна состоять из небольшого количества выражений, в идеале однострочник. Если же требуется что-то большое — пишите метод (и для java 8 работает).