데이터 타입
데이터 타입 (Data Types)
Rust의 모든 값은 어떤 *데이터 타입(data type)*을 가집니다. 이 타입은 어떤 종류의 데이터가 지정되고 있는지 Rust에게 알려줘서, 그 데이터를 어떻게 다뤄야 할지 알게 해줍니다. 여기서는 스칼라(scalar)와 컴파운드(compound)라는 두 데이터 타입의 하위 집합을 살펴볼게요.
Rust는 정적 타입(statically typed) 언어라는 점을 명심하세요. 즉 컴파일 시점에 모든 변수의 타입을 알아야 합니다. 컴파일러는 값과 사용 방식에 따라 보통 어떤 타입을 쓰려는지 추론할 수 있어요. Chapter 2의 "Comparing the Guess to the Secret Number" 섹션에서 parse로 String을 숫자 타입으로 변환했을 때처럼 여러 타입이 가능한 경우에는, 다음과 같이 타입 주석을 추가해야 합니다.
#![allow(unused)]
fn main() {
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[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
다른 데이터 타입들마다 다른 타입 주석을 보게 될 거예요.
출처: The Rust Book
본문
스칼라 타입 (Scalar Types)
스칼라(scalar) 타입은 단일 값을 나타냅니다. Rust에는 네 가지 주요 스칼라 타입이 있는데요, 정수, 부동소수점 숫자, 불리언, 그리고 문자입니다. 다른 프로그래밍 언어에서 이미 본 적이 있을 거예요. 이제 Rust에서 어떻게 동작하는지 살펴볼게요.
정수 타입 (Integer Types)
*정수(integer)*는 분수 부분이 없는 숫자입니다. Chapter 2에서 u32 타입을 하나 사용했죠. 이 타입 선언은 연결된 값이 부호 없는 정수(unsigned integer, 부호 있는 정수 타입은 u 대신 i로 시작해요)여야 하며 32비트 공간을 차지한다는 것을 나타냅니다. Table 3-1은 Rust의 내장 정수 타입을 보여줍니다. 이 변형 중 아무거나 사용해 정수 값의 타입을 선언할 수 있어요.
Table 3-1: Rust의 정수 타입
| Length | Signed | Unsigned |
|---|---|---|
| 8-bit | i8 |
u8 |
| 16-bit | i16 |
u16 |
| 32-bit | i32 |
u32 |
| 64-bit | i64 |
u64 |
| 128-bit | i128 |
u128 |
| Architecture-dependent | isize |
usize |
각 변형은 부호 있음(signed) 또는 부호 없음(unsigned)일 수 있고 명시적인 크기를 가집니다. Signed와 unsigned는 숫자가 음수가 될 수 있는지, 다시 말해 숫자에 부호가 필요한지(signed) 아니면 항상 양수라서 부호 없이 표현할 수 있는지(unsigned)를 말합니다. 종이에 숫자를 적는 것과 비슷해요. 부호가 중요할 땐 숫자에 더하기/빼기 부호를 표시하지만, 양수라고 안전하게 가정할 수 있을 때는 부호 없이 표시합니다. 부호 있는 숫자는 two's complement 표현으로 저장됩니다.
각 부호 있는 변형은 −(2n − 1)부터 2n − 1 − 1까지(양끝 포함) 저장할 수 있는데, 여기서 n은 그 변형이 사용하는 비트 수입니다. 그래서 i8은 −(27)부터 27 − 1, 즉 −128부터 127까지 저장할 수 있어요. 부호 없는 변형은 0부터 2n − 1까지 저장할 수 있으므로, u8은 0부터 28 − 1, 즉 0부터 255까지 저장할 수 있지요.
추가로 isize와 usize 타입은 프로그램이 실행 중인 컴퓨터의 아키텍처에 따라 달라집니다. 64비트 아키텍처에서는 64비트, 32비트 아키텍처에서는 32비트예요.
정수 리터럴은 Table 3-2에 있는 형태 중 어떤 것으로도 작성할 수 있습니다. 여러 숫자 타입이 될 수 있는 숫자 리터럴은 57u8처럼 타입 접미사를 붙여 타입을 지정할 수 있다는 점을 기억하세요. 숫자 리터럴은 1_000처럼 _를 시각적 구분자로 사용해 읽기 쉽게 할 수도 있으며, 이는 1000이라고 지정한 것과 같은 값입니다.
| Number literals | Example |
|---|---|
| Decimal | 98_222 |
| Hex | 0xff |
| Octal | 0o77 |
| Binary | 0b1111_0000 |
Byte (u8 only) |
b'A' |
그럼 어떤 정수 타입을 써야 할지 어떻게 알까요? 잘 모르겠다면 Rust의 기본값이 일반적으로 좋은 출발점이에요. 정수 타입은 기본적으로 i32입니다. isize나 usize를 쓰는 주요 상황은 어떤 컬렉션의 인덱싱을 할 때예요.
정수 오버플로 (Integer Overflow)
0과 255 사이의 값을 담을 수 있는 u8 타입의 변수가 있다고 해볼게요. 그 범위를 벗어난 값, 예컨대 256으로 변수를 바꾸려고 하면 *정수 오버플로(integer overflow)*가 발생하는데, 이는 두 가지 동작 중 하나로 귀결될 수 있어요. 디버그 모드로 컴파일할 때 Rust는 정수 오버플로 검사를 포함하며, 검사가 발생하면 프로그램이 런타임에 panic 하게 됩니다. Rust는 프로그램이 오류로 종료될 때 panicking이라는 용어를 쓰며, panic에 대해서는 Chapter 9의 "Unrecoverable Errors with panic!" 섹션에서 더 깊게 다룰게요.
--release 플래그로 릴리스 모드로 컴파일할 때 Rust는 panic을 일으키는 정수 오버플로 검사를 포함하지 않습니다. 대신 오버플로가 발생하면 Rust는 two's complement wrapping을 수행합니다. 간단히 말해 타입이 담을 수 있는 최댓값보다 큰 값은 타입이 담을 수 있는 최솟값으로 "돌아서(wrap)" 감습니다. u8의 경우 값 256은 0이 되고, 257은 1이 되는 식이에요. 프로그램은 panic 하지 않지만, 변수는 아마 기대하지 않았을 값을 갖게 됩니다. 정수 오버플로의 wrapping 동작에 의존하는 것은 오류로 간주됩니다.
오버플로 가능성을 명시적으로 처리하려면 표준 라이브러리가 기본 숫자 타입에 제공하는 다음 메서드 계열들을 사용할 수 있어요.
wrapping_add같은wrapping_*메서드로 모든 모드에서 wrapping.- 오버플로가 있으면
None값을 반환하는checked_*메서드. - 값과 오버플로 여부를 나타내는 불리언을 반환하는
overflowing_*메서드. - 값의 최솟값이나 최댓값에서 saturate(포화)시키는
saturating_*메서드.
부동소수점 타입 (Floating-Point Types)
Rust에는 부동소수점 숫자(floating-point number), 즉 소수점이 있는 숫자를 위한 기본 타입도 두 개 있습니다. Rust의 부동소수점 타입은 f32와 f64이며 각각 32비트와 64비트 크기예요. 기본 타입은 f64인데, 현대 CPU에서는 f32와 속도가 거의 같으면서 더 많은 정밀도를 제공하기 때문입니다. 모든 부동소수점 타입은 부호 있습니다.
부동소수점 숫자가 동작하는 모습을 보여주는 예시를 하나 볼게요.
Filename: src/main.rs
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
부동소수점 숫자는 IEEE-754 표준에 따라 표현됩니다.
숫자 연산 (Numeric Operations)
Rust는 모든 숫자 타입에 대해 기대할 만한 기본 수학 연산, 즉 덧셈, 뺄셈, 곱셈, 나눗셈, 나머지를 지원합니다. 정수 나눗셈은 가장 가까운 정수로 0을 향해 버려집니다(truncate). 다음 코드는 각 숫자 연산을 let 문에서 어떻게 쓰는지 보여줍니다.
Filename: 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;
}
이 문장들의 각 표현식은 수학 연산자를 사용하며 단일 값으로 평가된 뒤 변수에 바인딩됩니다. Appendix B에는 Rust가 제공하는 모든 연산자 목록이 들어 있어요.
불리언 타입 (The Boolean Type)
대부분의 다른 프로그래밍 언어에서처럼, Rust의 불리언 타입은 true와 false라는 두 가지 가능한 값을 가집니다. 불리언은 크기가 1바이트예요. Rust의 불리언 타입은 bool로 지정합니다. 예를 들어.
Filename: src/main.rs
fn main() {
let t = true;
let f: bool = false; // with explicit type annotation
}
불리언 값을 쓰는 주요 방식은 if 표현식 같은 조건문을 통해서입니다. if 표현식이 Rust에서 어떻게 동작하는지는 "Control Flow" 섹션에서 다룰게요.
문자 타입 (The Character Type)
Rust의 char 타입은 언어에서 가장 원시적인 알파벳 타입입니다. char 값을 선언하는 예시를 몇 개 볼게요.
Filename: src/main.rs
fn main() {
let c = 'z';
let z: char = 'ℤ'; // with explicit type annotation
let heart_eyed_cat = '😻';
}
char 리터럴은 문자열 리터럴이 사용하는 큰따옴표가 아니라 작은따옴표로 지정한다는 점을 기억하세요. Rust의 char 타입은 크기가 4바이트이며 Unicode 스칼라 값을 나타내므로, ASCII보다 훨씬 많은 것을 표현할 수 있습니다. 액센트 글자, 중국어·일본어·한국어 문자, 이모지, 제로 폭 공백 모두 Rust에서 유효한 char 값이에요. Unicode 스칼라 값의 범위는 U+0000부터 U+D7FF, 그리고 U+E000부터 U+10FFFF까지(양끝 포함)입니다. 하지만 "character"는 사실 Unicode에서의 개념이 아니므로, "문자"가 무엇인지에 대한 사람의 직관이 Rust에서의 char와 일치하지 않을 수 있어요. 이 주제는 Chapter 8의 "Storing UTF-8 Encoded Text with Strings"에서 자세히 다룰게요.
컴파운드 타입 (Compound Types)
컴파운드 타입은 여러 값을 하나의 타입으로 묶을 수 있습니다. Rust에는 튜플(tuple)과 배열(array)이라는 두 가지 기본 컴파운드 타입이 있어요.
튜플 타입 (The Tuple Type)
*튜플(tuple)*은 다양한 타입의 값들을 하나의 컴파운드 타입으로 묶는 일반적인 방법입니다. 튜플은 고정된 길이를 가져요. 한번 선언되면 크기가 커지거나 줄어들지 않습니다.
튜플은 괄호 안에 쉼표로 구분된 값 목록을 작성해 만듭니다. 튜플의 각 위치에는 타입이 있으며, 튜플 안의 서로 다른 값들의 타입은 같지 않아도 됩니다. 이 예시에는 선택적인 타입 주석을 넣었어요.
Filename: src/main.rs
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
튜플은 하나의 컴파운드 요소로 간주되기 때문에 변수 tup은 튜플 전체에 바인딩됩니다. 튜플에서 개별 값을 꺼내려면 패턴 매칭으로 튜플 값을 구조 분해(destructure)하면 돼요. 이렇게요.
Filename: 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를 출력합니다.
튜플 요소는 점(.) 뒤에 접근하려는 값의 인덱스를 붙여 직접 접근할 수도 있습니다. 예를 들어.
Filename: 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이라는 특별한 이름이 있습니다. 이 값과 해당 타입은 모두 ()로 쓰며, 빈 값 또는 빈 반환 타입을 나타내요. 표현식은 다른 값을 반환하지 않으면 암묵적으로 unit 값을 반환합니다.
배열 타입 (The Array Type)
여러 값의 컬렉션을 갖는 또 다른 방법은 *배열(array)*입니다. 튜플과 달리 배열의 모든 요소는 같은 타입이어야 해요. 다른 언어의 배열과 달리 Rust의 배열은 고정된 길이를 가집니다.
배열의 값은 대괄호 안에 쉼표로 구분된 목록으로 작성합니다.
Filename: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
}
배열은 지금까지 본 다른 타입들처럼 데이터가 힙(heap)이 아니라 스택(stack)에 할당되기를 원할 때(스택과 힙에 대해서는 Chapter 4에서 더 다룰게요), 또는 항상 고정된 수의 요소를 갖도록 보장하고 싶을 때 유용합니다. 하지만 배열은 벡터(vector) 타입만큼 유연하지는 않아요. 벡터는 표준 라이브러리가 제공하는 유사한 컬렉션 타입으로, 그 내용물이 힙에 있기 때문에 크기가 커지거나 줄어드는 것이 허용됩니다. 배열을 쓸지 벡터를 쓸지 잘 모르겠다면 아마 벡터를 쓰는 게 맞을 거예요. 벡터는 Chapter 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라는 이름의 배열은 5개의 요소를 담고 있으며, 모든 요소가 초기에 값 3으로 설정됩니다. 이는 let a = [3, 3, 3, 3, 3];라고 쓰는 것과 같지만 더 간결한 방식이에요.
배열 요소 접근 (Array Element Access)
배열은 스택에 할당될 수 있는, 알려진 고정 크기의 메모리 덩어리 하나입니다. 인덱싱으로 배열의 요소에 접근할 수 있어요. 이렇게요.
Filename: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
let first = a[0];
let second = a[1];
}
이 예시에서 first라는 변수는 값 1을 갖게 됩니다. 배열의 인덱스 [0]에 있는 값이기 때문이에요. second라는 변수는 배열의 인덱스 [1]에서 값 2를 갖게 됩니다.
잘못된 배열 요소 접근 (Invalid Array Element Access)
배열의 끝을 넘어가는 요소에 접근하려 하면 어떻게 되는지 볼게요. Chapter 2의 숫자 맞히기 게임과 비슷하게, 사용자로부터 배열 인덱스를 받는 이 코드를 실행해보세요.
Filename: 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! 문은 실행되지 않았어요. 인덱싱으로 요소에 접근하려 하면 Rust는 지정한 인덱스가 배열 길이보다 작은지 확인합니다. 인덱스가 길이보다 크거나 같으면 Rust는 panic 해요. 이 검사는 특히 이 경우 반드시 런타임에 일어나야 합니다. 컴파일러는 사용자가 나중에 코드를 실행할 때 어떤 값을 입력할지 알 수 없거든요.
이것은 Rust의 메모리 안전(memory safety) 원칙이 실제로 작동하는 예시입니다. 많은 저수준 언어에서는 이런 검사가 이루어지지 않아 잘못된 인덱스를 제공하면 잘못된 메모리에 접근할 수 있어요. Rust는 메모리 접근을 허용하고 계속 진행하는 대신 즉시 종료함으로써 이런 종류의 오류로부터 당신을 보호합니다. Chapter 9는 Rust의 오류 처리와, panic도 일으키지 않고 잘못된 메모리 접근도 허용하지 않는 읽기 좋고 안전한 코드를 작성하는 방법을 더 다룹니다.