본문 바로가기

데이터 타입

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

러스트의 모든 값은 특정한 타입을 가지며, 이는 러스트가 해당 데이터로 작업하는 방법을 알 수 있도록 어떤 종류의 데이터가 지정되고 있는지 알려줍니다.

여기서는 타입을 스칼라 타입과 복합 타입, 두 가지 부분 집합으로 나누어 보겠습니다.

러스트는 정적 타입(statically typed) 언어라는 점을 기억하세요.

이게 의미하는 바는 모든 변수의 타입이 컴파일 시점에 반드시 정해져 있어야 한다는 겁니다.

보통 컴파일러는 우리가 값을 어떻게 사용하는지에 따라 타입을 추측할 수 있습니다.

1장의 ‘비밀 숫자와 추릿값을 비교하기’에서 사용한 parse는 여러 타입으로 변환할 수 있습니다. 아래 한 줄을 다른 타입 정보 없이 사용하면 변환 결과를 고를 근거가 부족하므로, : u32로 원하는 타입을 알려 줍니다.

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

이 예제처럼 뒤의 사용에도 타입을 결정할 정보가 없다면, 타입 명시를 제거했을 때 아래와 같은 진단을 만납니다. 모든 parse 호출에 같은 위치의 명시가 필수라는 뜻은 아닙니다. "42".parse::<u32>()로 호출의 타입 인수를 지정하거나, 다른 사용 위치가 같은 타입 제약을 제공할 수도 있습니다. 진단의 줄 번호·표현은 도구 버전에 따라 달라집니다.

$ 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!");
  |         ^^^^^
  |
help: consider giving `guess` an explicit type
  |
2 |     let guess: _ = "42".parse().expect("Not a number!");
  |              +++

For more information about this error, try `rustc --explain E0282`.
error: could not compile `no_type_annotations` due to previous error

다양한 데이터 타입들의 타입 명시를 살펴보겠습니다.

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

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)은 소수점이 없는 숫자입니다.

1장의 추리 게임에서 정수형 중 하나인 u32 타입을 사용했었죠.

해당 타입의 선언은 부호 없는 32비트 변수임을 나타냅니다(부호 있는 타입은 u 대신 i로 시작합니다).

표 2-1은 러스트에서 사용되는 정수형들을 보여줍니다.

이 변형 중 어떤 것이라도 정숫값의 타입 선언에 사용할 수 있습니다.

표 2-1: 러스트의 정수형 타입들
길이부호 있음(signed)부호 없음(unsigned)
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize

각각의 타입은 부호 있는 (signed) 혹은 부호 없는 (unsigned) 타입이며 명시된 크기를 갖습니다.

부호 혹은 부호 없음의 의미는, 이 타입이 음수를 다룰 수 있는지를 나타냅니다.

다르게 말하면, 숫자가 부호를 가져야 하는 경우인지(부호 있는) 혹은 0과 양수만을 가질 것이기에 부호 없이 표현 가능한지를 (부호 없는) 나타냅니다.

종이에 숫자를 기재하는 것과 같습니다.

부호와 함께 다뤄야 하는 경우 숫자는 덧셈이나 뺄셈 기호와 함께 표시하지요.

하지만 숫자가 양수라고 가정해도 문제없는 상황에는 부호 없이 표시하게 됩니다.

부호 있는 숫자는 2의 보수 형태를 사용하여 저장됩니다.

각 부호 있는 타입의 변수는 -(2n - 1)부터 2n - 1 - 1까지의 값을 저장할 수 있습니다.

여기서 n은 사용되는 타입의 비트 수입니다.

따라서 i8은 -(27)에서 27 - 1까지의 값, 즉 -128에서 127 사이의 값을 저장할 수 있습니다.

부호 없는 타입은 0에서 2n - 1까지의 값을 저장할 수 있습니다.

그래서 u8 타입은 0에서 28 - 1 다시 말해, 0에서 255까지의 값을 저장할 수 있습니다.

추가로, isize와 usize 타입은 여러분의 프로그램이 동작하는 컴퓨터 환경에 따라 결정되는데, 위 테이블에는 ‘arch’라고 적시되어 있습니다.

64-bit 아키텍처이면 64비트를, 32-bit 아키텍처이면 32비트를 갖게 됩니다.

정수형 리터럴은 표 2-2에서 보시는 것과 같은 형태로 작성할 수 있습니다.

여러 숫자 타입이 될 수 있는 숫자 리터럴에는 57u8과 같은 타입 접미사를 사용하여 타입을 지정할 수 있습니다.

1_000처럼 시각적인 구분으로 읽기 쉽게 하기 위해서 _을 사용할 수 있는데, 이는 1000이라고 쓴 것과 똑같은 값이 됩니다.

표 2-2: 러스트의 정수형 리터럴
숫자 리터럴예
Decimal98_222
Hex0xff
Octal0o77
Binary0b1111_0000
Byte(u8 only)b'A'

그러면 어떤 타입의 정수를 사용해야 하는지는 어떻게 알아낼까요?

확실히 정해진 경우가 아니라면 러스트의 기본값인 i32가 일반적으로 좋은 시작 지점이 됩니다.

isize나 usize는 주로 어떤 컬렉션 종류의 인덱스에 사용됩니다.

정수 오버플로우

여러분이 0과 255 사이의 값을 담을 수 있는 u8 타입의 변수를 갖고 있다고 해봅시다. 만약에 이 변수의 값을 256처럼 범위 밖의 값으로 변경하려고 하면 정수 오버플로우(integer overflow)가 일어나는데, 이는 둘 중 한 가지 동작을 일으킵니다. 코드를 디버그 모드에서 컴파일하는 경우, 기본 dev 프로필은 런타임에 정수 오버플로우가 발생했을 때 패닉(panic)을 발생시키는 검사를 포함시킵니다. 러스트에서는 에러가 발생하면서 프로그램이 종료되는 경우 패닉이라는 용어를 사용합니다; 8장의 ‘panic!으로 복구 불가능한 에러 처리하기’ 절에서 패닉에 대해 좀 더 자세히 다루겠습니다.

--release 플래그를 사용하여 코드를 기본 release 프로필로 컴파일하는 경우에는 패닉을 발생시키는 정수 오버플로우 검사를 실행파일에 포함시키지 않습니다. 대신 오버플로우가 발생하면 러스트는 2의 보수 감싸기(two's complement wrapping) 을 수행합니다. 짧게 설명하자면, 해당 타입이 가질 수 있는 최댓값보다 더 큰 값은 허용되는 최솟값으로 ‘돌아갑니다(wrap around)’. u8의 경우 256은 0이, 257은 1이 되는 식입니다. 프로그램은 패닉을 발생시키지 않으나, 해당 변수는 아마도 여러분이 예상치 못했던 값을 갖게 될 겁니다. 정수 오버플로우의 감싸기 동작에 의존하는 것은 에러로 간주됩니다.

이 동작은 기본 프로필 설정입니다. overflow-checks 설정이나 컴파일러 옵션으로 검사 여부를 바꿀 수 있으므로, dev/release 이름만으로 항상 같은 정책이라고 가정하지 않습니다.

명시적으로 오버플로우의 가능성을 다루기 위해서는 표준 라이브러리에서 기본 수치 타입에 대해 제공하는 아래 메서드 종류들을 사용할 수 있습니다.

  • wrapping_add와 같은 wrapping_* 메서드로 감싸기 동작 실행하기
  • checked_* 메서드를 사용하여 오버플로우가 발생하면 None 값 반환하기
  • overflowing_* 메서드를 사용하여 값과 함께 오버플로우 발생이 있었는지를 알려주는 부울린 값 반환하기
  • saturating_* 메서드를 사용하여 값의 최대 혹은 최솟값 사이로 제한하기

부동 소수점 타입

러스트에도 소수점을 갖는 숫자인 부동 소수점(floating-point) 숫자 기본 타입이 두 가지 있습니다.

러스트의 부동 소수점 타입은 f32와 f64로, 각각 32bit와 64bit의 크기를 갖습니다.

추론에서 다른 제약이 없을 때 기본 타입은 f64이며, f32보다 더 넓은 정밀도를 제공합니다. 실제 처리 속도와 메모리 사용량은 하드웨어·연산·데이터 규모에 따라 달라지므로 같은 속도라고 가정하지 않습니다. 처음에는 필요한 정밀도를 기준으로 고르고, 성능이 문제라면 같은 작업을 측정합니다.

모든 부동 소수점 타입은 부호가 있습니다.

다음은 부동 소수점 숫자의 용례입니다.

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

    let y: f32 = 3.0; // f32
}

부동 소수점 숫자는 IEEE-754 표준을 따릅니다.

f32 타입은 1배 정밀도(single-precision)의 부동 소수점 타입이고, f64는 2배 정밀도(double-precision)의 부동 소수점 타입입니다.

수치 연산

러스트는 모든 숫자 타입에 대해서 여러분이 예상할 수 있는 기본 수학 연산 기능을 제공합니다.

더하기, 빼기, 곱하기, 나누기 등등을 말이죠.

정수 나눗셈은 0 방향으로 소수 부분을 버립니다.

아래 코드는 let 구문 내에서 각 연산을 어떻게 사용하는지를 보여줍니다.

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

위의 구문에서 각 표현식은 수학 연산자를 사용하여 값을 평가한 뒤, 그 값을 변수에 묶어 넣습니다.

부록 B에 러스트가 제공하는 모든 연산자 목록이 있습니다.

부울린 타입

대부분의 다른 언어들처럼, 러스트에서의 부울린(boolean) 타입도 true와 false 두 값을 가질 수 있습니다.

부울린 값은 1바이트 크기입니다.

러스트에서 부울린 타입은 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바이트 크기이며 유니코드 스칼라 값을 표현하며, 이는 ASCII보다 훨씬 더 많은 값을 표현할 수 있다는 의미입니다.

억양 표시가 있는 문자, 한국어/중국어/일본어 문자, 이모지, 넓이가 0인 공백문자 모두가 러스트에서는 유효한 char 값입니다.

유니코드 스칼라 값의 범위는 U+0000에서 U+D7FF, 그리고 U+E000에서 U+10FFFF입니다.

하지만 ‘문자’는 유니코드를 위한 개념이 아니기 때문에, ‘문자’에 대한 여러분의 직관은 char와 들어맞지 않을지도 모릅니다.

7장의 ‘문자열에 UTF-8 텍스트 저장하기’에서 이 주제에 대해 자세히 다루겠습니다.

예를 들어 '가'라는 char 값의 크기는 4바이트이고, 문자열 "가"의 UTF-8 데이터 길이인 "가".len()은 3바이트입니다. 같은 글자를 다루어도 타입과 인코딩이 다르기 때문입니다. 사람이 한 글자로 보는 모든 표시가 char 하나라는 뜻도 아닙니다. 결합 문자나 여러 스칼라로 이루어진 이모지는 이 구분이 더 중요합니다. char의 표현 설명에서 두 표현을 비교할 수 있습니다.


복합 타입

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

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 여부를 따른다. 배열을 특정 저장 영역에 항상 둔다고 일반화하지 않습니다.

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

복합 타입(compound type)은 여러 값을 하나의 타입으로 묶을 수 있습니다.

러스트에는 튜플(tuple)과 배열(array), 두 가지 기본 복합 타입이 있습니다.

튜플 타입

튜플은 다양한 타입의 여러 값을 묶어 하나의 복합 타입으로 만드는 일반적인 방법입니다.

튜플은 고정된 길이를 갖습니다.

즉, 한번 선언되면 그 크기를 늘리거나 줄일 수 없습니다.

괄호 안에 쉼표로 구분하여 값들의 목록을 작성하면 튜플을 만들 수 있습니다.

튜플 내의 각 위치는 타입을 갖고, 이 튜플 내의 타입들은 서로 달라도 됩니다.

다음은 (안 써도 괜찮지만) 타입을 명시해 본 예제입니다.

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

이 프로그램은 먼저 튜플을 만든 후 이를 변수 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)이라는 특별한 이름을 갖습니다.

이 값과 타입은 모두 ()로 작성되고 빈 값이나 비어 있는 반환 타입을 나타냅니다.

표현식이 어떠한 값도 반환하지 않는다면 암묵적으로 유닛 값을 반환하게 됩니다.

배열 타입

여러 값의 집합체를 만드는 다른 방법으로는 배열이 있습니다.

튜플과는 달리 배열의 모든 요소는 모두 같은 타입이어야 합니다.

몇몇 다른 언어들과는 달리 러스트의 배열은 고정된 길이를 갖습니다.

대괄호 안에 쉼표로 구분한 값들을 나열해서 배열을 만들 수 있습니다.

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

배열은 길이가 타입에 포함되어 항상 고정된 개수의 요소를 다룰 때 유용합니다. 위처럼 로컬 값으로 둘 수도 있고, 다른 소유 타입 안에서 힙에 둘 수도 있으므로 배열 타입 자체가 저장 위치를 결정하는 것은 아닙니다. 힙과 스택은 3장에서 더 다루겠습니다.

하지만 배열은 벡터 타입처럼 유연하지는 않습니다.

벡터는 표준 라이브러리가 제공하는 배열과 유사한 컬렉션 타입인데 크기를 늘리거나 줄일 수 있습니다.

배열을 이용할지 혹은 벡터를 이용할지 잘 모르겠다면, 아마도 벡터를 사용해야 할 겁니다.

7장에서 벡터에 대해 더 자세히 다룰 예정입니다.

그러나 요소의 개수가 바뀔 필요가 없다는 것을 알고 있을 때라면 배열이 더 유용합니다.

한 가지 예로, 프로그램에서 달의 이름을 이용하려고 한다면, 이것이 언제나 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는 배열이 5개의 요소를 갖고 있음을 나타냅니다.

또한 다음과 같이 대괄호 안에 초깃값과 세미콜론을 쓴 다음 배열의 길이를 적는 방식을 사용하여 모든 요소가 동일한 값으로 채워진 배열을 초기화할 수도 있습니다.

let a = [3; 5];

a라는 이름의 배열은 모두 3으로 채워진 5개의 요소를 갖게 될 것입니다.

이는 let a = [3, 3, 3, 3, 3];이라고 쓴 것과 똑같지만 좀 더 편리한 방식입니다.

배열 요소에 접근하기

배열은 스택에 할당될 수 있는 계산 가능한 고정된 크기의 단일 메모리 뭉치입니다.

아래와 같이 인덱스를 통해 배열 요소에 접근할 수 있습니다.

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가 되겠죠.

유효하지 않은 배열 요소에 대한 접근

만약 배열의 끝을 넘어선 요소에 접근하려고 하면 어떤 일이 벌어지는지 알아봅시다.

사용자로부터 배열 인덱스를 입력받기 위해서 1장의 추리 게임과 유사한 아래 코드를 실행한다 칩시다.

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

프로그램은 인덱스 연산에서 잘못된 값을 사용한 시점에서 런타임 에러를 발생시켰습니다.

이 프로그램은 에러 메시지와 함께 종료되고 마지막 println! 구문을 실행하지 못했습니다.

인덱스를 이용하여 요소에 접근을 시도하는 경우, 러스트는 여러분이 명시한 인덱스가 배열 길이보다 작은지 검사할 것입니다.

인덱스가 배열 길이보다 크거나 같을 경우 러스트는 패닉(panic)을 일으킵니다.

특히 위의 경우 이러한 검사는 런타임에서 일어나야 하는데, 이는 사용자가 코드를 실행한 뒤에 어떤 값을 입력할지 컴파일러로서는 알 수 없기 때문입니다.

이 예제는 러스트의 안전성 원칙이 동작하는 하나의 예입니다.

많은 저수준 언어들에서는 이러한 검사가 이루어지지 않고, 여러분이 잘못된 인덱스를 제공하면 유효하지 않은 메모리에 접근이 가능합니다.

러스트는 이런 메모리 접근을 허용하고 계속 실행하는 대신 즉시 실행을 종료함으로써 이런 종류의 에러로부터 여러분을 보호합니다.

러스트의 에러 처리 및 패닉을 일으키지 않으면서 유효하지 않은 메모리 접근도 허용하지 않는 읽기 쉽고 안전한 코드를 작성하는 방법에 대해서는 8장에서 더 자세히 다루겠습니다.

마지막으로, 타입을 선택할 때 컴파일 시점과 실행 시점에 무엇을 확인하는지 점검해 보세요.

예시 원래 배열 입력 예제에서 한 줄 읽기가 성공했다고 가정하고, 4·5·foo를 각각 새 실행에 입력해 보세요. 배열을 읽은 뒤 출력까지 도달하는지, 도달하지 않는다면 변환과 인덱싱 중 어디에서 멈추는지 먼저 예상합니다.

입력 단계별 풀이

4는 usize 변환과 범위 검사를 통과하여 값 5를 출력합니다. 5는 숫자로 바뀌지만 길이 5인 배열의 유효 인덱스 0..4를 벗어나 인덱싱에서 패닉을 일으킵니다. foo는 usize 변환에 실패하여 parse 뒤의 expect에서 먼저 패닉을 일으킵니다. 두 실패 모두 마지막 값 출력은 없지만, 실패한 단계는 다릅니다. 컴파일 성공은 실행 중 입력의 유효성을 보증하지 않습니다.

오버플로우 정책을 직접 비교하기

범위 밖 리터럴을 그대로 쓴 let value: u8 = 256;은 컴파일 단계에서 거부되는 사례입니다. 실행 중 덧셈의 결과가 u8 범위를 넘는 상황과 구별하세요. 다음 별도 프로그램은 단순 +의 기본 프로필에 기대지 않고, 앞에서 소개한 네 메서드로 정책을 명시합니다. 튜플 절까지 읽은 뒤 실행하면 (값, 발생 여부)도 추적할 수 있습니다.

src/main.rs — 명시적인 u8 덧셈 정책
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 순서입니다. {:?}는 값을 디버깅 형태로 표시하며, checked_add의 None은 u8에 담을 덧셈 결과가 없다는 뜻입니다. wrapping_add는 260을 256으로 나눈 나머지 4를 만들고, overflowing_add는 그 4와 오버플로우 발생 여부를 함께 돌려줍니다. saturating_add는 최댓값 255에서 제한합니다. 각 API의 계약은 u8 표준 라이브러리 설명에 있습니다.

스스로 확인하기 value를 240_u8로만 바꾸면 네 출력은 어떻게 달라질까요? 두 입력 모두 범위를 넘는다는 가정을 먼저 하지 말고, 덧셈 결과와 최댓값 255를 비교하세요.

240에 10을 더한 풀이

250은 범위 안이므로 Some(250), 250, 250 false, 250입니다. 네 메서드 모두 같은 숫자를 만들지만 checked_add의 성공을 나타내는 Some과 overflowing_add의 발생 여부 false처럼 반환하는 정보는 다릅니다.