非纯函数破坏组合性是什么意思?

13

有人能举个例子解释在实践中人们说非纯函数破坏了函数式语言组合性是什么意思吗?

我想看一个组合性的例子,然后看看同样的例子假设非纯函数,以及非纯度如何破坏了组合性。


1
这是我最喜欢的关于纯度和惰性如何促进组合的帖子之一:http://apfelmus.nfshost.com/articles/quicksearch.html - luqui
4个回答

11

以下是我曾经遇到可变状态问题的一些例子:

  • 我编写了一个函数来从一堆文本中爬取一些信息。它使用简单的正则表达式在混乱的文本中找到正确的位置并抓取一些字节。它停止工作了,因为我的程序中的另一个部分打开了正则表达式库中的大小写敏感模式;或者打开了改变正则表达式解析方式的“魔法”模式;或者其他十几个我在调用正则表达式匹配器时忘记可用的选项。

    这在纯语言中不是问题,因为正则表达式选项会出现作为匹配函数的明确参数。

  • 我有两个线程想对我的语法树进行一些计算。我毫不考虑地去做了。由于两个计算都涉及重写树中的指针,因此当我跟随之前良好但由于其他线程所做的更改而过时的指针时,我最终会崩溃。

    在纯语言中,这不是问题,因为树是不可变的;两个线程返回的树存在于堆的不同部分,并且两者都可以看到原始的未被干扰的树。

  • 我自己没有遇到这个问题,但我听过其他程序员的抱怨:基本上每个使用OpenGL的程序都有这个问题。管理OpenGL状态机是一场噩梦。如果你稍微弄错了任何部分的状态,每个调用都会做一些愚蠢的事情。

    很难说在纯设置中会是什么样子,因为并没有那么多广泛使用的纯图形库。对于3D方面,可以看看来自Haskell领域的fieldtrip,而在2D方面,也许可以看看diagrams。在每种情况下,场景描述是组合的,意味着可以使用像“将这个场景放在那个场景左边”、“将这两个场景叠加”、“在那个场景之后显示这个场景”等组合器轻松地将两个小场景组合成一个更大的场景,而后端代码会确保在渲染这两个场景的调用之间修改底层图形库的状态。

以上非纯净场景的共同点是,无法从一个代码块中看出它的本地作用。必须全局理解整个代码库才能确信了解这个代码块将要执行的操作。这就是组合性的核心含义:可以组合小的代码块并理解它们的作用;当它们被嵌入到较大的程序中时,它们仍然会执行相同的操作。


3
我觉得你不会“看到同样的例子,假设不是纯函数,以及不纯性如何破坏可组合性”。任何情况下,副作用对于可组合性都是一个问题,但这种情况在纯函数中不会出现。
但以下是人们所说的“非纯函数破坏可组合性”的一个示例:
假设你有一个POS系统,类似于这样(假装这是C++或其他语言):
class Sale {
private:
    double sub_total;
    double tax;
    double total;
    string state; // "OK", "TX", "AZ"
public:

    void calculateSalesTax() {
        if (state == string("OK")) {
            tax = sub_total * 0.07;
        } else if (state == string("AZ")) {
            tax = sub_total * 0.056;
        } else if (state == string("TX")) {
            tax = sub_total * 0.0625;
        } // etc.
        total = sub_total + tax;
    }

    void printReceipt() {
        calculateSalesTax(); // Make sure total is correct
        // Stuff
        cout << "Sub-total: " << sub_total << endl;
        cout << "Tax: " << tax << endl;
        cout << "Total: " << total << endl;
   }

现在您需要添加对俄勒冈州(无销售税)的支持。只需添加以下代码块:
        else if (state == string("OR")) {
            tax = 0;
        }

要计算销售税,可以使用calculateSalesTax函数。但是假设有人决定变得“聪明”并说:

        else if (state == string("OR")) {
            return; // Nothing to do!
        }

现在total不再被计算了!由于calculateSalesTax函数的输出不够清晰,程序员做出了一个不产生所有正确值的更改。

回到Haskell,使用纯函数,上述设计根本行不通;相反,你必须说出类似于下面的话:

calculateSalesTax :: String -> Double -> (Double, Double) -- (sales tax, total)
calculateSalesTax state sub_total = (tax, sub_total + tax) where
    tax
        | state == "OK" = sub_total * 0.07
        | state == "AZ" = sub_total * 0.056
        | state == "TX" = sub_total * 0.0625
        -- etc.

printReceipt state sub_total = do
    let (tax, total) = calculateSalesTax state sub_total
    -- Do stuff
    putStrLn $ "Sub-total: " ++ show sub_total
    putStrLn $ "Tax: " ++ show tax
    putStrLn $ "Total: " ++ show total

现在很明显,需要通过添加一行来添加Oregon。
    | state == "OR" = 0

计算税收时,通过函数的输入和输出全部明确,成功避免了Bug。


在这个例子中,问题是否是突变而不是早期返回并不清楚 - 这是一种自过程命令式编程的早期就批评的构造。作为一个魔鬼的拥护者的例子,考虑混合函数/命令式语言(如Schema或ML),在这里没有限制突变,但语言不提供从函数早期的return。那么编写像示例一样的错误就更加困难了。 - Luis Casillas
即使return是问题,我认为这仍然回答了问题。return是一个不纯的函数,一种效果。 - luqui

2
答案实际上很简单:如果你有不纯的函数,也就是有副作用的函数,那么这些副作用可能会相互干扰。一个基本的例子是在执行期间将某些内容存储在外部变量中的函数。两个使用同一变量的函数无法组合——只有一个结果会被保留。这个例子看起来很琐碎,但在一个具有多个不纯函数的复杂系统中,访问各种资源时发生冲突可能非常难以追踪。
一个经典的例子是在多线程环境中保护可变(或其他排他)资源。单个访问资源的函数没有问题。但是两个这样的函数运行在不同的线程中时就会出现问题——它们不能组合。
因此,我们为每个资源添加锁,并根据需要获取/释放锁来同步操作。但是,函数仍然无法组合。并行运行只需要一个锁的函数没有问题,但是如果我们开始将函数组合成更复杂的函数,并且每个线程可以获取多个锁,则可能会出现死锁(一个线程获取Lock1,然后请求Lock2,而另一个线程获取Lock2,然后请求Lock1)。
因此,我们要求所有线程按照给定的顺序获取锁以防止死锁。现在框架是无死锁的,但不幸的是,函数由于另一个原因而无法组合:如果f1获取Lock2并且f2需要f1的输出来决定获取哪个锁,并且f2根据输入请求Lock1,则违反了顺序不变性,即使f1f2分别满足它....

解决这个问题的可组合解决方案是软件事务内存STM。每个这样的计算都在一个事务中执行,并在其访问共享可变状态与另一个计算干扰时重新启动。这里严格要求计算是纯的 - 计算可以被中断和重新启动,在任何时候都可能会执行它们的副作用部分和/或多次执行。


1

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