주니어 JS 개발자가 처음 배우는 Rust

이동창·2021년 10월 26일
0

Hello World

fn main() {
    println!("Hello, world!"); 
}

일단 러스트에서 main 함수는 가장 먼저 실행됨
또한 러스트에서 들여쓰기는 탭이 아닌 공백 4개

println! 는 러스트 매크로라고 한다. !가 붙어서 함수랑은 다름
또한 세미클론은 꼭 붙여줘야 함

또한, 러스트 프로그램은 rustc main.rs과 같이 컴파일러로 먼저 컴파일 해야 함
그 후 컴파일 된 바이너리 파일을 여는 거임

Cargo 란?

카고는 러스트 빌드 시스템이자 패키지 관리자임
라이브러리 다운로드 같은걸 다 해줌 (느낌이 npm이랑 비슷한 것 같음)

cargo new

새로운 프로젝트를 생성하는 명령어

  • src
  • Cargo.toml
  • .gitignore
    파일이 생성된다

Cargo.toml

카고의 설정 파일 형식임
중요한 것은 가장 밑에 적히는 [dependencies] 인데 의존 라이브러리 목록임
러스트에서 패키지는 크레이트(crate)라고 부름

cargo build

src에 작성한 코드를 빌드할 수 있음

  • Cargo.lock
  • target 폴더
    가 생기는데 Cargo.lock은 yarn.lock이랑 비슷한 느낌이고
    target 폴더는 빌드된 파일이 모여있음

단, build 변경 사항이 있을 때에만 새로 빌드한다.

cargo run, check

  • run 컴파일된 파일 실행
  • check 코드를 컴파일만 하고 실행 파일은 생성하지 않음

웬만해선 컴파일만 하고 싶을 땐 수시로 check를 통해 확인해주자

cargo build --release

최종적으로 프로젝트를 릴리즈하고 싶다면, 위의 명령어로 최적화된 실행 파일을 생성


첫 프로젝트

첫 코드 분석

use std::io;

fn main () {
    println!("숫자를 맞혀봅시다!");

    println!("정답이라고 생각하는 숫자를 입력하세요. ");

    let mut guess = String::new();
    io::stdin().read_line(&mut guess).expect("입력한 값을 읽지 못했습니다.");
    
    println!("입력한 값: {}", guess);
}

입출력을 위해 io(input/output) 라이브러리 가져온다
io 라이브러리는 std라는 표준 라이브러리에 포함되어 있음

let mut guess = String::new()를 보자
일단 기본적으로 러스트 변수는 immutable임
하지만 변경할 수 있는 변수를 생성하기 위해 mut키워드를 사용중

String::new() 는 String 타입의 새 인스턴스를 만드는 것
그 인스턴스를 guess 변수에 바인딩했다고 보면 됨 (new는 String의 메소드)


io::stdin().read_line(&mut guess).expect("입력~~.")

이제 io의 메소드인 stdin을 이용해 사용자의 입력값을 읽을 수 있다.
여기서 &mut guess는 참조인데, 데이터를 복사할 필요 없이 접근하게 해주는 기능
포인터 같은 개념 같은데, 4장에서 다시 다룬다고 하니 그때 다시 공부하자

expect는 에러 처리 메서드인데 안 쓰면 경고가 뜸
(에러를 꼼꼼히 다 처리하라는 러스트의 배려.. ㄷㄷ)

난수 생성

러스트에서 난수 생성 기능을 표준으로 제공하진 않는다.
다만 rand라는 라이브러리(앞으로는 크레이트라고 하자)을 제공함
크레이트를 사용하기 전에 Cargo.toml에서 dependencies를 수정해주자

난 약간 yarn add 에 익숙해져 있다보니, 뭔가 손으로 dependencies 쓰는게 귀찮아서
이 링크를 참고해서 cargo install 이용했다.

다만...

특정 라이브러리 버전이 필요할 때는 버전을 적어줘야 한다.
버전 적는 방식과 호환성은 솔리디티와 비슷한 듯
자세한 사용법은 여기에

문제 발생!

책에서는 rand 크레이트 0.6.1 버전을 사용하는데 cargo install로 설치하니
가장 최신 버전의 0.8.4가 설치되었다.
그래서 메소드의 인자 갯수가 달라 에러가 났는데, 이럴땐 라이브러리 보고 고쳐야 함

cargo doc --open

이럴땐 물론 구글링해서 찾아도 되지만, 터미널에서 위의 명령어를 치면
현재 프로젝트에서 쓰고 있는 라이브러리의 문서를 정리해서 웹페이지로 알아서 띄워준다.
구글링도 좋지만, 이 방법이 묶어서 한번에 보여주니 편하긴 한듯

최종코드

use std::io;
use rand::Rng;
use std::cmp::Ordering;

fn main () {
    let secret_number = rand::thread_rng().gen_range(1..101);
    let mut count = 0;

    loop {
        count += 1;
        println!("숫자를 맞춰보세요! ({}번째 시도)", count);

        let mut guess = String::new();
        io::stdin().read_line(&mut guess).expect("숫자를 입력하세요!");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("입력한 숫자가 작습니다! \n"),
            Ordering::Greater => println!("입력한 숫자가 큽니다! \n"),
            Ordering::Equal => {
                println!("정답!");
                break;
            },
        }
    }
}

책에서 몇 가지 feature를 좀 추가했다

기억할 만한 것

GitHub에 올릴 때 궁금해졌던 것이, Cargo.lock을 git에 올려야 하는 것인가였다.
찾아보니 여기서 잘 설명해주고 있었다.

요약하자면, 완성된 프로젝트 배포 같은 경우에는 Cargo.lock을 올려주고
업데이트가 꾸준히 필요한 라이브러리 같은 경우에는 올리지 않아도 된다.


일반 프로그래밍 개념

변수

let, const 키워드를 이용하며 두 키워드 모두 기본적으로 불변성을 갖고 있다.
다만 mut를 붙이거나 shadowing을 이용하면 값을 변경 가능하다.

let vs const

letconst는 둘다 불변성을 갖고 있지만,
constmut 키워드를 쓸 수 없고, 선언시 타입을 지정해줘야 한다.
또한, const 변수는 대문자와 밑줄_을 이용하는 것이 국룰이다.

ex)
const MAX_POINTS: u32 = 1000_000; 
/// 밑줄은 숫자 가독성 높일 때도 사용할 수 있음 (오?)

mut vs Shadowing

일단 Shadowing이 뭔지부터 보자

fn main() {
    let x = 5;
    let x = x + 1;
    let x = x * 2;
    println!("x is {}", x); // x is 12
}

x 값을 이용해, let으로 새로 선언한 x 위에 덮어쓰기를 하고 있는 모습이다.

그럼 mut이랑 뭐가 다른걸까?
일단 let 키워드로 새로 선언한다는 것도 다르고 무엇보다...

타입에 구애받지 않는다.
let spaces = "   ";
let spaces = spaces.len(); // 3

let mut spaces = "   ";
spaces = spaces.len(); // compile error!

데이터 타입

스칼라

러스트에서 다음 네 가지 종류의 스칼라 타입을 정의하고 있다.

  • integer
  • floating point number
  • Bolleans
  • charaters

먼저 정수 타입부터 보자

정수 타입은 u8, u16, u32, u64i8, i16, i32, i64가 있다.
저장할 수 있는 범위는 u는 0 ~ 2^(n)-1, i는 -2^(n-1) ~ 2^(n-1)-1이다.

u8은 0~255, u16은 0~65535, u32는 0~4294967295,
u64는 매우 큰 범위까지 커버 가능하다.

러스트는 기본적으로 u32 타입을 default로 사용하고, 웬만한 경우는 얘로 하자

그 다음은 소수점 타입

f32f64가 있는데 러스트는 f64를 기본으로 사용함

bolleans는 패스하고.. 문자열 같은 경우는

영어는 물론, 한국어와 일본어, 심지어는 이모티콘까지도 저장 가능하다.

컴파운드

컴파운드 타입은 여러 개 값의 그륩 타입이라고 보면 된다.
기본적으로 러스트에서 지원하는 컴파운드 타입은 튜플과 배열이다.

기본적으로 다른 언어와 비슷하지만,
길이가 정해져있고 삭제하거나 추가할 것이 거의 없지 않는 한
배열보다는 벡터를 쓰는 것이 좋다. (이는 추후에 또 알아보자)

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
    let (x, y, z) = tup;
    
    println!("x : {}", tup.0); // 500
    println!("y : {}", y); // 6.4
    
    let a: [i32; 3] = [1,2,3]; // 타입 지정은 이렇게
    let b = [3;5]; // [3,3,3,3,3]
}

참고로 배열에서 유효하지 않은 요소에 접근하면,
컴파일은 가능하지만 실행 시에 에러가 발생한다

함수

함수명은 스네이크 케이스 ( ex. process_mint)
함수 매겨변수는 타입을 꼭 명시해줘야 한다.

중요! 구문 vs 표현식

구문은 값의 리턴이 없고, 표현식은 최종 결과값으로 리턴이 있다.
함수의 리턴 값은 이름을 부여하진 않지만, 타입은 꼭 정해줘야 됨

fn main() {
    let y = 6; // 구문
    let x = (let y = 6); // let y = 6는 구문이라 return이 없어서 error
    let x = 5;
    
    // 코드 블록 또한 표현식이다. 즉 표현식과 리턴값이 있다는건데
    // 바로 x + 1 와 같이 세미클론이 붙지 않은 저 친구가 표현식임
    let y = {
        let x = 3;
        x + 1 // 세미클론 없는 것에 주목
    }
}

물론 return을 붙이면 다른 언어와 비슷하게 작성되나
러스트는 그냥 함수 마지막의 표현식 값을 함수의 리턴값으로 써도 무방하다.

즉, 아래의 두 함수 모두 정상적으로 작동한다.

fn five() -> u32 {
    return 5;
}

fn six() -> u32 {
    6
}

흐름 제어

if문

여타 다른 언어와 동일
다만 표현식을 이용해 다음과 같은 변수 초기화가 가능

let x = if condition {
    5
} else {
    6
};

대신 표현식이 return하는 타입이 서로 같아야 한다.

반복문

러스트는 loop, while, for 이 세 가지 종류의 반복문을 제공한다.

loop은 기본적으로 조건없이 무한 반복을 실행하는데
break와 함께 return할 표현식을 작성하는 방법으로 중단할 수 있다.

fn main() {
    let mut counter = 0;
    
    let rsult = loop {
        counter += 1;
        
        if counter == 10 {
            break counter * 2; // 20
        }
    };
}

또한 for..in 구문도 작성 가능함
이터레이션 많이 사용한다고 하니 그거 이용하자.


소유권

자바스크립트 같은 경우는 가비지 콜렉터를 통해 메모리 관리를 하는데
러스트는 소유권이라는 개념을 컴퓨터 메모리를 관리한다.

소유권 규칙

  • 러스트가 다루는 각각의 값은 소유자라고 부르는 변수를 갖고 있다.
  • 특정 시점에 값의 소유자는 단 하나뿐이다.
  • 소유자가 범위를 벗어나면 그 값은 제거된다.

문자열 리터럴 vs String 타입

let s = "hello"; // 문자열 리터럴
let t = String::from("hello"); // String 타입

문자열 리터럴은 변경이 불가하지만, String 타입은 내용을 바꿀 수 있다.
그 이유는 바로, String 타입은 힙 메모리를 이용하기 때문이다.

하지만 가비지 컬렉터가 없다면, 사용하지 않는 메모리를 개발자가 직접 처리해야하는데,
이를 제대로 수행하는 것이 예전부터 쉽지 않았다. (너무 빨리 제거, 혹은 중복 해제 등등)

반면 러스트는 다른 방법으로 메모리 할당과 해제를 수행하는데 바로..

drop

변수가 스코프를 벗어나는 순간, 할당된 메모리는 자동으로 해제된다.

{
    let s = String::from("hello") // s는 지금부터 유효
} // 여기서 범위를 벗어나게 되는 순간, s는 이제 유효하지 않다.

Move, Clone, Copy

Copy
let x = 5;
let y = x;

정수와 같은 고정 크기의 단순한 값은 복사하더라도, 동일한 값 2개가 스택에 저장된다.
당연히 x와 y 둘다 접근이 가능하고, 값도 동일하게 복사된다.

이렇게 작동되는 이유는 Copy 트레이트가 적용되어 있어서인데, 이는 나중에 더 알아보자.

Move

반대로 String 타입과 같은 가변 메모리는 값을 가리키는 포인터 값이 복사되어 스택에 저장된다.
즉, 데이터 값은 동일한데, 이 데이터를 가리키는 포인터가 2개 생기는 것이다.

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

단순하게 코드만 봤을 땐, 그냥 자바스크립트 객체 복사와 비슷한 것처럼 보인다.
하지만 러스트는 위와 같이 작성하면 복사하지 않고, s1과 같은 첫 번째 변수를 무효화시킨다.
따라서 s1에 접근할 수 없고, s1에서 s2move했다고 표현한다.

Clone

만약 스택 데이터가 아닌 힙 메모리에 저장된 데이터가 복사되길 원한다면
clone이라는 공통 메서드를 사용하면 된다.
이러면 동일한 데이터가 힙메모리에 2개 생기고, 각각의 데이터를 가리키는 포인터가 복사되어 스택에 쌓인다.

참조, 가변 참조

rust에선 함수에 변수를 넣어서 호출하면, 그 함수에게 변수의 소유권이 이전된다.
만약에 그 함수가 다시 변수를 return하지 않고 끝내면, 그 변수는 drop되게 되어 할당이 해제된다.

그래서 매번 함수에서 사용한 변수의 소유권을 다시 이전하기가 귀찮기 때문에 참조라는 개념이 생겼다.
다만 참조는 데이터를 변경할 수가 없기에 가변 참조를 이용해서 데이터를 변경시키는 방법을 써야한다.

참조의 규칙
  • 어느 한 시점에 하나의 가변 참조, 또는 여러 개의 불변 참조를 생성할 수 있지만, 둘 다 생성할 순 없음
  • 참조는 항상 유효해야 함

구조체

마치 자바스크립트 객체와 비슷하다.
다만, 구조체의 몇몇 필드만을 가변 데이터로 표시할 수 없고,
구조체 인스턴스 자체가 가변이거나 불가변이거나

근데 진짜 생성, 초기화, 호출은 자바스크립트의 객체랑 비슷한듯

derive 에노테이션

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

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

    println!("사각형의 면적 : {} 제곱 픽셀", rect1.width)); // 30
    println!("rect1: {}", rect1); // error
}

위에서 rect1.width과 같이 단일 값은 프린트할 때 형식을 지정할 필요가 없지만
rect1와 같이 구조체들은 프린트할 때 형식을 지정해줘야 한다.

물론 자바스크립트는 console.log하면 알아서 해줬지만, 러스트는 그걸 지정해줘야 됨
그 중 하나가 바로 #[derive(Debug)]

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

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

    println!("사각형의 면적 : {} 제곱 픽셀", rect1.width)); // 30
    println!("{:?}", rect1); // Rectangle { width: 30, height: 50 }
}

이렇게 하면 console.log와 같이 구조체의 모든 필드값을 확인 가능해진다.

메소드

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

impl Rectangle {
	fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
	let rect1 = Rectangle { width: 30, height: 50 };
    let rect2 = Rectangle { width: 10, height: 40 };
    
    println!("rect1는 rect2를 포함하는가? {}", rect1.can_hold(&rect2));
}

다음과 같이 메소드를 직접 정의할 수 있다.

&self는 자기 자신을 참조한다는 의미인데, 그렇기에 타입을 적을 필요가 없다.
다른 인자를 받고 싶을 때는, other: &Rectangle과 같이 적어주면 된다.

연관함수

String::from과 같이 self 매개변수를 사용하지 않는 함수를 연관 함수라고 한다.
생성자 함수 같은 애들이 연관 함수의 예이다 (ex String::from("Hello"))

연관 함수 호출하려면 :: 문법 사용하면 됨

열거자

열거자란 형식의 개념은 비슷하나 서로 다른 구조를 가지고 있는 타입을 생성할 때 사용한다.

예를 들면 IP 주소가 있다.
현재 IP 주소는 v4, v6이 있는데, 서로 개념은 비슷하나 데이터 구조는 서로 다르다.
구조체로는 이를 구현하기 어렵지만, 열거자를 사용하면 구현 가능하다.

먼저 구조체로 이를 구현하면

enum IpAddrKind {
    V4,
    V6,
}

struct IpAddr {
    kind: IpAddrKind,
    address: String,
}

let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
}

구조체를 이용해서 enum 타입인 kind와 String 타입인 address를 묶었다
하지만, 구조체를 사용하지 않고도 열거자만 이용해도, 데이터를 열것값에 직접 넣을 수 있다.

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(String::from("127.0.01"));

let loopback = IpAddr::V6(String::from("::1"));

위에서 구조체로 묶었던 것을, enum안에 직접 표현해준 모습을 볼 수 있다.
또한 열것값마다 데이터 구조를 다양한 방법으로 정의할 수 있다는 것도 장점이다.

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

보다시피 String, u8 뿐만 아니라 구조체, 심지어 열거자를 저장해도 무방하다.
물론 Quit처럼 연관 데이터를 전혀 갖지 않아도 된다.
중요한 점은 Quit, Move, Write, ChangeColor 모두 Message 타입에 속한다는 것이다.

또한, 열거자에 메소드를 정의할 수 있다.

impl Message {
    fn call(&self) {}
}55

let m = Message::Write(String::from("hello"));

m.call();

이런 식으로

Option과 Null

통상적으로 프로그래밍 언어에서 비어있는 변수는 0, ""가 아닌 null로 표현할 때가 많다.
(자바스크립트에서는 undefined이나 NaN도 있고)

근데 이런 null을 null이 아닌 값처럼 사용하려고 하면 당연히 에러가 나는데,
이런 에러가 너무나도 많이, 그리고 쉽게 발생한다는 것이 문제 중 하나이다.

enum Option<T> {
   Some(T),
   None,
}

러스트의 표준 라이브러리가 제공하는 열거자이다.

let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

이 코드를 실행하면 에러가 난다.
i8Option<i8>은 타입이 다르기 때문이다.

이렇게 구분해두면, null값이 필요한 상황에서는 Option 열거자를 사용하고,
null 값이 필요없을 때는 기본 자료형을 써주면 된다.

match

if~else 문과 비슷하지만,
조건문에 들어가는 변수의 값을 활용할 수 있다는 것이 차이점으로 보인다.

enum UsState {
    Alabama, Alaska, ....
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {:?}!", state);
            25
        },
    }
}

fn main() {
    value_in_cents(
        Coin::Quater(UsState::Alaska)
    ); // State quarter from Alaska!

Quarter 열것값을 이용하는 match 문을 보면 UsState 값을 이용하는 중


패키지와 크레이트

러스트의 모듈 시스템은 다음과 같은 개념이 포함됨

  • 패키지 : 크레이트를 빌드, 테스트, 공유 가능한 기능
  • 크레이트 : 라이브러리나 실행 파일을 생성하는 모듈의 트리
  • 모듈과 use : 코드의 구조와 범위, 경로의 접근성을 제어하는 기능
  • 경로 : 구조체, 함수, 모듈의 이름을 결정하는 방식

조금 더 자세하게 보자면, 크레이트가 라이브러리임
패키지는 이 크레이트를 빌드하거나 테스트하거나 해주는데, 이때 Cargo.toml 파일을 이용함

원래는 모듈 만들어서 상대경로나 절대경로로 하나하나 불러와야되는데
use 키워드로 경로를 범위로 가져올 수 있다. 마치 import 구문처럼
as 붙여서 이름 바꿔 불러올 수도 있음

모듈은 기본적으로 비공개로 만들어짐.
그래서 외부에서 사용할 수 있게 하려면 pub 키워드 붙여야 함 (함수든, 모듈이든)

{}나 * 쓰는 건 JS랑 똑같은 듯
그냥 하면서 배우면 될 것 같다.


컬렉션

컬렉션이란, 하나의 특정한 값이 아닌 여러개의 값을 포함하는 데이터 타입이다.

가장 많이 활용되는 세가지 컬렉션은 다음과 같다.

  • 벡터
  • 문자열
  • 해시 맵

벡터

쉽게 말해서 동적 배열이다.

벡터 생성은 let v = vec![1, 2, 3]와 같이 하며 된다
백터로부터 값을 읽는게 조금 복잡한데

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

let third: &i32 = &v[2]; // &와 []로 읽는 방법

// get 메서드를 이용해 읽는 방법, 이때 Option<T> 타입을 리턴한다.
match v.get(2) {
    Some(third) => println(third),
    None => println!("None")
}

또한 벡터에 저장된 값에 모두 하나씩 접근하려면, iterate하는 것이 좋다.

let mut v = vec![1, 2, 3, 4, 5];
for i in &mut v {
    *i += 50;
}

문자열

러스트에서 문자열 타입은 String과 &str가 있다.
둘다 표준 라이브러리이며 모두 UTF-8 형식으로 인코딩 되어 있음

문자열 생성은 이미 다뤘었다.

let mut s = String::new();
let s = String::from("문자열");

문자열 합치기는 +format!을 사용하면 됨

하지만 index로 문자열을 자르거나 조회하는 것은 불가능하다. (생각보다 문자열 구조가 복잡함)
그래서 표준 라이브러리에서 제공하는 메서드를 잘 찾아보고 사용하자.

해시 맵

JavaScript의 객체 역할을 하는 친구이다.

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("블루"), 10);
socres.insert(String::from("그린"), 50);

근데 생각보다 많이 안 쓰인다고 함... 그래서 라이브러리도 아직 부족한 상태


제네릭

제네릭이란, 역할과 동작은 똑같은데 인자의 타입이 다른 함수들 간의 코드 중복을 줄여주는 역할을 한다.

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];
    
    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];
    
    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

보면 둘 다 가장 큰 값을 찾는 함수인데, 타입만 다르지 함수 안의 코드는 동일함
이럴 때 fn largest<T>(list: &[T]) -> T로 함수 선언을 바꿔주면,
list에 처음으로 들어가는 변수의 타입이 무엇이냐에 따라, T의 타입이 정해지게 됨

fn largest<T>(list: &[T]) -> T {
    ...
}

let number_list_1 = vec![34, 50, 25, 100, 65];
let number_list_2 = vec!["1", "2", "3"];
let result_1 = largest(&number_list_1);
let result_2 = largest(&number_list_2);

1과 같이 하면 T는 i32로 설정되고, 2와 같이 하면 T는 char로 설정되게 된다.


제네릭을 두 개 이상 쓰는 것도 가능하다

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let point = Point { x: 1, y: 2.0 }
}

이렇게 하면 T는 i32, U는 f64로 설정된다.


트레이트

다른 언어에서 인터페이스와 유사한 기능을 함

pub trait Summary {
    fn summarize(&self) -> String;
}

이제 Summary 트레이트를 따르는 타입은
반드시 메서드 본문에 summarize를 구현해야한다.

0개의 댓글