【问题标题】:How do you manage an object graph in Haskell?你如何在 Haskell 中管理对象图?
【发布时间】:2010-03-02 13:11:03
【问题描述】:

我正在尝试重新学习系统分析。我有很多面向对象的思想,但我无法在 Haskell 中找到等价物。

一个虚构的系统由救护站、救护车和船员组成。 (它已经变成对象了。)所有这些状态都可以封装在一个大的 SystemState 类型中。 SystemState [车站] [救护车] [船员]。然后我可以创建接受 SystemState 并返回新 SystemState 的函数。

module AmbSys
    ( version
    , SystemState
    , Station
    , Ambulance
    , Crew
    ) where

version = "0.0.1"

data SystemState = SystemState [Station] [Ambulance] [Crew] deriving (Show)

data Station = Station { stName :: String
                       , stAmbulances :: [Ambulance]
                       } deriving (Show)

data Ambulance = Ambulance { amCallSign :: String
                           , amStation :: Station
                           , amCrew :: [Crew]
                           } deriving (Show)

data Crew = Crew { crName :: String
                 , crAmbulance :: Ambulance
                 , crOnDuty :: Bool
                 } deriving (Show)

这是我创建一些数据的 ghci 会话。

*AmbSys> :load AmbSys                 
[1 of 1] Compiling AmbSys           ( AmbSys.hs, interpreted )
Ok, modules loaded: AmbSys.
*AmbSys> let s = Station "London" []                
*AmbSys> let a = Ambulance "ABC" s []               
*AmbSys> let s' = Station "London" [a]
*AmbSys> let c = Crew "John Smith" a False        
*AmbSys> let a' = Ambulance "ABC" s [c]   
*AmbSys> let s'' = Station "London" [a']             
*AmbSys> let system_state = SystemState [s''] [a'] [c]
*AmbSys> system_state                                 
SystemState [Station {stName = "London", stAmbulances = [Ambulance {amCallSign = "ABC",
 amStation = Station {stName = "London", stAmbulances = []}, amCrew = [Crew 
 {crName = "John Smith", crAmbulance = Ambulance {amCallSign = "ABC", 
 amStation = Station {stName = "London", stAmbulances = []}, amCrew = []}, 
 crOnDuty = False}]}]}] [Ambulance {amCallSign = "ABC", amStation = Station {
 stName = "London", stAmbulances = []}, amCrew = [Crew {crName = "John Smith",
 crAmbulance = Ambulance {amCallSign = "ABC", amStation = Station {stName = "London",
 stAmbulances = []}, amCrew = []}, crOnDuty = False}]}] [Crew {crName = "John Smith",
 crAmbulance = Ambulance {amCallSign = "ABC", amStation = Station {stName = "London",
 stAmbulances = []}, amCrew = []}, crOnDuty = False}]

您已经可以在这里看到几个问题:

  1. 我无法创建一致的 SystemState - 一些值是“旧”值,例如 s 或 s',而不是 s''。
  2. 许多对“相同”数据的引用都有单独的副本。

我现在可以创建一个函数,它接受一个 SystemState 和一个船员姓名,它返回一个新的 SystemState,其中船员是“下班”。

我的问题是我必须找到并更改救护车中的机组人员以及 SystemState 中的机组人员(相同的副本)。

这对于小型系统是可能的,但实际系统有更多的链接。它看起来像一个 n 平方问题。

我非常清楚我正在以面向对象的方式考虑系统。

如何在 Haskell 中正确创建这样的系统?

编辑:感谢大家的回答,也感谢 reddit 上的各位 http://www.reddit.com/r/haskell/comments/b87sc/how_do_you_manage_an_object_graph_in_haskell/

我现在的理解似乎是我可以在 Haskell 中做我想做的事情。不利的一面是,对象/记录/结构图似乎不是 Haskell 中的“第一类”对象(就像它们在 C/Java/等中一样),因为缺少必要的引用。这只是一个权衡 - Haskell 中的一些任务在语法上更简单,而 C 中的一些任务更简单(并且更不安全)。

【问题讨论】:

    标签: oop haskell


    【解决方案1】:

    小提示:如果您使用递归 letwhere(在 .hs 文件中,我认为它不适用于 ghci),您至少可以更轻松地设置初始图形,如下所示:

    ambSys = SystemState [s] [a] [c] where
        s = Station "London" [a]
        a = Ambulance "ABC" s [c]
        c = Crew "John Smith" a False
    

    这会让你达到我认为你想要达到的状态,但不要尝试使用派生的 Show 实例 :-) 像这样更新状态是另一罐 bean;我会考虑一下,看看我想出了什么。

    编辑:我已经考虑了更多,这就是我可能会做的:

    我会通过使用键来打破对象图中的循环。这样的事情会起作用(我在构建真实图表时使用了类似的方法):

    import qualified Data.Map as M
    
    version = "0.0.1"
    
    newtype StationKey = StationKey String deriving (Eq,Ord,Show)
    newtype AmbulanceKey = AmbulanceKey String deriving (Eq,Ord,Show)
    newtype CrewKey = CrewKey String deriving (Eq,Ord,Show)
    
    data SystemState = SystemState (M.Map StationKey Station) (M.Map AmbulanceKey Ambulance) (M.Map CrewKey Crew) deriving (Show)
    
    data Station = Station { stName :: StationKey
                           , stAmbulances :: [AmbulanceKey]
                           } deriving (Show)
    
    data Ambulance = Ambulance { amCallSign :: AmbulanceKey
                               , amStation :: StationKey
                               , amCrew :: [CrewKey]
                               } deriving (Show)
    
    data Crew = Crew { crName :: CrewKey
                     , crAmbulance :: AmbulanceKey
                     , crOnDuty :: Bool
                     } deriving (Show)
    
    ambSys = SystemState (M.fromList [(londonKey, london)]) (M.fromList [(abcKey, abc)]) (M.fromList [(johnSmithKey, johnSmith)]) where
        londonKey = StationKey "London"
        london = Station londonKey [abcKey]
        abcKey = AmbulanceKey "ABC"
        abc = Ambulance abcKey londonKey [johnSmithKey]
        johnSmithKey = CrewKey "John Smith"
        johnSmith = Crew johnSmithKey abcKey False
    

    然后您就可以开始定义自己的状态修改组合器了。如您所见,现在状态的构建更加冗长,但showing 再次运行良好!

    另外我可能会设置一个类型类来使StationStationKey 等类型之间的链接更加明确,如果这变得太麻烦的话。我没有在我的图形代码中这样做,因为我只有两个键类型,它们也是不同的,所以不需要新类型。

    【讨论】:

    • 谢谢,这很好地解决了一个偶然的问题。
    • 我将“Key”定义为“newtype Key a = Key String deriving (Eq,Ord,Show)”。它只是在三种不同的键类型之间节省了少量重复。
    【解决方案2】:

    在您开始谈论继承和子类型多态性之前,它不会变得面向对象。早在 OO 构思之前,程序就包含称为“救护车”和“车站”的数据结构; OO 没有垄断数据抽象和封装。 FP 设计也将是“领域驱动的”,命令式编程也是如此。

    您遇到的问题是如何管理状态,这是 Haskell 中的一个长期问题(实际上,在任何编程系统中,请参阅 SICP 的第 3.1.3 节(Abelson 和 Sussman 的计算机程序的结构和解释http://mitpress.mit.edu/sicp/ (不要被大的学术词汇或域名吓到,它的可读性很强——他们的例子是银行账户)。

    您的问题是您引用并坚持旧的,过时的状态。我建议您编写获取当前状态、修改它并返回新状态的函数。比如:

    addStation state station = 
         let (SystemState stations ambs crews) = state
         in SystemState (station:stations) ambs crews)
    

    如果你使用 ghci 解释器,了解 it 变量会很方便,它包含上次计算的结果。

    你最终会在 State Monad 结束,但听起来那是以后的事了......

    【讨论】:

      【解决方案3】:

      这里的其他人提供的一个选项是能够使用单独的密钥类型,并在可能的船员、车站或救护车的地图中查找您持有的可能循环引用的值。

      当然还有使用引用的更直接的编码,它的行为更像你习惯的:

      data Station = Station { stName :: String 
                             , stAmbulances :: [IORef Ambulance] 
                             } deriving (Show) 
      
      data Ambulance = Ambulance { amCallSign :: String 
                                 , amStation :: IORef Station 
                                 , amCrew :: [IORef Crew] 
                                 } deriving (Show) 
      
      data Crew = Crew { crName :: String 
                       , crAmbulance :: IORef Ambulance 
                       , crOnDuty :: Bool 
                       } deriving (Show) 
      

      这会导致严重副作用的编程风格。本质上,您只是开始使用 IO monad 在 Haskell 中编写 C/C++。

      有两种类似 Haskell 的方法可以解决这个问题。

      一种是打结,保持循环引用,但更新就成问题了。

      另一个是杀死循环引用:

      data Station = Station { stName :: String 
                             , stAmbulances :: [Ambulance] 
                             } deriving (Show) 
      
      data Ambulance = Ambulance { amCallSign :: String 
                                 , amCrew :: [Crew] 
                                 } deriving (Show) 
      
      data Crew = Crew { crName :: String 
                       , crOnDuty :: Bool 
                       } deriving (Show) 
      

      您可以从车站访问船员:

      stCrew :: Station -> [Crew]
      stCrew = stAmbulances >>= amCrew
      

      根据您需要的访问类型,这可能需要相当慢的路径才能访问船员。

      但是,更好的编码可能是从您的思维中几乎完全消除对象,并将您用来查找键的映射作为结构本身的一部分。我为这段代码的粗糙性质道歉,我是临时写的。

      import Control.Monad ((>=>))
      import Data.Map (Map)
      import qualified Data.Map as Map
      
      type Name = String
      newtype CrewTraits = CrewTraits { onDuty :: Bool }
      type Crew = (Name, CrewTraits) 
      
      type CallSign = String
      type AmbulanceTraits = Map Name AssignmentTraits
      type Amulance = (CallSign, AmbulanceTraits)
      
      type StationName = String
      type StationTraits = Map CallSign AmbulanceTraits
      type Station = (StationName,StationTraits)
      
      type Fleet = Map StationName StationTraits
      
      crew :: Name -> Bool -> Crew
      crew name isOnDuty = (name, CrewTraits isOnDuty)
      
      ambulance :: CallSign -> [Crew] -> Ambulance
      ambulance sign crew = (sign, Map.fromList crew)
      
      station :: StationName -> [Ambulance] -> Station
      station name ambulances = (name, Map.fromList ambulances)
      
      fleet :: [Station] -> Fleet
      fleet = Map.fromList
      

      现在您只需使用 Data.Map 的内置功能即可更改站点:

      updateStationTraits :: (StationName -> StationTraits -> Maybe StationTraits) ->
                             StationName -> Fleet -> Fleet
      updateStationTraits = Map.updateWithKey
      

      您可以通过将 Name 和 StationTraits 合并起来使其看起来更自然一些:

      updateStation :: (Station -> Maybe StationTraits) -> 
                       StationName -> Fleet -> Fleet
      updateStation = Map.updateWithKey . curry
      
      addAmbulanceToFleet :: Ambulance -> StationName -> Fleet -> Fleet
      addAmbulanceToFleet (k,v) = Map.adjust (Map.insert k v)
      

      通过所有这些,您现在可以将此结构中的路径概念与早期的键概念统一起来:

      type CrewPath = (StationName,CallSign,Name)
      type AmbulancePath = (StationName, CallSign)
      type StationPath = StationName
      
      lookupCrewTraits :: CrewKey -> Fleet -> Maybe CrewTraits
      lookupCrewTraits (s,c,n) = lookup s >=> lookup c >=> lookup n
      
      lookupCrew :: CrewKey -> Fleet -> Maybe Crew
      lookupCrew scn@(_,_,n) = (,) n `fmap` lookupCrewTraits scn
      

      【讨论】:

      • 这看起来很有趣。你写的很多东西超出了我目前对 Haskell 的理解,但这是一件好事。
      【解决方案4】:

      Haskell 是对您所描述的系统进行建模的绝佳选择。

      但是,与任何编程语言一样,您为系统建模的方式是 很大程度上取决于您要对其进行的操作。 像 Haskell 这样的函数式编程语言可以帮助您专注于这一点。 数据建模很好,但函数在哪里?

      您的救护车、车站和船员类型简单明了。 我不知道为什么你想把它们组合成一个大的 系统状态。这种结构在某些情况下确实有用。 不过,这对你来说有点复杂,这并不奇怪, 因为它有点像 ad hoc 混搭。无论是否需要 完全取决于您将要编写的函数类型。

      但这里的主要问题是如何有效地使用 GHCi。

      您究竟想在 GHCi 中做什么?我花了很多时间在 GHCI 提示。我可以把那段时间分成三类:探索函数 为了更好地理解它们,测试和调试功能以确保它们正常工作, 并使用我已经了解的函数执行一次性计算 并且已经知道正在工作。我不认为我已经非常使用 GHCi 只需输入数据结构并让 GHCi 将它们吐回给我。

      不过,对于这三种用途中的每一种,我都需要数据结构。 通常我需要的很简单,我可以 一口气输入整个内容。他们实际上不必非常简单 那 - 不要忘记您可以键入多个相互递归 在单个 let 语句中定义定义,用 ';' 分隔它们,以及 GHCi 支持带有“:{”和“:}”命令的多行语句。

      如果我需要的数据结构足够复杂,我想要 像你做的那样逐步建立它,有 有几种简单的方法可以做到这一点。

      获取一个可变变量,你反复修改它来构建 建立你的结构,类似于你在 命令式语言的命令行提示符, 查看 Data.IORef 模块。如果你是 Haskell 的新手,我会 建议在你的编程中避免像瘟疫一样的 Data.IORef - 它总是会诱惑你,而且几乎总是错的 去做。但在 GHCi 提示符下,没问题。

      说实话,我几乎从不这样做。懒惰,我只是使用 向上箭头和其他命令行编辑键来获取整个内容 渐进式地集成到一个 GHCi 命令中。

      当然,如果您输入的数据结构实际上是 有意义而不是一次性的示例,您需要输入 将其放入您最喜欢的 Haskell 编辑器中的文件中,而不是 在提示下。然后你将使用你的编辑器的 GHCi 集成, 或 GHCi 的 ":r" 命令,以保持您的最新版本 GHCi 中可用的结构。

      【讨论】:

      • 使用 ghci 没有什么特别的原因,只是我在实验时使用的。我正在考虑的一个功能示例是将机组人员从一辆救护车转移到另一辆救护车的功能。这将影响救护车中的船员名单和船员记录中的救护车字段。 SystemState 的目的是提供一些可以作为此类函数的参数的东西。
      【解决方案5】:

      有几种方法可以解决这个问题。一种简单的方法是将数据视为 SQL 数据库。也就是说,您的 Stations、Ambulances 和 Crew 都是带有相关卫星数据的表格。另一种选择是将其定义为带有图形库的图形数据库。

      【讨论】:

      • 这可能是正确的答案。图数据库是我已经考虑过的答案,但我希望这不是唯一的答案。如果我更喜欢的答案在几天内没有出现,我会接受这个。
      【解决方案6】:

      我也尝试过做这种事情,我得出的结论是 Haskell(对我来说)可能不是适合这项工作的工具。

      你的问题 2 得到了解决:

      大量引用“相同”数据 有单独的副本。

      Haskell,作为一门语言,专门设计以使其难以“共享实例”或制作“单独的副本”。因为所有变量都保存不可变的值,所以没有对对象的引用来比较身份。

      也就是说,有一些技巧。

      一种技巧是为您的结构使用mutable objects。然而,这会强制你的所有代码进入一个 monad。

      您还可以查看这篇论文Type-Safe Observable Sharing,它展示了如何在创建图表时使用一些支持低级引用的新语言特性。他们的例子是数字电路,但我认为它可以概括。

      【讨论】:

        【解决方案7】:

        如果你真的非常需要数据是递归的,请使用像 fgl 这样的适当的图形库。

        【讨论】:

          猜你喜欢
          • 2023-03-23
          • 2010-10-27
          • 2020-03-13
          • 1970-01-01
          • 2011-12-03
          • 1970-01-01
          • 1970-01-01
          • 2017-03-09
          • 2011-12-05
          相关资源
          最近更新 更多