함수형 프로그래밍에서 함수

Posted on November 23, 2022

(미완성 글입니다.)
쓸모 없는 글일지, 어떨지 잘 모르겠습니다. 목표는, 함수의 숨은 뜻과 고차 함수를 활용하는 다양한 관용구idiom를 정리하는 것입니다. 고차 함수의 활용은 OOP의 GOF패턴 같은 패턴이라기보다, 그보다 더 기본에 가까운 도구들입니다. 이 도구들의 활용이 익숙해져야 각 종 고급 개념들을 보는데 도움이 됩니다.

단순히 무엇을 매핑한다는 정도의 함수 정의만 봐서는 금방 머리에 들어오지 않는 내용들입니다. 사실은 하스켈 말고 만지는 함수형 언어가 없어, 하스켈에서의 함수라 해야 맞긴 한데요. 작성하고 보니, 다른 함수형에도 해당하는 것 같아 제목을 지금처럼 바꿨습니다.

아직 아래처럼 정리한 내용을 본 적은 없지만, 비전공자가 해석 머리를 갖는데 중요한 역할을 하고 있습니다.

왜 값이 아닌 함수를 주고 받을까?

값으로 받지 않고, 함수로 주고 받았다는 건 어떤 의미가 있을까요?

준비 혹은 약속

함수란 아직 시작되지 않은 작업입니다. 인자로 트리거를 당겨야 작업이 시작됩니다.(잠시 Lazy 개념은 치워두고 생각하겠습니다.) 어떤 변수든 람다 헤드에 걸어두면binding 실행되지 않고 인자를 기다리게 됩니다.

순수 함수는 결과를 이어지는 함수가 받지 않으면 둘 곳도 없기 때문에, 미리 실행하면 안됩니다.

예를 들면, 지금 당장 사용할 수 있는 값을 주는 게 아니라, “어떤 작업이 끝나면 쓸 수 있는, 약속(함수)”만 주는 겁니다.

미래의 값을 당겨 쓰기

매개 변수는 미래와 연결되는 곳입니다. 함수를 정의할 때, 아직 인자argument와 바인딩 없이 매개 변수parameter를 사용합니다. 이렇게 사용한 매개 변수는 미래에 어느날 (함수를 실행할 때) 인자와 바인딩이 될 것입니다. 이런 방식의 생각이 도움이 될 때도 있습니다.

합성해도 같은 타입

다른 작업과 묶어서 하나의 작업처럼 처리할 수 있습니다. 함수를 받는 곳에는 여러 함수들을 합성composition한, 함수 엮음을 넘겨 줄 수도 있습니다. (String -> String) 함수를 받는 곳에 (Bool -> String) . (Int -> Bool) . (String -> Int) 이런 함수들도 넘길 수 있습니다.

캡슐화

값과 필요한 함수를, 즉 정보를 묶어서 다닐 수 있습니다.

say :: ((String -> String) -> String) -> (String -> String)
say needFunc = \name -> needFunc (\s -> name <> " says " <> s <> "!")
ghci> say (\f -> f "hi") "Jack"
"Jack says hi!"
ghci> say (   (\f -> f "hi")   (\x -> (\yf -> yf (x <> " everybody")))   ) "Jack"
"Jack says hi everybody"

ghci> :t say (   (\f -> f "hi")   (\x -> (\y -> y (x <> " everybody")))   )
say (   (\f -> f "hi")   (\x -> (\y -> y (x <> " everybody")))   )
  :: String -> String

원하는 만큼 함수도, 값도 안에 넣어 놓을 수 있습니다.

폴리모픽

타입 클래스와 함께 쓰면, 컴파일러가 알아서 적당한 구현을 골라주는 ad-hoc polymorphic을 구현할 수 있습니다.

class HasName a where
  sound :: String

data Dog
data Cat 

instance HasName Dog where
  sound = "멍"

instance HasName Cat where
  sound = "야옹"
ghci> sound @Dog
"멍!"
ghci> sound @Cat
"야옹!"

반복 작업 추상화

함수를 데이터 타입 안에 넣어버리면, 함수 합성을 할 때, 항상 특정 동작을 하도록 할 수 있습니다.

특별히 이름 붙인 타입이 없는 함수는 보통 특별한 동작 없이 합성합니다.

noTypeF1 :: Int -> Int
noTypeF1 n = n + 1

noTypeF2 :: Int -> Int
noTypeF2 n = n - 1

compNoType :: Int -> Int
compNoType = noTypeF2 . noTypeF1

만일 이런 류의 함수를 합성할 때, 항상 어떤 작업을 하고 싶다면 SomeType으로 감싸서, 앞으로 이 타입을 합성할 때는 특정한 일을 할거라는 걸(혹은 해야만 된다는 걸) 하스켈에게 알려줄 수 있습니다.

data SomeType a = SomeType (Int -> a)

someTypeF1 :: SomeType Int
someTypeF1 = SomeType noTypeF1

someTypeF2 :: SomeType Int
someTypeF2 = SomeType noTypeF2

예를 들어, 합성할 때마다, 첫 계산 이 후부터는 1000씩 보너스를 준다면

combinator :: SomeType a -> SomeType a -> SomeType a
combinator (SomeType f1) (SomeType f2) = SomeType $ \n -> f2 $ (f1 n) + 1000

이런 컴비네이터를 이용해 합성할 수 있습니다.

compSomeType = someTypeF2 `combinator` someTypeF1

별도의 타입을 만든다는 건, 그 타입만이 가지는 속성에 의존해서 돌아가는 함수(메소드)가 필요하기 때문입니다. 값이 됐든, 함수가 됐든 상관이 없습니다. 생성자로 감싸wrap 놓으면, 반드시 어떤 절차를 거쳐야만 벗길unwrap 수 있습니다.

또한, 타입 체킹하는데도 도움을 줍니다.

패턴 매칭을 위해 runSomeType을 만듭니다.

ghci> let runSomeType (SomeType f) = \n -> f n
ghci> runSomeType compSomeType 1
1001

클로저를 만들 수 있다.

@todo 감싼nested람다 함수 헤드에 있는 변수들을, 타 언어의 전역 변수나 한정된 모듈 혹은 블록에서 유효한 변수처럼 사용하는 예시 추가할 것

시간이 존재한다.

※ 꼭 현재와 비교해서 과거, 미래가 아니라 임의의 시간의 흐름에서 상대적으로 이 전에 위치하면 과거로, 나중에 위치하면 미래로 표현하겠습니다.
함수를 안에 가지고 있는 새로운 타입을 만나면 약간 불편했던 이유가 바로 시간입니다. 값은 정지한 시간이지만, 함수는 정의 시점에서는 미래의 값이 필요합니다. 미래가 현실이 되어 값이 들어오면, 그 값은 과거의 값이 되고, 결과가 새로운 현실의 값이 됩니다. 값과 함수의 근본적인 차이입니다. 절차형은 정지된 시간을 주로 다룬다면, 함수형은 흐르는 시간을 다룹니다.

전자는 값으로 확정지어 가면서 진행되지만, 함수형은 시작 시점(입력)과 종료 시점(출력)을 두고, 시작점 이전으로 가서 변형을 하든지, 종료 시점으로 가서 변형하든지 하며 시간의 흐름을 붙여 갑니다.

Some a만 있거나, Some (e -> a)일 경우, a는 함수의 시간에서 미래 위치에 있고, Some (a -> e)는 과거에 위치한다고 볼 수 있습니다. 과거에 위치한 것을 변형하기 위해선 contravariant functor를 써야하고, 미래에 위치한 것을 변형하려면 covariant functor를 써야 합니다. 시간은 과거에서 미래로 흐르고, 한 함수의 미래는 다른 함수의 과거와 만납니다.

함수는 가능한 모든 경우를 의미한다.

함수는 한 가지 길을 의미하는 게 아니라 가능한 모든 길을 의미합니다. 시간에 따른 가능한 경우의 길을 모두 확보한 후(시작점에서 이를 수 있는 모든 종료점을 모두 늘어 놓은 후에), 인자로 트리거를 당겨 함수의 시간이 흘러가도록 합니다. 함수의 시간을 시뮬레이션 해서 읽고 있는데, 자꾸 과거로 가서 뭔가를 바꿔야 하면 명령형, 즉 정지된 시간에 익숙한 사람에겐 낯선 상황입니다.

함수는 대상을 좁힌다.

세상에 모든 대상 중 일부만으로 제한하는 동작을 합니다.

@TODO 계속 함수의 속성을 추가할 예정입니다.

함수를 연결 연결해서 프로그램의 흐름을 만들어 내는 함수형에서는, 굉장히 중요한 특징들입니다. 순수형 함수만 쓸 수 있을 때는 더더욱 중요한 특징들입니다. 튜링 완전한 프로그램을 함수형으로 만들 수 있는 이유는, 역시 함수에 있습니다. 명령형 프로그래밍과는 완전 다른 개념들로 채워진 세계처럼 얘기하지만, 꼭 그렇지만은 않습니다. 예를 들면, 보통 함수형 프로그래밍 입문서들이, 한 번 정해지면 바뀔 수 없는 불변immutable 변수만 있는 것처럼 얘기하지만, 사실은 다른 형태로 동일한 목적을 달성하는 방법을 만들어 두었습니다.

조금 더 특징들이 손에 잡히면 같이 올리려 했는데, 지금 내용만으로도 충분히 중요하단 생각이 들어 미완성이지만 일단 올려 둡니다.

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com