gimmesilver's blog

Agbird.egloos.com

포토로그



C 프로그래머를 위한 하스켈 - 함수와 타입 하스켈 스프링노트

 하스켈은 정적 타입 언어이면서 강타입 언어이다. 정적 타입 언어라는 뜻은 변수나 함수의 타입이 컴파일 시에 명시적으로 결정된다는 뜻이다. C, C++, JAVA같은 언어가 대표적인 정적 타입 언어이다. 보통 정적 타입 언어는 컴파일 시에 타입이 결정되기 때문에 타입 오류를 컴파일 시간에 찾을 수 있는 장점을 갖는 반면 프로그래밍 시 타입을 일일이 명시해 줘야 하는 불편함이 있다고 알려져 있다. 하지만 하스켈은 정적 타입 언어이면서 강력한 타입 추론 시스템을 갖고 있기 때문에 C 언어처럼 변수나 함수의 타입을 명시적으로 지정해 줄 필요가 없다. 이전 장의 코드를 다시 살펴보면,

  1. System.IO
  2. main = do
  3.     putStrLn "What's your name?"
  4.     buf <- getLine
  5.     putStrLn ("Hello, " ++ buf)

4번째 줄에서 getLine 의 반환값은 String 타입이다. 따라서 buf 의 타입이 String 이 되어야 한다. 하스켈의 타입 시스템은 getLine의 타입을 통해 buf의 타입을 유추할 수 있으므로 프로그래머가 명시적으로 buf의 타입을 표시해 줄 필요가 없다. 때문에 마치 동적 타입 언어와 비슷한 편리함을 갖고 있다. 여기에 추가적으로 동적 타입 언어의 경우 모든 타입 검사가 실행 시간에 일어나기 때문에 타입 오류가 있는 코드가 실행 시점에 예외로 처리되지만 하스켈은 대부분의 타입 오류를 컴파일 단계에서 완벽하게 찾아내기 때문에 동적 타입 언어보다 더 안전한 프로그래밍이 가능하다. 즉, 동적 타입 언어에서 해줘야 하는 타입 검사용 테스트 코드가 전혀 필요없게 된다. 한편 하스켈은 강타입 언어이다. C 언어처럼 묵시적인 타입 변환을 전혀 허용하지 않는다. 때문에 의도하지 않은 타입 변환에 의한 오류의 가능성이 전혀 없으며 매우 안전하고 신뢰성 높은 프로그래밍이 가능하다.

 하스켈에는 많은 타입들이 존재한다. 이들 미리 정의된 타입 중 대표적인 타입들은 아래와 같다.

  • Boolean - 말그대로 참,거짓을 구분하는 타입, True, False 값을 갖는다.
  • Char - 문자 타입
  • String - 문자열 타입, 이것은 문자의 리스트인 [Char] 과 동의타입(synonym)이다. 리스트에 대해서는 나중에 자세히 설명하겠다.
  • Integer - 정수 타입, C 언어와 달리 제한 값이 없다.
  • Int - Integer와 같은 정수 타입이지만 -2^29 에서 2^29 - 1 까지의 범위를 갖는다. 대신 이 타입은 Integer 보다 좋은 성능을 갖는다.
  • Float, Double - 실수 타입, C와 비슷하다.

 이 외에 C 언어에 없는 하스켈만의 독특한 타입이 몇 개 더 있는데 그 중 대표적인 것이 리스트(List)와 튜플(Tuple)이다. 리스트는 동일 타입의 복수 개의 값을 표현할 때 사용하는 타입이다. 얼핏 C 언어의 배열과 비슷하지만 고정 사이즈가 아니다. 오히려 C++ 에 있는 std::vector<> 와 더 비슷하다. 리스트는 [1,2,3,4] 나 ['a'. 'b'. 'c'] 와 같이 표현된다. 위에서 잠깐 언급했듯이 String 은 문자 리스트인 [Char]이다. 따라서 "Hello, World!" 라는 문자열은 사실 ['H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd', '!'] 을 보기 편하게 바꾼 것이다. 실제 의미적으로 볼 때 둘은 완전히 같다. 튜플은 리스트와 달리 고정 사이즈이면서 여러 타입을 갖는 데이터를 표현할 때 사용한다. (1, 'a') 나 ('x', True, 3, 'y') 같은 식으로 표현한다. 둘 다 여러 개의 값을 반환하고자 할 때 유용하게 사용할 수 있다.


 하스켈의 함수는 C와 생김새가 많이 틀리다. Hello Haskell 에서 만들었던 프로그램 소스를 다시 살펴보자.

  1. // C
  2. #include <stdio.h>
  3. int main() {
  4.     printf("Hello, World!\n");
  5.     return 0;
  6. }
  7. -- Haskell
  1. import System.IO
  2. main = putStrLn "Hello, World!"

 하스켈에서는 함수 정의 시 반환 타입을 표시하지 않으며 앞서 언급했듯이 = 연산자를 사용해서 함수 이름과 함수 내용을 구분한다. 게다가 인자값을 주는 형태도 많이 틀리다. 예를 들어 두 수의 합을 계산하는 add 함수를 정의하면 다음과 같다.

  1. int add(int x, int y) { return x + y; } // C 함수
  2. add x y = x + y -- 하스켈 함수

 하스켈에서는 인자들을 괄호로 묶지 않고 쉼표(,)로 구분하지도 않는다. 단지 공백 문자로 구분한 첫 단어가 함수 이름이고 뒤에 나오는 단어들이 차례로 인자가 된다. 물론 앞서 말했듯이 함수 반환값이나 인자값의 타입을 명시해줄 필요가 없다. 여러분이 GHCi 라는 하스켈 인터프리터를 사용한다면 위 add 함수를 인터프리터 상에서 바로 정의해 줄 수 있다. 인터프리터 상에서 바로 함수를 정의할 때는 let 을 맨 앞에 붙여준다. 즉, 다음과 같이 하면 된다.

  1. let add x y = x + y

이제 add 2 3 이라고 하고 엔터를 치면 결과값인 5 가 출력될 것이다. 파일을 따로 만들어서 함수를 정의할 때는 let 을 빼고 정의한다. 이제 인터프리터 상에서 :t add 라고 입력하고 엔터를 쳐보자. 그럼 다음과 같은 결과가 출력될 것이다.

  1. add :: (Num a) => a -> a -> a

 :t 는 GHCi 인터프리터에서 제공하는 명령어인데 뒤에 나오는 함수의 타입을 출력해준다. 하스켈 함수의 타입은 C 함수 타입과 크게 다르지 않다. 가령 위 int add(int x, int y) 함수의 타입은 int (*)(int,int) 이다. C 언어에서 이 타입이 의미하는 뜻은 '반환값이 int 형이고 두 개의 int 형 인자를 받는 함수 타입' 이다. 하스켈 역시 함수 타입을 반환 타입과 인자 타입을 가지고 표현한다. 그런데 저 위에 나온 결과가 다소 낯설다. 어디가 반환 타입이고 인자 타입인지 헷갈릴 것이다. 우선 맨 앞에 add 는 당연히 함수 이름을 뜻한다. :: 는 그 뒤에 나오는 것들이 add 의 타입을 뜻한다는 구분자이다. (Num a) 란 앞으로 나올 a 라는 문자가 Num 이라는 타입을 의미한다는 뜻이다. 그리고 a -> a -> a 는 앞에서부터 차례로 인자 타입을 나타내며 마지막 a 가 반환 타입이 된다. 다시 말하면 위에 'add :: (Num a) => a -> a -> a' 가 의미하는 것은 'add 라는 함수는 두 개의 Num 타입 인자를 받아 Num 타입 결과를 반환하는 함수' 라는 뜻이다. 실상 'add :: Num -> Num -> Num' 이라고 해도 될 것을 굳이 헷갈리게 'add :: (Num a) => a -> a -> a' 이라고 했냐? 고 반문한다면 여기에는 아직 여러분이 이해할 수 없는 고매한 뜻이 담겨 있기 때문이라고 밖에 말할 수 없다. 실제로 '(Num a) =>' 라는 표기에는 정말 심오한 의미가 담겨 있다! 이에 대해서는 나중에 자세히 알아보도록 하겠다. 어쨌든 함수의 타입은 다음과 같은 형식으로 이루어져 있다고 생각하면 된다.

함수 이름 :: (타입 대리자) => 타입 -> 타입 -> ... -> 타입

 여러분은 단지 마지막 타입은 반환 타입이고 그 앞에 있는 것들은 인자 타입이구나...라고 생각하면 된다. 만약 인자가 하나도 없는 함수라면 당연히 함수의 타입은 결과 타입과 같다. 그럼 반환값도 없는 함수는 어떻게 하나? 예를 들어 C 언어에서 void foo(void) 같은 함수말이다. 하스켈에서는 결과값이 없는 함수란 존재하지 않는다.자고로 함수란 어떤 일이든 해야 하는데 부수 효과(side effect)가 없는 하스켈에서는 결과값이 없다는 말은 아무 일도 하지 않는다는 뜻이므로 그런 함수를 정의하는 것을 허용하지 않는다. 물론 예외가 있긴한데 대표적인 예가 입출력 작업을 수행하는 함수이다. 입출력 작업은 실제로 어떤 결과값을 반환하지 않더라도 의미있는 작업을 수행할 수 있다. 가령 콘솔에 'Hello, World!' 따위의 문자열을 출력하는 함수는 결과값을 반환하지 않는다. 이런 함수는 앞서 언급했듯이 IO () 라는 타입을 갖는다.

참고로 C 에서는 한 줄 주석을 // 로, 블럭 주석을 /- ... *- 로 표시하지만 하스켈에서는 한 줄 주석을 -- 로, 블럭 주석을 {- ... -} 로 표시한다.

 여기서 한 가지 의문점이 생길지 모른다. '왜 하스켈의 함수 타입은 C 언어의 함수처럼 인자 타입과 반환 타입이 명확하게 구별되어 있지 않을까?' 라는 것이다. 그 이유는 하스켈 함수의 매우 특이한(동시에 매력적인) 성질 때문이다. 앞서 언급했듯이 하스켈에서 모든 것은 함수로 이루어져 있다. 따라서 인자도 함수이고 결과값도 함수이다. 이것은 단지 상징적인 특성이 아니다. 정말로 하스켈의 함수는 함수를 인자로 받을 수 있고 함수를 결과값으로 반환할 수 있다! 예를 들어 위에 add 함수는 '두 개의 인자를 받아 더한 값을 반환하는 함수'라고 생각할 수도 있지만 한편으로는 '하나의 인자 x를 받아 또 다른 인자 y를 받아 x와 더한 값을 반환하는 함수를 반환하는 함수'라고 생각할 수도 있다! 헷갈리는가? 다시 말하면 add x y = x + y 라는 함수는 x와 y 를 입력값으로 받아 x+y 값을 반환하는 함수이기도 하지만 한편으론 x 값을 받아 (x+) 라는 함수를 반환하는 함수이기도 하다. 의심스러운가? 그러면 여러분의 인터프리터에서 다음과 같은 함수를 정의해보기 바란다.

  1. let inc = add 1

 이제 inc 4 라고 하면 5 가 출력될 것이다. C 언어로 표현하자면 다음과 같다.

  1. int inc(int y) {
  2.     return add(1, y);
  3. }

 정확히 말하자면 하스켈 함수는 인자를 하나 받아서 다음 인자를 처리할 수 있는 함수를 반환하는 구조로 되어 있다. 그러니까 위의 add 함수는 원래 첫번째 인자만을 받아서 나머지 인자를 받아 처리할 함수(위의 inc 함수)를 반환하며 이 때 반환된 함수가 다시 두 번째 인자를 받고서 결과값을 반환하는 것이다. 이런 함수를 '커리(curry) 함수'라고 부른다. 즉, 하스켈의 함수들은 모두 커리 함수이다. 쓸데없이 복잡해 보이지만 이런 하스켈의 특성은 함수를 재활용하기 쉽게 해준다(당장 위 코드 예를 보더라도 우리는 add 함수를 사용해서 inc 함수를 굉장히 쉽게 정의할 수 있었다).


덧글

  • I\'m 2 2015/03/22 14:55 # 삭제 답글

    학교에서 하스켈에 대해 배우고있는데 검색을 통해 들어왔습니다.
    헷갈리는 부분이 잘 정리되어있어서 잘 공부하고 갑니다^^!
    감사합니다
  • gimmesilver 2015/04/29 14:50 #

    네 오래된 글인데 도움이 되셨다니 좋네요. ^^
댓글 입력 영역