ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 간단한 모나드 설명과 예제
    프로그래밍/설계 2020. 2. 11. 13:57

    리엑트 네이티브 스터디 때문에 시작한 글이었는데 생각보다 길어져서 분리하게 되었다.

    자바스크립트 관련 코드는 해당 스터디쪽 문서에 올릴 예정.

     

    역시 모나드를 이해할때는 Haskell이 가장 효율적이므로 Haskell로 된 코드를 사용해보자.

    자바스크립트로도 설명할 수 있지만(모나드 섹션 하단의 링크 참조) 이해하기에 코드가 깔끔하진 않다.

     

    하스켈의 간단한 입출력 예제다.

    main = do
        putStrLn  "Input: "
        x <- getLine
        putStrLn ("The Input was " ++ x)

    모나드가 적용되어 있지만 그리 어렵진 않다는 것을 알 수 있다.

     

    모나드의 정의

    그럼 차근차근 모나드에 대해 알아보도록 하자.

     

    모나드의 조건은 람다대수 BNF처럼 간단하다.

    • 타입 생성자 M
      원래 하스켈에서는 m라고 표시하지만 가독성을 위해 M 이라 표시했다.
    • return 함수
    • 바인드(bind) 연산자 >>=

     

    타입 생성자 M

    타입 생성자는 C++의 templete 와 유사하여 다른 타입을 인자로 받는다.

     

    완전히 같진 않지만 아래와 같이 대응된다고 생각하면 된다.

    -- Haskell
    myFunc :: Int -> Int
    data MyData t = MyData t             -- 형태: * -> *
    data MyData t1 t2 = MyData t1 t2     -- 형태: * -> * -> *
    data MyData tmpl = MyData (tmpl Int) -- 형태: (* -> *) -> *
    // C++
    int myFunc(int a);
    template <typename t> class MyData;
    template <typename t1, typename t2> class MyData;
    template <template <typename t> class tmpl> class MyData;

     

    return 함수

    하나 주의할 것이 있다면 return의 의미가 살짝 다르다.

    return :: a -> M a

     

    return 함수는 타입 a를 받아 M<a>방식으로 반환해주는 것이기 때문이다.

    그냥 타입 생성자로 감싸주는 것이라 생각하자.

     

    바인드(bind) 연산자 >>=

    (>>=):: M a -> (a -> M b) -> M b

     

    >>=

    • M<a>타입의 생성자가 있을 때.
    • a 타입의 값을 받고, M<b>타입을 반환하는 함수를 받아
    • M<b>타입의 값을 반환한다.

     

    바인드 연산자를 사용하면 서로 다른 타입인 ab결합할 수 있게 만들어 주는 것이다.

     

    모나드, 바인드 연산자 그리고 순수함수

    그렇다면 도대체 어떻게 모나드를 이용하면 부작용(Side Effect)가 없는 함수, 즉 순수함수를 만들 수 있다는 것인가?

     

    답은 간단하다.

    외부 상태를 인수로 전달하고, 결과로 상태값을 반환하면 된다.

    흔히 볼 수 있는 순수함수의 설명이랄까?

     

    이제 모나드를 이용하여 간단한 예를 들어보자.

    외부상태의 타입을 State , 함수 fg가 있다면 다음처럼 나타낼 수 있다.

    (ab는 또다른 결과값)

    f:: State -> (a, State)
    g:: State -> (b, State)

     

    그런데, 함수를 합성(g(f(x)))해야 할 일이 생긴다면??

    f의 출력과 g의 입력이 맞지 않는다.

     

    이 해결방안이 바로 바인드 연산자(>>=)다.

    바인드 연산자는

    • f의 결과값을 aState로 분리하고,
    • ag와 결합해 새로운 함수 g'를 만들어 주며
    • Stateg'에 전달해준다.

     

    바인드 연산자가 만들어준 새로운 함수 g'

    g':: State -> (b, State)

    의 형태가 되도록 2개의 함수가 합쳐진 것이다!!

     

    합성함수를 다른 관점에서 살펴보면 State값이 fg함수를 '연속적'으로 거친 것이라 생각할수도 있는데 Chaining이라 부른다.

     

    이제 다시 한번 바인드 연산자의 정의를 보자.

    (>>=):: M a -> (a -> M b) -> M b

    이해가 가는가?

     

    다음은 차례대로 쓰기, 읽기, 상태 변화를 모나드로 어떻게 처리하는지에 대한 그림이다.

    • 쓰기

    • 읽기

    • 상태

     

    바인드 연산자와 do

    다시 처음으로 돌아와서 do 연산자를 봐보자.

    사실 do는 >>=(바인드 연산자)의 연속된 형태인데..

     

    do의 문법은 바인드 연산자를 쉽게 쓰도록 만든 Syntax Sugar다.

    e1 >>= \p -> e2   -- 바인드 연산자
    do p <- e1; e2    -- Do 연산자

     

    바인드 연산자 사용 예제와 do

    (>>=):: [a] -> (a -> [b]) -> [b]

    그럼 바인드 연산자가 리스트 형태로 묶어서 리턴한다고 가정해보자.

     

    우리의 목표는 아래와 같은 리스트 컴프레션을 만드는 것.

    [(x,y) | x <- [1,2,3] , y <- [1,2,3], x /= y]

    결과: [(1,2),(1,3),(2,1),(2,3),(3,1),(3,2)]

    /===의 반대로, 다를 때 True 같으면 False를 리턴한다.

     

    바인드 연산자를 이용하면 다음과 같이 구현한다.

    [1,2,3] >>= (\ x -> [1,2,3]       >>=
                (\ y -> return (x/=y) >>=
       (\r -> case r of True -> return (x,y)
                        _    -> fail "")
                ))

     

    그리고, do를 사용하면 매우 간단하게 표현할 수 있다.

    do x <- [1,2,3]
       y <- [1,2,3]
       True <- return (x /= y)
       return (x,y)

     

    do 모나드를 보고 나면 연속적으로 적용한다는 chaining 또한 쉽게 이해가 될 것이라 생각한다.

     

    모나드 활용예시

    모나드 활용을 2가지 예로 알아보자.

    보편적으로 사용해서 사람들이 많이 알지만, 언어 설계상 구멍 또한 많은 자바로 사용해봤다.

    역시 자바는 까야 제맛 이 아니라.. OOP로만 유명한 언어에서도 잘 써먹을 수 있다는 것을 보여줄 수 있기 때문이다.

     

    NULL 문제 해결

    Null값과 관련된 NullPointerException은 정말 자바에서 유서깊고 익숙한 버그다.

    자바로 어느정도 코딩을 해본적이 있는 사람이라면 안만난적이 없을 것이라 장담한다.

     

    Null Pointer를 처음 생각해낸 토니 호어 아저씨도 후회한다구..

    https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/

     

    Null References: The Billion Dollar Mistake

    Tony Hoare introduced Null references in ALGOL W back in 1965 "simply because it was so easy to implement", says Mr. Hoare. He talks about that decision considering it "my billion-dollar mistake".

    www.infoq.com

    물론 자바는 타입 안정성(Type Safty)를 보장하지 않기 때문에 수많은 익셥션과 런타임 에러처리에 많은 노력을 들이고, 코드가 이상해진다.

     

    하지만, 방금 배운 모오오나드를 사용하면 우아하게 해결할 수 있다.

    스위프트 사용자라면 ?기호로, 하스켈이라면 Maybe로 유명한 그것.

     

    Null값과 아닌 것을 구분하는 똑똑한 타입이다.

    M >>= g = case M of
                 Just x  -> g x
                 Nothing -> Nothing

     

     

    Maybe 모나드가 유용한 상황을 알아보기 위해 우선 간단한 예제를 만들어봤다.

    class FamilyTree {
        @Getter
        @AllArgsConstructor
        class Person {
            private String relation;
            private Person father;
            private Person mother;
        }
    
        Person createFamilyTree() {
            return new Person("Me",
                    new Person("Father",
                            new Person("GrandFather",
                                    new Person(null,                null, null),
                                    new Person("Great-GrandMother", null, null)),
                            new Person("GrandMother", null, null)),
                    new Person("Mother",
                            new Person("Maternal GrandFather", null, null),
                            new Person("Maternal GrandMother", null, null)));
        }
    
        Person person;
        FamilyTree() {
            this.person = createFamilyTree();
        }
    ...
    }

    보다시피 아래와 같이 이루어진 가계도란 것을 알 수 있다.

     

    • 사람(Person)
      관계, 아버지, 어머니 정보를 가진다.
    • 생성자로 생성된 사람한계
      • 친가: 친증조할아버지(이름 누락), 친증조할머니
      • 외가: 외할아버지, 외할머니

     

    그럼 여기에서 친증조할아버지와 외할머니를 찾아보자.

    void find1() {
        if ( person.getFather() != null ) {
            Person father = person.getFather();
            if ( father.getFather() != null ) {
                Person grandFather = father.getFather();
                if ( grandFather.getFather() != null ) {
                    Person greatGrandFather = grandFather.getFather();
                    if ( greatGrandFather.getRelation() != null )
                        System.out.println( "1-1: " + greatGrandFather.getRelation() );
                    else
                        System.out.println( "1-1: Failed" );
                } else {
                    System.out.println( "1-1: Failed" );
                }
            }  else {
                System.out.println( "1-1: Failed" );
            }
        } else {
            System.out.println( "1-1: Failed" );
        }
    
        if ( person.getMother() != null ) {
            Person mother = person.getMother();
            if ( mother.getMother() != null ) {
                Person maternalGrandMother = mother.getMother();
                if ( maternalGrandMother.getRelation() != null )
                    System.out.println( "1-2: " + maternalGrandMother.getRelation() );
                else
                    System.out.println( "1-2: Failed" );
            } else {
                System.out.println( "1-2: Failed" );
            }
        } else {
            System.out.println( "1-1: Failed" );
        }
    }

     

    예제용으로 만들었기 때문에 약간 과장되어 있다.

    위 코드는 재귀로 나타나면 간결하게 만들수 있겠지만 현실에서는 복잡한 처리를 원해서 재귀나 반복문을 쓰기 힘들 수도 있다.

    (예를 들어 Person 만으로 이루어지지 않았거나 나, 아빠, 할아버지등에 모두 다른 처리가 필요하다거나..)

    그래서 If문을 사용해서 Null값을 확인해야 한다면 자바스크립트 콜백지옥을 보는 느낌이 나는건 여전할 것이다.

     

    이번에는 자바에서 Maybe 역할을 하는 Optional을 사용해서 처리해보자.

    void find2() {
        Optional.of( person )
            .map( Person::getFather   )
            .map( Person::getFather   )
            .map( Person::getFather   )
            .map( Person::getRelation )
            .ifPresentOrElse(
                info -> System.out.println( "2-1: " + info ),
                ()   -> System.out.println( "2-1: Failed"  )
            );
    
        Optional.of( person )
            .map( Person::getMother   )
            .map( Person::getMother   )
            .map( Person::getRelation )
            .ifPresentOrElse(
                info -> System.out.println( "2-2: " + info ),
                ()   -> System.out.println( "2-2: Failed"  )
            );
    }

     

    간결하고, 우아하다.

    직접적으로 null을 다루지 않으면서 처리를 할 수 있다.

     

    모나드의 개념을 이용해서 바라보면

     

    • Optional.of()는 Optional 타입 모나트 생성자 역할로 person을 감싸준다. (타입 생성자, return)
    • map 함수를 연속적으로 적용 또는 합성한다. (바인드 연산자, chaining)
    • 마지막으로 Optional 타입을 이용해서 Null값 여부를 확인한다.

    영락없는 모나드의 형태며,  적용되는 것을 보면 do 연산자를 보는 느낌이 든다.

     

    요즘 유행하는 Rx를(여기서는 RxJava) 사용해도 비슷하게 구성할 수 있다.

    void find3() {
        Maybe.just( person )
            .map( Person::getFather   )
            .map( Person::getFather   )
            .map( Person::getFather   )
            .map( Person::getRelation )
            .subscribe(
                info -> System.out.println( "3-1: " + info ),
                e    -> System.out.println( "3-1: Failed"  )
            );
    
        Maybe.just(person)
            .map( Person::getMother   )
            .map( Person::getMother   )
            .map( Person::getRelation )
            .subscribe(
                info -> System.out.println( "3-2: " + info ),
                e    -> System.out.println( "3-2: Failed"  )
            );
    }

     

    Try 문제 해결

    자바를 쓰다보면 생각나는 또다른 문제는 바로 Try-Catch다.

    NullPointerExteption의 연장선상으로 논할 수 있다.

     

    그럼 Try-Catch의 단점을 생각해보자면 역시

    • 가독성이 엄청 떨어지고
    • 본문과 동떨어져 있다는 특성 때문에 Goto 문법을 쓰는 느낌
    • 그러다 보니 예외처리를 중복으로 할 수도 있고 유지보수가 힘듦

    이 가장 큰 단점이다.

     

    그리고 역시 모나드를 사용하면 깔끔하게 해결이 가능하다.

    대략 아래 그림처럼 예외처리가 가능해진다.

    스칼라의 Try와 Promise

    Maybe 모나드를 사용할 때와 비슷~

     

    역시 Try를 쓰는 예제를 만들어봤다.

    0으로 나누기를 하면 ArithmeticException이 발생한다는 것에 착안해 만들었으며 null을 다루는 예제보다 훨씬 간단하다.

    void divide1( int divide_num ) {
        try {
            System.out.println( "1: " + ( num / divide_num ) );
        } catch ( ArithmeticException e ) {
            System.out.println( "1: Can't divide by 0" );
        }
    }

    여기서는 요구사항이 간단해 그다지 헷갈리지 않지만,

    코드가 복잡해질수록 가독성이 나빠질 것이다.

     

    그래서 다른 방법을 생각해보자면

    아까도 썼었던 Rx의 경우 subscribe를 주목할 필요가 있다.

    subscribe를 사용하면 정상적일 때와 에러가 났을 때를 구분할 수 있기 때문이다.

    void divide2( int divide_num ) {
        SingleJust.just( num )
            .map( num -> num / divide_num )
            .subscribe(
                result -> System.out.println( "2: " + result         ),
                e      -> System.out.println( "2: Can't divide by 0" )
            );
    }

    subscribe를 이용하면 자연스럽게 성공, 실패를 구분하는 것이 가능하며 가독성도 그대로 살릴 수 있다.

     

    그렇다면 Optional, Maybe처럼 Try 타입은 없는가?

    대답을 하자면  vavr Try 타입을 사용해볼 수 있다.

    void divide3( int divide_num ) {
        Try.of( () -> num / divide_num )
            .onSuccess( result -> System.out.println( "3: " + result         ) )
            .onFailure( e      -> System.out.println( "3: Can't divide by 0" ) );
    }

     

    개인적으로는 Rx가 함수형 개념을 훌륭히 버무렸다고 생각해서 선호한다.

     

    후기.

    원래 설계용으로 강좌를 쓰던 Lisp(Racket)로 쓸까말까 고민하다 하스켈 위주로 설명해서 올렸다.

     

    Lisp로 모나드를 구현 한 코드를 보면 합쳐서 300라인 정도라 짧다!!

    https://github.com/tonyg/racket-monad

     

    tonyg/racket-monad

    Monads for Racket (!). Contribute to tonyg/racket-monad development by creating an account on GitHub.

    github.com

    리스프를 좋아하는 사람이라면 볼만한듯.

     

    매크로를 이용해 아예 방언으로 만든 구현체도 있는데

    https://docs.racket-lang.org/heresy/index.html

     

    The Heresy Programming Language

    7.5 The Heresy Programming Language source code: https://github.com/jarcane/heresy The Heresy language is a functional Lisp/Scheme dialect implemented in Racket, with syntax inspired by the BASIC family of programming languages. Its principle goals are to

    docs.racket-lang.org

     

    아름답게 융합된 모습을 볼 수 있다.

    (is-none? (maybe-do
               (a <- (some 5) )
               (b <-     None )
               (c =   (+ a b) )
               (yield       c )))

     

    마지막으로 원 코드는 아래에 올려놨으니까 참고할 사람은 참고하시면 되겠습니다.

    https://github.com/black7375/JavaMonad

     

    black7375/JavaMonad

    Contribute to black7375/JavaMonad development by creating an account on GitHub.

    github.com

     

    참고

    참고할 만한것:

     
     

     

    댓글 0

Designed by black7375.