Learning Rust #4

TAEJUN KIM·2021년 9월 3일
1

Rust

목록 보기
4/4
post-thumbnail

Ownership

  • Ownership 의 개념은 garbage collector 없이도 memory safey를 보장해준다.

  • 연관지을 하위 키워드로 borrowing , slices, rust에서 data를 메모리에 어떻게 두는지 정도로 정리할 수 있다.

Ownership의 정의

  • 모든 컴퓨터는 컴퓨터의 메모리를 사용하는동안 (실행동안) 메모리를 관리하는 방법을 가지고 있어야한다.

  • 어떤 프로그래밍 언어는 가비지콜렉터가 있어서 사용되지 않게되는 메모리를 알아서 정리해준다. (JAVA Python) , 또는 명시적으로 할당하고 해제해주어야 한다. (C, C++) 러스트는 제 3의 방식을 사용한다.

  • 러스트에서 메모리는 Ownership System의 규칙들의 집합에 의해 관리된다.

  • 컴파일러가 컴파일 타임에 체크한다. Ownership의 특징은 running time에 프로그램의 속도를 저하하지 않는다.

Stack and Heap

  • Rust 같은 시스템 프로그래밍 언어에서는 Stack 과 Heap 을 신경써주어야한다.

-스택과 힙은 둘다 런타임에 코드를 돌아가게해주는 메모리의 일부인데, 스택은 LIFO(Last In First Out)이다.

  • 스택에 저장되는 모든 데이터는 고정되고 (컴퓨터에게)알려진 사이즈를 가져야한다.

  • 컴파일타임에 사이즈가 정해져있지 않거나, 변경되어야 하는 사이즈는 무조건 힙에 저장되어야 한다. 힙은 스택보다 덜 정도되어있다. (왜냐면 메모리를 얼마나쓸지 모르니까, 안 정해져 있거나 변하거나 하니까)

  • The Memory Allocator는 heap에 있는 충분히 큰(big enough) empty spot을 찾아서 Used 라고 표시하고 그 시작 주소에대한 포인터를 반환한다. 이를 Allocating on the heap (allocating) 이라고 한다.

  • stack 에 데이터를 push 하는 행위는 allocating이 아니다.

  • 따라서 스택에 집어넣는것이 힙에 할당하는거보다 동작이 빠르다, 힙과 달리 스택은 적당히 빈(big enough) 자리를 찾을 필요가 없기 떄문이다. 그냥 top에 쌓기만 하면 된다.

  • 당연히 Access하는것도 힙에 있는 data를 access 하는게 느리다. 어느 공간에 할당했는지 포인터들을 따라 흘러흘러 들어가야하기 떄문이다.

  • 함수를 호출하면 함수의 지역변수들이 스택에 푸시되고 , 호출된 함수가 끝나면 스택에서 POP 된다.

  • 코드의 어떤 부분이 힙의 어떤 데이터를 사용하는지 계속 추적하는건, 힙의 데이터 중복을 방지할수 있다.

  • 힙 데이터를 관리할 줄 아는것은 왜 Ownership 이라는 개념이 존재하고 왜 그 방식으로 동작하는지 설명하는데 도움을 줄 것이다.

Ownership Rules

세가지 룰이 있늗네 꼭 기억해두자.

  • Each value in Rust has a variable that's called its owner
    러스트의 각 값은 그것의 owner라는 변수를 가진다.

  • There can only be one owner at a time
    owner는 한번에 하나만 존재한다. (중복되는 owner는 존재하지 않는다.)

  • When the owner goes out of scope, the value will be dropped
    owner가 스코프를 벗어나면, 값은 드랍된다 (owner에 붙어있는 값이 떨어진다/분리된다 )

Variable Scope

기본 data type 들은 스택에 저장되고 호출이 끝나면 팝된다.
heap에 저장되는 data type 인 String을 다루어볼것이다.

  • 아래와 같은 변수가 있다고 해보자
{
    let s = "hello";
}  // 이 스코프 안에서 s 가 존재한다. 

s는 string literal을 참조하는 immutable한 변수이다.
스트링 리터럴은 프로그램에 하드코드 되는 스트링 값이다. 편리하지만, 스트링리터럴의 사용이 적절치 못한 상황들이 있다.

일단 값을 변경할 수 없다. "Hello"라고 선언했는데 "Hello!"라고 바꿀 수 없는것이다. 그리고 User input을 받아 스트링을 저장하는경우 무엇이 올지 모르지 않나.

그래서 러스트는 String 을 제공한다. heap에 저장되고 컴파일 타임에 text의 길이를 모르더라도 괜찮다.

string literal로 부터 String을 선언하는 방법은 from 함수를 사용하며 아래와 같다.

let s = String::from("hello");

이제 s는 변경이 가능하다.

// --snip--
s.push_str(", world!"); // s : hello, world!
//

왜 String은 변경이 가능한데 literal은 변경이 불가능한가?
이에대한 해답은 이 두 타입이 메모리를 다루는 방식에 있다.

Memory and Allocation

  • string literal은 contents를 compile time에 안다. 그래서 최종 실행본에 그냥 하드코딩 될 수 있다. 이게 스트링 리터럴이 빠르고 효과적인 이유이다. 그냥 바로 값을 가져다 하드코딩하는거니

근데 이런 빠르고 효과적인 성능이 보장되는 이유가 왜인가, 스트링 리터럴이 변경되지 않는다는게 보장이 되어있으니까 그런것이다.

  • mutable 한 String이 되기 위해선 힙에 메모리를 할당한다, 컴파일타임엔 알 수 없다. 이는 두가지를 의미한다.

  • 1.Memory allocator에 Memory를 runtime에 request해야한다.

  • 2.String을 다 사용하고 나면 Memory를 반납하는 방법이 필요하다.

메모리를 런타임에 할당요청하는건(1번 의미) String::from을 통해서 완료하였다.

2번 의미가 보통의 프로그래밍 언어와 다른데 garbage collector(below GC)를 지원하는 언어들은 더 사용되지 않는 메모리를 끊임없이 track하고 메모리를 해제한다. 따라서 우리는 메모리관리에대해 생각할 필요가 없다. 알아서 해주니까.

근데 GC가 없으면 메모리 관리하는건 프로그래머의 몫이다. 근데 이게 까다로운게 메모리를 해제하는걸 까먹어도 문제고, 너무 빨리해제해버리면 invalid 가생겨서 문제고, 해제를 그렇다고 두번해버리면 그것도 버그다.

중요한점은
하나의 allocate는 하나의 free와 정확히 짝을 지어야 한다는 것이다.
할당-해제 pair가 정확히지켜져야 버그가 없다. 결자해지 회자정리!

근데 여기서 러스트는 다른방식을 차용한다.

  • 변수가 가지고 있던 memory는 vaild한 scope를 벗어나면 자동적으로 return 된다.

위의 스트링 예제에서 from함수로 string literal로부터 String 타입변수를 선언했다. 스코프 밖으로 나가면 Rust가 drop이라는 특별한 함수를 호출하고 이 drop이라는 함수가 Rust 프로그래머가 s에 사용된 메모리를 반환하는 코드를 넣을 수 있는 곳이다.

  • rust는 중괄호가 끝날때 } (scope종료) drop함수를 자동적으로 호출한다.

C++ 의 (Resource Acquisition is intialization)RAII패턴이 러스트에 많은 영향을 주었으니 참고할것

let x = 5;
let y = x;

같은 코드의 동작을 보자. x에 5라는 값을 bind 해주고 x의 복사본을 만들어 이를 y 에 bind 해준다. x랑 y는 둘다 5라는 값을 가지고 있으므로 스택에 두개의 5가 push 되어 있을것이다.

이를 String 버전으로 생각해보자.

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

int32를 다룬 코드처럼 s1의 복사본이 s2에 bind 되는 식으로 메모리가 동작할것 같지만 그렇지 않다.

s1이라는 스트링은 다음과 같은 메모리 구조를 가지고 있다.
3가지로 구성되는데
1. s1의 value 인 string literal의 시작주소를 가르키는 포인터
2. s1의 길이 (length)
3. s1의 용량 (capacity)

이 세가지로 이루어진 스트링 데이터 블록이 스택에 저장된다.

그리고 s2 = s1을 하면 s1의 스트링 데이터 블록이 카피된다.
여기서 중요한건 힙에 있는 값인 스트링 리터럴 "hello"가 카피되는것이 아니라 스택에 있는 스트링 데이터 블록을 카피하는 것이다.

그리고 s2의 포인터도 hello의 시작주소를 가르키는 것이다.

따라서 상대적으로 unorganized 되어있는 힙의 값을 복사하지 않고 organized 된 스택의 값을 복사해서 쓰므로 퍼포먼스 저하를 방지하는 것이다.

그럼 여기서 항상 shallow copy issue가 생각날것이다.

두 포인터가 같은 객체를 참조하는데 한 포인터에서 객체를 소멸시켜버리면 나머지 포인터는 어떤 객체를 참조하는가?!!?!?

마찬가지로 s1과 s2가 scope 밖을 벗어나서 drop이 될떄를 생각해보면
먼저 drop되는 변수에의해 hello 라는 heap의 데이터는 해제되었다.
그럼 나머지 하나가 또 해제하면 double free error 인 것이다. - Memory corruption , security vulnerabilities 를 일으킬 수 있다.

메모리 안정성 보장을 위해 Rust 에서

let s1 = String::from("hello");
//-- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
let s2 = s1;
//-- value moved here
println!("{}, world!", s1);
//s1의 value는 borrowed된다 here after move

을 하게 되면 s1이 더이상 valid 하지 않다고 간주해서 s1이 scope를 벗어날떄 drop으로 메모리 해제해줄 필요가 없다.

shallow copy 같지만 rust는 기존의 variable을 invalidate 하기 떄문에 valid한걸 옮겨준다는 Move라고 부른다.
s1 was moved into s2.

따라서 s2만 한번 해제해주는게 되므로 메모리 중복해제 에러가 발생하지 않는다.

이것이 암시하는바는 : Rust는 절대 자동적으로 deep copy를 생성하지 않는다는 점이고 이것의 의미는 러스트에서 자동적으로 복사되는 어떤것이든 런타임 성능 관점에서 코스트가 싸다.(inexpensive in terms of runtime performance)

Ways Variables and Data interact : Clone

  • String 을 deep copy 하고 싶으면 clone 메소드를 사용하면 된다.
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2)'

명시적으로 heap 에 있는 hello 라는 string literal 까지 복사한다.

그럼 이런 구조가 되겠다.

Stack-Only Data : Copy

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

이건 괜찮다. 왜냐면 x랑 y는 i32이고 i32같은 사이즈가 컴파일 타임에 알려진 값들은 모두 스택에 저장, 복사 될 수 있기 떄문이다.

  • Rust는 특별한 Copy 라는 Annotation을 제공한다.

  • drop이 호출되는 타입에는 Copy annotation을 붙일 수 없다.

Ownership and Functions

  • 함수에 값을 전달하는 것은 변수에 값을 할당하는것과 비슷한 의미를 가진다.

  • 함수에 값을 전달하는것 또한 변수할당 할 때 처럼 copy 되거나 move 될 것이다.

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // x would move into the function,
                                    // but i32 is Copy, so it's okay to still
                                    // use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

스트링 s를 take_ownership이후 사용하려고 하면 컴파일 에러가 발생한다.
왜냐 take_ownership에서 이미 scope를 벗어나 memory가 drop을 통해 해제되었기 떄문이다.

반면 makes_copy는 상관없다 스택값을 복사해서 써주는거니까~

Return Values and Scope

  • 값을 반환하는것도 Ownership을 transfer 할 수 있다.
fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1

    let s2 = String::from("hello");     // s2 comes into scope

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 goes out of scope but was
  // moved, so nothing happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_string = String::from("hello"); // some_string comes into scope

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function
}

// takes_and_gives_back will take a String and return one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope

    a_string  // a_string is returned and moves out to the calling function
}

s1 은 give_ownership이라는 함수가 실행되고 거기서 some_string의 ownership을 받아온다.
s2는 직접할당한다

s3는 s2의 onwership을 가지고 takes_and_give_back 함수로 들어갔다가 다시 반환을한다.

그리고 메인이 종료되므로 s1과 s3에 있는 메모리가 drop된다.

이렇게 소유권을 주고받는건 귀찮은 일이다.
우리가 함수가 값을 사용하게 해주고싶은데 소유권은 가져가지 못하게 하면 어떻게 될까? 뭘 집어넣든 다시 반환해야하니 귀찮을 것이다. 그래야 메모리를 해제해줄 수 있으니까

tuple을 사용하면 여러가지 값을 한번에 반환할 수 있다.

fn main(){
    let s1= String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("the length of '{}' is {}.", s2,len);
}

fn calculate_length(s : String) -> (String, usize){ // (String, usize)로 구성된 튜플을 리턴하는 함수

    let length = s.len();
    (s, length)
}

근데 사실 이런건 너무 복잡하다 그래서 Rust는 reference라는 개념을 제공한다.

profile
前) 컴퓨터 공학과 학생 現)취준 백수

0개의 댓글