镜头,状态单子和已知键的映射表

4

这里有一个难题一直困扰着我,而且我相信之前没有任何SO问题涉及到:当我明确知道某些键存在于涉及的Map中时,如何最好地使用lens库在管理嵌套数据结构的State单子中设置或获取值?

{-# 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,因此它可以关注多个元素或根本不关注。在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

这将生成一个聚焦于Maybe UserLens。这意味着,后续的balance镜头类型是错误的。

Couldn't match typeUser
                     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’

尝试三:使用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’

尝试三b:与 atMaybe 协作

让我们尝试另一种方式来处理那个 Maybe

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

根据文档:

如果v是类型a的一个元素,且a'是不包含元素v的类型,则非v是从Maybe a'到a的同构。

我们可以使用undefined、error或任何“空”的User,因为在地图中存在用户ID的情况下,这种情况永远不会被触发。

为了使其正常工作,我们需要为User提供Eq。并且它已经编译和运行,也就是说,进行读取操作时可以正常工作。然而,对于写入操作,最初会出现意外的转折:

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 会掩盖这个错误。它始终用空用户替换现有用户,在尝试充值时实际上失去了用户的初始余额。

结论

因此,我已经找到了使阅读工作的选项,但没有一个是令人信服的。我无法弄清楚如何进行写作。

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

编辑:将解决方案移动到自我答案。


1
也许是 maybeToExceptT - n. m.
如果你确信这些键存在,那么为什么不直接不将它们存储在映射中呢? - leftaroundabout
1
你愿意使用 justified-containers 吗? - Joseph Sible-Reinstate Monica
1
我不是镜头专家,也记不清楚遍历是否足以进行编写。users . at uid . traversed . balance += b 这样的写法可行吗? - Daniel Wagner
1
我建议将“编辑-解决方案”部分移动到自我回答中。(其他人的建议可以通过链接到他们来自的答案/评论来提供归属。) - duplode
显示剩余2条评论
3个回答

3
如果你确定该键存在,那么你可以使用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

每次要查找用户时,只需调用其中一个函数即可。这样,您就不必在函数头部使用单独的检查。


2

最直接的 lens 设施用于执行此操作的是不安全操作,它将把一个你“知道”只会针对一个元素的遍历视为镜头。正如您可能已经知道的那样,有一个运算符用作 ^.^? 的变体来执行此操作:

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

但是在Reader中的view或者State中的use,好像没有内置的变化。不过你可以使用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

1

这里有:

  • balance <- use $ users . at uid . to fromJust . balance 用于读取 (参见此答案)
  • users . at uid . traversed . balance += b 用于写入 (参见此评论)

而这个镜头适用于读写:


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