본문 바로가기

Rust Programming

[Rust] 3. Ticket v1: Encapsulation, Ownership, Setters, Stack

앞에서 modules와 visibility에 대해 기본적인 이해는 했을 것이다.

다시 encapsulation으로 돌아와보자.

encapsulation은 object의 내부적인 것들을 숨기는 행위다.

보통 object의 상태를 바꾸지 못하게 하는 것에 사용된다.

 

Ticket struct로 돌아가보자.

struct Ticket {
    title: String,
    description: String,
    status: String,
}

 

만약 모든 filed가 public으로 만들어졌다면, 그것은 encapsulation이 아니다.

 

엄격한 룰을 적용하려면, 개발자는 fileds를 private으로 설정해야한다.

그리고 public methods를 사용자에게 제공하여 제한된 사용을 하도록 해야한다.

그러한 public methods가 fileds에 대한 특정한 rule을 적용할 수 있다.

 

만약, 모든 filed가 private이라면, Ticket instance를 struct instantiation syntax를 통해 직접 만들 수 없다.

// This won't work!
let ticket = Ticket {
    title: "Build a ticket system".into(),
    description: "Create a system that can manage tickets across a Kanban board".into(),
    status: "Open".into()
};

 

이것이 동작되지 않는 것은 이전 exercise에서 확인할 수 있었을 것이다.

우리는 직접 만들 수 없기에 public constructors가 필요하지만, 운이 좋게도

이전에 Ticket::new와 같은 method가 constructor로서의 기능을 한다.

 


 

  • 모든 Ticket fileds는 private이다.
  • public constructor(Ticket::new)는 validation rules를 creation 과정에 적용시킬 수 있다.

이제 creating하는 과정은 이해했다.

그럼 interacing하는 과정은 어떨까?

모든 field가 private이라면 어떻게 접근해야할까?

 

그래서 accessor methods가 필요하다.

accessor methods는 struct의 private fields의 값을 읽게 해주는 public methods다.

 

Rust는 built-in generate accessor methods가 없다.

따라서, 개발자가 직접 만들어야한다.

 


05_encapsulation exercise는 getter기능을 구현하는 것이었다.

그냥 public methods 3개를 만들면 되는 부분이었으며,

이전에 배운 struct method 생성 시 parameter로 self를 넣는 것을 사용했다.

 

 


 

위 exercise를 풀었다면 아래와 같은 코드를 사용했을 것이다.(진짜 똑같다)

impl Ticket {
    pub fn title(self) -> String {
        self.title
    }

    pub fn description(self) -> String {
        self.description
    }

    pub fn status(self) -> String {
        self.status
    }
}

 

이 method들은 컴파일되고 test에서도 pass한다.

하지만, real-world scenario에서는 다르다.

 

아래를 보자.

if ticket.status() == "To-Do" {
    // We haven't covered the `println!` macro yet,
    // but for now it's enough to know that it prints 
    // a (templated) message to the console
    println!("Your next task is: {}", ticket.title());
}

 

만약 위 코드를 컴파일하려하면, 아래와 같은 에러가 뜰 것이다.

error[E0382]: use of moved value: `ticket`
  --> src/main.rs:30:43
   |
25 |     let ticket = Ticket::new(/* */);
   |         ------ move occurs because `ticket` has type `Ticket`, 
   |                which does not implement the `Copy` trait
26 |     if ticket.status() == "To-Do" {
   |               -------- `ticket` moved due to this method call
...
30 |         println!("Your next task is: {}", ticket.title());
   |                                           ^^^^^^ value used here after move
   |
note: `Ticket::status` takes ownership of the receiver `self`, which moves `ticket`
  --> src/main.rs:12:23
   |
12 |         pub fn status(self) -> String {
   |                       ^^^^

 

축하한다! 이것이 첫번째, borrow-checker error다.

 


 

Rust의 ownership은 다음과 같은 것들을 보장하려고 디자인된 system이다.

  • Data는 읽는 도중에 절대 변하지 않는다.
  • Data는 변하는 도중에 절대 읽히지 않는다.
  • Data는 파괴된 후, 절대 접근되지 않는다.

이 제약들은 borrow checker에 의해 행해진다.

borrow checker는 Rust compiler의 subsystem중 하나다.

 

Ownership은 Rust의 주요 개념이며, Rust가 특별한 이유다.

Ownership은 Rust에게 performance 저하 없이 memory safety를 보장해준다.

아래 사항들은 모두 Rust에게 적용되는 것이다.

  1. runtime garbage collector가 없다.
  2. 개발자로서, memory를 직접 관리하지 않아도 된다.
  3. dangling pointers, double frees 혹은 다른 memory관련 bugs가 일어날 수 없다.

Python, JS, Java는 2와 3을 제공하지만 1은 제공하지 않는다.

C나 C++은 1을 제공하지만, 2나 3은 제공하지 않는다.

 

글쓴이는 3에 대한 배경지식이 없다면 "저게 뭔데?" 라고 생각할 수 있다고 한다.

하지만 나는 시스템프로그래밍, 운영체제 시간에 저런 개념에 대해 배웠으며

보안상의 문제나, 프로그램의 오작동 문제가 발생할 수 있다는 것을 안다.

이후에 글쓴이가 3에 대해 다룬다고 하는데 복습하는 느낌으로 읽으면 될 것 같다.

 

이제, Rust의 ownership system이 어떻게 작동하는지 배워보자.

 


 

Rust에서 각 value는 owner를 갖고 있다.

compile-time에 statically determined되는 것이다.

어떤 시점에든 각 value에는 오직 하나의 owner가 있다.

 

하지만, ownership은 이전될 수 있다.

만약 특정 변수가 어떤 값을 갖고 있다면,

아래와 같이 다른 변수에게 ownership을 이전할 수 있다.

let a = "hello, world".to_string(); // <--- `a` is the owner of the String
let b = a;  // <--- `b` is now the owner of the String

 

Rust의 ownership system은 type system에 내장되어 있다.

 

각 함수는 인자와 상호작용하는 방법을 선언해야한다.

지금까지, 모든 methods와 functions은 ownership을 arguments로 가져와서 사용했다.

impl Ticket {
    pub fn description(self) -> String {
        self.description
    }
}

 

Ticket::description은 호출한 Ticket instance의 ownership을 가져간다.

이것을 move semantics라 부른다.

값(self)의 ownership이 caller로부터 callee로 이동한 것이다.

그리고 caller는 더이상 그것을 사용할 수 없다.

이게 앞서 compile error가 난 이유다.

error[E0382]: use of moved value: `ticket`
  --> src/main.rs:30:43
   |
25 |     let ticket = Ticket::new(/* */);
   |         ------ move occurs because `ticket` has type `Ticket`, 
   |                which does not implement the `Copy` trait
26 |     if ticket.status() == "To-Do" {
   |               -------- `ticket` moved due to this method call
...
30 |         println!("Your next task is: {}", ticket.title());
   |                                           ^^^^^^ value used here after move
   |
note: `Ticket::status` takes ownership of the receiver `self`, which moves `ticket`
  --> src/main.rs:12:23
   |
12 |         pub fn status(self) -> String {
   |                       ^^^^

 

특히, 아래는 ticket.status()를 호출할 때, 펼쳐지는 일련의 events다. 

  • Ticket::status는 Ticket instance의 ownership을 가져간다.
  • Ticket::status는 self로부터 status를 추출하고 status의 ownership을 다시 caller로 이전한다.
  • Ticket instance의 나머지는 버려진다.(titile과 description)

ticket.title()을 통해 ticket을 사용하려한다면,

compiler는 ticket value가 사라졌다고 할 것이다.

 

유용한 accessor methods를 만들기 위해, 우리는 references를 사용할 필요가 있다.

 


 

ownership을 가져가지 않고, 값을 읽을 수 있는 methods를 만드는 것이 바람직하다.

다른 Programming에서는 꽤 제한적일 수 있지만,

Rust에서는 borrowing을 통해 가능하다.

 

값을 borrow할 때마다, 그것에 대한 reference를 얻는다.

References는 privileges에 따라, tag되어 있다.

  • Immutable references(&)는 값을 읽을 순 있지만, 변경할 순 없다.
  • Mutable references(&mut)는 값을 읽을 수 있고, 변경할 수 있다.

Rust ownership system의 목표로 돌아가자.

  • Data는 읽는 도중 변경되어서는 안된다.
  • Data는 변경되는 도중 읽혀서는 안된다.

위 두 특성을 보장하기위해 Rust는 references에 대한 몇가지 restrictions를 도입했다.

  • 동시에 같은 값에 대한 mutable reference(&mut)와 immutable reference(&)를 가질 수 없다.
  • 동시에 같은 값에 대한 둘 이상의 mutable reference(&mut)를 가질 수 없다.
  • 값의 owner는 borrow되는 도중에 값을 변경할 수 없다.
  • 값에 대한 mutable reference(&mut)가 없는 한, immutable references(&)는 원하는 만큼 가질 수 있다.

정리하자면, immutable reference(&)는 값에 대한 "read-only" lock이고,

반면, mutable reference(&mut)는 "read-write" lock이다.

 

이 모든 제약들은 borrow checker에 의해 compile-time에 행해진다.

 


 

실제로 값을 어떻게 borrow할 수 있을까?

&나 &mut를 변수 앞에 첨가함으로써, 값을 borrowing할 수 있다.

 

여기서 주의해야할 것이 있다!

 

&나 &mut를 type 앞에 첨가하는 것은 다른 의미다.

original type의 reference로 사용하려 할 때, 나타내는 다른 type이다.

 

struct Configuration {
    version: u32,
    active: bool,
}

fn main() {
    let config = Configuration {
        version: 1,
        active: true,
    };
    // `b` is a reference to the `version` field of `config`.
    // The type of `b` is `&u32`, since it contains a reference to a `u32` value.
    // We create a reference by borrowing `config.version`, using the `&` operator.
    // Same symbol (`&`), different meaning depending on the context!
    let b: &u32 = &config.version;
    //     ^ The type annotation is not necessary, 
    //       it's just there to clarify what's going on
}

 

b는 config의 version field에 대한 reference다.

b의 type은 &u32다.

 

function arguments와 return types에 대해서도 같은 개념이 적용된다.

// `f` takes a mutable reference to a `u32` as an argument, 
// bound to the name `number`
fn f(number: &mut u32) -> &u32 {
    // [...]
}

 

참조를 저장하려면 변수의 타입도 참조 타입이어야한다.

error[E0308]: mismatched types
 --> src/main.rs:2:18
  |
2 |     let y: i32 = &x;
  |                  ^^ expected `i32`, found `&i32`
  |
  = note: expected type `i32`
             found type `&i32`

 

따라서 위와 같이 쓰면 compile error가 난다.

 

Rust의 ownership system은 처음엔 이해하기 어려울 수 있다고 글쓴이는 말한다.

하지만, 연습을 계속 한다면 자연스럽게 익숙해진다고 한다.

이번 chapter 이후에도 많은 practice를 수행할 것이고 그 때, 익숙해지면 된다고 한다.

해당 개념이 나올 때마다, 다시 remind를 중간중간 시켜줄테니 걱정말란다.

 

Compile error가 날 때마다, 힘들어하지말고 배울 기회라 생각하고 기꺼이 받아들이라 하는데

새로운 시각으로 compile error를 바라봐서 신기하면서도 이래야 개발자 하는구나~~라고 생각했다.

 


06_ownership exercise는 getter 기능에 borrow를 사용하는 것이었다.

parameter, return type, return value 모두에 reference를 넣어주면 해결된다.

 

 


 

위 exercise에서 만든 accessor methods는 아래와 같을 것이다.

impl Ticket {
    pub fn title(&self) -> &String {
        &self.title
    }

    pub fn description(&self) -> &String {
        &self.description
    }

    pub fn status(&self) -> &String {
        &self.status
    }
}

 

Ticket instance를 consuming하지않고 Ticket instance의 field에 접근할 수 있는 방법이 생겼다.

 

setter methods를 통해 Ticket struct를 향상시켜보자.

 


 

Setter methods는 fields에 대한 규칙을 지키면서

Ticket의 private fields의 값들을 users가 변경할 수 있도록 하는 methods다.

 

Rust에서 setters를 구현하는 것에는 두가지 방법이 있다.

  • self를 input으로 가져가는 것
  • &mut self를 input으로 가져가는 것

 

첫번째 접근은 다음과 같다.

impl Ticket {
    pub fn set_title(mut self, new_title: String) -> Self {
        // Validate the new title [...]
        self.title = new_title;
        self
    }
}

 

self의 ownership을 가져가고 title을 바꾼다.

그리고 수정된 Ticket instance를 return한다.

 

호출할 때는 다음과 같이 호출할 수 있다.

let ticket = Ticket::new("Title".into(), "Description".into(), "To-Do".into());
let ticket = ticket.set_title("New title".into());

 

set_title은 self의 ownership을 가져가기 때문에,

개발자는 결과를 변수에 다시 할당할 필요가 있다.

위의 예시는 변수 이름을 다시 사용하는 variable shadowing의 장점을 가질 수 있다.

이는 개발자가 새로운 변수를 이미 존재하는 이름으로 선언할 때,

새로운 변수가 이미 존재하는 변수를 shadow한다고 한다.

 

self-setters는 여러 fields를 한번에 바꿀 필요가 있을 때, 꽤 좋다.

다음과 같이 연쇄적으로 호출할 수 있다.

let ticket = ticket
    .set_title("New title".into())
    .set_description("New description".into())
    .set_status("In Progress".into());

 


 

두번째 접근은 다음과 같다.

impl Ticket {
    pub fn set_title(&mut self, new_title: String) {
        // Validate the new title [...]
        
        self.title = new_title;
    }
}

 

method는 self의 mutable reference를 input으로 갖고,

title을 바꾸고 끝이다.

아무 return도 없다.

 

그리고 아래와 같이 사용할 수 있다.

let mut ticket = Ticket::new("Title".into(), "Description".into(), "To-Do".into());
ticket.set_title("New title".into());

// Use the modified ticket

 

Ownership은 caller에 여전히 머무므로, original ticket variable은 여전히 유효하다.

그래서 결과를 다시 할당할 필요가 없다.

대신, ticket을 mut keyword로 mutable하다고 mark해야한다.

왜냐하면, mutable reference를 사용할 것이기 때문이다.

 

&mut-setters는 다음과 같은 단점이 있다.

여러개의 호출을 연쇄적으로 할 수 없다.

수정된 Ticket instance를 return하지 않기 때문에,

다른 setter를 이전의 결과의 input으로 넣을 수 없다.

따라서, 하나하나 직접 호출해야한다.

ticket.set_title("New title".into());
ticket.set_description("New description".into());
ticket.set_status("In Progress".into());

 

위에서 설명되지 않은 부분인 into()와 method chaining에 대해 알아봤다.

 

into()는 type 변환을 위해 사용되며, 위에서 사용된 into()는

문자열 슬라이스(&str) type을 String type으로 변환하기 위해 사용됐다.

아마 type 변환에 대해 많이 배우지 않아, 글쓴이는 넘어간 것 같다.

 

method chaining은 pipeline과 유사한 역할을 한다.

Rust에서는 method chaining을 통해 객체의 여러 method를 연속적으로 호출할 수 있다.

이 과정에서 method는 자기 자신을 반환하므로, 연쇄적인 method 호출이 가능하다.

즉, 첫번째 method로부터 마지막 method까지 self를 input과 return으로 각각 사용하여

마지막으로 return된 것은 모든 methods의 과정이 반영된 객체다.


07_setters exercise를 수행했다.

&mut-setters 방법으로 setter를 구현하는 것이었고

주어진 조건대로 잘 구현했다.

 


 

ownership과 references에 대해 작동하는 것을 중점으로 봤다.

되는 것과 안되는 것을 구분하는 정도는 이제 알 수 있다.

이제 더 아래쪽으로 들어가, memory에 대해 얘기해보자.

 

 memory에 대해 얘기할 때, stack과 heap에 대해 얘기하는 것을 들었을 것이다.

이것들은 프로그램의 data를 저장할 때, 사용되는 서로 다른 memory 영역이다.

 

stack부터 시작해보자.

 


 

stack은 LIFO(Last In, First Out) 자료 구조다.

함수를 호출할 때, 새로운 stack frame이 stack의 맨 위에 추가된다.

해당 stack frame은 function의 arguments와 local variables와 몇몇의 "bookkeeping" values를 저장한다.

 

function이 return될 때, stack frame은 stack에서부터 pop된다.

                                 +-----------------+
                       func2     | frame for func2 |   func2
+-----------------+  is called   +-----------------+  returns   +-----------------+
| frame for func1 | -----------> | frame for func1 | ---------> | frame for func1 |
+-----------------+              +-----------------+            +-----------------+

 

실행적인 관점에서 stack allocation과 de-allocation은 매우 빠르다.

우린 항상 stack의 제일 위로부터 data를 넣고 뺀다. 

그래서 우리는 free memory를 찾을 필요가 없다.

또한, fragmentation에 대한 걱정도 할 필요 없다.

왜냐하면, stack은 일련의 연속된 memory block이기 때문이다.

 


 

Rust는 stack에 자주 data를 할당할 것이다.

function의 u32 type의 input argument를 갖고 있다면, 해당 32 bits는 stack에 있을 것이다.

i64 type의 local variable을 정의한다면, 해당 64 bits는 stack에 있을 것이다.

compile time에 해당 integers에 대해 알 수 있으므로,

compiled program은 stack에 공간을 예약하여 사용할 수 있다.

 


 

당신은 std::mem::size_of function을 사용해

특정 type이 stack 영역의 어느정도를 차지하는지 알 수 있다.

 

u8 type을 예시로 보자.

// We'll explain this funny-looking syntax (`::<u8>`) later on.
// Ignore it for now.
assert_eq!(std::mem::size_of::<u8>(), 1);

 

당연히 1이다.

왜냐하면, u8은 8bits고 1byte이기 때문이다.

 


08_stack exercise는 각 type의 size를 확인하는 것이었다.

각 type에 알맞는 size를 두번째 인자로 넘겨주면 test에 통과한다.

 

 


 

Rust의 compiler는 신기하다.

system programming에서 배운 low level적인 내용이 많다.

compile 단계에서 모두 처리해주니 보안상으로나 시스템적으로나

안정적일 수 밖에 없다는 생각이 들었다.