Rust 데이터 타입 탐구

Beginner

This tutorial is from open-source community. Access the source code

소개

Data Types에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.

이 랩에서는 Rust 의 데이터 타입 개념을 탐구합니다. Rust 에서는 모든 값에 특정 타입이 할당되어 해당 값의 처리 방식을 결정하며, 여러 타입이 가능한 경우에는 컴파일러에 필요한 정보를 제공하기 위해 타입 어노테이션 (type annotation) 을 추가해야 합니다.

이것은 가이드 실험입니다. 학습과 실습을 돕기 위한 단계별 지침을 제공합니다.각 단계를 완료하고 실무 경험을 쌓기 위해 지침을 주의 깊게 따르세요. 과거 데이터에 따르면, 이것은 초급 레벨의 실험이며 완료율은 83%입니다.학습자들로부터 100%의 긍정적인 리뷰율을 받았습니다.

데이터 타입 (Data Types)

Rust 의 모든 값은 특정 **데이터 타입 (data type)**을 가지며, 이는 Rust 에게 어떤 종류의 데이터가 지정되었는지 알려주어 해당 데이터를 어떻게 처리해야 하는지 알 수 있게 합니다. 우리는 두 가지 데이터 타입 하위 집합, 즉 스칼라 (scalar) 와 컴파운드 (compound) 를 살펴보겠습니다.

Rust 는 정적으로 타입이 지정된 (statically typed) 언어라는 점을 기억하십시오. 즉, 컴파일 시간에 모든 변수의 타입을 알아야 합니다. 컴파일러는 일반적으로 값과 사용 방식을 기반으로 우리가 사용하려는 타입을 추론할 수 있습니다. "Comparing the Guess to the Secret Number"에서 parse를 사용하여 String을 숫자 타입으로 변환하는 경우와 같이 여러 타입이 가능한 경우에는 다음과 같이 타입 어노테이션 (type annotation) 을 추가해야 합니다.

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!");
  |         ^^^^^ consider giving `guess` a type

다른 데이터 타입에 대해서는 다른 타입 어노테이션을 보게 될 것입니다.

스칼라 타입 (Scalar Types)

스칼라 (scalar) 타입은 단일 값을 나타냅니다. Rust 에는 정수, 부동 소수점 숫자, 부울 (Boolean), 문자와 같은 네 가지 주요 스칼라 타입이 있습니다. 다른 프로그래밍 언어에서 이러한 타입을 보셨을 것입니다. Rust 에서 어떻게 작동하는지 살펴보겠습니다.

정수 타입

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

표 3-1: Rust 의 정수 타입

길이 부호 있음 부호 없음


8 비트 i8 u8
16 비트 i16 u16
32 비트 i32 u32
64 비트 i64 u64
128 비트 i128 u128
arch isize usize

각 변형은 부호 있거나 부호 없을 수 있으며 명시적인 크기를 갖습니다. 부호 있음 (Signed)부호 없음 (unsigned) 은 숫자가 음수가 될 수 있는지 여부를 나타냅니다. 즉, 숫자가 부호 (signed) 를 가져야 하는지, 아니면 항상 양수이고 부호 없이 표현할 수 있는지 (unsigned) 를 나타냅니다. 종이에 숫자를 쓰는 것과 같습니다. 부호가 중요할 때는 숫자 앞에 더하기 기호 또는 빼기 기호가 표시됩니다. 그러나 숫자가 양수라고 가정해도 안전할 때는 부호 없이 표시됩니다. 부호 있는 숫자는 2 의 보수 표현 (two's complement representation) 을 사용하여 저장됩니다.

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

또한 isizeusize 타입은 프로그램이 실행되는 컴퓨터의 아키텍처에 따라 달라지며, 표에서 "arch"로 표시됩니다. 64 비트 아키텍처에서는 64 비트, 32 비트 아키텍처에서는 32 비트입니다.

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

표 3-2: Rust 의 정수 리터럴

숫자 리터럴 예시


10 진수 98_222
16 진수 0xff
8 진수 0o77
2 진수 0b1111_0000
바이트 (u8 만 해당) b'A'

그렇다면 어떤 정수 타입을 사용해야 할까요? 잘 모르겠다면 Rust 의 기본값이 일반적으로 시작하기에 좋은 곳입니다. 정수 타입은 기본적으로 i32입니다. isize 또는 usize를 사용하는 주요 상황은 어떤 종류의 컬렉션을 인덱싱할 때입니다.

정수 오버플로우 (Integer Overflow)

u8 타입의 변수가 0 에서 255 사이의 값을 가질 수 있다고 가정해 보겠습니다. 변수를 해당 범위를 벗어나는 값 (예: 256) 으로 변경하려고 하면 정수 오버플로우 (integer overflow) 가 발생하며, 이는 두 가지 동작 중 하나로 이어질 수 있습니다. 디버그 모드로 컴파일하는 경우 Rust 는 이 동작이 발생하면 런타임에 프로그램이 패닉 (panic) 되도록 하는 정수 오버플로우 검사를 포함합니다. Rust 는 프로그램이 오류와 함께 종료될 때 패닉 (panicking) 이라는 용어를 사용합니다. "panic! 으로 복구할 수 없는 오류"에서 패닉에 대해 자세히 논의할 것입니다.

--release 플래그를 사용하여 릴리스 모드로 컴파일하는 경우 Rust 는 패닉을 발생시키는 정수 오버플로우 검사를 포함하지 않습니다. 대신 오버플로우가 발생하면 Rust 는 2 의 보수 래핑 (two's complement wrapping) 을 수행합니다. 간단히 말해서, 타입이 가질 수 있는 최대값보다 큰 값은 타입이 가질 수 있는 최소값으로 "감싸집니다 (wrap around)". u8의 경우 값 256 은 0 이 되고, 값 257 은 1 이 되는 식입니다. 프로그램은 패닉되지 않지만 변수는 예상했던 값과 다를 수 있습니다. 정수 오버플로우의 래핑 동작에 의존하는 것은 오류로 간주됩니다.

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

  • wrapping_* 메서드 (예: wrapping_add) 를 사용하여 모든 모드에서 래핑합니다.
  • checked_* 메서드를 사용하여 오버플로우가 있는 경우 None 값을 반환합니다.
  • overflowing_* 메서드를 사용하여 값과 오버플로우가 있었는지 여부를 나타내는 부울 값을 반환합니다.
  • saturating_* 메서드를 사용하여 값의 최소 또는 최대 값으로 포화시킵니다.

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

Rust 는 또한 소수점이 있는 숫자, 즉 *부동 소수점 숫자 (floating-point numbers)*에 대한 두 가지 기본 타입을 가지고 있습니다. Rust 의 부동 소수점 타입은 f32f64이며, 각각 32 비트와 64 비트 크기입니다. 기본 타입은 f64인데, 최신 CPU 에서 f32와 거의 동일한 속도를 가지면서 더 많은 정밀도를 제공하기 때문입니다. 모든 부동 소수점 타입은 부호가 있습니다.

data-types라는 새 프로젝트를 만듭니다.

cargo new data-types
cd data-types

다음은 부동 소수점 숫자를 보여주는 예시입니다.

파일 이름: src/main.rs

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; // 결과는 -1

    // 나머지 (remainder)
    let remainder = 43 % 5;
}

이러한 문장의 각 표현식은 수학 연산자를 사용하고 단일 값으로 평가되며, 이 값은 변수에 바인딩됩니다. 부록 B 에는 Rust 가 제공하는 모든 연산자 목록이 있습니다.

부울린 타입 (The Boolean Type)

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

파일 이름: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // 명시적 타입 어노테이션 (explicit type annotation)
}

부울린 값을 사용하는 주요 방법은 if 표현식과 같은 조건문을 통하는 것입니다. "제어 흐름 (Control Flow)"에서 Rust 의 if 표현식이 어떻게 작동하는지 다룰 것입니다.

문자 타입 (The Character Type)

Rust 의 char 타입은 언어의 가장 기본적인 알파벳 타입입니다. 다음은 char 값을 선언하는 몇 가지 예입니다.

파일 이름: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // 명시적 타입 어노테이션 (explicit type annotation)
    let heart_eyed_cat = '😻';
}

문자열 리터럴이 큰따옴표를 사용하는 것과 달리, char 리터럴은 작은따옴표로 지정합니다. Rust 의 char 타입은 크기가 4 바이트이며 유니코드 스칼라 값 (Unicode Scalar Value) 을 나타내며, 이는 ASCII 보다 훨씬 더 많은 것을 나타낼 수 있음을 의미합니다. 악센트가 있는 문자, 중국어, 일본어 및 한국어 문자, 이모지, 그리고 너비가 0 인 공백 모두 Rust 에서 유효한 char 값입니다. 유니코드 스칼라 값은 U+0000에서 U+D7FF까지, 그리고 U+E000에서 U+10FFFF까지 (포함) 의 범위를 가집니다. 그러나 "문자"는 실제로 유니코드의 개념이 아니므로, "문자"에 대한 인간적인 직관은 Rust 의 char와 일치하지 않을 수 있습니다. 이 주제는 "문자열로 UTF-8 인코딩된 텍스트 저장 (Storing UTF-8 Encoded Text with Strings)"에서 자세히 논의할 것입니다.

복합 타입 (Compound Types)

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

튜플 타입 (The Tuple Type)

튜플은 다양한 타입의 여러 값을 하나의 복합 타입으로 묶는 일반적인 방법입니다. 튜플은 고정된 길이를 가집니다: 선언되면 크기가 커지거나 줄어들 수 없습니다.

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

파일 이름: src/main.rs

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

변수 tup는 전체 튜플에 바인딩됩니다. 튜플은 단일 복합 요소로 간주되기 때문입니다. 튜플에서 개별 값을 가져오려면 패턴 매칭 (pattern matching) 을 사용하여 튜플 값을 분해 (destructure) 할 수 있습니다. 다음과 같습니다.

파일 이름: 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)*이라는 특별한 이름을 갖습니다. 이 값과 해당 타입은 모두 ()로 작성되며 빈 값 또는 빈 반환 타입을 나타냅니다. 다른 값을 반환하지 않으면 표현식은 암시적으로 유닛 값을 반환합니다.

배열 타입 (The Array Type)

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

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

파일 이름: src/main.rs

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

배열은 데이터를 힙 (heap) 이 아닌 스택 (stack) 에 할당하려는 경우 (4 장에서 스택과 힙에 대해 더 자세히 논의할 것입니다) 또는 항상 고정된 수의 요소를 갖도록 하려는 경우에 유용합니다. 하지만 배열은 벡터 (vector) 타입만큼 유연하지 않습니다. 벡터는 표준 라이브러리에서 제공하는 유사한 컬렉션 타입으로, 크기가 커지거나 줄어들 수 있습니다. 배열과 벡터 중 어느 것을 사용해야 할지 확신이 서지 않는다면, 벡터를 사용하는 것이 좋습니다. 8 장에서 벡터에 대해 더 자세히 설명합니다.

그러나 배열은 요소의 수가 변경될 필요가 없다는 것을 알고 있을 때 더 유용합니다. 예를 들어, 프로그램에서 월 이름을 사용하는 경우, 항상 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는 배열이 다섯 개의 요소를 포함한다는 것을 나타냅니다.

또한 초기 값을 지정하고, 세미콜론을 입력한 다음, 대괄호 안에 배열의 길이를 지정하여 각 요소에 동일한 값을 포함하도록 배열을 초기화할 수 있습니다. 다음은 그 예입니다.

let a = [3; 5];

a라는 배열은 처음에 모두 3 값으로 설정된 5개의 요소를 포함합니다. 이것은 let a = [3, 3, 3, 3, 3];을 작성하는 것과 동일하지만 더 간결한 방식입니다.

배열 요소 접근 (Accessing Array Elements)

배열은 스택에 할당될 수 있는 알려진 고정 크기의 단일 메모리 덩어리입니다. 다음과 같이 인덱싱 (indexing) 을 사용하여 배열의 요소에 접근할 수 있습니다.

파일 이름: 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를 얻습니다.

잘못된 배열 요소 접근 (Invalid Array Element Access)

배열의 끝을 넘어선 배열의 요소에 접근하려고 하면 어떻게 되는지 살펴보겠습니다. 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

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

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

요약 (Summary)

축하합니다! 데이터 타입 (Data Types) 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.