비욘드 JS: 러스트 - Structure

dante Yoon·2022년 12월 21일
1

beyond js

목록 보기
5/20
post-thumbnail

동영상 강의로 보기

글을 시작하며

안녕하세요, 단테입니다.
오늘으 Structs에 대해 알아보겠습니다.
자바의 클래스와 같은 역할을 합니다.

Tuple과 유사하다.

Struct는 Tuple과 명백히 다른 타입이나 유사한 점이 있습니다.
유사한 점은 tuple이 배열과 다르게 다른 타입의 요소들을 가질 수 있는 것과 같이
Struct또한 다른 타입의 요소들로 이뤄질 수 있다는 것입니다.

Tuple과 다르다.

다른 점은 Tuple의 각 요소에는 이름을 붙일 필요가 없지만 Struct의 각 요소(데이터)에는 이름을 붙여야 합니다.
또한 순서에 구애받지 않고 데이터를 구성할 수 있습니다.

struct User {
	active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

struct Point {
	x: i32,
    y: i32,
}

let p = Point { x: 1, y: 2 };
println!("Point p is at ({}, {})", p.x, p.y);

Point struct에 두 필드 x, y를 선언했습니다. 이때 x,y의 타입은 i32로 지정했으며 Point 객체를 생성할 때 자바스크립트의 object literal 형식과 유사하게 선언했습니다.

그리고 각 필드를 콘솔에 출력하기 위해서 . dot notation을 통해 접근했습니다.

메소드 선언

러스트에서 Struct는 메소드를 가질 수 있습니다

Copy code
struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn distance_from_origin(&self) -> f64 {
        let x_squared = (self.x as f64).powi(2);
        let y_squared = (self.y as f64).powi(2);
        (x_squared + y_squared).sqrt()
    }
}

let p = Point { x: 3, y: 4 };

println!("Point p is {} units from the origin", p.distance_from_origin());

distance_from_origin 메소드는 Point객체의 두 필드를 가지고 원점(0,0)에서부터의 거리를 계산합니다.

&self parameter

앞서서 본 코드에 자바스크립트에서 한번도 본적 없는 파라메터가 등장했습니다. 마치 this와 비슷하다고 느낄 수도 있겠군요. this라는 말은 아닙니다.

distance_from_origin 메소드는 첫번째 파라메터로 &self를 사용하는데요, 여기서 &self는 객체 Point를 borrow한 것입니다. borrow 했기 때문에 distance_from_origin 스코프 내부에서 Point 객체를 참조하더라도 오너쉽이 변경되지 않죠.

러스트에서 &self는 해당 &self를 사용하는 메소드에서 &self reference가 가르키는 객체를 borrow 한 것임을 나타냅니다.

&self를 사용함으로 얻는 이점이 있습니다.

1. move (오너쉽을 넘기거나) copy 하지 않고도 여러 개의 메소드가 동일한 객체를 borrow하여 객체에 여러 가지 동작을 수행할 수 있습니다.

2. 객체를 borrowing 하는 것이 move, copy 하는 것보다 더 성능상 좋습니다.

3. 직접 소유하고 있지 않은 객체에 대해 메소드를 정의할 수 있습니다.

3번에 대해 좀 더 알아봅시다.

직접 소유하고 있지 않은 객체에 대해 메소드를 정의할 수 있습니다.는 무슨 의미일까요?
러스트에서 struct에 메소드를 선언할 때 &self를 사용한다면, 실제로 오너쉽을 가지고 있지 않은 객체에 메소드를 정의해줄 수 있습니다. 이것은 외부 라이브러리에서 제공하지 않은 기능을 확장해서 사용할 때 도움이 됩니다.

use std::vec::Vec;

impl Vec<i32> {
    fn sum(&self) -> i32 {
        self.iter().sum()
    }
}

let v = vec![1, 2, 3];

println!("The sum of the values in v is {}", v.sum());

위의 코드에서 Vec 타입에 sum이라는 메소드를 선언하고 싶습니다. 이 때 Vec의 iter().sum() 메소드를 사용해 자체적으로 sum이라는 메소드를 만들어냈습니다. 이렇게 직접적으로 Vec 타입에 대한 조작은 하지 못하지만 borrow한 &self reference를 사용해 새로운 메소드를 만들어낼 수 있습니다.

&self는 알겠어. 그럼 그냥 self는 ?

메서드가 호출된 객체를 move하고 있음을 나타내기 위해 self 키워드만(& 없이) 사용할 수 있습니다. 이는 객체의 소유권을 메서드로 이전하려는 경우나 객체를 소유(owner)하고 다시 사용하지 못하도록 방지하려는 경우에 유용할 수 있습니다.

Struct instance 만들기

not using shorthand

fn build_user(email: String, username: String) -> User {
    User {
        email: email,
        username: username,
        active: true,
        sign_in_count: 1,
    }
}

using shorthand

fn build_user(email: String, username: String) -> User {
    User {
        email,
        username,
        active: true,
        sign_in_count: 1,
    }
}

필드가 없는 Structs

다음처럼 필드가 없는 Structs 또한 만들 수 있습니다.
이런 structs를 unit-like structs라고 합니다.
심지어 만들 때 {}도 생략합니다.

struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

위의 코드는 중괄호나 소괄호를 사용하지 않았습니다.

잠깐 unit ?

러스트에서 unit value는 struct에서만 등장하는 단어가 아닙니다.

함수나 메소드가 유의미하지 않은 리턴 타입을 반환할 때도 사용합니다.

다음 함수 print_hello는 출력만 할 뿐 유의미한 값을 반환하지 않습니다.

fn print_hello() -> () {
    println!("Hello, world!");
}

또다른 예시로 패턴 매칭에 사용되는 코드를 보여드리면,

fn process_data(data: Vec<i32>) -> Result<(), String> {
    // Do some processing here...

    if data.is_empty() {
        return Err("Data is empty".to_string());
    }

    Ok(())  // Return the unit value if processing was successful
}

match process_data(vec![1, 2, 3]) {
    Ok(()) => println!("Processing successful"),
    Err(e) => println!("Error: {}", e),
}

process_data는 Vec<i32> 타입을 인수로 받아들이고 Ok, Err를 variants로 갖는 Enum 타입 Result를 반환합니다.

데이터가 비어있지 않으면 process_data는 Ok(()) 는 process_data의 반환값 Ok(())와 매칭하는데 사용됩니다.

unit like struct

다시 struct로 돌아와서 아무런 데이터를 가지고 있지 않은 struct를 unit like struct라고 한다고 앞서 설명했습니다.

특정 타입을 만족하는 trait을 만들고 싶은데 안에 아무런 데이터가 없어야 한다면 unit like struct 사용을 생각해볼 수 있습니다.

trait에 대해서는 나중에 다시 다루고 아래와 같은 코드가 있다는 것만 한번 보고 넘어가겠습니다.

struct AlwaysEqual;

fn main() {
  let subject = AlwaysEqual;
}

Struct Data Ownership

struct에서 특정 데이터의 오너쉽을 갖게 할 수 있습니다.
이를 위해서는 lifetime specifier(지시어) 가 필요합니다. Lifetime에 대해서는 나중에 더 자세히 알아보겠지만, struct에 의해 reference되는 데이터는 struct가 유지되는 한 유효하도록 보장하는 것이며, 명시해주지 않으면 안된다고만 알고 있겠습니다.

struct User {
  active: bool,
  username: &str,
  email: &str,
  sign_in_count: u64,
}

fn main() {
  let user1 = User {
     email: "someone@example.com",
     username: "someusername123",
     active: true,
     sign_in_count: 1,
  };
}

위와 같이 작성후 컴파일 하면 다음과 같은 에러가 발생합니다.

struct 사용 예시

variable을 사용해서 작성한 코드

variable만 사용한 코드와 struct를 사용한 코드를 비교해보겠습니다.

사각형의 넓이를 구하는 간단한 코드입니다.

fn main() {
  let width1 = 30;
  let height1 = 30;
  
  println!(
    "The area of the rectangle is {} square pixels.",
    area(width1, height1)
  );
}

fn area(width: u32, height: u32) -> u32 {
  width * height
}

하품이 나올정도로 간단한 코드입니다만, area 코드는 꼭 사각형을 구하는데 사용하지 않아도 되고 프로젝트 구조에 어디에 있어도 사용할 수 있을 정도로 응집도가 낮습니다.

tuple 을 사용해서 refactoring

fn main() {
  let rect1 = (30, 50);
  
  println!(
    "The area of the rectangle is {} square pixels.",
    area(rect1)
  );
}

fn area(dimensions: (u32, u32)) -> u32 {
  dimensions.0 * dimensions.1
}

dimensions라는 tuple 사용으로 인해 area에서 사용하는 데이터가 무엇인지 좀 더 명확하게 알게 되었습니다.
그리고 fn area는 width,height 두 개의 argument를 받는게 아니라 하나의 tuple만 받게 되었습니다.

근데 width는 0 index, height은 1 index라는 사실은 area 사용시에 항상 염두에 두고 사용해야 합니다.

struct

struct Rectangle {
  width: u32,
  height: u32,
}

fn main() {
  let rect1 = Rectangle {
    width: 30,
    height: 50,
  };
  
  println!(
    "The area of the rectangle is {} square pixels.",
    area(&rect1)
  );
}

fn area(rectangle: &Rectangle) -> u32 {
  rectangle.width * rectangle.height
}

Rectangle struct를 사용해 width, height를 필드를 가지는 객체를 만들었습니다.
이제 area 함수는 Rectangle에 대한 immutable borrow 타입인 rectangle를 가지고 width, height에 접근할 수 있게 되었습니다. 이제 area가 무슨 의미인지 fn area가 선언된 코드만 보아도 이해가 가능하게 되었습니다.

adding useful functionality with derived trait

아래와 같이 코드를 작성하고 컴파일 해봅시다.

struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect is {}", rect1);
}

언뜻 보기에 문제없어 보이지만 컴파일에 실패합니다.

error[E0277]: 'Rectangle' doesn't implement 'std::fmt::Display' 라는 에러가 나왔습니다. println! 으로 어떤 것을 출력해야 할지 모호하기 때문입니다.

컴파일 에러 로그를 보면 {:?}를 사용하라고 합니다. 해봅시다.

...
    println!("rect is {}", rect1);

다른 에러가 발생했습니다.

Debug가 구현되지 않아서 에러가 발생한 것 같습니다. struct에서는 다음과 같이 명시적으로 구현해주어야 합니다.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {:?}", rect1);
}

Method Syntax

메소드는 fn 키워드로 선언되는 함수와 유사하지만 struct 내부 context에서 선언된다는 점이 다릅니다. 또한 첫번째 인자로 항상 &self를 가집니다.

Defining method

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30, 
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    )
}

메소드 선언을 하기 위해서 먼저 impl Rectangle를 사용해 Rectangle 내부에 메소드를 만들었습니다.

impl block 내부에 있는 모든 것은 Rectangle의 타입과 관계됩니다.

impl 블럭 내부의 area 함수 내부를 보면 첫번째 파라메터로 self가 보입니다.

그리고 main 함수로 이동하면 println! 매크로 호출 시 두번째 argument로 struct의 메소드 호출이 . (dot notation)으로 이뤄진다는 것을 알 수 있습니다.

impl block

러스트에서 impl 키워드는 특정 타입의 trait나 메소드를 선언할 때 사용합니다.

trait은 나중에 배울 주제이지만 impl block을 설명하기 위해 한번 알아보겠습니다.

trait Display {
    fn display(& self) -> String;
}

#[derive(Debug)]
struct Point {
    x: f32,
    y: f32,
}

impl Display for Point {
    fn display(&self) -> String {
        format!("({}, s{}", self.x, self.y)
    }
}

fn main() {
    let point = Point {
        x: 10.0,
        y: 20.0
    };

    println!("{:#?}", point)
}

위에서 Display trait은 display라는 메소드를 가지고 있습니다.
impl Display for Point 라인은
Point struct는 Display trait으로 구현되었다는 뜻으로 display 메소드가 Point type에서 구현되어야 한다고 말하는 것입니다.

실제 메소드 구현은 imple 블럭 내부에서 되어야 하는 것이죠.

multiple imple block

하나의 struct는 여러 개의 imple 블럭을 가질 수 있습니다.

struct Point {
    x: f32,
    y: f32,
}

impl Point {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

impl Point {
    fn translate(&mut self, dx: f32, dy: f32) {
        self.x += dx;
        self.y += dy;
    }
}

impl Point {
    fn invert(&mut self) {
        self.x = -self.x;
        self.y = -self.y;
    }
}

fn main() {
    let mut p = Point { x: 3.0, y: 4.0 };
    let distance = p.distance_from_origin();
    
    p.translate(1.0, 2.0);
    p.invert();
}

위의 예시에서 Point struct는 세 개의 impl 블럭을 가지고 있습니다. 그리고 main 함수에서 r각각의 impl 블럭에서 선언한 모든 메서드를 자유롭게 사용함을 볼 수 있습니다.

Associated Functions

imple block 내부에서 정의되는 함수들은 모두 associated function 이라고 부릅니다.
associated function은 self를 첫번째 파라매터로 꼭 가지고 있지 않아도 되며 이렇게 선언되면 이 함수들은 메소드가 아닙니다.

이 associated function은 보통 constructor 에서 새로운 struct나 인스턴스를 반환할 때 사용됩니다.

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square (size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

위에서 반환 타입인 Self 키워드나 함수 내부에 선언된 Self는 impl 키워드가 가르키는 타입 그 자체를 의미합니다. 위의 예제 코드를 기반으로 말하면 Rectangle 이죠.

이 associated 함수를 호출하기 위해서는 :: 문법을 사용합니다. :: 문법은 associated functions를 호출할 때도 사용되지만 모듈의 namespace를 참조할 때도 사용합니다.

fn main() {
    let rectangle = Rectangle::square(32);
}

글을 마치며

struct는 구성하려고 하는 서비스 도메인에 맞는 응집력 높은 코드를 작성할 수 있게 도와줍니다.
감사합니다.

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

0개의 댓글