默认参数值未定义,这是 JavaScript 的 Bug 吗?

4
以下是一个语法正确的JavaScript程序——只是,它的行为并不完全符合我们的预期。问题的标题应该帮助你的眼睛聚焦到问题区域

const recur = (...args) =>
  ({ type: recur, args })

const loop = f =>
  {
    let acc = f ()
    while (acc.type === recur)
      acc = f (...acc.args)
    return acc
  }

const repeat = n => f => x =>
  loop ((n = n, f = f, x = x) => // The Problem Area
    n === 0
      ? x
      : recur (n - 1, f, f (x)))

console.time ('loop/recur')
console.log (repeat (1e6) (x => x + 1) (0))
console.timeEnd ('loop/recur')
// Error: Uncaught ReferenceError: n is not defined

如果我使用唯一标识符,程序就可以完美地运行。

const recur = (...args) =>
  ({ type: recur, args })

const loop = f =>
  {
    let acc = f ()
    while (acc.type === recur)
      acc = f (...acc.args)
    return acc
  }

const repeat = $n => $f => $x =>
  loop ((n = $n, f = $f, x = $x) =>
    n === 0
      ? x
      : recur (n - 1, f, f (x)))

console.time ('loop/recur')
console.log (repeat (1e6) (x => x + 1) (0)) // 1000000
console.timeEnd ('loop/recur')              // 24 ms

只有这样没有意义。现在让我们谈谈原始代码,它不使用$前缀。

当评估loop的lambda时,由repeat接收的n在lambda的环境中可用。将内部的n设置为外部n的值应该有效地shadow外部的n。但是,JavaScript将其视为某种问题,导致内部的n结果被赋值为undefined

对我来说,这似乎是一个错误,但我很擅长阅读规范,所以我不确定。

这是一个错误吗?


我添加了FP标签,因为我知道你们中的许多人会认识到此模式的效用。 - Mulan
FF错误信息:ReferenceError: can't access lexical declaration 'n' before initialization。看起来赋值是在作用域之后完成的,就像使用letconst一样。例如,const v = v不起作用,因为新变量已经遮蔽了外部作用域中的v - Sylwester
1
使用Babel,它会给出相同的错误。因此,我假设它会以类似的方式扩展.. n = n 将给出 var n = arguments.length > 0 && arguments[0] !== undefined ? arguments[0]: n; 因此,外部的'n'基本上已被新的 var n 所取代。 - Keith
在作用域创建后分配变量,我猜规范必须要展示这个顺序——如果真的是这样,那太悲催了。 - Mulan
1
文档没有处理变量遮蔽,但它显示默认值在每次调用时都会被计算,并且后面的表达式可以像 letrec* 一样使用前面的值。想象一下你做了这样的事情:const test = (fun = (v) => v == 0 ? 0 : 1 + fun(v-1), v = 10) => fun(v)。请注意,表达式在递归中使用了绑定的名称,如果 fun 在闭包创建时不存在,这将无法工作。 - Sylwester
@Sylwester,现在显而易见的是为什么像Racket这样好的语言能够给我们选择绑定的样式/形式的自由 - 超越了letconst - Mulan
1个回答

2
我猜你已经想通了为什么你的代码不起作用。默认参数的行为类似于递归let绑定。因此,当你写n = n时,你将新声明的(但尚未定义的)变量n赋值给它本身。个人认为这非常合理。
所以,你在评论中提到了Racket,并谈到了Racket允许程序员在letletrec之间选择。我喜欢将这些绑定与Chomsky hierarchy进行比较。 let绑定类似于常规语言。它并不是很强大,但允许变量遮蔽。 letrec绑定类似于递归可枚举语言。它可以做任何事情,但不允许变量遮蔽。
由于 letrec 可以做到 let 能做的一切,因此你实际上根本不需要 let。一个典型的例子是 Haskell,它只有递归的 let 绑定(不幸的是被称为 let 而不是 letrec)。现在出现了一个问题,即像 Haskell 这样的语言是否也应该有 let 绑定。为了回答这个问题,让我们看下面的例子:
-- Inserts value into slot1 or slot2
insert :: (Bool, Bool, Bool) -> (Bool, Bool, Bool)
insert (slot1, slot2, value) =
    let (slot1', value')  = (slot1 || value,  slot1 && value)
        (slot2', value'') = (slot2 || value', slot2 && value')
    in  (slot1', slot2', value'')

如果 Haskell 中的 let 不是递归的,那么我们可以将这段代码写成:
-- Inserts value into slot1 or slot2
insert :: (Bool, Bool, Bool) -> (Bool, Bool, Bool)
insert (slot1, slot2, value) =
    let (slot1, value) = (slot1 || value, slot1 && value)
        (slot2, value) = (slot2 || value, slot2 && value)
    in  (slot1, slot2, value)

为什么Haskell没有非递归的let绑定呢?嗯,使用不同的名称确实有一些优点。作为编译器编写者,我注意到这种编程风格类似于单静态赋值形式,其中每个变量名只被使用一次。通过仅使用变量名一次,程序变得更容易被编译器分析。
我认为这对人类也是适用的。使用不同的名称可以帮助阅读代码的人理解它。对于编写代码的人来说,重用现有名称可能更可取。然而,对于阅读代码的人来说,使用不同的名称可以防止由于所有内容看起来相同而产生的任何混淆。事实上,JavaScript专家Douglas Crockford经常倡导上下文着色来解决类似的问题。
无论如何,回到手头的问题。我能想到两种可能的方法来解决你的即时问题。第一种解决方案是使用不同的名称,这就是你所做的。第二个解决方案是模拟非递归的let表达式。请注意,在Racket中,let只是一个宏,它会扩展为一个左-左-λ表达式。例如,请考虑以下代码:
(let ([x 5])
  (* x x))

这个 `let` 表达式将被宏展开为以下的左左 lambda 表达式:
((lambda (x) (* x x)) 5)

实际上,我们可以使用 Haskell 中的反向应用运算符 (&) 来完成同样的操作:
import Data.Function ((&))

-- Inserts value into slot1 or slot2
insert :: (Bool, Bool, Bool) -> (Bool, Bool, Bool)
insert (slot1, slot2, value) =
    (slot1 || value, slot1 && value) & \(slot1, value) ->
    (slot2 || value, slot2 && value) & \(slot2, value) ->
    (slot1, slot2, value)

同样地,我们可以通过手动“宏展开”let表达式来解决您的问题:

const recur = (...args) => ({ type: recur, args });

const loop = (args, f) => {
    let acc = f(...args);
    while (acc.type === recur)
        acc = f(...acc.args);
    return acc;
};

const repeat = n => f => x =>
    loop([n, f, x], (n, f, x) =>
        n === 0 ? x : recur (n - 1, f, f(x)));

console.time('loop/recur');
console.log(repeat(1e6)(x => x + 1)(0)); // 1000000
console.timeEnd('loop/recur');

在这里,我没有使用初始循环状态的默认参数,而是直接将它们传递给了loop。你可以把loop想象成Haskell中的(&)运算符,它也可以进行递归。实际上,这段代码可以直接转换为Haskell代码:
import Prelude hiding (repeat)

data Recur r a = Recur r | Return a

loop :: r -> (r -> Recur r a) -> a
loop r f = case f r of
    Recur r  -> loop r f
    Return a -> a

repeat :: Int -> (a -> a) -> a -> a
repeat n f x = loop (n, f, x) (\(n, f, x) ->
    if n == 0 then Return x else Recur (n - 1, f, f x))

main :: IO ()
main = print $ repeat 1000000 (+1) 0

正如你所看到的,你实际上根本不需要使用 let 关键字。所有可以由 let 完成的任务都可以由 letrec 完成,如果你真的想要变量屏蔽,那么你只需要手动执行宏扩展即可。在 Haskell 中,甚至可以更进一步,使用 所有 Monad 之母 使代码更加美观。

Aadit,感谢你的回答;我担心我可能不得不扩展宏,但是你提供的其他选择也很好;很酷看到循环/递归的类型化版本 ^_^ - Mulan
我最近对语法高亮有了不同的想法;等我再花点时间去探索后,很乐意与你分享。 - Mulan
很棒,期待着它。=) - Aadit M Shah

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接