Blog categories

Comments

[FP Basics] 함수형 프로그래밍 기본 E3

[FP Basics] 함수형 프로그래밍 기본 E3

* 본 글을 최 하단에 표시된 링크 게시물을 한국어로 번역/가공한 글입니다.

모든 규칙은 변화해야 하는가?

우린 언제나 새로운 패러다임을 시작할 때마다 우리의 오랜 관습들에 직면하는 문제를 겪는다. 그럴 때마다 우리는 스스로에게 이러한 관습들이 새로운 패러다임에서도 유효한지 묻곤 한다. 예를 들어 테스트 주도 개발(TDD, Test Driven Developemnt)에 대해 생각해보자. 이것은 함수형 프로그래밍에서도 유효할까? 유효하다면 어떤식으로 할 수 있을까?

옳고 그름을 판별해낼 수 있는 가장 좋은 방법은 한 번 시도해 보는 것이다. 자 그러면, Word Wrap 이라는 간단한 함수형 프로그램을 하나 작성해보독 하자. 요구 조건은 간단하다. 각 단어가 띄어씌기(single space)로 구분된 문자열과 각 라인의 길이가 주어질 때, 어떠한 라인도 해당 길이를 넘지 않도록 line-end characters를 넣어주면 된다. 만약 단어들이 특정한 길이보다 길 경우 분리시키도록 한다.

자, 우리가 게티즈버그연설(Gettysburg Address)을 문자열로 가져왔다고 해보자.

Four score and seven years ago our fathers brought forth upon this continent a new nation conceived in liberty and dedicated to the proposition that all men are created equal

그리고 우리가 이제 각 라인이 10자를 넘지 않도록 바꾼다면 다음과 같이 바뀌게 된다 :

Four score\nand seven\nyears ago\nour\nfathers\nbrought\nforth upon\nthis\ncontinent\na new\nnation\nconceived\nin liberty\nand\ndedicated\nto the\npropositio\nn that all\nmen are\ncreated\nequal

Brain Marick의 사랑스러운 테스트 프레임웍 Midje를 이용해 보도록 하자. 먼저 첫 테스트를 작성해보자.

(facts
  (wrap "" 1) => "")

위 코드는 (wrap “” 1) 호출 시 “” 이 리턴될 것이라는 의미이다. 다음과 같이 함수를 정의하여 본 테스트를 통과할 수 있다.

(defn wrap [s n]
  "")

다음 테스트는 조금 변형시켜 보자. 물론, 많이는 아니다.

(wrap "x" 1) => "x"

(defn wrap [s n]
  s)

다음 테스트는 너무 긴 단어들을 나누도록 해보자.

(wrap "xx" 1) => "x\nx"

(defn wrap [s n]
  (if (<= (length s) n)
    s
    (str (subs s 0 n) "\n" (subs s n))))

클로저에서의 if 구문은 C나 Java의 ? : (삼항연산자)와 같다. 만약 if 구문이 참이라면 첫 번째 식을, 아닐 경우 두 번째 식을 리턴한다. subs 메소드는 Java에서의 subString 메소드와 비슷하게 self-explanatory 해야 한다. str 메소드는 단순하게 모든 매개변수를 받아 하나의 문자열로 연결시킨다.

다음은 재귀를 사용하는 순환단계이다.

(wrap "xxx" 1) => "x\nx\nx"

(defn wrap [s n]
  (if (<= (count s) n)
    s
    (str (subs s 0 n) "\n" (wrap (subs s n) n))))

다음 테스트는 어디에서 줄바꿈 이후 공백이 나오는지 테스트하는 것이다. 줄 앞에 공백이 나오는 거을 원치 않기 때문에 해당 공백을 없애버리자.

(wrap "x x" 1) => "x\nx"

(defn wrap [s n]
  (if (<= (count s) n)
    s
    (let [trailing-space (= \space (get s n))
          new-line-start (if trailing-space (inc n) n)
          head (subs s 0 n)
          tail (subs s new-line-start)]
      (str head "\n" (wrap tail n)))))

클로저는 let 구문을 통해 함수 안에서 사용할 값을 단순히 보관할 수 있는 로컬 심볼의 기능을 제공해준다. 하지만 변하지 않기 때문에 변수는 아니다.

get 함수는 문자열 내에서 문자의 위치 n을 반환한다. \space는 space를 의미하는 클로저 내의 상수 문자이다.

다음은 줄바꿈 문자 전에 공백이 있는지 찾기 위해 역방향 검색하는 것이다. 만약 공백이 존재한다면 n 길이 이상의 단어를 찾은 것이다. 다른 말하면 우리는 해당 공백에서 줄을 나누어야 한다. 역방향 검색은 Java 문자열 라이브러리의 .lastIndexOf 함수를 그대로 호출하여 진행할 수 있다. ‘.’ 기호는 java에 호출을 사용한다는 의미이다.

(wrap "x x" 2) => "x\nx"

(defn wrap [s n]
  (if (<= (count s) n)
    s
    (let [space-before-end (.lastIndexOf s " " n)
          this-line-end (if (neg? space-before-end) 
                            n 
                            space-before-end)
          trailing-space (= \space (get s this-line-end))
          new-line-start (if trailing-space 
                             (inc this-line-end) 
                             this-line-end)
          head (subs s 0 this-line-end)
          tail (subs s new-line-start)]
      (str head "\n" (wrap tail n)))))

하지만 이건 좀 멍청해보인다. 약간 리팩토링 해보자.

(defn find-start-and-end [s n]
  (let [space-before-end (.lastIndexOf s " " n)
        line-end (if (neg? space-before-end) n space-before-end)
        trailing-space (= \space (get s line-end))
        line-start (if trailing-space (inc line-end) line-end)]
    [line-start line-end]))

(defn wrap [s n]
  (if (<= (count s) n)
    s
    (let [[start end] (find-start-and-end s n)
          head (subs s 0 end)
          tail (subs s start)]
      (str head "\n" (wrap tail n)))))

아마 모든 테스트 케이스를 완전히 이해하지는 못했을 것이다. 만약 그렇다면 실패했던 테스트 케이스들을 정복하길 빈다. 이 여섯가지의 간단한 테스트 케이스를 찬찬히 살펴보길 바란다. 대부분의 사람들은 다음과 같은 방식으로 작성하지 않는다. 잘 정리하고, 세심히 고민하고, 테스트 케이스를 최소화 하는 것들의 가치를 깨닫기 전에 TDD를 통해 작업하는데 많은 시간이 걸렸다.

어찌되었건, 최소한 이번 케이스에서는 어떠한 종류의 프로그래밍을 하든 함수형 프로그래밍과 같이 적용할 수 있을 것 같이 보인다. 그러니 함수형 프로그래밍을 시작하면서 그냥 던저버리지 말길 바란다. TDD의 방식을 지키도록 하자.

일부 사람들은 이러한 알고리즘은 함수형 프로그래밍이 아니라고 불평할지 모른다. 하지만 난 이것이 옳다고 본다. 이건 전혀 다른 부수효과가 없다. 이것은 보기에 투명하다. 또한 영속적인 자료 구조를 사용한다. 이것은 함수적이다.

그럼에도 불구하고 이것은 많은 사람들이 연관짓는 함수형 프로그래밍의 특징 중 하나가 부족하다. 이것은 변형(transformations)의 연속(series)으로 구성되어 있지 않다.

다시 제곱 프로그램을 살펴보자

(take 25 (squares-of (integers))

변환의 연속으로 부터 나오는 사실로부터 코드의 순수한 우아함을 엿볼 수 있다. 첫번째 변환은 integers 함수 이다. 이것은 1로부터 시작하는 lazy sequence 정수는 그대로 둔 체 변화한다. 두 번째 변화는 squares-of 함수 이다. 이것은 어떠한 정수 리스트 이던지 그들의 제곱 형태의 리스트로 변화시킨다. 마지막 변형은 미리 정의된 lazy sequence의 크기를 정확히 25개의 원소로 변환한다.

그렇다면 문자열 변형 문제도 이러한 변형의 연속으로 바꿀 수 있을까? 물론이다.
다음을 보자

(defn wrap [s n]
  (join "\n"
        (make-lines-up-to n 
                          (break-long-words n (split s #" ")))))

정수 제곱 프로그램을 읽었듯이 안에서 밖으로 읽는 방식으로 읽으면 된다. 먼저 문자열을 단어 단위로 조꺤다. 그 이후 n 길이 보다 긴 단어들을 두 개 이상의 단어로 쪼갠다. 그리고 이 문자들을 n을 넘지 않는 줄로 합치도록 한다. 최종적으로 ‘\n’ 단어와 함께 각 라인을 합치도록 한다. 간단하지 않는가?

저렇게 적으면 단순해 보이지만, 여기 전체 프로그램 코드를 보자.

(defn break-long-words [n words]
  (if (empty? words)
    []
    (let [word (first words)]
      (if (>= n (count word))
        (cons word (break-long-words n (rest words)))
        (cons (subs word 0 n) 
              (break-long-words 
                 n 
                 (cons (subs word n) (rest words))))))))

(defn make-lines-up-to
  ([n words]
    (make-lines-up-to n words [] []))
  ([n words line lines]
    (if (empty? words)
      (conj lines (join " " line))
      (let [new-line (conj line (first words))]
        (if (>= n (count (join " " new-line)))
          (make-lines-up-to n (rest words) new-line lines)
          (make-lines-up-to n 
                            words 
                            [] 
                            (conj lines (join " " line))))))))

(defn wrap [s n]
  (join "\n" (make-lines-up-to 
                n 
                (break-long-words n (split s #" ")))))

이건 마치 난 최고의 함수형 프로그래머는 아니야 라고 말하는 것과 같이 조금 전통에서 어긋나 보인다. 그들이 맞다. 그리고 이 프로그램은 증명하기에는 아쉽게도 부적합해보인다. 난 아직도 내 첫 번째 솔루션이 위 코드보다 저급하다는 것을 인정하지 못하겠다. 이건 연속적인 변형들이 나쁘다는 것을 의미하지는 않는다. 반대로 괭장히 강력하기에 나중에 더 공부해볼 것이다. 내 요지는 모든 함수형 프로그램이 연속적인 변형을 필요로 하는 것은 아니라는 것이다. “간단한 것이 최고다”와 같은 오랜 규칙은 여전히 적용 가능하다. 그리고 단어 변형 알고리즘을 위해서는 최소한 변형을 이용한 답은 내가보기엔 베스트가 아니라는 것이다.

어찌되었건 난 위에서 볼 수 있는 변형을 이용한 해결책을 위한 TDD를 이용하지 않았다. 대신 내가 한 방식을 제안한다. 내가 보기엔 위 방식은 시간이 오래걸리고, 콘솔에서 부분을 위해 전체를 확인하는 수동적인 디버깅을 수행해야 한다. (REPL이라고도 불린다, Read Evaluate Print Loop). 디버깅과 프린트 문들을 합치면 전체적인 처리 시간이 TDD 방식에 비해 최소한 3배 이상 오래걸릴 뿐만 아니라, 많은 재귀 구문을 발생시키고 스택과 콘솔에 충돌을 일으킨다. 간단히 말해서 난 이 방식을 추천하지 않는다.

따라서 난 다음과 같이 요약하겠다. 오랜 방식은 여전히 유요하다. 함수형 프로그래밍은 바뀌어야 한다. 이것은 중요한 변화이지만, 프로그램이 여전히 프로그램으로 존재하는 모든 것을 바꾸는 것은 아니다. 규칙은 여전히 규칙이다. 그리고 TDD는 다른 어떠한 스타일의 프로그래밍에서와 같이 함수형 프로그래밍에서도 잘 동작한다. 아마 당신이 함수형프로그래밍을 적용한다면 버려야 할 몇가지 규칠들이 있을 것이다. 하지만 아직까지 난 발견하지 못했다.

* 본 글을 위 링크 게시물을 한국어로 번역/가공한 글입니다.

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

div#stuning-header .dfd-stuning-header-bg-container {background-color: #3f3f3f;background-size: cover;background-position: top center;background-attachment: initial;background-repeat: no-repeat;}#stuning-header div.page-title-inner {min-height: 350px;}