Як моделювати круговий довідник між незмінними об'єктами в C #?


24

У наступному прикладі коду ми маємо клас для незмінних об'єктів, який представляє кімнату. Північ, Південь, Схід та Захід представляють виходи в інші приміщення.

public sealed class Room
{
    public Room(string name, Room northExit, Room southExit, Room eastExit, Room westExit)
    {
        this.Name = name;
        this.North = northExit;
        this.South = southExit;
        this.East = eastExit;
        this.West = westExit;
    }

    public string Name { get; }

    public Room North { get; }

    public Room South { get; }

    public Room East { get; }

    public Room West { get; }
}

Отже, ми бачимо, цей клас розроблений з рефлексивним круговим посиланням. Але оскільки клас непорушний, я застряг із проблемою «курка чи яйце». Я впевнений, що досвідчені функціональні програмісти знають, як з цим боротися. Як з ним можна поводитися на C #?

Я намагаюся кодувати текстову пригодницьку гру, але використовую принципи функціонального програмування лише заради навчання. Я застряг у цій концепції і можу скористатися деякою допомогою !!! Спасибі.

ОНОВЛЕННЯ:

Ось робоча реалізація, заснована на відповіді Майка Накіса щодо лінивої ініціалізації:

using System;

public sealed class Room
{
    private readonly Func<Room> north;
    private readonly Func<Room> south;
    private readonly Func<Room> east;
    private readonly Func<Room> west;

    public Room(
        string name, 
        Func<Room> northExit = null, 
        Func<Room> southExit = null, 
        Func<Room> eastExit = null, 
        Func<Room> westExit = null)
    {
        this.Name = name;

        var dummyDelegate = new Func<Room>(() => { return null; });

        this.north = northExit ?? dummyDelegate;
        this.south = southExit ?? dummyDelegate;
        this.east = eastExit ?? dummyDelegate;
        this.west = westExit ?? dummyDelegate;
    }

    public string Name { get; }

    public override string ToString()
    {
        return this.Name;
    }

    public Room North
    {
        get { return this.north(); }
    }

    public Room South
    {
        get { return this.south(); }
    }

    public Room East
    {
        get { return this.east(); }
    }

    public Room West
    {
        get { return this.west(); }
    }        

    public static void Main(string[] args)
    {
        Room kitchen = null;
        Room library = null;

        kitchen = new Room(
            name: "Kitchen",
            northExit: () => library
         );

        library = new Room(
            name: "Library",
            southExit: () => kitchen
         );

        Console.WriteLine(
            $"The {kitchen} has a northen exit that " +
            $"leads to the {kitchen.North}.");

        Console.WriteLine(
            $"The {library} has a southern exit that " +
            $"leads to the {library.South}.");

        Console.ReadKey();
    }
}

Це здається гарним випадком для конфігурації та шаблону Builder.
Грег Бургхардт

Мені також цікаво, чи потрібно відокремити кімнату від плану рівня або сцени, щоб кожна кімната не знала про інші.
Грег Бургхардт

1
@RockAnthonyJohnson Я б не дуже називав це рефлексивним, але це не доречно. Чому це проблема? Це надзвичайно часто. Насправді, так будуються майже всі структури даних. Подумайте про пов’язаний список чи двійкове дерево. Всі вони є рекурсивними структурами даних, і таким є ваш Roomприклад.
садівник

2
@RockAnthonyJohnson Незмінні структури даних надзвичайно поширені, принаймні, у функціональному програмуванні. Це , як ви визначаєте пов'язаний список: type List a = Nil | Cons of a * List a. І бінарне дерево: type Tree a = Leaf a | Cons of Tree a * Tree a. Як бачимо, вони обидва є самореференційними (рекурсивними). Ось як ви б визначити номер: type Room = Nil | Open of {name: string, south: Room, east: Room, north: Room, west: Room}.
садок

1
Якщо вам цікаво, знайдіть час, щоб вивчити Haskell або OCaml; це також розширить ваш розум;) Майте на увазі, що чіткого розмежування між структурами даних та "бізнес-об'єктами" немає. Подивіться, наскільки подібні визначення вашого Roomкласу та а List в Haskell, про який я писав вище.
садок

Відповіді:


10

Очевидно, ви не можете це зробити, використовуючи саме той код, який ви опублікували, оскільки в якийсь момент вам потрібно буде сконструювати об’єкт, який потрібно підключити до іншого, ще не побудованого об'єкта.

Для цього я можу придумати два способи (якими я користувався раніше):

Використання двох фаз

Усі об'єкти будуються спочатку, без будь-яких залежностей, і як тільки вони побудовані, вони з'єднуються. Це означає, що об'єкти повинні пройти дві фази у своєму житті: дуже коротка змінна фаза, за якою слідує незмінна фаза, яка триває протягом усього їхнього життя.

Ви можете зіткнутися з точно такою ж проблемою при моделюванні реляційних баз даних: одна таблиця має зовнішній ключ, який вказує на іншу таблицю, а інша може мати зовнішній ключ, який вказує на першу таблицю. Як це обробляється в реляційних базах даних, полягає в тому, що обмеження зовнішніх ключів можуть (і зазвичай є) задані додатковим ALTER TABLE ADD FOREIGN KEYоператором, який є окремим від CREATE TABLEоператора. Отже, спочатку ви створюєте всі свої таблиці, потім додаєте свої обмеження для зовнішніх ключів.

Різниця між реляційними базами даних і тим, що ви хочете зробити, полягає в тому, що реляційні бази даних продовжують дозволяти ALTER TABLE ADD/DROP FOREIGN KEYоператорами протягом усього життя таблиць, тоді як ви, ймовірно, встановите прапор «IamImmutable» і відмовитеся від подальших мутацій, як тільки всі залежності будуть зрозумілі.

Використання лінивої ініціалізації

Замість посилання на залежність ви передаєте делегата, який повертає посилання на залежність при необхідності. Після того, як залежність отримана, делегат більше не викликається.

Зазвичай делегат має форму лямбда-виразу, тому він буде виглядати лише дещо більш багатослівним, ніж фактично передаючи залежності конструкторам.

(Мінімальним) недоліком цієї методики є те, що вам доведеться витрачати місця для зберігання, необхідні для зберігання покажчиків для делегатів, які будуть використовуватися лише під час ініціалізації вашого об'єктного графіка.

Ви навіть можете створити загальний клас "ледачий довідник", який реалізує це так, що вам не доведеться повторно реалізовувати його для кожного свого члена.

Ось такий клас написаний на Java, ви можете легко його переписати на C #

(Мій Function<T>- як Func<T>делегат C #)

package saganaki.util;

import java.util.Objects;

/**
 * A {@link Function} decorator which invokes the given {@link Function} only once, when actually needed, and then caches its result and never calls it again.
 * It behaves as if it is immutable, which includes the fact that it is thread-safe, provided that the given {@link Function} is also thread-safe.
 *
 * @param <T> the type of object supplied.
 */
public final class LazyImmutable<T> implements Function<T>
{
    private static final boolean USE_DOUBLE_CHECK = false; //TODO try with "double check"
    private final Object lock = new Object();
    @SuppressWarnings( "FieldAccessedSynchronizedAndUnsynchronized" )
    private Function<T> supplier;
    @SuppressWarnings( "FieldAccessedSynchronizedAndUnsynchronized" )
    private T value;

    /**
     * Constructor.
     *
     * @param supplier the {@link Function} which will supply the supplied object the first time it is needed.
     */
    public LazyImmutable( Function<T> supplier )
    {
        assert supplier != null;
        assert !(supplier instanceof LazyImmutable);
        this.supplier = supplier;
        value = null;
    }

    @Override
    public T invoke()
    {
        if( USE_DOUBLE_CHECK )
        {
            if( supplier != null )
                doCheck();
            return value;
        }

        doCheck();
        return value;
    }

    private void doCheck()
    {
        synchronized( lock )
        {
            if( supplier != null )
            {
                value = supplier.invoke();
                supplier = null;
            }
        }
    }

    @Override
    public String toString()
    {
        if( supplier != null )
            return "(lazy)";
        return Objects.toString( value );
    }
}

Цей клас повинен бути безпечним для потоків, а "подвійний перевірка" пов'язаний з оптимізацією у випадку одночасності. Якщо ви не плануєте бути багатопотоковим, ви можете зняти все, що було. Якщо ви вирішили використовувати цей клас у багатопотокових налаштуваннях, обов’язково прочитайте про «ідіому подвійної перевірки». (Це довге обговорення поза межами цього питання.)


1
Майку, ти геніальний. Я оновив оригінальний пост, щоб включити реалізацію на основі того, що ви опублікували про ліниву ініціалізацію.
Рок Ентоні Джонсон

1
Бібліотека .Net надає ледачу посилання, влучно названу Lazy <T>. Як чудово! Про це я дізнався з відповіді на перегляд коду, який я опублікував на сайті codereview.stackexchange.com/questions/145039/…
Rock Anthony Johnson

16

Лінивий шаблон ініціалізації у відповіді Майка Накіса спрацьовує добре для одноразової ініціалізації між двома об’єктами, але стає непростим для декількох взаємопов'язаних об'єктів з частими оновленнями.

Набагато простіше та зручніше підтримувати зв’язки між кімнатами, що знаходяться поза предметами кімнати, у чомусь подібному до ImmutableDictionary<Tuple<int, int>, Room>. Таким чином, замість того, щоб створювати кругові посилання, ви просто додаєте єдиний, легко оновлюваний, односторонній посилання на цей словник.


Майте на увазі, що говорили про незмінні об’єкти, тому оновлень немає.
Рок Ентоні Джонсон

4
Коли люди говорять про оновлення незмінних об'єктів, вони мають на увазі створення нового об'єкта з оновленими атрибутами і посилання на цей новий об'єкт в новому обсязі замість старого об'єкта. Але це стає трохи стомлюючим сказати кожен раз, хоча.
Карл Білефельдт

Карл, пробач мене, будь ласка. Я все ще ноб на функціональних принципах, ха-ха.
Рок Ентоні Джонсон

2
Це правильна відповідь. Циркулярні залежності взагалі слід порушувати та делегувати третій стороні. Це набагато простіше, ніж програмування складної системи побудови та заморожування змінних об'єктів, які стають незмінними.
Бенджамін Ходжсон

Хотілося б, щоб я міг дати цьому ще кілька значень +1 ... Незмінний чи ні, без "зовнішнього" сховища чи індексу (або будь-якого іншого ), підключення всіх цих приміщень належним чином було б досить зайвим. І це не забороняє Roomз з'являючись , щоб ці відносини; але, вони повинні бути людьми, які просто читаються з індексу.
svidgen

12

Спосіб зробити це у функціональному стилі - розпізнати те, що ви насправді будуєте: спрямований графік із міченими ребрами.

Room library = new Room("Library");
Room ballroom = new Room("Ballroom");
Thing chest = new Thing("Treasure chest");
Thing book = new Thing("Ancient Tome");
Dungeon dungeon = Dungeon.Empty
  .WithRoom(library)
  .WithRoom(ballroom)
  .WithThing(chest)
  .WithThing(book)
  .WithPassage("North", library, ballroom)
  .WithPassage("South", ballroom, library)
  .WithContainment(library, chest)
  .WithContainment(chest, book);

Підземелля - це структура даних, яка відстежує купу приміщень і речей, а також які стосунки між ними. Кожен дзвінок "з" повертає нове, інше незмінне підземелля. Кімнати не знають, що від них північ і південь; книга не знає, що це в грудях. Темниця знає ці факти, і що річ не має ніяких проблем з циклічними посиланнями , тому що їх немає.


1
Я вивчав спрямовані графіки та вільні будівельники (та DSL). Я бачу, як це могло побудувати спрямований графік, але це перший, що я бачив дві пов'язані ідеї. Чи є книга, яку я не пропустив? Або це створює спрямований графік просто тому, що це вирішує проблему?
candied_orange

@CandiedOrange: Це ескіз того, як може виглядати API. Насправді побудова непорушної структури даних графіків, що лежать в основі, потребує певної роботи, але це не важко. Незмінний спрямований графік - це лише незмінний набір вузлів і незмінний набір (початок, кінець, мітка) трійки, тому його можна звести до складу вже вирішених проблем.
Ерік Ліпперт

Як я вже говорив, я вивчив і DSL, і спрямований графіки. Я намагаюся зрозуміти, чи читали ви чи писали щось, що поєднує їх, чи ви щойно зібрали їх, щоб відповісти на це конкретне питання. Якщо ви знаєте щось там, що поєднує їх, я б хотів, щоб ви могли мені вказати на це.
candied_orange

@CandiedOrange: Не особливо. Я писав серію блогів багато років тому про незмінний непрямий графік для створення зворотного вирішення судоку. І я нещодавно писав серію блог про проблеми об'єктно-орієнтованого дизайну для змінних структур даних у домені майстрів-підземелля.
Ерік Ліпперт

3

Курка і яйце правильно. Це не має сенсу в c #:

A a = new A(b);
B b = new B(a);

Але це робить:

A a = new A();
B b = new B(a);
a.setB(b);

Але це означає, що А не є непорушним!

Ви можете обдурити:

C c = new C();
A a = new A(c);
B b = new B(c);
c.addA(a);
c.addB(b);

Це приховує проблему. Впевнені, що А і В мають непорушний стан, але вони посилаються на щось, що не змінюється. Що може легко перемогти точку зробити їх непорушними. Я сподіваюся, що C принаймні такий безпечний для ниток, як вам потрібно.

Існує схема, що називається заморожування-відлига:

A a = new A();
B b = new B(a);
a.addB(b);
a.freeze();

Тепер "a" незмінна. "A" не є, але "a" є. Чому це нормально? Поки нічого більше не знає про "а", перш ніж воно застигне, кого це хвилює?

Існує метод thaw (), але він ніколи не змінює "a". Це робить змінну копію "a", яку можна оновити, а також заморозити.

Мінусом цього підходу є те, що клас не застосовує незмінності. Наступна процедура є. Ви не можете сказати, чи він незмінний від типу.

Я справді не знаю ідеального способу вирішити цю проблему в c #. Я знаю способи приховати проблеми. Іноді цього достатньо.

Коли це не так, я використовую інший підхід, щоб взагалі уникнути цієї проблеми. Наприклад: подивіться, як тут реалізується модель стану . Ви б могли подумати, що вони будуть робити це як круговий довідник, але цього не роблять. Вони викручують нові об'єкти щоразу, коли стан змінюється. Іноді простіше зловживати смітником, а потім розібратися, як дістати яйця з курей.


+1 за ознайомлення з новою схемою. Спершу я коли-небудь чув про замерзання-відлигу.
Рок Ентоні Джонсон

a.freeze()може повернути ImmutableAтип. які роблять його в основному конструктором.
Брайан Чен

@BryanChen, якщо ви це зробите, то bзалишається посилання на старий змінний a. Ідея полягає в тому, що a і bслід вказувати на незмінні версії один одного, перш ніж випускати їх до решти системи.
candied_orange

@RockAnthonyJohnson Це також те, що Ерік Ліпперт назвав Popsicle незмінністю .
Виявлено

1

Деякі розумні люди вже висловили свою думку з цього приводу, але я просто думаю, що відповідальність кімнати не в тому, щоб знати, що таке її сусіди.

Я думаю, що відповідальність за будівництво - знати, де знаходяться кімнати. Якщо кімната справді повинна знати, її сусіди передають їй INEigbourFinder.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.