没有可变状态,你如何做任何有用的事情呢?

35 浏览
0 Comments

没有可变状态,你如何做任何有用的事情呢?

最近我一直在阅读关于函数式编程的文章,我能理解大部分内容,但是唯一让我无法领会的是无状态编程。在我看来,通过去除可变状态简化编程就好像“简化”汽车通过去除仪表盘:最终产品可能会更加简单,但愿好运与最终用户进行交互。

我认为几乎每个用户应用程序都涉及状态作为核心概念。如果你写文档(或SO帖子),每次输入都会改变状态。或者如果你玩视频游戏,有大量的状态变量,从所有字符的位置开始,这些字符往往会不断移动。如果没有跟踪变化的值,你怎么可能做任何有用的事情呢?

每次我找到关于这个问题的文章,都会使用非常技术化的函数式术语来写,假定读者已经有很重的FP背景,而我却不具备。有没有人知道一种方法可以向完全不懂函数式编程但对命令式编程有很好、很扎实的理解的人解释这个问题?

编辑:到目前为止,许多回复似乎都试图说服我不可变值的优点。我理解这部分了,它是非常合理的。我不理解的是,如何在没有可变变量的情况下,跟踪那些不得不变化、不断变化的值。

admin 更改状态以发布 2023年5月21日
0
0 Comments

简短回答:不可能。

那么不可变性有什么值得称道的点呢?

如果你精通命令式语言,那么你就知道"全局变量是不好的"。为什么?因为它们会(或有潜在的会)在你的代码中引入一些非常难以解开的依赖关系。而依赖关系并不是好的;你希望你的代码是模块化的。程序的部分尽可能少地影响其他部分。而编程范式(FP)将你带到模块化的至高境界:完全没有副作用。你只有你的f(x)= y。输入x,获得y。没有对x或其他任何内容的更改。FP让你停止思考状态,开始以值为单位进行思考。所有的函数都只接收值并产生新的值。

这有几个优点。

首先,没有副作用意味着程序更简单,更容易理解。不必担心引入新的程序部分会干扰并崩溃现有的工作部分。

其次,这使得程序容易地并行化(高效的并行化又是另外一个问题)。

第三,有一些潜在的性能优势。比如说你有一个函数:

double x = 2 * x

现在你输入一个值3,你每次都得到一个值6。但是你可以在命令式中做到这一点,对吧?是的。但问题是,在命令式中,你还可以做:

int y = 2;
int double(x){ return x * y; }

可是我也可以这样做

int y = 2;
int double(x){ return x * (y++); }

命令式编译器不知道我是否会产生副作用,这使得它更难以进行优化(例如,两倍的2不必每次都是4)。“功能”编译器知道我不会产生副作用,因此每次看到“两倍的2”就可以进行优化。

现在,尽管每次创建新值对于计算机内存中复杂类型的值似乎非常浪费,但并非如此。因为,如果你有f(x)= y,而值x和y“大多数相同”(例如,只有一些叶子不同的树),那么x和y可以共享部分内存,因为它们中的任何一个都不会发生变化。

那么,如果这种不变性如此出色,为什么我回答你不能做任何有用的事情而不是改变状态?嗯,没有可变性,你的整个程序都将变成一个巨大的f(x)= y函数。并且所有程序的部分都将是这样:只是函数,而且在“纯”意义上的函数。正如我所说,这意味着每次f(x)= y。因此,例如readFile(“myFile.txt”)每次都需要返回相同的字符串值。没什么用。

因此,每个FP都提供了一些修改状态的手段。“纯”的函数式语言(例如Haskell)使用一些有点可怕的概念,如单子,而“不纯”的语言(例如ML)可以直接允许这样做。

当然,功能性语言包括许多其他有用的工具,使编程更有效率,例如一流函数等。

0
0 Comments

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

如果你感兴趣,这里有一系列的文章,介绍使用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 Cons(T hd, Stack tl) { return new Stack(hd, tl); }
        public static Stack Append(Stack x, Stack y)
        {
            return x == null ? y : Cons(x.Head, Append(x.Tail, y));
        }
        public static void Iter(Stack x, Action f) { if (x != null) { f(x.Head); Iter(x.Tail, f); } }
    }
    class Stack
    {
        public readonly T Head;
        public readonly Stack Tail;
        public Stack(T hd, Stack tl)
        {
            this.Head = hd;
            this.Tail = tl;
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            Stack x = Stack.Cons(1, Stack.Cons(2, Stack.Cons(3, Stack.Cons(4, null))));
            Stack y = Stack.Cons(5, Stack.Cons(6, Stack.Cons(7, Stack.Cons(8, null))));
            Stack 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解析器,它完全无状态(或者至少的代码是无状态的,我不知道底层的词法分析库是否无状态)。

无状态编程与有状态编程一样具有表现力和强大的能力,只需要一点练习就可以训练自己开始无状态思考。当然,“尽可能使用无状态编程,在必要时使用有状态编程”似乎是大多数不纯函数语言的座右铭。在函数式方法不够清晰或高效时使用可变变量并没有伤害。

0