Rust의 Slice와 컬렉션 참조

Rust의 slice(슬라이스)는 배열, 벡터, 문자열 등 메모리상에 연속적으로 저장된 데이터의 일부 구간을 참조하는 타입이고, 컬렉션 참조는 컬렉션(배열 포함) 전체에 대한 참조입니다. 그러나, 모두 소유권을 갖지 않고 데이터를 참조하는 것은 같지만, 그 목적과 내부 구조, 사용 방식에 중요한 차이가 있습니다.

1. 슬라이스(Slice)

가.기본 개념

  • 슬라이스는 배열, 벡터, 문자열 등 메모리상에 연속적으로 저장된 데이터의 일부 구간을 참조하는 타입으로, &[T] 또는 &str(문자열 슬라이스)와 같이 참조 형태로 표현된다.
  • 슬라이스는 원본 데이터의 소유권을 이동시키지 않으며, 원본 데이터가 유효할 때만 사용할 수 있다.
  • 슬라이스는 연속된 메모리 블록의 시작 주소와 길이 정보를 함께 저장하는 fat pointer(두 개의 값: 포인터 + 길이)이다.
  • 슬라이스는 항상 연속적인 데이터만 참조할 수 있습니다.

나.문법과 사용법

  • 슬라이스는 [start..end] 범위 문법을 사용합니다.
    • start는 포함, end는 포함하지 않습니다(즉, 반 열린 구간).
      let a = [1, 2, 3, 4, 5];
      let slice = &a[1..4]; // [2, 3, 4]
  • start나 end를 생략하면 처음이나 끝을 의미합니다.
    &a[..3] → 처음부터 3번째 전까지
    &a[2..] → 2번째부터 끝까지(끝 포함)
    &a[..] → 전체

다. 문자열 슬라이스

  • 문자열 슬라이스는 String이나 문자열 리터럴의 일부를 참조하며 타입은 &str 입니다.
  • 문자열 일부 참조
    let s = String::from(“hello world”);
    let hello = &s[0..5]; // “hello”
    let world = &s[6..11]; // “world”
  • 문자열 리터럴 
    let s = “hello, world”;     // &’static str
    let part: &str = &s[0..5]; // “hello” 부분만 참조
    “hello” 자체도 &str 타입의 슬라이스이므로, 문자열 리터럴의 일부도 참조할 수 있습니다.

라. 특징과 주의점

  • 슬라이스는 참조이므로, 원본 데이터가 유효해야 한다.
    원본 데이터가 변경(예: String의 clear 등)되면 기존 슬라이스는 무효화되어 컴파일 에러가 발생합다.
  • 슬라이스는 소유권과 라이프타임 개념을 이해하는 데 중요한 역할을 합니다. -> 아래 ‘3. 슬라이스와 라이프타임’에서 설명
  • 슬라이스는 함수의 파라미터로 자주 사용됩니다.
fn main() {
    let s = "hello, world"; 
    println!("First two elements: {:?}", first_two(s));
}

fn first_two(slice: &str) -> &str {
    if slice.len() < 2 {
        panic!("Slice must have at least two elements");
    }
    &slice[0..2]
}

위 코드에서 s는 문자열 리터럴인 문자열 슬라이스이므로 함수로 넘길 때는 &를 안붙여도 되지만(붙여도 됨), 함수에서 받을 때는 타입을 &str으로 지정하고, 반환도 &slice[0..2]이므로&str 타입으로 지정해야 합니다.

실행 결과는 First two elements: “he”입니다.

2. 컬렉션 참조

가. 개념

  • 컬렉션 참조는 컬렉션 전체에 대한 참조입니다. 예를 들어, &Vec, &String, &[T; N] 등입니다. 여기서 &[T; N]은 배열 전체에 대한 참조입니다. 엄격하게 말하면 배열은 컬렉션은 아니지만 “배열 전체를 참조한다”는 측면에서 컬렉션 참조에 포함해서 설명합니다.
  • 이 참조는 해당 컬렉션 타입의 모든 메서드와 속성을 사용할 수 있게 해줍니다.
  • 컬렉션 참조는 그 자체가 소유한 데이터 전체를 가리키며, 슬라이스처럼 부분만을 가리키지는 않습니다.
  • 예시:
    let v = vec![1, 2, 3];
    let r = &v; // Vec 전체에 대한 참조

벡터 컬렉션 참조의 간단한 예시는 다음과 같습니다.

fn main() {
let v = vec![100, 32, 57]; // Vec<i32> 생성
// 벡터에 대한 불변 참조를 사용하여 각 요소 출력
for i in &v {
println!("{}", i);
}
}
  • 여기서 &v는 Vec 타입의 벡터에 대한 불변 참조(&Vec)입니다.
  • for i in &v 구문은 벡터의 각 요소에 대한 참조(&i32)를 순회하며 안전하게 접근합니다.
  • 벡터 자체를 소유하지않고 참조만 하므로, 함수 인자나 반복문에서 자주 활용됩니다.

나. 예제

fn print_array(arr: &[i32; 3]) {
println!("{:?}", arr);
}

fn main() {
let data = [10, 20, 30];
print_array(&data); // &[i32; 3] 타입의 참조 전달
}
  • 위 예제에서 print_array 함수는 크기가 3인 i32 배열에 대한 참조(&[i32; 3])를 인자로 받습니다.
  • &data는 [i32; 3] 타입 배열 전체에 대한 참조입니다.

다. 슬라이스와 주요 차이점 비교

구분슬라이스
(&[T], &str)
컬렉션 참조 (&Vec<T>, &String)
대상컬렉션의 일부(연속 구간)컬렉션 전체
내부 구조포인터 + 길이 (fat pointer)포인터 (컬렉션 구조체 전체)
메서드 사용슬라이스 관련 메서드만 사용 가능컬렉션의 모든 메서드 사용 가능(벡터의 len(), push() 등
용도부분 참조, 함수 인자 등전체 참조, 컬렉션의 메서드 활용
동적 크기길이가 런타임에 결정됨컬렉션 타입에 따라 다름
예시&arr[1..3], &vec[..], &s[2..]&vec, &arr, &String

&[T; N]은 배열 전체를 참조하고, &[T]는 슬라이스로, 배열의 일부 구간(혹은 전체)을 참조할 수 있습니다.
예를 들어, &arr[1..3]은 &[T] 타입(슬라이스)이지만, &arr은 &[T; N] 타입(컬렉션 참조)입니다. 이처럼 &[T; N]은 정확히 N개 원소를 가진 배열 전체에 대한 참조입니다.

또한 &str과 &String은 문자열 슬라이스와 문자열 참조로 다르지만, &String이 &str으로 자동 변환이 가능하기 때문에 함수 인자로는 &str이 더 범용적이고 권장됩니다.

3. 슬라이스와 라이프타임

가. 개념

Rust에서 라이프타임(lifetime)은 참조가 유효한 기간을 명확하게 지정하는 개념으로, 참조의 유효성에 직접적인 영향을 미칩니다. 라이프타임이 중요한 이유와 그 영향은 다음과같습니다.

  • 댕글링 참조 방지
    라이프타임의 가장 큰 목적은 댕글링 참조(dangling reference)를 방지하는 것입니다. 댕글링 참조란, 이미 스코프를 벗어나 소멸된 데이터를 참조하는 상황을 의미합니다. Rust는 각 참조자의 라이프타임을 추적하여, 참조가 원본 데이터보다 오래 살아남을 수 없도록 컴파일 타임에 강제합니다.
  • 안전한 메모리 접근 보장
    라이프타임을 통해 참조자가 항상 유효한 메모리만 가리키도록 보장합니다. 예를 들어, 아래와 같은 코드는 컴파일되지 않습니다.

    let r; // r의 라이프타임 시작
    {
    let x = 5; // x의 라이프타임
    r = &x; // r이 x를 참조
    } // x의 라이프타임 종료, r은 더 이상 유효하지 않음
    println!(“{}”, r); // 컴파일 에러!
    Rust는 위 코드에서 r이 x보다 오래 살아남으려 하므로 컴파일 에러를 발생시킵니다.
  • 함수와 구조체에서의 명확한 라이프타임 관계
    여러 참조가 얽히는 함수나 구조체에서는, 각각의 참조가 얼마나 오래 유효해야 하는지 명확히 지정해야 합니다. 라이프타임 파라미터를 명시함으로써, 함수가 반환하는 참조가 입력 참조자 중 어느 것과 연관되어 있는지 컴파일러가 알 수 있습니다.
  • 암묵적 추론과 명시적 지정
    대부분의 경우 Rust는 라이프타임을 자동으로 추론하지만, 여러 참조가 얽히거나 복잡한 관계가 있을 때는 명시적으로 라이프타임을 지정해야 합니다. 이를 통해 참조 유효성에 대한 컴파일러의 보장이 더욱 강력해집니다.

나. 예제 1

Rust에서 슬라이스의 라이프타임이 중요한 이유를 보여주는 대표적인 예제는, 두 개의 문자열 슬라이스 중 더 긴 쪽을 반환하는 함수입니다. 이 예제를 통해 슬라이스의 라이프타임을 명확히 지정하지 않으면 컴파일 에러가 발생하고, 올바르게 지정하면 안전하게 참조를 반환할 수 있음을 알 수 있습니다.

fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {}", result);
}

// 라이프타임 명시가 반드시 필요!
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
  • 이 함수 시그니처에서 ‘a라는 라이프타임 파라미터를 명시했습니다.
  • 이 뜻은, x와 y 모두 최소한 ‘a만큼 살아 있어야 하며, 반환되는 슬라이스도 ‘a만큼 살아 있어야 한다는 의미입니다.
  • 실제로 함수가 호출될 때, Rust는 x와 y의 라이프타임 중 더 짧은 쪽에 맞춰 반환값의 라이프타임을 제한합니다. 따라서, ‘a와 ‘b를 사용해서
    fn longest<‘a, ‘b>(x: &’a str, y: &’b str) -> &’a str
    라고 하면 x가 반환될 수도 있고 y가 반환될 수도 있기 때문에 에러가 발생합니다.
  • 또한 라이프타임을 명시하지 않아도, Rust는 반환되는 참조가 입력 참조자 중 어느 쪽과 연관되어 있는지 알 수 없어 컴파일 오류가 발생합니다. 따라서, 라이프타임을 ‘a로 통일한 것입니다.

이처럼, 슬라이스의 라이프타임을 명확히 지정하지 않으면 댕글링 참조가 생길 수 있고, Rust는 이를 컴파일 타임에 방지합니다.
따라서 라이프타임 명시는 슬라이스가 안전하게 사용될 수 있는 핵심적인 장치입니다.

위 코드에서 as_str()은 String 타입을 &str 타입으로 변환하는 메소드입니다.

다. 예제2

fn main() {
let result;
{
let string1 = String::from("abc");
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
// string1, string2는 여기서 소멸
}
println!("The longest string is {}", result); // 컴파일 에러!
}
  • 위 코드는 result가 내부 스코프의 데이터인 string1 또는 string2의 슬라이스를 참조하게 되므로, 컴파일 에러를 발생시켜 댕글링 참조를 차단합니다.

literal과 variable, value, const, static

Rust에서 리터럴(literal)은 소스 코드에 직접 값을 표현한 것이며(예: 5, “hello”, true), 변수(variable)는 프로그램 실행 중에 값을 저장하고 변경할 수 있는 이름이 있는 저장 공간입니다(예: let x = 5;).

1. 리터럴과 변수

가. 리터럴(Literals)

프로그래밍에서 리터럴은 변수(variable)나 상수(const)와 같이 특정 데이터 유형의 값을 직접 나타내는 표현 방식입니다. 예를 들어, 숫자 10, 문자 ‘a’, 문자열 “hello” , true, false 등이 모두 리터럴입니다. 

  • 직접적인 값:리터럴은 프로그램 실행 중에 변경될 수 없는 고정된 값 자체를 의미합니다. 
  • 불변성: 리터럴은 정의된 이후에 그 값을 변경할 수 없습니다.
  • 표현식:때로는 코드에서 특정 값을 계산하는 표현식 자체가 리터럴로 사용되기도 합니다. 예를 들어, (2 + 3)과 같이 계산 결과가 고정된 값으로 나타나는 경우. 

나. 변수(Variables)

  • 이름이 있는 저장소: 변수는 메모리에 값을 저장하는 이름이 붙은 공간입니다.
  • 가변성 또는 불변성: Rust에서는 let x = 5;처럼 불변 변수도 만들 수 있고, let mut x = 5;처럼 가변 변수도 만들 수 있습니다. 가변 변수는 값을 변경할 수 있습니다.
  • 모든 타입 가능: 변수는 기본 타입은 물론, 구조체나 참조 등 다양한 데이터 타입의 값을 저장할 수 있습니다.
  • 데이터 저장과 조작: 변수는 프로그램에서 필요에 따라 데이터를 저장하거나 변경하는 데 사용됩니다.

다. 비교표

항목리터럴(Literal)변수(Variable)
표현 방식값을 직접 표현이름이 있는 저장 공간
변경 가능 여부변경 불가 (immutable)변경 가능 또는 불가능 (mutable / immutable)
※ Rust에서는 기본값이 immutable
저장 방식변수처럼 메모리에 저장되지 않음메모리에 저장됨
사용 목적상수값(변하지 않는 값) 표현데이터 저장 및 조작
예시5, “hello”, truelet x = 5;에서 x
let mut y = “hello”;에서 y

라. 예제 1

fn main() {
let x = 10; // 10은 정수 리터럴
let y = "hello"; // "hello"는 문자열 리터럴

println!("x = {}", x);
println!("y = {}", y);
}

여기서:

  • 10은 숫자 리터럴 (미리 정의된 값)
  • “hello”는 문자열 리터럴 (미리 코드에 써 넣은 값)

즉, 이런 리터럴 값은 코드에서 바로 보이고 바뀌지 않는 상수 같은 값입니다.


마. 예제 2

fn main() {
let a = 5; // 5는 리터럴
let b = a + 3; // 3도 리터럴, b는 변수

println!("b = {}", b); // 출력: b = 8
}
  • 여기서 5와 3은 literal 또는 literal value (미리 정해진 값)로 바뀔 수 없는데,
  • a와 b는 변수로 나중에 바뀔 수 있습니다. 그러나, Rust에서는 mut가 없으면 불변이 기본임

바. 요약

Rust에서 10, “hello”, true 같은 값들은 literal로서
코드에 직접 써 넣은, 미리 정의된 값들입니다.

2. 리터럴과 값(value)

가. Literal (리터럴)

“소스 코드에 직접 쓰인 값”
즉, 고정된 형태의 값을 코드에 명시한 것입니다.

42       // 정수 리터럴
3.14 // 부동소수점 리터럴
"hello" // 문자열 리터럴
true // 불리언 리터럴
'b' // 문자 리터럴

리터럴은 컴파일 타임에 결정되며 변하지 않습니다.


나. Value (값)

프로그램이 실행될 때 메모리에 존재하는 데이터입니다.
리터럴, 변수, 연산 결과 등 다양한 방식으로 생성될 수 있습니다.

let x = 5;            // 리터럴 5를 변수 x에 저장 → value는 5
let y = x + 2; // 연산 결과 7도 value
let s = String::from("hi"); // value는 "hi"라는 힙에 저장된 문자열

value는 runtime의 개념이며, 리터럴은 value를 만들기 위한 한 방법입니다.


다. 비교표

항목LiteralValue
정의코드에 직접 적힌 고정 값실행 중 사용되는 데이터
예시“hello”, 3.14, true변수에 저장된 값, 함수의 반환값 등
생성 시점컴파일 타임런타임
변경 가능성불변변경 가능 (가변 변수에 저장된 경우)
위치코드에 직접 작성됨메모리에 존재

라. 관계 요약

  • 리터럴은 value를 만드는 수단 중 하나입니다.
  • 모든 리터럴은 value지만, 모든 value가 리터럴은 아닙니다.

예:

let a = 1 + 2;  
// 1과 2는 리터럴 → 3은 value (리터럴이지만 연산 결과이기도 함)

let b = a * 2;
// b의 값 6은 value지만 literal은 아님 (런타임 연산 결과)

3. literal과 const, static

구분의미특징
literal코드에 직접 써 넣은 값미리 정의된 값. 변수나 상수에 저장 가능
const컴파일 시점 상수항상 값이 고정, 함수 밖/안 모두 사용 가능, 메모리 없음
static정적 변수 (전역 상수)프로그램 전체에서 공유, 고정 메모리 공간 사용

가. 예제

const CONST_VALUE: i32 = 10;
static STATIC_VALUE: i32 = 20;

fn main() {
let literal = 5; // 5는 literal

println!("literal: {}", literal);
println!("const: {}", CONST_VALUE);
println!("static: {}", STATIC_VALUE);
}

나. 설명

  1. 5는리터럴(literal)
    → 그냥 코드에 직접 쓴 고정된 값
  2. CONST_VALUE는 const
    → 컴파일 시간에 값이 확정됨. 메모리에 저장되지 않음.
    → const는 let처럼 지역적으로도 가능하지만 항상 값이 바뀌지 않음
  3. STATIC_VALUE는 static
    → 프로그램이 끝날 때까지 살아있는 고정 메모리 주소에 저장됨
    → 전역 변수처럼 사용 가능
    → 다만static mut은 unsafe하게 써야 함

다. 비교표

항목값이 고정됨변경 가능성메모리 위치사용 시기
literal스택 또는 인라인코드 내 직접
const없음 (값 인라인)컴파일 시
static❌ (기본)전역 메모리런타임 전체 기간

라. static mut의 대체 수단

static mut COUNTER: i32 = 0;

fn main() {
unsafe {
COUNTER += 1;
println!("counter: {}", COUNTER);
}
}
  • static mut은 동시성 문제 때문에 unsafe로 접근해야 함

Rust의const와 static은 값이 컴파일 타임에 반드시 결정되어야 하는데,

다음처럼 런타임 정보나 복잡한 로직으로 초기화해야 하는 경우에는 문제가 생깁니다:

static MY_STRING: String = String::from("hello"); // ❌ 에러!

🚫String은 컴파일 타임에 생성할 수 없기 때문에 static으로 직접 초기화 불가능

이럴 때 쓰는 게 바로 아래 세 가지입니다:

(1) const fn

  • const fn은 컴파일 타임에 실행 가능한 함수를 정의합니다.
  • const 상수나 const 표현식에서 사용할 수 있음
const fn square(x: i32) -> i32 {
x * x
}

const RESULT: i32 = square(4); // ✅ OK!

📌 단점: const fn은 제한이 많아서, String, Vec, 파일읽기 등은 못 씀


(2) lazy_static (deprecated 경향 있음)

  • 복잡한 값도 런타임 최초 1회 초기화 후 전역처럼 사용 가능
  • macro_use가 필요하고, 내부적으로 unsafe와 Mutex를 씀
#[macro_use]
extern crate lazy_static;

use std::collections::HashMap;

lazy_static! {
static ref CONFIG: HashMap<&'static str, i32> = {
let mut m = HashMap::new();
m.insert("threshold", 10);
m
};
}

fn main() {
println!("threshold = {}", CONFIG["threshold"]);
}

✅ 장점: 복잡한 초기화 가능
⚠️ 단점: 오래된 방식이고 macro 기반, 무거움


(3) once_cell (추천)

  • 최신 Rust 커뮤니티에서는 lazy_static 대신 once_cell을 많이 씁니다.
  • 런타임 최초 1회 초기화, macro 없이, 더 가볍고 안전
use once_cell::sync::Lazy;
use std::collections::HashMap;

static CONFIG: Lazy<HashMap<&'static str, i32>> = Lazy::new(|| {
let mut m = HashMap::new();
m.insert("threshold", 10);
m
});

fn main() {
println!("threshold = {}", CONFIG["threshold"]);
}

✅ 장점: lazy_static보다 깔끔하고 안전
🔒 sync::Lazy: 여러 스레드에서 안전하게 사용 가능
🧵 unsync::Lazy: 단일 스레드용으로 더 빠름


(4) const, static 등 비교표

방식초기화 시점복잡한 타입 가능스레드 안전성비고
const컴파일 타임❌ (기본 타입만)N/A빠르고 간단
static컴파일 타임❌ (기본 타입만)가능전역 메모리 사용
const fn컴파일 타임제한적가능복잡한 초기화 불가능
lazy_static런타임 (1회만)무겁고 오래된 방식
once_cell런타임 (1회만)✅ or ❌최신 방식, 매크로 없이 가능

(5) 요약

  • 간단한 상수는 const
  • 전역 정적 값은 static
  • 복잡한 초기화가 필요한 전역 값은 once_cell::Lazy 추천