如何使用高阶函数过滤和匹配记录中的选项字段

How to use high order functions to filter and match on options fields in a record

所以需要一些上下文才能理解我的问题。在角色扮演游戏中,我将设备视为具有可选字段的记录,这意味着,虽然它们是 None,但还没有任何属性。装备由代表武器装备或角色保护(头盔等)的游戏物品构成,将在下面的代码片段中看到。删除了功能并减少了域模型以使其更易于阅读。

type ConsummableItem =
    | HealthPotion
    | ManaPotion 

type Weaponry = 
    | Sword 
    | Spear 
    | BattleAxe 

type CharaterProtection = 
    | Helmet 
    | Gloves 
    | Boots 

type GameItem = 
    | Consumable of ConsummableItem * int
    | Weapon of Weaponry * int
    | Protection of CharacterProtection * int

 type Inventory = {
     Bag : GameItem array 
 }
 with // code omitted in the add function
     member x.addSingleItem (item: GameItem) = 
         let oItemIndex = x.Bag |> Array.tryFindIndex(fun x -> x = gi)
         match oItemIndex with
         | Some index ->
             let item = x.Bag.[index]
             let bag =  x.Bag
                    bag.[index] <- item
                    { x with Bag = bag }
         | None ->
             let newBag = x.Bag |> Array.append [|gi|]
             { x with Bag = newBag }

type Equipment = { 
     Helmet : Protection option 
     Weapon : Weapon option
     Boot   : Protection option
     Hands  : Protection option 
     Loot1  : Consummable option
     Loot2  : Consummable option
}

我遇到的问题如下:玩家从商店购买物品后,他可能想直接将物品转移到角色装备中。所以,我正在设计一个将物品放入设备的功能。如果该字段很忙,那么我会将设备中的物品发送回库存,并用商店的物品更新角色的设备。

为了开始解决我的问题,我想我可以将这些字段作为 GameItem 选项的列表并找出哪些是 Some 并在该列表中迭代时查看是否可以找到属于与我要添加到设备中的类型相同。我有一些问题,我真的不知道在模式匹配时检查两个项目是否属于同一类型。此外,我不太确定我的设计是否是实现我想做的事情的最佳方式。

现在,我有这个显然没有编译的代码片段,我正在寻找一种方法使其不仅可以编译而且还可以阅读。

我见过这个 post 直接在整个记录上进行一些模式匹配,但我想知道是否有任何其他方法可以使用高阶函数来做到这一点?

更新 1

我忘记了我们可以对多个元素进行模式匹配,所以我的问题实际上变成了如何动态验证记录字段中的 Some 值并能够通过高阶函数来完成。

// 更新函数 sendBackToInventory

member x.sendBackToInventory (gi: GameItem) =
        let mutable itemFound = false
        let equipmentFields = [ Protection(x.Hat.Value, 0) ; Protection(x.Armor.Value,0); Protection(x.Legs.Value,0); Protection(x.Hands.Value,0); Protection(x.Ring.Value,0); Protection(x.Shield.Value,0) ]
        equipmentFields 
        |> List.iter(fun item ->   
            match item, gi with 
            | (:? Consummable as c1, :? Consummable as c2)  -> 
            | (:? Protection as p1, :? Protection as p2)  -> 
            | (:? Weaponry as w1, :? Weaponry as w2)  -> 
            // match oItem with 
            // | Some item -> 
            //     if item = gi then 
            //         itemFound <- true 
            //         x.InventoryManager <! AddSingleItem gi
            //     else 
            //         ()
            // | None -> ()
        )

更新 2

通过添加一个新的可区分联合来表示它可能是的游戏项目类别,在我看来,这使得它与域模型有点多余,我已经以某种方式克服了我的问题,但这不是一种行为我想保留...有很多不应该存在的样板代码,但我希望至少有功能。

    type ItemCategory = 
        | Weapon 
        | Shield 
        | Loot 
        | Head 
        | Body 
        | Pant 
        | Finger 
        | Hand

    type Equipment = {
        Hat :   GameItem option
        Armor : GameItem option
        Legs  : GameItem option
        Gloves : GameItem option
        Ring  : GameItem option
        Weapon : GameItem option
        Shield : GameItem option
        Loot1  : GameItem option
        Loot2  : GameItem option 
        Loot3  : GameItem option 
        InventoryManager : IActorRef
    }
    with 
        member x.canAddMoreLoot() = 
            not (x.Loot1.IsSome && x.Loot2.IsSome && x.Loot3.IsSome)

        member x.putItemInEquipment 
            (gi: GameItem) 
            (cat: ItemCategory) = 
            let mutable equipment = x 
            match cat with 
            | Head -> 
                 equipment <-  { x with Hat = Some gi } 
                 match x.Hat with 
                 | None ->  ()
                 | Some h ->  x.InventoryManager <! AddItem h

            | Weapon -> 
                 equipment <- { x with Weapon = Some gi } 
                 match x.Weapon with 
                 | None -> () 
                 | Some w -> x.InventoryManager <! AddItem w

            | Shield -> 
                 equipment <- { x with Weapon = Some gi } 
                 match x.Shield with 
                 | None -> () 
                 | Some sh -> x.InventoryManager <! AddItem sh

            | Loot -> 
                 if not (x.canAddMoreLoot()) then x.InventoryManager <! AddItem gi
                 else 
                    match x.Loot1 with 
                    | Some l -> 
                        match x.Loot2 with 
                        | Some l -> equipment <- { x with Loot3 = Some gi } 
                        | None -> equipment <- { x with Loot2 = Some gi }
                    | None -> equipment <- { x with Loot1 = Some gi } 

            | Finger -> 
                equipment <- { x with Ring = Some gi } 
                match x.Ring with
                | None -> () 
                | Some r -> x.InventoryManager <! AddItem r 

            | Body -> 
                equipment <- { x with Armor = Some gi } 
                match x.Armor with 
                | None -> () 
                | Some a -> x.InventoryManager <! AddItem a 
            | Pant ->
                equipment <- { x with Legs = Some gi } 
                match x.Legs with 
                | None -> () 
                | Some l -> x.InventoryManager <! AddItem l 
            | Hand -> 
                equipment <- { x with Gloves = Some gi } 
                match x.Gloves with 
                | None -> () 
                | Some g -> x.InventoryManager <! AddItem g 
            equipment

我从您的原始更新中获取了代码并对其进行了一些调整以更正您犯的几个错误。我稍后会讨论这些错误,但首先,让我们看一下更正后的代码:

type ConsumableItem =
    | HealthPotion
    | ManaPotion 

type Weaponry = 
    | Sword 
    | Spear 
    | BattleAxe 

type CharacterProtection = 
    | Helmet 
    | Gloves 
    | Boots 

type GameItem = 
    | Consumable of ConsumableItem
    | Weapon of Weaponry
    | Protection of CharacterProtection

type Equipment = { 
     Helmet : CharacterProtection option 
     Weapon : Weaponry option
     Boot   : CharacterProtection option
     Hands  : CharacterProtection option 
     Loot1  : ConsumableItem option
     Loot2  : ConsumableItem option
}

// type Inventory omitted for space

首先,请注意我更正了 ConsumableItem 的拼写。它会编译得很好,所以我不会在这上面花更多的时间。请注意拼写已更改,因此如果您将此答案中的任何内容复制并粘贴到您的代码中,您就会知道如何适当调整拼写。

其次,我从你的 GameItem DU 中删除了 * int。在我看来,它属于不同的类型,比如一个名为 ItemStack:

的 single-case DU
type ItemStack = ItemStack of item:GameItem * count:int

第三,我调整了你的Equipment记录的类型,编译不了。受歧视的工会的情况不是类型;他们是构造函数。 DU 本身是一种类型。 (有关详细信息,请参阅 http://fsharpforfunandprofit.com/posts/discriminated-unions/,尤其是标题为 "Constructing a value of a union type" 的部分)。所以你不能有一个类型为 Protection 的字段 Helmet,因为名称 Protection 没有引用类型。该字段必须是 GameItem 类型。但您实际要做的是确保 Helmet 字段永远只包含一个保护项目——因此将其设为 CharacterProtection 类型是可行的。这仍然没有完全达到使非法状态无法表示的程度,因为您可以将靴子放在 Helmet 插槽中。但是要完全使非法状态无法表示,您最终会得到这样的结果:

type Helmet = Helmet of armorValue:int // Plus any other properties you want
type Boots  = Boots  of armorValue:int
type Gloves = Gloves of armorValue:int

type CharacterProtection =
    | Helmet of Helmet
    | Gloves of Gloves
    | Boots  of Boots

type Equipment = {
    Head  : Helmet option
    Feet  : Boots option
    Hands : Gloves option
    // And so on for weapon, potions, etc.
}

我将其称为 "complex" 游戏模型,而您的原始代码(包含我的修复)我将称为 "simple" 游戏模型。两者都很有价值,只是取决于您最终使用代码的位置。

好吧,现在让我们忘掉刚刚给大家展示的"complex"游戏模型,回到"simple"游戏模型。您要解决的问题是,当玩家从商店购买头盔时,如果他还没有装备头盔,您想要 auto-equip 头盔。如果他确实装备了头盔,你希望购买的头盔放在包里,让他稍后再装备。 (或者,你可以让他选择 auto-equip 他刚买的头盔,把 previously-equipped 头盔放在包里。)

那么,您可能想要做的是这样的事情:

let addToInventory newItem inventory =
    let newBag = inventory.Bag |> Array.append [|newItem|]
    { inventory with Bag = newBag }

let playerWantsToAutoEquip newItem =
    // In real game, you'd pop up a yes/no question for the player to click on
    printfn "Do you want to auto-equip your purchase?"
    printfn "Actually, don't bother answering; I'll just assume you said yes."
    true

let equipPurchasedProtection newItem (inventory,equipment) =
    match newItem with
    | Helmet ->
        match equipment.Helmet with
        | None ->
            let newEquipment = { equipment with Helmet = Some newItem }
            (inventory,newEquipment)
        | Some oldHelm
            if (playerWantsToAutoEquip newItem) then
                let newEquipment = { equipment with Helmet = Some newItem }
                let newInventory = inventory |> addToInventory oldHelm
                (newInventory,newEquipment)
            else
                let newInventory = inventory |> addToInventory newItem
                (newInventory,equipment)
    | Gloves ->
        match equipment.Hands with
        | None ->
            let newEquipment = { equipment with Hands = Some newItem }
            (inventory,newEquipment)
        | Some oldGloves
            if (playerWantsToAutoEquip newItem) then
                let newEquipment = { equipment with Hands = Some newItem }
                let newInventory = inventory |> addToInventory oldGloves
                (newInventory,newEquipment)
            else
                let newInventory = inventory |> addToInventory newItem
                (newInventory,equipment)
    | Boots ->
        match equipment.Feet with
        | None ->
            let newEquipment = { equipment with Boot = Some newItem }
            (inventory,newEquipment)
        | Some oldBoots
            if (playerWantsToAutoEquip newItem) then
                let newEquipment = { equipment with Boot = Some newItem }
                let newInventory = inventory |> addToInventory oldBoots
                (newInventory,newEquipment)
            else
                let newInventory = inventory |> addToInventory newItem
                (newInventory,equipment)

let equipPurchasedWeapon newItem (inventory,equipment) =
    // No need to match against newItem here; weapons are simpler
    match equipment.Weapon with
    | None ->
        let newEquipment = { equipment with Weapon = Some newItem }
        (inventory,newEquipment)
    | Some oldWeapon
        if (playerWantsToAutoEquip newItem) then
            let newEquipment = { equipment with Weapon = Some newItem }
            let newInventory = inventory |> addToInventory oldWeapon
            (newInventory,newEquipment)
        else
            let newInventory = inventory |> addToInventory newItem
            (newInventory,equipment)

// I'll skip defining equipPurchasedConsumable since it's very similar

let equipPurchasedItem gameItem (inventory,equipment) =
    let funcToCall =
        match gameItem with
        | Consumable of potion -> equipPurchasedConsumable potion
        | Weapon of weapon     -> equipPurchasedWeapon weapon
        | Protection of armor  -> equipPurchasedProtection armor
    (inventory,equipment) |> funcToCall

现在,这些函数中仍然存在一些冗余。 equipPurchasedPotion 中的匹配案例看起来非常非常相似。那么让我们抽象出我们能做的:

let equipHelmet newHelm equipment =
    { equipment with Helmet = Some newHelm }
let getOldHelmet equipment = equipment.Helmet

let equipGloves newGloves equipment =
    { equipment with Hands = Some newGloves }
let getOldGloves equipment = equipment.Hands

let equipBoots newBoots equipment =
    { equipment with Boots = Some newBoots }
let getOldBoots equipment = equipment.Boots

let equipWeapon newWeapon equipment =
    { equipment with Weapon = Some newWeapon }
let getOldWeapon equipment = equipment.Weapon

let genericEquipFunction (getFunc,equipFunc) newItem equipment =
    let oldItem = equipment |> getFunc
    let newEquipment = equipment |> equipFunc newItem
    match oldItem with
    | None -> (None,newEquipment)
    | Some _ ->
        if playerWantsToAutoEquip newItem then
            (oldItem,newEquipment)
        else
            (newItem,equipment)

let equipPurchasedProtection newItem (inventory,equipment) =
    let equipFunction =
        match newItem with
        | Helmet -> genericEquipFunction (getOldHelmet,equipHelmet)
        | Gloves -> genericEquipFunction (getOldGloves,equipGloves)
        | Boots  -> genericEquipFunction (getOldBoots, equipBoots)
    let itemForInventory,newEquipment = equipFunction newItem equipment
    match itemForInventory with
    | None -> (inventory,newEquipment)
    | Some item ->
        let newInventory = inventory |> addToInventory item
        (newInventory,newEquipment)

let equipPurchasedWeapon newItem (inventory,equipment) =
    // Only one possible equipFunction for weapons
    let equipFunction = genericEquipFunction (getOldWeapon,equipWeapon)
    let itemForInventory,newEquipment = equipFunction newItem equipment
    match itemForInventory with
    | None -> (inventory,newEquipment)
    | Some item ->
        let newInventory = inventory |> addToInventory item
        (newInventory,newEquipment)

事实上,现在我们可以将 equipPurchasedProtectionequipPurchasedWeapon 函数组合成一个通用的 equipPurchasedItem 函数!类型必须更改:现在 newItem 参数的类型将变为 GameItem。但是您仍然可以同时匹配多个 "levels" 嵌套 DU,如下所示:

let equipPurchasedItem newItem (inventory,equipment) =
    let equipFunction =
        match newItem with
        | Protection Helmet -> genericEquipFunction (getOldHelmet,equipHelmet)
        | Protection Gloves -> genericEquipFunction (getOldGloves,equipGloves)
        | Protection Boots  -> genericEquipFunction (getOldBoots, equipBoots)
        | Weapon _ -> genericEquipFunction (getOldWeapon,equipWeapon)
        // getOldLoot1 and similar functions not shown. Just pretend they exist.
        | Consumable HealthPotion -> genericEquipFunction (getOldLoot1,equipLoot1)
        | Consumable ManaPotion   -> genericEquipFunction (getOldLoot2,equipLoot2)
    let itemForInventory,newEquipment = equipFunction newItem equipment
    match itemForInventory with
    | None -> (inventory,newEquipment)
    | Some item ->
        let newInventory = inventory |> addToInventory item
        (newInventory,newEquipment)

这种拥有通用 getset 函数并将它们配对成一个元组的技术通常被称为 "lens"。使用镜头通常可以让您编写更通用的代码,而不必担心如何更新数据结构的特定部分。您可以只编写执行业务逻辑的通用代码,然后告诉镜头 "Here's the new data that should go into the structure. I don't care how you do it, just put it in there somehow"。这允许更好地分离关注点:决定 what 做什么的代码与知道 how 做什么的代码是分开的。以下是使用镜头时该代码的外观:

type Lens<'r,'a> = getter:('r -> 'a) * setter:('a -> 'r -> 'r)

let equipHelmet newHelm equipment =
    { equipment with Helmet = Some newHelm }
let getOldHelmet equipment = equipment.Helmet
// Convention for lenses is to give them a name that ends with one underscore
let Helmet_ = (getOldHelmet,equipHelmet)
// Now Helmet_ has type Lens<CharacterProtection,Equipment>

let equipGloves newGloves equipment =
    { equipment with Hands = Some newGloves }
let getOldGloves equipment = equipment.Hands
let Gloves_ = (getOldGloves,equipGloves)
// Gloves_ also has type Lens<CharacterProtection,Equipment>

let equipBoots newBoots equipment =
    { equipment with Boots = Some newBoots }
let getOldBoots equipment = equipment.Boots
let Boots_ = (getOldBoots,equipBoots)
// Boots_ also has type Lens<CharacterProtection,Equipment>

let equipWeapon newWeapon equipment =
    { equipment with Weapon = Some newWeapon }
let getOldWeapon equipment = equipment.Weapon
let Weapon_ = (getOldWeapon,equipWeapon)
// Weapon_ has a different type: Lens<Weaponry,Equipment>

// And so on for getOldLoot1,equipLoot1, and so on

let equipWithLens itemLens newItem equipment =
    let oldItem = equipment |> itemLens.getter
    let newEquipment = equipment |> itemLens.setter newItem
    match oldItem with
    | None -> (None,newEquipment)
    | Some _ ->
        if playerWantsToAutoEquip newItem then
            (oldItem,newEquipment)
        else
            (newItem,equipment)

let equipPurchasedProtection newItem (inventory,equipment) =
    let lens =
        match newItem with
        | Protection Helmet -> Helmet_
        | Protection Gloves -> Gloves_
        | Protection Boots  -> Boots_
        | Weapon -> Weapon_
        | Consumable HealthPotion -> Loot1_
        | Consumable ManaPotion   -> Loot2_
    let itemForInventory,newEquipment = equipWithLens lens newItem equipment
    match itemForInventory with
    | None -> (inventory,newEquipment)
    | Some item ->
        let newInventory = inventory |> addToInventory item
        (newInventory,newEquipment)

我希望这足以让您了解如何将此概念应用到戒指、胸甲、腿甲等物品上。

警告:所有这些代码都是在 Whosebug 编辑中输入的 window;我没有在 F# Interactive 中测试其中的任何一个。所以可能会有错别字。不过,总体思路应该可行。

更新: 查看我的代码,我看到了一个我始终犯的类型错误。各种装备(头盔、手套等)的getter和setter需要两种不同的值!例如,对于头盔,getter 只是 returning equipment.Helmet,即 CharacterProtection option。但是 setter 正在将 Some newItem 分配给 Helmet 字段。这意味着它期望 newItem 成为 CharacterProtection — 但我编写通用装备函数的方式是从设备记录中获取项目,然后将其传递给 setter .所以我得到一个 CharacterProtection option,并将其传递给 setter,后者试图分配 Some newItem — 所以我的 setter 正在尝试分配 CharacterProtection option option!哎呀

要修复此错误,请获取所有 setter 函数并从中删除 Some。然后他们期望的类型将不是 CharacterProtection,而是 CharacterProtection option — 就像 getter 一样。 那是关键: getter 和 setter 应该期待 相同类型

更新 2:正如承诺的那样,我将稍微讨论一下设计。看看Helmet_ lens如果是record的成员会是什么样子,如果是function会是什么样子

type Equipment = { 
     Helmet : CharacterProtection option 
     Weapon : Weaponry option
     Boot   : CharacterProtection option
     Hands  : CharacterProtection option 
     Loot1  : ConsumableItem option
     Loot2  : ConsumableItem option }
     with
        member x.getHelmet () = x.Helmet
        member x.equipHelmet newHelm = { x with Helmet = newHelm }
        member x.Helmet_ = (x.getHelmet, x.equipHelmet)

let getHelmetFun e = e.Helmet
let equipHelmetFun newHelm e = { e with Helmet = newHelm }
let HelmetFun_ = (getHelmetFun, equipHelmetFun)

到目前为止,唯一可见的差异rence 是外部函数需要一个显式参数,而实例方法有它 "baked in"。这就是实例方法的意义所在,所以这不足为奇。但是让我们看看使用这两种不同的镜头实现作为其他一些函数的参数是什么感觉:

let getWithInstanceLens lens =
    let getter = fst lens
    getter ()

let getWithExternalLens lens record =
    let getter = fst lens
    getter record

同样,这里没有真正的惊喜。作为实例变量的镜头有它的实例"baked in",所以我们不需要给它传递任何参数。定义为外部函数的镜头没有任何"baked in"记录,所以需要传一个。请注意,它是一个完全通用的函数(这两个都是完全通用的),这就是我将参数命名为 record 而不是 equipment 的原因:它可以处理 any镜头和匹配记录(并且 getWithInstanceLens 可以处理 任何 实例为 "baked in" 到 getter 的镜头。

但是当我们尝试将这些用作参数时,比如 List.map,我们会发现一些有趣的事情。先看代码再说吧

let sampleEquipment = { Helmet = Some Helmet
                        Weapon = Some BattleAxe
                        Boot   = Some Boots
                        Hands  = Some Gloves
                        Loot1  = Some HealthPotion
                        Loot2  = None }

let noHelm = { sampleEquipment with Helmet = None }
let noBoots = { noHelm with Boot = None }
let noWeapon = { noBoots with Weapon = None }

let progressivelyWorseEquipment = [ sampleEquipment; noHelm; noBoots; noWeapon ]

let demoExternal equipmentList =
    equipmentList
    |> List.map (getWithExternalLens HelmetFun_)

let demoInstance equipmentList =
    equipmentList
    |> List.map (fun x -> getWithInstanceLens x.Helmet_)

demoExternal progressivelyWorseEquipment
demoInstance progressivelyWorseEquipment
// Both return [Some Helmet; None; None; None]

我们注意到的第一件事是,因为实例镜头有它的实例"baked in",所以将它作为参数传递给higher-order函数实际上有点丑陋,比如List.map。我们必须显式构造一个 lambda 来调用它,我们不能利用 F# 的漂亮 partial-application 语法。但是还有一个问题。在您实际将此代码复制到您的 F# IDE 中之前,它不会变得明显 — 但此版本的 demoInstance 无法编译!当我说这两个函数 return 一个值时,我撒谎了;事实上,F# 编译器抱怨 demoInstance 中的 x.Helmet_ 表达式,产生以下错误消息:

Lookup on object of indeterminate type based on information prior to this program point. A type annotation may be needed prior to this program point to constrain the type of the object. This may allow the lookup to be resolved.

有了demoInstance函数,F#的类型推断就帮不了我们了!此时它所知道的是 equipmentListsome 类型的列表(如果您将鼠标悬停在 IDE 中,工具提示将向您显示它是 'a list 类型),并且未知的 'a 类型必须有一个名为 Helmet_ 的实例。但是 F# 编译器目前还不够了解,无法生成正确的 CIL 字节码——归根结底,这是它的主要工作。 Helmet_ 实例可能有许多不同的 类,因此它必须向您询问更多信息。所以你必须在你的函数上提供一个明确的类型注释,现在看起来像这样:

let demoInstance (equipmentList : Equipment list) =
    equipmentList
    |> List.map (fun x -> getWithInstanceLens x.Helmet_)

作为对比,这里又是 demoExternal 函数:

let demoExternal equipmentList =
    equipmentList
    |> List.map (getWithExternalLens HelmetFun_)

如您所见,使用外部函数更加简洁。

另一方面,在记录定义中使用 static 方法可以让您两全其美,几乎:镜头与记录类型密切相关, 您使用它们的方式与使用外部函数几乎相同:

type Equipment = {
     Helmet : CharacterProtection option
     Weapon : Weaponry option
     Boot   : CharacterProtection option
     Hands  : CharacterProtection option
     Loot1  : ConsumableItem option
     Loot2  : ConsumableItem option }
     with
        static member getHelmet x = x.Helmet
        static member equipHelmet newHelm x = { x with Helmet = newHelm }
        static member Helmet_ = (Equipment.getHelmet, Equipment.equipHelmet)

let demoStatic equipmentList =
    equipmentList
    |> List.map (getWithExternalLens Equipment.Helmet_)

在这里,在易用性方面没有缺点;这看起来与 "external functions" 示例几乎相同。就个人而言,我更喜欢这种方法,或者 "define them in a module" 方法:

module EquipmentLenses =
    let getHelmet e = e.Helmet
    let equipHelmet hewNelm e = { e with Helmet = newHelm }
    let Helmet_ = (getHelmet,equipHelmet)

let demoModule equipmentList =
    equipmentList
    |> List.map (getWithExternalLens EquipmentLenses.Helmet_)

在静态方法和外部模块之间,这实际上只是个人偏好的问题——您更喜欢如何组织代码。任何一个都是很好的解决方案。