비욘드 JS: 러스트 - smart pointers

dante Yoon·2023년 1월 3일
0

beyond js

목록 보기
16/20
post-thumbnail

글을 시작하며

안녕하세요, 단테입니다.
오늘은 러스트의 스마트 포인터에 대해 알아보겠습니다.

스마트 포인터

포인터는 메모리의 주소를 가지고 있는 변수의 개념입니다. 이 주소는 다른 데이터를 가르킨다 혹은 참조한다고 말할 수 있습니다. 우리는 reference를 통해 이미 포인터를 알아보았습니다. reference는 포인터의 일종입니다. 레퍼런스는 & 심볼을 이용해서 가르키는 값을 빌립니다.(borrow). 이 포인터를 사용하는데는 오버헤드가 걸리지 않습니다.

스마트 포인터는 포인터와 유사하지만 추가적인 메타데이터와 기능을 제공합니다. 스마트 포인터는 러스트에서 새로운 개념이 아닙니다. 러스트는 표준 라이브러리에서 제공하는 여러가지의 스마트 포인터를 가지고 있습니다. 이들은 레퍼런스에서 제공하는 것보다 더 많은 기능을 제공합니다.

스마트 포인터 타입인 reference counting에 대해 알아보겠습니다. 이 포인터는 여러 owner가 존재하는 것을 가능하게 합니다. 그리고 owner가 존재하지 않으면 알아서 메모리에서 해제됩니다.

러스트에서는 오너쉽과 버로우라는 개념을 통해 reference와 스마트 포인터간의 차이점에 대해 설명할 수 있습니다. 레퍼런스는 데이터를 버로우하고, 스마트 포인터는 그들이 가르키고 있는 데이터를 소유합니다. (버로우는 borrow 입니다. 한글로 표현하니까 좀 어색하네요.)

예전 강의에서 String과 벡터 타입에 대해 이야기할 때 당시에는 그렇게 부르지 않았지만 이미 우리는 스마트 포인터를 접했었습니다. 이 타입들은 메모리를 소유하고 조작할 수 있게 한다는 점에서 스마트 포인터로 간주됩니다. 이들은 또한 추가적인 기능을 제공하는데

String 타입의 경우 문자열의 용량을 메타데이터로 저장하고, 데이터가 항상 유효한 UTF-8이 되도록 보장합니다.

스마트 포인터는 보통 struct를 통해 구현되는데, 일반적인 스트럭트와는 다르게 Deref, Drop trait을 구현합니다. Deref trait은 스마트 포인터 인스턴스가 레퍼런스처럼 동작하는 것을 허용하여 Drop trait은 스마트 포인터가 스코프를 벗어날 때 동작할 수 있는 코드를 사용자 지정할 수 있습니다.

스마트 포인터는 러스트에서 빈번하게 사용되는 디자인 패턴으로, 이제부터 자세히 알아보겠습니다.

힙의 데이터를 가르킬 수 있는 Box<T>

가장 직관적인 스마트 포인터는 box입니다. 이제 박스라고 부르겠습니다.
박스는 Box<T>라고 작성됩니다. 박스는 스택이 아니라 힙에 데이터를 저장할 수 있게 해줍니다. 스택에 있는 것은 힙에 저장된 데이터를 가르키는 포인터입니다.

박스는 성능 오버헤드 단점을 가지지 않습니다.
보통 아래의 상황에서 많이 사용됩니다.

  • 컴파일 타임에 알지 못하는 데이터 크기를 다뤄야 할 때
  • 큰 데이터를 복사하지 않고 오너쉽을 변경하고 싶을 때: 소유권을 이전할 떄 데이터가 스택에 복사되기 때문에 많은 시간이 필요로 할 수 있습니다. 성능 향상을 위해 힙에 있는 박스에 해당 데이터를 저장하고 작은 양의 포인터 데이터만 스택에 복사하여 데이터 레퍼런스는 힙에 그대로 있게 할 수 있습니다.
  • 특정 값을 소유하고 특정 타입이 되는 것이 아닌 특정 trait을 구현하는 타입이라는 것만 신경쓸 때 : trait object라는 것을 통해 자세히 알아보겠습니다.

힙에 데이터를 저장해보자.

fn main() {
  let b = Box::new(5);
  println!("b = {}", b);
}

위 코드에서 변수 b가 5라는 값을 가르키고 있는 박스를 가질 수 있게 합니다. 이 박스의 데이터는 힙에 저장되어있습니다. 코드를 실행시켜보면 b =5 가 출력되는데, 박스에 있는 데이터를 스택에 있는 데이터에 접근하듯이 접근할 수 있습니다. 다른 소유된 값들과 동일하게, main 함수가 끝나는 것 같이 박스가 스코프를 벗어나면 메모리 할당해제됩니다. 이 해제는 스택에 저장된 박스와 박스가 가르키는 힙에 저장된 데이터 모두를 할당 해제합니다.

박스의 재귀적 타입

recursive type 의 값은 다른 동일한 타입을 자신의 일부로 가질 수 있습니다. 재귀 타입은 컴파일 타임에 정확한 사이즈를 알기 어렵게 해 문제가 되는데, 이론적으로 무한한 재귀적 구조가 반복될 수 있기에 러스트는 얼마나 큰 데이터 구조인지 파악할 수 없습니다. 하지만 박스는 정의된 사이즈가 있기 때문에 재귀적 타입으로 구성할 수 있습니다.

recursive type의 예제로 cons list에 대해 살펴보겠습니다. 이 타입은 함수형 프로그래밍 언어에서 흔히 찾아볼 수 있는 타입입니다.

Cons List

데이터 구조중 한가지의 종류로 리스프라는 프로그래밍 언어에서 파생되었습니다. 여기서 cons는 constructor function에서 유래된 이름입니다. 리스프는 cons 함수를 호출할 때 특정 값과 다른 cons list를 인자로 받아들여 recursive한 pair를 구성합니다.
예를 들면 아래와 같은 pair가 cons list입니다.

(1, (2, (3, Nul)))

마지막 아이템은 Nil을 가지고 있으며 base case입니다. const list는 cons 함수를 재귀적으로 호출함으로서 만들어집니다. null과 nil은 다릅니다.

const list는 러스트에서 흔히 사용되는 타입은 아닙니다. 러스트에서 리스트 형태의 데이터를 다룰 때는 Vec<T>가 더 나은 선택입니다.

다음 코드에서 cons list를 정의했습니다. 이 코드는 List 타입의 정확한 크기를 모르기 때문에 컴파일 되지 않습니다.

enum List {
  Cons(i32, List)
  Nil,
}

아래 코드에서 Cons value는 1과 다른 List를 다른 값으로 가지고 있습니다. List값은 다른 Cons value로 이 Cons는 2와 다른 List를 가지고 있습니다. Nil을 만난다면, list의 마지막을 만났다고 봅니다.

use crate::List::{Cons, Nil};

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


```rust
$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^ recursive type has infinite size
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing drop-check constraints for `List`
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing drop-check constraints for `List` again
  = note: cycle used when computing dropck types for `Canonical { max_universe: U0, variables: [], value: ParamEnvAnd { param_env: ParamEnv { caller_bounds: [], reveal: UserFacing, constness: NotConst }, value: List } }`

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` due to 2 previous errors

컴파일 에러가 말하는 것은 type List가 무한한 size를 가지고 있기 때문에 발생한 것입니다. List의 variant 가 recursive 하기 때문에 러스트가 구체적으로 얼마나 많은 공간을 필요로 하는지 알지 못하는 것입니다.

재귀적이지 않은 타입을 저장하는데 얼마의 사이즈가 필요할까?

재귀적이지 않은 타입을 영어로 Non-Recursive Type이라고 합니다. 아래는 Message enum입니다.

enum Message {
  Quit,
  Move { x: i32, y: i32},
  Write(String), 
  ChangeColor(i32, i32, i32),
}

Message enum에 얼마나 큰 사이즈가 필요한지 알기 위해서는 각 variant의 사이즈를 계산해야 합니다. 러스트가 본 Message:Quit은 아무런 사이즈가 필요로 하지 않으며, Message:Move는 두 i32 값을 가지기 위한 공간을 필요로 합니다. Message enum은 한번에 최대 한개의 variant를 사용할 것이기 때문에 결국이 varinat 중 가장 큰 사이즈를 기준으로 이 Enum이 필요한 공간 크기가 결정됩니다.

러스트가 recursive type을 다룰 때 필요한 공간을 계산하는 것을 이전 계산과 비교해보세요. 컴파일러는 Cons variant를 살펴보기 시작하는데 i32와 List 타입을 가지고 있습니다. 따라서 Cons는 i32 + List 타입의크기와 동일한 사이즈를 필요로 합니다. 여기서 List 타입이 필요한 메모리 공간을 알기위해서 컴파일러는 varinat를 살펴보고 Cons variant부터 살펴보기 시작합니다. 이러한 과정이 반복되면서 아래와 같은 그림을 그려볼 수 있습니다.

러스트 컴파일러가 이러한 과정을 반복하면서 공간을 계산할 수는 없기에 컴파일러는 아래와 같은 제안을 에러 메세지에 보여줍니다.

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to make `List` representable
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

Using Box<T> to Get a Recursive Type with a Known Size

앞선 에러문구에서 indirection 이라는 말이 등장했는데 값을 직접 저장하는 것이 아니라 다른 데이터 구조를 사용해서 포인터를 값 대신 저장하는 것을 의미합니다.
즉 여기서 insert some indirection은 포인터를 사용하라는 말입니다.

Box<T>가 포인터이기 때문에, 러스트는 Box<T>가 필요한 사이즈를 알 수 있습니다. 포인터의 사이즈는 포인터가 가르키는 데이터의 사이즈에 비례해서 커지거나 작아지지 않습니다. 이 뜻은 Box<T> 를 Cons의 variant로 List의 값 대신에 집어 넣을 수 있다는 말로 이어집니다.

이렇게 되면 개념적으로 결국 어떤 모양이 될까요? 개념적으로는 list를 포함하는 다른 list를 가진다는 점에서 이전의 Cons와 다를 바가 없지만, 이제는 포인터를 사용함으로써 아이템들을 아이템의 내부에 담는게 아니라 다른 장소에 담는 것으로 바꿀 수 있습니다.

List enum의 정의를 변경해봅시다.

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

use crate::List::{Cons, Nil};f

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

Cons variant는 이제 i32 타입과 Box 타입을 가지고 있습니다. Nil variant는 아무런 값을 가지고 있지 않기 때문에 Cons variant와 비교해서 더 작은 값을 가지게 됩니다. 박스를 사용함으로 컴파일러가 List value 사이즈를 계산할 수 되었습니다.

Box 는 오직 indirection과 힙 할당만 제공하고 다른 스마트포인터 타입들처럼 특별한 기능을 제공하지 않습니다. 특별한 기능을 가지고 있지 않기에 다른 오버헤드도 가지고 있지 않으며 앞선 예제의 cons list를 사용하는 것과 같은 상황에서 유용하게 사용할 수 있습니다.

Box<T> 타입은 Deref trait을 구현했기 때문에 스마트 포인터입니다. 이 trait은 Box<T> 값을 레퍼런스같이 다룰 수 있게 해줍니다. Box<T> 값이 스코프를 벗어날 때 박스가 가르키는 힙 데이터가 Drop trait 구현 덕분에 자동으로 할당해제 할 수 있게 합니다. Deref, Drop 이 두 trait은 추가 기능을 제공하는 다른 스마트 포인터 타입에게 더 중요합니다.

이 두 trait에 대해 좀 더 자세히 알아보겠습니다.

Deref Trait

Deref trait은 역참조 (dereference) 연산자 *의 동작을 커스터마이징 할 수 있게 합니다. Deref를 구현함으로 인해 스마트 포인터를 일반 레퍼런스와 같이 다룰 수 있으며 r레퍼런스를 사용할 때 사용했던 연산자들을 스마트 포인터를 다룰 때 동일하게 사용할 수 있습니다.

먼저 레퍼런스를 다룰 때 사용했던 역참조 연산자를 살펴보겠습니다.

포인터에서 값까지

일반 레퍼런스는 포인터 타입인데 포인터는 다른 장소에 저장되어있는 값을 가르키는 역할을 합니다.
아래에서 i32 값에 대한 레퍼런스를 만들고 역참조 연산자를 사용해 값에 대한 참조를 따르게 했습니다.

fn main() {
  let x = 5;
  let y = &x;
  
  assert_eq!(5, x);
  assert_eq!(5, *y);
}

변수 x는 i32 값 5를 가지고 있습니다. y 는 x에 대한 레퍼런스를 가지게 했습니다. x와 5는 쉽게 비교할 수 있는데 y 안에 있는 값을 비교하기 위해서는 *y를 사용했습니다. 이것은 레퍼런스가 가지고 있는 값을 역참조하여 컴파일러가 실제 값을 비교할 수 있게 하기 위해서입니다. Y에 대해 역참조를 하게 되면 y가 가르키는 값에 접근할 수 있게 됩니다.

역참조를 통해 y가 가르키는 값에 접근하다.

만약 assert_eq!(5, y)와 같이레퍼런스와 비교하게 되면 에러가 발생하게 됩니다.

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = help: the following other types implement trait `PartialEq<Rhs>`:
            f32
            f64
            i128
            i16
            i32
            i64
            i8
            isize
          and 6 others
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

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

박스를 레퍼런스 대신 사용하기

위의 코드를 레퍼런스 대신 박스를 사용해서 구현해봅시다. 박스 타입을 대상으로도 역참조를 할 수 있습니다.

fn main() {
  let x = 5;
  let y = Box::new(x);
  
  assert_eq!(5, y);
  assert_eq!(5, *y);
}

큰 변경은 참조 연산자를 사용하지 않고 y에 패턴 바인등을 할 때 Box의 인스턴스를 새로 생성했다는 것입니다. 이 인스턴스는 x의 복사된 값가르키고 있습니다. 즉, x의 값을 가르키는게 아니라 x의 복사된 값을 가르키는 것입니다. 차이점을 아시겠나요?

우리만의 스마트 포인터를 만들어보자.

박스와 비슷한 스마트 포인터를 만들어봅시다.

struct MyBox<T>(T);

impl<T> MyBox<T> {
  fn new(x: T) -> MyBox<T> {
    MyBox(x)
  }
}

Box<T> 타입은 궁극적으로는 한 가지의 엘리먼트만 가진 튜플 struct같이 만들어졌습니다.
따라서 MyBox도 비슷하게 만들었습니다. new 메소드도 만들었습니다.

MyBox를 선언하고 제너릭 T를 파라메터 타입으로 정의했습니다. MyBox::new 함수는 임의의 타입 T를 인자로 받아들일 수 있을 것입니다.

fn main() {
  let x = 5;
  let y = MyBox::new(x);
  
  assert_eq!(5, x);
  assert_eq!(5, *y);
}

잘 된 것 같지만 아쉽게도 에러가 발생합니다.

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

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

MyBox 타입은 역참조를 할 수 있는 기능을 구현하지 않았기 때문에 그렇습니다.
역참조 연산자를 사용하기 위해서는 Deref trait을 사용해야 합니다.

표준 라이브러리가 제공하는 Deref trait은 deref 구현을 요구합니다. 이 메소드는 self를 borrow하고 inner data를 반환해야 합니다. 아직 무슨 말인지 이해가 잘 안되지만 코드를 먼저 보겠습니다.

use std::ops::Deref; 

impl<T> Deref for MyBox<T> {
  type Target = T;
  
  fn deref(&self) -> &Self::Target {
    &self.0
  }
}

type Target = TDeref trait이 사용하는associated Type 이라고 말합니다. associated type은 제너릭 파리메터를 선언하는 것과 약간 다른데 나중에 자세히 알아보겠습니다.

deref 메소드를 보면 $self.0을 반환하는데 이는 값의 레퍼런스를 반환하는 것이기 때문에 역참조 연산 사용을 가능하게 합니다. .0 접근은 튜플 struct의 첫번째 값에 접근한다고 튜플 구조에서 공부한바가 있습니다.

Deref trait을 구현하면, 컴파일러는 deref를 호출해 & 레퍼런스 심볼을 가져올 수 있게되고 따라서 역참조시에도 에러를 발생하지 않습니다.

다시 앞서 만들었던 마이박스 예제를 가져와서

fn main() {
  let x = 5;
  let y = MyBox::new(x);
  
  assert_eq!(5, x);
  assert_eq!(5, *y);
}

y를 하는 부분을 보면 수면 밑에서 실제로 러스트가 코드를 동작할 때 다음처럼 작동하게 됩니다.
`
(y.deref())`

역참조 연산자를 deref 메소드와 역참조 연산자로 대체하기 때문에 우리가 명시적으로 deref 메소드를 호출해줄 필요가 없습니다. 따라서 Deref trait을 구현하는 것으로 일반 레퍼런스를 다루는 것과 동일한 기능을 제공하게 되는 것입니다.

implicit Deref Coercions

Deref coercionDeref trait을 구현한 타입의 레퍼런스를 다른 타입의 레퍼런스로 변경합니다. 예를 들어 &String&str로 변경하는 것입니다.String이 Deref trait을 구현했기 때문에 &str을 반환하는 것입니다. Deref coercion은 함수와 메소드의 인자에 자동으로 적용되는 편리한 기능이며 Deref trait을 구현한 타입에 한해서만 동작합니다. 함수, 메소드 파라메터 타입과 일치하지 않는 인수가 주어질 때 자동으로 변경합니다.

fn hello(name: &str) {
  println!("Hello, {name}!");
}

우리는 hello 함수 호출 시 string slice 타입을 인자로 전달할 수 있습니다. 예를 들면 hello("Rust")와 같은 코드입니다.

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

위 예제에서 hello 함수를 &m 인자와 호출했는데 &m은MyBox<String> 값에 대한 레퍼런스입니다. Deref trait을 Mybox<T>에 구현했기 때문에 러스트는 deref 메소드를 호출함으로 &MyBox<String>을 &String 으로 변경할 수 있습니다.

만약 MyBox가 deref 메소드를 호출하지 않았다면 어떨까요

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

(*m)은 MyBox<String>을 String으로 역참조합니다. &와 [..]는 String 타입을 string slice 타입으로 변경하는 것입니다. 이런 코드는 읽기도 힘들고 쓰기도 어렵습니다.

글을 마치며

글이 길어짐에 따라 스마트 포인터는 두 부로 나누어 포스팅하겠습니다.
수고 많으셨습니다:)

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글