2024-07-07
HSPACE Rust 특강 #2 — 기본 타입
이번 회차는 Rust의 기본 타입을 훑는다. 겉보기엔 지루한 주제지만, 여기서 "C였다면 조용히 넘어갔을 것들이 Rust에서는 왜 명시적으로 드러나는가" 를 알게 되는 회차였다.
고정 크기 수치 타입
크기가 이름에 그대로 들어 있다.
| 종류 | 타입 |
|---|---|
| 부호 없는 정수 | u8, u16, u32, u64, u128, usize |
| 부호 있는 정수 | i8, i16, i32, i64, i128, isize |
| 부동소수점 | f32, f64 |
usize / isize 는 포인터 크기와 같다(64비트 환경에서 64비트). 배열 인덱스나
컬렉션 길이는 전부 usize 다. C의 int 가 플랫폼마다 다른 크기를 갖는 것과 달리,
i32 는 어디서든 32비트다. 크로스 컴파일할 때 이 차이가 크게 다가온다.
리터럴에는 접미사로 타입을 붙일 수 있고(42u8, 1729_isize), 가독성을 위해
밑줄을 넣을 수 있다(1_000_000).
타입 변환은 항상 명시적으로
as 캐스트는 범위 밖 값을 2^N 모듈로 연산으로 잘라낸다.
assert_eq!(10_i8 as u16, 10_u16); // 범위 내
assert_eq!(-1_i16 as i32, -1_i32); // 부호 확장
assert_eq!(65535_u16 as i32, 65535_i32); // 0 확장
// 범위 밖 → 대상 폭 N비트에 대해 2^N 모듈로 (절단)
assert_eq!(1000_i16 as u8, 232_u8);
assert_eq!(65535_u32 as i16, -1_i16);
assert_eq!(-1_i8 as u8, 255_u8);
assert_eq!(255_u8 as i8, -1_i8);
동작 자체는 C와 같지만, 결정적인 차이는 암묵적으로는 절대 일어나지 않는다는
점이다. -1_i8 as u8 == 255 같은 결과가 나오려면 내가 as 를 직접 쳐야 한다.
C에서 이 변환이 조용히 일어나 생기는 버그를 생각하면, 타이핑 몇 자는 싸다.
정수 메서드
메서드는 타입에 붙어 있다.
assert_eq!(2_u16.pow(4), 16); // 거듭제곱
assert_eq!((-4_i32).abs(), 4); // 절댓값
assert_eq!(0b101101_u8.count_ones(), 4); // 1인 비트 수
// println!("{}", (-4).abs()); // 에러: 수치 타입이 모호함
println!("{}", (-4_i32).abs());
println!("{}", i32::abs(-4));
(-4).abs() 가 에러라는 게 처음엔 황당했다. 리터럴 -4 는 정수라는 것만 알 뿐
어떤 정수 타입인지 정해지지 않았고, abs() 는 타입마다 따로 정의되어 있어서
결정할 수 없다. 문맥에서 타입이 추론되면 접미사 없이도 되지만, 이렇게 단독으로
쓰면 명시해줘야 한다.
오버플로를 다루는 네 가지 방법
여기가 이 회차의 핵심이었다. Rust는 디버그 빌드에서 정수 오버플로를 패닉으로 잡는다. 릴리스 빌드에서는 성능 때문에 래핑(wrapping)되지만, 어느 쪽이든 "조용히 잘못된 값이 흘러가는" 상황을 기본값으로 두지 않는다. 그리고 오버플로를 어떻게 다룰지 연산마다 골라 쓸 수 있다.
1. checked_* — 실패하면 None
assert_eq!(10_u8.checked_add(20), Some(30));
assert_eq!(100_u8.checked_add(200), None);
// 오버플로면 패닉시키고 싶을 때
let x = i8::MAX;
let _ = x.checked_add(1).unwrap();
// 부호 있는 나눗셈도 오버플로가 날 수 있다.
// n비트 부호 있는 타입은 -2^(n-1)은 표현하지만 2^(n-1)은 표현하지 못한다.
assert_eq!((-128_i8).checked_div(-1), None);
마지막 줄이 재미있다. -128_i8 / -1 은 128 이어야 하는데 i8 최댓값은 127
이므로 표현할 수 없다. C였다면 정의되지 않은 동작(UB)인 자리다.
2. wrapping_* — 2^N 모듈로 래핑
assert_eq!(100_u16.wrapping_mul(200), 20000);
assert_eq!(500_u16.wrapping_mul(500), 53392); // 250000 mod 2^16
assert_eq!(500_i16.wrapping_mul(500), -12144); // 부호 있는 타입은 음수로 감길 수 있다
// 시프트 거리도 타입 폭으로 래핑된다: 16비트 타입에서 17비트 시프트 = 1비트 시프트
assert_eq!(5_i16.wrapping_shl(17), 10);
해시 함수처럼 래핑이 의도된 곳에서 쓴다.
3. saturating_* — 경계에서 멈춤
assert_eq!(32760_i16.saturating_add(10), 32767);
assert_eq!((-32760_i16).saturating_sub(10), -32768);
오디오 샘플이나 픽셀 값처럼 "넘치면 최댓값으로 붙잡아 두는" 게 맞는 도메인용이다.
4. overflowing_* — 결과와 오버플로 여부를 함께
assert_eq!(255_u8.overflowing_sub(2), (253, false));
assert_eq!(255_u8.overflowing_add(2), (1, true));
assert_eq!(5_u16.overflowing_shl(17), (10, true));
(값, 넘쳤는지) 튜플을 준다.
하나의 + 뒤에 네 가지 정책이 숨어 있고, 그중 무엇을 원하는지 코드에 적게 한다.
"오버플로를 어떻게 처리할지 생각해본 적 있는가"를 언어가 계속 물어보는 셈이다.
bool, 문자, 튜플
bool 은 true / false 뿐이고, C처럼 0/1 이 자동으로 진리값이 되지 않는다.
크기는 1바이트다.
char 는 유니코드 스칼라 값 하나로, 4바이트다. C의 char(1바이트)와 이름만
같고 다른 것이다.
assert_eq!('*' as i32, 42);
assert_eq!('ಠ' as u16, 0xca0);
assert_eq!('ಠ' as i8, -0x60); // U+0CA0을 8비트로 절단, 부호 있음
assert_eq!('*'.is_alphabetic(), false);
assert_eq!('β'.is_alphabetic(), true);
assert_eq!('8'.to_digit(10), Some(8));
assert_eq!('ಠ'.len_utf8(), 3);
assert_eq!(std::char::from_digit(2, 10), Some('2'));
'8'.to_digit(10) 이 Some(8) 을 돌려준다는 게 Rust답다. 숫자가 아닌 문자면
None 이니, 실패 가능성이 반환 타입에 적혀 있다.
튜플은 서로 다른 타입을 묶고, 구조 분해로 꺼낸다.
let text = "I see the eigenvalue in thine eye";
let (head, tail) = text.split_at(21);
assert_eq!(head, "I see the eigenvalue ");
assert_eq!(tail, "in thine eye");
temp.0, temp.1 로 꺼내는 것과 같지만 훨씬 읽기 좋다. 함수가 값을 여러 개
돌려주는 표준적인 방법이기도 하다.
포인터 타입
Rust에는 세 종류의 포인터가 있다.
- 레퍼런스
&T/&mut T— 안전한 기본값. 항상 유효한 값을 가리키고, 절대 널이 아니며, 빌림 검사기가 감시한다. - 박스
Box<T>— 힙 할당 소유 포인터. C++의unique_ptr에 해당한다. - 원시 포인터
*const T/*mut T— C의 포인터. 역참조하려면unsafe블록이 필요하다.
"위험한 걸 못 하게 막는다"가 아니라 "위험한 부분을 unsafe 로 표시해서 가둔다"
는 접근이다. 나중에 커널 코드처럼 하드웨어를 직접 다루는 영역에서는 결국
unsafe 를 쓰게 되는데, 그때 검토해야 할 범위가 unsafe 블록으로 좁혀진다.
배열, 벡터, 슬라이스
세 가지가 비슷해 보이지만 역할이 다르다.
배열 [T; N] — 길이가 타입의 일부다. 크기가 컴파일 시점에 고정되고 스택에
놓인다.
let lazy_caterer: [u32; 6] = [1, 2, 4, 7, 11, 16];
let taxonomy = ["Animalia", "Arthropoda", "Insecta"];
assert_eq!(lazy_caterer[3], 7);
assert_eq!(taxonomy.len(), 3);
// 에라토스테네스의 체
let mut sieve = [true; 10000];
for i in 2..100 {
if sieve[i] {
let mut j = i * i;
while j < 10000 {
sieve[j] = false;
j += i;
}
}
}
assert!(sieve[211]);
assert!(!sieve[9876]);
[true; 10000] 처럼 "값을 N개 반복"하는 문법이 있다.
벡터 Vec<T> — 힙에 할당되는 가변 길이 배열. (포인터, 길이, 용량) 세 워드로
이루어진다.
let mut primes = vec![2, 3, 5, 7];
assert_eq!(primes.iter().product::<i32>(), 210);
primes.push(11);
primes.push(13);
assert_eq!(primes.iter().product::<i32>(), 30030);
let v: Vec<i32> = (0..5).collect();
assert_eq!(v, [0, 1, 2, 3, 4]);
product::<i32>() 의 터보피시(::<>)는 이터레이터가 무슨 타입으로 곱을 낼지
알려주는 문법이다. collect() 도 마찬가지로 "무엇으로 모을지"를 타입으로 정한다.
슬라이스 &[T] — 배열이든 벡터든 연속된 구간을 가리키는 뷰. (포인터, 길이)
두 워드짜리 뚱뚱한 포인터다.
let v: Vec<f64> = vec![0.0, 0.707, 1.0, 0.707];
let a: [f64; 4] = [0.0, -0.707, -1.0, -0.707];
fn print(n: &[f64]) {
for elt in n {
println!("{}", elt);
}
}
print(&a); // 배열에 동작
print(&v); // 벡터에도 동작
print(&v[0..2]); // v의 앞 두 개
print(&a[2..]); // a[2]부터 끝까지
여기가 핵심이다. 함수를 &[f64] 로 받도록 쓰면 배열·벡터·부분 구간을 전부
받을 수 있다. C에서 (포인터, 길이) 를 따로 넘기던 관례가 타입 하나로 들어와
있고, 길이가 포인터에 붙어 다니니 어긋날 일이 없다.
문자열 타입
Rust의 문자열은 항상 UTF-8 이다.
문자열 리터럴 &'static str
let speech = "\"Ouch!\" said the well.\n";
// 여러 줄에 걸칠 수 있다
println!("In the room the women come and go,
Singing of Mount Abora");
// 줄 끝의 백슬래시는 개행과 다음 줄 앞 공백을 제거한다
println!("It was a bright, cold day in April, and \
there were four of us—\
more or less.");
// 원시 문자열: 이스케이프가 없다
let path = r"C:\Program Files\Gorillas";
println!(r###"'"' 뒤에 ###이 와야 끝나는 원시 문자열"###);
윈도우 경로나 정규식을 쓸 때 r"..." 가 정말 편하다. 따옴표가 들어가야 하면
r#"..."# 처럼 # 개수를 늘린다.
바이트 문자열 &[u8; N]
let method = b"GET";
assert_eq!(method, &[b'G', b'E', b'T']);
프로토콜 헤더처럼 UTF-8이 아니라 바이트를 다뤄야 할 때 쓴다.
String — 힙에 있는 소유 문자열. Vec<u8> 위에 UTF-8 보장을 얹은 것이다.
let error_message = "too many pets".to_string();
assert_eq!(format!("{}°{:02}′{:02}″N", 24, 5, 23), "24°05′23″N".to_string());
let bits = vec!["veni", "vidi", "vici"];
assert_eq!(bits.concat(), "venividivici");
assert_eq!(bits.join(", "), "veni, vidi, vici");
assert!("ONE".to_lowercase() == "one");
assert!("peanut".contains("nut"));
assert_eq!("ಠ_ಠ".replace("ಠ", "■"), "■_■");
assert_eq!(" clean\n".trim(), "clean");
for word in "veni, vidi, vici".split(", ") {
assert!(word.starts_with("v"));
}
String 과 &str 의 관계는 Vec<T> 와 &[T] 의 관계와 똑같다. 소유 vs 뷰.
그래서 함수 인자는 웬만하면 &str 로 받는 게 좋다. String 을 넘겨도 자동으로
&str 로 역참조 강제(deref coercion)되기 때문에 호출자가 편해진다.
UTF-8이라 바이트 인덱싱이 안 된다는 점은 짚고 넘어가야 한다. s[0] 은
컴파일되지 않는다. 한 글자가 1~4바이트이므로 s.chars() 로 순회하거나
s.bytes() 로 바이트를 본다. 처음엔 불편했지만, 이게 없으면 멀티바이트 문자
가운데를 잘라 잘못된 UTF-8을 만드는 게 너무 쉽다.
타입 별칭
긴 타입에 이름을 붙인다.
type Bytes = Vec<u8>;
fn decode(data: &Bytes) {
// ...
}
새 타입을 만드는 게 아니라 그냥 별명이라, Vec<u8> 과 완전히 호환된다. 뒤에
오류 처리 회차에서 type Result<T> = std::result::Result<T, MyError>; 형태로
아주 유용하게 쓰인다.
정리
기본 타입 회차인데 배운 건 결국 "Rust는 애매한 자리를 남기지 않는다" 였다.
- 정수 크기가 플랫폼에 따라 흔들리지 않는다
- 타입 변환은 반드시 명시적
- 오버플로 정책을 연산마다 고른다
- 슬라이스로 (포인터, 길이) 관례를 타입에 넣었다
- 문자열은 UTF-8이 보장되고, 그래서 바이트 인덱싱이 금지된다
다음 편은 소유와 이동 — 첫 회차에서 훑고 지나간 소유권을 제대로 파고든다.