본문 바로가기

프로그래밍 기술 노트/Functional Study

[Expression Problem] Mutiple Dispatch (Feat Visitor / Multi Method)

 

 

Mutiple dispatch 가 무엇인가 부터 알아보도록 하자

dispatch 는 대충 함수 호출이라고 보면 되고, 어느 함수가 호출될까를 결정하는것이

  1. dynamic 하게 dispatch  (== 런타임)
  2. mutiple (모든 파라미터) 에 따라서 dispatch 

일반적으로 C++ / Java / C# 같은경우는 일반적으로

  1. Compile 타임에 Compile 당시 타입에 따라서 디스패치된다.
  2. virtual 함수의 경우 dynamic 하게 단 하나 (receiver 즉 this) 타입에 따라서 디스패치된다.

Complie 과 Runtime 시점에 디스패치된다는것은 OOP 배울때 자주 배우는 정적 바인딩/ 동적 바인딩 의 차이이다.

 

Complie 타임 디스패치 / Dynamic single 디스패치

(모든 예를 설명할수있는 C# 으로 예를 들어서 설명하면)

class Charater
{
    public void Hit()  // virtual 없음에 주의!!!
    {
        Console.WriteLine("Charater Hit");
    }
}

class Healer : Charater
{
    public void Hit()
    {
        Console.WriteLine("Healer Hit");
    }
}

Charater a = new Healer();
a.Hit(); // "Charater Hit"

virtual keyword 가 없으면 a 가 실제로  Healer 일 지 라도, 부모의 함수가 불린다. (정적바인딩)

 

class Charater
{
    virtual public void Hit()  // virtual 없음에 주의!!!
    {
        Console.WriteLine("Charater Hit");
    }
}

class Healer : Charater
{
    override public void Hit()
    {
        Console.WriteLine("Healer Hit");
    }
}

Charater a = new Healer();
a.Hit(); // "Healer Hit"

Healer Hit 가 나오길 원한다면, virutal 키워드를 붙여서 동적(dynamic) 바인딩으로 함수가 연결되도록 해야한다

 

그럼 Mutiple dispatch 란 무엇인가?

설명하기에 앞서서, function(receiver , arg1, arg2) 는 receiver.function(arg1, arg2) 와 사실상 동일하다는걸 먼저 확인하자

  • python 같은 언어는 실제로 완전하게 동일하다 (unbound 식 호출 /bound 식 호출).
  • 그렇지 않은 언어라고 할지라도 실제 내부적으로는
    receiver.function(arg1 , arg2) -> function ( receiver , arg1, arg2) 꼴의 함수가 존재하고, receiver.function(arg1,arg2) 호출시, receiver 의 this 포인터가 넘거가는 식으로 구현되어있다. 그렇지 않으면 모든 인스턴스별 함수가 메모리에 적제되어야 하기 때문. 
    [C++] 숨겨진 This pointer 는 어떻게 넘어 가는가~ (tistory.com) 참조

 

여기서 receiver.function(arg1, arg2)  는 기본적으로 함수가 receiver 에 종속/결합되기 때문에 (OOP 식)
 function(receiver , arg1, arg2)  식 표현이 보다 유연하며, 이런 방식으로 예시를 들도록 하겠다. (FP 식)

 

함수 오버로딩

자 우선 모든 파라미터에 대하여 dispatch 가 되어야 한다고 했다.
우리는 이미 파라미터별로 호출되는 함수가 다른것을 알고있다. 바로 함수 오버로딩이다.

namespace Test
{
    public abstract record Character();
    public record Healer(int hp) : Character();
    public record Knight(int hp) : Character();

    public static class Test
    {
        static void Hit(Healer healer, Knight knight)
        {
            Console.WriteLine("Healer hit Knight");
        }

        static void Hit(Healer healer, Healer knight)
        {
            Console.WriteLine("Healer hit Healer");
        }

        static void Hit(Knight healer, Healer knight)
        {
            Console.WriteLine("Knight hit Healer");
        }

        static void Hit(Knight healer, Knight knight)
        {
            Console.WriteLine("Knight hit Knight");
        }

        public static void Main()
        {
            Knight k = new Knight(100);
            Healer h = new Healer(100);

            Hit(h, k); // Healer hit Knight
        }
    }
}

Hit는 자동으로 Hit(Knight healer, Healer knight) 함수가 불리게 된다.

자 그런데, C#은 기본적으로 virtual 함수가 아닌경우, Compile 타임에 바인딩된다고 했다.

따라서 Healer 와 Knight 의 타입을 부모로 변경하면 (실제로 Healer와 Knight 타입임에도 불구하고) 사용이 불가능하다

따라서 이런 함수가 동작하도록 하려면 Character / Character  를 받는 함수를 생성해 주어야한다.

 

static void Hit(Character c1, Character c2)
{
    Console.WriteLine("Character hit Character");
}

public static void Main()
{
    Character k = new Knight(100);
    Character h = new Healer(100);

    Hit(h, k); // Character hit Character
}

다시 말하지만 C#은 함수오버로딩은 Compile 타임에 바인딩 되므로, "Healer hit Knight" 가 아닌 "Character hit Character" 가 출력되는것은 자명하다.

동적바인딩을 위해서는 virutal 키워드를 추가하여 dynamic 바인딩으로 변경하고, 실제 this 포인터의 객체 타입에 따라 함수를 호출시켯다. 그러나 이렇게 Character c1 / Character c2 의 경우에는 이러한 바인딩이 불가능하다. 

virtual 함수를 만들기 위하여 c1.Hit(c2) 인 꼴로 변경한다고 할지라도 c2 타입이 컴파일타임에 Character 인 이상 타입 c2 의 실체 타입에 맞는 함수를 호출하는것이 불가능하다.

그럼 어떻게 해야하나? Mutiple dispatch 가 지원되지 않는 언어에서는 크게 2가지 방법으로 해결한다.

  1. 런타임 검사
  2. Visitor Pattern

 

런타임 검사

런타임 검사는 그냥 런타임에 직접 검사하는것이다. 예를 들면 이렇게 짜야된다.

namespace Test
{
    public abstract record Character();
    public record Healer(int hp) : Character();
    public record Knight(int hp) : Character();

    public static class Test
    {
        static void Hit(Healer healer, Knight knight)
        {
            Console.WriteLine("Healer hit Knight");
        }

        static void Hit(Healer healer, Healer knight)
        {
            Console.WriteLine("Healer hit Healer");
        }

        static void Hit(Knight healer, Healer knight)
        {
            Console.WriteLine("Knight hit Healer");
        }

        static void Hit(Knight healer, Knight knight)
        {
            Console.WriteLine("Knight hit Knight");
        }

        static void Hit(Character c1, Character c2)
        {
            switch (c1, c2) 
            {
                case (Healer cf, Knight cs):
                    Hit(cf, cs);
                    break;
                case (Healer cf, Healer cs):
                    Hit(cf, cs);
                    break;
                case (Knight cf, Healer cs):
                    Hit(cf, cs);
                    break;
                case (Knight cf, Knight cs):
                    Hit(cf, cs);
                    break;
            };
        }

        public static void Main()
        {
            Character k = new Knight(100);
            Character h = new Healer(100);

            Hit(h, k); // Healer hit Knight
        }
    }
}

어차피 컴파일 타임에는 Hit(Character c2, Character c2) 가 불리니까,
이 불린 함수에서 타입을 직접 체크 하는것이다.
패턴매칭이 좀 지원되는 언어라면 그나마 상황이 낫지만, 아니라면 그냥 if 문으로 죙일 비교해야한다.
죙일 비교하는것도 죙일인데, 코드를 계속 뜯어 고쳐야한다 즉 expression problem 이다.

 

Visitor

이렇게, 싱글 디스패칭밖에 안되는 OOP 언어에서, 디스패칭을 해주는 놈을 클래스로 만들어 놓는것이 Visitor 이다.

앞서서 OOP 언어에서는 dynamic 한 함수를 만들기 위하여 virutal 키워드를 이용하여 동적바인딩 한다고했다.

또한 Receiver 하나에 대하여만 dynamic 디스패칭이 가능하다고 했다.

그럼 virutal 로 receiver 에 대한 dynamic 을 여러번 하면 되는거 아님가? -> 이게 비지터다

Visitor 패턴에 대한 구현은 포스팅 범위를 넘어가므로 생략하겠다.

위키백과에 따르면

"구조를 수정하지 않고도 실질적으로 새로운 동작을 기존의 객체 구조에 추가 할 수 있게 된다"

 

다만 Visitor  패턴을 구현해보면 알겠지만, 이게 사용하기 상당히 거지같다.. 

 

왜 Visitor ?

근데 왜 비지터를 Expression Problem 에 설명하고있을까? 하면

OOP 식 접근은 타입추가는 쉽지만, 기능추가가 어렵다는게 Expression Problem 이였다.
여기서 함수(동작)를 Visitable 한 클래스로 만들면?

짜잔 위키백과에 따라서 "구조를 수정하지 않고도 실질적으로 새로운 동작을 기존의 객체 구조에 추가할 수 있게 된다"

 public interface CharacterVisit
 { 
     //방문자 정의
     void VisitHealer(Healer c); 
     void VisitKnight(Knight c); 
 }

 class Hit : CharacterVisit
 {
     public void VisitHealer(Healer c)
     {
         throw new NotImplementedException();
     }

     public void VisitKnight(Knight c)
     {
         throw new NotImplementedException();
     }
 }
 
 // Skill 이 추가되어도 별도 수정 필요없음
 class Skill : CharacterVisit
 {
     public void VisitHealer(Healer c)
     {
         throw new NotImplementedException();
     }

     public void VisitKnight(Knight c)
     {
         throw new NotImplementedException();
     }
 }

와 Skill (동작) 을 추가할때 기존 코드 수정없이 뚝딱 추가 가능!
Expression Problem 해결! 메데타시 메데타시

 

이면 좋겠으나 그렇게 호락호락하지않다. 이유를 잘 생각해보자.

OOP 식 접근은 타입추가는 쉽지만, 기능추가가 어렵다.

FP 식 접근은 기능추가는 쉽지만, 타입추가가 어렵다.

Visitor는 기능 추가는 쉽지만, 타입이 추가되면... 어?

 public interface CharacterVisit
 { 
     //방문자 정의
     void VisitHealer(Healer c); 
     void VisitKnight(Knight c); 
     void VisitTanker(Tanker c); 
 }

CharacterVisit 에 방문할 타입을 추가해야한다 -> 모든 기존 Visitor 에 코드가 수정되어야 한다 -> 기존코드 수정이 발생한다.

즉 기능을 자유롭게 추가하기 위하여 Visitor를 쓰면 OOP 식 접근에서 FP 식 접근으로 변경된것이다.

 

Mutiple dispatch

모든 파라미터에 실제 타입에 맞게 바인딩 되도록 하는것이  Mutiple dispatch 이다.

C# 에서는 dynamic 이라는 키워드를 통하여 런타임전용 타입을 만드는것이 가능하다, 이는 함수를 사용할때도 가능하다. (잘쓰지는 않지만..)

namespace Test
{
    public abstract record Character();
    public record Healer(int hp) : Character();
    public record Knight(int hp) : Character();

    public static class Test
    {
        static void Hit(Healer healer, Knight knight)
        {
            Console.WriteLine("Healer hit Knight");
        }

        static void Hit(Healer healer, Healer knight)
        {
            Console.WriteLine("Healer hit Healer");
        }

        static void Hit(Knight healer, Healer knight)
        {
            Console.WriteLine("Knight hit Healer");
        }

        static void Hit(Knight healer, Knight knight)
        {
            Console.WriteLine("Knight hit Knight");
        }

        public static void Main()
        {
            Character k = new Knight(100);
            Character h = new Healer(100);

            Hit(h as dynamic, k as dynamic); // Healer hit Knight
        }
    }
}

여기서 기능이 추가되면?

//Skill 이 추가됨
static void Skill(Healer healer, Knight knight)
{
    Console.WriteLine("Healer skill Knight");
}

public static void Main()
{
    Character k = new Knight(100);
    Character h = new Healer(100);

    Hit(h as dynamic, k as dynamic); // Healer hit Knight
    Skill(h as dynamic, k as dynamic); // Healer skill Knight
}

 

타입이 추가되도

//Tanker 타입 추가
public record Tanker(int hp) : Character();

//Tanker 타입에 대한 함수 Skill 이 추가됨
static void Skill(Tanker tanker, Knight knight)
{
    Console.WriteLine("Tanker skill Knight");
}

public static void Main()
{
    Character k = new Knight(100);
    Character h = new Healer(100);
    Character t = new Tanker(100);

    Skill(t as dynamic, k as dynamic); // Tanker skill Knight
}

걍 맞는 함수 구현 하면 된다.

 

추가적인 팁으로, 기본적으로 정적언어인 C# 과 함수 오버로딩은 컴파일타임에 된다는걸 활용하여

static void Hit(Character c1, Character c2)
{
    Hit(c1 as dynamic, c2 as dynamic);
}

public static void Main()
{
    Character k = new Knight(100);
    Character h = new Healer(100);

    Hit(h, k); // Healer hit Knight
}

다음과 같이 컴파일 타임에 바인딩 되는 함수내에서 Mutiple dispatch 를 사용 할 수 있다.

 

 

Multimethod

위키백과에 따르면 Multi Method == Multiple dispatch 이기 때문에 크게 신경쓸것은 아니지만

(clojure 에 관심없으면 패스)

굳이 섹션을 나눠서 적는 이유는

- Multiple dispatch는 일반적으로 타입(class-based) 에 디스패칭을 의미한다.

- clojure 의 multi method 는 이것저것 다 된다. (클래스, 값, 심볼, multiple dispatch + adhoc 상속)

따라서 multiple dispatch 를 안써도 Expression Problem 을 해결할수있기 때문 oop 에서는 method 가 Class 와 강하게 결합되어있기 때문에 single dynamic dispatch 로는 expression problem 이 해결되지 않았으나 (클래스의 수정이 필요), clojure 는 애초에 function 과 type 이 직교하기 때문에, 걍 object 타입에 맞는 함수를 외부에서 작성하면된다. 

 

728x90