F#Seq的实现问题

Yin*_*Zhu 11 .net f# haskell functional-programming

我最近正在深入研究F#源代码.

在Seq.fs中:

// Binding. 
//
// We use a type defintion to apply a local dynamic optimization. 
// We automatically right-associate binding, i.e. push the continuations to the right.
// That is, bindG (bindG G1 cont1) cont2 --> bindG G1 (cont1 o cont2)
// This makes constructs such as the following linear rather than quadratic:
//
//  let rec rwalk n = { if n > 0 then 
//                         yield! rwalk (n-1)
//                         yield n }
Run Code Online (Sandbox Code Playgroud)

看到上面的代码后,我测试了两个代码:

let rec rwalk n = seq { if n > 0 then 
                         yield n
                         yield! rwalk (n-1)
                      }
Run Code Online (Sandbox Code Playgroud)

let rec rwalk n = seq { if n > 0 then 
                         yield! rwalk (n-1)
                         yield n 
                      }
Run Code Online (Sandbox Code Playgroud)

我发现第一个非常快,而第二个非常慢.如果n = 10000,我的机器上生成此序列需要3秒,因此是二次时间.

二次时间是合理的,例如

seq { yield! {1; 2; ...; n-1}; yield n } 翻译成

Seq.append {1; 2; ...; n-1} {n}
Run Code Online (Sandbox Code Playgroud)

我想这个追加操作应该是线性时间.在第一个代码中,追加操作是这样的:seq { yield n; yield! {n-1; n-2; ...; 1} }它需要花费不变的时间.

代码中的注释表示它linear(可能这是线性时间不是线性时间).也许这linear与使用序列而不是Moand/F#计算表达式的定制实现有关(如F#规范中所述,但是规范没有提到这样做的原因......).

谁能澄清这里的模糊性?非常感谢!

(ps因为这是一个语言设计和优化问题,我还附上了Haskell标签,看看那里的人是否有见解.)

Tom*_*cek 11

yield!出现在非尾部调用位置时,它基本上意味着同样的事情:

for v in <expr> do yield v
Run Code Online (Sandbox Code Playgroud)

这个问题(以及为什么是二次方的原因)是对于递归调用,这会创建一个带有嵌套for循环的迭代器链.您需要迭代<expr>为每个元素生成的整个序列,因此如果迭代是线性的,则得到二次时间(因为线性迭代发生在每个元素上).

假设rwalk函数生成了[ 9; 2; 3; 7 ].在第一次迭代中,递归生成的序列有4个元素,因此你将迭代4个元素并添加1.在递归调用中,你将迭代3个元素并添加1等.使用图表,你可以看看那是二次的:

x
x x 
x x x
x x x x
Run Code Online (Sandbox Code Playgroud)

此外,每个递归调用都会创建一个新的object(IEnumerator)实例,因此也会有一些内存成本(尽管只是线性的).

尾部调用位置,F#compiler/librar进行优化.它将当前IEnumerable与递归调用返回的当前"替换" ,因此它不需要迭代它以生成所有元素 - 它只是返回(这也消除了内存成本).

有关.在C#lanaugage设计中已经讨论过同样的问题,并且有一篇有趣的论文(他们的名字yield!yield foreach).