Lenses、State monad 和具有已知键的 Maps

Lenses, the State monad, and Maps with known keys

这是一个我一直遇到的难题,我相信,以前的 SO 问题都没有解决:如何最好地使用 lens 库来设置或获取 State monad 管理涉及 Maps 的嵌套数据结构,当我知道涉及的映射中存在某些键时 ?

这是谜题

{-# LANGUAGE TemplateHaskell, DerivingVia #-}

import Control.Monad.State
import Control.Monad.Except
import Control.Lens
import Data.Maybe
import Control.Monad
import Data.Map

type M = StateT World (ExceptT String Identity)

data World = World
  { _users      :: Map UserId User
  , _otherStuff :: Int
  }

type UserId = Int

data User = User
  { _balance     :: Balance
  , _moreStuff   :: Int
  }

newtype Balance = Balance Int
  deriving (Eq, Ord, Num) via Int

makeLenses 'World
makeLenses 'User

deleteUser :: UserId -> M ()
deleteUser uid = do
   user <- use $ users . at uid
   unless (isJust user) (throwError "unknown user")

   -- from here on we know the users exists.

   -- Question: how should the following lens look like?
   balance <- use $ users . ix uid . balance
   when (balance < 0) (throwError "you first have to settle your debt")
   when (balance > 0) (throwError "you first have to withdraw the remaining balance")

   users . at uid .= Nothing

尝试 1:使用 ix

上面的代码片段使用了 ix

   balance <- use $ users . ix uid . balance

这会产生 Traversal,因此它可能会关注多个元素或根本 none。在 use 的上下文中,这意味着我们需要一个 Monoid 和一个 Semigroup 实例。其实GHC是这么说的:

    • No instance for (Monoid Balance) arising from a use of ‘ix’
    • In the first argument of ‘(.)’, namely ‘ix uid’
      In the second argument of ‘(.)’, namely ‘ix uid . balance’
      In the second argument of ‘($)’, namely ‘users . ix uid . balance’
   |
45 |    balance <- use $ users . ix uid . balance

Balance 没有实现 <> 的好方法。我可以只实现加法,或者使用 error,因为事实上,这个函数永远不会被调用。但这是最干净的方法吗?

尝试 2:使用 at

另一个选项似乎是使用 at

   balance <- use $ users . at uid . balance

这会产生一个 Lens,它专注于 Maybe User。这意味着,跟进镜头 balance 类型错误。

    • Couldn't match type ‘User’
                     with ‘Maybe (IxValue (Map UserId User))’
      Expected type: (User -> Const Balance User)
                     -> Map UserId User -> Const Balance (Map UserId User)
        Actual type: (Maybe (IxValue (Map UserId User))
                      -> Const Balance (Maybe (IxValue (Map UserId User))))
                     -> Map UserId User -> Const Balance (Map UserId User)
    • In the first argument of ‘(.)’, namely ‘at uid’
      In the second argument of ‘(.)’, namely ‘at uid . balance’
      In the second argument of ‘($)’, namely ‘users . at uid . balance’

尝试 3:使用 atMaybe

让我们尝试使用它 Maybe

   balance <- use $ users . at uid . _Just . balance

这次我们有一个Prism,需要处理必须和Nothing一起工作的情况。所以我们回到要求 Monoid.

    • No instance for (Monoid Balance) arising from a use of ‘_Just’
    • In the first argument of ‘(.)’, namely ‘_Just’
      In the second argument of ‘(.)’, namely ‘_Just . balance’
      In the second argument of ‘(.)’, namely ‘at uid . _Just . balance’

尝试 3b:使用 atMaybe

让我们尝试另一种方法来处理它 Maybe

   balance <- use $ users . at uid . non undefined . balance

来自文档:

If v is an element of a type a, and a' is a sans the element v, then non v is an isomorphism from Maybe a' to a.

我们可以使用 undefinederror 或我们想要的任何“空”User,没关系,因为这种情况永远不会触发,只要用户 ID 存在在地图上。

为此,我们需要 EqUser,这很公平。它编译并且似乎工作;也就是说,阅读。对于写作,最初有一个意想不到的转折:

topUp :: UserId -> Balance -> M ()
topUp uid b = do
   user <- use $ users . at uid
   unless (isJust user) (throwError "unknown user")

   users . at uid . non undefined . balance += b

运行 爆炸了

experiment-exe: Prelude.undefined
CallStack (from HasCallStack):
  error, called at libraries/base/GHC/Err.hs:79:14 in base:GHC.Err
  undefined, called at app/Main.hs:55:25 in main:Main

的解释是,在书写时,我们使用光学“从右到左”,在这个方向上 non 正在注入我们提供的 User 作为其参数。将 undefined 替换为“空”User 可以掩盖此错误。它总是用空用户替换现有用户,在尝试充值时有效地失去用户的初始余额。

结论

所以,我找到了让这篇文章适合阅读的选项,但 none 似乎很有说服力。我想不出这个来写。

你有什么建议?应该如何构造该镜头?

编辑:将解决方案移至自答。

如果您确定密钥存在,那么您可以使用fromJustMaybe User变成User

balance <- use $ users . at uid . to fromJust . balance

尽管作为设计问题,我建议用引发有意义错误的函数替换 use $ users . at uid ...

getUser :: UserId -> M User

以及方便的镜头访问:

getsUser :: UserId -> Getter User a -> M a

然后每次您想查找用户时调用其中一个。这样您就不必在函数的开头进行单独检查。

执行此操作的最直接工具 lens 是不安全的操作,这些操作将您“知道”的遍历处理为仅针对一个元素,就好像它是一个镜头一样。正如您可能知道的那样,有一个运算符可以用作 ^.^?:

的变体
s <- get
let user = s ^?! users . at uid

但是对于view(在Reader内)或use(在State内),似乎没有任何built-in变化。不过,您可以使用 Control.Lens.Traversal 中的 unsafeSingular 函数编写自己的函数:

use1 :: MonadState s m => Traversal' s a -> m a
use1 = use . unsafeSingular

view1 :: MonadReader s m => Traversal' s a -> m a
view1 = view . unsafeSingular

之后:

balance <- use1 $ users . ix uid . balance

应该可以。

如果您宁愿使光学元件本身不安全,也不愿不安全地使用安全光学元件,您可以直接使用 unsafeSingular 来修改光学元件。例如:

balance <- use $ users . unsafeSingular (ix uid) . balance

有:

  • balance <- use $ users . at uid . to fromJust . balance 用于阅读 ()
  • users . at uid . traversed . balance += b 写作 ()

而这款镜头适合阅读和写作:

  • users . unsafeSingular (ix uid) . balance ()