Làm thế nào để mô hình một tham chiếu vòng tròn giữa các đối tượng bất biến trong C #?


24

Trong ví dụ mã sau đây, chúng ta có một lớp dành cho các đối tượng bất biến đại diện cho một căn phòng. Bắc, Nam, Đông và Tây đại diện cho lối ra vào các phòng khác.

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

Vì vậy, chúng ta thấy, lớp này được thiết kế với một tham chiếu vòng tròn phản xạ. Nhưng vì lớp học không thay đổi, tôi bị mắc kẹt với vấn đề 'con gà hoặc quả trứng'. Tôi chắc chắn rằng các lập trình viên có kinh nghiệm biết cách giải quyết vấn đề này. Làm thế nào nó có thể được xử lý trong C #?

Tôi đang nỗ lực để viết mã một trò chơi phiêu lưu dựa trên văn bản, nhưng sử dụng các nguyên tắc lập trình chức năng chỉ vì mục đích học tập. Tôi bị mắc kẹt trong khái niệm này và có thể sử dụng một số trợ giúp !!! Cảm ơn.

CẬP NHẬT:

Đây là một triển khai hoạt động dựa trên câu trả lời của Mike Nakis về khởi tạo lười biếng:

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

Cảm giác này giống như một trường hợp tốt cho cấu hình và Mô hình Builder.
Greg Burghardt

Tôi cũng tự hỏi liệu một căn phòng nên được tách rời khỏi cách bố trí của một cấp độ hoặc sân khấu, để mỗi phòng không biết về những người khác.
Greg Burghardt

1
@RockAnthonyJohnson Tôi thực sự sẽ không gọi đó là phản xạ, nhưng điều đó không thích hợp. Tại sao đó là một vấn đề, mặc dù? Điều này là vô cùng phổ biến. Trên thực tế, đó là cách mà hầu hết các cấu trúc dữ liệu được xây dựng. Hãy suy nghĩ về một danh sách liên kết hoặc một cây nhị phân. Tất cả đều là cấu trúc dữ liệu đệ quy và Roomví dụ của bạn cũng vậy .
vườn

2
@RockAnthonyJohnson Cấu trúc dữ liệu bất biến là cực kỳ phổ biến, ít nhất là trong lập trình chức năng. Đây là cách bạn xác định một danh sách liên kết : type List a = Nil | Cons of a * List a. Và một cây nhị phân : type Tree a = Leaf a | Cons of Tree a * Tree a. Như bạn có thể thấy, cả hai đều tự tham chiếu (đệ quy). Đây là cách bạn xác định phòng của bạn : type Room = Nil | Open of {name: string, south: Room, east: Room, north: Room, west: Room}.
vườn

1
Nếu bạn quan tâm, hãy dành thời gian để tìm hiểu Haskell hoặc OCaml; nó sẽ mở rộng tâm trí của bạn;) Cũng nên nhớ rằng không có ranh giới rõ ràng giữa các cấu trúc dữ liệu và "đối tượng kinh doanh". Nhìn vào định nghĩa tương tự của Roomlớp bạn và a List trong Haskell tôi đã viết ở trên.
vườn

Câu trả lời:


10

Rõ ràng, bạn không thể làm điều đó bằng cách sử dụng chính xác mã bạn đã đăng, bởi vì tại một số điểm, bạn sẽ cần phải xây dựng một đối tượng cần được kết nối với một đối tượng khác chưa được xây dựng.

Có hai cách mà tôi có thể nghĩ ra (mà tôi đã sử dụng trước đây) để làm điều này:

Sử dụng hai giai đoạn

Tất cả các đối tượng được xây dựng đầu tiên, không có bất kỳ sự phụ thuộc nào và một khi chúng đã được xây dựng xong, chúng được kết nối. Điều này có nghĩa là các đối tượng cần trải qua hai giai đoạn trong cuộc đời: một giai đoạn đột biến rất ngắn, sau đó là một giai đoạn bất biến kéo dài suốt phần còn lại của cuộc đời.

Bạn có thể gặp phải cùng một loại vấn đề khi mô hình hóa cơ sở dữ liệu quan hệ: một bảng có khóa ngoại trỏ đến một bảng khác và bảng khác có thể có khóa ngoại trỏ đến bảng đầu tiên. Cách xử lý này trong cơ sở dữ liệu quan hệ là các ràng buộc khóa ngoài có thể (và thường được) chỉ định bằng một ALTER TABLE ADD FOREIGN KEYcâu lệnh bổ sung tách biệt với CREATE TABLEcâu lệnh. Vì vậy, trước tiên, bạn tạo tất cả các bảng của mình, sau đó bạn thêm các ràng buộc khóa ngoại của mình.

Sự khác biệt giữa cơ sở dữ liệu quan hệ và những gì bạn muốn làm là cơ sở dữ liệu quan hệ tiếp tục cho phép các ALTER TABLE ADD/DROP FOREIGN KEYcâu lệnh trong suốt vòng đời của các bảng, trong khi bạn có thể sẽ đặt cờ 'IamImmutable` và từ chối mọi đột biến tiếp theo sau khi tất cả các phụ thuộc đã được nhận ra.

Sử dụng khởi tạo lười biếng

Thay vì tham chiếu đến một phụ thuộc, bạn vượt qua một đại biểu sẽ trả lại tham chiếu cho phụ thuộc khi cần. Một khi sự phụ thuộc đã được tìm nạp, đại biểu không bao giờ được gọi lại.

Đại biểu thường sẽ có dạng biểu thức lambda, vì vậy nó sẽ chỉ trông hơi dài dòng hơn so với việc thực sự chuyển các phụ thuộc cho các hàm tạo.

Nhược điểm (nhỏ) của kỹ thuật này là bạn phải lãng phí không gian lưu trữ cần thiết để lưu trữ các con trỏ tới các đại biểu sẽ chỉ được sử dụng trong quá trình khởi tạo biểu đồ đối tượng của bạn.

Bạn thậm chí có thể tạo một lớp "tham chiếu lười biếng" chung chung để thực hiện điều này để bạn không phải triển khai lại nó cho từng thành viên của mình.

Đây là một lớp được viết bằng Java, bạn có thể dễ dàng phiên âm nó bằng C #

(My Function<T>giống như Func<T>đại biểu của 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 );
    }
}

Lớp này được coi là an toàn cho luồng và công cụ "kiểm tra kép" có liên quan đến tối ưu hóa trong trường hợp đồng thời. Nếu bạn không có kế hoạch đa luồng, bạn có thể loại bỏ tất cả những thứ đó. Nếu bạn quyết định sử dụng lớp này trong một thiết lập đa luồng, hãy nhớ đọc về "thành ngữ kiểm tra kép". (Đây là một cuộc thảo luận dài ngoài phạm vi của câu hỏi này.)


1
Mike, bạn thật xuất sắc. Tôi đã cập nhật bài viết gốc để bao gồm một triển khai dựa trên những gì bạn đã đăng về khởi tạo lười biếng.
Rock Anthony Johnson

1
Thư viện .Net cung cấp một tài liệu tham khảo lười biếng, có tên là Lazy <T>. Thật tuyệt vời! Tôi đã học được điều này từ một câu trả lời cho một đánh giá mã mà tôi đã đăng trên codereview.stackexchange.com/questions/145039/ chủ
Rock Anthony Johnson

16

Mẫu khởi tạo lười biếng trong câu trả lời của Mike Nakis chỉ hoạt động tốt khi khởi tạo một lần giữa hai đối tượng, nhưng trở nên khó sử dụng cho nhiều đối tượng liên quan đến nhau với các cập nhật thường xuyên.

Thật đơn giản và dễ quản lý hơn để giữ các liên kết giữa các phòng bên ngoài các vật thể trong phòng, trong một cái gì đó giống như một ImmutableDictionary<Tuple<int, int>, Room>. Bằng cách đó, thay vì tạo tham chiếu vòng tròn, bạn chỉ cần thêm một tham chiếu một chiều, dễ cập nhật, một chiều vào từ điển này.


Hãy nhớ rằng đã nói về các đối tượng bất biến, vì vậy không có cập nhật.
Rock Anthony Johnson

4
Khi mọi người nói về việc cập nhật các đối tượng bất biến, họ có nghĩa là tạo ra một đối tượng mới với các thuộc tính được cập nhật và đề cập đến đối tượng mới đó trong một phạm vi mới thay cho đối tượng cũ. Điều đó có một chút tẻ nhạt để nói mỗi lần, mặc dù.
Karl Bielefeldt

Karl, xin hãy tha thứ cho tôi. Tôi vẫn là một người không theo nguyên tắc chức năng, hahaa.
Rock Anthony Johnson

2
Đây là câu trả lời đúng. Nói chung phụ thuộc nên được phá vỡ và ủy thác cho một bên thứ ba. Nó đơn giản hơn nhiều so với việc lập trình một hệ thống xây dựng và đóng băng phức tạp của các đối tượng có thể thay đổi trở thành bất biến.
Benjamin Hodgson

Ước gì tôi có thể cung cấp thêm vài + 1 ... Không thể thay đổi hay không, nếu không có kho lưu trữ hoặc chỉ mục "bên ngoài" (hoặc bất cứ điều gì ), việc tất cả các phòng này được nối đúng cách sẽ khá phức tạp. Và điều này không ngăn cấm việc Roomtừ xuất hiện để có những mối quan hệ; nhưng, họ nên là những người chỉ đơn giản là đọc từ chỉ mục.
Svidgen

12

Cách để làm điều này theo kiểu chức năng là nhận ra những gì bạn đang thực sự xây dựng: một biểu đồ có hướng với các cạnh được dán nhãn.

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

Hầm ngục là một cấu trúc dữ liệu theo dõi một loạt các phòng và đồ vật, và mối quan hệ giữa chúng là gì. Mỗi cuộc gọi "với" trả về một dungeon bất biến mới, khác nhau . Các phòng không biết phía bắc và phía nam của chúng là gì; Cuốn sách không biết nó nằm trong rương. Các dungeon biết những sự kiện, và rằng điều không có vấn đề với sự tham khảo hình tròn bởi vì có ai sánh kịp.


1
Tôi đã nghiên cứu các đồ thị có hướng và các nhà xây dựng thông thạo (và DSL). Tôi có thể thấy làm thế nào điều này có thể xây dựng một biểu đồ có hướng nhưng đây là lần đầu tiên tôi thấy hai ý tưởng liên quan. Có một cuốn sách hoặc bài viết blog tôi đã bỏ lỡ? Hay điều này tạo ra một biểu đồ chỉ đạo đơn giản vì điều đó giải quyết vấn đề câu hỏi?
candied_orange

@CandiedOrange: Đây là bản phác thảo về API có thể trông như thế nào. Trên thực tế, việc xây dựng cấu trúc dữ liệu đồ thị theo hướng bất biến bên dưới nó sẽ mất một số công việc, nhưng nó không khó. Một đồ thị có hướng bất biến chỉ là một tập hợp các nút bất biến và một bộ ba (bắt đầu, kết thúc, nhãn) bất biến, do đó nó có thể được giảm xuống thành một phần của các vấn đề đã được giải quyết.
Eric Lippert

Như tôi đã nói, tôi đã nghiên cứu cả DSL và đồ thị có hướng. Tôi đang cố gắng tìm hiểu xem bạn đã đọc hoặc viết một cái gì đó đặt cả hai lại với nhau hay nếu bạn vừa đưa họ lại với nhau để trả lời câu hỏi cụ thể này. Nếu bạn biết một cái gì đó ngoài kia đặt chúng lại với nhau, tôi sẽ thích nó nếu bạn có thể chỉ cho tôi nó.
candied_orange

@CandiedOrange: Không đặc biệt. Tôi đã viết một loạt blog nhiều năm trước trên một biểu đồ vô hướng bất biến để tạo ra một bộ giải sudoku quay lại. Và tôi đã viết một loạt blog gần đây hơn về các vấn đề thiết kế hướng đối tượng cho các cấu trúc dữ liệu có thể thay đổi trong miền wizards-and-dungeons.
Eric Lippert

3

Gà và một quả trứng là đúng. Điều này không có ý nghĩa trong c #:

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

Nhưng điều này không:

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

Nhưng điều đó có nghĩa là A không phải là bất biến!

Bạn có thể gian lận:

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

Điều đó che giấu vấn đề. Chắc chắn A và B có trạng thái bất biến nhưng họ đề cập đến một thứ không phải là bất biến. Mà có thể dễ dàng đánh bại điểm làm cho chúng bất biến. Tôi hy vọng C ít nhất là chủ đề an toàn như bạn cần.

Có một mô hình gọi là đóng băng-tan băng:

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

Bây giờ 'a' là bất biến. 'A' không phải nhưng 'a' là. Tại sao vậy? Chừng nào không có gì khác biết về 'a' trước khi nó đóng băng, ai quan tâm?

Có một phương thức tan () nhưng nó không bao giờ thay đổi 'a'. Nó tạo ra một bản sao có thể thay đổi của 'a' có thể được cập nhật sau đó được đóng băng.

Nhược điểm của phương pháp này là lớp không thi hành tính bất biến. Thủ tục sau đây là. Bạn không thể biết nếu nó bất biến từ loại.

Tôi thực sự không biết một cách lý tưởng để giải quyết vấn đề này trong c #. Tôi biết cách để che giấu những vấn đề. Đôi khi thế là đủ.

Khi không phải tôi sử dụng một cách tiếp cận khác để tránh vấn đề này hoàn toàn. Ví dụ: xem cách mô hình trạng thái được thực hiện ở đây . Bạn sẽ nghĩ rằng họ sẽ làm điều đó như một tài liệu tham khảo vòng tròn nhưng họ thì không. Họ tạo ra các vật thể mới mỗi khi trạng thái thay đổi. Đôi khi, việc lạm dụng công cụ thu gom rác sau đó dễ dàng hơn để tìm ra cách lấy trứng ra khỏi gà.


+1 để giới thiệu cho tôi một mẫu mới. Đầu tiên tôi từng nghe về sự đóng băng.
Rock Anthony Johnson

a.freeze()có thể trả về ImmutableAloại. mà làm cho nó về cơ bản mô hình xây dựng.
Bryan Chen

@BryanChen nếu bạn làm điều đó thì bsẽ giữ tham chiếu đến biến thể cũ a. Ý tưởng là a bnên chỉ ra các phiên bản bất biến của nhau trước khi bạn phát hành chúng cho phần còn lại của hệ thống.
candied_orange

@RockAnthonyJohnson Đây cũng là điều mà Eric Lippert gọi là tính bất biến Popsicle .
Phát hiện

1

Một số người thông minh đã nói lên ý kiến ​​của họ về vấn đề này, nhưng tôi chỉ nghĩ rằng đó không phải là trách nhiệm của căn phòng để biết hàng xóm của mình là gì.

Tôi nghĩ rằng đó là trách nhiệm của tòa nhà để biết phòng ở đâu. Nếu căn phòng thực sự cần biết hàng xóm của nó vượt qua INeigbour Downloader cho nó.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.