小编典典

如果没有可变状态,你怎么能做任何有用的事情?

all

最近我读了很多关于函数式编程的东西,我可以理解其中的大部分内容,但我无法理解的一件事是无状态编码。在我看来,通过移除可变状态来简化编程就像通过移除仪表板来“简化”汽车:成品可能更简单,但祝它与最终用户交互好运。

几乎我能想到的每个用户应用程序都将状态作为核心概念。如果您编写文档(或 SO
帖子),则状态会随着每个新输入而改变。或者如果你玩电子游戏,有大量的状态变量,从所有角色的位置开始,他们往往会不断地四处走动。如果不跟踪值的变化,你怎么可能做任何有用的事情呢?

每次我找到讨论这个问题的东西时,它都是用真正的技术功能语言编写的,假设我没有沉重的 FP
背景。有没有人知道一种方法可以向对命令式编码有良好、扎实理解但在功能方面完全n00b的人解释这一点?

编辑:到目前为止,一堆回复似乎试图让我相信不可变值的优势。我明白那部分。这很有意义。我不明白的是如何在没有可变变量的情况下跟踪必须更改并不断更改的值。


阅读 116

收藏
2022-04-21

共1个答案

小编典典

或者如果你玩电子游戏,有大量的状态变量,从所有角色的位置开始,他们往往会不断地四处走动。如果不跟踪值的变化,你怎么可能做任何有用的事情呢?

如果你有兴趣,这里有一系列描述 Erlang 游戏编程的文章。

你可能不会喜欢这个答案,但在你使用它之前你不会 得到 函数式程序。我可以发布代码示例并说“在这里,你没 看到
”——但如果你不理解语法和基本原理,那么你的眼睛就会呆滞。从您的角度来看,我似乎在做与命令式语言相同的事情,但只是设置了各种边界以有目的地使编程变得更加困难。我的观点是,你只是在体验Blub
悖论

起初我持怀疑态度,但几年前我跳上了函数式编程的火车并爱上了它。函数式编程的诀窍在于能够识别模式、特定的变量赋值,并将命令式状态移动到堆栈中。例如,for
循环变成递归:

// Imperative
let printTo x =
    for a in 1 .. x do
        printfn "%i" a

// Recursive
let printTo x =
    let rec loop a = if a <= x then printfn "%i" a; loop (a + 1)
    loop 1

它不是很漂亮,但我们得到了相同的效果,没有突变。当然,只要有可能,我们喜欢完全避免循环并将其抽象掉:

// Preferred
let printTo x = seq { 1 .. x } |> Seq.iter (fun a -> printfn "%i" a)

Seq.iter 方法将枚举集合并为每个项目调用匿名函数。非常便利 :)

我知道,打印数字并不令人印象深刻。但是,我们可以对游戏使用相同的方法:将所有状态保存在堆栈中,并使用递归调用中的更改创建一个新对象。这样,每一帧都是游戏的无状态快照,其中每一帧只是创建一个全新的对象,其中包含需要更新的任何无状态对象的所需更改。其伪代码可能是:

// imperative version
pacman = new pacman(0, 0)
while true
    if key = UP then pacman.y++
    elif key = DOWN then pacman.y--
    elif key = LEFT then pacman.x--
    elif key = UP then pacman.x++
    render(pacman)

// functional version
let rec loop pacman =
    render(pacman)
    let x, y = switch(key)
        case LEFT: pacman.x - 1, pacman.y
        case RIGHT: pacman.x + 1, pacman.y
        case UP: pacman.x, pacman.y - 1
        case DOWN: pacman.x, pacman.y + 1
    loop(new pacman(x, y))

命令式和函数式版本是相同的,但函数式版本显然不使用可变状态。功能代码保持所有状态都保存在堆栈上——这种方法的好处是,如果出现问题,调试很容易,你只需要一个堆栈跟踪。

这可以扩展到游戏中任意数量的对象,因为所有对象(或相关对象的集合)都可以在它们自己的线程中渲染。

几乎我能想到的每个用户应用程序都将状态作为核心概念。

在函数式语言中,我们不是改变对象的状态,而是简单地返回一个带有我们想要的更改的新对象。它比听起来更有效率。例如,数据结构很容易表示为不可变的数据结构。例如,堆栈非常容易实现:

using System;

namespace ConsoleApplication1
{
    static class Stack
    {
        public static Stack<T> Cons<T>(T hd, Stack<T> tl) { return new Stack<T>(hd, tl); }
        public static Stack<T> Append<T>(Stack<T> x, Stack<T> y)
        {
            return x == null ? y : Cons(x.Head, Append(x.Tail, y));
        }
        public static void Iter<T>(Stack<T> x, Action<T> f) { if (x != null) { f(x.Head); Iter(x.Tail, f); } }
    }

    class Stack<T>
    {
        public readonly T Head;
        public readonly Stack<T> Tail;
        public Stack(T hd, Stack<T> tl)
        {
            this.Head = hd;
            this.Tail = tl;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Stack<int> x = Stack.Cons(1, Stack.Cons(2, Stack.Cons(3, Stack.Cons(4, null))));
            Stack<int> y = Stack.Cons(5, Stack.Cons(6, Stack.Cons(7, Stack.Cons(8, null))));
            Stack<int> z = Stack.Append(x, y);
            Stack.Iter(z, a => Console.WriteLine(a));
            Console.ReadKey(true);
        }
    }
}

上面的代码构造了两个不可变列表,将它们附加在一起以创建一个新列表,然后附加结果。在应用程序的任何地方都没有使用可变状态。它看起来有点笨重,但这只是因为 C#
是一种冗长的语言。这是 F# 中的等效程序:

type 'a stack =
    | Cons of 'a * 'a stack
    | Nil

let rec append x y =
    match x with
    | Cons(hd, tl) -> Cons(hd, append tl y)
    | Nil -> y

let rec iter f = function
    | Cons(hd, tl) -> f(hd); iter f tl
    | Nil -> ()

let x = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))
let y = Cons(5, Cons(6, Cons(7, Cons(8, Nil))))
let z = append x y
iter (fun a -> printfn "%i" a) z

创建和操作列表不需要可变变量。几乎所有的数据结构都可以很容易地转换成它们的等效功能。我在这里写了一个页面,它提供了堆栈、队列、左派堆、红黑树、惰性列表的不可变实现。没有一个代码片段包含任何可变状态。为了“变异”一棵树,我用我想要的新节点创建了一个全新的树——这非常有效,因为我不需要复制树中的每个节点,我可以在我的新节点中重用旧节点树。

使用一个更重要的示例,我还编写了这个完全无状态的 SQL
解析器
(或者至少
我的 代码是无状态的,我不知道底层的词法库是否是无状态的)。

无状态编程与有状态编程一样富有表现力和强大,它只需要一点练习来训练自己开始无状态思考。当然,“尽可能无状态编程,必要时有状态编程”似乎是大多数不纯函数式语言的座右铭。当函数式方法不那么干净或高效时,使用可变变量并没有什么坏处。

2022-04-21