본문 바로가기

러스트의 기본 타입들

기술적인 이야기/기타 개발 2020. 7. 31.
반응형

프로그램의 정의는 데이터를 이용해 연산을 하거나 컴퓨터에 특정 명령을 내리는 것입니다. 특히 이 연산 작업이 핵심이고 이걸 하려면 타입(Types)이라는 것을 필수적으로 알아야 합니다. 이 글에서는 러스트에서 제공하는 기본 타입을 정리하려 합니다.

참고로 여기서 기본 타입이라는 의미는 원시 타입(Primitive Types)과 함께 별도의 use 선언 없이 쓸 수 있는 타입들이라는 의미입니다. 물론 자의적인 기준입니다.

수치 타입

숫자 데이터를 저장하거나 연산하기 위한 타입을 뭉뚱그려서 수치 타입이라고 칭하겠습니다. 이 분류는 크게는 정수형과 실수형으로 나눌 수 있습니다.

정수형(Integer Types)

수치 타입 중 가장 자주 사용하게 될 것이 바로 정수형입니다. 정수란 소수점 없이 깔끔(?)한 숫자를 말합니다. 디지털에 가장 최적화된 수치 타입이기도 하지요.

기본적으로 아래의 타입들이 러스트에서 사용하는 일반적인 정수형 타입입니다.

i8, i16, i32, i64, i128, isize

여기서 i의 의미는 integer 즉 정수를 의미합니다. 그리고 그 뒤로 오는 숫자들은 비트(bits) 단위입니다. 즉 i16의 경우 16비트 정수형을 담을 수 있는 타입입니다. 당연하게도 이 비트가 클수록 큰 숫자를 담을 수 있습니다. 앞으로는 대충 데이터를 담는 그릇의 크기라고 표현할게요.

let a = 10;
let b: i32 = -65539;

이런 식으로 정수를 담는 그릇이 바로 정수형입니다. 물론 위의 예에서 a는 별 다른 타입이 적혀있지 않은데, 러스트는 타입 추론이 가능한 언어라 대입되는 10이라는 숫자에서 a가 정수형이라고 판단하고 a의 타입을 자동으로 정수로 정의합니다. 참고로 제 맥에서 ai32 타입으로 선언되더군요.

위에 언급된 타입들 중 마지막의 isize의 경우는 좀 특이한데 일단 넘어갑시다.

일반적으로 정수는 양수만 쓰는 건 아니지요. -가 앞에 붙는 음수도 사용합니다. 그런데 컴퓨터는 이 음수가 필요 없는 경우가 있어서 아래와 같은 타입을 준비해 뒀습니다.

u8, u16, u32, u64, u128, usize

u로 시작되는 타입들은 부호 없는(unsigned) 정수형 타입입니다. 나머지는 그릇의 크기를 의미하는 비트를 의미하는 숫자가 동일하게 붙어 있습니다.

let a: u16 = 65535;

왜 부호를 빼는 것일까요? 일단 부호를 표기하는 비트를 데이터 용도로 더 쓸 수 있다는 점이 장점입니다. 이 경우 표현 가능한 최대 수치 2배로 증가하지요. 음수를 쓸 이유가 없는 경우라면 이런 부호 없는 타입이 더 적합할 것 같습니다.

이제 앞에서 그냥 넘어간 isizeusize에 대해 알아봅시다.

사실 이 두 타입에 붙은 size도 그릇의 크기를 의미합니다. 단지 차이가 있다면 시스템에 정의된 어떤(?) 크기를 사용한다는 점입니다. 예를 들어 구형 16비트 머신이라면 이 타입은 16비트일 수 있습니다. 32비트 머신에서 특정 OS에서는 32비트일 수도 있지요. 아래는 제가 시험해 본 코드입니다.

use std::mem::size_of;

fn main() {
    println!("size of i32 = {}", size_of::<i32>());
    println!("size of isize = {}", size_of::<isize>());
    println!("size of usize = {}", size_of::<usize>());
}

size_of() 함수를 이용하면 원시 타입의 바이트 크기를 알 수 있습니다. 이 코드를 제 맥북프로에서 돌아가는 macOS에서 돌려보니 아래와 같은 결과를 얻을 수 있었습니다.

size of i32 = 4
size of isize = 8
size of usize = 8

8은 바이트 단위로 비트로 바꾸면 64비트입니다. 즉 제 컴퓨터에서 isizei64, usizeu64를 의미합니다. 즉 이 size가 붙은 타입은 사용하는 OS나 컴퓨터에 따라 달라질 수 있습니다.

C 언어를 알고 계시다면 뭐 굳이 설명할 필요는 없겠지만 intunsigned int의 특징과 비슷하다는 것을 진작에 눈치채셨을 것입니다. 네 바로 그거예요.

뭔가 내용이 길어졌는데 이제 실수형으로 넘어가 봅시다.

실수형(Floating Types)

실수는 그 실수(mistake)는 아니고, 수학에서 무리수와 유리수를 합한 그 개념을 말합니다. 즉 소수점을 찍어서 정수보다 더 자세한 단위의 수치를 표현할 수 있는 수치 타입입니다.

이 '실수'의 영문 이름에 'float'라는 용어가 보이는데 뭔가 둥둥 떠다닌다는 의미이지요. 실제로 실수형의 또 다른 한국식 이름은 부동소수점 실수입니다. 여기서 부동이란 말은 '부동산'처럼 움직이지 않는다는 그런 의미가 아니라, 오히려 부유하면서 둥둥 떠다니며 움직인다라는 의미의 부동(浮動)입니다. 비트 상에서 소수점의 위치가 고정되지 않고 여기저기 이동한다는 의미이지요.

이런 실수형으로 아래 두 타입이 제공됩니다.

f32, f64

실수형은 32비트와 64비트의 두 가지 타입이 제공되는 것을 알 수 있습니다.

let a: f32 = 1.5;
let b: f64 = 3.0;
let c = 3.;     // 3.0 (f64)

실수형의 비트 크기도 그릇의 크기를 의미하지만, 이 그릇의 크기가 크다고 무조건 큰 수치를 담을 수 있다기보다는 담을 수 있는 수치의 정확도가 높아진다고 표현하는 게 맞을 것 같습니다. 아무래도 이 부분이 실수와 정수의 가장 큰 차이가 아닐까 생각되네요.

이 외의 정수형과의 차이로 실수형은 부호 없는(unsigned) 타입은 제공되지 않는다는 특징이 있겠네요.

그 외의 특징으로 floatdouble의 관계와 비슷하게 f32f64는 서로 간에 호환성이 없는 것 같습니다.

let a: f32 = 1.5;
let b: f64 = 3.5;
let c = a + b;

위 코드의 3번째 라인은 아래와 같은 컴파일 오류를 일으킵니다.

mismatched types, cannot add 'f64' to 'f32'

보통 이런 경우 컴파일러에서 큰 타입으로 형변환(casting)을 해서 알아서 처리해 주는 경우가 많은데, 러스트의 경우 시스템 프로그래밍을 위해서 탄생한 언어라서 그런지 몰라도 이런 유연성은 오히려 지원하지 않는 것 같다는 느낌을 받았습니다.

이런 코드는 아래와 같이 직접 형변환을 이용해 간단히 해결할 수 있습니다.

let a: f32 = 1.5;
let b: f64 = 3.5;
let c = a as f64 + b;

여기서는 그릇이 큰 타입으로 형변환해서 통일했는데, 사이즈가 큰 타입으로 형 변환하는 것이 오버플로우를 막을 수 있거나 정확도 측면에 있어서 더 유리하겠지요. 따라서 다른 특별한 이유가 없다면 가급적 큰 그릇의 타입으로 형 변환하는 것을 권장합니다.

 

범위형(Range)

범위 타입은 Swift에 익숙하다면 뭔지 감이 올 수도 있습니다. 시작과 끝 인덱스를 이용해 특정 수치의 범위를 표현하기 위한 특수한 타입이지요. 심지어 간단 선언 방법조차도 Swift와 비슷합니다.

let r = 0..10;
println!("r = {} ~ {}", r.start, r.end);

위 코드에서 r의 초기값으로 사용된 0..10의 의미는 시작 인덱스로 0, 끝 인덱스로 10을 의미하는 범위 표현입니다. 이 코드는 아래와 같이 표현할 수도 있습니다.

let r = std::ops::Range { start: 0, end: 10 };

그냥 0..10과 같은 간단한 표현이 역시 간단해서 좋은 것 같습니다.

범위는 범위(?)이기 때문에 포함되어 있는 수치를 판단하거나, 혹은 범위가 좁아서 중간에 특별한 수치가 들어있을 가능성이 없는 경우 등을 판단하는 메서드를 제공합니다.

if r.contains(&5) {
    // ...
}

if r.is_empty() {
    // ...
}

위의 사용 방법도 범위형의 유용한 사용 방법들이지만, 데모(?) 용도로는 보통 아래와 같이 for 루프에서 사용되는 경우도 유명할 것 같습니다.

for i in 0..10 {
    println!("{}", i);
}

참고로 위의 코드는 0에서 9까지를 콘솔에 찍습니다. 끝 인덱스는 10이지만, 보통 이런 범위는 시작 인덱스는 포함하는 반면 큰 인덱스는 포함하지 않고 생각하는 것이 일반적이게 되어서 그런 것 같습니다.

범위는 아래와 같이 ..=를 이용해 표현하면 끝 인덱스도 포함된 형태의 범위를 만들 수 있습니다.

for i in 0..=10 {
    println!("{}", i);
}

위 코드는 0에서 10까지를 콘솔에 표시합니다.

 

문자형

C 언어의 char 타입과 거의 비슷하게 러스트도 동명의 char 타입이 제공됩니다.

let ch: char = 'A';

char은 문자(Character)의 앞 4글자를 따온 의미로 볼 수 있습니다. 그리고 그 이름처럼 하나의 문자를 담기 위한 타입입니다.

문자 타입에 담게 되는 데이터는 대체로 ASCII 코드인 경우가 대부분이지요. 아래와 같이 정수형으로 변환하면 ASCII 코드를 알 수도 있습니다.

def main() {
    let ch = 'A';
    println!("{} == {} == {}", ch, ch as i32, ch as u8 as char);
}

위의 코드는 A라는 문자의 ASCII 코드 번호 65를 알 수 있습니다. 즉 형 변환을 통해 char 타입의 수치가 무엇인지 확인할 수 있습니다. 이는 C 언어와도 거의 동일한 특징입니다. C 언어의 char 타입은 8비트 정수형 타입이기도 하지요.

참고로 러스트에서 char로 형변환 가능한 타입은 u8 뿐입니다. i8char로 형변환을 하려 하면 컴파일 에러가 발생합니다. 이는 어찌 보면 당연한데, 표준이든 확장이든 ASCII 코드는 음수는 사용하지 않고 127 혹은 그 이상의 정수로 정의되어 있습니다. 그렇다면 u8이 딱 맞겠지요.

어쨌거나 이 타입은 문자 하나만을 담을 수 있기 때문에 너무 작은 그릇입니다. 그렇다면 더 큰 무언가도 있겠지요.

 

기본 문자열(str)

문자열의 가장 단순한 정의는 문자가 2개 이상 모인 연속된 데이터를 의미한다고 볼 수 있습니다. 아 물론 한 글자로도 문자열을 만들 수 있는데, C 언어에서 유래한 C 문자열은 끝에 \0이라는 NULL 문자가 꼭 존재해야 하기 때문에 결과적으로 2개 이상의 문자(char)가 모여야만 문자열이 되지요. 러스트도 이와 유사할까요? 아직은 잘 모르겠습니다만...

위에서 이야기한 C 문자열(C String)과 가장 비슷한 타입이 바로 str입니다. 다만, str이라는 타입은 특이하게도 스택에 생성된 문자열 메모리를 가리키는 포인터로 주로 이용되기 때문에 원래의 이름보다는 &str이라는 이름으로 주로 표현됩니다. 참고로 여기서 사용된 &는 C 언어와 비슷하게 해당 변수의 포인터를 돌려주는 오퍼레이터입니다.

let a = "string a";
let b = "string b";
println!("a = {}, b = {}", a, b);

러스트의 문자열은 다행히도(?) UTF-8으로 인코딩 된 유니코드 문자열이라 한글 표현도 문제없이 가능합니다.

C 언어와 동일하게 러스트의 &str도 스택에 연속으로 나열된 데이터의 포인터를 액세스 하는 방식을 사용하기 때문에 상대적으로 빠른 퍼포먼스를 보여줄 거라고 생각됩니다. 물론 잘 사용해야 가능한 이야기지만요.

다만 원시 타입 형태로만 제공되는 타입이기 때문에 좀 더 고급 문자열 변조 기능을 사용하기 위해서는 이 타입 보다는 아래 컬렉션에서 소개할 String 타입을 사용하는 것이 좋을 것 같습니다.

 

배열(Array)

배열은 동일한 타입의 여러 아이템이 연속적으로 배열된 리스트라고 정의할 수 있습니다. 뭔가 현대적인 언어에서 제공하는 리스트(list)와 비교할 때 상당히 제약이 심한 것 같은데 사실 제약이 정말 심합니다. 왜냐하면 메모리 상에 연속해서 나열하는 데이터를 표현하기 위한 타입이니깐요. 그래서인지 컬렉션 타입으로 취급해 주지 않고 원시 타입에 가깝게 취급(?)됩니다. 이쯤 되면 C 언어의 배열과 정말 흡사하다고 느낄 수 있습니다.

러스트의 배열은 [T; size]으로 표기합니다.

let arr: [i32; 5] = [1, 2, 3, 4, 5];
println!("number of items in arr = {}", arr.len());
println!("second item in arr = {}", arr[1]);

러스트의 배열도 C 언어의 배열과 비슷하게 반드시 정해진 한 타입의 원소들로 구성되며 컴파일 시 반드시 크기가 결정되어야 합니다. 거기다 타입과 개수를 반드시 명시해야 하는 점도 좀 특이하다면 특이한 것 같습니다.

그래도 .len() 메서드로 배열의 크기를 알 수 있게 해 준 점은 좀 친절하다고 할 수 있을 것 같네요.

배열을 생성할 때 연속된 원소로 배열을 초기화하려면 아래와 같이 할 수도 있습니다.

let arr: [i32; 100] = [0; 100];

이렇게 하면 arr 배열은 100개의 32비트 정수 0으로 채워진 배열로 생성됩니다.

 

튜플(Tuples)

튜플은 하나 이상의 다양한 타입의 데이터를 담을 수 있는 배열과 비슷한 컨테이너 타입입니다. 어떤 언어에서 먼저 생긴 개념인지는 모르겠지만 개인적으로 파이썬(Python)에서 튜플 개념을 처음 봤었는데 그것과 비슷합니다.

let t = (10u8, 20u16, 30u32, 40u64, "tuple");
println!("{:?}", t);

위의 코드에서 다양한 타입의 4개의 값을 괄호를 이용해 하나의 변수 t로 묶은 것을 볼 수 있는데 이것이 바로 튜플입니다. 이 코드의 실행 결과는 예상대로 (10, 20, 30, 40, "tuple")을 콘솔에 출력합니다.

튜플의 각 원소를 읽으려면 아래처럼 액세스 할 수 있습니다.

let a = t.0;
let b = t.1;

마치 배열의 원소에 접근하는 것과 비슷하게 각 인덱스를 이용해 튜플의 원소 아이템에 접근할 수 있습니다.

혹은 튜플을 한 번에 풀어버리는 방법도 있습니다.

let (a, b, c, d, e) = t;

위의 코드는 튜플의 각 원소를 각 위치에 해당하는 변수에 풀어줍니다.

튜플은 이렇게 다양한 타입의 값을 묶는다는 것만으로도 사용도가 높습니다. 특히 개인적으로는 함수(function)의 반환 값이 두 개 이상 필요한 경우 별도의 타입을 만들지 않고도 이를 실현해주는 점이 제법 유용하다고 생각합니다.

 

컬렉션 타입(Collection Types)

컬렉션 타입은 배열과 비슷하게 다양한 개수의 아이템을 받기 위한 특대형 특수 그룻(?) 타입입니다. 하지만 원시 타입과는 다르게 별도의 모듈로 구현되어 있기 때문에 내용이 방대할 수 있어서 여기서는 간단한 소개만 언급하겠습니다.

문자열(String)

앞서 살펴본 기본 문자열과는 다른 고급(?) 문자열은 String이라는 모듈로 제공됩니다. &str에 비해 힙 영역에 생성되고 문자열 처리를 위한 여러 기능을 제공하기 때문에 편하지만 대신 상대적으로 느리고 메모리도 좀 더 먹는 편이지요.

이 문자열 인스턴스를 생성하는 방법은 대표적으로 아래 세 방법이 있습니다.

fn main() {
    let a = String::from("string a");
    let b = "string b".to_string();
    let c: String = "string c".into();
    println!("a = {}, b = {}, c = {}", a, b, c);
}

String::from()to_string()&str에서 String을 생성하는 데 사용할 수 있습니다.

into()&str에서 String을 생성하는 데 사용할 수 있습니다. 하지만 into() 자체가 문자열에서만 쓰는 함수가 아니기 때문에 타입 추론이 안 됩니다. 그래서 위 예제의 b 변수와 같이 타입을 명시하거나 다른 방법으로 타입 추론이 가능해야 사용이 가능합니다.

이 외에도 이 글에 언급하기에 부적절(?)할 정도로 많은 기능이 제공되니 다음 기회에 더 살펴보겠습니다.

벡터(Vec)

Vec는 사실 다른 언어에 익숙하다면 리스트(List)에 가까운 자료구조입니다. C++이나 러스트에서는 벡터(Vector)라는 이름으로 취급하고 있는 게 좀 신기하기도 합니다. 어쨌든 고정된 타입의 하나 이상의 아이템을 담을 수 있는 대표적인 컬렉션 타입입니다.

let list = vec![1, 2, 3, 4, 5];
println!("list item count = {}", list.len());
println!("list items = {:?}", list);

벡터의 경우 수정 가능(Mutable)일 경우 아이템 추가와 삭제가 배열에 비해 자유로운 편입니다. 물론 위의 예제는 변경 불가능(immutable)한 타입을 사용한 경우라 좀 다르긴 합니다.

어쨌든 기회가 되면 벡터에 관해서 별도로 정리해 보기로 하고 넘어가겠습니다.

 

제어용 타입

사실 이런 이상한 타입을 분류하는 사람은 저 밖에 없을지도 모르겠습니다. 제어용 타입은 제 자의적인 분류로 논리 타입 혹은 이와 비슷한 용도의 타입을 묶은 분류입니다.

bool

논리 타입의 대표 격은 역시 이진 타입이죠. bool이라는 친숙한 이름의 타입이 제공됩니다. bool 타입이 가질 수 있는 값은 truefalse라는 역시 친숙한 두 가지 값이 있습니다.

let a = true;
let b: bool = false;

if a {
    println!("a is true!");
}

macOS에서 확인하니 bool 타입은 한 바이트짜리 정수로 선언되는 것 같습니다. 물론 바이트 팩(byte pack) 같은 시대에 뒤떨어지는(?) 일을 하지 않는다면 크기는 무의미하겠지요.

Result

Result라는 타입은 enum으로 선언된 열거형 타입입니다. 하지만 러스트의 다양한 기능에서 이 Result를 이용해 실행 결과를 표현하는 경우가 많습니다. 마치 bool의 용도를 대체하고 있다고 생각되지요. 그래서 개인적으로 이 Result 타입을 제어용 타입으로 묶어서 같이 소개합니다.

Result타입은 성공이나 결과를 의미하는 Ok 그리고 실패를 의미하는 Err 중 하나의 값을 가질 수 있는 타입입니다. 다른 언어에서도 비슷한 이름으로 거의 동일한 용도로 제공되기도 하기 때문에 익숙한 분들이 계실지도 모르겠습니다.

let a: Result<i32, &str> = Ok(100);
let b: Result<i32, &str> = Err("Error Messages!");

앞서 이야기한 대로 Result 타입은 Ok 혹은 Err이라는 값을 가지는데, generic이기도 하기 때문에 각각 원하는 타입을 데이터를 가질 수 있습니다. 그래서 위처럼 Result를 선언할 때 다양한 연관 타입을 명시해서 사용합니다.

이 Result 타입을 처리할 때는 대표적으로 match 구문을 사용할 수 있습니다.

match a {
    Ok(v) => println!("result is {}", v),
    Err(e) => println!("error is {}", e),
}

혹시 다른 언어에 익숙하신가요? switch-case 구문을 아시나요? 그렇다면 match 구문이 switch-case와 비슷하다고 느껴진다면 정답입니다. 성공일 때의 값 v와 오류일 때의 값 e를 구해서 여러 좋은(?) 용도로 활용할 수 있습니다.

위 코드는 아래와 같이 if문을 이용해 표현할 수도 있습니다.

if let Ok(v) = a {
    println!("result is {}", v);
} else if let Err(e) = a {
    println!("error is {}", e);
}

어떤 방법을 사용할지는 개발자 마음이지요. 물론 match를 쓰는 편이 좀 더 간단해 보이기는 합니다.

그 외에 Result 타입의 성공이나 실패 여부를 판단하기 위해 다른 메서드를 활용할 수도 있습니다.

if a.is_ok() {
    println!("a is ok!");
}

if b.is_err() {
    println!("b is error!");
}

위의 경우도 다양한 메서드 중 몇 가지 예일뿐입니다.

Option

과연 이 타입을 제어용 타입으로 묶어야 하나 고민을 좀 했습니다만 제어 쪽에 무게를 두는 것이 더 좋겠다는 결론을 내렸습니다. 어쨌든 Option은 Swift나 Python의 Optional을 알고 있다면 이해할 수 있는 타입이라고 생각됩니다. 다른 표현으로 Nullable이라고 하기도 하지요. 즉 NULL을 명시할 수 있는 타입을 의미합니다.

NULL의 의미요? '값이 없음'을 명확하게 표현하기 위한 존재로 볼 수 있습니다. 아 참고로 러스트에서는 NULL이 아니라 None이라고 표현합니다. 파이썬에 익숙하다면 친근하게 느껴질 이름이지요.

let a = Some(5i32);
let b: Option<i32> = None;

러스트의 Option은 위와 같은 방식으로 정의할 수 있습니다. 위에서 a 변수는 특정 정수 데이터를 Some이라는 값으로 감싸고 있습니다. 그리고 b 변수는 None이라는 값을 가지게 되어서 값이 없는 상태를 만들었습니다.

이런 Option도 열거형으로 구현되어 있기 때문에 위의 Result 타입과 비슷한 방식으로 값의 존재 여부나 값을 가져오는 방식을 사용할 수 있습니다.

match a {
    Some(v) => println!("a has value {}", v),
    None => println!("a is not exists"),
}

Some인 경우는 값이 있는 상태이고 그 값을 받아서 사용할 수 있지요. Swift에서는 옵셔널에서 데이터를 구하는 것을 벗겨내기(unwrapping)이라고 표현하는데 러스트에서는 뭐라고 칭하는지 모르겠네요.

이 밖에 Option을 벗겨내기 위해서 if let 구문을 사용하거나, 혹은 None 여부를 파악하기 위해 .is_none()을 사용할 수도 있습니다.

if let Some(v) = a {
    println!("a has value {}", v);
}

if b.is_none() {
    println!("b is none");
}

이 외에도 다양한 기능이 제공되리라 생각합니다.

 

기타 관련 정보

이 항목은 타입과 직접적이진 않지만 관련이 있는 몇 가지 항목을 정리합니다.

변수(Variables)와 상수(Constants) 선언

사실 위의 글에서 진득(?)하게 변수 선언을 하는 코드를 써왔기 때문에 아마도 러스트를 모르셨더라도 눈치채셨을 수도 있습니다. 바로 let 키워드가 변수를 선언할 때 사용하는 키워드입니다.

let a = 10;

이런 식으로 변경이 불가능한(immutable) 변수가 생성됩니다. 그리고 변조가 불가능하기 때문에 이후 a의 값을 변경하려는 코드는 컴파일 오류가 발생하게 됩니다.

변경 가능한(mutable) 변수는 아래와 같이 mut 속성을 추가하면 생성할 수 있습니다.

let mut a = 10;

이렇게 하면 a 변수의 값을 마음대로 바꿀 수 있습니다.

그런데 앞서 등장한 변경이 불가능한(immutable) 변수는 상수(Constants)라는 말일까요? 아뇨 좀 다릅니다. 러스트의 상수는 아래와 같이 표기합니다.

const a: i32 = 10;

const 키워드를 이용해 상수를 선언할 수 있습니다. 그리고 상수는 타입 추론이 지원되지 않기 때문에 반드시 위처럼 타입을 명시해야 합니다.

자 그럼 변조 불가능과 상수의 차이는 무엇일까요?

상수는 런타임으로 생성할 수 없는 변수입니다. 즉 아래와 같은 방식으로 생성할 수 없습니다.

fn some_value() -> i32 {
    return 1 + 2;
}

fn main() {
    const a: i32 = some_value();
    println!("{}", a);
}

위의 코드에서 a를 선언한 부분에서 컴파일 오류가 발생합니다. 상수의 초기값은 런타임 값으로는 정의할 수 없다고 말이지요.

하지만 let으로 선언하면 이런 제약이 없습니다.

물론 변수와 상수 사이에는 이 말고도 더 깊은(?) 차이가 있을 순 있겠지만 여기서 언급하기엔 길어질 것 같으니 다음 기회로 미루겠습니다.

값을 이용한 타입 추론(Type Inference)

러스트는 정적 타이핑 언어이며 컴파일할 때 타입이 반드시 확정되어야 합니다. 여기서 타입 확정은 변수의 타입을 확실하게 명시하는 방법으로 할 수도 있겠지만, 대입하는 값을 이용해 타입 추론이 가능하기도 합니다.

let a = 100;
let b = 200i64;
let ch = 100u8;

위의 코드의 경우 컴파일러가 알아서 각 변수가 차례대로 i32, i64, u8 타입을 가지는 것으로 이해하고 컴파일을 합니다.

위의 세 줄에서 2, 3번 라인은 값 뒤에 i64u8이 적혀있는데 이는 러스트 특유의 문법으로 값 뒤에 타입을 명시함으로써 정확히 타입 추론을 도와주는 용도입니다. 첫 번째 100의 경우 이 값이 i32인지 i16인지 컴파일러는 명확하게 알 수가 없지만 이런 식으로 수치 뒤에 타입을 명시할 수도 있지요.

실수형의 경우도 비슷하게 추론이 됩니다.

let a = 1.0f32
let b = 1.0f64

주의할 점은 1.f32와 같이 점(.) 뒤에 바로 타입을 적으면 문법 오류가 발생합니다. 따라서 반드시 숫자로 끝나도록 1.1.0으로 명확히 적어야만 가능합니다.

형 변환(Type Casting)

이미 앞서 예제에서 몇 번 등장했지만 as를 이용하면 형 변환이 가능합니다.

let a = 64u8;
let b: char = a as char;

이해하기 어렵진 않아서 자세한 설명은 생략합니다.

별명(Aliasing)

러스트도 다른 언어들과 비슷하게 타입의 다른 이름을 지어줄 수 있는 방법을 제공합니다. type 명령을 사용하면 특정 타입의 별명을 지을 수 있습니다.

fn main() {
    type Byte = u8;
    let ch = 'a';
    let ch_num = ch as Byte;
    println!("{} = {}", ch, ch_num);
}

위의 예에서는 u8 대신 Byte 라는 타입 별칭을 지어서 사용하고 있습니다. 특이하게도 최상위 스코프(scope)가 아니라도 원하는 스코프 내에서 별명을 지어줄 수 있는 것 같습니다.

변수의 별명은 잘 활용하면 코드의 가독성을 높여줄 수 있습니다. 하지만 무분별할 별명 짓기는 오히려 코드 읽기를 힘들게 할 수도 있다는 점을 명심합시다.

 

맺음말

이것으로 러스트의 기본 타입들을 살펴 봤습니다. 과연 이 내용들이 제가 정의하는 기본 타입의 전부인지는 잘 모르겠습네요. 심지어 글을 쓰면서 처음 생각했던 양에서 30% 정도는 더 새로운 것을 발견하면서 너무 길어진 것 같네요.

공부를 하다 보니 Swift나 Python에서 보던 것들과 비슷한 개념들이 보여서 좀 반가운 면이 제법 있었습니다. 아무래도 현대적인 언어들은 닮아가기 마련인데, 러스트 같이 시스템 프로그래밍을 위한 언어에서 많은 고급 언어들의 기능을 보인다는 것은 엄청난 일일지도 모르지요. 정말 이러다 C 언어를 대체하는 게 현실이 될 것 같습니다.

다음 글은 러스트의 제어 구조에 대한 주제로 이어집니다.

728x90
반응형

댓글