【问题标题】:Updating nested immutable data structures更新嵌套的不可变数据结构
【发布时间】:2011-11-18 08:13:12
【问题描述】:

我想更新一个嵌套的、不可变的数据结构(我附上了一个假设游戏的小例子。)我想知道这是否可以更优雅地完成。

每当地牢中的某些东西发生变化时,我们都需要一个新的地牢。所以,我给了它一个普通的更新成员。对于一般情况,我可以想到的最佳使用方法是为每个嵌套指定处理函数,然后将组合函数传递给更新成员。

然后,对于非常常见的情况(例如将地图应用于特定级别的所有怪物),我会提供额外的成员 (Dungeon.MapMonstersOnLevel)。

整个事情都有效,我只是想知道,如果有人能想出更好的方法来做这件事。

谢谢!

// types
type Monster(awake : bool) = 
    member this.Awake = awake

type Room(locked : bool, monsters : Monster list) = 
    member this.Locked = locked
    member this.Monsters = monsters

type Level(illumination : int, rooms : Room list) = 
    member this.Illumination = illumination
    member this.Rooms = rooms

type Dungeon(levels : Level list) = 
    member this.Levels = levels

    member this.Update levelFunc = 
        new Dungeon(this.Levels |> levelFunc)

    member this.MapMonstersOnLevel (f : Monster -> Monster) nLevel =
        let monsterFunc = List.map f
        let roomFunc = List.map (fun (room : Room) -> new Room(room.Locked, room.Monsters |> monsterFunc))
        let levelFunc = List.mapi (fun i (level : Level) -> if i = nLevel then new Level(level.Illumination, level.Rooms |> roomFunc) else level)
        new Dungeon(this.Levels |> levelFunc)

    member this.Print() = 
        this.Levels 
        |> List.iteri (fun i e -> 
            printfn "Level %d: Illumination %d" i e.Illumination
            e.Rooms |> List.iteri (fun i e ->
                let state = if e.Locked then "locked" else "unlocked"
                printfn "  Room %d is %s" i state
                e.Monsters |> List.iteri (fun i e ->
                    let state = if e.Awake then "awake" else "asleep"
                    printfn "    Monster %d is %s" i state)))

// generate test dungeon
let m1 = new Monster(true)
let m2 = new Monster(false)
let m3 = new Monster(true)
let m4 = new Monster(false)
let m5 = new Monster(true)
let m6 = new Monster(false)
let m7 = new Monster(true)
let m8 = new Monster(false)
let r1 = new Room(true, [ m1; m2 ])
let r2 = new Room(false, [ m3; m4 ])
let r3 = new Room(true, [ m5; m6 ])
let r4 = new Room(false, [ m7; m8 ])
let l1 = new Level(100, [ r1; r2 ])
let l2 = new Level(50, [ r3; r4 ])
let dungeon = new Dungeon([ l1; l2 ])
dungeon.Print()

// toggle wake status of all monsters
let dungeon1 = dungeon.MapMonstersOnLevel (fun m -> new Monster(not m.Awake)) 0
dungeon1.Print()

// remove monsters that are asleep which are in locked rooms on levels where illumination < 100 and unlock those rooms
let monsterFunc2 = List.filter (fun (monster : Monster) -> monster.Awake)
let roomFunc2 = List.map(fun (room : Room) -> if room.Locked then new Room(false, room.Monsters |> monsterFunc2) else room)
let levelFunc2 = List.map(fun (level : Level) -> if level.Illumination < 100 then new Level(level.Illumination, level.Rooms |> roomFunc2) else level)
let dungeon2 = dungeon.Update levelFunc2
dungeon2.Print()

【问题讨论】:

  • 您是否考虑过使用记录或单大小写联合类型?
  • @RamonSnir:我尝试在下面的 pad 回答中解释自己。

标签: f# functional-programming


【解决方案1】:

这是使用当前在 FSharpx 中定义的镜头的相同代码。 正如其他答案所指出的,在这里使用记录很方便;它们免费为您提供结构上的平等。 我还将属性的相应镜头附加为静态成员;您还可以在模块中定义它们或将它们定义为松散的函数。我更喜欢这里的静态成员,实际上它就像一个模块。

open FSharpx

type Monster = {
    Awake: bool
} with 
    static member awake =
        { Get = fun (x: Monster) -> x.Awake
          Set = fun v (x: Monster) -> { x with Awake = v } }

type Room = {
    Locked: bool
    Monsters: Monster list
} with
    static member locked = 
        { Get = fun (x: Room) -> x.Locked
          Set = fun v (x: Room) -> { x with Locked = v } }
    static member monsters = 
        { Get = fun (x: Room) -> x.Monsters
          Set = fun v (x: Room) -> { x with Monsters = v } }

type Level = {
    Illumination: int
    Rooms: Room list
} with
    static member illumination = 
        { Get = fun (x: Level) -> x.Illumination
          Set = fun v (x: Level) -> { x with Illumination = v } }
    static member rooms = 
        { Get = fun (x: Level) -> x.Rooms
          Set = fun v (x: Level) -> { x with Rooms = v } }

type Dungeon = {
    Levels: Level list
} with
    static member levels =
        { Get = fun (x: Dungeon) -> x.Levels 
          Set = fun v (x: Dungeon) -> { x with Levels = v } }
    static member print (d: Dungeon) = 
        d.Levels 
        |> List.iteri (fun i e -> 
            printfn "Level %d: Illumination %d" i e.Illumination
            e.Rooms |> List.iteri (fun i e ->
                let state = if e.Locked then "locked" else "unlocked"
                printfn "  Room %d is %s" i state
                e.Monsters |> List.iteri (fun i e ->
                    let state = if e.Awake then "awake" else "asleep"
                    printfn "    Monster %d is %s" i state)))

我还将print 定义为静态成员;再次,它就像一个模块中的函数,它比实例方法更可组合(虽然我不会在这里组合它)。

现在生成示例数据。我认为{ Monster.Awake = true }new Monster(true) 更具破坏性。如果您想使用类,我会明确命名参数,例如Monster(awake: true)

// generate test dungeon
let m1 = { Monster.Awake = true }
let m2 = { Monster.Awake = false }
let m3 = { Monster.Awake = true }
let m4 = { Monster.Awake = false }
let m5 = { Monster.Awake = true }
let m6 = { Monster.Awake = false }
let m7 = { Monster.Awake = true }
let m8 = { Monster.Awake = false }

let r1 = { Room.Locked = true;  Monsters = [m1; m2] }
let r2 = { Room.Locked = false; Monsters = [m3; m4] }
let r3 = { Room.Locked = true;  Monsters = [m5; m6] }
let r4 = { Room.Locked = false; Monsters = [m7; m8] }

let l1 = { Level.Illumination = 100; Rooms = [r1; r2] }
let l2 = { Level.Illumination = 50;  Rooms = [r3; r4] }

let dungeon = { Dungeon.Levels = [l1; l2] }
Dungeon.print dungeon

现在有趣的部分来了:合成镜头以更新地牢中特定级别的所有房间的怪物:

open FSharpx.Lens.Operators

let mapMonstersOnLevel nLevel f =
    Dungeon.levels >>| Lens.forList nLevel >>| Level.rooms >>| Lens.listMap Room.monsters
    |> Lens.update (f |> List.map |> List.map)

// toggle wake status of all monsters
let dungeon1 = dungeon |> mapMonstersOnLevel 0 (Monster.awake.Update not)
Dungeon.print dungeon1

对于第二个地牢,我也使用了镜头,但没有镜头合成。它是一种由小型组合函数定义的 DSL(一些函数来自镜头)。也许有镜头可以更简洁地表达这一点,但我还没有弄清楚。

// remove monsters that are asleep 
// which are in locked rooms on levels where illumination < 100 
// and unlock those rooms

let unlock = Room.locked.Set false
let removeAsleepMonsters = Room.monsters.Update (List.filter Monster.awake.Get)

let removeAsleepMonsters_unlock_rooms = List.mapIf Room.locked.Get (unlock >> removeAsleepMonsters)

let isLowIllumination = Level.illumination.Get >> ((>)100)
let removeAsleepMonsters_unlock_level = Level.rooms.Update removeAsleepMonsters_unlock_rooms
let removeAsleepMonsters_unlock_levels = List.mapIf isLowIllumination removeAsleepMonsters_unlock_level

let dungeon2 = dungeon |> Dungeon.levels.Update removeAsleepMonsters_unlock_levels
Dungeon.print dungeon2

我在这里过度使用了镜头和无点,部分是故意的,只是为了展示它的外观。有些人不喜欢它,声称它不是惯用的或清晰的。也许是这样,但它是您可以选择使用或不使用的另一种工具,具体取决于您的上下文。

但更重要的是,由于 Update 是一个 Get 后跟一个函数,后跟一个 Set,因此在处理列表时,它的效率不如您的代码:Lens.forList 中的 Update 首先获取列表中的第 n 个元素列表,这是一个 O(n) 操作。

总结一下:

优点:

  • 非常简洁。
  • 启用无点样式。
  • 涉及镜头的代码通常会忽略源类型表示(它可以是类、记录、单格 DU、字典,没关系)。

缺点:

  • 在当前实施的某些情况下可能效率低下。
  • 由于缺少宏,需要一些样板文件。

感谢这个例子,因此我将修改 FSharpx 中当前的镜头设计,看看是否可以优化。

我将此代码提交到 FSharpx 存储库:https://github.com/fsharp/fsharpx/commit/136c763e3529abbf91ad52b8127ce11cbb3dff28

【讨论】:

  • 太棒了。非常感谢你在我这个愚蠢的小例子中投入了这么多的工作,不仅回答了这个问题,而且提供了很多额外的东西来思考。令人难以置信的赞赏。 (不过,我不想拿走一个已经被接受的答案,所以希望你能接受一个简单的投票)。
【解决方案2】:

我问了一个类似的问题,但关于haskell:Is there a Haskell idiom for updating a nested data structure?

优秀的答案提到了一个被称为功能性镜片的概念。


不幸的是,我不知道 F# 的包是什么,或者它是否存在。

更新:两个知识渊博的 F# 主义者(F#-ers?F#as?)在 cmets 中留下了关于此的有用链接,所以我将它们发布在这里:

  • @TomasPetricek 建议使用 FSharpXthis 描述它的网站

  • @RyanRiley 为包裹提供了 link

这两个家伙花时间阅读我的回答、评论和改进真是太棒了,因为他们都是FSharpX的开发者!


更多无关信息:Clojure 的 assoc-inupdate-in 函数激励我弄清楚如何做到这一点,这向我证明了它在函数式语言中是可能的!当然,Clojure 的动态类型使它比 Haskell/F# 更简单。我相信 Haskell 的解决方案涉及到模板。

【讨论】:

  • 在 FSharpX 包中有一个用于 F# 的镜头实现。请参阅 Mauricio Scheffer 的描述:bugsquash.blogspot.com/2011/11/lenses-in-f.html。它看起来是一个有趣的选项,但它仍然是一个有点实验性的设计——它还不是 F# 中使用的标准模式(还没有?)。
  • 感谢您指出您的问题 - 里面有很多有趣的链接!
  • @TomasPetricek:谢谢,我看看。
  • 观看FSharpx 了解即将发布的包含镜头的 NuGet 版本。现在拉source也可以获得镜头。
【解决方案3】:

posted 大约一年前提出了一个关于 Scala 的类似问题。答案提到了三个概念来解决这个问题:拉链、树重写和镜头。

【讨论】:

  • 感谢您指出您的问题。本帖积累了不少关于现在解决问题的知识!
【解决方案4】:

我不知道你为什么要在这里使用类。我认为,如果您使用记录来保存数据并保持最小化,您可以利用模式匹配的力量:

// Types
type Monster = {
    Awake: bool
    }
    with override x.ToString() =
            if x.Awake then "awake" else "asleep"
type Room = {
    Locked: bool;
    Monsters: Monster list
    }
    with override x.ToString() =
            let state = if x.Locked then "locked" else "unlocked"
            state + "\n" + (x.Monsters |> List.mapi (fun i m -> sprintf "    Monster %d is %s" i (string m)) |> String.concat "\n")

type Level = {
    Illumination : int;
    Rooms : Room list
    }
    with override x.ToString() =
              (string x.Illumination) + "\n" + (x.Rooms |> List.mapi (fun i r -> sprintf "  Room %d is %s" i (string r)) |> String.concat "\n")

type Dungeon = {
    Levels: Level list;
    }
    with override x.ToString() =
            x.Levels |> List.mapi (fun i l -> sprintf "Level %d: Illumination %s" i (string l)) |> String.concat "\n"

对我来说,将操作 Dungeon 的函数放在类中是不自然的。如果你把它们放在一个模块中并使用上面的声明,代码看起来会更好:

/// Utility functions
let updateMonster (m: Monster) a =
    {m with Awake = a}

let updateRoom (r: Room) l monstersFunc =
    {   Locked = l; 
        Monsters = r.Monsters |> monstersFunc}    

let updateLevel (l: Level) il roomsFunc = 
    {Illumination = il; Rooms = l.Rooms |> roomsFunc}

let updateDungeon (d: Dungeon) levelsFunc =
    {d with Levels = d.Levels |> levelsFunc}


/// Update functions
let mapMonstersOnLevel (d: Dungeon) nLevel =
    let monstersFunc = List.map (fun m -> updateMonster m (not m.Awake))
    let roomsFunc = List.map (fun r -> updateRoom r r.Locked monstersFunc)
    let levelsFunc = List.mapi (fun i l -> if i = nLevel then updateLevel l l.Illumination roomsFunc else l)
    updateDungeon d levelsFunc

let removeSleptMonsters (d: Dungeon) =
    let monstersFunc = List.filter (fun m -> m.Awake)
    let roomsFunc = List.map (fun r -> if r.Locked then updateRoom r false monstersFunc else r)
    let levelsFunc = List.map (fun l -> if l.Illumination < 100 then updateLevel l l.Illumination roomsFunc else l)
    updateDungeon d levelsFunc

然后您可以看到操作这些嵌套的数据结构要容易得多。但是,上述功能仍然存在冗余。如果您使用与记录非常自然的 lenses,您可以进行更多重构。看看the insightful article by Mauricio Scheffer,真的很接近这个公式。

【讨论】:

  • 感谢您解决这个问题!至于我为什么使用类:在更现实的应用程序中,类型往往更复杂,有辅助函数等,我发现使用类提供可发现的接口更容易,我没有发现缺少记录的复制语法是一个很大的问题。您提到了模式匹配,但我找不到您在哪里使用它?我会看看镜头,​​谢谢你的提示!
  • @bknotic : 记录也可以有成员函数;毕竟,它们只是底层的类。
  • @ildjarn:我知道,但是我看到的类的优点是它们为您提供了简单的私有帮助函数,而对于记录,您必须将它们放在私有模块中或使用签名文件把它们藏起来。老实说,我仍在努力寻找构建组件的最佳方法。
  • @bknotic 记录类型可以像类一样拥有成员。不同之处在于它们可能只有一个分配给所有字段的公共构造函数,而这些字段又必须是公共的。
  • @Ramon Snir:但是你建议如何防止类型的辅助函数泄漏?让他们成为私人成员?将它们放在私有模块中?使用 .fsi?我很好奇,虽然也许这件事值得提出一个全新的问题......
【解决方案5】:

我已经通过反射在 C# 中实现了一个镜头库。图书馆的核心是 这个函数

/// <summary>
/// Perform an immutable persistent set on a sub
/// property of the object. The object is not
/// mutated rather a copy of the object with
/// the required change is returned.
/// </summary>
/// <typeparam name="ConvertedTo">type of the target object</typeparam>
/// <typeparam name="V">type of the value to be set</typeparam>
/// <param name="This">the target object</param>
/// <param name="names">the list of property names composing the property path</param>
/// <param name="value">the value to assign to the property</param>
/// <returns>A new object with the required change implemented</returns>
private static T Set<T, V>
    (this T This, List<string> names, V value)
    where T : class, Immutable
{
    var name = names.First();
    var rest = names.Skip(1).ToList();
    if (names.Count == 1)
    {
        var copy = This.ShallowClone();
        copy.SetPrivatePropertyValue(names.First(), value);
        return copy as T;
    }
    else
    {
        var copy = This.ShallowClone();
        var subtree = copy
            .GetPrivatePropertyValue<Immutable>(name)
            .Set(rest, value);

        copy.SetPrivatePropertyValue(names.First(), subtree);
        return copy as T;
    }
}

上述函数是使用帮助库组合成各种实用程序, 其中之一是基于不可变持久记录的撤消堆栈。那里 是这个函数的重载

public static Maybe<T> MaybeSet<T,V>
    (this T This, Expression<Func<T, V>> prop, V value)
    where T : class, Immutable
{
    if (!EqualityComparer<V>.Default.Equals(This.Get(prop.Compile()),value))
    {
        var names = ReactiveUI.Reflection.ExpressionToPropertyNames(prop).ToList();
        return This.Set(names, value).ToMaybe();
    }
    else
    {
        return None<T>.Default;
    }
}

它允许使用 LINQ 表达式进行更自然的类型安全表示法。

foo = foo.Set(f=>f.A.B.C, 10);

图书馆有很多反思,但减少 在样板中值得性能打击。请参阅规范。我只需要 将记录标记为Immutable 以使其正常工作。我不必 提供 getter 和 settor。

class A : Immutable
{
    public int P { get; private set; }
    public B B { get; private set; }
    public A(int p, B b)
    {
        P = p;
        B = b;
    }
}

class B : Immutable
{
    public int P { get; private set; }
    public C C { get; private set; }
    public B(int p, C c)
    {
        P = p;
        C = c;
    }
}

class C : Immutable
{
    public int P { get; private set; }
    public C(int p)
    {
        P = p;
    }
}


namespace Utils.Spec
{
    public class ImmutableObjectPatternSpec : IEnableLogger
    {
        [Fact]
        public void SetterSpec()
        {
            var a = new A
                ( p:10
                , b: new B
                    ( p: 20
                    , c : new C(30)));

            var a_ = a.Set(p => p.B.C.P, 10);

            a.Should().NotBe(a_);
            a.B.C.P.Should().Be(30);
            a_.B.C.P.Should().Be(10);
        }

        [Fact]
        public void StringListGettersShouldWork()
        {
            var a = new A
                ( p:10
                , b: new B
                    ( p: 20
                    , c : new C(30)));

            var a_ = a.Set(p => p.B.C.P, 10);

            a_.Get(p=>p.B.C.P).Should().Be(10);

        }




    }
}

也许基于反射的镜头会减少 F# 中的样板。也许 可以通过缓存访问器来提高性能,或者 IL 生成。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2020-02-08
    • 2020-12-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2020-01-31
    相关资源
    最近更新 更多