Сегодня я бы хотел представить вашему вниманию перевод небольшой статьи Роберта Норриса, возможно, знакомого вам под никнеймом 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)
senia
18.06.2017 23:52+3Это все-таки об идиоматичном коде.
Если на scala адаптируется какой-то алгоритм с while + break, то есть всего 2 варианта:
- завернуть while в метод и делать return внутри
- избавиться от while в пользу хвостовой рекурсии
Второй вариант не всегда оправдан в случае «академического» алгоритма.
Bonart
19.06.2017 00:05+3Нехилый косяк дизайна однако.
В шарпе return работает в рамках самой внутренней лямбды, как в скале умнейшие головы умудрились сделать такую бомбу — понять решительно невозможно.
Автор, пожалуй, слишком жестко подходит к использованию исключений (для breakable они в принципе нормально), но return через exception воистину за пределами добра и зла.senia
19.06.2017 00:45+4Это компромисс для плавного перехода с java. Если не ошибаюсь негативные его последствия были описаны еще в первой версии Programming in Scala.
Нужен он чтоб подобное работало:
for (i <- 0 to (array.length - 1)) { if (...) return array(i) }
За пределами добра и зла это было когда в этом исключении был не отключен стектрейс. Теперь это не слишком дорого.
Не думайте, что на эти грабли наступают многие. Чтобы воспользоваться return в лямбде в scala для возвращения результата из этой лямбды надо обладать неординарным воображением.Bonart
19.06.2017 22:09+2Это компромисс для плавного перехода с java
Так в том и странность определения хода мысли автора фичи — получается return в скале работает одинаково не нативно что для скалы, что для явы.
Дальнейшее немного предсказуемо — раз уж последствия были замечены сразу и описаны в книге, то в ногу себе никто не стреляет.
bm13kk
20.06.2017 15:48отличный пример.
То есть получается наоптимизированность скалы на таком простом и частом примере. Программист,
а) либо использует фунциональный стиль первый_в_списке(фильтр(массив)) — и необоснованно проходит весь массив
б) либо использует блочный стиль ретурн — и делает оверхед из-за скрытого использования исключения.senia
20.06.2017 18:53+3Либо удосужится прочитать документацию и узнать о find/collectFirst и ленивых коллекциях.
senia
20.06.2017 19:22+1Чтоб не быть голословным: https://scalafiddle.io/sf/1PJR8IG/0
А return для тех, кто документацию не прочитал.
Выше я описал единственную адекватную причину использовать return после прочтения инструкции.
shishmakov
22.06.2017 11:43-1В Java 8 вы можете без проблем использовать return внутри лямбд. Он работает именно внутри неё.
Не понимаю почему в Scala "не смогли", скорее "не захотели" так как для них только правило 1ин return и обязательно в конце.senia
22.06.2017 12:05+1Вы мне отвечаете?
Зачем в java return понятно — там есть множество синтаксических конструкций, не являющихся выражениями. В первую очередь это if и try.
В этом отношении в scala return не нужен.
И тем более он не нужен внутри лямбды: лямбда должна состоять из небольшого количества выражений, в идеале однострочник. Если же требуется что-то большое — пишите метод (и для java 8 работает).
RuddyRudeman
Когда они пришли за goto, я молчал — ведь я не использовал goto…