Обновление вложенных неизменяемых структур данных - PullRequest
17 голосов
/ 18 ноября 2011

Я хочу обновить вложенную неизменную структуру данных (я привел небольшой пример гипотетической игры.) И мне интересно, можно ли сделать это немного более элегантно.

Каждый раз, когда что-то внутри подземелья меняется, нам нужно новое подземелье. Итак, я дал ему общее обновление участника. Лучший способ использовать это, что я мог бы придумать для общего случая, - это указать функции обработки для каждого вложения и передать комбинированную функцию члену обновления.

Затем, для действительно распространенных случаев (например, применение карты ко всем монстрам определенного уровня), я предоставляю дополнительных членов (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()

Ответы [ 5 ]

22 голосов
/ 19 ноября 2011

Вот тот же код с использованием линз, который в настоящее время определен в 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, это не так эффективно, как ваш код, когда дело доходит до обработки списков: Update в Lens.forList сначала получает nthэлемент в списке, который является операцией O (n).

Подведем итог:

Плюсы:

  • Очень кратко.
  • Включаетстиль pointfree.
  • Код с линзами обычно не учитывает представление типа источника (это может быть класс, запись, единичный DU, словарь, это не имеет значения).

Минусы:

  • Может быть неэффективным в некоторых случаях в текущей реализации.
  • Из-за отсутствия макросов требуется некоторый шаблон.

Спасибо за этот пример, в результате я пересмотрю текущий дизайн линз в FSharpx и посмотрю, можно ли его оптимизировать.

Я передал этот код в репозиторий FSharpx: https://github.com/fsharp/fsharpx/commit/136c763e3529abbf91ad52b8127ce11cbb3dff28

14 голосов
/ 18 ноября 2011

Я задал похожий вопрос, но о haskell: Существует ли идиома Haskell для обновления вложенной структуры данных?

В превосходных ответах упоминается концепция, известная как функциональные линзы .


К сожалению, я не знаю, что это за пакет или, если он вообще существует, для F #.

Обновление: два знающих F # -иста (F # -ers? F # as?) Оставили полезные ссылки об этом в комментариях, поэтому я опубликую их здесь:

  • @ TomasPetricek предложил FSharpX и этот веб-сайт, описывающий его

  • @ RyanRiley дал ссылку для пакета

Удивительно, что эти два парня нашли время, чтобы прочитать мой ответ, прокомментировать и улучшить его, так как они оба разработчики FSharpX!


Более посторонняя информация: я был мотивирован, чтобы выяснить, как это сделать, с помощью функций assoc-in и update-in Clojure, которые доказали мне, что возможно на функциональных языках! Конечно, динамическая типизация Clojure делает его проще, чем в Haskell / F #. Я полагаю, что решение Хаскелла предполагает использование шаблонов.

5 голосов
/ 18 ноября 2011

Я не знаю, почему вы хотите использовать классы здесь.Я думаю, что вы можете использовать возможности сопоставления с образцом, если вы используете записи для хранения данных и поддержания их минимальности:

// 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

Тогда вы можете увидеть, что манипулировать этими вложенными структурами данных намного проще.Однако вышеперечисленные функции все еще имеют избыточность.Вы можете изменить рефакторинг, если используете линзы , которые очень естественны для записей.Посмотрите проницательную статью Маурисио Шеффера , которая действительно близка к этой формулировке.

4 голосов
/ 19 ноября 2011

I отправил аналогичный вопрос о Scala около года назад.Ответы упоминают три концепции в качестве решения этой проблемы: молнии, переписывание дерева и линзы.

0 голосов
/ 02 мая 2013

Я реализовал библиотеку линз в 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, чтобы она заработала.Мне не нужно предоставлять геттеры и сеттеры.

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.

...