&str와 lifetime

&str는 string literal과 빌림 문자열 두 가지가 있습니다. string literal은 String이 아니고, 큰 따옴표안에 들어가 있는 문자열이고, 빌림 문자열은 문자열을 빌리는 것입니다.

string literal은 static 수명이 있고, 빌림 문자열은 필요한 경우에 static이 아닌 수명을 지정해줘야 합니다.

1. 예시

가. string literal 예시

fn main() {
    let my_str = "I am a string";    
    println!("{}", my_str);
}

my_str 다음의 숨겨진 타입을 보면 &’static str로 되어 있습니다.

my_str의 타입이 &'static str임

println!(“{}”, my_str);을 println!(“{}”, &my_str);로 my_str 앞에 &를 붙여도 동작합니다.

나. &str 예시

fn print_str(my_str: &str) {
    println!("{my_str}");
}

fn main() {
    let my_str = "I am a string".to_string();
    print_str(&my_str);
}

my_str은 .to_string() 함수를 적용해서 string literal을 String 타입으로 변환했고, 이것을 빌리기 위해 my_str앞에 &를 붙여서 print_str의 인수로 전달했습니다.

2. 문자열 리터럴의 lifetime

fn returns_str() -> &str {
    "I am a str"
}

fn main() {
    let my_str = returns_str();
    println!("{my_str}");
}

위 코드는 returns_str 함수를 이용해 문자열을 리턴 받은 후 그 문자열을 화면에 출력하려고 하는 것인데,

명명된 lifetime parameter가 필요함

리턴 타입에서 명명된 라이프타임 파라미터가 기대된다는 에러 메시지가 나오면서 중간에 녹색으로 & 다음에 ‘static을 붙이는 것을 고려하라고 합니다.

반환 값이 “I am a str”, 다시 말해 문자열 리터럴이므로 &’static을 붙여야 합니다.

수정된 코드는 아래와 같습니다.

fn returns_str() -> &'static str {
    "I am a str"
}

fn main() {
    let my_str = returns_str();
    println!("{my_str}");
}

코드를 수정하고 실행하니 ‘I am a str’가 잘 출력됩니다.

'static을 추가해서 화면 출력이 잘 됨

위에 &str을 String으로 바꿔보라고 하는 제안도 있었는데, 이것을 적용하면 아래와 같이 됩니다.

fn returns_str() -> String {
    "I am a str".to_string()
}

fn main() {
    let my_str = returns_str();
    println!("{my_str}");
}

String 타입으로 바꾸면 수명 문제도 발생하지 않고, 출력값도 같습니다.

3. &str의 lifetime 1

#[derive(Debug)]
struct City {
    name: &str,
    date_founded: u32,
}

fn main() {
    let city_names = vec![String::from("Hoccimin"), String::from("Busan")];
    let my_city = City {
        name: &city_names[0],
        date_founded: 1952,
    };

    println!("{my_city:?}");
}

위 코드는 City 구조체를 만든 후 my_city라는 인스턴스를 만드는데, name으로 city_names라는 Vector에서 첫번째 것을 빌림으로 가져와서 대입하고, date_founded는 직접 1952를 입력한 다음 화면에 출력하는 것입니다.

그런데, 실행하면

빌림 문자열인 경우의 lifetime parameter 문제

&str에 명명된 lifetime parameter가 기대된다고 하면서

struct City 다음에 <‘a>를 붙이고, name 필드의 & 다음에도 ‘a를 붙이라고 합니다. 이렇게 하면 “name이 City 만큼 오래 살아야” 하므로 문제가 해결됩니다.

여기서 a는 라이프 타임 파라미터를 지칭하는 것으로 다른 문자를 사용해도 됩니다.

아래와 같이 수정하고 실행하면

#[derive(Debug)]
struct City<'a> {
    name: &'a str,
    date_founded: u32,
}

fn main() {
    let city_names = vec![String::from("Hoccimin"), String::from("Busan")];
    let my_city = City {
        name: &city_names[0],
        date_founded: 1952,
    };

    println!("{my_city:?}");
}

잘 출력됩니다.

lifetime parameter인 'a를 추가해서 화면 출력 성공

4. &str의 lifetime 2

그러나, &city_names[0]이 문자열 리터럴이 아니고 빌림 문자열이기 때문에 City 필드를 name: &’static str로 수정하면

#[derive(Debug)]
struct City {
    name: &'static str,
    date_founded: u32,
}

fn main() {
    let city_names = vec![String::from("Hoccimin"), String::from("Busan")];
    let my_city = City {
        name: &city_names[0],
        date_founded: 1952,
    };

    println!("{my_city:?}");
}

아래와 같은 에러가 발생합니다.

'a가 아닌 'static을 추가해서 에러가 발생

“&city_names[0]이라는 빌린 값이 충분히 오래 살지 못한다”고 하고,
그 아래에서는 “이 사용법은 city_names가 ‘static을 위해 빌리는 것이 요구된다”고 합니다. 왜냐하면 City 구조체 정의 시 name 타입 지정시’static 을 사용했기 때문입니다.

따라서, 3번에서와 같이 ‘a를 사용해야 합니다.

그런데 City 오른쪽에 <‘a>를 추가하지 않고, name 오른쪽에 만 ‘a를 추가하면

struct City {
name: &'a str,
date_founded: u32,
}
lifetime을 선언하지 않고 사용해 에러 발생

선언되지 않은 라이프타임 에러가 발생합니다. 따라서, City 오른쪽에 <‘a>를 추가해서 lifetime을 선언해야 합니다.

스마트 포인터(Smart Pointer)

Rust는 메모리 안전성을 보장하기 위해 소유권 시스템을 사용하며, 여기에 스마트 포인터라이프타임이 핵심 역할을 합니다. 스마트 포인터는 데이터를 가리키는 기능이외에 소유권, 참조 카운팅 등 추가 기능을 포함하며, Box<T>, Rc<T>, RefCell<T>에 대해 설명하겠습니다.


1. 스마트 포인터(Smart Pointer)란?

  • 일반 포인터처럼 데이터를 가리키지만, 추가적인 기능(소유권, 참조 카운팅 등)을 포함합니다.
  • 표준 스마트 포인터의 종류에는 아래 세 가지가 있습니다.
    1. Box<T>: 일반 포인터는 스택에 저장되는데, Box는 힙에 데이터를 저장합니다.
    2. Rc<T>: 참조 카운팅(reference counting)을 통한 공유
    3. RefCell<T>: 런타임 시점 가변성 검사

2. 스마트 포인터의 종류

가. Box<T> – 힙에 저장

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}
  • let b = Box::new(5);
    5는 i32 타입이므로 스택에 저장되는 것인데,
    Box::new를 통해 힙에 저장하고, 변수 b에 할당하므로
    변수 b는 Box<i32> 타입이 되며, 힙에 있는 5를 가리킵니다.
  • println!(“b = {}”, b);
    Box는 Deref(역참조) trait을 구현하고 있어서,
    자동으로 Box 안의 값을 꺼내서 5를 출력해 줍니다.

변수 b는 Box 타입이 되며, 힙에 있는 5를 가리킵니다.

  • 박스는 여러분이 데이터를 스택이 아니라 힙에 저장할 수 있도록 해줍니다.
  • 구조체 간 재귀 타입 정의 시 유용합니다.
  • 아래와 같이 재귀 타입의 enum 정의시, 다시 말해 List가 List를 포함하고 잇으므로, 컴파일 타임에서는 List 타입의 크기를 알 수 없으므로 아래를 실행하면
enum List {
    Cons(i32, List),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

error[E0072]: recursive type ‘List’ has infinite size
(에러[E0072]: 재귀 타입 List가 무한한 크기를 가지고 있습니다)
란 에러 메시지가 나옵니다.

재귀 타입 열거형 라이프타임 오류

따라서, 아래와 같이 List를 Box로 감싸야 합니다.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

Box를 사용해서 완성한 코드는 아래와 같습니다.

#[derive(Debug)]
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1,
        Box::new(Cons(2,
            Box::new(Cons(3,
                Box::new(Nil))))));
    println!("list = {:?}", list);
}
  • List 열거형을 출력하기 위해서 #[derive(Debug)]를 추가해야 하며, println!에서는 {:?}로 출력 포맷을 지정해야 합니다.
  • Box::new로 List를 구현합니다.

나. Rc<T> – 여러 소유자 공유

어떤 값이 계속 사용되는지 아니면 그렇지 않은지를 알기 위해 해당 값에 대한 참조자의 갯수를 계속 추적하는 것입니다. 만일 값에 대한 참조자가 0개라면, 그 값은 어떠한 참조자도 무효화하지 않고 메모리에서 정리될 수 있습니다.

우리 프로그램의 여러 부분에서 읽을 데이터를 힙에 할당하고, 어떤 부분이 그 데이터를 마지막에 이용하게 될지 컴파일 타임에는 알 수 없는 경우 Rc 타입을 사용합니다. 만일 어떤 부분이 마지막으로 사용할지 컴파일 타임에 알 수 있다면 보통의 소유권 규칙이 적용됩니다.

use std::rc::Rc;

fn main() {
    let a = Rc::new(10);
    let b = Rc::clone(&a);
    println!("a = {}, b = {}", a, b);
}
  • use std::rc::Rc;
    Rc는 프렐루드(prelude, 프렐루드는 Rust가 모든 Rust 프로그램에 자동으로 가져오는 항목들의 목록입니다. )에 포함되어 있지 않으므로 use 구문을 추가했습니다. Box는 use를 사용하지 않는 것과 대비됩니다.
  • let a = Rc::new(10);
    10을 Rc에 저장한 후 a에 할당합니다.
    따라서, a는 Rc<i32> 타입이 되고, 다시 말하면 a는 10을 가리키는 참조 카운팅 스마트 포인터입니다.
    이 경우 참조 카운트는 1입니다.
  • let b = Rc::clone(&a);
    a.clone()을 호출할 수도 있지만, 러스트의 관례는 Rc::clone(&a)를 사용합니다. Rc::clone은 clone 이 하는 것처럼 깊은 복사 (deep copy) 를 만들지 않고, 오직 참조 카운트만 증가 시켜 큰 시간이 들지 않습니다.
    이제 참조 카운트는 2가 됩니다.
  • println!(“a = {}, b = {}”, a, b);
    Rc는 Deref(역참조) trait을 구현해서 자동으로 &T처럼 동작하므로 내부 값이 출력됩니다.
    출력 값은 a = 10, b = 10입니다.
스마트 포인터(Rc)
스마트 포인터(Rc)
  • 단일 스레드 환경에서만 사용가능합니다.
  • 참조수는 Rc::strong_count(&a)로 확인 가능합니다.

다. RefCell<T> – 런타임 내부 가변성

내부 가변성 (interior mutability) 은 어떤 데이터에 대한 불변 참조자가 있을 때라도 여러분이 데이터를 변형할 수 있게 해주는 러스트의 디자인 패턴입니다. 보통 이러한 동작은 빌림 규칙에 의해 허용되지 않습니다.

불변 및 가변 참조자를 만들때, 우리는 각각 & 및 &mut 문법을 사용합니다. RefCell을 이용할때는 borrow와 borrow_mut 메소드를 사용하는데, 이들은 RefCell이 소유한 안전한 API 중 일부입니다. borrow 메소드는 스마트 포인터 타입인 Ref<T>를 반환하고, borrow_mut는 스마트 포인터 타입 RefMut<T>를 반환합니다. 두 타입 모두 Deref를 구현하였으므로 우리는 이들을 보통의 참조자처럼 다룰 수 있습니다.

만일 런타임 시 빌림 규칙을 위반한다면, RefCell의 구현체는 panic!을 일으킵니다.

use std::cell::RefCell;

fn main() {
    let data = RefCell::new(1);
    *data.borrow_mut() += 1;
    println!("data = {:?}", data.borrow());
}
  • let data = RefCell::new(1);
    1이라는 값을 감싼 RefCell<i32>를 생성한 후 data에 할당하므로, data는 RefCell<i32> 타입이며, 내부에 1을 가지고 있습니다.
    data는 mut가 없으므로 불변 참조입니다.
  • *data.borrow_mut() += 1;
    .borrow_mut()은 RefCell<i32>인 data의 내부 값을 가변 참조(mutably borrow) 하겠다는 의미입니다.

    * 연산자는 실제 값을 꺼내는 역할을 합니다.
    따라서, 이 코드는 data의 내부값을 꺼내서 1을 더한다는 의미입니다.

    이 함수는 런타임에 현재 가변 참조가 가능한지 검사해서
    이미 다른 가변/불변 참조가 있으면 panic!이 발생합니다.
  • println!(“data = {:?}”, data.borrow());
    .borrow()는 내부 값을 불변 참조(immutably borrow)하겠다는 뜻입니다.
    {:?}는 Debug 포맷 출력입니다.
    이 시점에서 내부 값은 2입니다.
    따라서, 출력 결과는
    data = 2 입니다.

Box와 Rc는 컴파일 타임 불변성, RefCell은 런타임 가변성


3. Box<T>, Rc<T>, RefCell<T> 비교

  • Rc<T>는 동일한 데이터에 대해 복수개의 소유자를 가능하게 하지만, Box<T>와 RefCell<T>는 단일 소유자만 갖습니다.
  • Box<T>는 컴파일 타임에 검사된 불변 혹은 가변 빌림을 허용하고, Rc<T>는 오직 컴파일 타임에 검사된 불변 빌림만 허용하지만, RefCell<T>는 런타임에 검사된 불변 혹은 가변 빌림을 허용합니다.
  • RefCell<T>가 런타임에 검사된 가변 빌림을 허용하기 때문에, RefCell<T>가 불변일 때라도 RefCell<T> 내부의 값을 변경할 수 있습니다.

4. 요약 정리

항목설명
Box<T>힙에 값 저장, 단일 소유자
Rc<T>다중 소유자 참조 카운팅, 단일 스레드만 지원
RefCell<T>내부 가변성 제공, 런타임시 체크