DataScience
article thumbnail
Published 2023. 4. 21. 09:55
Rust 소유권 Rust
728x90

소유권

 

소유권(Ownership)은 러스트의 가장 유니크한 특성이며, 러스트가 가비지 콜렉터 없이 메모리 안정성 보장을 하게 해줍니다. 그러므로, 소유권이 러스트 내에서 어떻게 동작하는지 이해하는 것은 중요합니다.

모든 프로그램은 실행하는 동안 컴퓨터의 메모리를 사용하는 방법을 관리해야 합니다. 몇몇 언어들은 프로그램이 실행될 때 더이상 사용하지 않는 메모리를 끊임없이 찾는 가비지 콜렉션을 갖고 있습니다; 다른 언어들에서는 프로그래머가 직접 명시적으로 메모리를 할당하고 해제해야 합니다. 러스트는 제 3의 접근법을 이용합니다: 메모리는 컴파일 타임에 컴파일러가 체크할 규칙들로 구성된 소유권 시스템을 통해 관리됩니다. 소유권 기능들의 어떤 것도 런타임 비용이 발생하지 않습니다.

 

스택과 힙

소유권에 대해서 알아보기 전에, 프로그램에서 메모리를 저장하는 영역인 스택과 힙에 대해서 살펴보겠습니다. 스택 영역은 함수가 실행될 때 사용하는 메모리 공간으로, 함수에서 사용하는 지역 변수가 스택에 저장됩니다. 일반적으로 스택에서 사용될 메모리 공간이 미리 정해지기 때문에 매우 빠르게 값을 저장하고 접근할 수 있습니다. 만일 함수 실행이 종료되면 스택 영역에서 사용된 모든 지역 변수는 메모리에서 삭제됩니다. 힙 영역은 동적으로 할당되는 메모리를 위해 존재하는 공간으로, 개발자가 명시적으로 특정 크기의 메모리 공간을 사용하겠다고 선언해야 합니다. 만일 해당 메모리 공간이 더 이상 필요하지 않은 경우에는 해당 메모리를 할당 해제해주어야 합니다. 왜냐하면 이미 점유된 메모리 공간은 다른 프로그램이나 스레드에서 사용할 수 없기 때문입니다.

파이썬은 스택을 사용하지 않고 모든 객체를 힙 영역에 저장합니다. 이렇게 저장된 객체들은 파이썬에서 가비지 콜렉션을 통해 메모리를 관리하기 때문에 파이썬을 사용할 때는 메모리 관리에 신경쓰지 않아도 됩니다. 위에서 힙 영역에 대해서 설명할 때 언급한 개발자가 할당하고 할당 해제하는 메모리를 파이썬의 가비지 콜렉터가 대신해주는 것입니다.

반면 러스트는 스택 영역과 힙 영역 모두를 사용합니다. 러스트는 기본적으로 아래와 같이 함수에서 사용하는 모든 값을 제한된 크기의 스택 영역에 저장합니다. 따라서 함수 호출이 종료되면 지역 변수 foo  var는 모두 삭제됩니다.

fn foo() {
    let foo = "foo";
    let var = 5;
}

힙 영역은 함수에서 명시적으로 선언하는 경우에만 사용되는데, 힙 영역에 저장하는 값은 전역적으로(globally) 접근이 가능합니다. Box 타입을 사용해 선언하면 됩니다.

fn main() {
    let num = Box::new(1);
}

정리하자면, 함수에서 사용하는 지역 변수의 값들은 모두 스택 영역에 저장되고, 전역적으로 사용되는 값들은 힙 영역에 저장됩니다. 참고로 뒤에서 배울 멀티스레딩에서 여러 스레드가 접근하는 변수의 값은 힙 영역에 저장되게 됩니다.

 

메모리 관리의 두 종류

거의 모든 시스템에서 메모리 관리는 프로그래머의 주요 관심사 중 하나입니다. 메모리 관리는 크게 명시적 메모리 관리 자동 메모리 관리로 나뉩니다.

 

명시적 메모리 관리

명시적 메모리 관리란 개발자가 사용한 메모리를 직접 해제하는 방식을 말하는데요. C/C++이 대표적으로 명시적 메모리 관리를 하는 프로그래밍 언어이죠. 개발자가 직접 malloc와 free함수를 사용해서 메모리를 동적으로 할당하고 해제합니다.

하지만, 명시적으로 메모리 관리를 하는 건 매우 어려운 일이라고 하는데요. 값이 없는 변수를 참조하면 댕글링 포인터라 부르고, 메모리 해제를 두 번 하면 에러가 발생하고 개발자가 까먹고 해제를 하지 않으면 메모리 릭이 발생합니다. 전문적인 개발자도 힘들어하는게 메모리 관리이기 때문에 요즘 만들어지는 대부분의 언어는 자동 메모리 관리 기능을 기본적으로 가지고 있습니다.

자동 메모리 관리

자동 메모리 관리는 현대 모든 새로 만들어진 언어가 제공하는 기능이구요. 프로그래머로 하여금 메모리 관리를 신경쓰지 않도록 해줍니다. 메모리는 유한한데 반해 마치 무한한 메모리가 존재하는 것 처럼 코딩할 수 있도록 해줍니다.

 

자동 메모리 관리를 구현하는데는 크게 2가지 접근법이 존재합니다.

레퍼런스 카운팅(RC: Reference Count)가비지 컬렉터(GC:Garbage Collector)입니다. 두 접근법 모두 사용하지 않는 객체는 자동으로 수거해서 메모리에 공간을 만드는 작업인데요. GC에 대해서 잠깐 이야기 하자면, GC는 mutator와 collector 두 가지 컴포넌트로 구성됩니다. mutator는 코드 실행 컨텍스트를 가지는 쓰레드로 흔히 collector를 제외한 모든 쓰레드를 mutator라고 부릅니다. collector는 실제 쓰지 않는 객체(=메모리)를 수거해가는 쓰레드입니다.

그런데, GC가 들어가는 순간 프로그램이 무거워집니다. 일단 쓰레기를 수집하는 동안 모든 mutator 쓰레드가 동작을 멈춰야 합니다. (=STW: Stop The Wrold), 그리고 쓰레기인지 아닌지 식별하기 위한 추가 정보가 객체에 붙어야 하기 때문에 공간 오버헤드도 발생하는데요. 

 

레퍼런스 카운팅

레퍼런스 카운팅은 메모리 관리를 자동으로 하기 위한 패러다임 중 하니입니다. 동작 원리는 매우 간단합니다.

  1. 모든 객체에 레퍼런스 카운트 (RC) 정보를 기입해 둡니다.
  2. 변수가 참조하면 RC가 1 증가하고, 참조하지 않으면 1 감소 시킵니다.
  3. RC를 감소시킬 때, 값이 0이 되면 메모리를 해제합니다.

아래 예제를 보면 A,B,C,D 각 객체는 잠조되고 있는 회수에 따라 RC의 값이 부여됩니다. 왼쪽에 있는 흰 네모박스는 roots 라고 부르며, 스택 프레임, 레지스터 등에 있는 변수를 의미한다고 보시면 됩니다.

각 객체 A,B,C,D에 레퍼런스 카운트가 기록된 그림

위 객체 중 D의 RC값은 2입니다. C로부터도 참조되고 있구요, B에서도 참조되고 있습니다. 만약 변수의 참조가 사라진다면 연쇄적으로 RC의 값이 줄어들게 됩니다. 아래 그림에서는 A의 참조가 사라진 경우 입니다. A의 값은 0이되고 메모리에서 해제됩니다. 마찬가지로 B도 자신을 참조하고 있는 객체가 없으니 RC의 값이 1 감소하고, 값이 0이 되므로 메모리에서 해제됩니다.

A와 B의 메모리가 해제된 모습

언뜻 보면, GC의 STW도 발생하지 않고 굉장히 깔끔한 방법인 것 같지만 하나 가장 큰 문제가 있는데요. 더블 링크드 리스트나 복잡한 그래프 구조에서는 순환 참조 (Circular Reference) 문제가 발생합니다.

객체 B, D, E가 각각 서로를 참조하고 있다.

위 그림에서는 객체 B, D, E가 서로를 참조하며 순환 그래프를 이루고 있습니다. 만약 이 경우에서 A와 C의 참조를 없앤다면, A와 C는 각각 RC가 0이 되면서 메모리에서 해제되는데요. 반면에 객체 B, D의 RC값은 1로 유지되면서 메모리에서 해제되지 않는 문제가 발생합니다.

객체가 메모리만 차지하는 현상이 발생한다. (순환 참조 문제)

위 그림에서는 A와 C의 객체를 해제한 모습을 보여주는데요. 힙에 할당된 객체를 참조하고 있는 변수가 없음에도, 메모리만 차지하는 객체가 존재하는 현상이 나타납니다. 이를 순환 참조 문제라고 부르고 이 문제를 해결하기 위해 가비지 컬렉션(GC)이 이용됩니다.

지금도 레퍼런스 카운팅을 쓰긴 합니다. 대표적으로 iOS 앱을 개발할 때 쓰는 Swift언어가 레퍼런스 카운팅으로 메모리를 관리하는데요. 개발자가 순환참조가 발생하지 않게끔 잘 코딩하면 문제가 발생하지 않으면서 GC보다 더 높은 성능을 이끌어 냅니다. 다만, 자동 메모리 관리라고 하기엔 개발자가 계속 객체 간의 참조 관계를 분석해야 하는게 불편하고 메모리 누수가 있다면 어디서 누수가 났는지 파악하기 어렵겠죠.

 

가비지 컬렉션

가비지 컬렉션을 사용하면 레퍼런스 카운팅과는 달리 순환 참조 문제가 발생하지 않습니다. Stop The World 때문에 성능은 더 느리겠지만 개발자가 참조 관계를 생각하지 않아도 되기 때문에 더 편리합니다. 가비지 컬렉션의 종류가 굉장히 많은데요. 가장 기본이 되는 Mark Sweep에서 대해서 짧게 설명해 보겠습니다. 일단, 원리 자체는 간단합니다.

  • Mark Phase | root에서 출발해서 모든 도달 가능한 객체를 Mark 합니다.
  • Sweep Phase | 모든 힙 메모리의 객체를 확인하면서 Mark 되지 않은 객체를 제거합니다.
Mark-Sweep 알고리즘의 동작 원리

우선 Roots란 글로벌 변수/ 지역 변수/ 스택 프레임/ 레지스터 등에 저장된 변수에서 출발하는 지점을 뜻합니다. Mark 단계에서는 해당 변수에서 출발해서 모든 도달 가능한 객체(Reachable Object)를 탐색한 후 Mark합니다. 위 예시에서는 각각 A,B,C,D,E 객체가 marked 되었고

이 후 Sweep 단계에서는 모든 객체를 탐색하면서, 마크 되지 않은 객체를 해제 합니다. 그림을 그리다 보니 D, D, D로 동일하게 그려서 오해를 살 수 도 있는데요. 제 원래 의도는 모두 다른 객체를 의미합니다. 빨간색 테두리를 가진 객체가 도달 되지 않아서 메모리를 해제합니다. Sweep에서는 모든 객체를 확인해야 하기 때문에 항상 Mark 단계에서 확인하는 객체보다 많습니다.

이 Mark Sweep 에서는 두 가지 단점이 존재합니다.

  1. (STW) Mark / Sweep 각 단계마다 컬렉터를 제외한 모든 쓰레드를 중지 시켜야 합니다. 중간에 힙 상태를 바꾸면서 메모리가 충돌되면 제대로 해제하지 못합니다. 그래서 GC를 하려면 끝까지 다 해야 하구요. 하지 않으려면 아예 하지 말았어야 합니다.
  2. (효율성) 프로그램이 사용하는 힙 메모리가 커지면 커질수록 확인해야 하는 객체의 수가 증가하기 때문에 부담이 됩니다. 메모리가 16GB라면 모든 16GB짜리 객체를 확인해야 하는데, 이는 프로그램이 부담이 되죠. 그래서 자바 같은 언어에서는 확인해야 하는 객체를 줄이기 위해서 각 GC마다 너무 자주 보이는 객체는 항상 쓰나 보다 하고 탐색 대상에서 제외 시킵니다. 이를 조금 멋있는 말로 (약한 세대 가설 : Week Generational Hypothesis) 이라고 부릅니다.

Go 언어에서는 이 효율성 문제를 해결 하기 위해 Tri-Color Marking 알고리즘이라는 Inremental GC를 사용하고 있습니다. 나중에 별도 포스팅에서 다룰 예정인데 컨셉만 설명하자면, Incremental GC에서는 GC 작업을 나눠서 할 수 있습니다. 예를 들어, 100%의 GC작업이 있다면 50%만 미리 해두고 나머지 50%는 나중에 해서 끝내는 방식입니다.

 

소유권 규칙

1. 러스트에서 모든 변수는 소유자(owner)라고 불리는 변수를 가지고 있다.
2. 한 순간에 소유자는 단 하나이다.
3. 소유자가 scope를 벗어나면 해당 값은 제거된다.

 

fn main() {
    { // scope A
        let s = String::from("hello, world");
    } 
    // 스코프를 벗어난다. s 객체의 drop 함수를 실행시켜서 메모리를 지운다.
}

변수 s는 스코프 A에서 생성되었다가 스코프를 벗어나면 러스트가 자동으로 변수 s가 참조하는 객체의 drop함수를 실행시켜서 메모리를 지웁니다. 러스트는 기초 자료형과 같이 크기가 고정되고 불변한 값은 보통 스택에 저장하는데요. 문자열 처럼 길이가 변하는 값은 객체로 힙 메모리에 저장합니다.

 

소유권의 이동

기초 자료형은 변수를 대입할 때 값을 복사하고 힙에 저장된 객체는 참조가 복사되어서 대입됩니다. 아래 코드에서 s1과 s2의 두 변수를 메모리에서 보면 아래 그림처럼 도식화 할 수 있습니다.

let s1 = String::from("hello");
let s2 = s1;

변수 s1과 s2를 메모리상에서 도식화한 그림

ptr 에서 실제 값을 참조하고 있고 len과 capacity는 각각 메모리 상에서 값이 어느 정도 크기로 참조하는지 나타냅니다. 변수를 대입할 때 참조를 복사하는게 아니라 값을 복사해버리면 동일한 객체가 여러개 존재하게 되서 매우 비효율적인 프로그램이 됩니다.

만약, s1과 s2 두 변수를 대입할 때 참조가 아닌 값이 복사된다면 매우 비효율적인 프로그램이 된다.

여기서 문제는, 소유권 규칙 3번 째에 의하면 scope를 벗어나는 변수는 drop함수를 실행시킨다는 것을 기억하실 겁니다. 즉 s1과 s2변수 각각 drop이 두 번 실행되어서 동일한 공간의 메모리 해제를 2번하게 되는 문제가 발생하는데요. 러스트의 해결방법은 s1을 다른 곳에 대입 시켯으면 변수 s1을 바로 invalidate 시켜 버립니다. 이를 소유권이 이동 (Move) 했다고 표현합니다. 즉, 참조가 옮겨 갈 뿐 절대 Shallow Copy나 Deep Copy를 하지 않습니다.

{
    let s1 = String::from("hello");
    let s2 = s1; // 변수 s1이 들고 있던 객체의 소유자가 변한다.
    println!("{}", s1); 
}

위 프로그램은 러스트에서는 컴파일 되지 않습니다. s1의 변수의 소유자가 s2로 이동했는데, s1을 참조하고 있기 때문에 러스트는 아래 그림처럼 컴파일 에러를 보여줍니다.

s1의 값이 소유권이 이동한 후 borrow를 했기 때문에 컴파일 에러가 발생한다.

 

소유권 돌려주기

먼저 함수에서 해당 변수의 소유권을 되돌려줄 수 있는 방법이 있습니다. 아래 예제를 보겠습니다.

fn dummy(x: String) -> String {
    println!("{}", x);
    x
}

fn main() {
    let x = String::from("Hello");
    let x = dummy(x);
    println!("{}", x);
}

실행 결과

Hello
Hello

함수 dummy에서 입력 변수 x는 함수 내부에서 사용된 다음 리턴됩니다. 그 다음 함수의 리턴값을 재선언한 변수 x에 할당함으로써 소유권이 x로 되돌아옵니다. 좀더 이해하기 쉽도록 변수명을 아래와 같이 바꿔보겠습니다. 결론적으로, "Hello"라는 값을 소유하고 있는 변수만 x  y  z 순서로 바뀌고, 값은 그대로 있게 됩니다. 하지만 이 방법은 매번 함수의 리턴값을 변수로 재선언해주어야 하기 때문에 코드의 가독성이 떨어지고, 값이 어느 변수로 이동하는지를 알기 어려운 단점이 있습니다.

fn dummy(y: String) -> String {
    println!("{}", y);
    y
}

fn main() {
    let x = String::from("Hello");
    let z = dummy(x);
    println!("{}", z);
}

실행 결과

Hello
Hello

 

함수 파라미터에서의 소유권 이동

 

함수의 파라미터로 넘길 때에도 소유권 이전이 발생합니다. 아래 예시에서, print_something 함수는 text라는 변수를 받는데요. 이를 실제로 호출할 때 파라미터로 소유권을 넘겼기 때문에 변수 s1을 쓰려고 하면 컴파일 에러가 발생합니다. (???: 뭔가 싶죠.. 다른 프로그래밍 언어에서 이런 종류의 에러를 발생시키는 언어는 처음 봅니다)

fn main() {
    let s1 = String::from("Hello, World!"); 
    print_something(s1); // s1의 소유권이 이동함
    println!("{}", s1);  // 컴파일 에러 발생
}fn print_something(text: String) {
    println!("{}", text)
}

아래 예시 코드처럼, 함수에서 리턴 값을 이용해서 소유권을 다시 받아올 수도 있습니다. takes 함수는 파라미터로 받은 값을 그대로 리턴해서 소유권을 이전시키구요. gives 함수도 지역 변수로 생성한 객체의 소유권을 리턴해서 이전 시키기 때문에 에러가 발생하지 않습니다.

fn main() {
    let s1 = gives();
    let s2 = String::from("hello"); 
    let s3 = takes(s2);
}fn gives() -> String {
    let some_string = String::from("yours"); 
    some_string // return 구문은 생략해서 쓴다.
}fn takes(a_string: String) -> String {
   a_string
}

물론, 매번 이런식으로 코딩하면 매우 피곤할겁니다. 그래서 레퍼런스(&)를 생성해서 파라미터로 넘겨서 소유권을 이전시키지 않고도 사용할 수 있습니다.

fn main() {
    let s1 = String::from("hello");
    let len = len(&s1);
    println!("The length of '{}' is {}.", s1, len);
}fn len(s: &String) -> usize { 
    s.len()
}

위 예시에서, len함수는 s변수의 타입을 &String으로 선언해서 레퍼런스를 받겠다고 선언합니다. 실제로 호출부에서도 len(&s1)으로 변수의 레퍼런스를 생성해서 넘겨주는데요. 이 때는 소유권 이전이 발생하지 않습니다. 실제 메모리 상에서는 s1의 변수를 참조하는 객체를 생성해서 할당해주는 모습을 보입니다.

파라미터 s는 s1을 참조하고 사용하고 있다.

이런 레퍼런스 생성이 가능한 이유는 변수 s의 생명주기(Lifetime)가 s1보다 짧기 때문에 가능합니다. 위 예시 코드를 한 번 더 쪼개서 보면 변수 s1의 객체의 생명주기(Lifetime)을 스코프가 벗어나는 위치까지로 볼 수 있습니다. 그리고 len 함수를 호출 할 때 레퍼런스를 생성해서 보내주는데요. 이 때, len함수를 호출하면서 넘긴 파라미터의 생명주기가 s1의 생명주기보다 짧기 때문에 레퍼런스가 항상 값을 가지고 있다는게 보장됩니다.

fn main() {
    let s1 = String::from("hello");                   +
    let len = len(&s1);                               |
    println!("The length of '{}' is {}.", s1, len);   |
}                                                     + s1의 생명주기fn len(s: &String) -> usize { 
    s.len()
}

 

레퍼런스의 값을 수정하기

 

러스트는 레퍼런스로 빌려온 변수의 값을 수정하는 것을 제한합니다. 그 이유를 이해하려면 현재 프로그래밍 패러다임에서 가장 중요한 동시성 이슈에 대해서 알 필요가 있습니다. 예전에 비해서, 멀티 쓰레드를 현명하게 다루는게 더욱 중요해졌기 때문에 요즘 나오는 언어는 언어 자체 스펙에서 동시성 제어 기능을 지원하고 있는데요. 고 언어에서는 채널과 고루틴이 그 중요한 역할을 하고 있습니다.

멀티 쓰레드 환경에서는 항상 deadlock과 race condition 문제가 발생할 수 있는데요. 러스트는 하나의 변수를 변경할 수 있는 권한을 한 소유자로 제한 하기 때문에 데이터에 있어서는 race condition 문제를 제거할 수 있도록 해줍니다. 주의할 점은 러스트는 Thread-safety를 보장하고자 하는게 아니라 Memory-safety를 보장하려는 것이기 때문에 이를 혼동해서는 안됩니다.

아래 예시를 볼까요? 원래는 레퍼런스의 값을 수정할 수 없다고 했는데요. &mut로 레퍼런스를 생성하면 값을 수정할 수 있는 레퍼런스가 만들어지고 이는 수정할 수 있습니다.

let mut x = String::from("hello");
let y = &mut x;

y.push_str(", world");

println!("x = {}", x); // x = hello, world

하지만, 만약 위 코드가 아래와 같이 변형된다면 어떻게 될까요? 자세히 보시면 x를 사용하는 println 함수를 y.push_str을 호출하는 부분보다 위로 올렸습니다. 이 경우에는 컴파일 에러가 발생합니다.

fn main() {
    let mut x = String::from("hello");
    let y = &mut x;
    
    println!("x = {}", x); // ???
    
    y.push_str(", world");
}error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
 --> src/main.rs:5:24
  |
3 |     let y = &mut x;
  |             ------ mutable borrow occurs here
4 |     
5 |     println!("x = {}", x); // ???
  |                        ^ immutable borrow occurs here
6 |     
7 |     y.push_str(", world");
  |     --------------------- mutable borrow later used here

Rust에서는 왜 이런 상황에서 컴파일 에러를 발생 시킬까요? Memory-safety를 보장하기 위해서 입니다. 위 예시에서는 먼저 hello를 출력하고 y가 나중에 값을 바꾸면 되는 것 같지만, 멀티 쓰레드 환경에서 생각해봅시다.

① 먼저 y가 문자열 값을 바꾸면 메모리에서 새 공간에 할당한 뒤에 기존 메모리는 해제시켜야 합니다. (크기가 달라졌기 때문입니다), ② 원래 변수 x가 가지고 있는 참조 객체가 새로운 공간에 할당되었는데요. 이 때 println! 함수로 먼저 사용한 값이 메모리 상에서는 이미 사라진 공간일 수도 있습니다. 메모리가 안전하지 않은 케이스입니다. 예시는 문자열이 었지만 vector를 사용하는 경우에도 동일한 컴파일 에러가 발생합니다.

fn main() {
    let mut items = vec![1];
    let item = items.last(); 
    items.push(2); // push함수를 실행하면서 메모리에 재할당 되는데요. 그러면 변수 item이 어느 순간 invalidate한 변수 일 수 있습니다.
}

마찬가지로 Memory-safety를 이유로 해서 불변 레퍼런스와 가변 레퍼런스를 동시에 가질 수는 없습니다. 모두 불변 레퍼런스라면 문제가 발생하지 않습니다. 이렇게 컴파일러 단계에서 강력하게 메모리 안정성을 추구하기 때문에 러스트에서는 Fearless Concurrency[3]라고 부르는 듯 합니다.

fn main() {
    let mut s = String::from("hello");
    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM
    println!("{}, {}, and {}", r1, r2, r3);
}error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:5:14
  |
3 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
4 |     let r2 = &s; // no problem
5 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
6 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

 

클로저와 소유권

앞에서 클로저를 단순히 익명 함수라고만 설명하고 넘어갔습니다. 하지만 이제 스코프와 소유권을 배웠기 때문에, 클로저에 대해 좀더 자세한 얘기를 해보려고 합니다. 클로저의 가장 큰 특징은 익명 함수를 만들고 이를 변수에 저장하거나 다른 함수의 인자로 전달할 수 있다는 것입니다.

클로저의 환경 캡처

클로저는 클로저가 선언된 스코프에 있는 지역 변수를 자신의 함수 내부에서 사용할 수 있는데, 이를 환경 캡처(Environment capture)라고 부릅니다. 클로저가 변수를 자신의 스코프 내부로 가져가는 방법은 총 3가지가 존재합니다.

  • 불변 소유권 대여
  • 가변 소유권 대여
  • 소유권 가져가기

먼저 아래 예제를 보면, 클로저 func 는 같은 스코프에 선언된 변수 multiplier를 자신의 함수 내부에서 사용할 수 있습니다. 이때 multiplier의 값은 클로저에서 사용된 이후에도 스코프 내부에서 사용이 가능합니다. 따라서 클로저는 multiplier를 불변 소유권 대여 방법으로 자신의 내부에서 사용한 것입니다.

fn main() {
    let multiplier = 5;

    let func = |x: i32| -> i32 { x * multiplier };

    for i in 1..=5 {
        println!("{}", func(i));
    }

    println!("{}", multiplier); // 👍
}

실행 결과

5
10
15
20
25
5

아래 예제는 multiplier를 가변 변수로 선언하고, 클로저 내부에서 multiplier의 값을 변경시키고 있습니다. 방금 살펴본 예제와 마찬가지로 클로저 호출이 끝난 다음에도 여전히 multiplier에 접근이 가능합니다.

fn main() {
    let mut multiplier = 5;

    let mut func = |x: i32| -> i32 {
        multiplier += 1;
        x * multiplier
    };

    for i in 1..=5 {
        println!("{}", func(i));
    }

    println!("{}", multiplier); // 👍
}

실행 결과

6
14
24
36
50
10

 

move 를 사용한 소유권 이동

클로저가 환경으로부터 사용하는 값의 소유권을 가져갈 수도 있습니다. 클로저가 같은 스코프에 선언된 지역 변수의 소유권을 가져가도록 하려면 클로저의 파라미터를 선언하는 코드 앞에 move 키워드를 사용하면 됩니다.

move | param, ... | body;

다음 예제에서는 클로저를 리턴하는 함수 factory를 만들었습니다. 여기서 리턴되는 클로저는 factory 함수의 파라미터인 factor를 캡처해 사용합니다. 그 다음 factory를 main 함수에서 사용해 만든 클로저를 호출하면 multiplier 변수를 모든 클로저에서 공유할 수 있게 됩니다.

fn factory(factor: i32) -> impl Fn(i32) -> i32 {
    |x| x * factor
}

fn main() {
    let multiplier = 5;
    let mult = factory(multiplier);
    for i in 1..=3 {
        println!("{}", mult(i));
    }
}

하지만 위 코드를 컴파일하면, 아래와 같은 에러가 발생합니다.

error[E0597]: `factor` does not live long enough
 --> src/main.rs:2:13
  |
2 |     |x| x * factor
  |     ---     ^^^^^^ borrowed value does not live long enough
  |     |
  |     value captured here
3 | }
  |  -
  |  |
  |  `factor` dropped here while still borrowed
  |  borrow later used here

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

factor 변수가 클로저 안에 캡처될 때, 소유권이 factory로부터 클로저로 대여됩니다. 하지만 factory함수가 종료되면 factor 변수의 값이 삭제되기 때문에 리턴된 클로저에서 더 이상 factor 를 사용할 수 없는 문제가 발생합니다. 이를 방지하기 위해서는 클로저 안으로 factor의 소유권을 이동시키면 됩니다. 이때 사용되는 키워드가 move입니다. move는 캡처된 변수의 소유권을 클로저 안으로 이동시킵니다.

fn factory(factor: i32) -> impl Fn(i32) -> i32 {
    move |x| x * factor
}

fn main() {
    let multiplier = 5;
    let mult = factory(multiplier);
    for i in 1..=3 {
        println!("{}", mult(i));
    }
}

실행 결과

5
10
15

클로저에서 move 를 가장 많이 사용하는 경우는 멀티스레드 혹은 비동기 프로그래밍을 작성할 때입니다.

 

'Rust' 카테고리의 다른 글

Rust 구조체  (102) 2023.05.10
Rust 데이터 구조와 이터레이터  (77) 2023.04.22
Rust 반복문  (83) 2023.04.20
Rust 함수  (90) 2023.04.19
Rust 변수  (94) 2023.04.18
profile

DataScience

@Ninestar

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!