[번역] 프로그래머를 위한 카테고리 이론 - 2. 타입과 함수
    프로그래밍

    [번역] 프로그래머를 위한 카테고리 이론 - 2. 타입과 함수


    타입과 함수로 이루어진 카테고리는 프로그래밍에서 꽤나 중요한 역할을 한다. 자 이제 타입이라는 것이 무엇이며 왜 이런 개념이 필요한지에 대해 이야기를 해보자.

    2.1 어떤 이들에게 타입이 필요한가?

    정적타입과 동적타입, 그리고 강타입과 약타입의 각각의 장단점에 대해서는 약간의 논란이 존재한다. 한번 간단한 사고실험을 통해 이 선택들에 대해 한번 상상해보자. 여기 수백만 마리의 원숭이가 컴퓨터 키보드 앞에 앉아 행복한 기분을 느끼며 아무런 키나 랜덤하게 누르고, 프로그램을 작성하고, 컴파일하고, 실행시키고 있다.

    만약 원숭이들이 기계어를 사용하는 상황이라면 이들이 만든 어떠한 바이트들의 조합이든 모두 허용되고 실행될 수 있다. 그러나 원숭이들이 고급 언어를 사용한다면 컴파일러가 어휘 및 문법 오류를 잡아준다는 사실에 그저 감사하게 될 것이다. 비록 많은 원숭이들이 바나나를 받지 못하고 떠나게 되겠지만, 그나마 유지보수할 수 있는 프로그램을 만들어낼 수 있을테니 말이다.

    타입 체킹은 무의미하게 작성된 프로그램에 대한 하나의 방어막이라고 볼 수 있다. 더 나아가 타입의 불일치를 런타임에서만 잡아낼 수 있는 동적 타입 언어와 다르게, 강력하게 타입이 정적으로 체크되는 언어에서는 타입의 불일치가 컴파일 타임에 발견되기 때문에 굳이 실행해보지 않더라도 잘못된 프로그램들을 잡아낼 수 있다.


    우리에게는 이 원숭이들을 행복하게 만드는 것이 중요할까? 아니면 정확한 프로그램을 만드는 것이 중요할까?

    일반적으로 이러한 원숭이 사고실험의 목표는 바로 셰익스피어의 전집을 만들어내는 것이며, 이때 맞춤법 검사기와 문법 검사기를 사용할 수 있다면 성공 확률은 크게 증가한다. 만약 타입 검사기와 비슷한 것이 있다면 로미오를 인간이라고 표현해놓고 뜬금없이 그에게서 나뭇잎이 자란다고 하거나, 로미오가 블랙홀이 되어 강력한 중력장으로 광자를 사로잡거나 하는 상황을 방지함으로써 셰익스피어 전집의 정밀도를 높혀나갈 수 있을 것이다.

    2.2 합성과 관련된 타입

    앞서 이야기했듯이 카테고리 이론은 결국 화살표를 합성하는 것이다. 하지만 임의의 두 화살표를 아무렇게나 합성할 수 있는 것은 아니다. 한 화살표의 목표인 대상은 다음 화살표의 출발 대상과 동일해야 한다. 프로그래밍에서는 한 함수의 결과를 다른 함수로 전달하는 것과 동일하다고 볼 수 있다. 만약 합성하려고 하는 함수의 결과물을 다른 함수가 제대로 해석할 수 없는 경우 프로그램은 제대로 작동하지 않을 것이다. 즉, 합성이 제대로 되려면 양 끝단이 일치해야 한다는 것이며, 프로그래밍 언어가 제공하는 타입 시스템이 강력할수록 이러한 일치 여부를 잘 표현할 수 있고, 기계적으로 잘 검증할 수도 있다.

    내가 들었던 정적타입, 강타입 체킹에 대한 반대 의견 중 유일하게 일리가 있었던 것은 바로 타입은 맞지 않더라도 의미적으로는 올바른 일부 프로그램이 배제될 수도 있다는 것이었다. 하지만 실제로 이런 일은 극히 드물기도 하고, 모든 정적 타입, 강타입 언어는 타입 시스템을 무시할 수 있는 방법을 제공해주기도 한다.

    심지어 Haskell에도 unsafeCoerce 라는 녀석이 존재하기는 하지만, 이러한 기능들은 매우 신중하게 사용되어야 한다. 프란츠 카프카의 소설인 “변신”의 주인공인 Gregor Samsa는 어느 날 갑자기 거대한 벌레가 되어버리면서 타입 시스템을 깨뜨렸고, 결국 그 결과가 어땠는지는 우리 모두 잘 알고 있다.

    💡 역주

    소설 변신에서의 Gregor Samsa는 어느 날 갑자기 거대한 벌레가 되어버리며, 단지 모습 뿐만 아니라 그가 인간으로써 지켜왔던 모든 것들을 함께 잃어버리게 된다. 이후 가족들은 그를 챙기려는 모습을 보이기는 하지만, 결국 가족들에게 그는 인간이 아닌 벌레로 평가되어 버림받았으며, 최종적으로는 쓸쓸히 죽음을 맞이한다. 경제적 능력을 상실한 한 명의 인간이 가정이나 사회에서 버림받게 된다는 씁쓸한 메세지를 던지는 소설인데, 이 책의 작가는 unsafeCoerce 타입을 “타입 역할을 제대로 못 하는 타입”으로 바라보고 있기 때문에 이런 예시를 든 것은 아닐까? 하는 생각을 해본다.

    흔히 들을 수 있는 또 다른 주장은 이러한 타입 처리들이 프로그래머에게 너무 많은 부담을 지운다는 것이다. 물론 C++에서 이터레이터 선언을 몇 번 해보고 난 뒤에는 이러한 마음에 어느 정도 공감할 수 있었다. 그러나 사실 컴파일러가 문맥을 통해 대부분의 타입을 추론할 수 있는 타입 추론 기술이 존재하기 때문에, C++에서는 변수를 auto로 선언하더라도 이 변수의 타입이 무엇인지 컴파일러가 알아서 결정할 수 있다.

    Haskell을 사용하는 대부분의 경우, 타입 선언은 선택 사항이다. 하지만 프로그래머들은 직접 타입 선언을 하는 경우가 많은데, 이러한 선언들이 코드의 의미에 대한 많은 정보를 알려주기도 하고 컴파일 오류를 이해하기 쉽게 만들어주기도 하기 때문이다. 사실 Haskell을 사용하여 프로젝트를 시작하는 경우에는 타입부터 설계하는 것이 일반적이다. 설계 이후에는 타입 선언들로부터 점진적으로 프로그램을 구현하게 된다.

    종종 강력한 정적 타이핑은 코드를 테스트하지 않는 핑계로도 사용된다. 간혹 Haskell 프로그래머들이 “컴파일된다면 이건 제대로 된 프로그램이다”라고 이야기하는 것을 들을 수 있다. 하지만 당연하게도 제대로 타이핑된 프로그램이라고 해서 반드시 제대로된 프로그램이라고 보장할 수는 없다.

    이런 경솔한 태도들은 여러 연구들에서 생각보다 Haskell이 코드 품질 측면에서 강력한 성과를 내지 못한다는 사실과도 이어진다. 상업적인 환경에서는 반드시 최고의 품질을 만들어 내야한다기보다는 일정한 품질 수준만 만족시키는 것이 나을 수도 있다. 이런 전략 선택의 팩터는 소프트웨어 개발의 경제적인 측면, 그리고 최종 사용자의 품질 허용 수준과 밀접한 관련이 있으며, 사실 프로그래밍 언어나 기술적 방법론과는 거의 관련이 없다.

    이런 환경에서 가장 우선시 되어야 하는 품질 기준은 얼마나 많은 프로젝트가 일정이 밀렸는지, 그리고 원하는 기능을 제대로 만족시키지 못한 상태로 사용자에게 전달되는지를 측정하는 것이다.

    또한 유닛 테스트가 강타입 시스템을 대체할 수 있다는 주장도 있다. 한번 강타입 시스템을 가진 언어에서 특정 함수가 가진 인수의 타입을 변경하는 일반적인 리팩토링 작업을 생각해보자.

    약타입 시스템 언어에서는 이제 이 함수가 다른 타입의 데이터를 기대한다는 사실이 호출부에 전달이 되지 않을 수도 있겠지만, 강타입 시스템 언어에서는 해당 함수의 선언을 수정하고 이후 파생된 빌드 오류들을 수정하는 것만으로도 충분할 것이다.

    하지만 결국 테스트라는 것은 확률적인 작업이기 때문에 증명이라는 개념을 대체하기에는 역부족이며, 유닛 테스트로 몇몇 구현의 불일치를 잡아내는 정도일 것이다.

    2.3 타입이란 무엇인가?

    타입을 가장 간단하게 설명하는 말은 바로 집합이다. Bool 타입은 True와 False 2개의 원소로 이루어진 집합이며, Char 타입은 a나 ą와 같은 모든 유니코드 문자를 원소로 가진 집합이다.

    집합은 유한할 수도 있고 무한할 수도 있다. Char의 리스트와 동일한 의미인 String 타입의 경우가 바로 무한집합의 예이다.

    한번 x라는 변수를 Integer 타입으로 선언해보도록 하자.

    x :: Integer

    우리는 이제 이 x라는 값이 정수 집합에 들어있는 하나의 원소라고 이야기할 수 있다. Haskell에서 Integer 타입은 임의 정밀도 산술을 사용하기 때문에 무한한 집합이다. C++의 int와 같이 원시 타입에 해당하는 유한집합 Int도 존재한다.

    물론 타입과 집합을 완전히 동일시 하기에는 재귀적인 정의를 포함하는 다형성 함수나 모든 집합을 원소로 가지는 집합을 정의할 수 없다는 등의 몇 가지 문제가 있기는 하지만, 앞서 이야기한대로 너무 엄격한 수학적인 정의를 이야기하지는 않겠다. 여기서 중요한 것은 “Set”이라고 불리는 집합들의 카테고리가 존재하며 우리가 앞으로 이 개념을 다룰 것이라는 점이다. 이 Set 카테고리에서 대상은 집합이고 사상(화살표)는 함수이다.

    Set은 매우 특별한 카테고리이다. 왜냐하면 우리는 이미 이 카테고리의 대상인 집합에 대해서 잘 알고 있으니 이로부터 많은 직관을 얻을 수 있기 때문이다. 예를 들어 우리는 이미 공집합이라는 것이 어떠한 원소도 가지고 있지 않다는 사실을 알고 있다. 또한 특별한 하나의 원소를 가진 집합도 알고 있으며, 함수라는 것이 어떤 한 집합의 원소를 다른 집합의 원소와 매핑(Mapping)해주는 개념이라는 것도 알고 있다.

    또한 함수가 두 개의 원소를 하나의 원소로 매핑할 수는 있지만, 반대로 하나의 원소를 두 개의 원소로 매핑할 수는 없다는 것도 알고 있다. 그리고 우리는 항등 함수가 집합의 각 원소를 자기 자신에게 매핑한다는 것을 알고 있다. 이제 우리의 목표는 이렇게 디테일한 개념들은 점점 잊어버리고 순수한 카테고리 이론의 용어, 즉 대상과 화살표만으로 이런 개념들을 추상화해서 이해하는 것이다.

    이상적인 상황에서 우리는 Haskell의 타입을 집합으로, Haskell의 함수는 집합 간의 수학적인 함수로 정의해볼 수 있을 것이다. 하지만 한 가지 문제가 있다. 바로 수학적인 함수는 코드를 실행하는 것이 아니라, 단순히 답을 알고 있는 추상적 개념이라는 것이다. 하지만 Haskell의 함수는 직접 답을 계산해야한다. 물론 유한한 계산단계 내에서 답을 얻어낼 수만 있다면 계산이 얼마나 복잡하던 이 차이가 딱히 문제로 번지지는 않는다. (물론 너무 큰 수를 사용한다면 문제가 될 수도 있지만 말이다.)

    하지만 재귀와 같은 일부 계산은 영원히 종료되지 않을 수도 있다. 그러나 종료되는 함수와 종료되지 않는 함수를 구분하는 것은 정지 문제라는 유명한 난제이기 때문에, Haskell 내에서 종료되지 않는 함수만 찾아내어 금지하는 것은 불가능하다. 그래서 컴퓨터 과학자들은 관점에 따라 훌륭한 아이디어 또는 Hack으로도 볼 수 있는 bottom이라는 한 가지 특별한 값을 제안했다.

    이 “값”은 종료되지 않는 연산을 표현하며, _|_ 또는 유니코드 로도 표현할 수 있다. 한번 예시를 보자.

    f :: Bool -> Bool

    위 함수는 True, False 또는 _|_ 를 반환하며, 이것은 이 함수가 종료되지 않을 수도 있다는 사실을 의미한다.

    재미있는 사실은 bottom을 타입 시스템의 일부로 받아들이기만 하면, 프로그램에서 발생하는 모든 런타임 에러를 bottom으로 표현하고 명시적으로 함수가 bottom을 반환할 수 있다는 개념만으로도 엄청난 편의성이 생긴다는 것이다. 이는 undefined를 사용한 표현에서 확인해볼 수 있다.

    f :: Bool -> Bool
    f x = undefined

    위 표현에서 undefined는 bottom으로 평가되기 때문에 타입 체크를 통과한다. bottom은 Bool을 포함한 모든 타입의 원소이기 때문이다. 심지어 아래와 같이 작성할 수도 있다.

    f :: Bool -> Bool
    f = undefined
    -- (x는 생략 가능)

    이게 가능한 이유는 bottom이 Bool -> Bool 타입에도 해당하기 때문이다. 이처럼 bottom을 반환할 수 있는 함수를 부분 함수(Partial Function)이라고 하며, 반대로 모든 인수에 대해 반드시 유효한 결과를 반환하는 함수는 전체 함수(Total Function)이라고 한다.

    바로 이 bottom이라는 개념 때문에 Haskell의 타입과 함수로 이루어진 카테고리는 Set이 아닌 Hask라는 이름으로 불린다. 하지만 이렇게 이론적으로 계속 파고 들다보면 끝도 없이 복잡해지기도 하고 어차피 실용적인 측면에서 보면 종료되지 않는 함수와 bottom을 그냥 무시하고 Hask를 Set이라고 생각해도 무방하니 이 이야기는 이쯤에서 마무리지으려고 한다.

    2.4 우리는 왜 수학적인 개념을 알아야 하는가?

    아마 여러분은 프로그래머로서 프로그래밍 언어의 구문과 문법에 대해 깊은 이해도를 가지고 있을 것이다. 언어의 문법이나 구문과 같은 요소들은 보통 언어 명세의 가장 첫 부분에 형식적인 표기를 사용하여 설명된다. 그러나 언어의 의미론적인 부분은 설명하기가 훨씬 까다롭다. 언어의 의미에 대한 설명들은 훨씬 더 많은 페이지를 필요로 하고 형식적이기도 어려우며, 대부분의 경우에는 설명이 완벽하기도 어렵다. 이 때문에 언어에 대한 논의는 끝나지 않고 언어 표준에 대한 내용에 대해 각자의 해석을 통해 저술한 책들이 범람하고 있는 것이다.

    물론 언어의 의미론적인 측면을 설명하는 형식화된 몇몇 도구들이 존재하기는 하지만, 워낙 복잡하기 때문에 주로 단순화된 학술적인 언어에나 사용되는 편이며 현실의 거대한 프로그래밍 언어에서는 잘 사용되지 않는다. 이러한 도구 중 하나인 운용 의미론(Operational Semantics)은 프로그램 실행 매커니즘을 기술하는데, 이는 형식화된 가상 인터프리터를 정의하는 것이다. 산업용 언어인 C++와 같은 언어들의 경우에는 보통 “추상기계(Abtract Machine)”와 같은 비형식적인 운용 추론을 사용하여 설명한다.

    문제는 이러한 운용 의미론을 이용하여 프로그램에 대한 증명을 하는 것이 굉장히 어렵다는 것이다. 어떤 프로그램에 대한 특징을 보여주기 위해서는 반드시 가상 인터프리터를 사용하여 프로그램을 “실행”시켜봐야 한다.

    단지 프로그래머들이 어떤 문제에 대한 형식적인 증명을 수행하는 것에 대해 익숙하지 않기 때문에 어렵다고 하는 것은 아니다. 우리는 항상 우리가 올바른 프로그램을 작성하고 있다고 “생각”한다. 아무도 키보드 앞에 앉아서 “일단 대충 몇 줄 짜보고 무슨 일이 벌어지는지 봐야지”라고 말하지는 않는다는 것이다. 우리는 우리가 작성하는 코드들이 원하는 결과를 얻을 수 있도록 특정한 동작을 수행할 것이라고 기대하는 것이다. 그래서 작성한 코드가 우리의 예상과 다르게 동작하면 우리는 크게 놀라게 된다.

    즉, 우리는 우리가 작성하는 프로그램의 동작에 대한 일종의 이성적 추론을 하고 있으며, 이러한 추론은 우리의 머릿속의 인터프리터를 통해 코드를 실행시켜보며 수행하고 있다는 것이다. 하지만 우리가 모든 변수를 추적한다는 것은 불가능에 가깝다. 컴퓨터는 프로그램을 실행하는 것에 특화되어있는 녀석이지만 우리 인간은 그렇지 않으니 말이다! 만약 우리가 컴퓨터만큼 프로그램을 잘 실행시킬 수 있었다면 컴퓨터라는 개념 자체가 필요없었을테니 말이다.

    이런 문제를 해결하기 위해 표시적 의미론(Denotational Semantics)라고 불리는 대안이 있다. 이는 수학을 기반으로 하며, 표시적 의미론에서는 모든 프로그래밍 구성에 수학적 해석이 부여되기 때문에 프로그램의 속성을 증명하려면 그저 수학적 정리를 증명하기만 하면 된다. 이런 수학적인 증명이 어려울 것이라고 생각할 수는 있겠지만, 사실 우리 인간은 지난 수천년동안 수학적 기술들을 발전시켜왔기 때문에, 이미 우리가 활용할 수 있는 많은 지식들이 쌓여있다. 또한 우리가 프로그래밍에서 마주치는 문제들은 전문적인 수학자들이 증명해야하는 문제들에 비해 상대적으로 간단한 경우가 많다.

    표시적 의미론을 아주 잘 표현할 수 있는 Haskell에서의 팩토리얼 함수 정의를 한번 살펴보자.

    fact n = product [1..n]

    [1..n] 표현식은 1부터 n까지의 정수로 이루어진 리스트를 의미한다. 함수 product는 이 리스트의 모든 원소를 곱한다. 이런 표현들은 우리가 수학 교과서에서 볼 수 있는 팩토리얼의 정의와 굉장히 유사하다. 한번 C와 비교해보자.

    int fact(int n) {
        int i;
        int result = 1;
        for (i = 2; i <= n; ++i)
            result *= i;
        return result;
    }

    설명이 더 필요할까?

    사실 팩토리얼 함수에는 이미 명백한 수학적 표현이 존재하기 때문에 살짝 비겁한 예시이기는 하다. 여기서 영리한 독자라면 “그럼 키보드에서 문자를 읽거나 네트워크를 통해 패킷을 보내는 행위들에 대한 수학적 모델은 뭔가요?”라고 물어볼 수도 있겠다. 나도 오랜 기간 고민해봤지만 이런 질문들은 자칫 난해한 설명을 하게 되기 쉬운 까다로운 질문이었다.

    물론 질문과 같이 중요한 작업들을 운용 의미론으로 다루기는 쉽지만 표시적 의미론으로 다루기에는 적합하지 않아보인다는 것은 인정한다. 이런 문제에 대한 돌파구는 바로 카테고리 이론에서 나왔다. Eugenio Moggi는 계산에서 파생된 효과(Computational Effect)를 모나드에 매핑할 수 있다는 사실을 발견했는데, 이는 표시적 의미론에 새로운 생명을 불어넣어 주었고 순수 함수형 프로그램을 더 유용하게 만들 뿐 아니라 전통적인 프로그래밍에도 새로운 시각을 제공해주었다. 추후 모나드에 대해서는 카테고리론적인 도구들에 좀 더 익숙해지고난 뒤 다시 설명하도록 하겠다.

    정리하자면 프로그래밍에서 수학적 모델이 가지는 중요한 장점 중 하나는 소프트웨어의 정확성에 대한 형식적인 증명을 수행할 수 있다는 것이다. 일상 속에서 비즈니스 소프트웨어를 작성할 때는 그렇게 중요하지 않을 수 있겠지만, 작은 실패 하나가 큰 대가로 돌아오는 프로그래밍 분야나 심지어 사람의 목숨이 위험해질 수 있는 분야도 있다. 만약 여러분이 의료 시스템을 위한 웹 어플리케이션을 작성한다면 Haskell 표준 라이브러리가 제공하는 알고리즘과 함수들이 프로그램의 정확성을 보장해준다는 사실에 감사하게 될 수도 있다.

    2.5 순수함수와 순수하지않은 함수

    우리가 C++ 또는 다른 명령형 언어에서 함수라고 부르는 것들은 수학자들이 함수라고 부르는 것과는 약간 다른 개념이다. 수학적 함수는 그저 어떠한 값들 간의 사상(Mapping)일 뿐이기 때문이다.

    물론 프로그래밍 언어를 사용하여 수학적인 함수를 구현하는 것도 가능하다. 이러한 함수는 주어진 입력 값을 받아 출력 값을 계산한다. 어떠한 수의 제곱을 구하는 함수는 받아들인 입력 값을 그 입력 값과 다시 곱하여 계산하고 반환할 것이다. 이 함수는 동일한 입력 값을 받아 호출될 때마다 항상 같은 출력 값을 보장한다. 어떠한 수의 제곱이라는 것은 달의 위상처럼 매번 변하는 개념이 아니기 때문이다.

    또한 어떠한 수의 제곱을 계산하는 일이 강아지에게 맛있는 간식을 주는 것과 같은 사이드이펙트를 가지면 안된다. 이런 “함수”는 수학적 함수로 모델링하기가 쉽지 않기 때문이다.

    이처럼 프로그래밍 언어에서 동일한 입력 값이 주어질 때 항상 동일한 결과를 생성하고 함수 외부세계에 영향을 끼치는 사이드이펙트가 없는 함수를 순수 함수라고 한다. Haskell과 같은 순수 함수형 언어에서는 모든 함수가 순수하기 때문에, 명령형 언어에 비해 표시적 의미론이나 카테고리 이론을 사용하여 모델링하는 것이 상대적으로 더 쉽다. 물론 다른 언어들을 사용하더라도 순수한 부분을 제한하거나 사이드이펙트를 별도로 다룰 수 있도록 만드는 것이 가능하다. 따라서 수학적 함수만 사용한다는 제약이 우리에게 어떠한 불이익을 가져다주는 일은 없을 것이며, 추후 모나드가 어떤 방식으로 순수 함수만을 사용하여 모든 종류의 효과들을 모델링할 수 있도록 만들어주는지도 알아볼 것이다.

    2.6 타입에 대한 예시

    타입이 사실은 집합이라는 사실을 깨닫고 나면, 이제 우리는 약간 독특한 타입에 대해서도 한번 생각해볼 수 있다. 예를 들어 공집합에 해당하는 타입은 무엇일까? Haskell에서는 이러한 타입을 Void라고 부르기는 하지만, C++에서의 void와는 다른 개념이다.

    이 타입은 어떠한 값도 가지고 있지 않는 타입이다. Void를 인자로 받는 함수를 정의할 수는 있겠지만 이를 호출할 수는 없을 것이다. 왜냐하면 이런 함수를 호출하기 위해서는 Void 타입의 값을 인자로 넣어줘야 하는데, 이 타입의 값이 존재하지 않기 때문이다. 이 함수는 어떤 타입이든 반환할 수 있기 때문에 반환할 수 있는 것들에 대한 제약은 전혀 없겠지만, 결국 호출할 수 없기 때문에 뭔가가 반환되는 일도 벌어지지 않을 것이다. 즉, 이 함수는 반환 타입에 대한 다형성을 가진 함수라고 할 수 있다.

    Haskell 개발자들은 이런 함수를 absurd라고 부른다. 여기서 a는 어떤 타입이든지 될 수 있는 타입 변수라는 것을 기억하자.

    absurd :: Void -> a

    이 함수의 이름은 그냥 지어진 것이 아니다. Curry-Howard 동형성이라는 논리학적인 측면에서 타입과 함수를 더 깊게 해석해볼 수 있다. Void 타입은 거짓을 나타내고, absurd 함수의 타입은 “거짓인 가정에서 시작된 모든 명제는 참이다”라는 명제에 해당한다. 이는 라틴어 속담 “ex falso sequitur quodlibet(모든 것은 거짓으로부터 나온다)”과 같은 말이다.

    💡 역주

    이는 형식논리학의 개념인데, P→Q(만약 P라면 Q이다)라는 명제가 있는 경우, P가 거짓이라 할 지라도 Q가 참이라면 전체 명제는 참으로 평가받는 것을 이야기하는 것이다. 만약 P가 “하늘이 하얀색이다”, Q가 “태양은 뜨겁다”라면 하늘이 하얀색이든 아니든 항상 태양은 뜨거우므로 P→Q(하늘이 하얀색이면 태양은 뜨겁다)도 참이 된다.

    다음으로는 단일원소 집합에 해당하는 타입이다. 이 타입은 단 하나의 값만을 가질 수 있으며, 이 값은 그저 “존재”한다. 처음에는 이 말이 잘 이해가 안 갈 수도 있지만, C++의 void가 바로 이러한 타입이다. void 타입의 인자를 받고 void 타입을 반환하는 함수를 한번 생각해보면, 이 함수는 어떤 상황이든 항상 호출될 수 있다. 만약 이 함수가 순수함수라면 이 함수는 항상 같은 값을 반환할 것이다.

    아래 예시를 한번 살펴보자.

    int f44() { return 44; }

    이 함수가 “아무 인자도 받지 않는 함수”로 보일 수도 있지만, 앞서 살펴본 것처럼 “아무 인자도 받지 않는 함수”는 호출될 수 없다. 왜냐하면 “아무것도 존재하지 않는다”라는 것을 나타내는 값이 없기 때문이다. 그렇다면 이 함수는 무엇을 인자로 받고 있는걸까? 사실 개념적으로 보면 이 함수는 단 하나의 원소만 존재할 수 있는 더미 값을 받는다고 볼 수 있기 때문에 우리가 직접 이 값을 명시해줄 필요는 없다. 그러나 Haskell에서는 이러한 더미 값을 빈 괄호쌍인 () 으로 표현한다.

    이것이 우연인지 아닌지는 모르겠지만 C++과 Haskell에서 void를 인자로 받는 함수를 호출하는 방법은 같은 모양으로 보인다. 또한 Haskell은 간결함을 사랑하기 때문에 기호 () 가 그 값의 타입, 생성자, 그리고 이로 인해 생성되는 유일한 값을 표현하는데에도 사용된다.

    즉, 위의 C++함수는 Haskell에서는 아래와 같이 표현된다.

    f44 :: () -> Integer
    f44 () = 44

    첫 번째 줄은 f44 함수가 unit이라고 발음하는 타입 ()을 받아 Integer 타입을 반환한다고 선언하고 있다. 두 번째 줄은 f44 함수가 unit의 유일한 생성자인 ()를 패턴 매칭하여 숫자 44를 생성한다는 것을 정의한다. 이 함수를 호출하기 위해서는 unit의 값 ()를 인자로 제공하면 된다.

    f44 ()

    unit을 인자로 받는 모든 함수는 반환 타입에 해당하는 원소 하나를 뽑는 것과 동일하다고 생각할 수 있다. (이 예시에서는 정수 44를 뽑았다.)

    사실 f44는 숫자 44의 또 다른 표현이라고 볼 수도 있다. 이는 집합의 특정한 원소를 명시적으로 표현하는 것이 아니라 이를 함수(화살표)로 표현하는 것으로 대체하는 방법의 예시이기도 하다. unit에서 임의의 타입 A로 나아가는 함수(unit을 인자로 받아 A를 반환하는 함수)는 집합 A의 원소들과 일대일 대응 관계에 있다.

    그렇다면 void 타입을 반환하는 함수, Haskell에서는 unit 타입을 반환하는 함수는 어떨까? 보통 C++에서 이런 함수는 사이드이펙트를 표현하기 위해 사용되지만, 우리는 이미 이런 함수들이 수학적인 의미에서 진짜 함수가 아니라는 것을 알고 있다. 즉, unit을 반환하는 순수 함수는 아무 일도 하지 않고, 그냥 인자를 버리기만 하는 것이다.

    수학적으로 집합 A에서 단일원소 집합으로 향하는 함수는 집합 A의 모든 원소를 공역에 해당하는 단일원소 집합에 들어있는 원소 하나에 매핑한다. 따라서 모든 A의 원소에 대해 이러한 일을 하는 함수는 단 하나만이 존재할 수 있다.

    아래 예시를 보도록 하자.

    fInt :: Integer -> ()
    fInt x = ()

    이 함수는 임의의 정수를 인자로 입력받으면 반드시 unit을 반환한다. 앞서 언급했듯이 Haskell은 간결함을 추구하기 때문에 와일드카드 패턴인 언더스코어(_)를 사용하여 버려지는 인자를 표현할 수 있으며, 이 표현을 사용하면 인자의 이름을 지정할 필요도 없다.

    즉, 위의 예시는 아래와 같이 다시 표현해볼 수 있다.

    fInt :: Integer -> ()
    fInt _ = ()

    이 함수는 인자로 넘겨받는 값 뿐 아니라 인자의 타입에도 의존하고 있지 않다는 점에 주목하자.

    이처럼 임의의 타입에 대해서 항상 같은 형태로 구현될 수 있는 함수를 매개변수 다형성(Parametrically Polymorphic)을 가진 함수라고 한다. 구체적인 타입을 정하는 대신 타입 변수를 사용하여 하나의 방정식으로 다양한 함수들을 구현할 수 있다. 이렇게 임의의 타입을 받아 unit을 반환하도록 만들어져 다형성을 갖춘 함수를 뭐라고 부르면 좋을까?

    공교롭게도 이런 함수도 똑같이 unit이라고 부른다.

    unit :: a -> ()
    unit _ = ()

    C++에서는 이 함수를 아래와 같이 작성해볼 수 있을 것이다.

    template<class T>
    void unit(T) {}

    다음으로 살펴볼 타입의 유형은 두 개의 원소를 가진 집합이다. C++에서는 이를 bool이라고 부르며, Haskell에서도 이와 비슷하게 Bool이라고 부른다. 이 두 개념 간의 차이점은 C++의 bool은 built-in 타입인 반면에, Haskell에서는 아래와 같이 정의할 수 있다는 것이다.

    data Bool = True | False

    이 정의는 Bool 타입이 True 또는 False 중 하나임을 의미한다. 원칙적으로는 C++에서도 Enum을 사용하여 bool 타입을 정의할 수 있어야 한다.

    하지만 C++의 Enum은 내부적으로 정수를 나타낸다. C++11의 “Enum Class”를 사용할 수도 있겠지만, 이 경우 해당 타입의 값을 사용할 때 bool::false 처럼 항상 클래스 이름을 붙혀주어야 한다. 또한 bool Enum을 사용하는 모든 파일들에 적절한 헤더를 포함시켜줘야 한다.

    Bool 타입을 인자로 받는 순수 함수는 대상 타입 내에서 True, False 두 값을 선택한다.

    Bool을 반환하는 함수들은 predicates라고 불리는데, 예를 들어 Haskell 라이브러리인 Data.charisAlpha, isDigit 같은 predicates들로 가득 차있다. C++에도 이와 비슷한 라이브러리가 존재하며, 역시 isalpha, isdigit 같은 함수들도 있지만 이 녀석들은 bool 타입이 아닌 int 타입을 반환한다. 실제 predicates는 std::ctype 에 정의되어있으며, ctype::is(alpha, c), ctype::is(digit, c)와 같은 형태를 가지고 있다.

    원문 보기

    👉 Category Theory for Programmers

    Evan Moon

    🐢 거북이처럼 살자

    개발을 잘하기 위해서가 아닌 개발을 즐기기 위해 노력하는 개발자입니다. 사소한 생각 정리부터 튜토리얼, 삽질기 정도를 주로 끄적이고 있습니다.