วิธีการสร้างแบบจำลองการอ้างอิงแบบวงกลมระหว่างวัตถุที่ไม่เปลี่ยนรูปใน 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 #

ฉันพยายามเขียนโค้ดเกมผจญภัยที่ใช้ข้อความเป็นหลัก แต่ใช้หลักการเขียนโปรแกรมเชิงหน้าที่เพื่อประโยชน์ในการเรียนรู้ ฉันติดอยู่กับแนวคิดนี้และสามารถใช้ความช่วยเหลือบางอย่าง !!! ขอบคุณ

UPDATE:

นี่คือการใช้งานตามคำตอบของ Mike Nakis เกี่ยวกับการเริ่มต้นขี้เกียจ:

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

นี่เป็นกรณีที่ดีสำหรับการกำหนดค่าและรูปแบบการสร้าง
เกร็ก Burghardt

ฉันยังสงสัยว่าห้องควรแยกจากเลย์เอาต์ของเลเวลหรือเวทีหรือไม่เพื่อให้แต่ละห้องไม่ทราบเกี่ยวกับห้องอื่น
Greg Burghardt

1
@ RockAnthonyJohnson ฉันจะไม่เรียกว่า reflexive นั้นจริง ๆ แต่นั่นไม่เกี่ยวข้อง ทำไมถึงเป็นปัญหา นี่เป็นเรื่องธรรมดามาก ในความเป็นจริงมันเป็นวิธีการสร้างโครงสร้างข้อมูลเกือบทั้งหมด คิดเกี่ยวกับรายการที่เชื่อมโยงหรือต้นไม้ไบนารี มันคือโครงสร้างข้อมูลแบบเรียกซ้ำทั้งหมดและเป็นRoomตัวอย่างของคุณ
Gardenhead

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}นี่คือวิธีที่คุณต้องการกำหนดห้องของคุณ:
Gardenhead

1
หากคุณสนใจใช้เวลาในการเรียนรู้ Haskell หรือ OCaml มันจะขยายความคิดของคุณ;) นอกจากนี้โปรดทราบว่าไม่มีการแบ่งแยกระหว่างโครงสร้างข้อมูลและ "วัตถุทางธุรกิจ" ที่ชัดเจน ดูความหมายของRoomคลาสของคุณที่มีความคล้ายคลึงและ a List ใน Haskell ที่ฉันเขียนไว้ด้านบน
Gardenhead

คำตอบ:


10

เห็นได้ชัดว่าคุณไม่สามารถทำได้โดยใช้รหัสที่คุณโพสต์เพราะในบางจุดคุณจะต้องสร้างวัตถุที่ต้องเชื่อมต่อกับวัตถุอื่นที่ยังไม่ได้สร้าง

มีสองวิธีที่ฉันสามารถคิด (ที่ฉันเคยใช้มาก่อน) เพื่อทำสิ่งนี้:

ใช้สองขั้นตอน

วัตถุทั้งหมดจะถูกสร้างก่อนโดยไม่มีการพึ่งพาใด ๆ และเมื่อพวกเขาได้รับการสร้างทั้งหมดพวกเขาจะเชื่อมต่อ ซึ่งหมายความว่าวัตถุต้องผ่านสองช่วงในชีวิตของพวกเขานั่นคือช่วงเวลาที่เปลี่ยนแปลงได้สั้นมากตามด้วยระยะที่ไม่เปลี่ยนรูปซึ่งจะคงอยู่ตลอดช่วงเวลาที่เหลือของชีวิต

คุณสามารถเจอปัญหาแบบเดียวกันเมื่อสร้างแบบจำลองฐานข้อมูลเชิงสัมพันธ์: ตารางหนึ่งมี foreign key ซึ่งชี้ไปที่อีกตารางหนึ่งและอีกตารางหนึ่งอาจมี foreign key ที่ชี้ไปที่ตารางแรก วิธีนี้ถูกจัดการในฐานข้อมูลเชิงสัมพันธ์คือข้อ จำกัด foreign key สามารถ (และมักจะ) ระบุด้วยALTER TABLE ADD FOREIGN KEYคำสั่งพิเศษซึ่งแยกจากCREATE TABLEคำสั่ง ดังนั้นก่อนอื่นคุณต้องสร้างตารางทั้งหมดของคุณจากนั้นเพิ่มข้อ จำกัด คีย์ต่างประเทศของคุณ

ความแตกต่างระหว่างฐานข้อมูลเชิงสัมพันธ์และสิ่งที่คุณต้องการทำคือฐานข้อมูลเชิงสัมพันธ์ยังคงอนุญาตให้มีALTER TABLE ADD/DROP FOREIGN KEYคำสั่งตลอดอายุการใช้งานของตารางในขณะที่คุณอาจตั้งค่าสถานะ 'IamImmutable` และปฏิเสธการกลายพันธุ์เพิ่มเติมใด ๆ

ใช้การเริ่มต้นขี้เกียจ

แทนที่จะอ้างอิงถึงการพึ่งพาคุณส่งผู้แทนซึ่งจะส่งกลับการอ้างอิงไปยังการพึ่งพาเมื่อจำเป็น เมื่อได้รับการอ้างอิงแล้วตัวแทนจะไม่ถูกเรียกอีกครั้ง

ตัวแทนมักจะอยู่ในรูปแบบของการแสดงออกแลมบ์ดาดังนั้นมันจะดู verbose เพียงเล็กน้อยกว่าการส่งผ่านการอ้างอิงไปยังตัวสร้าง

ข้อเสีย (เล็ก) ของเทคนิคนี้คือคุณต้องเสียพื้นที่เก็บข้อมูลที่จำเป็นในการจัดเก็บพอยน์เตอร์ไปยังผู้รับมอบสิทธิ์ซึ่งจะใช้เฉพาะในช่วงเริ่มต้นของกราฟวัตถุของคุณ

คุณสามารถสร้างคลาส "การอ้างอิงขี้เกียจ" ทั่วไปซึ่งใช้สิ่งนี้เพื่อให้คุณไม่ต้องนำมันไปใช้ใหม่สำหรับสมาชิกทุกคนของคุณ

นี่คือคลาสที่เขียนด้วย 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 );
    }
}

คลาสนี้ควรจะปลอดภัยต่อเธรดและสิ่งที่ "ตรวจสอบซ้ำ" เกี่ยวข้องกับการปรับให้เหมาะสมในกรณีที่เกิดขึ้นพร้อมกัน หากคุณไม่ได้วางแผนที่จะมีหลายเธรดคุณสามารถตัดสิ่งเหล่านั้นออกไป หากคุณตัดสินใจที่จะใช้คลาสนี้ในการตั้งค่าแบบมัลติเธรดตรวจสอบให้แน่ใจว่าได้อ่านเกี่ยวกับ "double check idiom" (นี่เป็นการสนทนาที่ยาวเกินขอบเขตของคำถามนี้)


1
ไมค์คุณยอดเยี่ยม ฉันได้อัปเดตโพสต์ดั้งเดิมเพื่อรวมการใช้งานตามสิ่งที่คุณโพสต์เกี่ยวกับการเริ่มต้นขี้เกียจ
Rock Anthony Johnson

1
ไลบรารี. Net จัดให้มีการอ้างอิงแบบขี้เกียจชื่อ Lazy <T> ช่างวิเศษเหลือเกิน! ฉันได้เรียนรู้สิ่งนี้จากคำตอบในการตรวจสอบรหัสที่ฉันโพสต์ไว้ที่codereview.stackexchange.com/questions/145039//
Rock Anthony Johnson

16

รูปแบบการเริ่มต้นที่ขี้เกียจในคำตอบของ Mike Nakis นั้นใช้ได้ผลดีสำหรับการเริ่มต้นครั้งเดียวระหว่างวัตถุสองชิ้น แต่กลายเป็นเรื่องไม่สะดวกสำหรับวัตถุที่เกี่ยวข้องกับหลายรายการพร้อมการอัปเดตบ่อยครั้ง

มันง่ายมากและจัดการได้มากขึ้นเพื่อให้การเชื่อมโยงระหว่างห้องนอกImmutableDictionary<Tuple<int, int>, Room>ห้องวัตถุตัวเองในสิ่งที่ต้องการ ด้วยวิธีนี้แทนที่จะสร้างการอ้างอิงแบบวงกลมคุณเพียงแค่เพิ่มการอ้างอิงทางเดียวแบบเดียวที่อัปเดตได้อย่างง่ายดายไปยังพจนานุกรมนี้


โปรดจำไว้ว่ากำลังพูดถึงวัตถุที่ไม่เปลี่ยนรูปดังนั้นจึงไม่มีการอัปเดต
Rock Anthony Johnson

4
เมื่อผู้คนพูดถึงการอัพเดทออบเจ็กต์ที่ไม่เปลี่ยนรูปพวกเขาหมายถึงการสร้างออบเจ็กต์ใหม่ด้วยคุณลักษณะที่อัปเดตแล้วและอ้างอิงถึงออบเจ็กต์ใหม่นั้นในขอบเขตใหม่แทนของวัตถุเก่า ที่ได้รับค่อนข้างน่าเบื่อที่จะพูดทุกครั้งแม้ว่า
Karl Bielefeldt

คาร์ลโปรดยกโทษให้ฉัน ฉันยังคงเป็นคนที่ใช้งานได้ดีฮ่าฮ่า
Rock Anthony Johnson

2
นี่คือคำตอบที่ถูกต้อง โดยทั่วไปการอ้างอิงแบบวงกลมควรแตกหักและมอบให้แก่บุคคลที่สาม มันง่ายกว่าการเขียนโปรแกรมระบบสร้างและหยุดการทำงานที่ซับซ้อนของวัตถุที่เปลี่ยนแปลงได้ซึ่งกลายเป็นไม่เปลี่ยนรูป
Benjamin Hodgson

หวังว่าฉันจะให้ +1 เพิ่มเติมอีกสองสามอัน ... ไม่เปลี่ยนรูปหรือไม่โดยไม่มีพื้นที่เก็บข้อมูลหรือดัชนี "ภายนอก" (หรืออะไรก็ตาม ) การทำให้ห้องทั้งหมดเหล่านี้เชื่อมต่อกันอย่างถูกต้องจะค่อนข้างซับซ้อนโดยไม่จำเป็น และนี้ไม่ได้ห้ามRoomจากที่ปรากฏจะมีความสัมพันธ์เหล่านั้น; แต่ควรเป็น getters ที่อ่านจากดัชนี
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 ที่อาจมีหน้าตา การสร้างโครงสร้างข้อมูลกราฟที่กำกับไม่ได้จริง ๆ แล้วจะต้องใช้งานบ้าง แต่ก็ไม่ได้ยากอะไร กราฟกำกับที่ไม่เปลี่ยนรูปเป็นเพียงชุดของโหนดที่ไม่เปลี่ยนรูปและชุดของสามจุด (เริ่มต้น, สิ้นสุด, ฉลาก) ที่ไม่เปลี่ยนรูปดังนั้นจึงสามารถลดลงเป็นองค์ประกอบของปัญหาที่แก้ไขแล้ว
Eric Lippert

อย่างที่ฉันพูดฉันได้ศึกษาทั้ง DSL และกราฟกำกับแล้ว ฉันพยายามที่จะคิดออกว่าคุณได้อ่านหรือเขียนอะไรบางอย่างที่ทำให้ทั้งสองเข้าด้วยกันหรือถ้าคุณเพิ่งพาพวกเขามารวมกันเพื่อตอบคำถามนี้โดยเฉพาะ หากคุณรู้ว่ามีอะไรบางอย่างที่ทำให้พวกเขาอยู่ด้วยกันฉันจะรักถ้าคุณสามารถชี้ให้ฉัน
candied_orange

@CandiedOrange: ไม่เฉพาะ ฉันเขียนซีรีย์บล็อกเมื่อหลายปีก่อนบนกราฟที่ไม่ระบุทิศทางแบบไม่เปลี่ยนทิศทางเพื่อทำการแก้ซูโดกุย้อนรอยย้อนหลัง และฉันเขียนชุดบล็อกเมื่อเร็ว ๆ นี้เกี่ยวกับปัญหาการออกแบบเชิงวัตถุสำหรับโครงสร้างข้อมูลที่ไม่แน่นอนในโดเมนวิซาร์ดและดันเจี้ยน
Eric Lippert

3

ไก่กับไข่นั้นถูกต้อง มันไม่สมเหตุสมผลใน c #:

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

แต่สิ่งนี้จะ:

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

แต่นั่นหมายความว่า A ไม่เปลี่ยนแปลงไม่ได้!

คุณสามารถโกง:

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

นั่นซ่อนปัญหา แน่นอนว่า A และ B มีสถานะไม่เปลี่ยนรูป แต่พวกเขาอ้างถึงบางสิ่งที่ไม่เปลี่ยนแปลง ซึ่งสามารถเอาชนะจุดที่ทำให้พวกเขาไม่เปลี่ยนรูปได้อย่างง่ายดาย ฉันหวังว่า C เป็นอย่างน้อยเป็นเธรดที่ปลอดภัยตามที่คุณต้องการ

มีรูปแบบที่เรียกว่า freeze-thaw:

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

ตอนนี้ 'a' ไม่เปลี่ยนรูป 'A' ไม่ใช่ แต่ 'a' คือ ทำไมถึงตกลง ตราบใดที่ไม่มีใครรู้เรื่อง 'a' ก่อนที่มันจะแข็งตัวใครจะไปสน?

มีวิธีการละลาย () แต่มันไม่เคยเปลี่ยน 'a' มันทำสำเนา 'a' ที่ไม่แน่นอนซึ่งสามารถอัปเดตแล้วตรึงได้เช่นกัน

ข้อเสียของวิธีการนี้คือชั้นเรียนไม่ได้บังคับใช้ไม่ได้ ขั้นตอนต่อไปคือ คุณไม่สามารถบอกได้ว่ามันไร้เดียงสาจากประเภทหรือไม่

ฉันไม่รู้วิธีที่ดีที่สุดในการแก้ปัญหานี้ใน c # ฉันรู้วิธีซ่อนปัญหา บางครั้งก็เพียงพอ

เมื่อไม่ได้ฉันใช้วิธีการอื่นเพื่อหลีกเลี่ยงปัญหานี้โดยสิ้นเชิง ตัวอย่างเช่นดูที่วิธีการรูปแบบรัฐจะดำเนินการที่นี่ คุณคิดว่าพวกเขาจะทำเช่นนั้นเป็นข้อมูลอ้างอิงแบบวงกลม แต่พวกเขาไม่ได้ทำ พวกมันเหวี่ยงออกวัตถุใหม่ทุกครั้งที่มีการเปลี่ยนแปลงสถานะ บางครั้งมันง่ายกว่าที่จะดูถูกนักสะสมขยะเพื่อหาวิธีกำจัดไข่ออกจากไก่


+1 สำหรับแนะนำให้ฉันรู้จักกับรูปแบบใหม่ ครั้งแรกที่ฉันเคยได้ยินว่าเป็นน้ำแข็ง
Rock Anthony Johnson

a.freeze()สามารถส่งคืนImmutableAประเภท ซึ่งทำให้รูปแบบการสร้างพื้น
ไบรอันเฉิน

@BryanChen ถ้าคุณทำแล้วที่เหลือถืออ้างอิงถึงแน่นอนเก่าb aแนวคิดก็คือa และbควรชี้ไปที่เวอร์ชันที่ไม่เปลี่ยนแปลงของกันและกันก่อนที่คุณจะเผยแพร่ไปยังระบบอื่น ๆ
candied_orange

@RockAnthonyJohnson นี้ยังเป็นสิ่งที่เอริค Lippert เรียกว่าไอติมไม่เปลี่ยนรูป
เห็น

1

คนฉลาดบางคนแสดงความคิดเห็นเกี่ยวกับเรื่องนี้แล้ว แต่ฉันแค่คิดว่ามันไม่ใช่ความรับผิดชอบของห้องที่จะรู้ว่าเพื่อนบ้านคืออะไร

ฉันคิดว่ามันเป็นความรับผิดชอบของอาคารที่จะรู้ว่าห้องพักอยู่ที่ไหน ถ้าห้องต้องการรู้ว่าเพื่อนบ้านส่งผ่าน INeigbourFinder ไปหรือไม่

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.