이번 4강의 시간에는 하스켈의 리스트와 튜플에
대해 배워보도록 하겠습니다.
리스트와 튜플
하스켈에서는 여러 개의 값을 관리하기 위한
근본적인 구조체가 두 가지가 있는데
바로 리스트와 튜플입니다 둘다 여러 값을
하나의 합성값으로 묶음으로써 작동합니다.
리스트
먼저 GHCi 에서 리스트 몇 개를 만들어 봅시다.
Prelude> let numbers = [1,2,3,4]
Prelude> let truths = [True, False, Flase]
Prelude> let strings = ["it's", "perfect", "good", "strings"]
각괄호는 리스트의 범위를 제한하며
개개인의 원소들은 쉼표로 구분합니다.
여기서 중요한 제약으로는 리스트 내의
모든 원소는 타입이 같아야 합니다.
타입이 혼재된 원소들의 리스트를 정의하려고
한다면 전형적인 타입 오류가 발생합니다.
Prelude> let conict = [True, "Focus"]
<interactive>:10:21: error:
• Couldn't match expected type ‘Bool’ with actual type ‘[Char]’
• In the expression: "Focus"
In the expression: [True, "Focus"]
In an equation for ‘conict’: conict = [True, "Focus"]
리스트 구축하기
각괄호와 쉼표를 이용해 리스트 전체를 한 번에
기입하는 방법 외 "cons"라고 부르는 ( : ) 연산자를
사용해서 한 조각씩 쌓아 올릴 수도 있습니다.
이렇게 리스트를 구축하는 공정을 컨싱(consing)이라
부르기도 합니다.
이 것은 원소를 리스트 앞에
붙이는 특정 작업을 지칭합니다.
예: 리스트에 무언가를 컨싱하자
Prelude> let numbers = [1,2,3,4]
Prelude> numbers
[1,2,3,4]
Prelude> 0:numbers
[0,1,2,3,4]
무언가를 리스트에 컨싱하면 우리는
또다른 리스트를 돌려받는 것 입니다.
따라서 원하는 만큼 컨싱을 계속할 수 있고
cons 연산자는 오른쪽에서 왼쪽으로 평가 됩니다.
cons를 생각하는 보다 일반적인 또다른 방법은
첫번째 값을 왼쪽에, 전체 표현식을 오른쪽에
취한다고 보는 것 입니다.
예: 리스트에 많은 것을 컨싱하기
Prelude> 1:0:numbers
[1,0,1,2,3,4]
Prelude> 2:1:0:numbers
[2,1,0,1,2,3,4]
Prelude> 5:4:3:2:1:0:numbers
[5,4,3,2,1,0,1,2,3,4]
Prelude> 6:5:4:3:2:1:0:numbers
[6,5,4,3,2,1,0,1,2,3,4]
사실 하스켈은 모든 원소를 빈 리스트에
컨싱하는 방식으로 모든 리스트를 구축합니다.
쉼표와 괄호 표기는 편의 문법일 뿐 이고
그러므로 [1,2,3,4,5]와 1:2:3:4:5:[]는 완전히 동치입니다.
그런데 리스트 생성에는 잠재적인
함정이 있어 조심해야 하는데
True:False:[]와 같은 것은 더할 나위 없이
훌륭한 하스켈 코드지만 True:False 자체는 아닙니다.
Prelude> True:False
<interactive>:19:6: error:
• Couldn't match expected type ‘[Bool]’ with actual type ‘Bool’
• In the second argument of ‘(:)’, namely ‘False’
In the expression: True : False
In an equation for ‘it’: it = True : False
True:False는 익숙한 타입 오류 메시지를
배출하는데 cons 연산자 ( : )는 두 번째 인자로
리스트를 기대했는데 우리는 리스트에 무언가
매달 줄만 아는 것에게 Bool값을 줘버렸습니다.
그러니 cons를 쓸 때 다음을 기억하면 됩니다.
- 리스트의 원소들은 타입이 같아야 함
- 무언가를 리스트에 cons할 수만 있다.
다른 식으로는 X, 따라서 가장 오른쪽 항목은
리스트가 와야하며 왼쪽 항목들은 리스트가 아닌 독립 원소여야함
문자열은 리스트일 뿐
하스켈의 문자열은 문자들의 리스트일 뿐입니다.
이는 String 타입의 값을 여타 리스트처럼
조작할 수 있다는 뜻으로 가령 문자열을
쌍따옴표로 감싼 문자들의 순열로
쭉 입력하는 대신, Char 값들을 (:)로 연결하고
빈 리스트로 마무리짓거나 쉼표-각괄호 표기를
사용할 수 있습니다.
Prelude> "come" == ['c','o','m','e']
True
Prelude> "good" == 'g':'o':'o':'d':[]
True
쌍따옴표 문자열은 좀 더 편리한 구문일 뿐입니다.
리스트 내의 리스트
리스트는 무엇이든지 포함할 수 있습니다.
그것들이 모두 같은 타입이기만 한다면
리스트도 그런 것 중 하나이기 때문에
리스트는 다른 리스트를 포함할 수 있습니다.
인터프리터에서 이것을 시도해보면
Prelude> let listOfLists = [[1,2],[3,4],[5,6]]
Prelude> listOfLists
[[1,2],[3,4],[5,6]]
리스트의 리스트는 가끔 상당히 혼란스러운데
어떤 것들의 리스트는 그 어떤 것들 자체와는
같은 타입이 아니기 떄문입니다.
가령 Int 타입은 [Int] 타입과는 다르기 때문이죠
리스트의 리스트는 복잡하고 구조화된 데이터를
표현할 수 있는 유용한 방법입니다.
또한 하스켈의 타입 체계가 빛을 발하는
장소이기도 하고 프로그래머들은
리스트의 리스트를 다룰 때마다 항상
혼란에 빠지곤 하며, 타입에 제약을 걸면
잠재적인 문제를 헤치고 나가는데 도음을 줍니다.
튜플
튜플은 여러 값을 한 값에 담는 또다른 방법입니다.
튜플과 리스트에는 중대한 두 가지 차이가 존재하는데
- 튜플은 고정된 개수의 원소들은 가진다(변경이 불가능 immutable)
튜플에 cons를 할 수는 없다 따러서 몇 개의 값이 저장될지 미리
아는 경우에는 튜플을 쓰는 것이 타당하다 - 가령 한 점의 2D 좌표를 보관하기 위해 타입을 원한다면 점마다
값이 몇 개가 필요한지 알고 있으므로(2개, x좌표 y좌표)
튜플이 합리적이다. - 튜플의 원소들은 같은 타입일 필요가 없다.
가령 전화번호부 애플리케이션에서 세 개의 값 이름, 전화번호,
주소를 사람별로 묶어 처리하는 경우가 있다 - 묶어서 처리할 경우에는 세 값의 타입이 같지 않아
리스트는 별 도움이 되지 않을 것이다. 하지만 튜플을 사용하면
도움이 된다.
튜플은 괄호 안에 원소들을 쉼표로 구분해
생성합니다. 튜플의 견본을 몇 개 보자면
예: 튜플 견본
(True, 1)
("Hello world", False)
(4, 5, "Six", True, 'b')
첫 번째 튜플은 두 원소를 담습니다.
첫 번째는 True 두 번째는 1입니다.
두 번째 튜플도 두 원소를 가지는데 첫 번째는
"Hello World"이고 두 번째는 False입니다.
세 번째 튜플은 다섯 개의 원소로 이루어져
있으며, 첫 번째는 4(숫자) 두 번쨰는 5(다시 숫자)
세 번째는 "Six"(문자열), 네 번쨰는 True(불리언 값)
다섯 번쨰는 'b'(문자) 입니다.
명명법을 소개하자면 크기 n인 튜플을
n-튜플이라 표기합니다.
2-튜플(원소가 2개인 튜플)은 짝(pair),
3-튜플 세짝(triple)이라 합니다.
그보다 크기가 큰 튜플들은 사실 흔하지 않지만
굳이 명령 체계를 확장하자면 네짝(quadruple)
다섯짝(quintuple) 등이 되겠습니다.
튜플은 함수로부터 하나보다 많은 값을
반환받고 싶을 때도 편리합니다.
많은 언어에서는 둘 이상을 한꺼번에
반환하려면 그 함수에서만 쓰는
자료 구조로 감싸야 하고 하스켈에선
아주 편리한 대안으로 그 값들을
튜플로 반환 합니다.
튜플 내의 튜플 (다른 조합들)
리스트 내의 리스트를 보관하는 것과 같은
추론을 튜플에 적용할 수 있습니다.
튜플도 형체가 있으므로 튜플 내에 튜플을
저장할 수 있습니다(얼마든지 복잡한 중첩가능)
비슷하게 튜플의 리스트, 리스트의 튜플
같은 선상의 온갖 조합이 가능합니다.
예: 튜플과 리스트 중첩
((2,3), True)
((2,3), [2,3])
[(1,2), (3,4), (5,6)]
튜플의 타입은 그 크기 뿐만 아니라
리스트와 마찬가지로 그 튜플이 포함하고 있는
객체들의 타입도 고려해서 정의됩니다.
("Hello"32)
(47,"World")
가령 위처럼 튜플 ("Hello"32)와 (47,"World")는
그 근간이 다릅니다. 하나는 (String,Int) 타입
다른 건 (Int,String) 타입입니다. 여기에는
튜플의 리스트 구축에 관해 함의하는 바 있고
[("a",1),("b",9),("c",9)]와 같은 리스트는 만들 수
있으나 하스켈은 위 같은 리스트를 가질 수 없습니다.
값 휙득하기
리스트와 튜플이 쓸모가 있으려면
그 안에 포함된 값들에 접근할 수단이
필요합니다.
점의 2D 좌표를 나타내는 짝(2-튜플)
부터 시작하며, 체스판의 특정 사각형을
표현하려 한다고 상상해봅시다.
모든 rank(가로줄)에 1에서 8까지 이름표를
붙이고 file(세로줄)에도 비슷하게 할 수 있고
그러면 짝 (2, 5)는 rank 2와 file 5에 있는
사각형을 나타냅니다. rank가 주어지면
그 rank의 모든 조각을 찾는 함수를
정의한다고 할 때, 한 가지 방법은 모든 조각의
좌표를 훑어보고 rank 부분이 우리에게
요구된 그 행인지 보는 것 입니다.
한 조각의 (x, y) 좌표짝을 얻은 후에는
이 함수는 x(rank 좌표)를 추출해야 합니다.
그런 목적으로는 fst, snd 함수가 있습니다.
이것들은 짝의 첫번째와 두 번째 원소를 흭득합니다.
예: fst와 snd 사용하기
Prelude> fst (2, 5)
2
Prelude> fst(True, "boo")
True
Prelude> snd (5, "Hello")
"Hello"
이 함수들은 정의에 따라
짝에 대해서만 작동합니다.
리스트의 경우에는 head 및 tail이
fst 및 snd와 대략 비슷하며
이 함수들은 ( : )로 연결된
리스트를 분해합니다.
head는 리스트의 첫번째 원소로
평가되고 tail은 리스트의 나머지를
돌려줍니다.
예: head와 tail 사용하기
Prelude> 3:[8,6,0]
[3,8,6,0]
Prelude> head [3,8,6,0]
3
Prelude> tail [3,8,6,0]
[8,6,0]
다형성 타입
리스트의 타입은 그 원소들의 타입에 의존하며
원소의 타입에 각괄호를 감싸 표기합니다.
Prelude> :t [True,False]
[True,False] :: [Bool]
Prelude> :t ["hey", "my"]
["hey", "my"] :: [[Char]]
그러므로 Bool의 리스트는 [Char](문자열)의
리스트나 Int의 리스트와는 다른 타입입니다.
함수는 함수의 타입에 명시한 타입의 인자만
받아들이기 떄문에 실전에서 복잡한
문제가 생기곤 합니다.
head의 경우를 생각해보면
[Int], [Bool], [String]은 모두 다른 타입을
headInt :: [Int] -> Int, headBool :: [Bool] -> Bool
headString :: [String] -> String 등 모든 경우에
별도의 함수가 필요한 것처럼 보입니다.
그런데 이러면 너무 성가시고 무분별합니다.
무엇보다 리스트는 그것이 포함되는
값들의 타입에 무관하게 동일한
방식으로 조립됩니다.
우리는 리스트의 첫번째 원소를
얻어내는 절차도 모든 경우에
동일하기를 바랍니다.
다행이 모든 리스트에 대해 작동하는
head라는 단 하나의 함수가 존재합니다.
Prelude> head [True, False]
True
Prelude> head ["hey", "my"]
"hey"
이게 어떻게 가능할까? 평소대로 head의
타입을 확인해보면 좋은 힌트가 보입니다.
... 예제: 첫번째 다형 타입
Prelude> :t head
head :: [a] -> a
각괄호 안의 a는 타입이 아닙니다. 타입 이름은
항상 대문자로 시작한다는 것을 기억합시다.
이것은 타입 변수이며, 하스켈은 타입 변수의
자리에는 아무 타입이나 들어가는 것을
허용하고 있습니다.
타입 이론에서는 이것을 다형성이라 부르고
단일 타입만을 가지는 함수나 변수는 단일형이라 하고,
하나보다 많은 타입을 허용하기 위해
타입 변수를 사용하는 것들은 다형성이라 합니다.
예를 들어 head의 타입이 뜻하는 바는
임의 타입(a)의 값들의 리스트( [a] )를 취해서
같은 타입의 값을 반환한다는 것 입니다.
하나의 타입 시그니쳐 내에서 한 타입 변수의
모든 경우는 같은 타입이어야 함을
명심합시다. 예를 들자면
f :: a -> a
는 f가 임의 타입의 인자를 취해 인자와 같은
타입의 무언가를 반환한다는 뜻이고, 반면
f :: a -> b
는 f가 임의 타입의 인자를 취해 임의 타입의
결과를 돌려주는데 그 타입이 a 자라에
들어갈 타입과 같을 수도 같지 않을 수도
있다는 뜻으로 서로 다른 타입 변수들은
그 타입들이 반드시 서로 다르다고 명시하지
않습니다. 단지 다를 수도 있도가 말할 뿐..
예제: fst와 snd
앞서 보았듯 fst와 snd를 사용하면 짝의 일부를
추출할 수 있습니다.
fst 와 snd 의 경우를 살펴보면 두 함수는 인자로
짝을 하나 취해서 이 짝의 한 원소를 반환하고
무엇보다 짝의 타입은 리스트처럼 그 원소들의
타입에 의존하므로 이 함수들도 다형성이어야 합니다.
그리고 튜플은 일반적으로 그 내부 타입들이
동종일 필요가 없다는 걸 명심하고
따라서 이렇게 적으면
fst :: (a, a) -> a
fst가 짝의 첫 번쨰와 두 번째 원소가
같은 타입일 때만 작동하게 됩니다.
따라서 올바른 타입은
// fst와 snd 타입
fst :: (a,b) -> a
fst :: (a,b) -> b
fst와 snd의 타입 시그니처 말고는
아무 것도 몰랐다 해도 이것들이 각각
짝의 첫번째와 두 번째 원소를 반환한다고
추측할 수 있습니다. 일리가 있지만 다른
함수들도 이와 같은 타입 시그니처를
가질 수도 있고, 시그니처가 말해주는 것은
짝의 첫번째 및 두번째 부분과 타입이
같은 무언가를 반환해야 한다는 것 뿐입니다.
요약
이번 장에서는 리스트와 튜플이라는 두가지의
새로운 개념을 소개해 보았습니다.
둘 사이의 중요한 유사점과 차이점은
아래와 같습니다.
- 리스트는 각괄호와 쉼표로 정의됨: [1, 2, 3]
- 리스트는 그 원소들의 타입이 같은 한 무엇이든 담을 수 있음
- 리스트는 또한 cons 연산자 ( : )를 이용해 구축할 수 있고,
다만 무언가를 리스트에 cons하는 것만 가능
- 튜플은 괄호와 쉼표로 정의됨: ("Bob", 32)
- 튜플은 서로 다른 타입의 무엇이든 담을 수 있음.
- 튜플의 길이는 그 튜플의 타입에 반영되어 있음. 즉
길이가 다른 두 튜플은 타입이 다름
- 리스트와 튜플은 어떤 식으로든 결합될 수 있음
리스트의 리스트, 리스트의 튜플... 등등 하지만 그 조합이 타당하려면
그 것들의 기준을 만족해야만 함
참고자료
'백엔드 > Haskell' 카테고리의 다른 글
[Haskell] 하스켈 기초반 5강 - 타입의 기초2 (0) | 2023.02.05 |
---|---|
[Haskell] 하스켈 기초반 3강 - 타입의 기초 (0) | 2023.01.12 |
[Haskell] 하스켈 기초반 2강 - 진위값 (0) | 2022.12.29 |