본문 바로가기

데이터 타입

정수·부동소수점·불리언·문자 스칼라 타입과 튜플·배열 복합 타입의 표현 범위와 연산 방식을 익힙니다.

러스트의 모든 값에는 타입이 있으며, 타입은 그 값이 어떤 종류의 데이터인지, 그래서 그 값으로 무엇을 할 수 있는지를 러스트에 알려 줍니다. 러스트는 정적 타입(statically typed) 언어여서, 모든 변수의 타입이 컴파일할 때 정해져 있어야 합니다. 대개는 값과 그 값을 쓰는 방식을 보고 컴파일러가 타입을 추론하지만, 여러 타입이 가능한 경우에는 타입을 직접 적어야 합니다.

추리 게임에서 parse로 String을 숫자로 바꿀 때가 그런 경우입니다. parse는 여러 타입으로 바꿀 수 있으므로 다음처럼 타입 표기를 붙입니다.

let guess: u32 = "42".parse().expect("Not a number!");

parse는 문자열을 u32, i64, f64처럼 여러 타입으로 바꿀 수 있어서, 결과 타입이 정해져야 동작합니다. 뒤의 코드에도 타입을 정할 단서가 없는데 이 : u32를 빼면, 컴파일러는 어떤 타입으로 바꿀지 알 수 없다며 다음 오류를 냅니다. 도움말에서 /* Type */으로 비워 둔 자리가 원래 코드의 u32가 있던 곳입니다.

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!");
  |              ++++++++++++

타입 표기가 필요하다(type annotations needed)는 오류이며, guess에 타입을 적으라는 도움말이 붙습니다. 모든 parse에 표기가 필요한 것은 아니고, 뒤의 코드가 그 값을 u32 매개변수에 넘기는 것처럼 타입을 정해 주거나 "42".parse::<u32>()처럼 메서드에 직접 타입을 적어도 됩니다. 아래 그림은 타입이 컴파일할 때 어떻게 정해지는지, 그리고 타입이 정해진 뒤에도 실행할 때 따로 검사하는 것은 무엇인지를 보여 줍니다.

타입 추론과 실행 시점 안전 검사의 경계

FLOWCHART · COMPILE / RUN

타입 추론과 실행 시점 안전 검사의 경계

리터럴과 사용 위치 제약 또는 명시적 annotation은 컴파일 때 하나의 구체 타입으로 합쳐진다. 타입 불일치는 실행 전에 막히지만, 동적 배열 index와 정수 overflow 정책은 별도의 실행 또는 API 경계다.

타입 추론의 입력과 성공·실패 분기, 별개 런타임 검사8노드7간선이다. 입력표현과사용위치/명시제약두입력이타입추론에합류한다. 제약충분성판정은컴파일진단과타입검증통과로갈린다. 검증통과에서동적index검사와명시overflow정책으로독립분기한다. index검사뒤overflow를순차실행한다는뜻도parse가항상성공한다는뜻도아니다. 모바일도기존desktop과같은의존과분기다.입력 표현literal · parse타입 제약사용 위치·타입 명시컴파일 추론예: parse::<u32>()제약이 충분하고일관적인가?컴파일 진단불일치 · 모호성타입 검증 통과입력 데이터는 동적불일치·부족일치동적 인덱스index < N 검사범위 밖이면 panic덧셈 정책checked · wrappingoverflowingsaturating두 검사는 독립된 사용 사례타입만으로 입력 범위를 보장 안 함
타입 추론과 실행 시점 안전 검사의 경계 리터럴과 parse 입력이 사용 위치 제약 및 annotation으로 구체 타입을 얻고 타입 불일치는 컴파일 실패하며 성공한 값은 실행 단계의 배열 범위 검사와 명시적 overflow API를 거친다. 불일치·모호성일치·충분입력 표현literal / parse리터럴·메서드타입 제약use + annotation사용 위치·명시컴파일 시 타입 추론COMPILE TIME예: parse::<u32>()타입 제약이일관·충분한가?컴파일 진단불일치·모호성타입 검증 통과데이터 값은 아직 동적동적 index 범위 검사index < N범위를 벗어나면 panic명시적 overflow 정책checked · wrappingoverflowing · saturating
  1. 타입 입력

    리터럴, 메서드 사용 위치, 함수 인수·반환 타입이 추론 제약을 제공하고 모호한 parse 결과에는 annotation을 둡니다.

  2. 컴파일 결과

    컴파일러는 값마다 하나의 구체 타입을 결정한다. 제약이 모순되거나 부족하면 실행 파일을 만들기 전에 거부합니다.

  3. 배열 범위

    유효한 [T; N] 타입만으로 동적 index가 범위 안임을 알 수 없다. 실행 때 index < N을 검사하고 벗어나면 패닉합니다.

  4. overflow 정책

    프로필에 따른 암시적 동작을 계약으로 삼지 말고 checked_*, wrapping_*, overflowing_*, saturating_*로 의도를 표현합니다.

  5. 서로 다른 보장

    컴파일 시 타입 안전과 실행 시 데이터 범위 검사는 협력하지만 같은 검사는 아닙니다.

Rust char는 4바이트 Unicode scalar value이지 사용자에게 보이는 grapheme 하나와 같지 않다. 부동소수점 IEEE-754도 정확한 십진 산술을 뜻하지 않는다.

값의 모양과 쓰이는 자리, 타입 표기를 모아 컴파일러가 하나의 타입을 정하며, 단서가 부족하거나 서로 맞지 않으면 컴파일 오류가 납니다. 타입이 맞는다고 실행 중의 모든 문제가 막히는 것은 아닙니다. 실행할 때 정해지는 배열 인덱스는 실행 중에 범위를 검사하고, 정수의 범위를 넘는 계산은 어떻게 처리할지 따로 정해야 합니다. 이 두 가지는 이 글의 뒤에서 다룹니다. 이제 러스트의 타입을 하나의 값을 나타내는 스칼라 타입과 여러 값을 묶는 복합 타입으로 나누어 살펴봅니다.

스칼라 타입

스칼라(scalar) 타입은 값 하나를 나타냅니다. 러스트의 스칼라 타입은 정수, 부동 소수점 수, 불리언(boolean), 문자 네 가지이며, 다른 언어에서도 본 적이 있을 것입니다.

정수형

정수형(integer type)은 소수 부분이 없는 수입니다. 추리 게임에서 쓴 u32가 정수형의 하나로, 32비트 크기의 부호 없는(unsigned) 정수를 뜻하며, 부호 있는(signed) 정수형은 u 대신 i로 시작합니다. 러스트의 정수형은 다음과 같습니다.

크기부호 있음부호 없음
8비트i8u8
16비트i16u16
32비트i32u32
64비트i64u64
128비트i128u128
아키텍처에 따름isizeusize

부호가 있다는 것은 음수도 담을 수 있다는 뜻이고, 부호가 없다는 것은 0과 양수만 담는다는 뜻입니다. 부호 있는 정수는 2의 보수(two's complement)로 저장하며, n비트 부호 있는 정수는 -2^(n-1)부터 2^(n-1) - 1까지를 담습니다. 그래서 i8은 -128부터 127까지를 담고, n비트 부호 없는 정수는 0부터 2^n - 1까지를 담으므로 u8은 0부터 255까지를 담습니다. isize와 usize의 크기는 프로그램이 실행되는 컴퓨터의 아키텍처에 따라 정해지며, 64비트 아키텍처에서는 64비트, 32비트 아키텍처에서는 32비트입니다.

정수 리터럴은 다음과 같은 형태로 쓸 수 있습니다. 여러 정수 타입이 될 수 있는 리터럴에는 57u8처럼 타입을 뒤에 붙여 정할 수 있고, 1_000처럼 밑줄로 자릿수를 나누어 읽기 쉽게 쓸 수 있으며 이는 1000과 같은 값입니다.

리터럴예
10진수98_222
16진수0xff
8진수0o77
2진수0b1111_0000
바이트(u8만)b'A'

어떤 정수형을 써야 할지 모르겠다면 러스트의 기본값인 i32로 시작하면 됩니다. isize와 usize는 주로 컬렉션의 인덱스에 씁니다.

정수형은 담을 수 있는 범위가 정해져 있습니다. 예를 들어 0부터 255까지 담는 u8 변수의 값을 256으로 만들려 하면 정수 오버플로(integer overflow)가 일어나며, 러스트는 빌드 설정에 따라 둘 가운데 하나로 동작합니다. 기본 dev 프로필로 빌드한 디버그 빌드에는 오버플로를 검사하는 코드가 들어 있어, 오버플로가 나면 실행 중에 패닉(panic)이 나서 프로그램이 멈춥니다. 패닉은 panic!과 복구 불가능한 오류에서 자세히 다룹니다.

--release로 빌드한 릴리스 빌드에는 이 검사가 들어가지 않고, 오버플로가 나면 2의 보수 감싸기(wrapping)를 합니다. 타입이 담을 수 있는 가장 큰 값을 넘으면 가장 작은 값부터 다시 세는 것으로, u8이라면 256은 0이, 257은 1이 됩니다. 패닉은 나지 않지만 변수에는 예상하지 못한 값이 들어가며, 이 감싸기 동작에 기대는 것은 오류로 봅니다. 이 차이는 기본 프로필 설정에 따른 것이고 overflow-checks 설정으로 바꿀 수 있으므로, 빌드의 이름만으로 늘 같다고 여기지는 않습니다.

오버플로가 날 수 있는 계산을 일부러 다루려면, 표준 라이브러리가 기본 숫자 타입에 제공하는 메서드를 씁니다. wrapping_* 메서드는 항상 감싸기를 하고, checked_* 메서드는 오버플로가 나면 값 대신 None을 돌려주며, overflowing_* 메서드는 결과 값과 오버플로가 났는지를 담은 불리언을 함께 돌려주고, saturating_* 메서드는 결과를 타입의 최솟값과 최댓값 사이로 자릅니다.

src/main.rs
fn main() {
    let value = 250_u8;
    println!("{:?}", value.checked_add(10));
    println!("{}", value.wrapping_add(10));
    let (wrapped, overflowed) = value.overflowing_add(10);
    println!("{wrapped} {overflowed}");
    println!("{}", value.saturating_add(10));
}
None
4
4 true
255

250에 10을 더한 260은 u8에 담을 수 없으므로 checked_add는 None을 돌려주며, {:?}는 이런 값을 디버깅용 모양으로 출력합니다. wrapping_add는 260에서 256을 뺀 4를, overflowing_add는 4와 오버플로가 났다는 true를, saturating_add는 u8의 최댓값인 255를 돌려줍니다. 한편 let value: u8 = 256;처럼 범위 밖의 리터럴을 바로 쓰면 실행할 필요도 없이 컴파일할 때 오류가 납니다.

부동 소수점 타입

러스트에는 소수점이 있는 수를 나타내는 부동 소수점(floating-point) 타입이 둘 있습니다. f32는 32비트, f64는 64비트이며 둘 다 부호가 있습니다. 기본 타입은 f64인데, 요즘 CPU에서는 f32와 속도가 거의 같으면서 더 정밀하기 때문입니다.

src/main.rs
fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

부동 소수점 수는 IEEE-754 표준을 따르며, f32는 단정밀도(single precision), f64는 배정밀도(double precision) 형식입니다.

수치 연산

러스트의 모든 숫자 타입은 덧셈, 뺄셈, 곱셈, 나눗셈, 나머지 같은 기본 연산을 지원합니다. 정수의 나눗셈은 0에 가까운 쪽으로 소수 부분을 버립니다.

src/main.rs
fn main() {
    // 덧셈
    let sum = 5 + 10;

    // 뺄셈
    let difference = 95.5 - 4.3;

    // 곱셈
    let product = 4 * 30;

    // 나눗셈
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // -1

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

각 식은 연산자로 값을 계산하고 그 결과를 변수에 묶습니다. -5 / 3은 -1.66...에서 0에 가까운 쪽으로 소수 부분을 버려 -1이 되고, 43 % 5는 3입니다. 러스트의 모든 연산자는 부록 B 연산자와 기호에 정리되어 있습니다.

더 깊이: 오버플로는 정의되지 않은 동작이 아닙니다

Reference는 정수 오버플로를 unsafe로 보지 않는 동작으로 분류합니다. 오버플로는 여전히 프로그래머의 실수이지만, debug_assert!가 켜진 빌드에서는 구현이 반드시 검사를 넣어 패닉을 내야 하고, 그 밖의 빌드에서는 패닉을 내거나 2의 보수로 감싼 값을 내야 합니다. 감싸는 경우에도 결과는 정해진 값이어야 하므로, 오버플로가 일어났다고 프로그램 전체가 정의되지 않은 동작에 빠지지는 않습니다. 이 규칙의 배경과 자세한 조건은 RFC 560에 정리되어 있습니다.

몇몇 연산은 overflow-checks 설정과 상관없이 실행 중에 검사됩니다. 정수를 0으로 나누거나 그 나머지를 구하면 언제나 패닉이 납니다. 부호 있는 타입의 최솟값을 -1로 나누는 경우도 Reference가 오버플로로 분류하면서, 예전부터의 사정으로 overflow-checks를 꺼도 검사가 남도록 정해 두었습니다. 시프트 연산 <<, >>에서 오른쪽 값이 왼쪽 타입의 비트 수 이상이거나 음수인 것도 Reference는 오버플로로 보지만, 이 경우에는 그런 예외가 없어 다른 오버플로처럼 overflow-checks 설정을 따릅니다.

반면 as로 부동 소수점 수를 정수로 바꾸는 변환은 패닉을 내지 않습니다. 소수 부분은 0 쪽으로 버리고, 정수 타입의 범위를 넘는 값은 그 타입의 최댓값이나 최솟값으로 자르며, NaN은 0이 됩니다. 그래서 이 변환은 실패하지 않는 대신, 범위를 넘었다는 사실을 결과만 보고는 알 수 없습니다.

불리언 타입

다른 언어처럼 러스트의 불리언 타입도 true와 false 두 값을 가지며, 크기는 1바이트이고 타입 이름은 bool입니다. 아래 코드는 타입을 추론에 맡기는 방법과 직접 적는 방법으로 불리언 변수를 하나씩 만듭니다. true를 넣은 t도 컴파일러가 bool로 추론하므로, 두 변수의 타입은 같습니다.

src/main.rs
fn main() {
    let t = true;

    let f: bool = false; // 타입을 직접 적음
}

불리언은 주로 if 같은 조건에 쓰며, if는 제어 흐름문에서 다룹니다.

문자 타입

러스트의 char는 가장 기본적인 문자 타입입니다. 아래 코드의 세 값은 영어 소문자, 수학 기호, 이모지로 모양이 서로 다르지만, 모두 char 하나에 담깁니다.

src/main.rs
fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // 타입을 직접 적음
    let heart_eyed_cat = '😻';
}

문자열 리터럴은 큰따옴표로 쓰지만 char 리터럴은 작은따옴표로 씁니다. char는 4바이트 크기이고 유니코드 스칼라 값(Unicode scalar value) 하나를 나타내므로 ASCII보다 훨씬 많은 문자를 담으며, 악센트가 붙은 글자, 한글과 한자, 이모지, 폭이 0인 공백도 모두 char 값입니다. 유니코드 스칼라 값의 범위는 U+0000부터 U+D7FF까지, 그리고 U+E000부터 U+10FFFF까지입니다.

다만 사람이 생각하는 글자 하나가 char 하나와 늘 같지는 않습니다. 예를 들어 '가'는 char로는 4바이트이지만, 문자열 "가"를 UTF-8로 저장하면 3바이트이므로 "가".len()은 3입니다. 같은 글자라도 타입과 인코딩에 따라 크기가 다르며, 이 문제는 UTF-8 문자열에서 자세히 다룹니다.

복합 타입

복합(compound) 타입은 여러 값을 하나의 타입으로 묶습니다. 러스트의 기본 복합 타입은 튜플(tuple)과 배열(array) 두 가지이며, 아래 그림처럼 둘 다 길이가 정해진 하나의 값이지만 원소의 타입과 접근 방법이 다릅니다.

튜플과 배열이 고정하는 구조 계약

ARCHITECTURE · COMPOUND TYPES

튜플과 배열이 고정하는 구조 계약

튜플은 위치마다 다른 타입을 갖는 고정 길이 구조이고, 배열은 모든 원소가 같은 타입인 [T; N] 고정 길이 구조다. 둘 다 값 하나지만 접근과 이동에서 드러내는 계약은 다르다.

튜플과 배열의 두 독립 구조 계약원desktop과같은6노드4간선이다. 각각타입→접근→포함값소유권의3노드2간선이고2구획사이간선없다. 튜플은위치별타입이달라도되고배열은동일T/고정N이다. 배열의저장장소를구조타입으로확정하지않는다. 모바일도소유권/Copy조건을보존한다.튜플 · 위치별 타입고정 구조(i32, f64, u8)위치마다 타입이 다름위치로 접근destructure · tuple.0컴파일 때 위치·타입 확정포함 값의 소유권필드별 이동·Copy배열 · 같은 T, N고정 구조[T; N]길이 N은 타입의 일부인덱스로 접근array[index]동적 값은 범위 검사포함 값의 소유권원소별 이동·Copy저장 장소는 별도 조건두 구조 사이 변환 간선 없음
튜플과 배열이 고정하는 구조 계약 튜플 영역은 서로 다른 위치 타입과 destructuring 및 점 index를, 배열 영역은 같은 T와 고정 N 및 대괄호 index와 실행 범위 검사를 보여주며 두 구조 모두 포함 값의 소유권을 따른다. 튜플 · 위치별 타입배열 · 같은 T, 고정 N(i32, f64, u8)고정 위치 · 다른 타입[T; N]고정 길이 · 같은 타입destructure · tuple.0위치·타입은 컴파일 때 확정array[index]동적 인덱스는 범위 검사필드별 소유권·Copy값 이동은 포함 타입을 따름원소별 소유권·Copy저장 영역은 구조만으로 고정 안 됨튜플: 위치별 타입 · 배열: 같은 타입 + 고정 길이
  1. 튜플 타입

    (i32, f64, u8)처럼 위치마다 타입이 다를 수 있고 길이는 고정됩니다.

  2. 튜플 접근

    패턴 let (x, y, z) = tuple로 구조 분해하거나 컴파일 시 정해진 .0, .1 위치를 사용합니다.

  3. 배열 타입

    [T; N]에서 모든 원소 타입은 T이고 길이 N은 타입 계약의 일부입니다.

  4. 배열 접근

    array[index]는 동적 index를 받을 수 있어 실행 시 범위를 검사한다. 고정 길이가 모든 index를 컴파일 때 증명하지는 않습니다.

  5. 소유권

    두 구조 모두 포함된 값의 소유권·Copy 여부를 따른다. 배열을 특정 저장 영역에 항상 둔다고 일반화하지 않습니다.

튜플은 이질적인 역할 묶음, 배열은 동질적인 고정 개수에 적합하다. 구조 선택은 배치 위치가 아니라 필드 타입·길이·접근 계약으로 설명한다.

튜플은 위치마다 타입이 다를 수 있고 위치로 원소를 꺼내며, 위치와 타입은 컴파일할 때 정해집니다. 배열은 모든 원소가 같은 타입 T이고 길이 N까지 타입의 일부인 [T; N]이며, 인덱스로 원소를 꺼내고 실행할 때 정해지는 인덱스는 범위를 검사합니다. 두 타입 모두 원소의 소유권은 원소마다 따로 다뤄지는데, 이는 소유권이 뭔가요?에서 다룹니다.

튜플 타입

튜플은 타입이 서로 다를 수 있는 여러 값을 하나로 묶는 일반적인 방법입니다. 길이가 정해져 있어 한 번 선언하면 늘리거나 줄일 수 없습니다. 괄호 안에 값을 쉼표로 나열해 만들며, 각 위치의 타입을 적을 수도 있습니다.

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

변수 tup에는 튜플 전체가 하나의 값으로 묶입니다. 튜플의 값을 하나씩 꺼내려면 패턴으로 튜플을 분해합니다.

src/main.rs
fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}
The value of y is: 6.4

let (x, y, z) = tup;은 패턴으로 튜플을 세 변수 x, y, z로 나눕니다. 튜플 하나를 여러 부분으로 나누므로 이를 구조 분해(destructuring)라고 합니다. 마침표 뒤에 위치 번호를 적어 원소에 바로 접근할 수도 있으며, 첫 위치는 0입니다.

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;
}

값이 하나도 없는 튜플 ()는 유닛(unit)이라고 하며, 값과 타입을 모두 ()로 씁니다. 유닛은 빈 값이나 돌려줄 것이 없는 반환 타입을 나타내며, 값을 돌려주지 않는 식은 유닛을 돌려줍니다.

배열 타입

배열도 여러 값을 묶지만, 튜플과 달리 모든 원소의 타입이 같아야 합니다. 또 다른 몇몇 언어와 달리 러스트의 배열은 길이가 정해져 있습니다. 대괄호 안에 값을 쉼표로 나열해 만듭니다.

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

배열은 원소의 개수가 바뀌지 않는다는 것을 알 때 유용합니다. 원소를 늘리거나 줄여야 한다면 표준 라이브러리의 벡터(vector)를 쓰며, 배열과 벡터 가운데 무엇을 쓸지 모르겠다면 대개 벡터가 맞습니다. 벡터는 벡터와 값 목록에서 다룹니다. 반면 한 해의 달 이름처럼 원소가 언제나 12개라는 것을 안다면 배열이 알맞습니다.

let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];

배열의 타입은 대괄호 안에 원소의 타입과 세미콜론, 원소의 개수를 적어 나타냅니다. [i32; 5]는 i32 원소 다섯 개로 된 배열입니다.

let a: [i32; 5] = [1, 2, 3, 4, 5];

같은 값으로 모든 원소를 채우려면 대괄호 안에 처음 값과 세미콜론, 길이를 적습니다. 다음 배열은 원소 다섯 개가 모두 3이며, let a = [3, 3, 3, 3, 3];과 같습니다.

let a = [3; 5];

배열 원소에 접근하기

배열은 크기가 정해진 한 덩어리의 메모리이며, 인덱스로 원소에 접근합니다. 인덱스는 배열 이름 뒤의 대괄호 안에 적고, 첫 원소의 인덱스는 0입니다. 아래 코드는 앞의 두 원소를 꺼내 각각 변수에 담습니다.

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가 들어갑니다.

범위를 벗어난 인덱스

배열의 끝을 넘는 원소에 접근하면 어떻게 될까요? 추리 게임처럼 사용자에게 인덱스를 입력받는 다음 코드로 확인해 봅니다.

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}");
}

이 코드는 컴파일됩니다. 실행해 0부터 4 사이의 수를 입력하면 그 인덱스의 원소를 출력하지만, 배열의 끝을 넘는 10을 입력하면 다음과 같이 패닉이 납니다.

thread 'main' (6017887) 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

괄호 안의 숫자는 스레드의 ID로 실행할 때마다 다르며, 메시지는 19번째 줄의 a[index]에서, 길이가 5인 배열에 인덱스 10을 쓰려 했다는 실행 중 오류입니다. 프로그램은 이 메시지와 함께 멈추고 마지막 println!은 실행되지 않습니다. 러스트는 인덱스로 원소에 접근할 때마다 그 인덱스가 배열의 길이보다 작은지 검사하고, 크거나 같으면 패닉을 냅니다. 사용자가 무엇을 입력할지는 컴파일할 때 알 수 없으므로 이 검사는 실행 중에 해야 합니다.

많은 저수준 언어는 이 검사를 하지 않아, 잘못된 인덱스를 쓰면 배열 밖의 메모리를 읽어 버립니다. 러스트는 그런 접근을 허용한 채 계속 실행하는 대신 바로 멈춰서, 잘못된 메모리를 쓰는 오류를 막습니다. 패닉 없이 이런 상황을 다루는 방법은 8장의 오류 처리에서 배웁니다.

정리

  • 러스트는 정적 타입 언어이며, 추론할 단서가 부족하면 : u32 같은 타입 표기를 적어야 합니다.
  • 정수형은 크기와 부호에 따라 i8부터 u128, isize와 usize가 있고, 기본은 i32입니다.
  • 정수 오버플로는 디버그 빌드에서 패닉을, 릴리스 빌드에서 감싸기를 하며, 일부러 다루려면 checked_*, wrapping_*, overflowing_*, saturating_*를 씁니다.
  • 부동 소수점 타입은 f32와 f64이고 기본은 f64이며, 정수의 나눗셈은 0 쪽으로 소수 부분을 버립니다.
  • bool은 true와 false를, char는 4바이트 유니코드 스칼라 값 하나를 담습니다.
  • 튜플은 타입이 다를 수 있는 값을 고정된 개수로 묶고, 구조 분해나 .0 같은 위치 번호로 꺼냅니다.
  • 배열은 같은 타입의 값을 고정된 개수로 묶은 [T; N]이며, 범위를 벗어난 인덱스는 실행 중에 패닉을 냅니다.

연습 문제

1. 입력에 따라 멈추는 곳

배열 인덱스 예제를 세 번 실행해 각각 4, 5, foo를 입력합니다. 각 실행은 마지막 줄까지 출력하나요? 그렇지 않다면 어느 단계에서 멈추나요?

해설 보기

4는 usize로 바뀌고 길이 5보다 작으므로 The value of the element at index 4 is: 5를 출력합니다. 5는 숫자로는 바뀌지만 유효한 인덱스 0부터 4를 벗어나므로, a[index]에서 index out of bounds: the len is 5 but the index is 5 패닉이 납니다. foo는 usize로 바뀌지 않으므로 그보다 앞의 expect에서 Index entered was not a number와 함께 멈추고, 인덱스 접근까지 가지 않습니다.

2. 범위를 넘는 계산

let a: u8 = 200;과 let b: u8 = 100;을 두고 a + b를 디버그 빌드와 릴리스 빌드에서 실행하면 각각 어떻게 되나요? 오버플로가 나면 u8의 최댓값으로 맞추고 싶다면 어떻게 쓰나요?

해설 보기

200 + 100 = 300은 u8의 범위 0부터 255를 넘습니다. 두 값을 이처럼 코드에 바로 적으면 컴파일러가 이 덧셈이 넘친다는 것을 미리 알아내므로, 디버그 빌드와 릴리스 빌드 모두 arithmetic_overflow 오류로 컴파일되지 않습니다. 두 값이 사용자 입력처럼 실행 중에 정해진다면, 기본 설정의 디버그 빌드에서는 오버플로 검사 때문에 패닉이 나고, 릴리스 빌드에서는 300에서 256을 뺀 44로 감싸집니다. 최댓값으로 맞추려면 a.saturating_add(b)를 쓰며 결과는 255입니다.

3. 튜플과 배열 고르기

(가) 이름, 나이, 키를 함께 돌려주는 값, (나) 일주일 동안의 하루 최고 기온 일곱 개, (다) 사용자가 계속 추가하는 할 일 목록에는 각각 무엇이 알맞나요?

해설 보기

(가)는 문자열과 정수, 부동 소수점 수처럼 타입이 서로 다른 값을 묶으므로 (String, u32, f64) 같은 튜플이 알맞습니다. (나)는 같은 타입의 값이 언제나 일곱 개이므로 [f64; 7] 배열이 알맞습니다. (다)는 원소의 개수가 바뀌므로 배열이 아니라 벡터를 씁니다.

다음 글 함수에서는 함수를 정의하고 매개변수를 넘기는 방법, 그리고 러스트에서 구문과 표현식이 어떻게 다른지 살펴봅니다.