gimmesilver's blog

Agbird.egloos.com

포토로그



C 프로그래머를 위한 하스켈 - Hello Haskell 하스켈 스프링노트

 C 언어를 배운 사람들은 누구나 다음과 같은 코드를 기억할 것이다.

  1. #include <stdio.h>
  2. int main() {
  3.     printf("Hello, World!\n");
  4.     return 0;
  5. }

 위와 동일한 결과를 출력하는 하스켈 코드는 아래와 같다.

  1. import System.IO
  2. main = putStrLn "Hello, World!"

 와~ 더 짧고 간단하다! 이미 C 코딩을 해본 사람이라면 위에 있는 하스켈 코드를 이해하는데 큰 무리가 없을 것이다.

 import 는 C언어의 #include 와 비슷하다. 아니 좀더 정확하게 말하자면 자바의 import 와 더 비슷하다. C에서 #include 는 실제 해당 모듈을 연동하는 것이 아니라 헤더 파일을 참조할 뿐이다. 실제 연동은 링크 단계에서 이루어진다. 하지만 하스켈은 소스 파일과 헤더 파일이 분리되어 있지 않기 때문에 import 를 사용해서 바로 해당 모듈을 연동한다. System.IO 는 C의 stdio.h 처럼 입출력에 관련된 표준 함수들을 미리 정의해 놓은 표준 라이브러리 모듈이다. 따라서 앞으로 입출력 작업을 한다면 C에서 그랬듯이 항상 import System.IO 를 덧붙이면 된다.

 하스켈 역시 시작은 main 이다. 즉, C처럼 하스켈도 진입함수(entry point)의 이름이 main 이다. 하스켈에는 컴파일러와 인터프리터가 둘다 제공되는데 인터프리터를 사용할 때는 상관이 없지만 컴파일을 해서 실행 파일로 만드려면 항상 main 함수를 정의해야 한다. C에서는 main 함수의 반환타입이 int 이지만(컴파일러에 따라 void도 사용할 수 있지만 표준에서는 int 만을 허용한다.) 하스켈에서는 IO () 라는 다소 낯선 타입이 반환 타입이다. 이에 대해서는 나중에 자세히 설명하겠지만 지금 간단하게 말하자면 IO () 란 main 함수가 입출력 작업을 수행하며 아무런 값도 반환하지 않는다는 뜻이다. 하스켈에서 main 은 언제나 IO () 타입이다.

 putStrLn 은 이름에서 짐작할 수 있듯이 문자열(Str)을 출력(put)하고 줄바꿈(Ln)을 하는 함수이다. 자바를 해본 사람이라면 System.out.println() 함수를 떠올리면 되겠다.

 이번엔 좀 더 난이도를 높여보도록 하자. 표준 입력으로 사용자의 이름을 입력 받아 Hello, <사용자 이름>! 을 화면에 출력하는 것이다. C 로 만들면 아래와 같다.

  1. #include <stdio.h>
  2. int main() {
  3.     char* buf[1024];
  4.     printf("What's your name?\n");
  5.     scanf("%s", buf);
  6.     printf("Hello, %s\n", buf);
  7.     return 0;
  8. }

 하스켈 코드는 다음과 같다.

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

 역시 간단하다! 게다가 여기 사용된 함수이름이 매우 직관적이다보니 코드를 이해하는데 별다른 어려움이 없을 것이다. getLine 은 말그대로 표준 입력에서 한줄을 읽어서 반환하는 함수이다. 앞서 '먼저 알아두어야 할 것들'에서 언급했듯이 하스켈에서 = 연산자는 할당의 의미가 아니라 정의한다는 뜻이다. getLine 함수처럼 외부 입력값을 저장해야 할 때는 <- 연산자를 사용한다. 따라서 눈치가 빠른 사람이라면 buf <- getLine 은 표준 입력에서 한줄을 받아 buf 에 저장한다는 의미라고 생각할 수 있다. 그런데 역시 '먼저 알아두어야 할 것들'에서 언급했듯이 하스켈에는 변수에 값을 할당한다는 개념이 없다. 나중에 좀더 자세히 알게 되겠지만 buf <- getLine 은 엄밀히 말하면 buf 에 값을 저장하는 것이 아니라 'getLine의 결과값을 임시로 buf 라고 부르자' 라는 뜻에 더 가깝다. 일종의 별칭(alias)인 셈이다. 따라서 buf 에 다시 다른 함수의 값을 저장할 수 없다. 오직 buf는 getLine의 결과를 대신할 뿐이다. 정리하자면 'A <- B' 라고 하면 이것은 'B 함수의 결과를 A 라고 하자' 라는 뜻이다.

 마지막 줄에 보면 "Hello, " ++ buf 라는 문장이 있다. ++ 라는 연산자는 (대충 짐작했겠지만) 두 문자열을 합쳐서 하나의 문자열로 만들어 준다. 이 때 괄호로 묶어준 이유는 (역시 눈치챘겠지만) 우선 순위에 의해 putStrLn 보다 먼저 실행하기 위해서이다. 만약 괄호로 묶어주지 않으면 putStrLn "Hello" 가 먼저 실행되고 나서 그 결과값과 buf 를 ++ 연산자에 적용하게 된다. ++ 연산자는 두 개의 문자열을 인자로 받아야 하는데 putStrLn 함수의 반환 타입은 main 처럼 IO () 타입이다. 따라서 타입이 맞지 않기 때문에 컴파일 단계에서 타입 에러가 발생한다.

 타입 에러 말이 나왔으니 하는 말인데 하스켈은 C, C++, JAVA 처럼 정적 타입(Static Typing) 언어이다. 모든 함수들은 타입을 가지고 있다. 게다가 강 타입(Strong Typed) 언어이기도 하다. C++나 JAVA 보다 타입 간 변환을 훨씬 엄격하게 제한한다. 타입간의 암묵적인 변환을 '전혀' 허용하지 않는다. 하지만 그럼에도 불구하고 대부분의 경우 프로그래밍 시에 타입에 대해 거의 신경을 쓰지 않아도 된다. 왜냐하면 하스켈은 매우 훌륭한 타입 추론 시스템을 가지고 있기 때문이다. 그래서 특별한 경우를 제외하고는 컴파일러에 의해 해당 함수나 변수의 타입이 자동으로 추론된다. 위의 코드에서도 buf 가 getLine 함수의 결과값을 대신하는 대리자 역할을 하며 getLine의 결과 타입이 String 이기 때문에 프로그래머가 굳이 buf의 타입이 String 이라고 선언하지 않더라도 컴파일러가 자동으로 String 타입이라는 사실을 추론할 수 있다. 때문에 마치 파이썬이나 루비같은 정적 타입 언어처럼 편하게 인자값을 사용할 수 있으면서도 잘못된 타입 적용 시에는 컴파일 단계에서 자동으로 오류를 검출할 수 있다(동적 타입 언어처럼 테스트 코드를 작성할 필요가 없다).

 마지막으로 do 에 대해서 알아보자. do 는 간단하게 말하면 앞으로 나오는 함수들을 차례로 실행하라는 뜻이다. C에서는 세미콜론(;)으로 각 문장(statement)를 구분하며 이렇게 구분된 문장들은 특별한 제어구문이 없다면 코드에 적힌 순서대로 실행된다. 하스켈에서는 이런 순차적인 실행을 하지 않는다. 하스켈의 함수들은 단지 해당 함수의 결과값이 필요한 시점에서만 실행된다. 이것을 지연 평가(Lazy Evaluation) 이라고 한다. 자세한 사항은 좀 복잡하니 나중에 설명하도록 하고 어쨌든 위 코드처럼 여러 문장을 순서대로 실행하려면 함수 앞에 do 를 붙여야 한다는 사실을 기억하기 바란다.


덧글

댓글 입력 영역