Rust : 소유와 이동

    들어가기 전

    이 글은 프로그래밍 러스트(Oreilly)를 공부하며 작성한 글입니다.

     


    소유권 (Ownership)

    • 러스트에서 소유권은 컴파일 시점에 검증된다. 
    • 소유권은 아래 메모리 안정성을 위해 러스트 컴파일러가 제공한다.
      • Dangling Pointer가 없도록 한다.
      • 원하는 시점에 메모리가 해제되도록 한다.
    • 모든 값은 하나의 소유자를 가진다.  (Rc, Arc는 복수 소유자가 존재 가능)
    • 소유자는 자신의 구성 요소들에 대한 소유권도 가진다. 이것은 소유권 트리 형태로 구성된다. 
      • 구조체는 자신의 필드들을 소유한다.
      • 벡터는 자신의 요소들을 소유한다. 
    • 소유자가 자신이 선언된 블록을 벗어날 때 무효화된다. 이 때, 소유하고 있던 값들도 모두 힙에서 제거된다. 
    • 소유권을 이동해서 트리를 만들고 바꿀 수 있다. 
    • Copy Trait을 구현한 타입은 소유권 규칙에 적용되지 않는다.
    • 소유권은 이동 / 빌림 개념이 있다. 

    아래 예시를 통해 소유권 트리와 스택 / 힙 메모리의 상태를 고민해본다. 

    struct Person { name: String, birth: i32}
    
    fn main() {
        let mut composers: Vec<Person> = vec![];
        composers.push(Person { name: "Palestrina".to_string(), birth: 1525 });
        composers.push(Person { name: "Dowland".to_string(), birth: 1563 });
        composers.push(Person { name: "Lully".to_string(), birth: 1632 });
    
        for composer in &composers {
            println!("{}, born in {}", composer.name, composer.birth);
        }
    }

    이 코드는 다음과 같이 동작한다.

    1. composer는 Vector를 소유한다.
    2. Vector는 자신에게 포함된 Person 3개를 소유한다.
    3. Person은 자신의 필드인 name, birth를 소유한다.

    composer를 Root Node로 해서 소유권 트리가 형성될 것을 암시한다.

    위 그림에서는 composer 필드가 Vector, 구조체를 소유하면서 생성되는 소유권 트리를 보여준다. composer가 선언된 블록을 벗어나게 되면, composer가 해제되면서 위 소유권 트리가 모두 해제된다. 

    메모리 관점에서 자세히 살펴보자. 

    1. 스택 프레임에는 버퍼를 가리키는 포인터가 있다. 이 때, 버퍼는 힙에 저장된 Vector를 가리킨다.
    2. Vector 버퍼에는 Person 구조체가 3개 저장되어 있다. 
    3. Person 구조체는 String 타입의 필드 name을 가진다. 이 때, Person 구조체의 버퍼는 이 String 값을 가리킨다. birth는 Vector 버퍼 내에 같이 저장되어있다.

    birth는 Vector 버퍼에 저장되어있다. birth는 i32 타입이고, 이것은 크기가 정해진 값이기 때문에 Vector 버퍼 내에 할당할 수 있다. 그러나 String은 크기가 바뀔 수 있기 때문에 버퍼를 가리키는 포인터가 Vector 버퍼 내에 할당된다. 

     

     


    이동 (Move)

    • 소유권의 이동은 소유권 트리를 변경하기 위함이다.
    • 값을 변수에 배정, 함수에 값을 전달, 함수에서 값을 반환하는 경우 값의 소유권은 이동한다.
    • 변수가 초기화 된 후에 값의 소유권이 이동하면, 그 변수는 미초기화된 상태가 된다. (그림 확인) 
      • 모든 소유권 이동은 원본을 미초기화 된 상태로 바꾸는 '바이트 단위의 얕은 복사'이다.
    • 소유권의 일부만 이동할 수는 없다.
      • Vector의 일부 요소의 소유권만 인덱스를 이용해 이동. (let a = vector[0].name; 
      • 구조체의 일부 요소의 소유권만 이동할 수는 없다. 구조체의 일부 소유권만 이동하면, 구조체 전체가 무효화된다.
    • Copy Trait을 구현한 값은 이동대신 '복사'된다.
      • i32, bool 같은 간단한 타입들에 대해서 적용된다. 
      • 해제할 때 특별한 처리가 필요한 타입들은 모두 Copy Trait을 구현하지 않는다. (Rc, MutexGuard, File 등등)
    • Copy Type만을 요소로 가지는 사용자 정의 타입은 자동적으로 Copy Trait을 구현하지 않는다.
      • Copy 타입을 나중에 비Copy 타입으로 변경하는 경우, 이 타입을 사용하는 많은 코드가 변경되어야 한다. 따라서 자동으로 Copy Type을 가진다고 하지 않고, 명시적으로 #[derive(Copy, Clone)]을 선언해야한다.
    • Rc<T>, Arc<T>는 레퍼런스 카운트 기반의 스마트 포인터다.
      • Rc<T>는 힙에 할당된 T를 가리킨다. 이 때, Reference Count가 힙에 내장되어있다. 
      • Rc<T>를 복제하면, Reference Count가 증가하면서 힙에 할당된 T를 가리키는 포인터가 반환된다. 
      • 기본적으로 Rc<T>는 불변이기 때문에 순환참조는 만들어지지 않는다. 
      • let a = Rc::new(String::from("a")) 코드에서 a는 Rc 인스턴스에 대한 소유권만 가진다. Rc 인스턴스는 스택 메모리에 선언되어있다.
        • a가 해제되면, Rc 인스턴스가 스택 메모리에서 제거된다. Rc 인스턴스가 제거될 때 마다, 이 인스턴스가 참조하던 힙 메모리에서 Reference Count가 감소한다. Reference Count가 0이 되면, 그 힙 메모리는 해제된다.  

     

    소유권이 이동하면 기존 값은 미초기화 됨.

    fn main() {
        let s = vec![
            String::from("udon"),
            String::from("rament"),
            String::from("soba"),
        ];
        let t = s;
    
        println!("{:?}", s);
    }

    위 코드는 소유권 관점에서 변수가 초기화 된 이후, 이동하는 것을 보여주는 코드다.

    1. s는 초기화된다.
    2. s에 저장된 값의 소유권은 t로 이동한다. 이 때, s는 미초기화 상태(무효화)가 된다.

    스택 메모리 / 힙 메모리 관점에서 보면 이렇게 동작하는 것을 알 수 있다. 

    1. s는 Vector 버퍼를 가리키는 포인터, 메타 정보를 스택 프레임에 저장한다.
    2. s가 가진 값의 소유권이 t로 이동한 뒤로, s는 미초기화(무효화) 된다. 스택 메모리 상에서 이전에 있던 s의 값은 없어지고, t에 그 값이 복사되어 있음을 알 수 있다.

    위를 통해 소유권이 이동되고, 기존 변수가 무효화 되었을 때의 스택 메모리가 어떻게 구성되어있는지를 알 수 있다. 

     


    소유권의 일부만 이동할 수 없음. (Vector)

    fn main() {
        let v = vec![
            "a".to_string(),
            "b".to_string(),
            "c".to_string()
        ];
    
        let k = v[1]; // 컴파일되지 않음. Cannot move
    }
    • Vector의 인덱스를 이용해 일부 요소만 소유권을 이동할 수 없다. 위 코드는 컴파일 되지 않는데, 인덱스를 이용해 일부 요소의 소유권을 이동하려고 했기 때문이다. 
    • 소유권 트리 관점에서 생각해보면 당연하다.
      • 힙 메모리에 선언된 Vector는 Vector 버퍼의 시작 지점을 가리키는 포인터를 가리킨다.
      • 만약 Vector[1], Vector[2]의 소유권을 각각 가지려고 한다면 스택 프레임에 저장된 메타 정보에 내가 소유하고 있는 인덱스 정보를 저장해야하는데 바람직하지 않다. 
      • 따라서 그런 방식으로 쓸 수가 없다. 

     

     

     


    소유권의 일부만 이동할 수 없음. (구조체)

    struct Person { name: String, second_name: String}
    
    fn main() {
    
        let k = Person { name: String::from("John"), second_name: "Hello".to_string() };
        let h = k.name;
    
        println!("{}", h);
        println!("{}", k);
    }
    • 구조체의 소유권 중 일부만 이동되는 것은 불가능하다. 
    • 구조체의 필드 중 일부만 소유권이 이동되면, 그 구조체는 전체가 무효화된다. 
      • 아래 그림을 참고해보면 소유권 트리 구조가 깨지는 것을 알 수 있음.
      • k는 여전히 힙의 구조체 메모리에 대한 소유권을 가지고 있음.
      • h는 가장 잎새 노드 값을 가리키고 있음. 예를 들면 Palestrina가 될 수 있음.
      • 결국 Palestrina는 두 개의 소유자를 가지기 때문에 러스트의 소유권 트리에서 벗어난다. 

     


    Copy Trait을 구현한 값은 이동 대신 복사된다.

    fn main() {
        let string1 = "somnambulance".to_string();
        let string2 = string1; // MOVE
        
        let num1 = 36;
        let num2 = num1; // COPY
    }
    • Copy Trait을 구현한 타입은 값을 대입했을 때, COPY 된다.
      • Copy Trait을 구현한 타입은 컴파일 타임에 값이 정해지는 타입이거나, 메모리를 해제할 때 특별한 조치가 필요하는 타입이다.

    위 코드를 메모리 관점에서 보면 다음과 같다.

    1. string1 값은 string2로 이동했기 때문에 무효화된다. 이것은 String 타입이 Copy Trait을 구현하지 않았기 때문이다.
    2. i32 타입인 36은 Copy Trait을 구현했기 때문에 스택 메모리 상에 값이 그대로 복사된다.

     

    Copy Type만을 요소로 가지는 사용자 정의 타입은 자동적으로 Copy Trait을 구현하지 않는다.

    struct Person { age: i32, price: i32}
    • 구조체 Person의 구성요소는 모두 Copy Trait을 구현한 타입이지만, Person은 Copy Trait을 구현했다고 취급되지 않음.
    • Copy Trait을 구현한 것과 구현하지 않은 인스턴스를 사용하는 것은 코드에서 많은 차이가 발생한다. 이는 추후에 Copy Trait 구현 객체에서 미구현 객체로 변경할 때 많은 Break Point를 가져온다. 따라서 기본적으로는 Copy Trait을 구현하지 않았다고 가정한다.
    #[derive(Copy, Clone)]
    struct Person { age: i32, price: i32}
    • 만약 Copy Trait을 구현한 객체로 사용하고 싶다면, #[derive(Copy, Clone)]을 명시적으로 사용해야한다. 

     


    Rc, Arc는 레퍼런스 카운트 기반의 스마트 포인터다.

    fn main() {
        let s = Rc::new("shirataki");
        let t = s.clone();
        let u = s.clone();
    }

    Rc는 Reference Count 기반의 스마트 포인터다. 

    • s, t, u 자체는 스택 프레임에 저장된 포인터에 대한 소유권만 가진다.
    • s, t, u의 포인터는 레퍼런스 카운트가 내장된 T 값이 저장된 힙 메모리를 가리킨다. 
    • s, t, u가 선언된 블록을 벗어나면, 그 스택 프레임은 제거된다. 왜냐하면 s, t, u 자체는 스택 프레임에 대한 소유권만 가지기 때문이다.
    • 그런데 referecne count가 0이 되면, 힙 메모리에 저장된 값 T가 메모리에서 제거된다. 

     

    Rc<T>, Arc<T>만 사용했을 때는 순환참조가 발생하지 않음.

    let s = Rc::new("shirataki");
    *s.as_mut_ptr() = "Hello";
    • 위 코드는 컴파일 되지 않는다. 기본적으로 Rc<T>가 내부적으로 가지고 있는 값 T는 불변이기 때문이다.
    • Rc<T>가 불변인 이유는 Rc가 여러 소유자를 가진다고 가정하기 때문이다. (Reference Count)
      • 여러 소유자가 있는 경우, 가변인 값에 대해서는 Race Condition 문제를 본질적으로 가지는 것을 의미함.
      • Rc<T>는 불변이기 때문에 Rc<T>만 이용해서는 순환참조 문제가 발생하지는 않는다.
      • Race Condition을 방지하기 위해 Rc<T>는 불변이다. RefCell을 이용하면 내부 가변성을 통해 unsafe하게 값을 수정할 수는 있다. 

    위와 같이 순환참조를 하려면, 처음에 Rc 인스턴스를 선언한 다음에 값을 수정해서 넣어줄 수 있어야 하기 때문이다.

     

     

     

    '프로그래밍 언어 > Rust' 카테고리의 다른 글

    Rust : 레퍼런스(Reference)  (1) 2024.12.21

    댓글

    Designed by JB FACTORY