소개
Data Types에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.
이 랩에서는 Rust 의 데이터 타입 개념을 탐구합니다. Rust 에서는 모든 값에 특정 타입이 할당되어 해당 값의 처리 방식을 결정하며, 여러 타입이 가능한 경우에는 컴파일러에 필요한 정보를 제공하기 위해 타입 어노테이션 (type annotation) 을 추가해야 합니다.
Data Types에 오신 것을 환영합니다. 이 랩은 Rust Book의 일부입니다. LabEx 에서 Rust 기술을 연습할 수 있습니다.
이 랩에서는 Rust 의 데이터 타입 개념을 탐구합니다. Rust 에서는 모든 값에 특정 타입이 할당되어 해당 값의 처리 방식을 결정하며, 여러 타입이 가능한 경우에는 컴파일러에 필요한 정보를 제공하기 위해 타입 어노테이션 (type annotation) 을 추가해야 합니다.
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) 타입은 단일 값을 나타냅니다. 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 까지의 숫자를 저장할 수 있습니다.
또한 isize 및 usize 타입은 프로그램이 실행되는 컴퓨터의 아키텍처에 따라 달라지며, 표에서 "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_*메서드를 사용하여 값의 최소 또는 최대 값으로 포화시킵니다.
Rust 는 또한 소수점이 있는 숫자, 즉 *부동 소수점 숫자 (floating-point numbers)*에 대한 두 가지 기본 타입을 가지고 있습니다. Rust 의 부동 소수점 타입은 f32와 f64이며, 각각 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는 배정밀도를 갖습니다.
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 가 제공하는 모든 연산자 목록이 있습니다.
대부분의 다른 프로그래밍 언어와 마찬가지로, Rust 의 부울린 타입은 두 가지 가능한 값, 즉 true와 false를 가집니다. 부울린은 크기가 1 바이트입니다. Rust 의 부울린 타입은 bool을 사용하여 지정됩니다. 예를 들어:
파일 이름: src/main.rs
fn main() {
let t = true;
let f: bool = false; // 명시적 타입 어노테이션 (explicit type annotation)
}
부울린 값을 사용하는 주요 방법은 if 표현식과 같은 조건문을 통하는 것입니다. "제어 흐름 (Control Flow)"에서 Rust 의 if 표현식이 어떻게 작동하는지 다룰 것입니다.
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)"에서 자세히 논의할 것입니다.
복합 타입은 여러 값을 하나의 타입으로 묶을 수 있습니다. Rust 에는 두 가지 기본 복합 타입이 있습니다: 튜플 (tuples) 과 배열 (arrays).
튜플은 다양한 타입의 여러 값을 하나의 복합 타입으로 묶는 일반적인 방법입니다. 튜플은 고정된 길이를 가집니다: 선언되면 크기가 커지거나 줄어들 수 없습니다.
괄호 안에 쉼표로 구분된 값 목록을 작성하여 튜플을 생성합니다. 튜플의 각 위치에는 타입이 있으며, 튜플 내의 서로 다른 값의 타입이 동일할 필요는 없습니다. 이 예제에서는 선택적 타입 어노테이션 (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)*이라는 특별한 이름을 갖습니다. 이 값과 해당 타입은 모두 ()로 작성되며 빈 값 또는 빈 반환 타입을 나타냅니다. 다른 값을 반환하지 않으면 표현식은 암시적으로 유닛 값을 반환합니다.
여러 값의 컬렉션을 갖는 또 다른 방법은 *배열 (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];을 작성하는 것과 동일하지만 더 간결한 방식입니다.
배열은 스택에 할당될 수 있는 알려진 고정 크기의 단일 메모리 덩어리입니다. 다음과 같이 인덱싱 (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를 얻습니다.
배열의 끝을 넘어선 배열의 요소에 접근하려고 하면 어떻게 되는지 살펴보겠습니다. 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) 에 대해 더 자세히 논의하고, 패닉하지 않고 잘못된 메모리 접근을 허용하지 않는 읽기 쉽고 안전한 코드를 작성하는 방법을 설명합니다.
축하합니다! 데이터 타입 (Data Types) 랩을 완료했습니다. LabEx 에서 더 많은 랩을 연습하여 실력을 향상시킬 수 있습니다.