Skip to main content

Impulse Control Flow(脉冲控制流)

Impulse Control Flow(脉冲控制流)

脉冲用于提供程序执行顺序的明确指示:先做 A,然后做 B,然后做 C…

与 ProtoGraph 的许多其他部分不同,有几种不同的方式来编写脉冲控制流。虽然这确实要求程序员思考“我应该怎么做”,但拥有方便的方法以更命令式的风格进行编程,其重要性足以获得特殊对待。

Refresher on Froox Context(复习 Froox 上下文)

默认情况下,当你创建一个上下文表达式时,它存在于“froox”上下文中。该上下文理解关于 ProtoFlux 和数据模型的内容;然而,它并没有说明程序中操作的顺序。froox 上下文是纯的、惰性的数据流编程,意味着你拥有依赖其他值的值,并且这些值仅在下游节点请求时才会被求值。你可以将其可视化为节点和连线的有向图(即 ProtoFlux),其中右侧/下游的节点依赖于左侧连接的节点。如果你有多个子树,froox 上下文并不关心它们以何种顺序执行,只要最终结果正确即可。

Value1 = 1 + 2 + 3;
Value2 = 4 + 5 + 6;
Value3 = Value1 + 4;
Value4 = Value2 + Value3

如果你看上面的例子,你会发现 Value3 依赖于 Value1,而 Value4 依赖于 Value2 和 Value3。虽然值是按这个顺序定义的,但我们也可以像这样改变顺序:

Value1 = 1 + 2 + 3;
Value3 = Value1 + 4;
Value2 = 4 + 5 + 6;
Value4 = Value2 + Value3

我们交换了 Value2 和 Value3 的定义,但程序的含义完全相同。在 froox 上下文中,除非存在真正的数据流依赖关系,否则定义值的顺序无关紧要。因此,虽然上述代码是正确的,但以下代码是无效的:

Value1 = 1 + 2 + 3;
Value2 = 4 + 5 + 6;
Value4 = Value2 + Value3; // 在使用 Value3 之前需要定义它
Value3 = Value1 + 4;

理解 froox 上下文的一种方式是:上下文中不存在时间概念。它是与时间无关的。

不必担心 Value1 或 Value2 哪个先被计算是很好的,但是当我们确实想要强制执行操作的时间顺序时该怎么办?这就需要脉冲了。

Ways to Use Impulses in Froox Context(在 Froox 上下文中使用脉冲的方法)

在 froox 上下文中,有几种方法可以使用脉冲来指定操作的显式顺序。每种方法在某些用例中都有用,具体取决于控制流的复杂性。

Explicit Arguments(显式参数)

将脉冲作为参数传递给节点是最简单的选项。这被称为延续传递风格。提供给节点的延续被用作回调,节点可以在需要时执行这些回调。

NotifyUser = ErrorMessage <- "Something went wrong";
// 这里我们将 NotifyUser 作为回调传递给 OnFalse 延续
If(Condition=MaybeTrue, OnFalse=NotifyUser);

当你想运行一个在程序其他地方定义的简单且独立的脉冲时(如处理失败的情况),使用显式回调参数是个好主意。不推荐使用此方法来链接多个相互依赖并产生最终结果的操作,因为这样会导致所谓的“回调地狱”。

Switch Expression(Switch 表达式)

对于更流畅的延续传递风格,你可以使用 switch 来匹配节点的各种延续:

switch For(Count=100)
| LoopStart |> ImpulseDisplay
| LoopEnd   |> ImpulseDisplay
| LoopIteration ForNode |> // 需要 switch 才能获取
    switch ValueWrite<_>(Value=ForNode.Iteration, Variable=SomeVariable)
    | OnWritten |> ImpulseDisplay
    | OnFail    |> ImpulseDisplay;

在许多情况下,switch 表达式可以用显式参数编写,但对于更复杂的回调,switch 的可读性明显更高。这是因为控制流是从上到下阅读的,而不是“回调”到之前定义的某些内容。

Switch 还有一个仅靠参数无法实现的附加功能:绑定节点。当像上面示例那样绑定节点时,你可以在 switch 表达式分支的主体中访问节点的输出。这确保了分支中的输出值是有效的;如果你尝试从脉冲上下文外部访问大多数脉冲节点的输出,你只会得到默认值(未初始化)。例如,如果你只是从 For 节点的 Iteration 输出拖出一根线而没有脉冲线,它将始终为 0,因为你从未进入循环。

Switch 对于更复杂的控制流非常有用,但如果嵌套过深,它们可能会失控,导致“厄运金字塔”。在这种情况下,你应该考虑升级到专用的脉冲上下文。

// 这是一个简单的厄运金字塔示例
// 注意不断增加的缩进
sync FinalValue: string;
switch GET_String(SomeUrl)
| OnResponse _resultUrlIndex |>
    switch GET_String(FormatUrlParameters(SomeUrl, _resultUrlIndex.Content))
    | OnResponse _resultUrlPath |>
        switch GET_String(FormatUrlParameters(_resultUrlPath.Content, _resultUrlIndex.Content))
        | OnResponse _resultFinalUrlPath |>
            switch GET_String(_resultFinalUrlPath.Content)
            | OnResponse _resultValue |> FinalValue <- ParseContent(_resultValue.Content)

Impulse Context(脉冲上下文)

脉冲上下文是一个新的上下文,它层叠在 froox 上下文之上,并且是时间感知的。这意味着你可以做所有在 froox 上下文中能做的事情,但现在声明的顺序实际上有意义了。使用脉冲上下文,你可以使用更扁平的风格(不再向右缩进)来编写等效的 switch 表达式,看起来类似于命令式编程语言。

// 使用脉冲上下文时必须指定名称
impulse {
    sync FinalValue: string;
    // 使用 `bind` 关键字为上下文的其余部分设置节点值
    bind _resultUrlIndex = GET_String(SomeUrl).OnResponse;
    // 使用 .OnResponse 指定要将该延续绑定到
    bind _resultUrlPath = GET_String(FormatUrlParameters(SomeUrl, _resultUrlIndex.Content)).OnResponse;
    bind _resultFinalUrlPath = GET_String(FormatUrlParameters(_resultUrlPath.Content, _resultUrlIndex.Content)).OnResponse;
    bind _resultValue = GET_String(_resultFinalUrlPath.Content).OnResponse;
    FinalValue <- ParseContent(_resultValue.Content);
}

使用脉冲上下文使编程操作序列更简单,但脉冲上下文的主要限制是你只能绑定到一个延续,因为脉冲是一系列按顺序发生的步骤/表达式。在扁平序列中没有多分支的概念。要在脉冲上下文中使用分支行为,你可以使用显式参数传递或 switch 表达式。一些常见的控制流节点(如 if/then/else)也有特殊语法,使编写常见的分支代码更容易。

警告:如果你在 bind 表达式的末尾省略了 .ContinuationName,脉冲将不考虑它来自哪个延续而进行排序。如果你不关心输出来自何处,这可能很有用,但对于某些节点(如 GET_String),这样做会导致丢失控制流信息(例如,你无法判断节点触发的是 OnResponse 还是 OnError)。

// 你可以使用其他方法来指定分支脉冲
impulse {
    sync FinalValue: string;
    sync ErrorMessage: string;
    bind _resultUrlIndex = GET_String(SomeUrl).OnResponse;
    bind _resultUrlPath = GET_String(FormatUrlParameters(SomeUrl, _resultUrlIndex.Content)).OnResponse;
    // 使用 switch 处理多个延续及其各自的脉冲
    switch GET_String(FormatUrlParameters(_resultUrlPath.Content, _resultUrlIndex.Content))
    | OnError _errorNode |> 
        impulse {
            // Normal value bindings are still allowed in 'impulse'
            _niceMessage = PrettyPrint(_errorNode.StatusCode);
            ErrorMessage <- _niceMessage;
        }
    | OnDenied |> ErrorMessage <- "You don't have access"
    | OnResponse _resultFinalUrlPath |>
        impulse {
            // Pass the FailCallback which handles the error case here by doing some cleanup elsewhere
            bind _resultValue = GET_String(_resultFinalUrlPath.Content, OnError=FailCallback).OnResponse;
            FinalValue <- ParseContent(_resultValue.Content);
        };
}

脉冲上下文最适合用于编写“快乐路径”:即一切顺利的逻辑。错误情况通常会破坏这种流程,最好将它们提取到单独的代码块或自己的模块中。通过利用所有不同的脉冲编写方式,你可以编写出非常复杂的行为。同样,所有这些都很容易变得难以阅读,所以要保持警惕,如果代码变得过于密集,请考虑重构。尝试使用“函数式核心,命令式外壳”设计模式,将脉冲保持在程序的边缘,并使核心逻辑主要是纯数据流。

Impulse Syntax Sugar(脉冲语法糖)

几个常见的脉冲节点具有特殊语法,可以更轻松地编写常见模式。这些由编译器转换为本页前面解释的其他语言结构之一。

if

if MaybeTrue then impulse {
    // 做一些事情...  
} else impulse {
    // 做其他事情...
}

// else 是可选的
if MaybeTrue then impulse {
    // 做一些事情... 
}

if <Condition> then <OnTrue> else <OnFalse> 结构脱糖为 If 流节点,并转换为延续传递风格,将 OnTrue 和 OnFalse 参数分别设置为 thenelse 关键字后指定的脉冲。

else 分支可以省略,此时 OnFalse 将保持未连接状态。

while / whileAsync

local Counter: int; while Counter < 10 do impulse { Counter <- Counter == 0 ? 1 : Counter * 2; };
// 异步变体
local Counter: int;
whileAsync Counter < 10 do impulse {
DelaySecondsInt(1);
Counter <- Counter == 0 ? 1 : Counter * 2;
};

local Counter: int;
while Counter < 10 do impulse {
    Counter <- Counter == 0 ? 1 : Counter * 2;
};

// 异步变体
local Counter: int;
whileAsync Counter < 10 do impulse {
    DelaySecondsInt(1);
    Counter <- Counter == 0 ? 1 : Counter * 2;
};

while <Condition> do <LoopIteration> 结构脱糖为 While 或 AsyncWhile 节点,并转换为延续传递风格,将 LoopIteration 参数设置为 do 关键字后的脉冲。

whileAsync 变体允许你在主体内部使用 AsyncImpulse,并将整个表达式转换为 AsyncImpulse。

for / forAsync

for _i = 100 to 10 by 8 do impulse {
    RootSlot->~trigger<int>("CountingDown", _i);
}

// 异步变体
forAsync _i = 100 to 10 by 8 do impulse {
    RootSlot->~triggerAsync<int>("CountingDown", _i);
}

for <IteratorIndex> = <Start> to <End> [by <StepSize>] do <LoopIteration> 结构脱糖为 RangeForLoopInt 或 RangeForLoopIntAsync,并转换为一个 switch 表达式,其中包含一个 LoopIteration 模式,该模式将 IteratorIndex 绑定到节点的 Current(int 类型)字段,以便在 LoopIteration 上下文中使用。

startend 值都是包含的,如果未指定,步长默认为 1。

forAsync 变体允许你在主体内部使用 AsyncImpulse,并将整个表达式转换为 AsyncImpulse。

Context Colors(上下文颜色)

如果你之前有编程经验,请阅读:你的函数是什么颜色?。如果你以前没有编程经验,这可能不太容易理解。

ProtoFlux/ProtoGraph 类型系统对不同上下文中允许的值类型施加了某些限制。简而言之,除非上下文支持该类型的值,否则你不能使用该类型。在 ProtoGraph 中,目前有两种上下文:froox 和 impulse。froox 上下文是最基本的可访问上下文,与时间无关。impulse 上下文在 froox 上下文的基础上增加了操作执行的时间箭头。

因为 impulse 是 froox 的超集,你可以在 impulse 表达式内部使用 froox 上下文表达式,但反过来不行。告诉 froox 按时间顺序排列节点,就像告诉生活在三维空间中的人向前/向后穿越时间一样。我们无法像那样在时间中移动。

Async Impulses(异步脉冲)

在 impulse 上下文中,还有另一种需要注意的颜色(即“你的函数是什么颜色?”中讨论的)。与同步脉冲一样,异步脉冲在时间上是有序的,但与同步脉冲不同的是,它们在未来的某个未知时间完成执行。因此,它们位于普通同步脉冲之上的一层。使用它们时:你可以从异步脉冲中调用同步脉冲,但你不能从同步脉冲中调用异步脉冲。

某些节点有特定的异步版本,当你需要使用异步脉冲时可以使用它们,但其他只有同步的节点(如 write)可以在其任何延续是异步时“转变为”异步脉冲。大多数情况下,impulse 上下文会处理异步和同步版本之间的切换,但了解其工作原理可以帮助你设计良好的程序并理解编译器可能引发的某些类型的错误。

Advanced Topics(高级主题)

对于那些希望深入研究的人(读者请注意):在另一种编程语言中,与 impulse 上下文最接近的类比是 Haskell 中的 IO 单子。IO 单子可以近似地看作接受一个代表世界状态的隐式参数,并在运行函数后返回一个新世界。在 ProtoGraph/ProtoFlux 中,脉冲操作连线携带着世界的状态,就像一个时间线,随着它的延伸,事件/节点以因果序列执行。

你可能会问:有 froox 上下文的类比吗?最接近 froox 上下文的类比可能是 Reader 单子,它可以访问隐式上下文(Froox 引擎)。严格来说,由于 impulse 层叠在 froox 上下文之上,impulse 可能应该被认为是 ReaderIO 单子栈,但争论什么是什么留作读者的练习。