튜플은 유한하고 순서가 있는 원소 목록이며, 한 번 만들면 바꿀 수 없어요. 튜플은 모든 위치가 고정된 타입을 가져야 해요. 따라서 컴파일러는 각 위치에 어떤 타입이 있는지 알 수 있어요. 튜플에 사용되는 타입은 위치마다 달라도 되지만, 그 타입들은 컴파일 시점에 알려져 있어야 해요.
튜플에 들어갈 값들의 타입을 컴파일 과정에서 해석할 수 있는지에 따라 튜플을 만드는 방법이 달라요. 값이 컴파일 시점에 알려져 있다면 튜플 리터럴 문법으로 튜플을 만들 수 있고, 그렇지 않다면 타입을 직접 선언해야 해요. 값의 타입이 튜플에 지정한 타입과 일치해야 하고, 값의 개수도 지정한 타입의 개수와 같아야 한다는 점도 중요해요. 다음은 튜플 리터럴 문법으로 정의하는 예시예요:
tuple = {1, "foo", 'c'} # Tuple(Int32, String, Char)
Tuple 클래스를 사용해 튜플을 만들 수도 있어요.
tuple = Tuple(Int32, String, Char).new(1, "foo", 'c')
또는 튜플이 할당될 변수의 타입을 직접 지정할 수도 있어요.
tuple : Tuple(Int32, String, Char) = {1, "foo", 'c'}
튜플의 타입을 직접 지정하면 특정 위치가 유니언 타입을 갖도록 정의할 수 있어서 유용해요. 즉, 하나의 위치가 여러 타입을 가질 수 있다는 뜻이에요.
tuple : Tuple(Int32 | String, String, Char) = {1, "foo", 'c'}
Tuple 클래스의 from 메서드를 사용하면 배열에서 튜플을 만들 수 있어요.
이때는 튜플의 타입을 지정해야 해요.
array = [1, "foo", 'c']
tuple = Tuple(Int32, String, Char).from(array)
to_a 메서드를 사용하면 튜플을 배열로 변환할 수 있어요.
변환된 배열의 원소 타입은 튜플에 있는 각 필드 타입의 합집합이에요.
tuple = {1, "foo", 'c'}
array = tuple.to_a
array # => [1, "foo", 'c']
배열과 마찬가지로 튜플의 인덱스는 0부터 시작해요. 즉, 첫 번째 원소의 인덱스는 0이에요.
하지만 배열과 달리 튜플은 각 원소의 타입이 고정되어 있고 컴파일 시점에 알려져 있어요. 따라서 튜플을 인덱싱할 때 원소의 타입은 위치마다 구체적으로 정해져 있어요.
튜플의 원소에 접근하려면 [] 연산자를 사용해요.
array = [1, "foo", 'c']
array[0] # => 1
typeof(array[0]) # => Int32 | String | Char
tuple = {1, "foo", 'c'}
tuple[0] # => 1
typeof(tuple[0]) # => Int32
배열에서 원소에 접근할 때와의 또 다른 차이점은, 인덱스를 지정하면 컴파일러가 그 인덱스가 튜플의 범위 안에 있는지 확인한다는 거예요. 즉, 런타임 오류 대신 컴파일 시점 오류가 발생해요.
tuple = {1, "foo", 'c'}
tuple[3]
# => Error: index out of bounds for Tuple(Int32, String, Char) (3 not in -3..2)
하지만 인덱스가 변수에 저장되어 있으면 컴파일러가 컴파일 시점에 그 인덱스가 튜플의 범위 안에 있는지 확인할 수 없어서, 대신 런타임 오류가 발생해요.
[] 연산자에 범위를 넘겨주면 튜플의 부분 튜플을 얻을 수 있어요.
지정한 범위의 원소들로 이루어진 새로운 튜플이 반환돼요.
범위는 컴파일 시점에 주어져야 해요. 그렇지 않으면 컴파일러가 부분 튜플에 있는 원소들의 타입을 알 수 없어요.
즉, 범위는 범위 리터럴이어야 하고 변수에 할당되어서는 안 돼요.
tuple = {1, "foo", 'c'}
subtuple = tuple[0..1] # Tuple(Int32, String)
i = 0..1
tuple[i]
# Error: Tuple#[](Range) can only be called with range literals known at compile-time
튜플은 값의 개수가 고정되어 있고 각 값의 타입이 컴파일 시점에 알려져 있는 값들을 하나로 묶고 싶을 때 유용해요. 튜플은 불변이기 때문에 배열보다 메모리를 적게 쓰고 더 빨라요. 또 다른 활용 사례는 메서드에서 여러 값을 반환하는 경우예요. 튜플은 위치마다 다른 타입을 가질 수 있으므로, 값들의 타입이 서로 다를 때 특히 유용해요.
크기가 늘어나거나 줄어들 수 있거나 자주 수정해야 하는 자료 구조가 필요할 때는 튜플을 사용하면 안 돼요.
심볼은 값을 가리키는 데 사용할 수 있는 이름이 붙은 식별자예요.
심볼은 이름 앞에 : 문자를 붙이는 심볼 리터럴로 만들어요. 예를 들어 :foo처럼요.
따옴표로 감싸서 쓸 수도 있어요. 예를 들어 :"foo"처럼 쓰면 이름에 공백을 넣을 수도 있어요.
:foo # => :foo
:"foo boo" # => :"foo boo"
:foo == :"foo" # => true
심볼은 언어 곳곳에서 사용돼요. NamedTuple의 키로 쓰이기도 하고, 메서드와 변수 이름을 나타내는 데에도 쓰여요.
Crystal의 심볼은 Ruby의 심볼과 상당히 달라요. Crystal에서 심볼은 일종의 상수이며, 따라서 컴파일 시점에 할당돼요. 즉, Ruby에서는 가능한 동적 생성이 심볼에서는 불가능해요.
Crystal에서 심볼은 Int32로 표현되어 매우 효율적이에요.
심볼은 문자열로 변환할 수 있지만 그 반대는 안 돼요. 심볼은 컴파일 시점에 만들어지고 문자열은 런타임에 만들어지기 때문이에요.
:foo.to_s # => "foo"
친구들을 위해 쿠키를 구울 준비를 하다가, 레시피에 쓰인 계량 단위 몇 가지를 변환해야 한다는 걸 알게 됐어요. 미터법에만 익숙한 상황이라면, 흔히 쓰이는 미국식 베이킹 계량을 편하게 다루기 위해 밀리리터(mL)로 변환하는 방법을 마련해야 해요.
풀이에는 이 변환표를 사용해요:
| 변환할 단위 | 부피 | 밀리리터(mL) 기준 |
|---|---|---|
| mL | 1 | 1 |
| 미국 컵 | 1 | 240 |
| 미국 액량 온스 | 1 | 30 |
| 미국 티스푼 | 1 | 5 |
| 미국 테이블스푼 | 1 | 15 |
프로그래머로 성장 중인 재능 있는 실력자답게, 나열된 어떤 단위에서든 다른 단위로(심지어 같은 단위로도) 변환하기 쉽도록 밀리리터를 중간 단위로 쓰기로 해요.
KitchenCalculator.get_volume 메서드를 구현해요.
부피 쌍 튜플이 주어지면 숫자 성분만 반환해야 해요.
KitchenCalculator.get_volume({:cup, 2.0})
# => 2.0
KitchenCalculator.to_milliliter 메서드를 구현해요.
부피 쌍 튜플이 주어지면 변환표를 사용해 부피를 밀리리터로 변환해야 해요.
KitchenCalculator.to_milliliter({:cup, 2.5})
# => {:milliliter, 600.0}
KitchenCalculator.from_milliliter 메서드를 구현해요.
부피 쌍 튜플과 원하는 단위가 주어지면, 변환표를 사용해 부피를 원하는 단위로 변환해야 해요.
KitchenCalculator.from_milliliter({:milliliter, 1320.0}, :cup)
# => {:cup, 5.5}
KitchenCalculator.convert 메서드를 구현해요.
부피 쌍 튜플과 원하는 단위가 주어지면, 주어진 부피를 원하는 단위로 변환해야 해요.
KitchenCalculator.convert({:teaspoon, 9.0}, :tablespoon)
# => {:tablespoon, 3.0}
Exercism에 가입하고 Crystal 트랙을 개념 26개연습 문제 142개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.