Devin.KR

수명 심화 - 구조체 속 참조와 'static 오해

개발자KR 조회 0

이 장에서 배우는 것

기본서에서 수명(lifetime)은 함수 시그니처에 붙는 표기로 처음 만났다. 이 장은 한 걸음 더 나아가 참조를 구조체 필드에 담을 때 수명이 어떻게 달라지는지, 컴파일러가 수명을 대신 채워 주는 생략 규칙이 어디까지 통하는지, 그리고 'static 이라는 표기가 왜 자주 오해되는지를 다룬다. 마지막에는 수명 표기를 늘리는 대신 소유 타입으로 바꾸는 편이 나은 경우를 가려 본다. 예제는 창고의 주문 텍스트와 품목 카탈로그를 읽어 들이는 코드다.

  • 참조를 담는 구조체에 수명 매개변수를 선언하고, 그 의미를 "원본이 살아 있어야 하는 구간"으로 설명할 수 있다.
  • 수명 생략 규칙 세 가지를 시그니처에 직접 적용해 보고, 생략이 실패하는 경우를 구분한다.
  • 수명 매개변수를 둘 이상 두어 서로 다른 원본에서 온 참조를 따로 추적한다.
  • T: 'static 경계가 "값이 프로그램 끝까지 산다"가 아니라 "값이 빌린 것을 갖고 있지 않다"는 뜻임을 설명한다.
  • 참조 구조체를 소유 구조체로 바꿔야 하는 시점을 판단한다.

문제 상황

창고 프로그램은 두 가지 텍스트를 읽는다. 하나는 품목 카탈로그(A100 = 볼트 같은 줄), 다른 하나는 주문 한 건(A100:3 같은 줄)이다. 줄마다 String 을 새로 만들어 담으면 코드는 쉽지만, 주문이 수천 줄이면 같은 글자를 계속 복사한다. 그래서 원본 텍스트 안의 조각을 가리키는 &str 을 구조체에 그대로 담고 싶어진다.

이때 곧바로 세 가지 질문이 생긴다. 구조체 정의에 수명을 어떻게 적는가, 메서드가 돌려주는 참조는 누구에게 묶이는가, 주문 텍스트가 사라진 뒤에도 카탈로그에서 꺼낸 품명을 쓸 수 있는가. 컴파일러가 거부하는 이유를 읽지 못하면 'static 을 붙이거나 clone 을 남발하는 쪽으로 흐르기 쉽다. 이 장은 그 판단 기준을 정리한다.

참조를 담는 구조체

수명 매개변수를 선언한다

구조체 필드가 참조이면 그 참조가 얼마나 오래 유효한지 타입에 드러나야 한다. 수명 매개변수는 제네릭 타입 매개변수와 같은 자리에 선언한다.

#[derive(Debug, Clone, Copy)]
struct Line<'a> {
    sku: &'a str,
    qty: u32,
}

Line<'a> 는 "어떤 원본 위에 놓인 조각 하나"라는 뜻이다. 'a 는 그 원본이 살아 있는 구간 이내를 가리키며, Line 값은 이 구간 밖으로 나갈 수 없다. 필드를 &str 로만 적고 수명을 생략하면 컴파일러는 error[E0106]: missing lifetime specifier 로 이름 있는 수명이 필요하다고 알려 준다. 구조체 정의에서는 생략 규칙이 적용되지 않기 때문이다.

아래 그림은 이 관계를 메모리 관점에서 본 것이다. Line 은 글자를 복사하지 않고 원본 버퍼 안의 위치와 길이만 가진다.

Line 의 sku 필드는 order_text 버퍼 안의 일부를 가리키므로 버퍼가 살아 있는 동안만 쓸 수 있다.

구현 블록의 수명

수명 매개변수가 있는 구조체에 메서드를 붙일 때는 impl 뒤에도 같은 수명을 선언한다. 카탈로그 구조체로 보자.

struct Catalog<'a> {
    entries: Vec<(&'a str, &'a str)>,
}

impl<'a> Catalog<'a> {
    fn name_of(&self, sku: &str) -> Option<&'a str> {
        // ...
    }
}

impl<'a> 는 "임의의 'a 에 대해 구현한다"는 선언이고, Catalog<'a> 는 그 'a 를 사용한다. 반환 타입의 'a 는 카탈로그가 빌린 원본 텍스트에 묶인다는 뜻이다. 아래에서 이것을 생략한 경우와 비교한다.

생략 규칙 세 가지

함수 시그니처에서 수명을 적지 않아도 되는 이유는 컴파일러가 다음 세 규칙을 순서대로 적용하기 때문이다. 규칙을 적용하고도 반환 타입의 수명이 정해지지 않으면 오류가 난다.

생략 규칙 세 가지와 적용 결과
규칙대상동작예
1입력 위치의 참조참조마다 서로 다른 수명을 부여한다fn f(a: &str, b: &str) 는 '1, '2
2입력 수명이 하나뿐일 때그 수명을 모든 출력 참조에 부여한다fn parse_line(text: &str) -> Option<Line<'_>>
3&self 나 &mut self 가 있을 때self 의 수명을 모든 출력 참조에 부여한다fn longest_name(&self) -> &str

규칙 2를 쓰는 함수가 이 장의 주문 해석 함수다. 입력 참조가 하나이므로 반환하는 Line 은 그 입력에 묶인다.

fn parse_line(text: &str) -> Option<Line<'_>> {
    let (sku, qty) = text.split_once(':')?;
    let qty = qty.trim().parse::<u32>().ok()?;
    Some(Line { sku: sku.trim(), qty })
}

명시적으로 쓰면 fn parse_line<'t>(text: &'t str) -> Option<Line<'t>> 와 같다. Line<'_> 의 '_ 는 "이름은 붙이지 않지만 여기에 수명이 있다"는 표시로, 구조체가 수명 매개변수를 가진다는 사실을 시그니처에서 숨기지 않게 해 준다.

반대로 입력 참조가 둘이고 self 도 없으면 규칙이 모자란다. fn pick(a: &str, b: &str) -> &str 는 반환값이 a 와 b 중 어느 쪽에 묶이는지 컴파일러가 알 수 없어 E0106 으로 거부된다. 이 경우에는 직접 적는다.

규칙 3의 함정

규칙 3은 편하지만 때로 너무 좁은 약속을 만든다. 메서드가 돌려주는 참조가 실제로는 self 가 아니라 구조체가 빌린 원본에서 왔는데도, 생략하면 &self 의 수명으로 묶여 버린다. 뒤의 "자주 틀리는 것"에서 이 차이가 오류로 나타나는 모습을 본다. 카탈로그의 name_of 가 Option<&'a str> 로 적힌 것도 그 때문이다.

여러 수명 매개변수

서로 다른 원본에서 온 참조를 한 구조체에 담으면 수명 하나로 묶는 것이 부정확해진다. 품명은 카탈로그 텍스트에서, SKU 는 주문 텍스트에서 오므로 둘을 따로 표기한다.

struct Resolved<'c, 'o> {
    name: &'c str,
    sku: &'o str,
    qty: u32,
}

fn resolve<'c, 'o>(catalog: &Catalog<'c>, line: &Line<'o>) -> Option<Resolved<'c, 'o>> {
    let name = catalog.name_of(line.sku)?;
    Some(Resolved { name, sku: line.sku, qty: line.qty })
}

여기서 catalog 매개변수 바깥쪽 참조에는 이름을 붙이지 않았다. 결과가 catalog 변수를 빌리는 기간과는 무관하고, 오직 카탈로그 텍스트('c)와 주문 텍스트('o)에만 묶이기 때문이다. 수명을 둘로 나누면 주문 텍스트가 먼저 해제되어도 카탈로그에서 꺼낸 품명은 계속 쓸 수 있다. 다음 그림이 그 구간 관계다.

주문 텍스트가 해제된 뒤에도 카탈로그 텍스트가 살아 있는 동안 품명 목록은 계속 쓸 수 있다.

같은 원리로 known_names 는 주문 줄 목록을 받아 품명만 모아 돌려준다. 반환 타입이 Vec<&'c str> 이고 입력 줄의 수명은 '_ 로 남겨 두었으므로, 결과는 주문 텍스트와 무관해진다. 수명을 하나로 합쳐 'a 로 적어도 컴파일되는 경우가 많지만(공변성 덕분에 더 짧은 쪽으로 줄여 맞춘다), 그러면 시그니처가 "결과는 두 원본 중 짧은 쪽에 묶인다"고 약속하게 되어 호출하는 쪽의 자유가 줄어든다.

'static 경계의 진짜 뜻

'static 은 두 가지 자리에 나타나며 의미가 다르다.

'static 이 쓰이는 두 자리
자리읽는 법예만족하는 값
참조의 수명프로그램 전체 동안 유효한 데이터를 가리킨다&'static str문자열 리터럴, 누출시킨 데이터
타입 경계값이 짧은 수명의 참조를 포함하지 않는다T: 'staticString, i32, Vec<OwnedLine>, 리터럴 참조

흔한 오해는 T: 'static 을 "T 값이 프로그램이 끝날 때까지 산다"로 읽는 것이다. 실제 뜻은 "T 안에 'static 보다 짧은 참조가 들어 있지 않다"이다. 그래서 String::from("A100") 은 만들자마자 버려져도 'static 경계를 만족한다. 빌린 것이 없기 때문이다. 반면 지역 변수를 가리키는 &String 은 'static 이 아니다.

이 경계는 값이 호출한 쪽의 스코프를 벗어나 움직여야 할 때 요구된다. 다음 장에서 다루는 스레드 생성 함수가 대표적이다. 새 스레드가 언제까지 돌지 알 수 없으므로 넘기는 값은 빌린 것이 없어야 한다. 그 때문에 이 장에서 'static 의 뜻을 미리 바로잡아 두는 것이 중요하다. Line<'a> 는 원본에 묶여 있으니 경계를 만족하지 못하고, OwnedLine 은 만족한다.

&'static 참조를 억지로 만들려고 Box::leak 을 쓰는 것은 대개 해법이 아니다. 데이터를 해제하지 않고 남겨 두어 메모리를 계속 쓰게 되며, 구조를 바로잡는 문제를 가리기만 한다.

소유 타입으로 바꿔 수명 문제 피하기

참조 구조체는 복사를 줄여 주지만 원본이 사라지면 쓸 수 없다. 주문을 읽은 뒤 큐에 쌓아 두었다가 나중에 처리하는 것처럼 값이 원본보다 오래 살아야 하면 소유 타입으로 바꾼다.

#[derive(Debug)]
struct OwnedLine {
    sku: String,
    qty: u32,
}

impl From<Line<'_>> for OwnedLine {
    fn from(line: Line<'_>) -> Self {
        OwnedLine { sku: line.sku.to_string(), qty: line.qty }
    }
}

선택 기준은 단순하다. 원본 텍스트를 읽고 곧바로 처리가 끝나는 구간(해석, 검증, 집계)에서는 참조 구조체를 쓰고, 값이 함수 경계나 스레드, 저장소를 넘어 이동하는 지점에서 한 번 변환한다. 변환 비용은 그 한 번뿐이다. 수명 매개변수가 두세 개를 넘어 시그니처가 읽기 어려워진다면 구조가 소유 쪽으로 기울었다는 신호로 볼 수 있다.

완성 코드

아래 프로그램은 위의 조각을 모두 합친다. 카탈로그와 주문 텍스트를 읽어 해석하고, 주문 텍스트가 사라진 뒤에도 품명 목록을 쓰며, 마지막에 소유 타입으로 바꾼 주문을 'static 경계 함수에 넘긴다. src/main.rs 한 파일이다.

use std::fmt::Debug;

#[derive(Debug, Clone, Copy)]
struct Line<'a> {
    sku: &'a str,
    qty: u32,
}

fn parse_line(text: &str) -> Option<Line<'_>> {
    let (sku, qty) = text.split_once(':')?;
    let qty = qty.trim().parse::<u32>().ok()?;
    Some(Line { sku: sku.trim(), qty })
}

fn parse_order(text: &str) -> Vec<Line<'_>> {
    text.lines().filter_map(parse_line).collect()
}

struct Catalog<'a> {
    entries: Vec<(&'a str, &'a str)>,
}

impl<'a> Catalog<'a> {
    fn parse(text: &'a str) -> Catalog<'a> {
        let entries = text
            .lines()
            .filter_map(|l| l.split_once('='))
            .map(|(sku, name)| (sku.trim(), name.trim()))
            .collect();
        Catalog { entries }
    }

    fn name_of(&self, sku: &str) -> Option<&'a str> {
        self.entries.iter().find(|(s, _)| *s == sku).map(|(_, n)| *n)
    }

    fn longest_name(&self) -> &str {
        self.entries
            .iter()
            .map(|(_, n)| *n)
            .max_by_key(|n| n.chars().count())
            .unwrap_or("")
    }
}

struct Resolved<'c, 'o> {
    name: &'c str,
    sku: &'o str,
    qty: u32,
}

fn resolve<'c, 'o>(catalog: &Catalog<'c>, line: &Line<'o>) -> Option<Resolved<'c, 'o>> {
    let name = catalog.name_of(line.sku)?;
    Some(Resolved { name, sku: line.sku, qty: line.qty })
}

fn known_names<'c>(catalog: &Catalog<'c>, lines: &[Line<'_>]) -> Vec<&'c str> {
    lines.iter().filter_map(|l| catalog.name_of(l.sku)).collect()
}

#[derive(Debug)]
struct OwnedLine {
    sku: String,
    qty: u32,
}

impl From<Line<'_>> for OwnedLine {
    fn from(line: Line<'_>) -> Self {
        OwnedLine { sku: line.sku.to_string(), qty: line.qty }
    }
}

fn load_pending() -> Vec<OwnedLine> {
    let text = String::from("A100:2\nB200:4");
    parse_order(&text).into_iter().map(OwnedLine::from).collect()
}

fn describe<T: Debug + 'static>(value: T) -> String {
    format!("{value:?}")
}

fn main() {
    let catalog_text = String::from("A100 = 볼트\nB200 = 와셔 세트\nC300 = 육각 너트 M8");
    let catalog = Catalog::parse(&catalog_text);
    println!("가장 긴 품명: {}", catalog.longest_name());

    let names;
    {
        let order_text = String::from("A100:3\nC300: 5\nZ999:1\nbad line\nB200:x");
        let lines = parse_order(&order_text);
        println!("해석한 줄 수: {}", lines.len());
        for line in &lines {
            match resolve(&catalog, line) {
                Some(r) => println!("{} ({}) x {}", r.sku, r.name, r.qty),
                None => println!("{}: 카탈로그에 없음", line.sku),
            }
        }
        names = known_names(&catalog, &lines);
    }
    println!("주문 텍스트가 사라진 뒤: {:?}", names);

    println!("{}", describe(String::from("A100")));
    println!("{}", describe("literal"));
    let pending = load_pending();
    for item in &pending {
        println!("{} -> {}", item.sku, item.qty);
    }
    println!("{}", describe(pending));
}

줄별 해설

Line 과 parse_line. Line<'a> 는 Copy 이므로 값으로 넘겨도 원본 소유권과 무관하다. parse_line 은 split_once 로 콜론 앞뒤를 나누고, ? 로 콜론이 없는 줄(bad line)을 None 으로 돌려보낸다. 숫자 해석이 실패하는 B200:x 도 .ok()? 에서 None 이 된다. trim 은 같은 원본의 더 작은 조각을 돌려주므로 수명이 그대로 유지된다.

parse_order. lines() 가 만드는 조각들은 모두 입력 텍스트의 일부이고, filter_map(parse_line) 에서 해석에 실패한 줄이 걸러진다. 반환 타입 Vec<Line<'_>> 는 규칙 2로 입력 text 에 묶인다.

Catalog. parse 는 연관 함수이므로 self 가 없다. 인자에 'a 를 직접 적어 Catalog<'a> 가 그 텍스트를 빌린다고 알린다. name_of 는 &self 와 sku 두 참조를 받지만 반환은 'a 로 명시했다. 생략하면 규칙 3으로 &self 에 묶인다. longest_name 은 반대로 규칙 3에 맡겼다. 결과를 잠깐 출력하는 용도라 충분하기 때문이다.

resolve 와 known_names. resolve 는 수명 두 개로 품명과 SKU 의 출처를 구분한다. known_names 는 반환을 Vec<&'c str> 로 한정하여 결과가 줄 목록의 수명과 무관함을 시그니처에 못박는다.

OwnedLine 과 load_pending. From<Line<'_>> 구현에서 to_string 으로 글자를 복사한다. load_pending 은 지역 String 을 해석한 뒤 소유 값으로 바꾸어 돌려주므로, 지역 변수가 사라져도 문제가 없다. Line 을 그대로 돌려주려 했다면 컴파일되지 않는다.

describe. T: Debug + 'static 경계 함수다. String, 문자열 리터럴, Vec<OwnedLine> 은 모두 통과한다.

main. let names; 로 선언만 해 두고 안쪽 블록에서 값을 넣는다. 블록이 끝나면 order_text 와 lines 가 해제되지만, names 는 catalog_text 에만 묶여 있으므로 이후 출력이 유효하다. 카탈로그 텍스트와 catalog 변수는 main 끝까지 살아 있다.

실행 결과

$ cargo run
가장 긴 품명: 육각 너트 M8
해석한 줄 수: 3
A100 (볼트) x 3
C300 (육각 너트 M8) x 5
Z999: 카탈로그에 없음
주문 텍스트가 사라진 뒤: ["볼트", "육각 너트 M8"]
"A100"
"literal"
A100 -> 2
B200 -> 4
[OwnedLine { sku: "A100", qty: 2 }, OwnedLine { sku: "B200", qty: 4 }]

실무에서 자주 틀리는 것

구조체 필드의 수명을 빼먹는다

struct Line {
    sku: &str,
    qty: u32,
}

컴파일러는 error[E0106]: missing lifetime specifier 로 sku 필드에 이름 있는 수명이 필요하다고 알려 준다. 구조체 정의에는 생략 규칙이 없다. 수명 매개변수를 선언하거나 필드를 String 으로 바꾼다.

struct Line<'a> {
    sku: &'a str,
    qty: u32,
}

지역 값을 가리키는 참조 구조체를 돌려준다

fn load_bad() -> Vec<Line<'static>> {
    let text = String::from("A100:2");
    parse_order(&text)
}

error[E0515]: cannot return value referencing local variable `text` 가 나온다. 반환값이 함수가 끝나면 사라질 text 를 빌리고 있다는 뜻이다. 수명 표기를 바꿔서는 풀리지 않으며, 소유 타입으로 변환해 돌려준다.

fn load_ok() -> Vec<OwnedLine> {
    let text = String::from("A100:2");
    parse_order(&text).into_iter().map(OwnedLine::from).collect()
}

T: 'static 을 오래 사는 값으로 읽는다

let local = String::from("A100");
println!("{}", describe(&local));

error[E0597]: `local` does not live long enough 이고, 요지는 인자가 'static 으로 빌려져야 한다는 것이다. &String 은 지역 변수를 빌린 참조이므로 경계를 만족하지 못한다. 이때 Box::leak 으로 억지로 맞추기보다 소유 값을 넘긴다.

let local = String::from("A100");
println!("{}", describe(local.clone()));

규칙 3 때문에 반환 참조가 self 에 묶인다

impl<'a> Catalog<'a> {
    fn name_of(&self, sku: &str) -> Option<&str> {
        self.entries.iter().find(|(s, _)| *s == sku).map(|(_, n)| *n)
    }
}

let name = catalog.name_of("A100");
drop(catalog);
println!("{:?}", name);

반환 타입이 규칙 3으로 &self 에 묶이므로 error[E0505]: cannot move out of `catalog` because it is borrowed 가 난다. 데이터는 catalog_text 에 있는데도 시그니처가 그 사실을 숨긴 것이다. 반환 타입을 Option<&'a str> 로 고치면 catalog 변수를 먼저 정리해도 품명을 쓸 수 있다.

한눈에 보기

수명 관련 상황별 선택 기준
상황선택이유
원본을 읽는 짧은 구간(해석, 집계)Line<'a> 같은 참조 구조체복사 없이 원본 조각을 그대로 쓴다
값이 원본보다 오래 살거나 함수 밖으로 이동OwnedLine 같은 소유 구조체수명 표기가 필요 없고 'static 경계도 만족한다
출처가 다른 참조를 한 구조체에 담음수명 매개변수 여러 개각 원본의 해제 시점을 따로 추적한다
메서드 결과가 self 가 아닌 원본에서 옴반환 타입에 'a 명시규칙 3의 과도한 제약을 피한다
생략 규칙이 통하는 시그니처와 통하지 않는 시그니처
시그니처적용 규칙결과
fn f(s: &str) -> &str2컴파일된다
fn f(&self, s: &str) -> &str3출력이 self 에 묶인다
fn f(a: &str, b: &str) -> &str해당 없음E0106
struct S { x: &str }해당 없음E0106

연습 문제

  1. 다음 세 시그니처 중 수명을 직접 적지 않아도 컴파일되는 것을 고르고, 컴파일되지 않는 것은 이유를 설명하라. (가) fn tail(text: &str, skip: usize) -> &str (나) fn merge(a: &str, b: &str) -> &str (다) 구조체 Catalog 의 메서드 fn first(&self, prefix: &str) -> Option<&str>
  2. 배송 정보를 담는 구조체 Shipment 가 운송사 이름(&str, 카탈로그와 별개의 텍스트에서 옴)과 메모(&str, 주문 텍스트에서 옴)를 가진다. 두 필드의 출처를 구분하는 정의를 쓰고, 운송사 이름만 돌려주는 함수를 메모 텍스트와 무관한 수명으로 작성하라.
  3. 다음 타입 중 T: 'static 을 만족하는 것을 모두 고르라. String, 지역 String 을 가리키는 &String, 문자열 리터럴 &str, Line<'a>(지역 텍스트에서 해석), Vec<OwnedLine>
  4. 주문 텍스트를 받아 Vec<OwnedLine> 으로 돌려주는 함수 load(text: &str) 를 이 장의 parse_order 와 OwnedLine 을 사용해 작성하라. 반환값이 text 보다 오래 살아도 되는 이유도 한 문장으로 적어라.

정답과 해설

  1. (가)와 (다)는 컴파일된다. (가)는 입력 참조가 text 하나뿐이므로 규칙 2로 반환이 text 에 묶인다. skip 은 참조가 아니라 수명과 무관하다. (다)는 규칙 3으로 반환이 &self 에 묶인다. (나)는 입력 참조가 둘이고 self 가 없어 반환 수명을 정할 수 없으므로 E0106 이 난다. fn merge<'a>(a: &'a str, b: &'a str) -> &'a str 처럼 적어야 한다.
  2. 정의는 다음과 같다.
    struct Shipment<'c, 'o> {
        carrier: &'c str,
        memo: &'o str,
    }
    
    fn carrier_of<'c>(s: &Shipment<'c, '_>) -> &'c str {
        s.carrier
    }
    반환 타입이 'c 이므로 메모 텍스트나 Shipment 값 자체가 먼저 사라져도 운송사 이름은 쓸 수 있다.
  3. String, 문자열 리터럴 &str, Vec<OwnedLine> 이다. 이들은 짧은 수명의 참조를 포함하지 않는다. 지역 String 을 가리키는 &String 과 지역 텍스트에서 해석한 Line<'a> 는 지역 변수를 빌리고 있으므로 만족하지 못한다. 리터럴이 통과하는 것은 프로그램 바이너리에 들어 있어 &'static str 이기 때문이다.
  4. 구현은 다음과 같다.
    fn load(text: &str) -> Vec<OwnedLine> {
        parse_order(text).into_iter().map(OwnedLine::from).collect()
    }
    OwnedLine 의 sku 가 String 으로 글자를 복사해 갖고 있어 원본 text 를 빌리지 않기 때문이다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.