读者模型的目的是什么?

149

读者模式看起来很复杂,而且似乎没有什么用处。在像Java或C++这样的命令式语言中,如果我没有弄错的话,没有与读者模式相当的概念。

你能给我一个简单的例子,并解释一下吗?


26
如果您想偶尔从一个(不可修改的)环境中读取一些值,但又不想显式地传递该环境,则可以使用阅读器单子。在Java或C++中,您可以使用全局变量(尽管这并不完全相同)。 - Daniel Fischer
6
@丹尼尔:那听起来非常像一个答案 - SingleNegationElimination
@TokenMacGuy 回答太短了,现在已经太晚了,我想不出更长的回答。如果没有其他人回答,我会在睡觉后回答。 - Daniel Fischer
8
在Java或C++中,Reader单子(monad)类似于在对象构造函数中传递的配置参数,这些参数在对象的生命周期内从未更改。在Clojure中,它有点像动态作用域变量,用于对函数行为进行参数化,而无需显式地将其作为参数传递。 - danidiaz
@DanielFischer,我希望你能把你的评论变成一个答案(加上一些充实的内容,使其成为一个合适的答案)。这可能是最简洁和最容易理解的答案。 - Motorhead
4个回答

206

不要害怕!读者单子其实并不那么复杂,而且有真正易于使用的实用功能。

有两种方法来理解单子:我们可以询问

  1. 单子做什么?它配备了哪些操作?它适用于什么情况?
  2. 单子是如何实现的?它从何而来?

从第一种方法来看,读者单子是某种抽象类型。

data Reader env a

如此

-- Reader is a monad
instance Monad (Reader env)

-- and we have a function to get its environment
ask :: Reader env env

-- finally, we can run a Reader
runReader :: Reader env a -> env -> a

那么我们如何使用它呢?读者单子模式适用于通过计算传递(隐含的)配置信息。每当您在计算中需要一个“常量”,但实际上您希望能够使用不同的值执行相同的计算时,就应该使用读者单子模式。读者单子模式也用于执行面向对象人员所谓的依赖注入。例如,negamax算法经常用于(高度优化的形式)计算两个玩家游戏中位置的价值。然而,该算法并不关心您正在玩什么游戏,只需确定游戏中的“下一个”位置,并且需要能够告诉当前位置是否为胜利位置。
 import Control.Monad.Reader

 data GameState = NotOver | FirstPlayerWin | SecondPlayerWin | Tie

 data Game position
   = Game {
           getNext :: position -> [position],
           getState :: position -> GameState
          }

 getNext' :: position -> Reader (Game position) [position]
 getNext' position
   = do game <- ask
        return $ getNext game position

 getState' :: position -> Reader (Game position) GameState
 getState' position
   = do game <- ask
        return $ getState game position


 negamax :: Double -> position -> Reader (Game position) Double
 negamax color position
     = do state <- getState' position 
          case state of
             FirstPlayerWin -> return color
             SecondPlayerWin -> return $ negate color
             Tie -> return 0
             NotOver -> do possible <- getNext' position
                           values <- mapM ((liftM negate) . negamax (negate color)) possible
                           return $ maximum values

这将适用于任何有限、确定性的两人游戏。
即使对于那些实际上不需要依赖注入的东西,此模式也很有用。假设你在金融领域工作,你可能会设计一些复杂的逻辑来定价某个资产(比如衍生品),这都是很好的,而且你可以没有单子做到这一点。但是,你修改了程序以处理多种货币。你需要能够随时在货币之间进行转换。你的第一个尝试是定义一个顶层函数。
type CurrencyDict = Map CurrencyName Dollars
currencyDict :: CurrencyDict

获取即时价格。您可以在代码中调用此字典...但等等!那行不通!货币字典是不可变的,因此不仅必须在程序生命周期内保持不变,而且从它被编译开始就必须如此!那么你该怎么办?嗯,一个选择是使用Reader monad:

 computePrice :: Reader CurrencyDict Dollars
 computePrice
    = do currencyDict <- ask
      --insert computation here

也许最经典的用例是实现解释器。但在此之前,我们需要介绍另一个函数。
 local :: (env -> env) -> Reader env a -> Reader env a

好的,所以 Haskell 和其他函数式编程语言是基于lambda演算。Lambda演算有一个看起来像这样的语法:
 data Term = Apply Term Term | Lambda String Term | Var Term deriving (Show)

我们希望为该语言编写一个评估器。为此,我们需要跟踪环境,这是与项相关联的绑定列表(实际上它将是闭包,因为我们想要进行静态作用域)。

 newtype Env = Env ([(String, Closure)])
 type Closure = (Term, Env)

当我们完成后,应该得到一个值(或错误):
 data Value = Lam String Closure | Failure String

那么,让我们来编写解释器:

interp' :: Term -> Reader Env Value
--when we have a lambda term, we can just return it
interp' (Lambda nv t)
   = do env <- ask
        return $ Lam nv (t, env)
--when we run into a value, we look it up in the environment
interp' (Var v)
   = do (Env env) <- ask
        case lookup (show v) env of
          -- if it is not in the environment we have a problem
          Nothing -> return . Failure $ "unbound variable: " ++ (show v)
          -- if it is in the environment, then we should interpret it
          Just (term, env) -> local (const env) $ interp' term
--the complicated case is an application
interp' (Apply t1 t2)
   = do v1 <- interp' t1
        case v1 of
           Failure s -> return (Failure s)
           Lam nv clos -> local (\(Env ls) -> Env ((nv, clos) : ls)) $ interp' t2
--I guess not that complicated!

最后,我们可以通过传递一个简单的环境来使用它:
interp :: Term -> Value
interp term = runReader (interp' term) (Env [])

就是这样。一个完全功能的lambda演算解释器。


另一种思考方式是问:它是如何实现的?答案是,阅读器单子其实是所有单子中最简单、最优雅的之一。
newtype Reader env a = Reader {runReader :: env -> a}

Reader 只是函数的一个花哨的名称!我们已经定义了 runReader,那么 API 的其他部分呢?好吧,每个 Monad 也同时是一个 Functor

instance Functor (Reader env) where
   fmap f (Reader g) = Reader $ f . g

现在,要获取一个单子:
instance Monad (Reader env) where
   return x = Reader (\_ -> x)
   (Reader f) >>= g = Reader $ \x -> runReader (g (f x)) x

这并不太可怕。ask真的很简单:

ask = Reader $ \x -> x

虽然 local 不是很糟糕:

local f (Reader g) = Reader $ \x -> runReader g (f x)

好的,所以阅读器单子只是一个函数。为什么需要Reader呢?很好的问题。实际上,你不需要它!

instance Functor ((->) env) where
  fmap = (.)

instance Monad ((->) env) where
  return = const
  f >>= g = \x -> g (f x) x

这些甚至更简单。而且,ask只是idlocal只是函数组合,函数的顺序被调换了!

6
非常有趣的回答。老实说,每当我想回顾单子模式时,我都会多次阅读它。顺便说一下,关于nagamax算法,“values <- mapM(negate . negamax(negate color)) possible”似乎不正确。我知道你提供的代码只是为了展示读者单子模式的工作原理。但如果你有时间,能否纠正negamax算法的代码?因为使用读者单子模式解决negamax问题很有趣。 - chipbk10
6
“Reader” 是一种带有特定实现的单子类型类的函数吗?如果之前说过这点,那么我就不会感到困惑了。一开始我没有理解它。中途我想,“哦,它允许你返回一个东西,一旦你提供了缺失的值,它就会给出你想要的结果。” 我认为这很有用,但突然意识到一个函数恰好做到了这点。 - ziggystar
1
阅读完这篇文章后,我大部分都理解了。不过,“local”函数还需要更多的解释。 - Christophe De Troyer
@Philip,我有一个关于Monad实例的问题。我们不能将bind函数写成(Reader f) >>= g = (g (f x))吗? - zeronone
@zeronone,x在哪里? - Ashish Negi
显示剩余3条评论

66
我记得当你感到困惑时,直到我自己发现Reader monad的变体无处不在。我是如何发现它的?因为我一直在写代码,结果发现它们都是小变化的变体。
例如,在某个时候,我正在编写一些处理历史值的代码;即随时间而变化的值。这的一个非常简单的模型是从时间点到该时间点上的值的函数:
import Control.Applicative

-- | A History with timeline type t and value type a.
newtype History t a = History { observe :: t -> a }

instance Functor (History t) where
    -- Apply a function to the contents of a historical value
    fmap f hist = History (f . observe hist)

instance Applicative (History t) where
    -- A "pure" History is one that has the same value at all points in time
    pure = History . const

    -- This applies a function that changes over time to a value that also 
    -- changes, by observing both at the same point in time.
    ff <*> fx = History $ \t -> (observe ff t) (observe fx t)

instance Monad (History t) where
    return = pure
    ma >>= f = History $ \t -> observe (f (observe ma t)) t

"

Applicative实例意味着如果你有employees :: History Day [Person]customers :: History Day [Person],你可以这样做:

"
-- | For any given day, the list of employees followed by the customers
employeesAndCustomers :: History Day [Person]
employeesAndCustomers = (++) <$> employees <*> customers

I.e., FunctorApplicative 可以让我们将普通的、非历史性的函数适应于历史记录。
通过考虑函数 (>=>) :: Monad m => (a -> m b) -> (b -> m c) -> a -> m c,我们可以最直观地理解单子实例。类型为 a -> History t b 的函数将 a 映射到 b 值的历史记录;例如,你可以有 getSupervisor :: Person -> History Day SupervisorgetVP :: Supervisor -> History Day VP。因此,History 的单子实例是关于组合这些函数的;例如,getSupervisor >=> getVP :: Person -> History Day VP 是一个函数,对于任何 Person,它获取了他们拥有的所有 VP 的历史记录。

嗯,这个 History monad 实际上与 Reader 完全相同。 History t a 真正等同于 Reader t a(也等同于 t -> a)。

另一个例子:最近我一直在 Haskell 中原型化 OLAP 设计。这里的一个想法是“超立方体”,它是从一组维度的交集到值的映射。我们再来看一下:

newtype Hypercube intersection value = Hypercube { get :: intersection -> value }

在超立方体上常见的操作是对应点应用多元标量函数。为了实现这个功能,我们可以为Hypercube定义一个Applicative实例:

instance Functor (Hypercube intersection) where
    fmap f cube = Hypercube (f . get cube)


instance Applicative (Hypercube intersection) where
    -- A "pure" Hypercube is one that has the same value at all intersections
    pure = Hypercube . const

    -- Apply each function in the @ff@ hypercube to its corresponding point 
    -- in @fx@.
    ff <*> fx = Hypercube $ \x -> (get ff x) (get fx x)

I just copypasted the History code above and changed names. As you can tell, Hypercube is also just Reader.
It goes on and on. For example, language interpreters also boil down to Reader, when you apply this model:
- Expression = a Reader - Free variables = uses of ask - Evaluation environment = Reader execution environment. - Binding constructs = local
一个很好的比喻是,Reader r a 表示一个带有“空洞”的 a,防止你知道我们正在谈论哪个 a。只有在提供一个 r 来填补这些空洞后,才能获得实际的 a。还有很多类似的东西。在上面的例子中,“history”是一个值,在指定时间之前无法计算;超立方体是一个值,在指定交点之前无法计算;语言表达式是一个值,在提供变量的值之前无法计算。这也让你直观地理解为什么 Reader r ar -> a 是相同的,因为这样的函数本质上也是一个缺少了 ra
因此,ReaderFunctorApplicativeMonad 实例非常适用于模拟任何“缺少 ra”类型的情况,并允许您将这些“不完整”的对象视为完整。
另一种说法是:一个Reader r a是消耗r并产生a的东西,而FunctorApplicativeMonad实例是处理Reader的基本模式。Functor=创建一个修改另一个Reader输出的Reader;Applicative=将两个Reader连接到同一输入并组合它们的输出;Monad=检查Reader的结果并使用它来构建另一个ReaderlocalwithReader函数=创建一个修改另一个Reader输入的Reader

8
好的回答。您还可以使用“GeneralizedNewtypeDeriving”扩展来派生新类型的“Functor”,“Applicative”,“Monad”等,基于它们的底层类型。 - Rein Henrichs

28

在Java或C++中,您可以从任何地方访问任何变量而不会出现任何问题。当代码变为多线程时,问题出现了。

在Haskell中,只有两种方法可以将值从一个函数传递到另一个函数:

  • 您可以通过可调用函数的其中一个输入参数传递该值。缺点是:1)您无法通过这种方式传递所有变量 - 输入参数列表会让您感到困惑。2)在函数调用序列:fn1 -> fn2 -> fn3中,函数fn2可能不需要从fn1传递到fn3的参数。
  • 您可以将值传递给某个monad的范围内。缺点是:您必须充分理解Monad概念。在传递值的过程中,只是使用单子的众多应用之一。实际上,Monad概念非常强大。如果您一开始没有领悟,请不要灰心。继续尝试并阅读不同的教程。您将获得回报的知识。

Reader monad只是传递您想要在函数之间共享的数据。函数可以读取该数据,但不能更改它。那就是Reader monad所做的全部。好吧,几乎全部。还有一些像local这样的函数,但是您可以先只使用asks


3
使用单子隐式传递数据的另一个缺点是,很容易发现自己在do符号语法中编写了许多“命令式风格”的代码,最好将其重构为纯函数。 - Benjamin Hodgson
6
使用 do-notation 中的单子编写“命令式”的代码并不一定意味着编写具有副作用(不纯)的代码。实际上,在 Haskell 中具有副作用的代码可能仅限于 IO 单子中。 - Dmitry Bespalov
如果另一个函数通过where子句连接到该函数,那么它是否被接受为传递变量的第三种方式? - Elmex80s
1
好的观点。您的回答清楚地表明了语言之间的差异,例如支持单子(monads)的 Haskell 和其他不推广单子使用的函数式语言,其中第一种选择是规范。 - mljrg

2

我从以上答案中学到了很多,并感谢它们的回答。虽然我对 Haskell 还很陌生,但我想谈一下关于 Reader Monad 的一些事情。

首先,将 Reader 视为一个 Computation 是很重要的。它不是一个状态,而是一个计算。 例如,Reader monad 的第一个官方示例 中提到的函数 calc_isCountCorrect 返回一个 Reader Bindings Bool,这意味着当调用 runReader 时,它接收一个 Bindings 并返回一个 Bool。它是一个计算。

calc_isCountCorrect :: Reader Bindings Bool
calc_isCountCorrect = do
    count <- asks (lookupVar "count")
    bindings <- ask
    return (count == (Map.size bindings))

您也可以通过参数传递Bindings,当您的代码非常简单时,没有什么显著的区别。
calcIsCountCorrectWithoutReader :: Bindings -> Bool
calcIsCountCorrectWithoutReader bindings = do
  let num = lookupVar "count" bindings
  let count = Map.size bindings
  num == count 

然而,它确实有一个区别,那就是价值来自何处。它提供了一种通过隐式来源而不是参数来检索它的方式。

当涉及类比问题时,我认为C++中的lambda是一个很好的解释。

在像Java或C++这样的命令式语言中,没有与读者单子等效的概念。

读者使您能够从外部(非全局变量,而是上层作用域)检索值。这很像C++ lambda中的捕获子句。

例如,您有一个Haskell代码,将number变为三倍,并将其与隐式值相加。以下代码输出10

import           Control.Monad.Reader
trebleNumberAndAddImplicitly :: Int -> Reader Int Int
trebleNumberAndAddImplicitly number = do
  implicitNumber <- ask
  return $ 3*number + implicitNumber

main :: IO ()
main = do
  print $ runReader (trebleNumberAndAddImplicitly 3) 1

隐式值在计算之外,但可以通过Reader访问。
在C++中,这被称为捕获子句。输出是:output is: 10。然而,它比Haskell有更多的限制。但在我看来它们相似。
#include<iostream>

int main(){
    int implicitNumber = 1;
    auto trebleNumberAndAddImplicitly = [implicitNumber](int number) -> int {
        return (3*number + implicitNumber);
    };
    std::cout << "output is: " << trebleNumberAndAddImplicitly(3);
}

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