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 #에서 어떻게 처리 할 수 ​​있습니까?

텍스트 기반 어드벤처 게임을 코딩하려고 노력하고 있지만 학습을 위해 기능적 프로그래밍 원칙을 사용하고 있습니다. 나는이 개념을 고수하고 도움을 사용할 수 있습니다! 감사.

최신 정보:

게으른 초기화에 관한 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();
    }
}

이것은 구성 및 빌더 패턴에 좋은 사례입니다.
Greg Burghardt

또한 각 방이 다른 방에 대해 알 수 없도록 방을 레벨 또는 무대의 레이아웃에서 분리해야하는지 궁금합니다.
Greg Burghardt

1
@RockAnthonyJohnson 나는 그 반사음을 실제로 호출하지는 않지만 관련이 없습니다. 그래도 왜 문제가 되나요? 이것은 매우 일반적입니다. 실제로 거의 모든 데이터 구조가 구축되는 방식입니다. 연결된 목록이나 이진 트리를 생각해보십시오. 그것들은 모두 재귀 데이터 구조이므로 귀하의 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을 배우십시오. 그것은 당신의 마음을 확장시킬 것입니다;) 또한 데이터 구조와 "비즈니스 객체"사이에 명확한 경계가 없다는 것을 명심하십시오. 내가 작성한 Haskell에 Room클래스와 a 의 정의가 얼마나 유사한 지 살펴보십시오 List.
gardenhead

답변:


10

분명히 어떤 시점에서 아직 생성되지 않은 다른 객체에 연결 해야하는 객체를 생성해야하기 때문에 게시 한 코드를 사용하여 정확히 수행 할 수는 없습니다.

이것을하기 위해 내가 생각할 수있는 두 가지 방법이있다 :

두 단계 사용

모든 개체는 종속성없이 먼저 구성되며 모든 개체가 구성되면 연결됩니다. 이것은 객체가 인생에서 두 단계를 거쳐야한다는 것을 의미합니다 : 매우 짧은 가변 단계 다음에 수명이 다할 때까지 지속되는 불변 단계.

관계형 데이터베이스를 모델링 할 때 똑같은 종류의 문제가 발생할 수 있습니다. 한 테이블에는 다른 테이블을 가리키는 외래 키가 있고 다른 테이블에는 첫 번째 테이블을 가리키는 외래 키가있을 수 있습니다. 이것이 관계형 데이터베이스에서 처리되는 방식은 외래 키 제약 조건이 ALTER TABLE ADD FOREIGN KEY명령문 과 별도의 추가 명령문으로 지정할 수 있고 일반적으로 지정 되는 것입니다 CREATE TABLE. 따라서 먼저 모든 테이블을 만든 다음 외래 키 제약 조건을 추가하십시오.

관계형 데이터베이스와 수행하려는 작업의 차이점은 관계형 데이터베이스 ALTER TABLE ADD/DROP FOREIGN KEY는 테이블 수명 동안 명령문을 계속 허용 하지만 모든 종속성이 실현되면 'IamImmutable'플래그를 설정하고 추가 변이를 거부한다는 것입니다.

지연 초기화 사용

종속성에 대한 참조 대신 필요한 경우 종속성에 대한 참조를 반환 하는 대리자 를 전달 합니다. 종속성이 페치되면 델리게이트는 다시 호출되지 않습니다.

대리자는 일반적으로 람다 식의 형태를 취하므로 실제로 생성자에 종속성을 전달하는 것보다 약간 더 장황하게 보입니다.

이 기술의 단점은 객체 그래프를 초기화하는 동안에 만 사용되는 델리게이트에 대한 포인터를 저장하는 데 필요한 저장 공간을 낭비해야한다는 것입니다.

이를 구현하는 일반 "게으른 참조"클래스를 만들 수도 있으므로 멤버마다 하나씩 다시 구현할 필요가 없습니다.

Java로 작성된 클래스는 다음과 같습니다. C #으로 쉽게 작성할 수 있습니다.

(내 C # Function<T>Func<T>대표 와 같습니다 )

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
마이크, 당신은 훌륭합니다. 지연 초기화에 대해 게시 한 내용을 기반으로 구현을 포함하도록 원래 게시물을 업데이트했습니다.
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
이것이 정답입니다. 순환 종속성은 일반적으로 깨져서 제 3 자에게 위임되어야합니다. 변경 불가능한 변경 가능한 객체로 구성된 복잡한 빌드 앤 프리즈 시스템을 프로그래밍하는 것보다 훨씬 간단합니다.
Benjamin Hodgson

"외부"리포지토리 나 인덱스 (또는 무엇이든 ) 없이이 +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);

던전은 방과 사물, 그리고 그 사이의 관계를 추적하는 데이터 구조입니다. 각각의 "with"호출은 새로운 다른 불변의 던전을 반환합니다 . 방은 북쪽과 남쪽이 무엇인지 모릅니다. 책은 그것이 가슴에 있다는 것을 모른다. 던전은 그 사실을 알고 있으며, 어느 것도 없기 때문에 것은 순환 참조 아무 문제가 없습니다.


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가 최소한 스레드 안전을 유지하기를 바랍니다.

동결 해동이라는 패턴이 있습니다.

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

이제 'a'는 불변입니다. 'A'는 아니지만 'a'입니다. 왜 괜찮아? 얼어 붙기 전에 'a'에 대해 아는 사람이 없다면, 누가 신경 쓰나요?

thaw () 메소드가 있지만 'a'는 변경되지 않습니다. 'a'의 변경 가능한 복사본을 만들어 업데이트 한 다음 고정 할 수도 있습니다.

이 방법의 단점은 클래스가 불변성을 강제하지 않는다는 것입니다. 다음 절차는 다음과 같습니다. 유형에서 불변인지 여부를 알 수 없습니다.

나는 C # 에서이 문제를 해결하는 이상적인 방법을 정말로 모른다. 문제를 숨기는 방법을 알고 있습니다. 때로는 충분합니다.

그렇지 않은 경우이 문제를 완전히 피하기 위해 다른 접근법을 사용합니다. 예를 들어 여기 에서 상태 패턴이 어떻게 구현되는지 살펴보십시오 . 당신은 그들이 순환 참조로 그렇게 할 것이라고 생각하지만 그렇지 않습니다. 상태가 바뀔 때마다 새 객체를 크랭크합니다. 때로는 가비지 수집기를 남용하는 것이 닭에서 계란을 얻는 방법을 알아내는 것이 더 쉽습니다.


새로운 패턴을 소개해 주신 +1 처음으로 동결 해동에 대해 들어 본 적이 있습니다.
Rock Anthony Johnson

a.freeze()ImmutableA유형을 반환 할 수 있습니다. 기본적으로 빌더 패턴이됩니다.
Bryan Chen

@BryanChen 당신이 그렇게 b하면 이전 변경 가능에 대한 참조를 보유하고 a있습니다. 아이디어는 것입니다 a 그리고 b당신이 시스템의 나머지 부분에 출시하기 전에 서로의 불변의 버전을 가리켜 야한다.
candied_orange

@RockAnthonyJohnson 이것도 Eric Lippert가 Popsicle 불변성 이라고 불렀습니다 .
발견

1

일부 똑똑한 사람들은 이미 이것에 대한 의견을 표명했지만 그 이웃이 무엇인지 아는 것은 방의 책임이 아니라고 생각합니다 .

방이 어디에 있는지 아는 것은 건물의 책임이라고 생각합니다. 방이 이웃을 알아야 할 경우 INeigbourFinder를 전달하십시오.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.