如何使用 lens 访问 sum 类型后面的记录字段

5

我正在尝试使用Haskell中的镜头和棱镜来访问嵌套记录:

import Data.Text (Text)
import Control.Lens.TH

data State = State
    { _stDone :: Bool
    , _stStep :: StateStep
    }

data StateStep
    = StatePause
    | StateRun
        { _stCounter  :: Int
        , _stMMistake :: Maybe Text
        }

makeLenses ''State
makeLenses ''StateStep
makePrisms ''StateStep

main :: IO ()
main = do
    let st = State False $ StateRun 0 Nothing

    -- works, but the `_2` seems weird
        mMistake = st ^? stStep . _StateStepRun . _2 . _Just

    -- why not something like (the following does not compile)
        mMistake = st ^. stStep . _StateStepRun . _Just . stMMistake

那个可行的代码留下了一些问题。我不确定类型是否仅是巧合。字段_stMMistake具有类型Maybe Text,但是对于 let st = State False StatePause,我该怎么办呢?
我错过了显式的join
而且我对棱镜(prisms)的工作原理毫无头绪。虽然棱镜给我一个元组似乎很合逻辑,但与此同时我期望它可以像使用镜头(lenses)那样进行深入嵌套结构。我需要手动派生实例吗?

为什么你对 _2 这个名字感到困扰?你说它很有道理地得到了一个 tuple,而 _2 是用来访问 tuple 中第二个元素的。Haskell 的类型系统非常严格,不会像你的第二个例子那样允许你跳过步骤。 - Silvio Mayolo
@SilvioMayolo 我本来希望使用 stMMistake 来在 _StateStepRun 之后深入访问。 _2 并不完全能表达原始数据类型不包含元组的情况。 - ruben.moor
2个回答

5

更新: 根据评论,我已经修复了一些错误,并在[[双方括号]]中添加了一些说明。

下面是你的第一个mMistake为什么会生效...

棱镜是一种光学装置,聚焦于可能存在于"整体"中的"部分"。[[严格来说,它聚焦于可以用来重建整个整体的那种部分,因此它实际上涉及到可以采用几种替代形式(例如和类型)的整体,其中这个"部分"是其中之一。但是,如果你只是用棱镜进行查看而不是设置,这种额外的功能并不太重要。]]

在你的例子中,_StateRun_Just 都是棱镜。 _Just 棱镜聚焦于 Maybe a 整体的 a 部分。这个 a 可能存在,也可能不存在。如果 Maybe a 的值是某个 x :: aJust x,则部分 a 存在且其值为 x,这就是 _Just 聚焦的内容。如果 Maybe a 的值是 Nothing,那么部分 a 不存在,_Just 就不会进行聚焦。

对于你的棱镜 _StateRun 来说,情况有些类似。如果整体 StateStep 是一个 StateRun x y 值,则 _StateRun 聚焦于表示为 StateRun 构造函数字段的元组的"部分",即 (x, y) :: (Int, Maybe Text)。另一方面,如果整体 StateStep 是一个 StatePause,这个部分不存在,棱镜就不会聚焦在任何东西上。

当你组合棱镜(如 _StateRun_Just)和透镜(如 stStep_2)时,你创建了一个新的光学装置,它将组合聚焦操作的序列。

正如评论中指出的,这个新光学元件不是棱镜;它只是“遍历”。事实上,它是一种特定类型的遍历,称为“仿射遍历”。一般的遍历可以关注零个或多个部分,而仿射遍历专注于恰好零个(未出现的部分)或一个(唯一的部分)。lens库没有区分仿射遍历和其他类型的遍历。新光学元件之所以“只是”仿射遍历而不是棱镜,与早期的技术细节有关。一旦您添加了镜头,就无法从单个“部分”重构整个“整体”。如果您仅用光学元件进行查看而不进行设置,则这并不重要。

无论如何,请考虑这个光学元件(仿射遍历):

optic1 = stStep . _StateRun . _2 . _Just

这个光学视野包含一个名为State的整体。第一个透镜stStep聚焦于它的StateStep字段。如果该StateStep是一个StateRun x (Just y)的值,则_StateRun棱镜聚焦于(x, Just y)部分,而_2透镜进一步聚焦于Just y部分,_Just棱镜进一步聚焦于y :: Text部分。
另一方面,如果StateStep字段是StatePause,则光学器optic1不会聚焦于任何内容(因为第二个组件棱镜_StateRun也没有聚焦于任何内容),如果是StateRun x Nothing,则光学器optic1仍然不会聚焦于任何内容,因为即使_StateRun可以聚焦于(x,Nothing)_2可以聚焦于Nothing,但最后的_Just不会聚焦于任何内容,因此整个光学器无法聚焦。
特别地,_2透镜处理StatePause时不会“误火”,也不会尝试引用缺少的第二个字段或类似的内容。使用_StateRun来聚焦于StateRun构造函数的字段元组,可以确保如果整个光学器聚焦,则所需字段将存在。
现在,这是您第二个光学器的原因:
optic2 = stStep . _StateRun . _Just . stMMistake

无法工作...

实际上有两个问题。首先,stStep . _StateRun 获取整个State并关注部分(Int, Maybe Text)。这不是一个Maybe值,因此它不能与_Just棱镜组合。您需要先选择Maybe Text字段,然后应用_Just棱镜,所以您实际想要的内容更像是:

optic3 = stStep . _StateRun . stMMistake . _Just

这看起来应该可以正常工作,是吧?stStep 镜头关注的是 StateStep_StateRun 棱镜只有在存在一个 StateRun x y 值时才聚焦,而 stMMistake 镜头应该让你聚焦于 y :: Maybe Text,留下 _Just 以聚焦于 Text

不幸的是,用 makePrisms 创建的棱镜并不是这样工作的。 _StateRun 棱镜聚焦于一个带有无名字段的普通元组,并且这些字段需要进一步选择使用 _1_2 等,而不能使用试图选择命名字段的 stMMistake

事实上,如果你仔细查看 stMMistake,你会发现它本身就是一个光学器(一个仿射遍历,或者就像 lens 库所说的,只是一个遍历),它接受整个StateStep 并直接聚焦于 _stMMistake 字段部分,而不需要指定构造函数。因此,你实际上可以使用 stMMistake 代替 _StateStepRun . _2,它们应该可以完全一样地工作:

mMistake = st ^? stStep . _StateStepRun . _2 . _Just
mMistake = st ^? stStep . stMMistake . _Just

这不是透镜的一些基本理论特性或其他什么东西。这只是makeLensesmakePrisms使用的命名和类型约定。使用makeLenses,您可以创建聚焦于数据结构中命名字段的光学工具。如果只有一个构造函数:
data Foo = Bar { _x :: Int, _y :: Double }

或者如果有多个构造函数但该字段在所有构造函数中都存在:
data Foo = Bar { _x :: Int, _y :: Double }
         | Baz { _x :: Int, _z :: Char }

那么字段光学(在此示例中为 x)是一种始终聚焦于该字段的镜头。如果有多个构造函数,其中一些具有该字段而另一些没有:

data Foo = Bar { _x :: Int, _y :: Double }
         | Baz { _x :: Int, _z :: Char }
         | Quux { _f :: Int -> Double }

那么,字段光学(在这里是x)是一种聚焦于字段的光学(遍历),但仅在其存在时进行(即值为BarBaz时,而不是Quux时)。

另一方面,makePrisms总是创建构造器棱镜,用于聚焦未命名元组形式的字段,需要使用_1_2等引用这些字段,而不是使用构造函数内部的任何字段名称。

也许这回答了您的问题?


stMMistake 的类型是 Traversal。我认为使用它而不是 _StateStepRun . _2 是最优雅的方式。 - ruben.moor
1
我认为我应该指出,当你将棱镜与透镜组合时,你会得到一个仿射遍历。棱镜可以被反转以创建一个值,这意味着使用棱镜访问某些内容需要它将返回包含的所有内容。这就是为什么生成的棱镜会给你返回构造函数的所有字段的元组。当你用透镜从那个元组中投影出来时,你不能再反转组合光学器,因为你无法提供所有必要的数据。你所剩下的只是一个匹配零或一个东西的遍历。 - Carl
哎呀,你们俩都是对的。感谢指正。我已经尝试更新答案了。 - K. A. Buhr

3

当每个sum类型构造器最多只有一个字段时,光学通常会更加清晰易懂。在您的情况下,您可以编写类似以下内容:

data StateStep
    = StatePause
    | StateRun {-# UNPACK #-} !Runny

data Runny = Runny
  { _ryCounter :: Int
  , _ryNoMistake :: Maybe Text
  }

通过使用严格的field(字段)和(由于该字段不是“小”意义上的-funpack-small-strict-fields),以及一个{-# UNPACK #-} pragma,您可以确保StateStep在运行时的表示方式与您的代码中一致。但现在,您可以获得很好的字段镜头来访问Runny中的所有内容,并且一切都会很好地解决 - 没有奇怪的元组。


看起来很有前途,我会试一下。 - ruben.moor
确实是最干净的解决方案。 - ruben.moor

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