본문 바로가기

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

[Expression Problem] 객체 대수 (Object algebras) 와 Tagless Final

 

 

솔직히 말하면 먼소리는 모르겠는데, 일단 포스팅

이전 장에서 나온 Visitor pattern 의 고오오급 패턴? 같은건데 

대수학 (algebra) 개념을 객체에 적용 했다고 보면 될 듯 하다.

[해당 글] 을 기준으로, 함수형 예시로 매번나오는 Eval 할수있는 DSL 을 짜는건 이해가 안되므로 계속 설명해오던 Charater 로 약간 변형하였음

 

객체 대수

C# 을 예로,
가장 먼저 object algebra interface 을 정의한다. 

필요한 객체를 ADT 로 디자인하는것과 유사한데 다음과 같다.
이 대수 집합(?) 은 Healer 와 Knight 를 포함한다.

interface ICharacterAlgebra<T>
{
    T Healer(int hp);
    T Knight(int hp);
}

 

필요한 함수셋을 나타내는 대수 집합 또한 정의 할 수 있다.
이는 Hit 라는 함수가 포함되어야 함을 나타내는  Expression 에 대한 인터페이스이다.

interface IHitExpr
{
    string Hit();
}

 

이는 인터페이스 이므로, Expression 을 설정하기 위한 클래스를 만들수있다.
아래는 실제 함수를 주입받아서 실행해주는 Expression 객체 이다.
이 Expression 자체는, 실제 Charater 마다 구현이 다를것이므로, 이 클래스는 단순히 주입받은 함수를 실행하도록 하고,

class HitExpr : IHitExpr
{
    public HitExpr(Func<string> hit)
    {
        _hit = hit;
    }
    Func<string> _hit;


    public string Hit() => _hit();
}

 

아까 정의한 대수 집합(ICharacterAlgebra) 인터페이스가 Expression (IHitExpr) 을 수행할 수 있도록 만들자
아래로 코드로 부터 타입별 Hit이 가능한 Expression 을 얻을수있다.

class HitAlgebra : ICharacterAlgebra<IHitExpr>
{
    public IHitExpr Healer(int hp) => new HitExpr(() => "Healer Hit");
    public IHitExpr Knight(int hp) => new HitExpr(() => "Knight Hit");
}

 

이제 특정 타입(대수)에 대한 Expression 을 생성할수있다.

static class Algebra
{
    //OOP 로 따지자면, 
    //Healer 에 맞는 함수가 의존성 주입된 IHitable 클레스가 나온다
    public static T CreateHealerExpr<T>(ICharacterAlgebra<T> factory)
        => factory.Healer(10);

    public static T CreateKnightExpr<T>(ICharacterAlgebra<T> factory)
        => factory.Knight(10);

    public static void Test()
    {
        {
            var character = CreateHealerExpr(new HitAlgebra());
            Console.WriteLine(character.Hit());
        }
        {
            var character = CreateKnightExpr(new HitAlgebra());
            Console.WriteLine(character.Hit());
        }
    }
}

 

 

여기까지가 가장 기본적인 객체 대수의 활용이다.

OOP 로 따지자면,

  • Hit 함수가 있는 IHitable 인터페이스를 상속받은 객체가 HitExpr 이 된다.
  • Visitor 각 타입별로 맞는 함수를 호출하는 대신에, 함수자체를 의존성 주입한 객체를 만든다.
  • 객체를 팩토리 패턴으로 만들듯이, 평가 가능한 객체를 만들 수 있다.
  • 인터페이스로 타입을 상속(서브타이핑) 대신에 대수 집합을 정의한다
    ICharacter 인터페이스 <- Healer/Knight 대신, ICharacterAlgebra 인터페이스 안에 Healer / Knight 용 함수 정의

 

객체 대수 확장

왜 이렇게 귀찮은 짓을 해야할까?

코드에 앞서서, 개인적으로 망상한 내용을 설명하자면
대수학이라는건 일종의 방정식이다.

초중등교육 수준의 대수학이란 수학 문제를 간단하게 만드는 기술들, 그러니까 미지수에 변수를 '대입'하는 기술, 그리고 이를 '계산'하는 기술, 그리고 마침내 '방정식을 푸는' 기술이다. - 나무위키 피셜
X + Y =?? (X, Y 는 0보다 큰 자연수)  에서 X == 5, Y == 5 라면 답은 10이다.

객체 대수는 객체로 "X + Y" 식을 만드는것이고, 이를 프로그램은 최종적으로 이 식을 평가(답을 도출)하는 것 정도로 이해하면 될것같다.

여기서 X + Y =?? (X, Y 는 0 이상의 실수)

이는 기존의 식과 다른식 이지만, 이 처럼 대입이 가능한 범위가 변경되었어도, 자연수 대신 0 이상의 실수를 대입만 가능하다면, 우리는 모든걸 처음부터 공부할 필요없이, 새로 추가된 범위에 대한 대입법만 알면 이 식을 평가할수있다

X + Y - Z =?? (X, Y, Z 는 0 이상의 실수)

처럼 새 연산이 생겨도, 새로 생긴 - 에 대한 연산 방법만 안다면, 모든걸 처음부터 공부할 필요없이 식을 평가할 수 있다.

대충 이런 개념이라고 생각하고 객체대수 확장 코드를 확인해보자

namespace GameExt
{
    // 기존 작성 코드가 Game Namespace 에 있었기때문에 using 을 통하여 사용하도록 하였음
    using Game;
    
    // 대수 범위가 확장될 수 있다.
    // 기존 범위에서 상속을 받았으므로이 Tanker / Healer / Knight 가 대입 가능하다
    interface ICharacterAlgebraExt<T> : ICharacterAlgebra<T>
    {
        T Tanker(int hp);
    }

    // 기존 범위는 이미 Hit 에 대한 연산방법을 알고있으므로
    // 늘어난 범위에 해당하는 Tanker만 추가로 정의하면된다
    class CharacterAlgebraExt : HitAlgebra, ICharacterAlgebraExt<IHitExpr>
    {
        public IHitExpr Tanker(int hp) => new HitExpr(() => "Tanker Hit");
    }

    interface ISkillExpr
    {
        string Skill();
    }

    class SkillExpr : ISkillExpr
    {
        public SkillExpr(Func<string> skill)
        {
            _skill = skill;
        }
        Func<string> _skill;


        public string Skill() => _skill();
    }
    
    // 연산이 추가 될 수 있다.
    // 이 연산이 적용되는 범위는  Tanker / Healer / Knight 이다.
    class SkillAlgebra : ICharacterAlgebraExt<ISkillExpr>
    {
        public ISkillExpr Healer(int hp) => new SkillExpr(() => "Healer skill");
        public ISkillExpr Knight(int hp) => new SkillExpr(() => "Knight skill");
        public ISkillExpr Tanker(int hp) => new SkillExpr(() => "Tanker skill");
    }

    static class AlgebraExt
    {
        public static T CreateHealerExpr<T>(ICharacterAlgebraExt<T> factory)
            => factory.Healer(10);

        public static T CreateKnightExpr<T>(ICharacterAlgebraExt<T> factory)
            => factory.Knight(10);

        public static T CreateTankerExpr<T>(ICharacterAlgebraExt<T> factory)
           => factory.Tanker(10);

        public static void Test()
        {
            {
                {
                    // 기존 식은 현제 Hit 만 가능하다
                    var character = CreateHealerExpr(new CharacterAlgebraExt());
                    Console.WriteLine(character.Hit());
                }
                
                {
                    // 확장한 식은 현제 Skill 만 가능하다
                    var character = CreateHealerExpr(new SkillAlgebra());
                    Console.WriteLine(character.Skill());
                }
            }
            {
                {
                    var character = CreateKnightExpr(new CharacterAlgebraExt());
                    Console.WriteLine(character.Hit());
                }

                {
                    var character = CreateKnightExpr(new SkillAlgebra());
                    Console.WriteLine(character.Skill());
                }
            }
            {
                {
                    var character = CreateTankerExpr(new CharacterAlgebraExt());
                    Console.WriteLine(character.Hit());
                }

                {
                    var character = CreateTankerExpr(new SkillAlgebra());
                    Console.WriteLine(character.Skill());
                }
            }
        }
    }
}

위 코드처럼 새로운 평가 방법(Skill)을 정의할 수 있고, 기존 범위를 기반으로 더 넓은 범위를 가진 범위를 생성할 수 있다.

범위를 늘리면서 (Tanker 를 추가) 하면서 Tanker 에 대한 Hit 연산방법만 정의하면, Tanker 는 Hit 을 사용할수있다.

다만 위 예시에서 Skill 연상방법을 정의했지만,
Hit 가 가능한 CharacterAlgebraExt 과 Skill 이 가능한 SkillAlgebra 가 각각 다르기때문에, 같이 사용은 불가능하다.

그러니까 CharacterAlgebraExt / SkillAlgebra  은 현제 쓸수있는 범위는 0 이상의 실수로 같은건데 사용할수있는 연산은 
CharacterAlgebraExt  는 덧셈만 가능하고, SkillAlgebra  은 뻴셈만 가능한거랑 비슷하다고 보면된다..

CharacterAlgebraExt  :  A+B / A + B + C  / A + B + C+ D 만 평가가능하고, 뻴셈은 하는법을 모름

SkillAlgebra  :  A - B / A - B - C  / A - B - C- D 만 평가가능하고, 덧셈은 하는법을 모름

 

결합

덧셈도 하는법을 이미 알고, 뻴셈도 하는법을 이미 안다면 A-B+C 같은 식도 평가할수있을것이다.

두 대수를 결합한 새로운 대수를 만들면된다.

namespace Combine
{
    using GameExt;

    class AlgCombine<T, U, IAlgebraT, IAlgebraU> : ICharacterAlgebraExt<(T, U)>
        where IAlgebraT : ICharacterAlgebraExt<T>
        where IAlgebraU : ICharacterAlgebraExt<U>
    {
        public AlgCombine(IAlgebraT tAlg, IAlgebraU uAlg)
        {
            _tAlg = tAlg;
            _uAlg = uAlg;
        }
        private IAlgebraT _tAlg;
        private IAlgebraU _uAlg;


        public (T, U) Healer(int hp)
            => (_tAlg.Healer(hp), _uAlg.Healer(10));

        public (T, U) Knight(int hp)
            => (_tAlg.Knight(hp), _uAlg.Knight(10));

        public (T, U) Tanker(int hp)
            => (_tAlg.Tanker(hp), _uAlg.Tanker(10));
    }

    static class ExprAlgCombine
    {
        public static AlgCombine<T, U, ICharacterAlgebraExt<T>, ICharacterAlgebraExt<U>> Create<T, U>(ICharacterAlgebraExt<T> tAlg, ICharacterAlgebraExt<U> uAlg)
            => new AlgCombine<T, U, ICharacterAlgebraExt<T>, ICharacterAlgebraExt<U>>(tAlg, uAlg);

    }

    static class Combine
    {
        public static T CreateHealerExpr<T>(ICharacterAlgebraExt<T> factory)
            => factory.Healer(10);

        public static T CreateKnightExpr<T>(ICharacterAlgebraExt<T> factory)
            => factory.Knight(10);

        public static T CreateTankerExpr<T>(ICharacterAlgebraExt<T> factory)
           => factory.Tanker(10);

        public static void Test()
        {
            var alg = ExprAlgCombine.Create(new CharacterAlgebraExt(), new SkillAlgebra());
            {
                {
                    var character = CreateHealerExpr(alg);
                    Console.WriteLine(character.Item1.Hit());
                    Console.WriteLine(character.Item2.Skill());
                }
                {
                    var character = CreateKnightExpr(alg);
                    Console.WriteLine(character.Item1.Hit());
                    Console.WriteLine(character.Item2.Skill());
                }
                {
                    var character = CreateTankerExpr(alg);
                    Console.WriteLine(character.Item1.Hit());
                    Console.WriteLine(character.Item2.Skill());
                }
            }
        }

    }
}

두개를 결합하기 위하여 튜플을 가지는 대수를 만들고 각 각 대수를 결합했다.

CharacterAlgebraExt / SkillAlgebra 는 각각 Tuple 에 item1 / item2 에 들어가서 계산할 수 있다.

 

 

Tagless Final

대충 HKT 랑 타입시스템 을 적절히 조합하여, Generic 한 객체 대수를 만드는것 같다. (맨 마지막에, 평가방법별 인터프리터가 평가함)

ADT (대수) 적으로 eDSL 을 구축하고, 이에대한 효과(Effect) 를 HKT + 인터프리터 를 이용하여 실행한다.

- https://www.reddit.com/r/scala/comments/s6ih9p/can_you_give_me_a_brief_understanding_on_tagless/

 

Can you give me a brief understanding on tagless final?

Is it some sort of design pattern in the FP world? What are DSL, interpreter and program, these terms supposed to mean? Is it dead in the year...

www.reddit.com

 

 

결론

아니 진짜 이렇게 짜는 사람이 있다고?

Expression Problem 에 대한 모든 포스팅이 끝났다.
Expression Problem 을 해결하기 위한 방법이 이것들만 있는것은 아니지만, 대표적인것만 정리했다.
Type Class 랑 객체 대수는 솔직히 아직도 이해가 가지 않는 부분도 있지만, 요 며칠간 하나의 주제로 많은 검색과.. 공부를 하면서 배운점도 많은 것 같다. (언어를 넘어가면서 이해야하는게 제일 어려움) 

필자는 개인적으로 Clojure 를 좋아해서 그런가.. Clojure 식 해결방법이 제일 심플해 보인다.

이유를 좀 더 붙이자면, Expression Problem 을 해결하기위하여 시작부터 머리를 싸매고 디자인을 해야하는것이 아니라, 이미 발생한 Expression Problem을 해결해야 한다고 생각한다.

프로그램이라는건 작은 기능부터 서서히 발전하면서 복잡해지기 마련이다. 따라서 대부분의 SW 는 맨 처음의 OOP 식 접근방법이나, FP 식 접근방법을 사용하여 디자인 되어있을것이고, 또한 우리가 다루는것이 항상 우리가 수정가능한 코드는 아니기 때문이다.

이를 Open Class 같은 불안한 몽키패칭이 아니라, 우아하게 디스패칭되는게 아주 멋지다. 

필자도 뭐 논문을 정리하고 이런게 아니라, 그냥 외국포스팅 여러개를 보면서 정리하고, 서로 다르게 되어있는 설명이면 이해하기 쉬운걸 그냥 정리하면서, 망상을 좀 추가한것 뿐이라, 틀린내용이 다수 있을수도 있으므로 이 시리즈를 너무 믿지는 않길 바라며... 포스팅을 마친다

728x90