3강에서는 하스켈의 타입에 대해 알아보겠습니다.
프로그래밍에서 타입은 비슷한 값들을 범주로
묶는 역할을 합니다.
하스켈에서 타입 체계는 코드 상에
실수를 줄여주는 강력한 수단이 됩니다.
프로그래밍은 여러 종류의 개제(entity)를
다루는 일들을 합니다.
두 수를 더하는 경우를 생각해보면
2 + 3
2와 3은 무엇인가?라고 묻는다면
당연하게 숫자입니다.
가운데 더하기 기호는 무엇인가?
분명 숫자는 아닌데
이것을 숫자 두 개를 가지고 할 수
있는 연산 즉, 덧셈을 뜻합니다.
만약 본인의 이름을 물어보고
"Hello"라고 답하는 프로그램을
생각해 봅시다.
여러분의 이름도, Hello라는 단어도
숫자도 아닌데 그럼 이것들은 어떻게
설명할 수 있을까요?
우리는 모든 단어와 문자을 통들어
텍스트라고 부를 수도 있습니다.
프로그래밍에서는 String(문자열)이라는
다소 이색적인 낱말을 사용하는데
이를 줄여 "문자들의 나열"의 줄임말 입니다.
하스켈에서는 모든 타입 이름이 대문자로 시작해야
한다는 규칙이 있다. 앞으로 이 관습을 유지할 것이다.
데이터베이스는 타입이라는 개념이 명확히 보여줍니다.
우리의 데이터 베이스에 사람들의 연락처에 관한
상세 정보를 보관하는 테이블이 있다 가정하고
일종의 개인 전화번호부인 셈인데 그 내용물은
성 | 이름 | 전화번호 | 주소 |
Sherlock | Homles | 743756 | 221B Baker Street London |
Bob | Jones | 655523 | 99 Long Road Street Villestown |
각 항목(entry)에 필드들은 값을 포함합니다.
Shelock은 값이고, 99 London..도, 655523도 값입니다.
이 예제의 값들을 타입의 관점으로 분류하보면
"성"과 "이름"은 텍스트를 포함하고
따라서 이 값들의 타입이 String이라고 말할수 있습니다.
언뜻 보면 주소를 문자열로 분류하고 싶겠으나
단순해보이는 주소의 이면에 상당히 복잡한 의미가
들어 있습니다. 주소를 어떻게 해석할지에 대해서는
다양한 관습이 있습니다. 가령 주소 텍스트가 숫자로 시작한다면
그 숫자는 그 집의 번지수일 수도 있습니다.
숫자가 아니라면 분명 그 집의 이름일 것이고
단순히 우편함 주소거나 사람이 어디 사는지
전혀 알려주지는 않습니다. 주소의 각 부분은 저마다
의미를 가집니다.
왜 타입이 유용한다?
개체들을 서술하고 분류하는 것이 프로그래밍적으로
어떠한 도움을 줄까? 타입을 정의하면
그것을 가지고 무엇이 가능하고 무엇이 불가능한지
기술할 수 있습니다. 이러면 거대한 프로그램을
관리하고 오류를 피하는 것이
훨씬 쉬워집니다.
반응형 커맨드 :type 활용
GHCi를 통해 타입이 어떻게 작동하는지 살펴보고
모든 표현식의 타입은 :type(줄여서 :t) 이라는
커맨드로 확인할 수 있습니다.
이전 과목의 불리언 값들에 시험해봅시다.
Prelude> :type True
True :: Bool
Prelude> :type False
True :: Bool
Prelude> :t (3 < 5)
(3 < 5) :: Bool
Prelude> :type it
it : [Char]
:: 기호는 "...이 다음의 타입을 가진다"라고 이해하고
타입 시그니처(type signature)를 뜻합니다.
:type은 하스켈에서 진위값들의 타입이 Bool임을
드러냅니다. 불리언 값들은 단순히 값 비교를
위한 것이 아니라는 것에 주의합시다.
Bool은 예/아니오 답의 의미를 포착하고
따라서 그런 종류의 모든 정보를 표현할 수 있습니다.
예를 들어 스프레드시트에 어떤 이름이 있는지
사용자가 on/off 옵션을 토글했는지에 대한 정보
문자와 문자열
무언가 새로운 것에 :t를 써봅시다.
리터럴 문자는 따옴표로 감싸 입력할 수
있습니다. 예를 들어 다음은 단일 문자 H라고 하고
Prelude> :t 'H'
'H' :: Char
즉 리터럴 문자 값은 Char타입을 가집니다.
하지만 따옴표는 단독 문자에만 가능하고
더 긴 텍스트, 즉 문자의 나열을 입력하려면
쌍 따옴표를 사용합니다.
Prelude> :t "Hello World"
"Hello World" :: [Char]
Char가 나오는 이유는 각괄호(square bracket)에
있습니다. [Char]은 여러 개의 문자가
졸졸이 이어져서 리스트를 형성하는 것을
뜻합니다. 하스켈은 모든 문자열을 문자들의
리스트로 취급합니다.
리스트는 하스켈에서 중요한 개념으로
조만간 자세히 배울 것입니다.
우연히도 하스켈에서는 사람 언어의 동의어와
썩 비슷하게 작동하는 타입 동의어라는 것이
있습니다.
하스켈에서 타입 동의어는 타입을 위한 또다를
이름으로, 가령 String은 [char]의 동의어로
정의되어 있기 떄문에 이 둘은
서로를 자유롭게 대처할 수 있습니다.
따라서 다음은 완벽히 타당하며
"Hello World" :: String
대부분의 경우 훨씬 가독성이 좋습니다.
이제부터는 대부분의 텍스트 값을
[Char] 대신 String이라 칭할 것입니다.
함수형 타입
지금까지는 값(문자열,불리언, 문자 등)이
어떻게 타입을 가지고 이런 타입이 어떻게 값을
분류하고 설명하는지 알아보았습니다.
이제는 하스켈의 타입 체계를 진정으로
강력하게 만드는 커다란 전환점을
맞이하고, 함수 역시 타입을 가집니다.
not 타입
not을 이용하면 불리언 값을 반전시킬 수 있습니다.
이 함수의 타입을 밝혀내려면 두 가지를
고려해야 하는데 바로 not이 입력으로 취하는 값의
타입과 반환하는 값의 타입입니다.
이 예제는 상황이 간단합니다.
not(반전시킬)은 Bool(반전된) 값을 반환합니다.
이것을 표현하자면
not :: Bool -> Bool
not은 Bool 타입의 무언가로부터 Bool 타입의 무언가로
가는 함수로 읽을 수 있습니다.
이 함수에 :t를 사용하면 예상대로 나옵니다.
Prelude> :t not
not :: Bool -> Bool
함수의 타입은 그 함수의 인자의 타입과
돌려주는 값의 타입으로 기술 됩니다.
chr과 ord
텍스트는 컴퓨터에 골칫거리로
가장 저수준에서 컴퓨터는 오직 1과 0밖에
모르는 수준입니다.
컴퓨터는 이진 체계로 작동하는데
이진수로 작업하는 것은
전혀 편하지 않기에
사람들은 컴퓨터가 텍스트를 보관할
수단을 만들어 냈습니다.
모든 문자는 일단 숫자로 변환되고
이 숫자는 이진수로 변환되 저장됩니다.
이것이 텍스트 한조각(문자들의 열)이
이진수로 부호환되는 절차며
우리는 대개 문자를 수학적 표상으로 부호화
하는 것에만 신경쓰는데
컴퓨터는 보통 이진수로의 변환을
알아서 처리하기 때문입니다.
문자를 숫자로 변환하는 가장 쉬운 방법은
모든 가능한 문자들을 적고 번호를 매기는 것입니다.
예를 들어서 'a'는 1 'b'는 2 순으로 대용할 수 있습니다.
ASCII 표준이 바로 이런 것인데
ASCII는 널리 쓰이는 128개의 문자에 순서대로
번호를 매깁니다. 물론 엉덩이 깔고 앉아서
커다란 참조표에서 부호화 할 때마다 문자를 일일이
찾는 것은 따분할 일이고
그런 일을 대신 해주는 chr('char'라발음)와
ord라는 함수가 있습니다.
예: chr와 ord의 시그너쳐
chr :: Int -> Char
ord :: Char -> Int
Char가 무엇을 뜻하는지 알고 있습니다.
위 시그니처에 Int는 처음 보는 타입으로
정수의 크기를 나타내고 여러 타입의 수 중
하나입니다. chr의 타입 시그니처는
Int 타입의 인자 즉 정수를 취해 Char 타입의
결과값으로 평가한다는 것을 알려줍니다.
ord는 그 반대이며, Char 타입의 값을 취해
Int 타입의 값을 반환합니다.
타입 시그니처로부터 얻은 정보로 어떠한
함수가 문자를 숫자 부호로 부호화하고(ord)
어떠한 함수가 그것을 다시 문자로 복호화하는지(chr)
분명히 보입니다.
좀더 확실히 하기 위해서는 여기 chr과
ord를 호출하는 몇 가지 예가 있습니다.
두 함수는 기본적으로 이용할 수 없고
그래서 GHCi에서 이것들을 실험하기 전
:moudle Data.Char(또는 :m Data.Char)로
이것들이 정의된 Data.Char 모둘을 불러와야 합니다.
Prelude> :m Data.Char
Prelude Data.Char> chr 97
'a'
Prelude Data.Char> chr 98
'b'
Prelude Data.Char> ord 'c'
99
Prelude Data.Char> chr 1
'\SOH'
Prelude Data.Char> chr 99
'c'
Prelude Data.Char> chr 88
'X'
인자가 둘 이상인 함수
지금까지 사용한 타입 시그니처는 인자가 하나인 함수에
충분하나 이런 함수의 타입은 어떨까요?
예: 인자가 하나보다 많은 함수
xor p q = (p || q) && not (p && q)
(xor은 베타적 or 함수로, 두 인자 중 하나만 True인
경우 True 평가, 그외 False로 평가됨)
인자가 여럿인 함수의 타입을 짜는 일반적인 절차로
모든 인자의 타입을 한 줄에 순서대로 쓰고
이것들을 -> 로 연결하는 것입니다.
최종적으로 그 줄의 끝에 결과값의 타입을 적고
바로 전에 -> 를 최종으로 놓습니다.
1. 인자의 타입을 써내려 간다.
이 경우 ( || )와 ( && )의 사용에서 p와 q가 Bool 타입이어야
한다는 것이 정해집니다.
Bool Bool
^^ p is a Bool ^^ q is a Bool as well
2.그 사이 -> 를 채운다
Bool -> Bool
3.결과값 타입과 마지막 -> 를 추가한다
여기서 기본적인 불리언 연산을 하므로 결과도 Bool
Bool -> Bool -> Bool
^^ We're returning a Bool
^^ This is the extra -> that got added in
최종 시그니처는 아래와 같습니다.
xor :: Bool -> Bool -> Bool
코드 안의 타입 시그니처
지금까지 타입의 이면에 있는 기본적 이론과 그 이론이
하스켈에 어떻게 적용되는지를 알아보았습니다.
이제 소스파일안에 함수에 타입 시크니처를 붙이는 방법을
알아봅시다 .다음은 앞서 예제로 든 xor 함수입니다.
xor :: Bool -> Bool -> Bool
xor p q = (p || q) && not (p && q)
우리가 할 일은 이게 전부입니다. 최대한 명료하게
하려면 시그니처는 대응하는 함수 정의 바로 앞에 놓습니다.
이렇게 추가한 시그니처는 두가지 역할을 맡습니다.
그 함수의 타입을 코드를 읽는 사람에게도
컴파일러 및 인터프리터에도 명확히 알립니다.
타입 추론
타입 시그니처가 인터프리터에게 함수의 타입을 알려주면
지금까지 타입 시그니처를 쓰지 않고
어떻게 하스켈 코드를 작성한것일까?
우리가 함수나 변수의 타입을 하스켈에게 알려주지 않으면
하스켈을 타입 추론이라는 절차를 통해
그 타입을 알아냅니다.
컴파일러는 알고 있는 것들을
타입으로 시작해 값의 나머지 부분의 타입을
알아냅니다.
-- 이 함수의 타입 시그너처는 의도적으로 생략됨
isL c = c == 'l'
isl은 인자 c를 취해 c === '1'을 평가한 결과를
반환합니다. 타입 시그니처가 없다면
c의 타입과 결과 값의 타입이 명시되지
않습니다. 하지만 표현식이 c === '1'에서
컴파일러는 '1'이 Char값이란 걸 알아냅니다.
c와 '1'이 (==)에서 항등 비교되고
(==)의 두 인자는 타입이 같아야 하기에
c는 반드시 char값이어야 한다는 결론이
나옵니다. 마지막으로 isL c는 (==)의
결과값이기 때문에 Bool이어야 합니다
그러므로 함수의 시그니처는 다음과 같습니다.
isL :: Char -> Bool
isL c = c == 'l'
타입 시그니처를 빼먹으면 하스켈 컴파일러는
이런 과정을 통해 그 타입을 찾습니다.
isL에 시그니처를 붙이거나 붙이지 않고
:t 를 사용해 검증해볼 수 있습니다.
타입 시그니처가 추론이 되는 것이라면
왜 작성해야 할까요?
어떤 경우 컴파일러가 타입을 추론하기에
정보가 부족해 시그니처가 필수적입니다.
타입 시그니처를 이용해 함수의 값의 최종
타입을 제한하는 경우도 있습니다.
지금은 그런 경우를 고려할 필요가 없으나
타입 시그니처를 명시할 이유는 그 외도
여러가지가 있습니다..
- 문서화: 타입 시그니처는 코드를 쉽게 읽게 만들고
대부분 함수는 그 이름과 타입을 보면 무슨 일을 하는지
충분히 추측할 수 있습니다. 물론 코드에 주석을 적절히
다는 것도 중요하나 타입을 확실히 하는 것도 만은 도움이 됩니다. - 디버깅: 함수에 타입을 달아놓고 함수 몸체 안에서 변수의
타입을 바꾸면 컴파일러가 컴파일 도중 여러분의 함수가
잘못되었음을 알려줄 것입니다. 타입 시그니처가 없으면
오류 있는 프로그램이 컴파일되고, 컴파일러가 잘못된 타입들을
할당할 수도 있습니다. 프로그램 실행전 이런 실수를 했다는 걸
알기에는 늦을 것 입니다.
타입과 가독성
보다 현실적인 예제를 통해 시그니처가 문서화에 어떻게
도움이 되는지 알아봅시다.
아래 코드는 작은 모듈로 GHC에 포함된 라이브러리들은
이런 식으로 코드를 조직화 합니다.
module StringManip where
import Data.Char
uppercase, lowercase :: String -> String
uppercase = map toUpper
lowercase = map toLower
capitalize :: String -> String
capitalize x =
let capWord [] = []
capWord (x:xs) = toUpper x : xs
in unwords (map capWord (words x))
이 작은 라이브러리는 문자열 조작 함수 3개를 제공합니다.
uppercase는 문자열을 대문자로, lowercase는 소문자로
capitalize는 모든 낱말의 첫글자를 대문자로 변환합니다.
각 함수는 String 값을 인자로 취해 다른 String으로 평가합니다.
우리는 이 함수들이 어떻게 작동하는지 몰라도 타입 시그니처를
들여다보고 인자들과 반환값의 타입을 바로 알 수 있습니다.
여기에 적절한 함수 이름도 곁들인다면 함수들을
어떻게 사용할지 충분히 알아낼 수 있습니다.
함수들의 타입이 같다면 시그니처를 한번만 쓸 수도 있다는
것에 주목하고 위에 uppercase와 lowercase처럼
함수들의 이름을 쉼표로 구분하면 됩니다.
타입은 오류를 방지한다
타입 있는 언어에서 타입은 오류 방지의 핵심입니다.
표현식들을 여기저기 전달할 때는 여기서 한 것처럼
타입이 일치하는지 확인 해야 합니다.
일치하지 않다면 컴파일할 때 타입 오류를 받게되고
여러분의 프로그램은 타입 검사를 통과하지 못할 것입니다.
이러면 프로그램에서 버그를 줄이는데 도움이 됩니다.
예를 들어 타입 검사가 없는 프로그램은
"hello" + "world" --- type error
프로그램에 이러한 줄이 있으면 컴파일에 실패하는데
두 문자열을 더할 수 없기 때문입니다.
프로그래머는 이와 비슷하게 생긴
결합 연산자(concatenation operator)를 의도했을 것 입니다.
이 연산자는 두 문자열을 하나로 합치는 연산자 입니다.
"hello" ++ "world" -- "hello world"
이런 오타는 쉽게 나오는 오타로
하스켈은 컴파일 시 이런 오류를 잡아냅니다.
프로그램을 실행하고 버그가 등장할 때까지
기다릴 필요가 없습니다.
프로그램을 수정하다보면 타입들도
변경하게 됩니다.
그런 변경이 의도되지 않거나
예상치 못한 결과라면 컴파일 시
바로 드러나게 됩니다.
하스켈 프로그래머들은 타입 오류를
모두 고치고 프로그램을 컴파일하면
대체로 "그냥 잘 돌아간다"고 말하고는 하는데
그 동작은 의도와 일치하지 않을 수 있으나
프로그램 자체는 터지지 않습니다.
하스켈은 다른 언어보다 런타임 오류가
훨씬 희귀한 프로그램입니다.
참고 자료
'백엔드 > Haskell' 카테고리의 다른 글
[Haskell] 하스켈 기초반 4강 - 리스트와 튜플 (0) | 2023.01.29 |
---|---|
[Haskell] 하스켈 기초반 2강 - 진위값 (0) | 2022.12.29 |
[Haskell] 하스켈 기초반 1강 - 변수와 함수 (2) | 2022.04.11 |