최근 수정 시각 : 2024-11-03 15:58:35

Haskell/특징


파일:상위 문서 아이콘.svg   상위 문서: Haskell
파일:하위 문서 아이콘.svg   하위 문서: Haskell/특징/모나드
,
,
,
,
,
#!wiki style="display: inline; display: none;"
, }}}
1. 개요2. 커링(Currying)
2.1. 파이썬에서2.2. 하스켈에서2.3. 대수학에서
3. 순수 함수형 언어4. 느긋한 계산5. 강력한 타입 추론6. 대수적 데이터 타입7. Parametric polymorphism8. 모나드
8.1. 예제8.2. 범주론에서8.3. 실용적 관점8.4. 다른 언어 사례
8.4.1. JavaScript
9. 기타

1. 개요

이 문서에서는 하스켈의 특징을 설명한다.

메인스트림 언어인 C/ C++/ C#/ Java/ Python 등은 각 언어마다의 특징들을 갖고 있긴 하지만, 사실 언어적으로 과거 사용되던 언어들에 비해 큰 혁신을 가져와서 유명해진 언어들은 아니다. 컴퓨터 언어적 관점에서 혁신과 함께 어느 정도 유명세를 탄 언어는 저들보다는 차라리 Fortran, Simula, Lisp, Prolog 정도를 꼽을 수 있고 Haskell은 Purity, Lazy evaluation, Monad 등 당시 기준으로는 좀 극단적으로 볼 수 있는 언어 디자인으로 유명하였다. 요즘에는 Purity(Immutable), Lazy evaluation 등은 Apache Spark의 주요 특징으로서 내세우는 등 극단적이라고까지 보기는 어려워졌다.

2. 커링(Currying)

커링은 여러 인자를 받는 함수를 각각 단일 인자를 받는 일련의 함수로 변환하는 기술이다.[1]

2.1. 파이썬에서

파이썬 람다로 예를 들면 아래와 같다.
#!syntax python
# 아래와 같이 인자 두 개를 받는 함수가 있다.
add = lambda x, y: x + y

# 위 함수를 아래와 같이 바꾸는 것을 커링이라고 한다.
curried_add = lambda x: lambda y: x + y

# 반대로 하는 것은 언커링(uncurrying)이라고 한다.
이때 add에는 꼭 인자 두 개를 다 넣어야 한다. 그렇지 않고 인자를 한 개만 넣으면 에러가 발생한다.
>>> add(1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: <lambda>() missing 1 required positional argument: 'y'
반면에 curried_add에는 인자를 한 개만 넣어야 한다. 이 함수에 인자를 한 개 넣으면 남은 인자를 받는 새로운 함수가 리턴된다.
>>> inc = curried_add(1)
>>> inc(2)
3
이처럼 부분 적용(partial application)은 여러 인자를 받는 함수에 일부 인자만 넣은 것을 말하는데 파이썬에서는 functools 모듈의 partial 함수를 이용해서 만들 수 있다. 예를 들어 위에서 만든 함수 add에 첫 번째 인자로 숫자 1만 넣은 새로운 함수 inc라는 부분 적용 함수를 만들려면 아래와 같이 한다.
#!syntax python
from functools import partial

inc = partial(add, 1)
inc에 아무 숫자나 넣으면 그 수에 1을 더한 값이 나온다.
>>> inc(2)
3
커리(curried) 함수는 부분 적용이 편하기 때문에 함수를 재활용하기 좋다.

2.2. 하스켈에서

하스켈에서는 파이썬에서와 같이 partial이나 일부러 람다를 이용하지 않아도 함수가 커리 함수로 만들어진다. add 함수를 아래와 같이 만들 수 있다.
add x y = x + y
하스켈 인터프리터 GHCi에서 아래처럼 사용할 수 있다.
ghci> add 1 2
3
이때 add에 인자 한 개만 적용해도 에러가 발생하지 않고 알아서 부분 적용 함수가 리턴된다.
ghci> inc = add 1
ghci> inc 2
3

수학/논리학자인 하스켈 커리(Haskell Curry)는 오늘날 함수형 언어에서 가장 중요한 컨셉 중 하나로 볼 수 있는 커링으로 유명한데, 이 커링은 사실 하스켈만의 특징이라기보다는 거의 모든 함수형 언어가 공유하는 특징이기도 하다. 커링은 다인자 함수의 변수(parameter)를 쪼개기로 생각하면 간단하다. 즉, 다변수 함수(여러 개의 값을 묶어서 한꺼번에 입력으로 받는 함수)를 일변수 함수(값을 하나씩 입력으로 받는 함수) 여러개로 쪼개는 것이다.(실제로 하스켈은 함수의 파라미터를 하나만 선언할 수 있다. 그 이상의 갯수의 파라미터를 가진 함수는 배후에서 커링으로 처리된다.)

예를들어, f(x, y)라는 함수를 g_x(y)로 바꾸는것이다. 여기서 g_x 함수는 이름처럼 고정된 게 아니라, x 값에 따라 정해진다. 따라서 g_x(y)에는 y를 받아 결과값을 돌려주는 g_x(y) 함수와, x를 받아 g_x 자체를 돌려주는 함수라는 2개의 일변수 함수가 중첩되어 들어 있다. 혹은 (g(x))(y)라고 생각해도 된다.

보다 구체적인 예를 들어 보자. 3 * 2에서 *를 두개의 인자를 받는 함수라고 생각하고 보통 함수와 같은 방식으로 쓴다면 *(3, 2)이라고 쓰면 될 것이다. 즉 * 함수가 32의 쌍을 한번에 받아서 6을 돌려주게 되는 것이다. 그런데, 입력값을 받아서 그 입력값의 3배를 돌려주는 것도 함수이므로, 그 함수를 (*3)이라고 표시해 보자. 그러면 (*3)(2) 라는 표현도 가능하다. 같은 방법으로, 입력값을 받아서 그 입력값의 4배를 돌려주는 함수를 (*4)라고 표시한다면, (*4)(3) 같은 식도 가능해진다. 이런 식으로 일반화해서 입력값을 받아서 그 입력값의 m배를 돌려주는 함수를 (*m)이라고 써 보자.

그러면 아래와 같은 일반식이 성립한다.
*(m, n) = (*m)(n)
이 식의 우변에 일변수 함수 1개가 있다고 생각하기 쉽지만, 그렇지 않다. 왜냐하면 *는 m을 입력으로 받아서 (*m)이라는 함수를 출력으로 돌려주는 함수이기 때문이다. 따라서 좌변에는 이변수함수 *, 우변에는 일변수함수 *(*m)이 있다고 보아야 한다.[2]

이런 식으로, 모든 이변수함수는 일변수함수 하나와 그 일변수함수를 결과값으로 내놓는 다른 일변수함수의 조합으로 분해가 가능하다. 이런 것을 커링이라고 하며, 타입을 이용하여 다음과 같이 생각해 볼 수도 있다.

*(m, n)은 아래와 같은 타입을 가진다.
(Int, Int) -> Int
이는 * 함수가 정수의 쌍을 받아서 정수를 돌려주는 함수라는 의미이다.

한편 (*m)(n)의 타입은 아래와 같다.
Int -> (Int -> Int)
이는 * 함수가 정수를 받아서 함수를 돌려주는 함수인데 *가 돌려준 함수는 정수를 받아서 정수를 돌려주는 함수라는 것을 의미한다.(이처럼, 함수의 출력이 함수가 되는 것을 얼마나 적절히 활용할 수 있느냐가 하스켈로 얼마나 괜찮은 코드를 짤 수 있는가를 말해주는 한 요소가 된다.)

이렇기 때문에, 하스켈에서는 그냥 * 3 2 라고 쓰고, *3 사이에서 끊어 읽으면 * (3, 2) 가 되고 32 사이에서 끊어 읽으면 (*3)(2)가 된다는 식으로 처리한다. * 3 2라는 표기 자체가 커링을 반영하고 있으며, 이런 커링 덕에 여러 가지 함수를 사용해서 코드를 짜면서도 코드가 간결해지고, 풍부한 의미를 담을 수도 있게 된다.(여러 개의 함수를 사용하면서 f(x), g(x, y)와 같은 방법으로 쓴다면 앞 주석의 (*(m))(n)처럼 괄호가 한없이 불어나게 마련인데, 커링 덕에 괄호를 최대한으로 생략하면서도 코딩할 수 있게 된다. 따라서 여러 개의 함수를 사용하더라도 덜 복잡하게 보이고, 따라서 여러 가지 함수 여러 개를 쓰고 싶은 만큼 마음껏 쓰는 것도 할 만해진다. 덧붙여 위의 * 3 2는 그냥 설명을 위한 예시이고, 실제 하스켈에서는 (*) 3 2로 쓰거나 더 간단하게 *을 중위 연산자로 취급하여 3 * 2로 표기한다.

커링은 타입 추론 알고리즘에 의해 자동으로 수행되므로, 프로그래머가 골치썩일 필요 없이 알아서 적용된다. 커링은 언뜻 보기에 별 쓸모 없어 보이지만, 사실 이게 빠진 함수형 언어는 포인터 없는 C 언어라 봐도 무방할 정도로 핵심적인 위치를 갖는다. 그래서 언어 이름도 하스켈

2.3. 대수학에서

커링과 연관된 집합론적 내용도 생각해 볼 수 있는데, 다음과 같다. [math(A \times B)]를 앞자리는 [math(A)]의 원소, 뒷자리는 [math(B)]의 원소인 순서쌍의 집합이라고 정의하고 [math(D^C)]를 [math(C)]에서 [math(D)]로의 함수의 집합으로 정의하면, [math(C^{A \times B})]와 [math({C^B}^A)] 사이에는 매우 적절한 일대일 대응이 존재한다. 이에서 집합의 기수 또는 농도(Cardinal number, cardinality)의 지수법칙의 일부도 증명된다.

3. 순수 함수형 언어

보통 순수 함수형 언어의 가장 큰 특징으로는 부작용(Side Effect)이 없는 것을 꼽는다. 하지만, 사실 이것은 함수형 언어보다는 순수 선언형 언어(Declarative language)의 특징에 가깝다. 함수형 언어는 순수 선언형 언어의 하위 카테고리라 볼 수 있다.

순수 함수형 언어에서 함수는 수학에서의 함수와 같아서 부작용(또는 부수효과)이 없는 것을 의미한다. 즉, 순수 함수형 언어에서 함수의 결과값은 파라미터(parameter)로 넘겨진 입력값에 의해서만 결정된다. 여기서 말하는 부작용이란, 같은 입력임에도 불구하고 출력이 달라지는 것을 말한다. 예를 들어 자동차를 클래스로 구현했을 때, 주어진 파라미터만큼 자동차의 속도를 증가시키고 현재 속도를 출력하는, 절차지향적으로 작성된 accelerate() 함수를 가정해 보자. 예를 들어, accelerate(10)는 자동차의 속도를 10 증가시킨다. 자동차가 정지해 있을 때 accelerate(10)이 호출되면 자동차의 속도는 10 증가하므로 현재 속도는 10이고, 따라서 현재 속도인 10이 출력된다. 이때 한 번 더 accelerate(10) 함수를 호출하면 출력값은 20이 된다. 이처럼 state를 가지는 상태머신의 경우, 출력값은 입력 뿐만 아니라 머신의 상태 역시 출력을 좌우할 수 있다. 즉, 같은 입력인데도 결과가 달라지는 것이 바로 부작용이다.

하지만 순수한 함수들로 구성된 프로그램은 부작용이 없어 실행 순서의 영향에서 자유롭기 때문에 병행성을 가지기 좋다. 또한 루프문이 없고 제어문도 아주 적으며, 대부분의 경우 제어문을 사용할 필요가 없다. 대부분의 분기는 패턴 매칭과 가드에 의해 만들어진다. 그래서 OOP 쓰던 사람이 Haskell 처음 입문하면 정말 속터진다.

하스켈은 System.IO.Unsafe 모듈의 unsafePerformIO 등을 제외하면 순수 함수형 언어로 규정할 수 있다. 하스켈 위키에서는 아예 함수 unsafePerformIO를 'The dark side of I/O monad' 단락에 포함시켜 놨다(...).[3] 이러한 것들은 시스템 레벨로 뭐 할 때 빼고는 누구도 안 쓴다. 사실 일반적인 프로그램에서 쓰면 안 되는 함수들이다.

4. 느긋한 계산

느긋한 계산(Lazy Evaluation)은 계산이 필요한 순간까지 계산을 미루어 둔다는 의미다. 따라서 그때그때 필요한 만큼만 계산하는 것도 가능하다. 이 느긋한 계산의 개념으로 인하여 하스켈은 프로그램에 무한의 개념을 쉽게 적용할 수 있다. 예를 들어 어떤 길이의 정수 제곱 리스트가 필요하다면 아래처럼 무한한 리스트를 만들게 해도 상관 없다.
-- 아래 코드는 무한한 정수의 제곱 리스트를 만드는 방법이다.
-- List Comprehension이라고 한다.
-- 수학에서의 집합 { x² | x∈{1, ..} }과 유사하다.

square = [x^2 | x <- [1..]]
유사한 배열을 만드는 JavaScript 코드.
#!syntax javascript
// 전형적인 무한루프
var square = []
var i = 1
while (true) {
  square.push(i*i);
  i += 1;
}

// ECMAScript 6의 제너레이터(Generator)를 이용한 버전
function* square_gen(){
  var i = 1;
  while(true){
    yield i*i;
    i++;
  }
}
var square = square_gen();
console.log(square.next().value);
console.log(square.next().value);
console.log(square.next().value);

일반적인 프로그래밍 언어에서는 직접 구현하기 상당히 귀찮지만, 하스켈에서는 기본적으로 값이 필요해지기 전까지 계산을 미루고 있다가 값을 요청할 때 계산을 해서 보내주기 때문에 아무 문제가 없다. 오히려 이렇게 정의해두면 필요할 때 필요한 만큼 동적으로 리스트를 생성하므로 유연한 프로그래밍을 위해 필요한 경우에는 무한을 사용하는 쪽이 권장된다.

계산 말고 패턴매칭에서도 느긋한 패턴매칭이라는 개념이 있는데, 느긋한 패턴매칭이란 패턴이 안 맞아도 일단 참으로 가정해서 통과시키고, 그 패턴이 실제 사용될 때가 되어서야 해당 패턴의 변수에 값을 대입하는 것이다. 문법적으로는 ~를 앞에 붙여 ~pattern 정도로 사용하지만, 몇몇 경우에는 ~를 따로 붙이지 않아도 암묵적으로 적용된다.

5. 강력한 타입 추론

파일:상세 내용 아이콘.svg   자세한 내용은 자료형 문서
5번 문단을
부분을
참고하십시오.
하스켈은 함수에 입력, 출력되는 타입에 대해 엄격하다. 즉, 하스켈에서 사용되는 모든 함수는 유일한 principal type을 가지며, 그것이 실제로 그러한지 컴파일 타임에 체크를 하고 그렇지 않을 경우, 에러를 뱉는다. 이는 컴파일 타임에 상당한 수의 버그를 잡아낼 수 있음을 의미하며, 런타임시 그것들을 체크할 필요가 없으므로 런타임 성능 향상에도 이점이 있다.

하스켈은 강력한 Hindley-Milner 타입 시스템을 사용하여 대부분의 경우, 타입을 명시적으로 지정하지 않아도 함수에서 사용된 데이터와 연산자 및 기타 정보를 통해 해당 함수의 타입을 자동으로 연역한다.

C++11이나 다른 많은 언어들(스칼라 등)에 도입된 타입 추론 기능은 지역 추론 기능으로, 하스켈이나 ML의 전역 추론과는 타입 추론이라는 것을 제외하고는 목적, 작동 방식 등 많은 면에서 다르다.

물론, 예외 상황이 있기 때문에, 타입을 명시적으로 적어야만 하는 경우도 있다. 추상화 레벨을 낮추어 최적화하고 싶거나 관습적으로 논리적 버그를 막기 위해, 적을 필요가 없더라도 쓰는 경우도 있다.

6. 대수적 데이터 타입

대수적 데이터 타입(Algebraic Data Type, ADT)은 하스켈에서 새로운 데이터 타입을 정의하는 방법이다. C의 struct, enum, union 등과 유사하지만, 훨씬 유연한 사용이 가능하다. 다음의 예를 보자.
data Point = Point Int Int
data Bool  = True | False
위는 정수 순서쌍 Point와 불 타입 Bool(대수학에서의 그 불 대수이다.)을 정의하는 하스켈 코드이다. PointBool은 각각 C에서 structenum을 사용해 구현할 수 있다. 등호 왼쪽에 오는 것은 타입의 이름이며, 등호 오른쪽에 오는 것은 데이터 생성자이다. 데이터 생성자는 함수의 일종이며, 패턴매칭을 통해서 분해할 수 있다. 위의 예에서 Point타입은 Point :: Int -> Int -> Point 라는 생성자를 가지며, Bool타입은 True :: BoolFalse :: Bool 두 개의 생성자를 가진다.

한편, 하스켈에서 데이터 타입은 다른 데이터 타입을 파라미터로 받는 것이 가능하다.
data Maybe a    = Nothing | Just a
data Either a b = Left a | Right b
data Pair a b   = Pair a b
data List a     = Nil | Cons a (List a)
Maybe는 nullable한 값을 표현하는 타입으로, Swift 등의 Optional타입과 같다.

EitherPair는 각각 sum type과 product type이라 불리는 것으로, Eithera, b중 한 가지 값만 가질 수 있는 타입을 표현하며, Paira, b의 값을 동시에 가지는 타입을 표현한다.

List는 lisp의 리스트처럼 재귀적으로 정의되는 리스트 자료구조이다.

Maybe, EitherList 타입은 C의 union과 유사한 방식으로 구현 되지만, 한 값을 여러 가지 타입으로 다루는 것을 금지하므로 union과 달리 안전하게 사용할 수 있다.

Maybe 등은 그 자체로는 타입이 아니며, Maybe Bool처럼 다른 타입을 적용해 타입을 얻을 수 있는데, 이처럼 다른 타입을 인자로 받아 새로운 타입을 만드는 타입을 타입 생성자라고 부르며 이는 타입 레벨에서의 함수로 생각할 수 있다.

이러한 타입들을 구분짓기 위해서 하스켈은 kind라는 개념을 가지고 있는데, kind는 간단히 말해 타입의 타입이라고 볼 수 있다. 위에서 정의한 타입들은 아래와 같은 kind를 가진다.
Bool   :: Type
Maybe  :: Type -> Type
Either :: Type -> Type -> Type
이것이 끝이 아니라, 다음처럼 타입 생성자를 파라미터로 받는 타입도 정의할 수 있다.
data Apply f a = App (f a)
-- Apply :: (Type -> Type) -> Type -> Type
또한 GHC 컴파일러는 이러한 타입시스템을 더욱 유연하게 확장하는 GADT 등의 컴파일러 확장을 제공한다.

7. Parametric polymorphism

파일:상세 내용 아이콘.svg   자세한 내용은 자료형 문서
4.3번 문단을
부분을
참고하십시오.
OOP에서 일반적으로 사용되는 다형성에는 ad hoc polymorphism과 subtype polymorphism이 있다. ad hoc polymorphism은 함수 오버로딩과 같이 타입에 따라 다른 구현이 존재하는 것을 말하며, subtype polymorphism은 클래스의 상속관계를 통해 구현되는 다형성을 말한다. parametric polymorphism과 타입 클래스는 하스켈이 자랑하는(?) OOP 스타일 다형성의 대항마이다.

parametric polymorphism은 그 이름처럼, 다형성을 가진 함수를, 타입 변수를 입력으로 받아 평범한 함수를 출력으로 내놓는 함수처럼 보는 것이다. 이 개념은 상당히 강력해서 C++의 템플릿을 포함한 다양한 프로그래밍 언어들에 전파되었다. 대표적인 용례가 제네릭 프로그래밍의 꽃이라고 불리는 STL이다. 간단히 말하자면, 유저가 타입을 직접 정의할 때 타입변수 a 같은 것을 사용할 수 있고, 이것을 이용해서 하나의 타입 선언으로 여러 개의 타입에 맞게 사용 가능하게끔 원소스 멀티유즈를 한다는 개념이다.

예를 들어 아래 코드에서 함수 const는 타입 변수 ab를 받아서 x :: ay :: b를 받아 x를 리턴하는 함수를 리턴한다.
const :: a -> b -> a
const x y = x
이 함수의 타입을 조금 더 명시적으로 쓰면
const :: forall a b. a -> b -> a
인데, curry-howard correspondence에 따르면 universal quantifier ∀ 또한 함수처럼 여길 수 있다. 이는 앞에서 언급한 parametric polymorphism이 타입을 변수로 받는 함수처럼 여긴다는 맥락과 일치한다. 한편, 실제로 하스켈에서 다형성 함수를 사용할 때에는 이 타입 변수들이 자동으로 추론되어 적용되기 때문에 명시할 필요는 없다. 이를 명시적으로 적용할 수 있는 컴파일러 확장도 존재한다.

그런데 하스켈의 타입 시스템은 어떤 형태로도 다운 캐스팅을 허용하지 않는다. 따라서 하스켈에서 f :: a -> a의 가능한 유일한 구현은[4] f x = x, 즉 id함수 뿐이다. 이처럼 parametric polymorphism만을 가지고는 유용하게 사용하기 어렵기 때문에[5], 하스켈은 타입 클래스를 통해 ad hoc polymorphism을 지원한다.

하스켈이 다운 캐스팅을 금지하는 것은 언어 디자인적인 이유가 있는데, 하스켈은 다운 캐스팅을 금지함으로써 ad hoc polymorphism을 타입 클래스의 인스턴스 정의에 가둔다. 즉 타입 클래스의 함수가 아니면서 다형적으로 작성된 함수는 항상 똑같은 방식으로(uniformly) 작동하며, 이를 parametricity가 보존된다고 표현한다. parametricity를 보존하는 것은 또한 컴파일 이후에 타입 정보를 완벽하게 지우는 것을 가능하게 한다.

타입 클래스는 타입들의 집합(사실 클래스)이라고 생각할 수 있다.[6] 예를 들어, 원소간 대소 비교가 가능한 타입들에 대해 정의하고 싶다면, 타입이 아래와 같은 대소 비교 함수 lessThan을 구현한 타입들을 Ord a로 묶는다.
lessThan :: a -> a -> Bool
그러면 Ord를 구현한 임의의 타입들에 대해 작동하는 함수 sort를 함수 lessThan을 사용해 구현하는 것이 가능하다.
sort :: Ord a => [a] -> [a]
'=>' 부분을 논리식에서의 implication 즉, if ~ then ~ 으로 읽는다면 'Ord a 이면 [a] -> [a] 이다' 정도로 볼 수 있다.

OOP보다 유연한 시스템이기 때문에 그 방법과 접근방식만 살짝 다를 뿐, OOP를 네이티브 OOP 프로그래밍 언어에 가까운 수준으로 간단히 에뮬레이션 하여 사용하는 것이 가능하다.
class Eq a => Ord a where
예를 들어 위 코드는 Ord a라는 타입 클래스를 정의하면서, 동시에 이것은 Eq a여야 함을 전제하고 있기 때문에 Ord aEq a의 부분집합[7]임을 말하는 것이고, 이는 Ord a 클래스의 타입들은 Eq a 클래스의 모든 메소드를 사용가능하다는 것이다. 즉 OOP에서의 상속과 유사해진다.

타입 클래스는 OOP에서 보이는 인터페이스와 유사한 개념이지만, 몇 가지 차이점이 있다. 가장 두드러지는 차이점은 대부분의 OOP 언어에서 인터페이스가 타입 선언과 인터페이스의 구현을 동시에 해야하는 것에 비해, 타입 클래스의 인스턴스는 코드의 아무 위치에서나 선언할 수 있다는 점이다. 이는 서로 다른 라이브러리에서 정의된 타입 클래스와 데이터 타입의 인스턴스를 만드는 등 훨씬 유연한 사용을 가능하게 한다. 또한 인스턴스가 Type kind에 대한 인터페이스를 선언하는 것만이 가능한 것에 비해, 하스켈의 타입 클래스는 임의의 kind에 대한 클래스를 선언하는 것이 가능하다. 아래 문단의 Monad또한 Type -> Type kind에 대해 정의된 타입 클래스이다.

하스켈은 기본적으로 dynamic dispatch를 지원하지 않지만, existential type이라는 compiler extension을 통해 비슷한 기능을 제공한다. existential 타입은 원래의 타입에 대한 정보를 잃으면서 특정 타입 클래스의 인스턴스 정보만을 유지할 수 있게 해준다. 예를 들어 exist a. Show a => a 타입은 이 타입이 Show 인스턴스를 가진다는 것은 알 수 있지만, 그 타입이 본래 무슨 타입인지는 알 수 없으며 패턴 매칭 또한 불가능하다. OOP와 비교하자면 업 캐스팅은 가능하지만 다운 캐스팅은 허용하지 않는 셈이다. 따라서 existential 타입은 parametricity를 해치지 않는다.

8. 모나드

파일:상세 내용 아이콘.svg   자세한 내용은 Haskell/특징/모나드 문서
번 문단을
부분을
참고하십시오.
모나드는 그냥 자기 함자 범주의 모노이드일 뿐인데, 대체 뭐가 어렵다는 거야?
A monad is a monoid in the category of endofunctors, what's the problem?
필립 와들러
위 인용문은 하스켈 설계 책임자 필립 와들러가 한 말로 알려져 있지만, A Brief, Incomplete, and Mostly Wrong History of Programming Languages라는 글에 농담으로 써놓은 것이 퍼진 것이고, 실제로는 Saunders Mac Lane이 쓴 범주론 교과서 Categories for the Working Mathematician에 나오는 글귀다.[8]

지금까지 좋은 점만 나열했는데, 그럼에도 하스켈 점유율이 바닥을 치는 이유가 있고, 그 이유는 물론 여러가지가 있을 수 있겠지만, 지금 이 항목에서 이야기하는 모나드가 커다란 부분을 차지하고 있다고 해도 과언이 아니다. 모나드는 수학의 범주론(Category theory)에서 사용되는 개념을 가져와 사용하는 것이다. 사실 하스켈을 배우던 사람 중 상당수가 모나드를 수학적으로 이해하려다 멘붕 후 접는다고 봐도 될 정도로 개념적 이해까지의 난이도가 높다.(학부 수준 이상 넘어가면 수학과 과목 중 가장 어려워지는 게 대수학이다.) 하지만, 애당초 수학의 모나드와는 미묘하게 다른데다 실제 사용례 위주로만 습득해서 써도 별 문제 없으며 쉽게 쓰라고 do 같은 문법도 지원해주므로 수학적인 개념이 이해가 안 간다고 그다지 난감해 할 것은 없다.

사실 설명만 잘 해도 포인터보다 이해하기 쉬운 것이 모나드이다.

Functors, Applicatives, and Monads In Pictures라는 글에서 모나드를 그림으로 잘 설명했다. 번역본도 있다.

8.1. 예제

가령 흔히 쓰이는 리스트를 예시로 들어보자. 오류가 있는 값은 빈 리스트 [], 올바른 값은 숫자 하나가 든 리스트로 표시하기로 했다고 해보자.
listDiv :: Int -> [Int]
listDiv 0 = []           -- 0으로 나누면 오류를 뜻하는 [] 반환
listDiv x = [24 `div` x]
이런 식으로 0으로 나누기 같은 오류를 예외(exception)를 던질 필요 없이 표현할 수 있다.

이 규칙을 따라, 입력 받은 숫자를 두 배 해주는 함수 double을 만들면
double :: Int -> [Int]
double x = [x * 2]

xs = [2]
x = head xs   -- 2

ys = double x -- [4]
y = head ys   -- 4

zs = double y -- [8]
연산을 할 때마다 값이 배열 안에 들어가기 때문에 사용이 번거로워진다.

이를 돕기 위한 함수를 만들어보자.
bind :: [Int] -> (Int -> [Int]) -> [Int]
[]  `bind` _ = []
[x] `bind` f = f x

[1] `bind` double                             -- [2]
[1] `bind` double `bind` double               -- [4]
[1] `bind` double `bind` double `bind` double -- [8]
리스트에 싸인 값이더라도 편리하게 쓸 수 있게 되었다.

그런데 어딘가 비슷한 것 같다.
(>>=) :: Monad m => m a -> (a -> m b) -> m b
바로 연산자 >>=의 타입이 함수 bind와 똑 닮았다.
[1] >>= double                       -- [2]
[1] >>= double >>= double            -- [4]
[1] >>= double >>= double >>= double -- [8]
이렇게 값을 담는 상자가 모나드이고, 상자 안의 값을 꺼내 연산을 하고 다시 상자 안에 담아주는 함수가 >>=이다.

더 자세히 설명한 링크

8.2. 범주론에서

파일:상세 내용 아이콘.svg   자세한 내용은 범주론 문서
번 문단을
부분을
참고하십시오.
막연히 어렵다 하면 대중적인 관점에서 프로그래밍 언어의 최대 난적으로 불리는 C언어의 포인터같은 것인가 생각이 들텐데, 짧은 지문에서 모나드의 정확한 개념적 설명은 무리고, 여기서는 대충 '무엇이 어려운지' 감을 잡기 위해 범주론에서 모나드란 게 무엇인지 개념을 훑어보도록 한다.

범주(Category)는 몇 가지 규칙을 만족시키는 대상(Object)과 그 대상 사이의 사상(Morphism)[9]으로 정의된다. 그런데 저 규칙이란 것이 결합법칙 성립, 항등사상[10]의 존재 2개밖에 없다. 이렇게 규칙이 아주 일반적이어서 사실 거의 모든 것들이 저 규칙에 해당된다.

프로그래밍 언어도 예외가 아니다. 순수 함수형 프로그래밍 언어에서 데이터 타입을 대상으로 놓고, 그 데이터 타입 사이의 함수(프로그램)를 사상으로 놓으면 이것도 하나의 훌륭한 범주가 된다. 여기서, 다시 두개의 범주 사이의 구조를 생각해볼 수 있는데, 범주론에서는 몇가지 규칙을 만족시키는 저런 구조를 함자(Functor)라 한다.[11] 이 규칙 역시 아주 관대해서 함수형 언어들에서 스탠다드로 사용되는 map 같이 함수 자체를 인풋으로 받는 고차함수(higher-order function) 몇몇이 이에 해당된다고 볼 수 있다. 여기서, 다시 두개의 함자 사이의 변환을 생각해볼 수 있겠는데, 범주론에서는 몇가지 조건을 만족하는 저런 변환을 자연 변환(Natural transformation)이라 한다. 이 조건 역시 상당히 관대한데 unit 같은 함수가 이런 조건을 만족시킨다. 여기서 다시 저 자연 변환이 관련된 몇가지 조건을 만족하는 함자 쌍을 수반함자(Adjoint functor)라 하는데, 모나드(Monad)는 바로 이 수반함자쌍의 Composition이다. 여기까지 오면 뭔소린지는 몰라도 뭐가 어려운건지 대충 이해는 갈 것이다. 그냥 함수의 함수의 함수의... 식으로 order 가 올라가고, order가 올라갈 때마다 조건이 붙고... 최종적으로 그래서 그게 대체 무엇인가 오리무중에 빠지는 난점이 있다.(이것은 모나드를 상당히 어렵게 설명한 것이므로 크게 걱정하지 않아도 된다. 훨씬 더 쉽게 추상화하여 설명이 가능하니 찾아보기 바람.)

8.3. 실용적 관점

다행히, 함수형 언어에서 사용되는 모나드 개념은 범주론을 적극적으로 사용하기보다는 아이디어만 빌려온 수준이며, 때문에 이런 쪽 전공이 아니라면 쓸데없이 시간만 잡아먹을 수학적 이해는 그냥 포기하고 사용례로 실용적인 부분만 습득하는 것도 적극적으로 권장된다.

실용적인 관점에서 모나드의 가장 중요한 용도는 Side-Effect가 있는 Impure Function을 Pure Function인 것처럼 다루는 것이다. 대략적으로 설명하자면, Side-Effect는 보통 외부 상태(이하 State)가 있고 이것에 접근하여 생긴다. 반대로 Side-Effect가 없는 Pure Function은 모든 정보가 인수로 전달되어 외부 상태에 접근하지 않는다. 그렇다면 어떤 함수에 인수로 State를 전달하고 그에 대한 결과로 State를 리턴한다면 외부 상태에 접근을 하지 않아도 되며 pure function이 된다. 이 때 State 외에도 결과값 a도 있을 것이다. 즉, 입력이 State면 출력이 (a, State)가 되는 것이다.(이것을 State -> (a, State)라고 나타낸다.) 이러한 함수는 다양하게 있을 수 있으며 그 결과값도 a 뿐만 아니라 여러가지가 있을 것이다. 그런데 하스켈은 순수 함수형 언어이기 때문에 함수를 합성하는 방식(fg를 합성하면 g(f(x))가 되는 것처럼)으로 작성을 해야한다. 그런데 만약 fState -> (a, State), gState -> (b, State)라면 f의 출력과 g의 입력 타입이 맞지 않게 된다.
f :: State -> (a, State)
g :: State -> (b, State)
이 때 f의 결과값인 (a, State)aState로 분리해서 ag와 결합하여 새로운 함수 g'를 만들고 State를 이 새로운 함수 g'에 전달하는 방식을 사용하게 된다. 이렇게 결합을 해주는 것이 bind(Haskell에서 >>= 연산자)라고 하고 결합되는 것을 monad라고 한다. 여기서 끝이 아니라 필요하다면 함수를 더 잇는 것도 가능하며, 이렇게 여러 개를 이으면 거대한 하나의 Pure Function이 된다.(예를 들어서 위의 경우 fg를 결합하면 입력이 State, 출력이 (b, State)인 함수가 된다.)

이것을 조금 다른 관점에서 보자면 어떤 외부 상태 State에 대해 f, g가 순차적으로 실행하는 순수한 함수가 생긴다는 것이다. 다르게 말하자면 명령형 언어처럼 순차적으로 함수를 호출한다는 것이다. 그러다보니 하스켈에서는 모나드를 이용하여 이러한 기능을 구현하게 된다.

즉, 하스켈은 순수 함수형 언어이면서 동시에 모나드를 이용해 정의되는 명령형 언어라는 서브언어를 내부에 갖고있는 셈이다. 그렇다면 반대로, C++ 이나 파이썬 같이 명령형 언어이면서 함수형 표현을 지원하는 언어와 다른 것은 무엇인가... 생각이 들법도 한데, 모나드를 이용해 하스켈 내부에 정의되는 명령형 언어 시스템은 순수성(Purity)을 훼손하지 않아 참조 투명성(Referential transparency)이 유지되며 동시에 명령형 언어 부분과 함수형 언어 부분이 엄격하게 구분된다. 일반적으로, 명령형 언어 베이스에 함수형 표현을 추가한 언어들은 말 그대로 표현을 지원하는 정도라, 구분이 존재하지 않고 섞이는 경향이 있다.

8.4. 다른 언어 사례

사실 모나드는 함수형 패러다임을 수용하는 언어라면 모두 이를 구현할 수 있다. 하스켈에서도 모나드는 언어 레벨에서 구현한 구조가 아니라, 수많은 타입 클래스 구현 중 평범하고(?) 조그마한(!) 하나이다. 실제로 모나드와 그 메소드를 정의하는 코드는 코멘트를 제외하면 8줄, 100글자도 안된다. 물론 Functor, Applicative 등을 계승하고 있지만, 그 또한 모나드만을 위해서 만들어진 것도 아니니 과장은 아니다. 게다가 정의도 매우 짧다. 이 8줄 정도 밖에 안되는 정의를 이해하지 못해서 많은 사람들이 좌절하고 있는 것이다. 읽어보면 당연한 내용만 정의되어 있고, 거기에다가 이해를 돕기 위한 주석이 코드의 4배 이상이다.[12] 다만 하스켈에서는 이를 보다 적극적으로 언어의 특징으로 내세워서 적용하고 있을 뿐이다.(그 덕에 많은 사람들이 멘붕을 하고 있기는 하지만...)

8.4.1. JavaScript

여담으로 모나드를 실용적으로 구현하여 사용하는 대표적인 분야가 바로 JavaScript로 작성된 여러 프레임워크나 라이브러리 중에서 Ajax를 처리하는 부분이다. 특히 Promise와 ES7에 추가된 async/await 문법은 하스켈의 모나드와 do이다.(사실 JavaScript의 Promise는 모나드 법칙을 만족하지 않는다. 하스켈을 제외한 언어에서 모나드 법칙까지 만족하는 진짜 모나드를 사용하는 경우는 드물다.) 여기에 모나드를 적극 적용하는 이유는 다른 것이 아닌, 이것을 적용하면 노가다 양이 줄고, 사용하기 편하며, 보기도 좋기 때문.

9. 기타

하스켈 스크립트는 GHC로 완전히 기계어 컴파일을 할 수 있고 GHCi의 가상 기계 인터프리터로도 작동 가능하다. 또한 스크립트 중 .lhs라는 확장자는 일반 텍스트가 메인이고 코드를 별도로 처리해서 작성했다가 컴파일 하는 방식이다. 기본적으로 뭘 만들어도 코드 자체는 상대적으로 짧게 만들어지기 때문에 주석이 메인인 코드 형식이 생긴 것이다. 다만 Haddock을 통한 문서화가 주류가 되면서 .lhs는 예제코드를 만들기 위한 용도 정도로나 사용되고 있다.


[1] https://en.wikipedia.org/wiki/Currying [2] 그래서 수학적으로 엄격하게는 *(m, n) = (*(m))(n)이 맞다. 그렇지만 가독성 문제가 있어 생략. [3] https://wiki.haskell.org/IO_inside [4] 사실 undefined의 존재로 인해 엄밀히 말하면 유일하지 않다. [5] 어떤 값에 아무런 제약이 없다면, 그 값을 가지고 무엇을 할 수 있는지 및 무엇을 할 수 없는지를 결정할 수가 없으므로 함수가 받은 그대로 돌려주는 일밖에 할 수 없는 것이다. [6] 수학적으로 모임(class)은 집합의 상위 개념이다. 수학에서 모든 집합의 집합 같은 것은 존재하지 않지만, 모든 집합의 클래스 개념은 받아 들인다. [7] 물론 엄밀히는 부분 모임, 즉 서브클래스(subclass)가 된다. [8] https://stackoverflow.com/a/3870310 [9] 함수를 추상화한 것이 사상이므로 그냥 함수라고 생각해도 된다. 정확히는 집합들을 대상으로 하는 사상이 함수이다. [10] 역시 항등함수(입력값을 그대로 돌려주는 함수)로 생각해도 좋다. [11] 하스켈에도 Functor 타입 클래스가 존재한다. 다만 하스켈의 Functor는 범주론에서 다루는 자기함자(Endofunctor)에 해당하는데, 이는 하스켈에서 다루는 범주가 Hask하나 뿐이기 때문에 모든 함자가 자기함자이기 때문이다. [12] https://hackage.haskell.org/package/ghc-internal-9.1001.0/docs/src/GHC.Internal.Base.html

파일:CC-white.svg 이 문서의 내용 중 전체 또는 일부는 문서의 r395에서 가져왔습니다. 이전 역사 보러 가기
파일:CC-white.svg 이 문서의 내용 중 전체 또는 일부는 다른 문서에서 가져왔습니다.
[ 펼치기 · 접기 ]
문서의 r395 ( 이전 역사)
문서의 r ( 이전 역사)

분류