Python的“with”是单子的吗?

37 投票
4 回答
4545 浏览
提问于 2025-04-17 00:00

就像许多勇敢的先驱一样,我正在努力穿越理解单子(Monad)的无边荒野。

我还在摸索中,但我注意到Python的with语句似乎有点像单子的特性。想想这个代码片段:

with open(input_filename, 'r') as f:
   for line in f:
       process(line)

可以把open()这个调用看作“单位”,而代码块本身则是“绑定”。实际上,单子并没有被直接展示(呃,除非f就是单子),但这个模式是存在的,对吧?还是我只是把函数式编程(FP)都误认为是单子?或者说现在是凌晨三点,什么都显得合理?

还有一个相关的问题:如果我们有单子,那还需要异常处理吗?

在上面的代码片段中,任何输入输出的失败都可以被隐藏起来。比如磁盘损坏、缺少指定的文件,或者文件为空,这些情况都可以被统一处理。所以不需要明显的输入输出异常。

当然,Scala的Option类型类已经消除了令人头疼的Null Pointer Exception。如果你把数字重新理解为单子(把NaNDivideByZero当作特殊情况)……

就像我说的,现在是凌晨三点。

4 个回答

9

在Haskell中,有一个和Python的with类似的东西,叫做withFile。这个:

with open("file1", "w") as f:
    with open("file2", "r") as g:
        k = g.readline()
        f.write(k)

相当于:

withFile "file1" WriteMode $ \f ->
  withFile "file2" ReadMode $ \g ->
    do k <- hGetLine g
       hPutStr f k

现在,withFile看起来可能有点像单子(monadic)。它的类型是:

withFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO r

右边看起来像(a -> m b) -> m b

还有一个相似之处:在Python中,你可以省略as,而在Haskell中,你可以用>>代替>>=(或者在do块中不使用<-箭头)。

所以我来回答这个问题:withFile是单子吗?

你可以想象它可以这样写:

do f <- withFile "file1" WriteMode
   g <- withFile "file2" ReadMode
   k <- hGetLine g
   hPutStr f k

但这样写是不对的,类型检查不通过。它不能这样写。

这是因为在Haskell中IO单子是顺序的:如果你写

do x <- a
   y <- b
   c

那么在a执行后,b会执行,然后是c。没有“回溯”来在最后清理a之类的东西。另一方面,withFile在块执行完后必须关闭文件句柄。

还有另一种单子,叫做继续单子(continuation monad),它允许做这样的事情。不过,这样你就有了两个单子,IO和继续单子,同时使用这两个单子的效果需要用到单子变换器。

import System.IO
import Control.Monad.Cont

k :: ContT r IO ()
k = do f <- ContT $ withFile "file1" WriteMode 
       g <- ContT $ withFile "file2" ReadMode 
       lift $ hGetLine g >>= hPutStr f

main = runContT k return

这就显得很麻烦。所以答案是:有点像,但这需要处理很多细节,让这个问题变得相当复杂。

Python的with只能模拟单子能做的有限部分——添加进入和结束的代码。我觉得你不能用with来模拟,比如:

do x <- [2,3,4]
   y <- [0,1]
   return (x+y)

(可能用一些小技巧可以做到)。相反,使用for

for x in [2,3,4]:
    for y in [0,1]:
        print x+y

而且Haskell中有一个函数可以做到这一点——forM

forM [2,3,4] $ \x ->
  forM [0,1] $ \y ->
    print (x+y)

我推荐你了解一下yield,它和单子更相似,而不是withhttp://www.valuedlessons.com/2008/01/monads-in-python-with-nice-syntax.html

一个相关的问题是:如果我们有单子,是否还需要异常?

基本上不需要,与你其余的函数不同的是,你可以写一个返回Either A B的函数,而不是一个抛出A或返回B的函数。Either A的单子会像异常一样工作——如果某一行代码返回错误,整个块都会返回错误。

不过,这意味着除法的类型会是Integer -> Integer -> Either Error Integer,以捕捉除以零的错误。你必须在任何使用除法或有可能出错的代码中检测错误(显式模式匹配或使用绑定)。Haskell使用异常来避免这样做。

24

这件事可能听起来有点简单,但第一个问题是,with 不是一个函数,也不接受函数作为参数。你可以通过为 with 写一个函数包装器来轻松解决这个问题:

def withf(context, f):
    with context as x:
        f(x)

因为这件事太简单了,你甚至可以不去区分 withfwith

第二个问题是,with 作为一个语句而不是表达式,它没有值。如果你要给它一个类型,那就是 M a -> (a -> None) -> None(这实际上是上面 withf 的类型)。实际上,你可以使用 Python 的 _ 来获取 with 语句的值。在 Python 3.1 中:

class DoNothing (object):
    def __init__(self, other):
        self.other = other
    def __enter__(self):
        print("enter")
        return self.other
    def __exit__(self, type, value, traceback):
        print("exit %s %s" % (type, value))

with DoNothing([1,2,3]) as l:
    len(l)

print(_ + 1)

由于 withf 使用的是函数而不是代码块,_ 的一个替代方法是返回函数的值:

def withf(context, f):
    with context as x:
        return f(x)

还有一个原因让 with(和 withf)不能成为单子绑定。代码块的值必须是与 with 项相同类型构造器的单子类型。现在,with 更加通用。考虑到 agf 提到的每个接口都是一个类型构造器,我将 with 的类型定为 M a -> (a -> b) -> b,其中 M 是上下文管理器接口(包含 __enter____exit__ 方法)。在 bindwith 的类型之间是 M a -> (a -> N b) -> N b 的类型。要成为单子,with 必须在运行时失败,当 b 不是 M a 时。此外,虽然你可以将 with 作为绑定操作单子地使用,但这样做通常没有意义。

你需要做这些微妙的区分是因为,如果你错误地认为 with 是单子的,你会误用它,写出由于类型错误而失败的程序。换句话说,你会写出垃圾代码。你需要做的是区分一个特定的构造(例如单子)和一个可以以那种方式使用的构造(例如,再次是单子)。后者需要程序员的自律,或者定义额外的构造来强制执行这种自律。这里有一个几乎是单子的 with 版本(类型是 M a -> (a -> b) -> M b):

def withm(context, f):
    with context as x:
        return type(context)(f(x))

最终分析下来,你可以认为 with 像一个组合器,但比单子所需的组合器(即绑定)更通用。使用单子的函数可以比所需的两个更多(例如,列表单子还有 cons、append 和 length),所以如果你为上下文管理器定义了合适的绑定操作符(比如 withm),那么 with 就可以在涉及单子的意义上成为单子。

13

是的。

在定义的下面,维基百科上说

在面向对象编程中,这种类型构造相当于声明一个单子类型,单位函数的作用就像构造方法,而绑定操作则包含执行注册回调(单子函数)所需的逻辑。

这听起来就像上下文管理器协议,对象实现上下文管理器协议,以及 with 语句。

来自 @Owen 在这篇帖子中的评论:

单子在最基本的层面上,基本上是一种很酷的使用继续传递风格的方法:>>= 接受一个“生产者”和一个“回调”;这基本上也是 with 的意思:像 open(...) 这样的生产者和一段代码块,一旦创建就会被调用。

维基百科的完整定义:

一种类型构造,定义了如何为每种基础类型获得相应的单子类型。在 Haskell 的表示法中,单子的名称代表类型构造器。如果 M 是单子的名称,t 是数据类型,那么 “M t” 就是相应的单子类型。

这听起来像是 上下文管理器协议

一个单位函数,它将基础类型中的一个值映射到相应单子类型中的一个值。结果是相应类型中“最简单”的值,完全保留原始值(简单性在这里是相对于单子来说的)。在 Haskell 中,这个函数叫做 return,因为它在后面描述的 do-notation 中的用法。单位函数的多态类型是 t→M t。

对象实际实现上下文管理器协议。

一个多态类型的绑定操作 (M t)→(t→M u)→(M u),Haskell 用中缀运算符 >>= 表示。它的第一个参数是单子类型中的一个值,第二个参数是一个函数,它将第一个参数的基础类型映射到另一个单子类型,结果在那个其他单子类型中。

这对应于 with 语句及其代码块。

所以我可以说 with 是一个单子。我查找了 PEP 343 以及所有相关的被拒绝和撤回的 PEP,但没有一个提到“单子”这个词。它确实适用,但似乎 with 语句的 目标 是资源管理,而单子只是实现这一目标的一个有用方式。

撰写回答