Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

데이터 타입

러스트의 모든 값은 특정한 데이터 타입(data type) 을 가집니다. 데이터 타입은 어떤 종류의 데이터가 지정되었는지를 러스트에게 알려 주며, 러스트는 이를 바탕으로 그 데이터를 어떻게 다뤄야 할지 알 수 있습니다. 여기서는 데이터 타입의 두 가지 하위 집합인 스칼라 타입과 복합 타입을 살펴보겠습니다.

러스트는 정적 타입(statically typed) 언어라는 점을 기억하세요. 즉, 컴파일 시점에 모든 변수의 타입을 알고 있어야 합니다. 컴파일러는 보통 값과 사용 방식을 보고 어떤 타입을 써야 하는지 추론할 수 있습니다. 하지만 2장의 “추측값을 비밀 숫자와 비교하기” 절에서 parse 를 사용해 String 을 숫자 타입으로 바꾼 경우처럼, 여러 타입이 가능한 상황에서는 다음처럼 타입 주석을 추가해야 합니다.

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

위 코드에 있는 : u32 타입 주석을 추가하지 않으면, 러스트는 아래와 같은 오류를 표시합니다. 이 오류는 컴파일러가 우리가 어떤 타입을 쓰고 싶은지 알기 위해 더 많은 정보를 필요로 한다는 뜻입니다.

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error

다른 데이터 타입에서도 여러 형태의 타입 주석을 보게 될 것입니다.

스칼라 타입

스칼라(scalar) 타입은 하나의 단일 값을 나타냅니다. 러스트에는 네 가지 기본 스칼라 타입이 있습니다. 정수, 부동소수점 수, 불리언, 문자입니다. 다른 프로그래밍 언어에서 이미 익숙할 수도 있습니다. 러스트에서 각각이 어떻게 동작하는지 바로 살펴봅시다.

정수 타입

정수(integer) 는 소수 부분이 없는 숫자입니다. 우리는 2장에서 이미 u32 라는 정수 타입을 사용했습니다. 이 타입 선언은 그 값이 부호 없는 정수(부호 있는 정수 타입은 u 대신 i 로 시작합니다)이며, 32비트 공간을 차지한다는 뜻입니다. 표 3-1에는 러스트의 내장 정수 타입이 나와 있습니다. 이들 중 어느 것을 사용해도 정수 값의 타입을 선언할 수 있습니다.

표 3-1: 러스트의 정수 타입

길이부호 있음부호 없음
8비트i8u8
16비트i16u16
32비트i32u32
64비트i64u64
128비트i128u128
아키텍처 의존isizeusize

각 변형은 부호 있음 또는 부호 없음일 수 있으며, 크기가 명시되어 있습니다. 부호 있음(signed)부호 없음(unsigned) 은 숫자가 음수가 될 수 있는지 여부를 뜻합니다. 즉 숫자와 함께 부호를 저장해야 하는지(부호 있음), 아니면 항상 양수이므로 부호 없이 표현할 수 있는지(부호 없음)를 말합니다. 종이에 숫자를 적는 것과 비슷합니다. 부호가 중요할 때는 더하기나 빼기 기호를 함께 적지만, 숫자가 양수라고 가정해도 안전할 때는 부호를 생략합니다. 부호 있는 숫자는 2의 보수 표현으로 저장됩니다.

각 부호 있는 변형은 −(2n − 1) 부터 2n − 1 − 1 까지의 수를 저장할 수 있습니다. 여기서 n 은 해당 변형이 사용하는 비트 수입니다. 따라서 i8 은 −(27) 부터 27 − 1 까지, 즉 −128 에서 127 까지 저장할 수 있습니다. 부호 없는 변형은 0 에서 2n − 1 까지 저장할 수 있으므로, u8 은 0 에서 28 − 1, 즉 0 에서 255 까지 저장할 수 있습니다.

또한 isizeusize 타입은 프로그램이 실행되는 컴퓨터의 아키텍처에 따라 달라집니다. 64비트 아키텍처라면 64비트, 32비트 아키텍처라면 32비트입니다.

정수 리터럴은 표 3-2에 보인 여러 형식으로 쓸 수 있습니다. 여러 숫자 타입으로 사용될 수 있는 숫자 리터럴은 57u8 처럼 타입 접미사를 붙여 타입을 지정할 수 있습니다. 또한 숫자를 읽기 쉽게 하기 위해 시각적 구분자인 _ 를 사용할 수도 있습니다. 예를 들어 1_0001000 과 같은 값입니다.

표 3-2: 러스트의 정수 리터럴

숫자 리터럴예시
10진수98_222
16진수0xff
8진수0o77
2진수0b1111_0000
바이트(u8 전용)b'A'

그렇다면 어떤 정수 타입을 써야 할까요? 잘 모르겠다면 러스트의 기본값이 대체로 좋은 출발점입니다. 정수 타입의 기본값은 i32 입니다. isizeusize 를 주로 쓰는 상황은 어떤 종류의 컬렉션을 인덱싱할 때입니다.

정수 오버플로

값이 0 에서 255 사이인 u8 타입 변수가 있다고 해 봅시다. 이 변수를 256처럼 그 범위를 벗어난 값으로 바꾸려 하면 정수 오버플로(integer overflow) 가 발생하며, 그 결과 두 가지 동작 중 하나가 일어날 수 있습니다. 디버그 모드로 컴파일할 때 러스트는 정수 오버플로 검사를 포함하며, 오버플로가 발생하면 프로그램이 런타임에 패닉 을 일으킵니다. 러스트에서 패닉 이란 프로그램이 오류와 함께 종료하는 것을 말합니다. 패닉은 9장의 [“panic! 으로 복구 불가능한 에러 다루기”] unrecoverable-errors-with-panic 절에서 더 자세히 다룹니다.

반면 --release 플래그를 써 릴리스 모드로 컴파일할 때는, 오버플로로 패닉을 발생시키는 검사가 포함되지 않습니다. 대신 오버플로가 발생하면 러스트는 2의 보수 래핑(two’s complement wrapping) 을 수행합니다. 간단히 말해, 타입이 담을 수 있는 최대값을 넘는 값은 해당 타입이 담을 수 있는 최소값 쪽으로 “되감기” 됩니다. u8 의 경우 256은 0이 되고, 257은 1이 되는 식입니다. 프로그램이 패닉을 일으키지는 않지만, 변수에는 여러분이 기대한 값이 아닐 가능성이 높은 값이 들어갑니다. 정수 오버플로의 래핑 동작에 의존하는 것은 보통 버그로 간주됩니다.

오버플로 가능성을 명시적으로 다루려면, 표준 라이브러리가 기본 숫자 타입에 제공하는 다음 메서드 계열을 사용할 수 있습니다.

  • wrapping_add 같은 wrapping_* 메서드: 모든 모드에서 래핑합니다.
  • checked_* 메서드: 오버플로가 있으면 None 을 반환합니다.
  • overflowing_* 메서드: 값과 함께 오버플로 여부를 나타내는 불리언을 반환합니다.
  • saturating_* 메서드: 값의 최솟값 또는 최댓값에서 포화시킵니다.

부동소수점 타입

러스트에는 소수점을 가진 숫자인 부동소수점 수(floating-point numbers) 를 위한 기본 타입도 두 가지 있습니다. 러스트의 부동소수점 타입은 각각 32비트와 64비트 크기의 f32, f64 입니다. 기본 타입은 f64 인데, 현대 CPU에서는 대체로 f32 와 속도가 비슷하면서도 더 높은 정밀도를 제공하기 때문입니다. 모든 부동소수점 타입은 부호가 있습니다.

다음은 부동소수점 수가 실제로 동작하는 예시입니다.

파일명: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

부동소수점 수는 IEEE-754 표준에 따라 표현됩니다.

숫자 연산

러스트는 모든 숫자 타입에 대해, 여러분이 기대할 만한 기본 수학 연산인 덧셈, 뺄셈, 곱셈, 나눗셈, 나머지 연산을 지원합니다. 정수 나눗셈은 0 쪽으로 버림하여 가장 가까운 정수를 얻습니다. 다음 코드는 let 문 안에서 각 숫자 연산을 어떻게 사용하는지를 보여 줍니다.

파일명: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Results in -1

    // remainder
    let remainder = 43 % 5;
}

이 문장들 안의 각 식은 수학 연산자를 사용하고, 하나의 값으로 평가된 뒤 변수에 바인딩됩니다. 러스트가 제공하는 모든 연산자 목록은 부록 B 에서 볼 수 있습니다.

불리언 타입

대부분의 다른 프로그래밍 언어와 마찬가지로, 러스트의 불리언 타입은 두 가지 가능한 값만 가집니다. truefalse 입니다. 불리언은 크기가 1바이트입니다. 러스트에서 불리언 타입은 bool 로 표기합니다. 예를 들면 다음과 같습니다.

파일명: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

불리언 값은 주로 if 식 같은 조건문에서 사용합니다. 러스트에서 if 식이 어떻게 동작하는지는 “제어 흐름” 절에서 다룹니다.

문자 타입

러스트의 char 타입은 언어에서 가장 기본적인 문자 타입입니다. 다음은 char 값을 선언하는 몇 가지 예입니다.

파일명: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

char 리터럴은 큰따옴표를 사용하는 문자열 리터럴과 달리, 작은따옴표로 감싼다는 점에 주목하세요. 러스트의 char 타입은 크기가 4바이트이며 유니코드 스칼라 값을 나타냅니다. 즉, 단순히 ASCII 문자만이 아니라 훨씬 더 많은 것을 표현할 수 있습니다. 악센트가 있는 문자, 중국어·일본어·한국어 문자, 이모지, zero-width space 모두 러스트에서 유효한 char 값입니다. 유니코드 스칼라 값 범위는 U+0000 부터 U+D7FFU+E000 부터 U+10FFFF 까지입니다. 다만 유니코드에서 “문자”라는 개념은 생각보다 단순하지 않기 때문에, 인간이 직관적으로 생각하는 “한 글자”와 러스트의 char 가 반드시 일치하지는 않습니다. 이 주제는 8장의 “문자열에 UTF-8 텍스트 저장하기” 절에서 자세히 다룹니다.

복합 타입

복합 타입(compound types) 은 여러 값을 하나의 타입으로 묶을 수 있습니다. 러스트에는 두 가지 기본 복합 타입이 있습니다. 튜플과 배열입니다.

튜플 타입

튜플(tuple) 은 서로 다른 타입의 여러 값을 하나의 복합 타입으로 묶는 일반적인 방법입니다. 튜플은 길이가 고정되어 있어서, 한 번 선언하면 크기를 늘리거나 줄일 수 없습니다.

튜플은 괄호 안에 쉼표로 구분된 값 목록을 써서 만듭니다. 튜플 안의 각 위치는 타입을 가지며, 서로 다른 값들이 같은 타입일 필요는 없습니다. 아래 예시에서는 선택적인 타입 주석을 추가해 두었습니다.

파일명: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

변수 tup 은 튜플 전체에 바인딩됩니다. 튜플은 하나의 단일 복합 요소로 간주되기 때문입니다. 튜플 안의 개별 값을 꺼내려면 다음처럼 패턴 매칭을 사용해 튜플을 구조분해할 수 있습니다.

파일명: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

이 프로그램은 먼저 튜플을 만들고 그것을 변수 tup 에 바인딩합니다. 그런 다음 let 과 패턴을 사용해 tup 을 세 개의 개별 변수 x, y, z 로 나눕니다. 이것을 구조분해(destructuring) 라고 하며, 하나의 튜플을 여러 부분으로 풀어내는 것입니다. 마지막으로 프로그램은 y 의 값인 6.4 를 출력합니다.

튜플 요소는 점(.)과 접근하려는 값의 인덱스를 사용해 직접 가져올 수도 있습니다. 예를 들면 다음과 같습니다.

파일명: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

이 프로그램은 튜플 x 를 만들고, 각 요소를 대응하는 인덱스로 접근합니다. 대부분의 프로그래밍 언어와 마찬가지로, 튜플의 첫 번째 인덱스는 0입니다.

아무 값도 가지지 않는 튜플에는 특별한 이름이 있는데, 바로 유닛(unit) 입니다. 이 값과 그에 대응하는 타입은 모두 () 로 쓰며, 비어 있는 값 또는 비어 있는 반환 타입을 나타냅니다. 어떤 식이 다른 값을 반환하지 않으면 암묵적으로 유닛 값을 반환합니다.

배열 타입

여러 값을 모아 두는 또 다른 방법은 배열(array) 입니다. 튜플과 달리, 배열의 모든 요소는 반드시 같은 타입이어야 합니다. 또 다른 언어의 배열과 달리, 러스트의 배열은 길이가 고정되어 있습니다.

배열 값은 대괄호 안에 쉼표로 구분된 목록으로 씁니다.

파일명: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

배열은 지금까지 본 다른 타입들과 마찬가지로 데이터를 스택에 배치하고 싶을 때, 또는 힙이 아닌 스택에 두고 싶을 때(스택과 힙은 4장 에서 더 다룹니다), 혹은 요소 수가 항상 고정되어 있음을 보장하고 싶을 때 유용합니다. 다만 배열은 벡터만큼 유연하지는 않습니다. 벡터는 표준 라이브러리가 제공하는 비슷한 컬렉션 타입으로, 내용물이 힙에 저장되기 때문에 크기를 늘리거나 줄일 수 있습니다. 배열과 벡터 중 무엇을 써야 할지 확신이 없다면, 아마 벡터를 써야 할 가능성이 큽니다. 벡터는 8장에서 자세히 다룹니다.

하지만 요소 개수가 절대 바뀌지 않는다는 것을 알고 있다면 배열이 더 적합합니다. 예를 들어 프로그램에서 월 이름을 사용한다면, 항상 12개 요소를 가진다는 것을 알고 있으므로 벡터보다는 배열을 사용할 것입니다.

#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

배열의 타입은 대괄호 안에 각 요소의 타입, 세미콜론, 그리고 배열 요소 수를 적어 표현합니다. 예를 들면 다음과 같습니다.

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

여기서 i32 는 각 요소의 타입입니다. 세미콜론 뒤의 숫자 5 는 배열이 다섯 개 요소를 포함한다는 뜻입니다.

또한 초기값 하나와 세미콜론, 길이를 지정해 모든 요소를 같은 값으로 초기화할 수도 있습니다. 다음과 같습니다.

#![allow(unused)]
fn main() {
let a = [3; 5];
}

이 배열 a 는 다섯 개 요소를 가지며, 처음에는 모두 값 3 으로 설정됩니다. 이것은 let a = [3, 3, 3, 3, 3]; 를 더 간결하게 쓴 것과 같습니다.

배열 요소 접근하기

배열은 스택에 배치될 수 있는, 크기가 알려져 있고 고정된 하나의 연속 메모리 조각입니다. 배열의 요소에는 다음처럼 인덱싱을 사용해 접근할 수 있습니다.

파일명: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

이 예제에서 first 라는 변수는 배열의 [0] 위치에 있는 값이 1 이므로 값 1 을 갖게 됩니다. second 라는 변수는 [1] 인덱스에 있는 값 2 를 갖게 됩니다.

잘못된 배열 요소 접근

배열 끝을 넘어선 요소에 접근하려 하면 어떤 일이 벌어지는지 봅시다. 2장의 숫자 맞히기 게임과 비슷하게, 사용자에게 배열 인덱스를 입력받는 다음 코드를 실행한다고 해 봅시다.

파일명: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

이 코드는 컴파일 자체는 성공합니다. cargo run 으로 실행해 0, 1, 2, 3, 4 를 입력하면 프로그램은 배열에서 해당 인덱스의 값을 출력합니다. 하지만 배열 끝을 넘는 숫자, 예를 들어 10 을 입력하면 다음과 같은 출력을 보게 됩니다.

thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

이 프로그램은 인덱싱 연산에 잘못된 값을 사용하는 지점에서 런타임 오류를 일으켰습니다. 프로그램은 오류 메시지와 함께 종료되었고, 마지막 println! 문은 실행되지 않았습니다. 인덱싱을 사용해 요소에 접근하려 할 때 러스트는 지정한 인덱스가 배열 길이보다 작은지 검사합니다. 인덱스가 길이보다 크거나 같으면 러스트는 패닉을 일으킵니다. 특히 이 예제처럼 사용자가 나중에 어떤 값을 입력할지 컴파일러가 알 수 없는 경우에는, 이런 검사를 런타임에 할 수밖에 없습니다.

이것은 러스트의 메모리 안전 원칙이 실제로 동작하는 예입니다. 많은 저수준 언어에서는 이런 검사가 이루어지지 않으며, 잘못된 인덱스를 주면 유효하지 않은 메모리에 접근할 수 있습니다. 러스트는 그런 잘못된 메모리 접근을 허용하고 계속 진행하는 대신, 즉시 종료함으로써 여러분을 이런 종류의 오류로부터 보호합니다. 9장에서는 러스트의 에러 처리를 더 살펴보고, 패닉을 일으키지도 않고 잘못된 메모리 접근도 허용하지 않는 읽기 쉽고 안전한 코드를 어떻게 작성할 수 있는지 설명합니다.