함수형 프로그래밍

함수형 프로그래밍은 프로그래밍 패러다임 중 하나다.

클로저는 함수형 프로그래밍을 기반으로 하고 있다.

시간이 흐름과 조건에 따라 상태를 바꿔 결과를 내는 명령형 패러다임과는 다르게 함수의 조합으로 결과를 표현하는 방식이다.

실제로 함수 조합은 단순하고 확장성이 뛰어나기 때문에 특별한 규칙없이 함수 조합만으로 숫자를 표현하거나 사칙 연산을 하거나 분기문, 반복문등을 표현할 수 있다.

하지만 함수형 프로그래밍 언어들은 연산 함수, 분기 처리, 함수 재귀등을 제공하기 때문에 람다 계산법을 이해하지 못해도 쉽게 프로그래밍을 작성할 수 있다.

그래도 함수형 프로그래밍을 잘 하려면 람다 계산법과 함수형 프로그래밍의 많은 기법들을 익혀야 복잡한 프로그램을 더 잘 표현할 수 있다.

이 글은 클로저를 처음 접하는 사람들을 위한 글이기 때문에 함수형 프로그래밍 기법을 다루지 않고 명령형 프로그래밍에서 익숙한 상태 변경과 반복문을 함수형 프로그래밍에서 어떤 식으로 표현하는지 알아본다.

함수형 프로그래밍을 알고 싶다면 먼저 람다 계산법을 익힌 후 SICP 책과 하스켈 언어와 같은 것을 공부하면 도움이 될 것 같다.

변경 불가능한 데이터

함수형 프로그래밍에서는 상태의 개념이 없기 때문에 클로저는 값을 변경할 수 없다.

user=> (def user {:id 1 :name "eunmin" :level 10})
#'user/user
user=> (update-in user [:level] inc)
{:name "eunmin", :level 11, :id 1}
user=> user
{:name "eunmin", :level 10, :id 1}
user=> (update-in user [:level] inc)
{:name "eunmin", :level 11, :id 1}
user=> (update-in user [:level] inc)
{:name "eunmin", :level 11, :id 1}

위 예제는 user라는 이름에 바인딩된 맵에 :level 값을 1 증가시키는 예제다.

update-in 함수는 첫번재 파라미터로 맵을 두번째 파라미터로 변경할 값을 가지는 키 벡터를 세번째 파라미터로 변경할 값을 리턴하는 함수를 받는다.

마지막 파라미터는 하나의 파라미터를 가지는 함수인데 원래 값이 인자로 넘어온다.

update-in 함수로 맵에 있는 값을 변경했지만 변경된 새로운 맵을 리턴하고 원래 user 값은 그대로 있다.

클로저의 변경 함수들은 기존 값을 변경하지 않고 새로운 값을 리턴한다.

크기가 큰 리스트 값의 일부가 바뀌어도 새로운 값이 리턴된다면 메모리가 낭비가 우려될 것이다.

하지만 클로저는 내부적으로 변경되지 않은 값은 원래 값을 참조하도록 설계되어 있어 걱정하지 않아도 된다.

새로운 값에 바인딩

4장 Var를 다룰 때 설명 했던 것처럼 Var에 새로운 값을 바인딩 한 것을 값이 바뀌었다고 착각하면 안된다.

user=> (def a 1)
#'user/a
user=> a
1
user=> (def a 2)
#'user/a
user=> a
2

위의 예는 a값이 바뀐것이 아니고 a 심볼과 1 값을 바인딩 했다가 a 심볼과 2 값을 바인딩 한것이다.

진짜 값의 변경

클로저에서 진짜 변경이 필요한 경우가 있다.

바로 병렬 처리를 할 때 인데 이때는 여러 워커에서 하나의 데이터를 변경해야 할 필요가 있다.

클로저는 공유되는 값을 안전하게 바꿀 수 있는 다양한 기능을 제공하는데 이것은 나중에 병렬처리에서 다룬다.

반복문

함수형 프로그래밍에서는 명령어를 순차적으로 실행하지 않기 때문에 '이것 다음에 저것이 실행되라'와 같은 개념이 없다. 따라서 반복문이라는 개념이 없다.

대신 대부분의 언어에서 제공하고 있는 꼬리 재귀 함수를 통해 함수를 여러번 적용하는 것으로 반복문을 표현한다.

보통 반복문에서 처리하는 데이터는 반복이 될 때마다 값을 변경해서 데이터를 처리하는데 클로저는 값을 변경하는 방법이 없기 때문에 파라미터를 이용해 변경된 값을 전달하는 것으로 표현한다.

user=> (def user {:id 1 :name "eunmin" :level 10})
#'user/user
user=> (defn inc-level [user data]
  #_=>   (if (empty? data)
  #_=>     user
  #_=>     (inc-level (update-in user [:level] #(+ (first data) %)) (rest data))))
user=> (inc-level user [1 5 1 2])
{:name "eunmin", :level 19, :id 1}

위 예제는 user 데이터에 :level을 증가시키는 예제다.

먼저 예제와 다른 점은 증가 값을 여러개 받는다는 점이다.

물론 실제 프로그램에서는 받은 값을 모두 더해서 한번에 증가시키겠지만 반복이라는 예제를 설명하기위해 데이터 개수 만큼 반복하게 작성했다.

예제에서 inc-level 함수 안에서 inc-level 함수를 썻다.

안에서 쓴 'inc-level` 함수는

첫번째 파라미터에 data이 들어온 첫번째 값에 기존 :level값을 더한 user를 넘기고

두번째 파라미터에 첫번째 항목을 없앤 data를 넣어 부른다.

그리고 data가 비었다면 user를 리턴한다.

재귀 함수는 중첩해 부르기 때문에 깊어지면 스택이 많이 쌓여 스택 오버플로어가 발생할 수 있다.

꼬리 재귀는 내부적으로 반복문으로 변경하는 최적화 작업을 할 수 있는데 클로저는 recur 함수가 그 기능을 한다.

그래서 보통 클로저에서 꼬리 재귀를 사용할 때는 recur 함수를 쓴다.

(defn inc-level [user data]
  (if (empty? data)
    user
    (recur (update-in user [:level] #(+ (first data) %)) (rest data))))

위의 예제에서 안쪽에 inc-level 대신 recur로 바꿔주면 된다.

시퀀스 함수

클로저는 다양한 시퀀스 함수가 있다.

클로저에서 재귀 함수들로 처리해야하는 것들 대부분은 시퀀스 함수로 처리할 수 있기 때문에 재귀 함수를 사용할 일은 많지 않다.

(def users [{:id 1 :name "eunmin" :level 2}
            {:id 2 :name "alan" :level 100}
            {:id 3 :name "alonzo" :level 150}])

(defn up-level [init-users]
  ((fn [result users]
      (if (empty? users)
        result
        (recur (conj result (update-in (first users) [:level] inc))
          (rest users))))
   [] init-users))

user=> (up-level users)
[{:name "eunmin", :level 3, :id 1} {:name "alan", :level 101, :id 2} {:name "alonzo", :level 151, :id 3}]

위 예제는 users 벡터에 있는 모든 유저 레벨을 1 올리는 예제다.

위에서 conj 함수는 벡터 뒤에 항목을 추가한다.

재귀 함수를 사용해 users의 모든 항목에 update-in 함수를 적용해 :level을 1 증가시켰다.

위 예제를 시퀀스 함수를 쓰도록 바꾸면 아래와 같다.

(defn up-level [users]
  (map #(update-in % [:level] inc) users))

시퀀스 함수를 쓴 것이 더 간결하기 때문에 반복되는 일은 대부분 시퀀스 함수를 써서 표현한다.

순수한 함수와 부수 효과

함수형 프로그램에서 함수는 하나의 일만 해야한다.

그 말은 함수 안에 하나의 구문만 있어야 한다는 말이다.

user=> (defn add [x y]
  #_=>   (+ x 1)
  #_=>   (+ y 1)
  #_=>   (+ x y))
#'user/add
user=> (add 1 1)
2

위 예처럼 클로저 함수는 여러 구문을 쓸 수 있다.

하지만 마지막 구문이 리턴되는 값이기 때문에 중간에 쓴 구문은 아무 의미가 없다.

값도 변경할 수 없기 때문에 중간 결과를 어디에 담을 수도 없다.

따라서 클로저 함수는 단 하나의 구문만 의미 있다.

하지만 예외가 있다.

바로 부수효과라고 부르는 일들이 함수 안에서 일어나게 되면 여러 구문이 의미 있게 된다.

부수효과는 외부 환경에 영향을 주는 일을 말한다.

데이터베이스와 같은 외부 환경에 명령문을 실행하거나 모니터 화면과 같은 외부 환경에 문자를 출력 하는 일이 모두 부수효과 다.

다음은 함수 중간에 화면에 출력하는 일을 하는 함수다.

user=> (defn add [x y]
  #_=>   (println "x:" x "y:" y)
  #_=>   (+ x y))
#'user/add
user=> (add 1 1)
x: 1 y: 1
2

다음은 데이터베이스에 insert 문을 실행하고 추가한 user를 리턴하는 함수 예제다.

user=> (defn insert-user! [db-spec user]
  #_=>   (jdbc/insert! db-spec :users user)
  #_=>   user)

부수 효과가 없는 함수를 순수 함수라고 하는데 순수 함수는 여러번 불려도 입력 값이 같다면 프로그램에서 문제가 되지 않고 항상 같은 결과가 나온다.

하지만 부수 효과가 있는 함수는 여러번 불리면 다른 결과가 생길 수 있다.

특히 외부 환경을 변화 시키는 함수는 문제가 심각해 질 수도 있다.

이런 함수들은 사용에 주의하기 위해 이름뒤에 !를 붙이다.

부수효과는 상태 변경과 같아서 프로그래밍 복잡해지면 상태가 어떻게 변하고 있는지 추적하기가 힘들다.

또 메모이제이션과 같은 다양한 함수형 프로그래밍 기법들을 쓰는데 제약이 생긴다.

하지만 부수 효과 없이는 프로그램이 아무 의미가 없기 때문에 부수 효과가 없는 프로그램은 없다.

그래서 함수형 프로그래밍에서는 부수 효과가 있는 함수와 부수 효과가 없는 순수한 함수를 잘 나눠 짜는 것이 중요하다.

results matching ""

    No results matching ""