Рекурсивные определения значений в F#
Сегодня мы поговорим о рекурсивных let-биндингах в F#, которые являются неотъемлемой частью разработки на функциональных языках семейства ML. Хм, а что тут может быть вообще интересного?
Интерес представляет то, что с помощью let rec можно определять не только рекурсивные и взаимнорекурсивные функции, но ещё и рекурсивные значения. Сложно представить? На самом деле это обычное дело, например, вам надо подписаться на IObservable каким-нибудь хитрым IObserver‘ом, который должен уметь сам отписываться при определённых условиях:
open System
let source : int IObservable = ...
let rec subscription : IDisposable =
source.Subscribe {
new IObserver<int> with
member o.OnNext(x) =
if x > 0 then
subscription.Dispose()
...
}
То есть мы использовали значение subscription внутри самого определения того, чем же subscription будет на самом деле являться. Ключевой момент тут заключается в том, что все обращения к subscription внутри IObserver‘а являются отложенными, они не будут производиться во время вызова Subscribe(), иначе это приводило либо к ошибке компиляции, если компилятор может выяснить, что использование не является отложенным:
let rec foo : int = foo
error FS0031: The value ‘foo’ will be evaluated as part of its own definition
Либо результировать ошибкой времени исполнения с непонятным сообщением:
let rec foo =
let f() = foo + 1 in f()
System.InvalidOperationException: ValueFactory attempted to access the Value property of this instance.
Вот ещё один пример рекурсивного определения значения бесконечной последовательности чисел Фибоначчи, возможного благодаря тому, что seq-выражения являются отложенными:
let rec fibs =
seq { yield 0
yield! Seq.scan (+) 1 fibs
} |> Seq.cache
Или более «понятный» вариант, через сложение последовательности с самой собой, смещённой на один элемент:
let rec fibs' =
seq { yield 0
yield 1
yield! Seq.map2 (+)
(Seq.skip 1 fibs')
(fibs')
} |> Seq.cache
В любом случае компилятор F# не любит рекурсивные определения значений и ругается на них следующим предупреждением:
warning FS0040: This and other recursive references to the object(s) being defined will be checked for initialization-soundness at runtime through the use of a delayed reference. This is because you are defining one or more recursive objects, rather than recursive functions.
Которое легко прибить директивой компилятора:
#nowarn "40"
Из сообщения предупреждения сразу становится интересно (по крайней мере, мне), что же такое «delayed reference» и во что компилируются обращения к значению внутри определения его самого, а так же что происходит со значением, если во время его инициализации происходит исключение.
Например, модуль с таким значением (абсолютно бессмысленные, но содержащим использование самого себя внутри своего определения):
module LetRec
let rec foo =
let neverUsed() = foo + 1
in 0
Реально компилятор F# транлирует в примерно следующий код:
let rec private foo' =
lazy (
let neverUsed() = Lazy.force foo' + 1
in 0
)
let foo = Lazy.force foo'
Здесь всё равно присутствует let rec-привязка, но за ней больше не скрывается какая-либо логика. Тело lazy-выражения неявно становится телом функции unit -> ‘T, которая становится функцией отложенной инициализации значения, представляемого классом System.Lazy<’T>. То есть инициализация рекурсивного значения подразумевает создание отложенного значения Lazy<’T> и его последующее немедленное вычисление, при этом все обращения к значению заменяются на вычисления отложенного значения.
Логика Lazy<’T> такова, что если во время вычисления значения происходит обращение к этому же значению, то возбуждается исключение System.InvalidOperationException (иначе бы происходило зацикливание и переполнение стека), что мы наблюдали в примере выше. Если во время вычисления отложенного значения произошло исключение, то оно будет проброшено к клиентскому коду, но и будет сохранено внутри Lazy<’T> чтобы быть выброшено при возможных следующих обращениях к отложенному значению. В этом легко убедиться, если «вытащить» обращение к рекурсивному значению из кода его инициализации, например:
let mutable f = (fun() -> 0)
try
let rec foo : int =
f <- (fun() -> foo) // сохраняем обращение к foo в f
failwith "uups!" // и выбрасываем исключение
printfn "foo = %d" foo
with e ->
printfn "foo = %A" e.Message
foo = uups!
Если теперь обратиться к значению функционального типа f, то мы снова получим исключение:
try
printfn "f() = %d" (f())
with e ->
printfn "f() = %A" e.Message
f() = uups!
В итоге следует понимать, что обращения к значению внутри определения его самого обращаются в манипуляции с классом Lazy<’T>, которые имеют небольшой оверхед и могут приводить к исключениям в неожиданных местах, поэтому следует свести к минимуму вероятность возникновения исключения в коде инициализации рекурсивного значения. Старайтесь вовсе избегать использования рекурсивных значений, заменяя значения на функции или используя вспомогательные «мутабельные» средства. Например, в Rx для решения проблемы с IObserver‘ом из начала поста применяется класс MutableDisposable:
var subscribtion = new System.Disposables.MutableDisposable();
subscribtion.Disposable = source.Subscribe(x => {
if (x > 0) subscribtion.Dispose();
});