Skip to content

3.2. Data Types


Rust의 모든 값은 특정 데이터 타입으로, 어떤 종류의 데이터가 지정되고 있는지 알려주어 Rust가 해당 데이터로 작업하는 방법을 알 수 있도록 합니다. 스칼라와 컴파운드의 두 가지 데이터 타입 하위 집합을 살펴보겠습니다.

Rust는 정적 타입 언어이므로 컴파일 시점에 모든 변수의 타입을 알아야 한다는 점을 명심하세요. 컴파일러는 일반적으로 값과 사용 방법을 기반으로 어떤 타입을 사용할지 유추할 수 있습니다. 2장의 "추측과 비밀 번호 비교하기" 섹션에서 parse를 사용하여 String을 숫자 타입으로 변환한 경우처럼 여러 타입이 가능한 경우에는 다음과 같이 타입 어노테이션을 추가해야 합니다:

let guess: u32 = "42".parse().expect("Not a number!");

앞의 코드에 표시된 : u32 타입 어노테이션을 추가하지 않으면 Rust에서 다음 오류가 표시되는데, 이는 컴파일러가 어떤 타입을 사용할지 알기 위해 더 많은 정보가 필요하다는 의미입니다:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^
  |
help: consider giving `guess` an explicit type
  |
2 |     let guess: _ = "42".parse().expect("Not a number!");
  |              +++

For more information about this error, try `rustc --explain E0282`.
error: could not compile `no_type_annotations` due to previous error

다른 데이터 타입에 대해서는 다른 타입 주석을 볼 수 있습니다.

스칼라 타입(Scalar Types)

스칼라 타입은 단일 값을 나타냅니다. Rust에는 정수, 부동 소수점 숫자, 부울, 문자의 네 가지 기본 스칼라 타입이 있습니다. 다른 프로그래밍 언어에서 이러한 스칼라 타입을 알아볼 수 있습니다. Rust에서 어떻게 작동하는지 살펴보겠습니다.

정수 타입(Integer Types)

정수는 분수 성분이 없는 숫자입니다. 2장에서는 정수 타입 중 하나인 u32 타입을 사용했습니다. 이 타입 선언은 연관된 값이 32비트의 공간을 차지하는 부호 없는 정수(부호 있는 정수 타입은 u 대신 i로 시작)여야 함을 나타냅니다. 표 3-1은 Rust에 내장된 정수 타입을 보여줍니다. 이러한 변형을 사용하여 정수 값의 타입을 선언할 수 있습니다.

표 3-1: Integer Types in Rust

Length Signed Unsigned
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
128-bit i128 u128
arch isize usize

각 변형은 부호화되거나 부호화되지 않을 수 있으며 명시적인 크기가 있습니다. 부호(Signed)부호 없음(Unsigned)은 숫자가 음수일 수 있는지, 즉 숫자에 부호가 있어야 하는지(부호 있음) 아니면 양수일 때만 부호 없이 표시할 수 있는지(부호 없음)를 나타냅니다. 종이에 숫자를 적을 때 부호가 중요한 경우에는 더하기 기호나 빼기 기호를 사용하여 숫자를 표시하지만, 숫자가 양수라고 가정해도 안전한 경우에는 부호 없이 숫자를 표시하는 것과 비슷합니다. 부호가 있는 숫자는 2의 보수 표현을 사용하여 저장됩니다.

또한, 표에서 "arch"로 표시된 대로 프로그램이 실행되는 컴퓨터의 아키텍처에 따라 isizeusize 타입이 달라집니다: 64비트 아키텍처를 사용하는 경우 64비트, 32비트 아키텍처를 사용하는 경우 32비트입니다.

정수 리터럴은 표 3-2에 표시된 모든 형식으로 작성할 수 있습니다. 여러 숫자 타입이 될 수 있는 숫자 리터럴은 타입 접미사(예: 57u8)를 사용하여 타입을 지정할 수 있습니다. 숫자 리터럴은 _를 시각적 구분 기호로 사용하여 숫자를 더 쉽게 읽을 수 있도록 할 수도 있습니다(예: 1_000, 1000을 지정한 것과 동일한 값을 갖습니다).

표 3-2: Integer Literals in Rust

Number literals Example
Decimal 98_222
Hex 0xff
Octal 0o77
Binary 0b1111_0000
Byte (u8 only) b'A'

그렇다면 어떤 타입의 정수를 사용할지 어떻게 알 수 있을까요? 확실하지 않은 경우, 일반적으로 정수 타입은 기본값이 i32로 설정되어 있는 Rust의 기본값이 좋은 출발점입니다. isize 또는 usize를 사용하는 주요 상황은 일종의 컬렉션을 인덱싱할 때입니다.

Integer Overflow

0에서 255 사이의 값을 담을 수 있는 u8 타입의 변수가 있다고 가정해 보겠습니다. 이 변수를 256과 같이 이 범위를 벗어나는 값으로 변경하려고 하면 정수 오버플로가 발생하여 두 가지 동작 중 하나가 발생할 수 있습니다. 디버그 모드에서 컴파일할 때 Rust는 정수 오버플로를 검사하여 이 동작이 발생하면 런타임에 프로그램을 패닉(Panic) 상태로 만듭니다. Rust는 프로그램이 오류와 함께 종료될 때 패닉이라는 용어를 사용하며, 패닉에 대해서는 9장의 "panic!으로 복구할 수 없는 오류" 섹션에서 자세히 설명하겠습니다.

--release 플래그를 사용하여 릴리스 모드에서 컴파일할 때 Rust는 패닉을 유발하는 정수 오버플로우 검사를 포함하지 않습니다. 대신 오버플로가 발생하면 Rust는 2의 보수 래핑을 수행합니다. 즉, 타입이 보유할 수 있는 최대값보다 큰 값은 타입이 보유할 수 있는 최소값으로 "래핑"됩니다. u8의 경우 값 256은 0이 되고 값 257은 1이 되는 식입니다. 프로그램은 당황하지 않지만 변수는 예상했던 값과 다른 값을 갖게 됩니다. 정수 오버플로의 래핑 동작에 의존하는 것은 오류로 간주됩니다.

오버플로 가능성을 명시적으로 처리하려면 원시 숫자 타입에 대해 표준 라이브러리에서 제공하는 다음과 같은 메서드를 사용할 수 있습니다:

  • 모든 모드에서 wrapping_* 메서드(예: wrapping_add)로 래핑합니다.

  • checked_* 메서드로 오버플로우가 있는 경우 None 값을 반환합니다.

  • overflowing_* 메서드에 오버플로가 있으면 값과 오버플로가 있었는지를 나타내는 부울을 반환합니다.

  • saturating_* 메서드를 사용하여 값의 최소값 또는 최대값을 포화시킵니다.

부동 소수점 타입(Floating-Point Types)

Rust에는 소수점이 있는 숫자인 부동 소수점 숫자를 위한 두 가지 기본 타입도 있습니다. Rust의 부동 소수점 타입은 각각 32비트와 64비트 크기인 f32f64입니다. 기본 타입은 f64인데, 최신 CPU에서는 f32와 속도가 거의 같지만 더 정밀하게 처리할 수 있기 때문입니다. 모든 부동 소수점 타입은 부호화됩니다.

다음은 부동 소수점 숫자가 실제로 어떻게 사용되는지 보여주는 예제입니다:

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

    let y: f32 = 3.0; // f32
}

부동 소수점 숫자는 IEEE-754 표준에 따라 표시됩니다. f32 타입은 단정밀도 부동 소수점이고, f64는 배정밀도입니다.

숫자 연산(Numeric Operations)

Rust는 더하기, 빼기, 곱하기, 나누기, 나머지와 같은 모든 숫자 타입에 대해 예상할 수 있는 기본적인 수학적 연산을 지원합니다. 정수 나누기는 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에는 Rust가 제공하는 모든 연산자 목록이 포함되어 있습니다.

부울 타입(Boolean Type)

대부분의 다른 프로그래밍 언어와 마찬가지로 Rust의 부울 타입에는 truefalse의 두 가지 가능한 값이 있습니다. 부울의 크기는 1바이트입니다. Rust의 부울 타입은 bool을 사용하여 지정됩니다. 예를 들어:

src/main.rs
fn main() {
    let t = true;

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

부울 값을 사용하는 주요 방법은 if 표현식과 같은 조건문을 사용하는 것입니다. Rust에서 if 표현식이 어떻게 작동하는지는 "제어 흐름" 섹션에서 다루겠습니다.

문자 타입(Character Type)

Rust의 char 타입은 언어에서 가장 원시적인 알파벳 타입입니다. 다음은 char 값 선언의 몇 가지 예입니다:

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

큰따옴표를 사용하는 문자열 리터럴과 달리 작은따옴표로 char 리터럴을 지정한다는 점에 유의하세요. Rust의 char 타입은 4바이트 크기이며 유니코드 스칼라 값을 나타내므로 ASCII보다 훨씬 더 많은 것을 나타낼 수 있습니다. 악센트 문자, 중국어, 일본어, 한국어 문자, 이모티콘, 0폭 공백은 모두 Rust에서 유효한 char 값입니다. 유니코드 스칼라 값의 범위는 U+0000에서 U+D7FF, U+E000에서 U+10FFFF까지입니다. 그러나 "문자"는 유니코드에서 실제로는 개념이 아니므로 "문자"가 무엇인지에 대한 인간의 직관과 Rust의 char가 무엇인지가 일치하지 않을 수 있습니다. 이 주제는 8장의 "문자열로 UTF-8 인코딩된 텍스트 저장하기"에서 자세히 설명하겠습니다.

컴파운드 타입(Compound Types)

컴파운드 타입은 여러 값을 하나의 타입으로 그룹화할 수 있습니다. Rust에는 튜플과 배열이라는 두 가지 기본 컴파운드 타입이 있습니다.

튜플 타입(Tuple Type)

튜플은 다양한 타입을 가진 여러 값을 하나의 컴파운드 타입으로 그룹화하는 일반적인 방법입니다. 튜플은 길이가 고정되어 있어 한 번 선언하면 크기가 커지거나 줄어들지 않습니다.

괄호 안에 쉼표로 구분된 값 목록을 작성하여 튜플을 만듭니다. 튜플의 각 위치에는 타입이 있으며, 튜플에 있는 서로 다른 값의 타입이 동일할 필요는 없습니다. 이 예제에서는 선택적 타입 어노테이션을 추가했습니다:

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)라고 하는데, 단일 튜플을 세 부분으로 나누기 때문입니다. 마지막으로 프로그램은 6.4y 값을 출력합니다.

마침표(.) 뒤에 액세스하려는 값의 인덱스를 사용하여 튜플 요소에 직접 액세스할 수도 있습니다. 예를 들어:

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 Type)

여러 값의 컬렉션을 만드는 또 다른 방법은 배열을 사용하는 것입니다. 튜플과 달리 배열의 모든 요소는 동일한 타입을 가져야 합니다. 다른 언어의 배열과 달리 Rust의 배열은 길이가 고정되어 있습니다.

배열의 값은 대괄호 안에 쉼표로 구분된 목록으로 작성합니다:

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

배열은 데이터를 힙이 아닌 스택에 할당하거나(스택과 힙에 대해서는 4장에서 자세히 설명하겠습니다), 항상 고정된 수의 요소를 확보하고 싶을 때 유용합니다. 하지만 배열은 벡터(Vector) 타입만큼 유연하지 않습니다. 벡터는 표준 라이브러리에서 제공하는 유사한 컬렉션 타입으로, 크기를 늘리거나 줄일 수 있습니다. 배열을 사용할지 벡터를 사용할지 잘 모르겠다면 벡터를 사용해야 할 가능성이 높습니다. 8장에서는 벡터에 대해 더 자세히 설명합니다.

그러나 배열은 요소의 수를 변경할 필요가 없는 경우에 더 유용합니다. 예를 들어 프로그램에서 월(Months)의 이름을 사용한다면 항상 12개의 요소가 포함된다는 것을 알기 때문에 벡터보다는 배열을 사용할 것입니다:

let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];

배열의 타입은 대괄호 안에 각 요소의 타입과 세미콜론, 그리고 배열의 요소 수를 다음과 같이 작성합니다:

let a: [i32; 5] = [1, 2, 3, 4, 5];

여기서 i32는 각 요소의 타입입니다. 세미콜론 뒤의 숫자 5는 배열에 5개의 요소가 포함되어 있음을 나타냅니다.

또한 다음과 같이 초기 값을 지정한 다음 세미콜론과 대괄호 안에 배열의 길이를 지정하여 각 요소에 동일한 값을 포함하도록 배열을 초기화할 수도 있습니다:

let a = [3; 5];

a라는 이름의 배열에는 5개의 요소가 포함되며 처음에는 모두 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을 가져옵니다. 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 'index out of bounds: the len is 5 but the index is 10', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

인덱싱 작업에서 잘못된 값을 사용하는 지점에서 프로그램이 런타임 오류를 발생시켰습니다. 프로그램이 오류 메시지와 함께 종료되고 최종 println! 문을 실행하지 않았습니다. 인덱싱을 사용하여 요소에 액세스하려고 하면 Rust는 사용자가 지정한 인덱스가 배열 길이보다 작은지 확인합니다. 인덱스가 길이보다 크거나 같으면 Rust는 패닉 상태에 빠집니다. 특히 이 경우 컴파일러는 사용자가 나중에 코드를 실행할 때 어떤 값을 입력할지 알 수 없기 때문에 이 검사는 런타임에 수행해야 합니다.

이것은 Rust의 메모리 안전 원칙이 실제로 작동하는 예입니다. 많은 저수준 언어에서는 이러한 종류의 검사가 수행되지 않으며, 잘못된 인덱스를 제공하면 잘못된 메모리에 액세스할 수 있습니다. Rust는 메모리 액세스를 허용하고 계속 진행하는 대신 즉시 종료함으로써 이러한 종류의 오류로부터 사용자를 보호합니다. 9장에서는 Rust의 오류 처리와 당황하지 않고 잘못된 메모리 액세스를 허용하지 않는 읽기 쉽고 안전한 코드를 작성하는 방법에 대해 자세히 설명합니다.


References