</>PatchNote
목록으로

2024-07-07

HSPACE Rust 특강 #2 — 기본 타입

RustHSPACE 2024Type System

이번 회차는 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 / -1128 이어야 하는데 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, 문자, 튜플

booltrue / 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이 보장되고, 그래서 바이트 인덱싱이 금지된다

다음 편은 소유와 이동 — 첫 회차에서 훑고 지나간 소유권을 제대로 파고든다.