Bagaimana memodelkan referensi melingkar antara objek yang tidak dapat diubah dalam C #?

24

Dalam contoh kode berikut, kami memiliki kelas untuk objek yang tidak dapat diubah yang mewakili ruangan. Utara, Selatan, Timur, dan Barat mewakili pintu keluar ke kamar lain.

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; }
}

Jadi kita lihat, kelas ini dirancang dengan referensi lingkaran refleksif. Tetapi karena kelasnya tidak berubah, saya terjebak dengan masalah 'ayam atau telur'. Saya yakin bahwa programmer fungsional yang berpengalaman tahu bagaimana menghadapi ini. Bagaimana bisa ditangani dalam C #?

Saya berusaha keras untuk membuat kode game petualangan berbasis teks, tetapi menggunakan prinsip pemrograman fungsional hanya untuk tujuan pembelajaran. Saya terjebak pada konsep ini dan dapat menggunakan bantuan !!! Terima kasih.

MEMPERBARUI:

Berikut ini adalah implementasi yang bekerja berdasarkan jawaban Mike Nakis mengenai inisialisasi malas:

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();
    }
}
Rock Anthony Johnson
sumber
Ini terasa seperti kasus yang baik untuk konfigurasi dan Pola Builder.
Greg Burghardt
Saya juga bertanya-tanya apakah sebuah ruangan harus dipisahkan dari tata letak level atau panggung, sehingga setiap kamar tidak tahu tentang yang lain.
Greg Burghardt
1
@ RockAnthonyJohnson Saya tidak akan benar-benar menyebut itu refleksif, tapi itu tidak relevan. Namun, mengapa itu menjadi masalah? Ini sangat umum. Bahkan, begitulah hampir semua struktur data dibangun. Pikirkan tentang daftar tertaut atau pohon biner. Mereka semua struktur data rekursif, dan begitu juga Roomcontoh Anda .
Gardenhead
2
@RockAnthonyJohnson Struktur data yang tidak dapat diubah sangat umum, setidaknya dalam pemrograman fungsional. Ini adalah bagaimana Anda mendefinisikan sebuah linked list: type List a = Nil | Cons of a * List a. Dan pohon biner: type Tree a = Leaf a | Cons of Tree a * Tree a. Seperti yang Anda lihat, mereka berdua merujuk pada diri sendiri (rekursif). Berikut adalah bagaimana Anda akan mendefinisikan kamar Anda: type Room = Nil | Open of {name: string, south: Room, east: Room, north: Room, west: Room}.
Gardenhead
1
Jika Anda tertarik, luangkan waktu untuk mempelajari Haskell atau OCaml; itu akan memperluas pikiran Anda;) Juga perlu diingat bahwa tidak ada batasan yang jelas antara struktur data dan "objek bisnis". Lihatlah betapa miripnya definisi Roomkelas Anda dan a List dalam Haskell yang saya tulis di atas.
Gardenhead

Jawaban:

10

Jelas, Anda tidak dapat melakukannya dengan menggunakan kode yang Anda poskan, karena pada titik tertentu Anda perlu membuat objek yang perlu dihubungkan ke objek lain yang belum dibangun.

Ada dua cara yang dapat saya pikirkan (yang telah saya gunakan sebelumnya) untuk melakukan ini:

Menggunakan dua fase

Semua objek dibangun terlebih dahulu, tanpa ketergantungan apa pun, dan begitu semuanya dibangun, mereka terhubung. Ini berarti bahwa objek-objek harus melalui dua fase dalam hidup mereka: fase yang sangat bisa berubah, diikuti oleh fase yang tidak dapat berubah yang berlangsung sepanjang sisa hidup mereka.

Anda bisa menemukan masalah yang sama persis ketika memodelkan basis data relasional: satu tabel memiliki kunci asing yang menunjuk ke tabel lain, dan tabel lainnya mungkin memiliki kunci asing yang menunjuk ke tabel pertama. Cara ini ditangani dalam database relasional adalah bahwa batasan kunci asing dapat (dan biasanya) ditentukan dengan ALTER TABLE ADD FOREIGN KEYpernyataan tambahan yang terpisah dari CREATE TABLEpernyataan itu. Jadi, pertama Anda membuat semua tabel Anda, lalu Anda menambahkan batasan kunci asing Anda.

Perbedaan antara basis data relasional dan apa yang ingin Anda lakukan adalah bahwa basis data relasional terus memperbolehkan ALTER TABLE ADD/DROP FOREIGN KEYpernyataan sepanjang umur tabel, sementara Anda mungkin akan menetapkan flag 'IamImmutable` dan menolak mutasi lebih lanjut setelah semua dependensi telah direalisasikan.

Menggunakan inisialisasi malas

Alih-alih referensi ke dependensi Anda melewati delegasi yang akan mengembalikan referensi ke dependensi bila diperlukan. Setelah ketergantungan diambil, delegasi tidak pernah dipanggil lagi.

Delegasi biasanya akan mengambil bentuk ekspresi lambda, sehingga hanya akan terlihat sedikit lebih bertele-tele daripada benar-benar memberikan dependensi kepada konstruktor.

Kelemahan (kecil) dari teknik ini adalah Anda harus membuang ruang penyimpanan yang diperlukan untuk menyimpan pointer ke delegasi yang hanya akan digunakan selama inisialisasi grafik objek Anda.

Anda bahkan dapat membuat kelas "referensi malas" generik yang mengimplementasikan ini sehingga Anda tidak perlu mengimplementasikannya kembali untuk setiap anggota.

Ini adalah kelas yang ditulis dalam Java, Anda dapat dengan mudah menuliskannya dalam C #

(My Function<T>seperti Func<T>delegasi 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 );
    }
}

Kelas ini seharusnya aman untuk thread, dan hal-hal "periksa ganda" terkait dengan optimasi dalam kasus konkurensi. Jika Anda tidak berencana melakukan multi-utas, Anda dapat menghapus semua itu. Jika Anda memutuskan untuk menggunakan kelas ini dalam pengaturan multi-utas, pastikan untuk membaca tentang "idiom periksa ganda". (Ini adalah diskusi panjang di luar ruang lingkup pertanyaan ini.)

Mike Nakis
sumber
1
Mike, kamu brilian. Saya telah memperbarui posting asli untuk memasukkan implementasi berdasarkan apa yang Anda posting tentang inisialisasi malas.
Rock Anthony Johnson
1
Pustaka .Net menyediakan referensi malas, dengan tepat bernama Malas <T>. Betapa indahnya! Saya belajar ini dari jawaban untuk review kode yang saya posting di codereview.stackexchange.com/questions/145039/…
Rock Anthony Johnson
16

Pola inisialisasi malas dalam jawaban Mike Nakis bekerja dengan baik untuk inisialisasi satu kali antara dua objek, tetapi menjadi sulit untuk beberapa objek yang saling terkait dengan pembaruan yang sering.

Jauh lebih sederhana dan lebih mudah dikelola untuk menjaga hubungan antar kamar di luar objek itu sendiri, dalam sesuatu seperti sebuah ImmutableDictionary<Tuple<int, int>, Room>. Dengan begitu, alih-alih membuat referensi melingkar, Anda hanya menambahkan referensi satu arah yang mudah diperbarui ke kamus ini.

Karl Bielefeldt
sumber
Perlu diingat sedang berbicara tentang objek yang tidak dapat diubah, sehingga tidak ada pembaruan.
Rock Anthony Johnson
4
Ketika orang berbicara tentang memperbarui objek yang tidak dapat diubah, mereka berarti membuat objek baru dengan atribut yang diperbarui dan merujuk ke objek baru itu dalam ruang lingkup baru sebagai pengganti objek lama. Tapi itu agak membosankan untuk dikatakan setiap waktu.
Karl Bielefeldt
Karl, maafkan aku. Saya masih noob pada prinsip-prinsip fungsional, hahaa.
Rock Anthony Johnson
2
Ini adalah jawaban yang benar. Ketergantungan melingkar pada umumnya harus dipecah dan didelegasikan kepada pihak ketiga. Ini jauh lebih sederhana daripada pemrograman sistem build-and-freeze objek yang bisa berubah yang menjadi tidak berubah.
Benjamin Hodgson
Seandainya saya bisa memberikan ini +1 lagi ... Tidak dapat berubah atau tidak, tanpa repositori atau indeks "eksternal" (atau apa pun ), mendapatkan semua kamar yang terhubung dengan benar akan menjadi kompleks yang tidak perlu. Dan ini tidak melarang Roomdari muncul untuk memiliki hubungan mereka; tetapi, mereka harus getter yang hanya membaca dari indeks.
svidgen
12

Cara untuk melakukan ini dalam gaya fungsional adalah mengenali apa yang sebenarnya Anda buat: grafik berarah dengan tepi berlabel.

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);

Penjara bawah tanah adalah struktur data yang melacak sekelompok kamar dan hal-hal, dan apa hubungan di antara mereka. Setiap panggilan "dengan" mengembalikan ruang bawah tanah yang baru dan berbeda . Kamar-kamar tidak tahu apa yang utara dan selatan dari mereka; buku itu tidak tahu itu ada di peti. The dungeon tahu fakta-fakta, dan bahwa hal tidak memiliki masalah dengan referensi melingkar karena tidak ada satu pun.

Eric Lippert
sumber
1
Saya telah mempelajari grafik langsung dan pembangun yang fasih (dan DSL). Saya bisa melihat bagaimana ini bisa membangun grafik terarah tetapi ini adalah yang pertama saya melihat dua ide yang terkait. Apakah ada buku atau posting blog yang saya lewatkan? Atau apakah ini menghasilkan grafik terarah hanya karena itu memecahkan masalah pertanyaan?
candied_orange
@CandiedOrange: Ini adalah sketsa seperti apa API itu nantinya. Sebenarnya membangun struktur data grafik diarahkan abadi yang mendasarinya akan membutuhkan beberapa pekerjaan, tetapi itu tidak sulit. Grafik terarah yang tidak berubah hanyalah seperangkat simpul yang tidak dapat berubah dan satu set tripel (awal, akhir, label) yang tidak dapat diubah, sehingga dapat direduksi menjadi komposisi masalah yang sudah dipecahkan.
Eric Lippert
Seperti yang saya katakan, saya telah mempelajari kedua DSL dan grafik berarah. Saya mencoba mencari tahu apakah Anda sudah membaca atau menulis sesuatu yang menyatukan keduanya atau jika Anda baru saja menyatukan mereka untuk menjawab pertanyaan khusus ini. Jika Anda tahu sesuatu di luar sana yang menyatukan mereka, saya akan menyukainya jika Anda bisa mengarahkan saya ke sana.
candied_orange
@CandiedOrange: Tidak terlalu. Saya menulis seri blog bertahun-tahun yang lalu pada grafik tidak berarah yang tidak berubah untuk membuat pemecah sudoku mundur. Dan saya menulis seri blog baru-baru ini tentang masalah desain berorientasi objek untuk struktur data yang bisa berubah dalam domain penyihir-dan-ruang bawah tanah.
Eric Lippert
3

Ayam dan telur benar. Ini tidak masuk akal di c #:

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

Tapi ini memang:

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

Tetapi itu berarti A tidak abadi!

Anda bisa menipu:

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

Itu menyembunyikan masalahnya. Tentu A dan B memiliki keadaan tidak berubah tetapi mereka merujuk pada sesuatu yang tidak abadi. Yang bisa dengan mudah mengalahkan titik membuat mereka tidak berubah. Saya harap C setidaknya seaman thread yang Anda butuhkan.

Ada pola yang disebut freeze-thaw:

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

Sekarang 'a' tidak berubah. 'A' bukan tapi 'a'. Kenapa tidak apa-apa? Selama tidak ada lagi yang tahu tentang 'a' sebelum beku, siapa yang peduli?

Ada metode pencairan () tetapi tidak pernah mengubah 'a'. Itu membuat salinan bisa berubah dari 'a' yang dapat diperbarui kemudian dibekukan juga.

Kelemahan dari pendekatan ini adalah bahwa kelas tidak memaksakan imutabilitas. Prosedur berikut adalah. Anda tidak dapat mengetahui apakah itu tidak stabil dari tipe.

Saya tidak benar-benar tahu cara ideal untuk menyelesaikan masalah ini di c #. Saya tahu cara untuk menyembunyikan masalah. Kadang itu sudah cukup.

Ketika tidak, saya menggunakan pendekatan yang berbeda untuk menghindari masalah ini sama sekali. Sebagai contoh: lihat bagaimana pola negara diterapkan di sini . Anda akan berpikir mereka akan melakukan itu sebagai referensi melingkar tetapi mereka tidak melakukannya. Mereka membuat objek baru setiap kali keadaan berubah. Terkadang lebih mudah menyalahgunakan pemulung untuk mencari tahu cara mengeluarkan telur dari ayam.

candied_orange
sumber
+1 untuk memperkenalkan saya ke pola baru. Pertama saya pernah mendengar tentang beku-mencair.
Rock Anthony Johnson
a.freeze()dapat mengembalikan ImmutableAtipe. yang membuatnya pada dasarnya pola pembangun.
Bryan Chen
@BryanChen jika Anda melakukannya maka bdibiarkan memegang referensi ke yang bisa berubah lama a. Idenya adalah itu a dan bharus menunjuk ke versi yang tidak berubah dari satu sama lain sebelum Anda melepaskannya ke seluruh sistem.
candied_orange
@RockAnthonyJohnson. Ini juga yang oleh Eric Lippert disebut Popsicle immutability .
Melihat
1

Beberapa orang pintar sudah menyuarakan pendapat mereka tentang hal ini, tetapi saya hanya berpikir bahwa itu bukan tanggung jawab ruangan untuk mengetahui apa tetangga mereka.

Saya pikir itu tanggung jawab gedung untuk mengetahui di mana kamar berada. Jika ruangan benar-benar perlu tahu tetangganya melewati INeigbourFinder untuk itu.

tymtam
sumber